mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-08-23 10:52:44 +02:00
Merge release 1.3.0
This commit is contained in:
@@ -47,7 +47,7 @@ jobs:
|
||||
-v "$(pwd)":/app \
|
||||
-w /app/backend \
|
||||
-e CGO_ENABLED=1 \
|
||||
golang:1.24 \
|
||||
golang:1.25.1 \
|
||||
go build -trimpath \
|
||||
-ldflags='-X github.com/phishingclub/phishingclub/version.hash=ph${{ steps.get_version.outputs.HASH }} -X github.com/phishingclub/phishingclub/version.version=${{ steps.get_version.outputs.VERSION }}' \
|
||||
-tags production -o ../build/phishingclub main.go
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
-v "$(pwd)":/app \
|
||||
-w /app/backend \
|
||||
-e CGO_ENABLED=1 \
|
||||
golang:1.24 \
|
||||
golang:1.25.1 \
|
||||
go build -trimpath \
|
||||
-ldflags='-X github.com/phishingclub/phishingclub/version.hash=ph${{ steps.get_version.outputs.HASH }} -X github.com/phishingclub/phishingclub/version.version=${{ steps.get_version.outputs.VERSION }}' \
|
||||
-tags production -o ../build/phishingclub main.go
|
||||
|
||||
+16
@@ -1,5 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
## [1.3.0] - 2025-09-19
|
||||
- Added dark mode support and various UI improvements
|
||||
- Added manual backup functionality
|
||||
- Added reported functionality for phishing campaigns
|
||||
- Added recipient manual send action
|
||||
- Added validation on save
|
||||
- Added link to release information on update modal and page
|
||||
- Fixed copy campaign wrong text on create
|
||||
- Fixed HTML to text template handling
|
||||
- Fixed bad title on settings page
|
||||
- Fixed dashboard scroll to top issue
|
||||
- Improved send again texts
|
||||
- Improved modal error position
|
||||
- Moved recent campaigns to bottom of dashboard
|
||||
- Bumped Go version and dependencies
|
||||
|
||||
## [1.2.1] - 2025-09-15
|
||||
- Add debug logging to SMTP
|
||||
- Fix excessive table URL params
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# development docker file
|
||||
FROM golang:1.24.5
|
||||
FROM golang:1.25.1
|
||||
|
||||
EXPOSE 8000 8001
|
||||
|
||||
|
||||
@@ -36,6 +36,10 @@ const (
|
||||
ROUTE_V1_UPDATE_AVAILABLE = "/api/v1/update/available"
|
||||
ROUTE_V1_UPDATE_AVAILABLE_CACHED = "/api/v1/update/available/cached"
|
||||
ROUTE_V1_UPDATE = "/api/v1/update"
|
||||
// backup
|
||||
ROUTE_V1_BACKUP_CREATE = "/api/v1/backup/create"
|
||||
ROUTE_V1_BACKUP_LIST = "/api/v1/backup/list"
|
||||
ROUTE_V1_BACKUP_DOWNLOAD = "/api/v1/backup/download/:filename"
|
||||
// user
|
||||
ROUTE_V1_USER = "/api/v1/user"
|
||||
ROUTE_V1_USER_ID = "/api/v1/user/:id"
|
||||
@@ -132,10 +136,12 @@ const (
|
||||
ROUTE_V1_CAMPAIGN_STATS = "/api/v1/campaign/statistics"
|
||||
ROUTE_V1_CAMPAIGN_STATS_ID = "/api/v1/campaign/:id/stats"
|
||||
ROUTE_V1_CAMPAIGN_STATS_ALL = "/api/v1/campaign/stats/all"
|
||||
ROUTE_V1_CAMPAIGN_UPLOAD_REPORTED = "/api/v1/campaign/:id/upload/reported"
|
||||
// campaign-recipient
|
||||
ROUTE_V1_CAMPAIGN_RECIPIENT_EMAIL = "/api/v1/campaign/recipient/:id/email"
|
||||
ROUTE_V1_CAMPAIGN_RECIPIENT_URL = "/api/v1/campaign/recipient/:id/url"
|
||||
ROUTE_V1_CAMPAIGN_RECIPIENT_SET_SENT = "/api/v1/campaign/recipient/:id/sent"
|
||||
ROUTE_V1_CAMPAIGN_RECIPIENT_EMAIL = "/api/v1/campaign/recipient/:id/email"
|
||||
ROUTE_V1_CAMPAIGN_RECIPIENT_URL = "/api/v1/campaign/recipient/:id/url"
|
||||
ROUTE_V1_CAMPAIGN_RECIPIENT_SET_SENT = "/api/v1/campaign/recipient/:id/sent"
|
||||
ROUTE_V1_CAMPAIGN_RECIPIENT_SEND_EMAIL = "/api/v1/campaign/recipient/:id/send"
|
||||
// asset
|
||||
ROUTE_V1_ASSET = "/api/v1/asset"
|
||||
ROUTE_V1_ASSET_ID = "/api/v1/asset/:id"
|
||||
@@ -364,6 +370,7 @@ func setupRoutes(
|
||||
POST(ROUTE_V1_CAMPAIGN_CLOSE, middleware.SessionHandler, controllers.Campaign.CloseCampaignByID).
|
||||
GET(ROUTE_V1_CAMPAIGN_EXPORT_EVENTS, middleware.SessionHandler, controllers.Campaign.ExportEventsAsCSV).
|
||||
GET(ROUTE_V1_CAMPAIGN_EXPORT_SUBMISSIONS, middleware.SessionHandler, controllers.Campaign.ExportSubmissionsAsCSV).
|
||||
POST(ROUTE_V1_CAMPAIGN_UPLOAD_REPORTED, middleware.SessionHandler, controllers.Campaign.UploadReportedCSV).
|
||||
POST(ROUTE_V1_CAMPAIGN_ANONYMIZE, middleware.SessionHandler, controllers.Campaign.AnonymizeByID).
|
||||
DELETE(ROUTE_V1_CAMPAIGN_ID, middleware.SessionHandler, controllers.Campaign.DeleteByID).
|
||||
// campaign-recipient
|
||||
@@ -371,6 +378,7 @@ func setupRoutes(
|
||||
GET(ROUTE_V1_CAMPAIGN_RECIPIENT_EMAIL, middleware.SessionHandler, controllers.Campaign.GetCampaignEmail).
|
||||
GET(ROUTE_V1_CAMPAIGN_RECIPIENT_URL, middleware.SessionHandler, controllers.Campaign.GetCampaignURL).
|
||||
POST(ROUTE_V1_CAMPAIGN_RECIPIENT_SET_SENT, middleware.SessionHandler, controllers.Campaign.SetSentAtByCampaignRecipientID).
|
||||
POST(ROUTE_V1_CAMPAIGN_RECIPIENT_SEND_EMAIL, middleware.SessionHandler, controllers.Campaign.SendEmailByCampaignRecipientID).
|
||||
// asset
|
||||
GET(ROUTE_V1_ASSET_DOMAIN_VIEW, middleware.SessionHandler, controllers.Asset.GetContentByID).
|
||||
GET(ROUTE_V1_ASSET_ID, middleware.SessionHandler, controllers.Asset.GetByID).
|
||||
@@ -415,6 +423,10 @@ func setupRoutes(
|
||||
// update
|
||||
GET(ROUTE_V1_UPDATE, middleware.SessionHandler, controllers.Update.GetUpdateDetails).
|
||||
POST(ROUTE_V1_UPDATE, middleware.SessionHandler, controllers.Update.RunUpdate).
|
||||
// backup
|
||||
POST(ROUTE_V1_BACKUP_CREATE, middleware.SessionHandler, controllers.Backup.CreateBackup).
|
||||
GET(ROUTE_V1_BACKUP_LIST, middleware.SessionHandler, controllers.Backup.ListBackups).
|
||||
GET(ROUTE_V1_BACKUP_DOWNLOAD, middleware.SessionHandler, controllers.Backup.DownloadBackup).
|
||||
// import
|
||||
POST(ROUTE_V1_IMPORT, middleware.SessionHandler, controllers.Import.Import)
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ type Controllers struct {
|
||||
SSO *controller.SSO
|
||||
Update *controller.Update
|
||||
Import *controller.Import
|
||||
Backup *controller.Backup
|
||||
}
|
||||
|
||||
// NewControllers creates a collection of controllers
|
||||
@@ -168,6 +169,10 @@ func NewControllers(
|
||||
Common: common,
|
||||
ImportService: services.Import,
|
||||
}
|
||||
backup := &controller.Backup{
|
||||
Common: common,
|
||||
BackupService: services.Backup,
|
||||
}
|
||||
|
||||
return &Controllers{
|
||||
Asset: asset,
|
||||
@@ -196,5 +201,6 @@ func NewControllers(
|
||||
SSO: sso,
|
||||
Update: update,
|
||||
Import: importController,
|
||||
Backup: backup,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
textTmpl "text/template"
|
||||
"time"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
@@ -316,7 +316,7 @@ func (s *Server) Handler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
// TODO extract this into another method, maybe file
|
||||
t, err := template.
|
||||
t, err := textTmpl.
|
||||
New("staticContent").
|
||||
Funcs(service.TemplateFuncs()).
|
||||
Parse(string(domain.PageNotFoundContent))
|
||||
@@ -363,7 +363,7 @@ func (s *Server) Handler(c *gin.Context) {
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
t, err := template.
|
||||
t, err := textTmpl.
|
||||
New("staticContent").
|
||||
Funcs(service.TemplateFuncs()).
|
||||
Parse(domain.PageContent)
|
||||
@@ -421,7 +421,7 @@ func (s *Server) handlerNotFound(c *gin.Context) {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
t := template.New("staticContent")
|
||||
t := textTmpl.New("staticContent")
|
||||
t = t.Funcs(service.TemplateFuncs())
|
||||
tmpl, err := t.Parse(string(domain.PageNotFoundContent))
|
||||
if err != nil {
|
||||
@@ -919,7 +919,9 @@ func (s *Server) renderDenyPage(
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get landing page: %s", err)
|
||||
}
|
||||
tmpl, err := template.New("page").Parse(page.Content.MustGet().String())
|
||||
tmpl, err := textTmpl.New("page").
|
||||
Funcs(service.TemplateFuncs()).
|
||||
Parse(page.Content.MustGet().String())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse page template: %s", err)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ type Services struct {
|
||||
SSO *service.SSO
|
||||
Update *service.Update
|
||||
Import *service.Import
|
||||
Backup *service.Backup
|
||||
}
|
||||
|
||||
// NewServices creates a collection of services
|
||||
@@ -49,6 +50,7 @@ func NewServices(
|
||||
certMagicConfig *certmagic.Config,
|
||||
certMagicCache *certmagic.Cache,
|
||||
licenseServerURL string,
|
||||
filePath string,
|
||||
) *Services {
|
||||
common := service.Common{
|
||||
Logger: logger,
|
||||
@@ -138,6 +140,7 @@ func NewServices(
|
||||
CampaignRepository: repositories.Campaign,
|
||||
PageRepository: repositories.Page,
|
||||
CampaignTemplateService: campaignTemplate,
|
||||
TemplateService: templateService,
|
||||
}
|
||||
domain := &service.Domain{
|
||||
Common: common,
|
||||
@@ -149,6 +152,7 @@ func NewServices(
|
||||
CampaignTemplateService: campaignTemplate,
|
||||
AssetService: asset,
|
||||
FileService: file,
|
||||
TemplateService: templateService,
|
||||
}
|
||||
email := &service.Email{
|
||||
Common: common,
|
||||
@@ -210,6 +214,12 @@ func NewServices(
|
||||
SessionService: sessionService,
|
||||
// MSALClient: msalClient, this dependency is set AFTER this function
|
||||
}
|
||||
backupService := &service.Backup{
|
||||
Common: common,
|
||||
OptionService: optionService,
|
||||
DB: db,
|
||||
FilePath: filePath,
|
||||
}
|
||||
updateService := &service.Update{
|
||||
Common: common,
|
||||
OptionService: optionService,
|
||||
@@ -250,5 +260,6 @@ func NewServices(
|
||||
SSO: ssoService,
|
||||
Update: updateService,
|
||||
Import: importService,
|
||||
Backup: backupService,
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -37,6 +37,7 @@ func IsUpdateAvailable() bool {
|
||||
// readonly
|
||||
var CampaignEventPriority = map[string]int{
|
||||
// campaign recipient events
|
||||
data.EVENT_CAMPAIGN_RECIPIENT_REPORTED: 90,
|
||||
data.EVENT_CAMPAIGN_RECIPIENT_CANCELLED: 80,
|
||||
data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA: 70,
|
||||
data.EVENT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED: 60,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/phishingclub/phishingclub/service"
|
||||
)
|
||||
|
||||
type Backup struct {
|
||||
Common
|
||||
BackupService *service.Backup
|
||||
}
|
||||
|
||||
// CreateBackup starts a backup operation
|
||||
func (b *Backup) CreateBackup(g *gin.Context) {
|
||||
session, _, ok := b.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
err := b.BackupService.CreateBackup(g, session)
|
||||
if ok := b.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
b.Response.OK(g, gin.H{
|
||||
"message": "backup started",
|
||||
})
|
||||
}
|
||||
|
||||
// ListBackups returns a list of available backup files
|
||||
func (b *Backup) ListBackups(g *gin.Context) {
|
||||
session, _, ok := b.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
backups, err := b.BackupService.ListBackups(g, session)
|
||||
if ok := b.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
b.Response.OK(g, backups)
|
||||
}
|
||||
|
||||
// DownloadBackup serves a backup file for download
|
||||
func (b *Backup) DownloadBackup(g *gin.Context) {
|
||||
session, _, ok := b.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
filename := g.Param("filename")
|
||||
if filename == "" {
|
||||
g.JSON(http.StatusBadRequest, gin.H{"error": "filename is required"})
|
||||
return
|
||||
}
|
||||
|
||||
backupFile, err := b.BackupService.GetBackupFile(g, session, filename)
|
||||
if ok := b.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
defer backupFile.Close()
|
||||
|
||||
// set headers for file download
|
||||
g.Header("Content-Description", "File Transfer")
|
||||
g.Header("Content-Transfer-Encoding", "binary")
|
||||
g.Header("Content-Disposition", "attachment; filename="+filename)
|
||||
g.Header("Content-Type", "application/octet-stream")
|
||||
|
||||
// serve the file content directly from the secure file handle
|
||||
g.DataFromReader(http.StatusOK, -1, "application/octet-stream", backupFile, nil)
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package controller
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
@@ -852,6 +854,27 @@ func (c *Campaign) SetSentAtByCampaignRecipientID(g *gin.Context) {
|
||||
c.Response.OK(g, gin.H{})
|
||||
}
|
||||
|
||||
// SendEmailByCampaignRecipientID sends an email to a specific campaign recipient
|
||||
func (c *Campaign) SendEmailByCampaignRecipientID(g *gin.Context) {
|
||||
// handle session
|
||||
session, _, ok := c.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// parse request
|
||||
id, ok := c.handleParseIDParam(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// send message (email or API depending on campaign template configuration)
|
||||
err := c.CampaignService.SendEmailByCampaignRecipientID(g.Request.Context(), session, id)
|
||||
// handle responses
|
||||
if ok := c.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
c.Response.OK(g, gin.H{})
|
||||
}
|
||||
|
||||
// DeleteByID deletes a campaign by its id
|
||||
func (c *Campaign) DeleteByID(g *gin.Context) {
|
||||
// handle session
|
||||
@@ -935,3 +958,70 @@ func (c *Campaign) GetAllCampaignStats(g *gin.Context) {
|
||||
}
|
||||
c.Response.OK(g, stats)
|
||||
}
|
||||
|
||||
// UploadReportedCSV uploads a CSV file with reported recipients
|
||||
func (c *Campaign) UploadReportedCSV(g *gin.Context) {
|
||||
// handle session
|
||||
session, _, ok := c.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// parse campaign id
|
||||
id, ok := c.handleParseIDParam(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// get the uploaded file
|
||||
file, header, err := g.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.Response.ValidationFailed(g, "file", err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// validate file extension
|
||||
if !strings.HasSuffix(strings.ToLower(header.Filename), ".csv") {
|
||||
c.Response.ValidationFailed(g, "file", errors.New("file must be a CSV"))
|
||||
return
|
||||
}
|
||||
|
||||
// read file content
|
||||
content, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
c.Response.ValidationFailed(g, "file", err)
|
||||
return
|
||||
}
|
||||
|
||||
// parse CSV
|
||||
reader := csv.NewReader(strings.NewReader(string(content)))
|
||||
records, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to parse CSV file", "error", err)
|
||||
c.Response.ValidationFailed(g, "file", errors.New("failed to parse CSV file: "+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if len(records) < 2 {
|
||||
c.Logger.Debugw("CSV file has insufficient rows", "rows", len(records))
|
||||
c.Response.ValidationFailed(g, "file", errors.New("CSV file must have header and at least one data row"))
|
||||
return
|
||||
}
|
||||
|
||||
c.Logger.Debugw("processing CSV", "rows", len(records), "headers", records[0])
|
||||
|
||||
// process CSV
|
||||
processed, skipped, err := c.CampaignService.ProcessReportedCSV(g.Request.Context(), session, id, records)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to process reported CSV", "error", err)
|
||||
if ok := c.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.Response.OK(g, gin.H{
|
||||
"processed": processed,
|
||||
"skipped": skipped,
|
||||
"message": "CSV processed successfully",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ const (
|
||||
EVENT_CAMPAIGN_RECIPIENT_PAGE_VISITED = "campaign_recipient_page_visited"
|
||||
EVENT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED = "campaign_recipient_after_page_visited"
|
||||
EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA = "campaign_recipient_submitted_data"
|
||||
EVENT_CAMPAIGN_RECIPIENT_REPORTED = "campaign_recipient_reported"
|
||||
EVENT_CAMPAIGN_RECIPIENT_CANCELLED = "campaign_recipient_cancelled"
|
||||
)
|
||||
|
||||
@@ -32,5 +33,6 @@ var Events = []string{
|
||||
EVENT_CAMPAIGN_RECIPIENT_PAGE_VISITED,
|
||||
EVENT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED,
|
||||
EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA,
|
||||
EVENT_CAMPAIGN_RECIPIENT_REPORTED,
|
||||
EVENT_CAMPAIGN_RECIPIENT_CANCELLED,
|
||||
}
|
||||
|
||||
@@ -35,11 +35,13 @@ type CampaignStats struct {
|
||||
TrackingPixelLoaded int `gorm:"not null;default:0" json:"trackingPixelLoaded"` // Email opens
|
||||
WebsiteVisits int `gorm:"not null;default:0" json:"websiteVisits"` // Link clicks
|
||||
DataSubmissions int `gorm:"not null;default:0" json:"dataSubmissions"` // Form submissions
|
||||
Reported int `gorm:"not null;default:0" json:"reported"` // Reported phishing
|
||||
|
||||
// Success rates (as percentages for quick display)
|
||||
OpenRate float64 `gorm:"not null;default:0" json:"openRate"`
|
||||
ClickRate float64 `gorm:"not null;default:0" json:"clickRate"`
|
||||
SubmissionRate float64 `gorm:"not null;default:0" json:"submissionRate"`
|
||||
ReportRate float64 `gorm:"not null;default:0" json:"reportRate"`
|
||||
|
||||
// Campaign metadata
|
||||
TemplateName string `gorm:"" json:"templateName"`
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
module github.com/phishingclub/phishingclub
|
||||
|
||||
go 1.23.6
|
||||
go 1.25.1
|
||||
|
||||
require (
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.3.2
|
||||
|
||||
@@ -5,11 +5,11 @@ import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
||||
@@ -225,6 +225,7 @@ func main() {
|
||||
certMagicConfig,
|
||||
certMagicCache,
|
||||
licenseServer,
|
||||
*flagFilePath,
|
||||
)
|
||||
// get entra-id options and setup msal client
|
||||
ssoOpt, err := services.SSO.GetSSOOptionWithoutAuth(context.Background())
|
||||
|
||||
@@ -6,4 +6,5 @@ type CampaignResultView struct {
|
||||
TrackingPixelLoaded int64 `json:"trackingPixelLoaded"`
|
||||
WebsiteLoaded int64 `json:"clickedLink"`
|
||||
SubmittedData int64 `json:"submittedData"`
|
||||
Reported int64 `json:"reported"`
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ type RecipientCampaignStatsView struct {
|
||||
CampaignsTrackingPixelLoaded int64 `json:"campaignsTrackingPixelLoaded"`
|
||||
CampaignsPhishingPageLoaded int64 `json:"campaignsPhishingPageLoaded"`
|
||||
CampaignsDataSubmitted int64 `json:"campaignsDataSubmitted"`
|
||||
CampaignsReported int64 `json:"campaignsReported"`
|
||||
RepeatLinkClicks int64 `json:"repeatLinkClicks"`
|
||||
RepeatSubmissions int64 `json:"repeatSubmissions"`
|
||||
}
|
||||
|
||||
@@ -794,6 +794,32 @@ func (r *Campaign) GetResultStats(
|
||||
return nil, res.Error
|
||||
}
|
||||
|
||||
// Get unique reported
|
||||
res = r.DB.Raw(`
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT DISTINCT recipient_id
|
||||
FROM campaign_events
|
||||
WHERE campaign_id = ?
|
||||
AND event_id = ?
|
||||
AND recipient_id IS NOT NULL
|
||||
UNION
|
||||
SELECT DISTINCT anonymized_id
|
||||
FROM campaign_events
|
||||
WHERE campaign_id = ?
|
||||
AND event_id = ?
|
||||
AND anonymized_id IS NOT NULL
|
||||
) as unique_ids
|
||||
`,
|
||||
campaignID,
|
||||
cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_REPORTED],
|
||||
campaignID,
|
||||
cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_REPORTED],
|
||||
).Scan(&stats.Reported)
|
||||
|
||||
if res.Error != nil {
|
||||
return nil, res.Error
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -419,6 +419,18 @@ func (r *Recipient) GetStatsByID(
|
||||
Distinct("campaign_events.campaign_id").
|
||||
Count(&stats.CampaignsDataSubmitted)
|
||||
|
||||
// get unique reported campaigns
|
||||
r.DB.Model(&database.CampaignEvent{}).
|
||||
Joins("JOIN campaigns ON campaigns.id = campaign_events.campaign_id").
|
||||
Where(
|
||||
"campaign_events.recipient_id = ? AND campaign_events.event_id = ? AND campaigns.is_test = ?",
|
||||
id,
|
||||
cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_REPORTED],
|
||||
false,
|
||||
).
|
||||
Distinct("campaign_events.campaign_id").
|
||||
Count(&stats.CampaignsReported)
|
||||
|
||||
// Get repeat link clicks in last selected threshold months
|
||||
var linkClickCount int64
|
||||
r.DB.Model(&database.CampaignEvent{}).
|
||||
|
||||
@@ -5,10 +5,10 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
@@ -651,6 +651,7 @@ func (a *APISender) buildHeader(
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse header value: %s", err)
|
||||
}
|
||||
valueTemplate = valueTemplate.Funcs(TemplateFuncs())
|
||||
var value bytes.Buffer
|
||||
if err := valueTemplate.Execute(&value, nil); err != nil {
|
||||
return nil, fmt.Errorf("failed to execute value template: %s", err)
|
||||
@@ -767,9 +768,7 @@ func (a *APISender) buildRequest(
|
||||
}
|
||||
// Remove the newline that Encode adds and the surrounding quotes
|
||||
jsonStr := strings.TrimSpace(buf.String())
|
||||
|
||||
// Mark as safe HTML so template won't escape it
|
||||
(*t)["Content"] = template.HTML(jsonStr[1 : len(jsonStr)-1])
|
||||
(*t)["Content"] = jsonStr[1 : len(jsonStr)-1]
|
||||
contentTemplate := template.New("content")
|
||||
contentTemplate = contentTemplate.Funcs(TemplateFuncs())
|
||||
contentTemplate, err = contentTemplate.Parse(apiSender.RequestBody.MustGet().String())
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/phishingclub/phishingclub/data"
|
||||
"github.com/phishingclub/phishingclub/errs"
|
||||
"github.com/phishingclub/phishingclub/model"
|
||||
"github.com/phishingclub/phishingclub/validate"
|
||||
)
|
||||
|
||||
// BackupFile represents a backup file available for download
|
||||
type BackupFile struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
RelativePath string `json:"relativePath"`
|
||||
}
|
||||
|
||||
type Backup struct {
|
||||
Common
|
||||
OptionService *Option
|
||||
DB *gorm.DB
|
||||
FilePath string // base file path for application data
|
||||
}
|
||||
|
||||
// BackupStatus represents the status of a backup operation
|
||||
type BackupStatus struct {
|
||||
IsRunning bool `json:"isRunning"`
|
||||
IsComplete bool `json:"isComplete"`
|
||||
HasError bool `json:"hasError"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
StartTime time.Time `json:"startTime"`
|
||||
EndTime time.Time `json:"endTime"`
|
||||
BackupPath string `json:"backupPath"`
|
||||
Progress string `json:"progress"`
|
||||
}
|
||||
|
||||
// BackupResult represents the result of a backup operation
|
||||
type BackupResult struct {
|
||||
BackupPath string `json:"backupPath"`
|
||||
DatabaseSize int64 `json:"databaseSize"`
|
||||
FilesSize int64 `json:"filesSize"`
|
||||
TotalSize int64 `json:"totalSize"`
|
||||
Duration time.Duration `json:"duration"`
|
||||
}
|
||||
|
||||
var (
|
||||
currentBackupStatus *BackupStatus
|
||||
)
|
||||
|
||||
// CreateBackup creates a backup of the database and files
|
||||
func (b *Backup) CreateBackup(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
) error {
|
||||
ae := NewAuditEvent("Backup.CreateBackup", session)
|
||||
|
||||
// check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil {
|
||||
b.LogAuthError(err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
b.AuditLogNotAuthorized(ae)
|
||||
return errors.New("unauthorized")
|
||||
}
|
||||
|
||||
// check if backup is already running
|
||||
if currentBackupStatus != nil && currentBackupStatus.IsRunning {
|
||||
return errors.New("backup already in progress")
|
||||
}
|
||||
|
||||
// initialize backup status
|
||||
currentBackupStatus = &BackupStatus{
|
||||
IsRunning: true,
|
||||
IsComplete: false,
|
||||
HasError: false,
|
||||
StartTime: time.Now(),
|
||||
Progress: "starting backup",
|
||||
}
|
||||
|
||||
// run backup synchronously to lock interface
|
||||
err = b.performBackup(ctx)
|
||||
currentBackupStatus.IsRunning = false
|
||||
currentBackupStatus.EndTime = time.Now()
|
||||
|
||||
if err != nil {
|
||||
currentBackupStatus.HasError = true
|
||||
currentBackupStatus.ErrorMessage = err.Error()
|
||||
b.Logger.Errorw("backup failed", "error", err)
|
||||
b.AuditLogAuthorized(ae)
|
||||
return errs.Wrap(err)
|
||||
} else {
|
||||
currentBackupStatus.IsComplete = true
|
||||
currentBackupStatus.Progress = "backup completed"
|
||||
b.Logger.Infow("backup completed successfully", "path", currentBackupStatus.BackupPath)
|
||||
|
||||
// automatically cleanup old backups to maintain maximum of 3
|
||||
currentBackupStatus.Progress = "cleaning up old backups"
|
||||
cleanupErr := b.CleanupOldBackups(ctx, session, 3)
|
||||
if cleanupErr != nil {
|
||||
b.Logger.Warnw("failed to cleanup old backups", "error", cleanupErr)
|
||||
// don't fail the backup operation if cleanup fails
|
||||
} else {
|
||||
b.Logger.Debugw("cleaned up old backups, keeping latest 3")
|
||||
}
|
||||
ae.Details["backupPath"] = currentBackupStatus.BackupPath
|
||||
}
|
||||
|
||||
if currentBackupStatus.HasError {
|
||||
ae.Details["error"] = currentBackupStatus.ErrorMessage
|
||||
b.AuditLogAuthorized(ae)
|
||||
return errs.Wrap(errors.New(currentBackupStatus.ErrorMessage))
|
||||
}
|
||||
|
||||
b.AuditLogAuthorized(ae)
|
||||
return nil
|
||||
}
|
||||
|
||||
// performBackup performs the actual backup operation
|
||||
func (b *Backup) performBackup(ctx context.Context) error {
|
||||
timestamp := time.Now().Format("20060102-150405")
|
||||
filesPath := strings.TrimSuffix(b.FilePath, "/")
|
||||
backupDir := filepath.Join(filesPath, "backups", fmt.Sprintf("backup-%s", timestamp))
|
||||
|
||||
// create backup directory
|
||||
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
currentBackupStatus.Progress = "backing up database"
|
||||
b.Logger.Debugw("starting database backup")
|
||||
|
||||
// backup database directly to backup root
|
||||
if err := b.backupDatabase(ctx, backupDir); err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
currentBackupStatus.Progress = "backing up files"
|
||||
b.Logger.Debugw("starting files backup")
|
||||
|
||||
// backup files directly to backup root (preserving directory structure)
|
||||
if err := b.backupFiles(backupDir); err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
currentBackupStatus.Progress = "compressing backup"
|
||||
b.Logger.Debugw("compressing backup")
|
||||
|
||||
// compress backup
|
||||
backupArchive := backupDir + ".tar.gz"
|
||||
if err := b.compressBackup(backupDir, backupArchive); err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// remove uncompressed backup directory
|
||||
if err := os.RemoveAll(backupDir); err != nil {
|
||||
b.Logger.Warnw("failed to remove uncompressed backup directory", "error", err)
|
||||
}
|
||||
|
||||
currentBackupStatus.BackupPath = backupArchive
|
||||
return nil
|
||||
}
|
||||
|
||||
// backupDatabase creates a backup of the sqlite database
|
||||
func (b *Backup) backupDatabase(ctx context.Context, backupPath string) error {
|
||||
// get the underlying sql.DB
|
||||
sqlDB, err := b.DB.DB()
|
||||
if err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// execute wal checkpoint to ensure all data is written to main db file
|
||||
_, err = sqlDB.ExecContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
if err != nil {
|
||||
b.Logger.Warnw("failed to checkpoint wal", "error", err)
|
||||
// continue anyway as this is not critical
|
||||
}
|
||||
|
||||
// extract database path from DSN
|
||||
dbPath := b.extractDatabasePath()
|
||||
|
||||
// copy main database file
|
||||
if err := b.copyFile(dbPath, filepath.Join(backupPath, "db.sqlite3")); err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// copy wal file if it exists
|
||||
walPath := dbPath + "-wal"
|
||||
if _, err := os.Stat(walPath); err == nil {
|
||||
if err := b.copyFile(walPath, filepath.Join(backupPath, "db.sqlite3-wal")); err != nil {
|
||||
b.Logger.Debugw("failed to copy wal file", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// copy shm file if it exists
|
||||
shmPath := dbPath + "-shm"
|
||||
if _, err := os.Stat(shmPath); err == nil {
|
||||
if err := b.copyFile(shmPath, filepath.Join(backupPath, "db.sqlite3-shm")); err != nil {
|
||||
b.Logger.Debugw("failed to copy shm file", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// backupFiles creates a backup of application files
|
||||
func (b *Backup) backupFiles(backupPath string) error {
|
||||
// files are stored in the path specified by --files flag
|
||||
// remove trailing slash if present for consistent path joining
|
||||
filesPath := strings.TrimSuffix(b.FilePath, "/")
|
||||
|
||||
filesToBackup := []string{"assets", "attachments", "certs"}
|
||||
|
||||
for _, dir := range filesToBackup {
|
||||
srcPath := filepath.Join(filesPath, dir)
|
||||
dstPath := filepath.Join(backupPath, dir)
|
||||
|
||||
// check if source directory exists
|
||||
if _, err := os.Stat(srcPath); os.IsNotExist(err) {
|
||||
b.Logger.Debugw("directory does not exist, skipping", "path", srcPath)
|
||||
continue
|
||||
}
|
||||
|
||||
// copy directory
|
||||
if err := b.copyDir(srcPath, dstPath); err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractDatabasePath extracts the database file path from the GORM DSN
|
||||
func (b *Backup) extractDatabasePath() string {
|
||||
// get the underlying sql.DB to access the data source name
|
||||
sqlDB, err := b.DB.DB()
|
||||
if err != nil {
|
||||
b.Logger.Debugw("failed to get sql.DB, using default path", "error", err)
|
||||
return "./db.sqlite3"
|
||||
}
|
||||
|
||||
// try to get database list to find the actual file path
|
||||
rows, err := sqlDB.Query("PRAGMA database_list")
|
||||
if err != nil {
|
||||
b.Logger.Debugw("failed to query database list, using default path", "error", err)
|
||||
return "./db.sqlite3"
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var seq int
|
||||
var name, file string
|
||||
err := rows.Scan(&seq, &name, &file)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// main database has seq=0 and name="main"
|
||||
if seq == 0 && name == "main" && file != "" {
|
||||
b.Logger.Debugw("found database path from PRAGMA database_list", "path", file)
|
||||
return file
|
||||
}
|
||||
}
|
||||
|
||||
// fallback to default
|
||||
b.Logger.Debugw("could not determine database path from PRAGMA, using default")
|
||||
return "./db.sqlite3"
|
||||
}
|
||||
|
||||
// compressBackup compresses the backup directory into a tar.gz file
|
||||
func (b *Backup) compressBackup(srcDir, dstFile string) error {
|
||||
file, err := os.Create(dstFile)
|
||||
if err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
gzipWriter := gzip.NewWriter(file)
|
||||
defer gzipWriter.Close()
|
||||
|
||||
tarWriter := tar.NewWriter(gzipWriter)
|
||||
defer tarWriter.Close()
|
||||
|
||||
return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// get relative path
|
||||
relPath, err := filepath.Rel(srcDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// create tar header
|
||||
header, err := tar.FileInfoHeader(info, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header.Name = relPath
|
||||
|
||||
// write header
|
||||
if err := tarWriter.WriteHeader(header); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// write file content if it's a regular file
|
||||
if info.Mode().IsRegular() {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = io.Copy(tarWriter, file)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// copyFile copies a file from src to dst
|
||||
func (b *Backup) copyFile(src, dst string) error {
|
||||
sourceFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
defer sourceFile.Close()
|
||||
|
||||
// create destination directory if it doesn't exist
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
destFile, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
defer destFile.Close()
|
||||
|
||||
_, err = io.Copy(destFile, sourceFile)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// copyDir recursively copies a directory from src to dst
|
||||
func (b *Backup) copyDir(src, dst string) error {
|
||||
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// get relative path
|
||||
relPath, err := filepath.Rel(src, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dstPath := filepath.Join(dst, relPath)
|
||||
|
||||
if info.IsDir() {
|
||||
return os.MkdirAll(dstPath, info.Mode())
|
||||
}
|
||||
|
||||
return b.copyFile(path, dstPath)
|
||||
})
|
||||
}
|
||||
|
||||
// CleanupOldBackups removes old backup files to save disk space
|
||||
func (b *Backup) CleanupOldBackups(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
keepCount int,
|
||||
) error {
|
||||
ae := NewAuditEvent("Backup.CleanupOldBackups", session)
|
||||
ae.Details["keepCount"] = keepCount
|
||||
|
||||
// check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil {
|
||||
b.LogAuthError(err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
b.AuditLogNotAuthorized(ae)
|
||||
return errs.ErrAuthorizationFailed
|
||||
}
|
||||
|
||||
filesPath := strings.TrimSuffix(b.FilePath, "/")
|
||||
backupDir := filepath.Join(filesPath, "backups")
|
||||
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
|
||||
return nil // no backups directory
|
||||
}
|
||||
|
||||
// get all backup files
|
||||
files, err := os.ReadDir(backupDir)
|
||||
if err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// filter backup files and sort by modification time
|
||||
var backupFiles []os.FileInfo
|
||||
for _, file := range files {
|
||||
if strings.HasPrefix(file.Name(), "backup-") && strings.HasSuffix(file.Name(), ".tar.gz") {
|
||||
info, err := file.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
backupFiles = append(backupFiles, info)
|
||||
}
|
||||
}
|
||||
|
||||
// if we have more backups than we want to keep, delete the oldest ones
|
||||
if len(backupFiles) > keepCount {
|
||||
// sort by modification time (oldest first)
|
||||
for i := 0; i < len(backupFiles)-1; i++ {
|
||||
for j := i + 1; j < len(backupFiles); j++ {
|
||||
if backupFiles[i].ModTime().After(backupFiles[j].ModTime()) {
|
||||
backupFiles[i], backupFiles[j] = backupFiles[j], backupFiles[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// delete oldest files
|
||||
filesToDelete := len(backupFiles) - keepCount
|
||||
deletedFiles := []string{}
|
||||
for i := 0; i < filesToDelete; i++ {
|
||||
filePath := filepath.Join(backupDir, backupFiles[i].Name())
|
||||
if err := os.Remove(filePath); err != nil {
|
||||
b.Logger.Warnw("failed to delete old backup", "file", filePath, "error", err)
|
||||
} else {
|
||||
b.Logger.Debugw("deleted old backup", "file", filePath)
|
||||
deletedFiles = append(deletedFiles, backupFiles[i].Name())
|
||||
}
|
||||
}
|
||||
ae.Details["deletedFiles"] = deletedFiles
|
||||
ae.Details["deletedCount"] = len(deletedFiles)
|
||||
}
|
||||
|
||||
ae.Details["totalBackups"] = len(backupFiles)
|
||||
b.AuditLogAuthorized(ae)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListBackups returns a list of available backup files
|
||||
func (b *Backup) ListBackups(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
) ([]BackupFile, error) {
|
||||
ae := NewAuditEvent("Backup.ListBackups", session)
|
||||
|
||||
// check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil {
|
||||
b.LogAuthError(err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
b.AuditLogNotAuthorized(ae)
|
||||
return nil, errs.ErrAuthorizationFailed
|
||||
}
|
||||
|
||||
filesPath := strings.TrimSuffix(b.FilePath, "/")
|
||||
backupDir := filepath.Join(filesPath, "backups")
|
||||
|
||||
// check if backup directory exists
|
||||
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
|
||||
return []BackupFile{}, nil // return empty list if no backups directory
|
||||
}
|
||||
|
||||
// read backup directory
|
||||
files, err := os.ReadDir(backupDir)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
|
||||
var backupFiles []BackupFile
|
||||
for _, file := range files {
|
||||
if strings.HasPrefix(file.Name(), "backup-") && strings.HasSuffix(file.Name(), ".tar.gz") {
|
||||
info, err := file.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
backupFiles = append(backupFiles, BackupFile{
|
||||
Name: file.Name(),
|
||||
Size: info.Size(),
|
||||
CreatedAt: info.ModTime(),
|
||||
RelativePath: filepath.Join("backups", file.Name()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// sort by creation time (newest first)
|
||||
for i := 0; i < len(backupFiles)-1; i++ {
|
||||
for j := i + 1; j < len(backupFiles); j++ {
|
||||
if backupFiles[i].CreatedAt.Before(backupFiles[j].CreatedAt) {
|
||||
backupFiles[i], backupFiles[j] = backupFiles[j], backupFiles[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ae.Details["backupCount"] = len(backupFiles)
|
||||
b.AuditLogAuthorized(ae)
|
||||
return backupFiles, nil
|
||||
}
|
||||
|
||||
// GetBackupFile returns a file handle to a backup file if it exists and is valid
|
||||
func (b *Backup) GetBackupFile(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
filename string,
|
||||
) (*os.File, error) {
|
||||
ae := NewAuditEvent("Backup.DownloadBackup", session)
|
||||
ae.Details["filename"] = filename
|
||||
|
||||
// check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil {
|
||||
b.LogAuthError(err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
b.AuditLogNotAuthorized(ae)
|
||||
return nil, errs.ErrAuthorizationFailed
|
||||
}
|
||||
|
||||
// validate filename - must be a backup file
|
||||
if !strings.HasPrefix(filename, "backup-") || !strings.HasSuffix(filename, ".tar.gz") {
|
||||
b.Logger.Debugw("invalid backup filename format", "filename", filename)
|
||||
return nil, validate.WrapErrorWithField(errors.New("invalid backup filename"), "filename")
|
||||
}
|
||||
|
||||
// get backup directory path
|
||||
filesPath := strings.TrimSuffix(b.FilePath, "/")
|
||||
backupDir := filepath.Join(filesPath, "backups")
|
||||
|
||||
// use os.OpenRoot for secure file access within backup directory
|
||||
root, err := os.OpenRoot(backupDir)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
defer root.Close()
|
||||
|
||||
// try to stat the file using the secure root - this prevents directory traversal
|
||||
info, err := root.Stat(filename)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
b.Logger.Debugw("backup file not found", "filename", filename)
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
|
||||
if !info.Mode().IsRegular() {
|
||||
b.Logger.Debugw("requested file is not a regular file", "filename", filename)
|
||||
return nil, validate.WrapErrorWithField(errors.New("not a regular file"), "filename")
|
||||
}
|
||||
|
||||
// open the file using the secure root - this maintains security throughout
|
||||
file, err := root.Open(filename)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
|
||||
ae.Details["backupSize"] = info.Size()
|
||||
b.AuditLogAuthorized(ae)
|
||||
return file, nil
|
||||
}
|
||||
+715
-4
@@ -1,17 +1,18 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
go_errors "github.com/go-errors/errors"
|
||||
@@ -766,7 +767,6 @@ func (c *Campaign) GetStats(
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
// no audit on read
|
||||
return &model.CampaignsStatView{
|
||||
Active: active,
|
||||
Upcoming: upcoming,
|
||||
@@ -1952,11 +1952,13 @@ func (c *Campaign) sendCampaignMessages(
|
||||
email,
|
||||
nil,
|
||||
)
|
||||
err = m.SetBodyHTMLTemplate(mailTmpl, t)
|
||||
var bodyBuffer bytes.Buffer
|
||||
err = mailTmpl.Execute(&bodyBuffer, t)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to set body html template", "error", err)
|
||||
c.Logger.Errorw("failed to execute mail template", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
m.SetBodyString("text/html", bodyBuffer.String())
|
||||
// attachments
|
||||
attachments := email.Attachments
|
||||
for _, attachment := range attachments {
|
||||
@@ -2962,6 +2964,449 @@ func (c *Campaign) AnonymizeByID(
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendEmailByCampaignRecipientID sends an email to a specific campaign recipient
|
||||
// Multiple sends to the same recipient are allowed to support retry scenarios and follow-ups.
|
||||
func (c *Campaign) SendEmailByCampaignRecipientID(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
campaignRecipientID *uuid.UUID,
|
||||
) error {
|
||||
ae := NewAuditEvent("Campaign.SendEmailByCampaignRecipientID", session)
|
||||
ae.Details["campaignRecipientId"] = campaignRecipientID.String()
|
||||
// check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
c.LogAuthError(err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
c.AuditLogNotAuthorized(ae)
|
||||
return errs.ErrAuthorizationFailed
|
||||
}
|
||||
|
||||
// get campaign recipient
|
||||
campaignRecipient, err := c.CampaignRecipientRepository.GetByID(
|
||||
ctx,
|
||||
campaignRecipientID,
|
||||
&repository.CampaignRecipientOption{
|
||||
WithRecipient: true,
|
||||
WithCampaign: true,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to get campaign recipient by id", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
campaign := campaignRecipient.Campaign
|
||||
if campaign == nil {
|
||||
return errors.New("campaign recipient has no campaign loaded")
|
||||
}
|
||||
|
||||
// check if campaign is active
|
||||
if !campaign.IsActive() {
|
||||
return errors.New("campaign is not active")
|
||||
}
|
||||
|
||||
// check if recipient exists (not anonymized)
|
||||
if campaignRecipient.Recipient == nil {
|
||||
return errors.New("recipient is anonymized or deleted")
|
||||
}
|
||||
|
||||
// check if cancelled
|
||||
if !campaignRecipient.CancelledAt.IsNull() {
|
||||
return errors.New("recipient has been cancelled")
|
||||
}
|
||||
|
||||
campaignID := campaign.ID.MustGet()
|
||||
|
||||
// add resend information to audit log
|
||||
isResend := !campaignRecipient.SentAt.IsNull()
|
||||
ae.Details["isResend"] = isResend
|
||||
if isResend {
|
||||
ae.Details["previouslySentAt"] = campaignRecipient.SentAt.MustGet().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// send the email using existing logic from sendCampaignMessages
|
||||
err = c.sendSingleCampaignMessage(ctx, session, &campaignID, campaignRecipient)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to send campaign message", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
c.AuditLogAuthorized(ae)
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendSingleCampaignMessage sends an email to a single campaign recipient
|
||||
func (c *Campaign) sendSingleCampaignMessage(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
campaignID *uuid.UUID,
|
||||
campaignRecipient *model.CampaignRecipient,
|
||||
) error {
|
||||
// get campaign template details - similar logic from sendCampaignMessages
|
||||
campaign, err := c.CampaignRepository.GetByID(
|
||||
ctx,
|
||||
campaignID,
|
||||
&repository.CampaignOption{},
|
||||
)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to get campaign by id", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
templateID, err := campaign.TemplateID.Get()
|
||||
if err != nil {
|
||||
return errors.New("campaign has no template")
|
||||
}
|
||||
|
||||
cTemplate, err := c.CampaignTemplateService.GetByID(
|
||||
ctx,
|
||||
session,
|
||||
&templateID,
|
||||
&repository.CampaignTemplateOption{
|
||||
WithDomain: true,
|
||||
WithSMTPConfiguration: true,
|
||||
WithIdentifier: true,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to get campaign template by id", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// check domain
|
||||
domain := cTemplate.Domain
|
||||
if domain == nil {
|
||||
return errors.New("campaign template has no domain")
|
||||
}
|
||||
|
||||
// get email details
|
||||
emailID, err := cTemplate.EmailID.Get()
|
||||
if err != nil {
|
||||
return errors.New("campaign template has no email")
|
||||
}
|
||||
|
||||
email, err := c.MailService.GetByID(ctx, session, &emailID)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to get email by id", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// update last attempt timestamp
|
||||
campaignRecipientID := campaignRecipient.ID.MustGet()
|
||||
campaignRecipient.LastAttemptAt = nullable.NewNullableWithValue(time.Now())
|
||||
err = c.CampaignRecipientRepository.UpdateByID(ctx, &campaignRecipientID, campaignRecipient)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to update last attempted at", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// prepare template for rendering
|
||||
content, err := email.Content.Get()
|
||||
if err != nil {
|
||||
return errors.New("failed to get email content")
|
||||
}
|
||||
|
||||
t := template.New("email")
|
||||
t = t.Funcs(TemplateFuncs())
|
||||
mailTmpl, err := t.Parse(content.String())
|
||||
if err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// check sending method
|
||||
isSmtpCampaign := cTemplate.SMTPConfigurationID.IsSpecified() && !cTemplate.SMTPConfigurationID.IsNull()
|
||||
isAPISenderCampaign := cTemplate.APISenderID.IsSpecified() && !cTemplate.APISenderID.IsNull()
|
||||
|
||||
if !isSmtpCampaign && !isAPISenderCampaign {
|
||||
return errors.New("campaign template has no SMTP configuration or API sender")
|
||||
}
|
||||
|
||||
if isAPISenderCampaign {
|
||||
// send via API
|
||||
err = c.APISenderService.Send(
|
||||
ctx,
|
||||
session,
|
||||
cTemplate,
|
||||
campaignRecipient,
|
||||
domain,
|
||||
mailTmpl,
|
||||
email,
|
||||
)
|
||||
} else {
|
||||
// send via SMTP
|
||||
err = c.sendSingleEmailSMTP(ctx, session, cTemplate, campaignRecipient, domain, mailTmpl, email)
|
||||
}
|
||||
|
||||
// save sending result
|
||||
saveErr := c.saveSendingResult(ctx, campaignRecipient, err)
|
||||
if saveErr != nil {
|
||||
c.Logger.Errorw("failed to save sending result", "error", saveErr)
|
||||
return errs.Wrap(saveErr)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// sendSingleEmailSMTP sends an email to a single recipient via SMTP
|
||||
func (c *Campaign) sendSingleEmailSMTP(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
cTemplate *model.CampaignTemplate,
|
||||
campaignRecipient *model.CampaignRecipient,
|
||||
domain *model.Domain,
|
||||
mailTmpl *template.Template,
|
||||
email *model.Email,
|
||||
) error {
|
||||
// get SMTP configuration
|
||||
smtpConfigID, err := cTemplate.SMTPConfigurationID.Get()
|
||||
if err != nil {
|
||||
return errors.New("failed to get SMTP configuration from template")
|
||||
}
|
||||
|
||||
smtpConfig, err := c.SMTPConfigService.GetByID(
|
||||
ctx,
|
||||
session, // use the actual session passed to the method
|
||||
&smtpConfigID,
|
||||
&repository.SMTPConfigurationOption{
|
||||
WithHeaders: true,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("smtp configuration did not load", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
smtpPort, err := smtpConfig.Port.Get()
|
||||
if err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
smtpHost, err := smtpConfig.Host.Get()
|
||||
if err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
smtpIgnoreCertErrors, err := smtpConfig.IgnoreCertErrors.Get()
|
||||
if err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// setup SMTP client options
|
||||
emailOptions := []mail.Option{
|
||||
mail.WithPort(smtpPort.Int()),
|
||||
mail.WithTLSConfig(
|
||||
&tls.Config{
|
||||
ServerName: smtpHost.String(),
|
||||
InsecureSkipVerify: smtpIgnoreCertErrors,
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
// setup authentication if provided
|
||||
username, err := smtpConfig.Username.Get()
|
||||
if err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
password, err := smtpConfig.Password.Get()
|
||||
if err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
if un := username.String(); len(un) > 0 {
|
||||
emailOptions = append(emailOptions, mail.WithUsername(un))
|
||||
if pw := password.String(); len(pw) > 0 {
|
||||
emailOptions = append(emailOptions, mail.WithPassword(pw))
|
||||
}
|
||||
}
|
||||
|
||||
// create message
|
||||
messageOptions := []mail.MsgOption{
|
||||
mail.WithNoDefaultUserAgent(),
|
||||
}
|
||||
m := mail.NewMsg(messageOptions...)
|
||||
|
||||
// set envelope from
|
||||
err = m.EnvelopeFrom(email.MailEnvelopeFrom.MustGet().String())
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to set envelope from", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// set headers
|
||||
err = m.From(email.MailHeaderFrom.MustGet().String())
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to set mail header 'From'", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
recpEmail := campaignRecipient.Recipient.Email.MustGet().String()
|
||||
err = m.To(recpEmail)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to set mail header 'To'", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// custom headers
|
||||
if headers := smtpConfig.Headers; headers != nil {
|
||||
for _, header := range headers {
|
||||
key := header.Key.MustGet()
|
||||
value := header.Value.MustGet()
|
||||
m.SetGenHeader(
|
||||
mail.Header(key.String()),
|
||||
value.String(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
m.Subject(email.MailHeaderSubject.MustGet().String())
|
||||
|
||||
// setup template variables
|
||||
domainName, err := domain.Name.Get()
|
||||
if err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
urlIdentifier := cTemplate.URLIdentifier
|
||||
if urlIdentifier == nil {
|
||||
return errors.New("url identifier must be loaded for the campaign template")
|
||||
}
|
||||
|
||||
urlPath := cTemplate.URLPath.MustGet().String()
|
||||
t := c.TemplateService.CreateMail(
|
||||
domainName.String(),
|
||||
urlIdentifier.Name.MustGet(),
|
||||
urlPath,
|
||||
campaignRecipient,
|
||||
email,
|
||||
nil,
|
||||
)
|
||||
|
||||
var bodyBuffer bytes.Buffer
|
||||
err = mailTmpl.Execute(&bodyBuffer, t)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to execute mail template", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
m.SetBodyString("text/html", bodyBuffer.String())
|
||||
|
||||
// handle attachments
|
||||
attachments := email.Attachments
|
||||
for _, attachment := range attachments {
|
||||
p, err := c.MailService.AttachmentService.GetPath(attachment)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get attachment path: %s", err)
|
||||
}
|
||||
if !attachment.EmbeddedContent.MustGet() {
|
||||
m.AttachFile(p.String())
|
||||
} else {
|
||||
attachmentContent, err := os.ReadFile(p.String())
|
||||
if err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
// setup attachment for executing as email template
|
||||
attachmentAsEmail := model.Email{
|
||||
ID: email.ID,
|
||||
CreatedAt: email.CreatedAt,
|
||||
UpdatedAt: email.UpdatedAt,
|
||||
Name: email.Name,
|
||||
MailEnvelopeFrom: email.MailEnvelopeFrom,
|
||||
MailHeaderFrom: email.MailHeaderFrom,
|
||||
MailHeaderSubject: email.MailHeaderSubject,
|
||||
Content: email.Content,
|
||||
AddTrackingPixel: email.AddTrackingPixel,
|
||||
CompanyID: email.CompanyID,
|
||||
Attachments: email.Attachments,
|
||||
Company: email.Company,
|
||||
}
|
||||
attachmentAsEmail.Content = nullable.NewNullableWithValue(
|
||||
*vo.NewUnsafeOptionalString1MB(string(attachmentContent)),
|
||||
)
|
||||
attachmentStr, err := c.TemplateService.CreateMailBody(
|
||||
urlIdentifier.Name.MustGet(),
|
||||
urlPath,
|
||||
domain,
|
||||
campaignRecipient,
|
||||
&attachmentAsEmail,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return errs.Wrap(fmt.Errorf("failed to setup attachment with embedded content: %s", err))
|
||||
}
|
||||
m.AttachReadSeeker(
|
||||
filepath.Base(p.String()),
|
||||
strings.NewReader(attachmentStr),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// send the email
|
||||
var mc *mail.Client
|
||||
|
||||
// try different authentication methods
|
||||
if un := username.String(); len(un) > 0 {
|
||||
// try CRAM-MD5 first when credentials are provided
|
||||
emailOptionsCRAM5 := append(emailOptions, mail.WithSMTPAuth(mail.SMTPAuthCramMD5))
|
||||
mc, _ = mail.NewClient(smtpHost.String(), emailOptionsCRAM5...)
|
||||
mc.SetLogger(log.NewGoMailLoggerAdapter(c.Logger))
|
||||
mc.SetDebugLog(true)
|
||||
if build.Flags.Production {
|
||||
mc.SetTLSPolicy(mail.TLSMandatory)
|
||||
} else {
|
||||
mc.SetTLSPolicy(mail.TLSOpportunistic)
|
||||
}
|
||||
err = mc.DialAndSendWithContext(ctx, m)
|
||||
|
||||
// check if it's an authentication error and try PLAIN auth
|
||||
if err != nil && (strings.Contains(err.Error(), "535 ") ||
|
||||
strings.Contains(err.Error(), "534 ") ||
|
||||
strings.Contains(err.Error(), "538 ") ||
|
||||
strings.Contains(err.Error(), "CRAM-MD5") ||
|
||||
strings.Contains(err.Error(), "authentication failed")) {
|
||||
c.Logger.Debugf("CRAM-MD5 authentication failed, trying PLAIN auth", "error", err)
|
||||
emailOptionsBasic := emailOptions
|
||||
emailOptionsBasic = append(emailOptions, mail.WithSMTPAuth(mail.SMTPAuthPlain))
|
||||
mc, _ = mail.NewClient(smtpHost.String(), emailOptionsBasic...)
|
||||
mc.SetLogger(log.NewGoMailLoggerAdapter(c.Logger))
|
||||
mc.SetDebugLog(true)
|
||||
if build.Flags.Production {
|
||||
mc.SetTLSPolicy(mail.TLSMandatory)
|
||||
} else {
|
||||
mc.SetTLSPolicy(mail.TLSOpportunistic)
|
||||
}
|
||||
err = mc.DialAndSendWithContext(ctx, m)
|
||||
}
|
||||
} else {
|
||||
// no credentials provided, try without authentication
|
||||
mc, _ = mail.NewClient(smtpHost.String(), emailOptions...)
|
||||
mc.SetLogger(log.NewGoMailLoggerAdapter(c.Logger))
|
||||
mc.SetDebugLog(true)
|
||||
if build.Flags.Production {
|
||||
mc.SetTLSPolicy(mail.TLSMandatory)
|
||||
} else {
|
||||
mc.SetTLSPolicy(mail.TLSOpportunistic)
|
||||
}
|
||||
err = mc.DialAndSendWithContext(ctx, m)
|
||||
|
||||
// if no-auth fails and we get an auth-related error, log it appropriately
|
||||
if err != nil && (strings.Contains(err.Error(), "530 ") ||
|
||||
strings.Contains(err.Error(), "535 ") ||
|
||||
strings.Contains(err.Error(), "authentication required") ||
|
||||
strings.Contains(err.Error(), "AUTH")) {
|
||||
c.Logger.Warnw("Server requires authentication but no credentials provided", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to send email", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetNotableCampaignEvent checks and update if most notable event for a campaign
|
||||
func (c *Campaign) setMostNotableCampaignEvent(
|
||||
ctx context.Context,
|
||||
@@ -3051,11 +3496,13 @@ func (c *Campaign) GenerateCampaignStats(ctx context.Context, session *model.Ses
|
||||
openRate := float64(0)
|
||||
clickRate := float64(0)
|
||||
submissionRate := float64(0)
|
||||
reportRate := float64(0)
|
||||
|
||||
if resultStats.Recipients > 0 {
|
||||
openRate = (float64(resultStats.TrackingPixelLoaded) / float64(resultStats.Recipients)) * 100
|
||||
clickRate = (float64(resultStats.WebsiteLoaded) / float64(resultStats.Recipients)) * 100
|
||||
submissionRate = (float64(resultStats.SubmittedData) / float64(resultStats.Recipients)) * 100
|
||||
reportRate = (float64(resultStats.Reported) / float64(resultStats.Recipients)) * 100
|
||||
}
|
||||
|
||||
// Determine campaign type
|
||||
@@ -3116,9 +3563,11 @@ func (c *Campaign) GenerateCampaignStats(ctx context.Context, session *model.Ses
|
||||
TrackingPixelLoaded: int(resultStats.TrackingPixelLoaded),
|
||||
WebsiteVisits: int(resultStats.WebsiteLoaded),
|
||||
DataSubmissions: int(resultStats.SubmittedData),
|
||||
Reported: int(resultStats.Reported),
|
||||
OpenRate: openRate,
|
||||
ClickRate: clickRate,
|
||||
SubmissionRate: submissionRate,
|
||||
ReportRate: reportRate,
|
||||
|
||||
TemplateName: templateName,
|
||||
CampaignType: campaignType,
|
||||
@@ -3184,3 +3633,265 @@ func (c *Campaign) GetAllCampaignStats(ctx context.Context, session *model.Sessi
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ProcessReportedCSV processes a CSV file with reported recipients
|
||||
func (c *Campaign) ProcessReportedCSV(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
campaignID *uuid.UUID,
|
||||
records [][]string,
|
||||
) (int, int, error) {
|
||||
ae := NewAuditEvent("Campaign.ProcessReportedCSV", session)
|
||||
ae.Details["campaignID"] = campaignID.String()
|
||||
|
||||
// check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
c.LogAuthError(err)
|
||||
return 0, 0, errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
c.AuditLogNotAuthorized(ae)
|
||||
return 0, 0, errs.ErrAuthorizationFailed
|
||||
}
|
||||
|
||||
// get campaign to check it exists and get details
|
||||
campaign, err := c.CampaignRepository.GetByID(ctx, campaignID, &repository.CampaignOption{})
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to get campaign by id", "error", err)
|
||||
return 0, 0, errs.Wrap(err)
|
||||
}
|
||||
|
||||
// validate CSV headers
|
||||
headers := records[0]
|
||||
reportedByIndex := -1
|
||||
dateReportedIndex := -1
|
||||
|
||||
c.Logger.Debugw("processing CSV headers", "headers", headers)
|
||||
|
||||
for i, header := range headers {
|
||||
switch strings.ToLower(strings.TrimSpace(header)) {
|
||||
case "reported by":
|
||||
reportedByIndex = i
|
||||
c.Logger.Debugw("found reported by column", "index", i)
|
||||
case "date reporter (utc+02:00)", "date reported(utc+02:00)", "date reported", "date reporter":
|
||||
dateReportedIndex = i
|
||||
c.Logger.Debugw("found date column", "index", i, "header", header)
|
||||
}
|
||||
}
|
||||
|
||||
if reportedByIndex == -1 {
|
||||
c.Logger.Errorw("CSV missing required column", "expected", "reported by", "headers", headers)
|
||||
return 0, 0, errs.NewValidationError(errors.New("CSV must have 'reported by' column"))
|
||||
}
|
||||
if dateReportedIndex == -1 {
|
||||
c.Logger.Errorw("CSV missing required date column", "expected", "date reported(utc+02:00)", "headers", headers)
|
||||
return 0, 0, errs.NewValidationError(errors.New("CSV must have 'date reporter (utc+02:00)' or similar date column"))
|
||||
}
|
||||
|
||||
processed := 0
|
||||
skipped := 0
|
||||
reportedEventID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_REPORTED]
|
||||
|
||||
// process each row
|
||||
for i, record := range records[1:] { // skip header
|
||||
if len(record) <= reportedByIndex || len(record) <= dateReportedIndex {
|
||||
skipped++
|
||||
c.Logger.Debugw("skipping row with insufficient columns", "row", i+2)
|
||||
continue
|
||||
}
|
||||
|
||||
reportedByEmail := strings.TrimSpace(record[reportedByIndex])
|
||||
dateReported := strings.TrimSpace(record[dateReportedIndex])
|
||||
|
||||
if reportedByEmail == "" {
|
||||
skipped++
|
||||
c.Logger.Debugw("skipping row with empty email", "row", i+2)
|
||||
continue
|
||||
}
|
||||
// parse date - try multiple formats and handle timezone
|
||||
var parsedDate time.Time
|
||||
dateFormats := []string{
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02T15:04:05Z",
|
||||
"2006-01-02T15:04:05-07:00",
|
||||
"2006-01-02T15:04:05+02:00",
|
||||
"2006-01-02",
|
||||
"01/02/2006 15:04:05",
|
||||
"01/02/2006",
|
||||
"02-01-2006 15:04:05",
|
||||
"02-01-2006",
|
||||
}
|
||||
|
||||
dateParseError := true
|
||||
for _, format := range dateFormats {
|
||||
if pd, err := time.Parse(format, dateReported); err == nil {
|
||||
// if the parsed date has no timezone info and the header mentions UTC+02:00,
|
||||
// assume the time is in UTC+02:00 and convert to UTC
|
||||
if pd.Location() == time.UTC && strings.Contains(strings.ToLower(headers[dateReportedIndex]), "utc+02:00") {
|
||||
// treat as UTC+02:00 and convert to UTC
|
||||
loc, _ := time.LoadLocation("Europe/Berlin") // UTC+2 (or use FixedZone)
|
||||
if loc != nil {
|
||||
pd = time.Date(pd.Year(), pd.Month(), pd.Day(), pd.Hour(), pd.Minute(), pd.Second(), pd.Nanosecond(), loc).UTC()
|
||||
}
|
||||
}
|
||||
parsedDate = pd
|
||||
dateParseError = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if dateParseError {
|
||||
skipped++
|
||||
c.Logger.Debugw("skipping row with invalid date format", "row", i+2, "date", dateReported, "tried_formats", dateFormats)
|
||||
continue
|
||||
}
|
||||
|
||||
c.Logger.Debugw("processing row", "row", i+2, "email", reportedByEmail, "date", parsedDate)
|
||||
|
||||
// find recipient by email in this campaign
|
||||
emailVO, err := vo.NewEmail(reportedByEmail)
|
||||
if err != nil {
|
||||
skipped++
|
||||
c.Logger.Debugw("invalid email format", "email", reportedByEmail, "row", i+2)
|
||||
continue
|
||||
}
|
||||
|
||||
// Get campaign to check company context
|
||||
companyID, _ := campaign.CompanyID.Get()
|
||||
var companyPtr *uuid.UUID
|
||||
if companyID != uuid.Nil {
|
||||
companyPtr = &companyID
|
||||
}
|
||||
|
||||
recipient, err := c.RecipientService.GetByEmail(ctx, session, emailVO, companyPtr)
|
||||
if err != nil {
|
||||
skipped++
|
||||
c.Logger.Debugw("recipient not found for email", "email", reportedByEmail, "row", i+2)
|
||||
continue
|
||||
}
|
||||
|
||||
recipientID := recipient.ID.MustGet()
|
||||
|
||||
// check if recipient is part of this campaign
|
||||
campaignRecipient, err := c.CampaignRecipientRepository.GetByCampaignAndRecipientID(
|
||||
ctx,
|
||||
campaignID,
|
||||
&recipientID,
|
||||
&repository.CampaignRecipientOption{},
|
||||
)
|
||||
if err != nil {
|
||||
skipped++
|
||||
c.Logger.Debugw("recipient not part of campaign", "email", reportedByEmail, "campaignID", campaignID.String(), "row", i+2)
|
||||
continue
|
||||
}
|
||||
|
||||
// check if already reported (to avoid duplicates)
|
||||
existingEvent, err := c.CampaignRepository.GetEventsByCampaignID(
|
||||
ctx,
|
||||
campaignID,
|
||||
&repository.CampaignEventOption{
|
||||
QueryArgs: &vo.QueryArgs{
|
||||
Limit: 1,
|
||||
},
|
||||
EventTypeIDs: []string{reportedEventID.String()},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
|
||||
alreadyReported := false
|
||||
if err == nil && existingEvent != nil {
|
||||
for _, event := range existingEvent.Rows {
|
||||
if event.RecipientID != nil && *event.RecipientID == recipientID {
|
||||
alreadyReported = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if alreadyReported {
|
||||
skipped++
|
||||
c.Logger.Debugw("recipient already reported", "email", reportedByEmail, "campaignID", campaignID.String())
|
||||
continue
|
||||
}
|
||||
|
||||
// create campaign event for reported
|
||||
eventID := uuid.New()
|
||||
|
||||
var campaignEvent *model.CampaignEvent
|
||||
if campaign.IsAnonymous.MustGet() {
|
||||
campaignEvent = &model.CampaignEvent{
|
||||
ID: &eventID,
|
||||
CampaignID: campaignID,
|
||||
RecipientID: nil,
|
||||
IP: vo.NewEmptyOptionalString64(),
|
||||
UserAgent: vo.NewEmptyOptionalString255(),
|
||||
EventID: reportedEventID,
|
||||
Data: vo.NewEmptyOptionalString1MB(),
|
||||
}
|
||||
} else {
|
||||
campaignEvent = &model.CampaignEvent{
|
||||
ID: &eventID,
|
||||
CampaignID: campaignID,
|
||||
RecipientID: &recipientID,
|
||||
IP: vo.NewEmptyOptionalString64(),
|
||||
UserAgent: vo.NewEmptyOptionalString255(),
|
||||
EventID: reportedEventID,
|
||||
Data: vo.NewEmptyOptionalString1MB(),
|
||||
}
|
||||
}
|
||||
|
||||
// save the event with custom timestamp
|
||||
err = c.saveReportedEvent(campaignEvent, parsedDate)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to save reported event", "error", err, "email", reportedByEmail)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// update most notable event for campaign recipient
|
||||
err = c.SetNotableCampaignRecipientEvent(
|
||||
ctx,
|
||||
campaignRecipient,
|
||||
data.EVENT_CAMPAIGN_RECIPIENT_REPORTED,
|
||||
)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to update notable event", "error", err)
|
||||
}
|
||||
|
||||
processed++
|
||||
}
|
||||
|
||||
ae.Details["processed"] = processed
|
||||
ae.Details["skipped"] = skipped
|
||||
c.AuditLogAuthorized(ae)
|
||||
|
||||
return processed, skipped, nil
|
||||
}
|
||||
|
||||
// saveReportedEvent saves a reported event with custom timestamp
|
||||
func (c *Campaign) saveReportedEvent(
|
||||
campaignEvent *model.CampaignEvent,
|
||||
customTime time.Time,
|
||||
) error {
|
||||
row := map[string]any{
|
||||
"id": campaignEvent.ID.String(),
|
||||
"event_id": campaignEvent.EventID.String(),
|
||||
"campaign_id": campaignEvent.CampaignID.String(),
|
||||
"ip_address": campaignEvent.IP.String(),
|
||||
"user_agent": campaignEvent.UserAgent.String(),
|
||||
"data": campaignEvent.Data.String(),
|
||||
"created_at": customTime,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
if campaignEvent.RecipientID != nil {
|
||||
row["recipient_id"] = campaignEvent.RecipientID.String()
|
||||
}
|
||||
|
||||
res := c.CampaignRepository.DB.Model(&database.CampaignEvent{}).Create(row)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ type Domain struct {
|
||||
CampaignTemplateService *CampaignTemplate
|
||||
AssetService *Asset
|
||||
FileService *File
|
||||
TemplateService *Template
|
||||
}
|
||||
|
||||
// Create creates a new domain
|
||||
@@ -60,6 +61,19 @@ func (d *Domain) Create(
|
||||
// d.Logger.Debugf("failed to validate domain", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
// validate template content if present
|
||||
if pageContent, err := domain.PageContent.Get(); err == nil {
|
||||
if err := d.TemplateService.ValidateDomainTemplate(pageContent.String()); err != nil {
|
||||
d.Logger.Errorw("failed to validate domain page template", "error", err)
|
||||
return nil, validate.WrapErrorWithField(errors.New("invalid page template: "+err.Error()), "pageContent")
|
||||
}
|
||||
}
|
||||
if notFoundContent, err := domain.PageNotFoundContent.Get(); err == nil {
|
||||
if err := d.TemplateService.ValidateDomainTemplate(notFoundContent.String()); err != nil {
|
||||
d.Logger.Errorw("failed to validate domain not found template", "error", err)
|
||||
return nil, validate.WrapErrorWithField(errors.New("invalid not found template: "+err.Error()), "pageNotFoundContent")
|
||||
}
|
||||
}
|
||||
// check for uniqueness
|
||||
name := domain.Name.MustGet() // safe as we have validated
|
||||
_, err = d.DomainRepository.GetByName(
|
||||
@@ -403,9 +417,19 @@ func (d *Domain) UpdateByID(
|
||||
current.HostWebsite.Set(v)
|
||||
}
|
||||
if v, err := incoming.PageContent.Get(); err == nil {
|
||||
// validate template content before updating
|
||||
if err := d.TemplateService.ValidateDomainTemplate(v.String()); err != nil {
|
||||
d.Logger.Errorw("failed to validate domain page template", "error", err)
|
||||
return validate.WrapErrorWithField(errors.New("invalid page template: "+err.Error()), "pageContent")
|
||||
}
|
||||
current.PageContent.Set(v)
|
||||
}
|
||||
if v, err := incoming.PageNotFoundContent.Get(); err == nil {
|
||||
// validate template content before updating
|
||||
if err := d.TemplateService.ValidateDomainTemplate(v.String()); err != nil {
|
||||
d.Logger.Errorw("failed to validate domain not found template", "error", err)
|
||||
return validate.WrapErrorWithField(errors.New("invalid not found template: "+err.Error()), "pageNotFoundContent")
|
||||
}
|
||||
current.PageNotFoundContent.Set(v)
|
||||
}
|
||||
if v, err := incoming.RedirectURL.Get(); err == nil {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
|
||||
@@ -171,6 +172,13 @@ func (m *Email) Create(
|
||||
if err := email.Validate(); err != nil {
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
// validate template content if present
|
||||
if content, err := email.Content.Get(); err == nil {
|
||||
if err := m.TemplateService.ValidateEmailTemplate(content.String()); err != nil {
|
||||
m.Logger.Errorw("failed to validate email template", "error", err)
|
||||
return nil, validate.WrapErrorWithField(errors.New("invalid template: "+err.Error()), "content")
|
||||
}
|
||||
}
|
||||
// check uniqueness
|
||||
var companyID *uuid.UUID
|
||||
if cid, err := email.CompanyID.Get(); err == nil {
|
||||
@@ -590,11 +598,13 @@ func (m *Email) SendTestEmail(
|
||||
email,
|
||||
nil,
|
||||
)
|
||||
err = msg.SetBodyHTMLTemplate(mailTmpl, t)
|
||||
var bodyBuffer bytes.Buffer
|
||||
err = mailTmpl.Execute(&bodyBuffer, t)
|
||||
if err != nil {
|
||||
m.Logger.Errorw("failed to set body html template", "error", err)
|
||||
return errs.Wrap(err)
|
||||
m.Logger.Errorw("failed to execute mail template", "error", err)
|
||||
return err
|
||||
}
|
||||
msg.SetBodyString("text/html", bodyBuffer.String())
|
||||
// attachments
|
||||
attachments := email.Attachments
|
||||
for _, attachment := range attachments {
|
||||
@@ -780,6 +790,11 @@ func (m *Email) UpdateByID(
|
||||
current.MailHeaderSubject.Set(v)
|
||||
}
|
||||
if v, err := email.Content.Get(); err == nil {
|
||||
// validate template content before updating
|
||||
if err := m.TemplateService.ValidateEmailTemplate(v.String()); err != nil {
|
||||
m.Logger.Errorw("failed to validate email template", "error", err)
|
||||
return validate.WrapErrorWithField(errors.New("invalid template: "+err.Error()), "content")
|
||||
}
|
||||
if _, err := email.AddTrackingPixel.Get(); err == nil {
|
||||
// handle tracking pixel
|
||||
email, err = m.toggleTrackingPixel(email)
|
||||
|
||||
@@ -20,6 +20,7 @@ type Page struct {
|
||||
PageRepository *repository.Page
|
||||
CampaignRepository *repository.Campaign
|
||||
CampaignTemplateService *CampaignTemplate
|
||||
TemplateService *Template
|
||||
}
|
||||
|
||||
// Create creates a new page
|
||||
@@ -49,6 +50,13 @@ func (p *Page) Create(
|
||||
p.Logger.Errorw("failed to validate page", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
// validate template content if present
|
||||
if content, err := page.Content.Get(); err == nil {
|
||||
if err := p.TemplateService.ValidatePageTemplate(content.String()); err != nil {
|
||||
p.Logger.Errorw("failed to validate page template", "error", err)
|
||||
return nil, validate.WrapErrorWithField(errors.New("invalid template: "+err.Error()), "content")
|
||||
}
|
||||
}
|
||||
// check uniqueness
|
||||
name := page.Name.MustGet()
|
||||
isOK, err := repository.CheckNameIsUnique(
|
||||
@@ -253,6 +261,11 @@ func (p *Page) UpdateByID(
|
||||
current.Name.Set(v)
|
||||
}
|
||||
if v, err := page.Content.Get(); err == nil {
|
||||
// validate template content before updating
|
||||
if err := p.TemplateService.ValidatePageTemplate(v.String()); err != nil {
|
||||
p.Logger.Errorw("failed to validate page template", "error", err)
|
||||
return validate.WrapErrorWithField(errors.New("invalid template: "+err.Error()), "content")
|
||||
}
|
||||
current.Content.Set(v)
|
||||
}
|
||||
// update page
|
||||
|
||||
@@ -5,10 +5,10 @@ import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"html"
|
||||
"html/template"
|
||||
"io"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/phishingclub/phishingclub/errs"
|
||||
"github.com/phishingclub/phishingclub/model"
|
||||
"github.com/phishingclub/phishingclub/utils"
|
||||
"github.com/phishingclub/phishingclub/vo"
|
||||
"github.com/yeqown/go-qrcode/v2"
|
||||
)
|
||||
|
||||
@@ -57,20 +58,113 @@ func (t *Template) CreateMail(
|
||||
baseURL,
|
||||
campaignRecipient.ID.MustGet().String(),
|
||||
)
|
||||
// #nosec
|
||||
trackingPixelMarkup := template.HTML(trackingPixel)
|
||||
return t.newTemplateDataMap(
|
||||
idKey,
|
||||
baseURL,
|
||||
url,
|
||||
campaignRecipient.Recipient,
|
||||
trackingPixelPath,
|
||||
trackingPixelMarkup,
|
||||
trackingPixel,
|
||||
email,
|
||||
apiSender,
|
||||
)
|
||||
}
|
||||
|
||||
// ValidatePageTemplate validates that a page template can be parsed and executed without errors
|
||||
func (t *Template) ValidatePageTemplate(content string) error {
|
||||
_, err := template.New("validation").
|
||||
Funcs(TemplateFuncs()).
|
||||
Parse(content)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse page template: %s", err)
|
||||
}
|
||||
|
||||
// also try to execute with mock data to catch runtime errors
|
||||
_, err = t.ApplyPageMock(content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute page template: %s", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateEmailTemplate validates that an email template can be parsed and executed without errors
|
||||
func (t *Template) ValidateEmailTemplate(content string) error {
|
||||
_, err := template.New("validation").
|
||||
Funcs(TemplateFuncs()).
|
||||
Parse(content)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse email template: %s", err)
|
||||
}
|
||||
|
||||
// also try to execute with mock data to catch runtime errors
|
||||
domain := &model.Domain{
|
||||
Name: nullable.NewNullableWithValue(
|
||||
*vo.NewString255Must("example.test"),
|
||||
),
|
||||
}
|
||||
recipient := model.NewRecipientExample()
|
||||
campaignRecipient := model.CampaignRecipient{
|
||||
ID: nullable.NewNullableWithValue(
|
||||
uuid.New(),
|
||||
),
|
||||
Recipient: recipient,
|
||||
}
|
||||
email := model.NewEmailExample()
|
||||
email.Content = nullable.NewNullableWithValue(
|
||||
*vo.NewUnsafeOptionalString1MB(content),
|
||||
)
|
||||
apiSender := model.NewAPISenderExample()
|
||||
|
||||
_, err = t.CreateMailBody(
|
||||
"id",
|
||||
"/test",
|
||||
domain,
|
||||
&campaignRecipient,
|
||||
email,
|
||||
apiSender,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute email template: %s", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateDomainTemplate validates that a domain template can be parsed and executed without errors
|
||||
func (t *Template) ValidateDomainTemplate(content string) error {
|
||||
_, err := template.New("validation").
|
||||
Funcs(TemplateFuncs()).
|
||||
Parse(content)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse domain template: %s", err)
|
||||
}
|
||||
|
||||
// also try to execute with mock data to catch runtime errors
|
||||
// domains only have access to BaseURL variable
|
||||
data := map[string]any{
|
||||
"BaseURL": "https://example.test",
|
||||
}
|
||||
|
||||
tmpl, err := template.New("domain").
|
||||
Funcs(TemplateFuncs()).
|
||||
Parse(content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse domain template: %s", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = tmpl.Execute(&buf, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute domain template: %s", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplyPageMock
|
||||
func (t *Template) ApplyPageMock(content string) (*bytes.Buffer, error) {
|
||||
// build response
|
||||
@@ -123,7 +217,6 @@ func (t *Template) CreateMailBody(
|
||||
email,
|
||||
apiSender,
|
||||
)
|
||||
// parse and execute the mail content
|
||||
mailContentTemplate := template.New("mailContent")
|
||||
mailContentTemplate = mailContentTemplate.Funcs(TemplateFuncs())
|
||||
content, err := email.Content.Get()
|
||||
@@ -194,14 +287,14 @@ func (t *Template) CreatePhishingPage(
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// newTemplateDataMap creates a new data map for the templates
|
||||
// newTemplateDataMap creates a new data map for templates
|
||||
func (t *Template) newTemplateDataMap(
|
||||
id string,
|
||||
baseURL string,
|
||||
url string,
|
||||
recipient *model.Recipient,
|
||||
trackingPixelPath string,
|
||||
trackingPixelMarkup template.HTML,
|
||||
trackingPixelMarkup string,
|
||||
email *model.Email,
|
||||
apiSender *model.APISender,
|
||||
) *map[string]any {
|
||||
@@ -287,6 +380,38 @@ func (t *Template) newTemplateDataMap(
|
||||
return &m
|
||||
}
|
||||
|
||||
// TemplateFuncs returns template functions for templates
|
||||
func TemplateFuncs() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"urlEscape": func(s string) string {
|
||||
return template.URLQueryEscaper(s)
|
||||
},
|
||||
"htmlEscape": func(s string) string {
|
||||
return html.EscapeString(s)
|
||||
},
|
||||
"randInt": func(n1, n2 int) (int, error) {
|
||||
if n1 > n2 {
|
||||
return 0, fmt.Errorf("first number must be less than or equal to second number")
|
||||
}
|
||||
return rand.Intn(n2-n1+1) + n1, nil
|
||||
},
|
||||
"randAlpha": RandAlpha,
|
||||
"qr": GenerateQRCode,
|
||||
"date": func(format string, offsetSeconds ...int) string {
|
||||
offset := 0
|
||||
if len(offsetSeconds) > 0 {
|
||||
offset = offsetSeconds[0]
|
||||
}
|
||||
targetTime := time.Now().Add(time.Duration(offset) * time.Second)
|
||||
goFormat := convertDateFormat(format)
|
||||
return targetTime.Format(goFormat)
|
||||
},
|
||||
"base64": func(s string) string {
|
||||
return base64.StdEncoding.EncodeToString([]byte(s))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Template) AddTrackingPixel(content string) string {
|
||||
if strings.Contains(content, trackingPixelTemplate) {
|
||||
return content
|
||||
@@ -406,39 +531,7 @@ func (t *Template) RemoveTrackingPixelFromContent(content string) string {
|
||||
return strings.ReplaceAll(content, trackingPixelTemplate, "")
|
||||
}
|
||||
|
||||
func TemplateFuncs() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"urlEscape": func(s string) string {
|
||||
return template.URLQueryEscaper(s)
|
||||
},
|
||||
"htmlEscape": func(s string) string {
|
||||
return html.EscapeString(s)
|
||||
},
|
||||
"randInt": func(n1, n2 int) (int, error) {
|
||||
if n1 > n2 {
|
||||
return 0, fmt.Errorf("first number must be less than or equal to second number")
|
||||
}
|
||||
// #nosec
|
||||
return rand.Intn(n2-n1+1) + n1, nil
|
||||
},
|
||||
"randAlpha": RandAlpha,
|
||||
"qr": GenerateQRCode,
|
||||
"date": func(format string, offsetSeconds ...int) string {
|
||||
offset := 0
|
||||
if len(offsetSeconds) > 0 {
|
||||
offset = offsetSeconds[0]
|
||||
}
|
||||
targetTime := time.Now().Add(time.Duration(offset) * time.Second)
|
||||
goFormat := convertDateFormat(format)
|
||||
return targetTime.Format(goFormat)
|
||||
},
|
||||
"base64": func(s string) string {
|
||||
return base64.StdEncoding.EncodeToString([]byte(s))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func GenerateQRCode(args ...any) (template.HTML, error) {
|
||||
func GenerateQRCode(args ...any) (string, error) {
|
||||
if len(args) == 0 {
|
||||
return "", errors.New("URL is required")
|
||||
}
|
||||
@@ -465,8 +558,7 @@ func GenerateQRCode(args ...any) (template.HTML, error) {
|
||||
if err := qr.Save(writer); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// #nosec
|
||||
return template.HTML(buf.String()), nil
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
const alphaChar = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
Reported by,Date reported(UTC+02:00)
|
||||
alice@black-boat.test,2025-09-17T20:11:24
|
||||
|
+285
-2
@@ -45,9 +45,292 @@
|
||||
src: url('/Phudu-Black.ttf') format('truetype');
|
||||
}
|
||||
|
||||
/* custom properties for theming */
|
||||
:root {
|
||||
/* light mode colors */
|
||||
--color-bg-primary: #ffffff;
|
||||
--color-bg-secondary: #f8f9fa;
|
||||
--color-bg-tertiary: #f1f3f4;
|
||||
--color-border: #e5e7eb;
|
||||
--color-border-hover: #d1d5db;
|
||||
--color-text-primary: #111827;
|
||||
--color-text-secondary: #6b7280;
|
||||
--color-text-tertiary: #9ca3af;
|
||||
--color-shadow: rgba(0, 0, 0, 0.1);
|
||||
--color-shadow-lg: rgba(0, 0, 0, 0.15);
|
||||
|
||||
/* form colors */
|
||||
--color-input-bg: #ffffff;
|
||||
--color-input-border: #d1d5db;
|
||||
--color-input-border-focus: #2563eb;
|
||||
|
||||
/* scrollbar colors */
|
||||
--color-scrollbar-track: #f1f1f1;
|
||||
--color-scrollbar-thumb: #819efb;
|
||||
--color-scrollbar-thumb-hover: #6b85d6;
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* dark mode colors */
|
||||
--color-bg-primary: #111827;
|
||||
--color-bg-secondary: #1f2937;
|
||||
--color-bg-tertiary: #374151;
|
||||
--color-border: #374151;
|
||||
--color-border-hover: #4b5563;
|
||||
--color-text-primary: #f9fafb;
|
||||
--color-text-secondary: #d1d5db;
|
||||
--color-text-tertiary: #9ca3af;
|
||||
--color-shadow: rgba(0, 0, 0, 0.3);
|
||||
--color-shadow-lg: rgba(0, 0, 0, 0.4);
|
||||
|
||||
/* form colors */
|
||||
--color-input-bg: #374151;
|
||||
--color-input-border: #4b5563;
|
||||
--color-input-border-focus: #3b82f6;
|
||||
|
||||
/* scrollbar colors */
|
||||
--color-scrollbar-track: #374151;
|
||||
--color-scrollbar-thumb: #6b7280;
|
||||
--color-scrollbar-thumb-hover: #9ca3af;
|
||||
}
|
||||
|
||||
/* global styles */
|
||||
body {
|
||||
background-color: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
color 0.2s ease;
|
||||
}
|
||||
|
||||
/* scrollbar styles */
|
||||
body {
|
||||
@apply [&::-webkit-scrollbar]:w-2
|
||||
[&::-webkit-scrollbar-track]:bg-gray-100
|
||||
[&::-webkit-scrollbar-track]:bg-[var(--color-scrollbar-track)]
|
||||
[&::-webkit-scrollbar-thumb]:rounded-md
|
||||
[&::-webkit-scrollbar-thumb]:bg-pc-dusty-light-blue;
|
||||
[&::-webkit-scrollbar-thumb]:bg-[var(--color-scrollbar-thumb)]
|
||||
[&::-webkit-scrollbar-thumb:hover]:bg-[var(--color-scrollbar-thumb-hover)];
|
||||
}
|
||||
|
||||
/* dark mode utility classes */
|
||||
@layer utilities {
|
||||
.bg-theme-primary {
|
||||
background-color: var(--color-bg-primary);
|
||||
}
|
||||
|
||||
.bg-theme-secondary {
|
||||
background-color: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.bg-theme-tertiary {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.text-theme-primary {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.text-theme-secondary {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.text-theme-tertiary {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.border-theme {
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.border-theme-hover:hover {
|
||||
border-color: var(--color-border-hover);
|
||||
}
|
||||
|
||||
.shadow-theme {
|
||||
box-shadow: 0 1px 3px 0 var(--color-shadow);
|
||||
}
|
||||
|
||||
.shadow-theme-lg {
|
||||
box-shadow: 0 10px 15px -3px var(--color-shadow-lg);
|
||||
}
|
||||
|
||||
.input-theme {
|
||||
background-color: var(--color-input-bg);
|
||||
border-color: var(--color-input-border);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.input-theme:focus {
|
||||
border-color: var(--color-input-border-focus);
|
||||
}
|
||||
}
|
||||
|
||||
/* component-specific dark mode overrides */
|
||||
.dark .bg-gray-50 {
|
||||
@apply bg-gray-800;
|
||||
}
|
||||
|
||||
.dark .bg-gray-100 {
|
||||
@apply bg-gray-700;
|
||||
}
|
||||
|
||||
.dark .bg-gray-200 {
|
||||
@apply bg-gray-600;
|
||||
}
|
||||
|
||||
.dark .bg-white {
|
||||
@apply bg-gray-800;
|
||||
}
|
||||
|
||||
.dark .text-gray-900 {
|
||||
@apply text-gray-100;
|
||||
}
|
||||
|
||||
.dark .text-gray-800 {
|
||||
@apply text-gray-200;
|
||||
}
|
||||
|
||||
.dark .text-gray-700 {
|
||||
@apply text-gray-300;
|
||||
}
|
||||
|
||||
.dark .text-gray-600 {
|
||||
@apply text-gray-400;
|
||||
}
|
||||
|
||||
.dark .text-black {
|
||||
@apply text-white;
|
||||
}
|
||||
|
||||
.dark .border-gray-200 {
|
||||
@apply border-gray-600;
|
||||
}
|
||||
|
||||
.dark .border-gray-300 {
|
||||
@apply border-gray-500;
|
||||
}
|
||||
|
||||
/* table dark mode styles */
|
||||
.dark table {
|
||||
@apply bg-gray-800;
|
||||
}
|
||||
|
||||
.dark table td {
|
||||
@apply bg-transparent text-gray-300 border-gray-600;
|
||||
}
|
||||
|
||||
/* form elements dark mode */
|
||||
.dark input,
|
||||
.dark textarea,
|
||||
.dark select {
|
||||
@apply bg-gray-700 border-gray-600 text-white placeholder-gray-400;
|
||||
}
|
||||
|
||||
.dark input:focus,
|
||||
.dark textarea:focus,
|
||||
.dark select:focus {
|
||||
@apply border-blue-500 ring-blue-500;
|
||||
}
|
||||
|
||||
/* modal and card dark mode */
|
||||
.dark .modal-content,
|
||||
.dark .card {
|
||||
@apply bg-gray-800 border-gray-600;
|
||||
}
|
||||
|
||||
/* button hover effects in dark mode */
|
||||
.dark .bg-cta-blue:hover {
|
||||
@apply bg-blue-600;
|
||||
}
|
||||
|
||||
.dark .bg-pc-darkblue:hover {
|
||||
@apply bg-blue-900;
|
||||
}
|
||||
|
||||
/* custom scrollbar for dark mode containers */
|
||||
.dark .overflow-auto,
|
||||
.dark .overflow-x-auto,
|
||||
.dark .overflow-y-auto {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #6b7280 #374151;
|
||||
}
|
||||
|
||||
.dark .overflow-auto::-webkit-scrollbar,
|
||||
.dark .overflow-x-auto::-webkit-scrollbar,
|
||||
.dark .overflow-y-auto::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.dark .overflow-auto::-webkit-scrollbar-track,
|
||||
.dark .overflow-x-auto::-webkit-scrollbar-track,
|
||||
.dark .overflow-y-auto::-webkit-scrollbar-track {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
.dark .overflow-auto::-webkit-scrollbar-thumb,
|
||||
.dark .overflow-x-auto::-webkit-scrollbar-thumb,
|
||||
.dark .overflow-y-auto::-webkit-scrollbar-thumb {
|
||||
background: #6b7280;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.dark .overflow-auto::-webkit-scrollbar-thumb:hover,
|
||||
.dark .overflow-x-auto::-webkit-scrollbar-thumb:hover,
|
||||
.dark .overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||
background: #9ca3af;
|
||||
}
|
||||
|
||||
/* native date picker dark mode styling */
|
||||
.dark input[type='date']::-webkit-calendar-picker-indicator,
|
||||
.dark input[type='time']::-webkit-calendar-picker-indicator,
|
||||
.dark input[type='datetime-local']::-webkit-calendar-picker-indicator {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.dark input[type='date']::-webkit-datetime-edit,
|
||||
.dark input[type='time']::-webkit-datetime-edit,
|
||||
.dark input[type='datetime-local']::-webkit-datetime-edit {
|
||||
color: rgb(209 213 219);
|
||||
}
|
||||
|
||||
.dark input[type='date']::-webkit-datetime-edit-fields-wrapper,
|
||||
.dark input[type='time']::-webkit-datetime-edit-fields-wrapper,
|
||||
.dark input[type='datetime-local']::-webkit-datetime-edit-fields-wrapper {
|
||||
background: rgb(55 65 81);
|
||||
}
|
||||
|
||||
/* force date picker to use system dark theme */
|
||||
.dark input[type='date'],
|
||||
.dark input[type='time'],
|
||||
.dark input[type='datetime-local'] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* selected date background in dark mode */
|
||||
.dark input[type='date']::-webkit-datetime-edit-day-field:focus,
|
||||
.dark input[type='date']::-webkit-datetime-edit-month-field:focus,
|
||||
.dark input[type='date']::-webkit-datetime-edit-year-field:focus,
|
||||
.dark input[type='time']::-webkit-datetime-edit-hour-field:focus,
|
||||
.dark input[type='time']::-webkit-datetime-edit-minute-field:focus {
|
||||
background-color: rgb(55 65 81);
|
||||
color: rgb(209 213 219);
|
||||
}
|
||||
|
||||
/* transitions for smooth theme switching */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
color 0.2s ease;
|
||||
}
|
||||
|
||||
/* override transitions for elements that shouldn't animate */
|
||||
.no-transition,
|
||||
.no-transition *,
|
||||
.no-transition *::before,
|
||||
.no-transition *::after {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
@@ -234,6 +234,43 @@ export class API {
|
||||
*/
|
||||
runUpdate: async () => {
|
||||
return await postJSON(this.getPath(`/update`));
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a backup
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
createBackup: async () => {
|
||||
return await postJSON(this.getPath(`/backup/create`));
|
||||
},
|
||||
|
||||
/**
|
||||
* List available backups
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
listBackups: async () => {
|
||||
return await getJSON(this.getPath(`/backup/list`));
|
||||
},
|
||||
|
||||
/**
|
||||
* Download a backup file
|
||||
* @param {string} filename - name of the backup file
|
||||
* @returns {Promise<Blob>}
|
||||
*/
|
||||
downloadBackup: async (filename) => {
|
||||
const response = await fetch(
|
||||
this.getPath(`/backup/download/${encodeURIComponent(filename)}`),
|
||||
{
|
||||
method: 'GET',
|
||||
credentials: 'same-origin'
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download backup: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.blob();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -689,6 +726,26 @@ export class API {
|
||||
return await postJSON(this.getPath(`/campaign/recipient/${campaignRecipient}/sent`));
|
||||
},
|
||||
|
||||
/**
|
||||
* Send message to campaign recipient (works for both email and API senders)
|
||||
*
|
||||
* @param {string} campaignRecipientID
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
sendMessage: async (campaignRecipientID) => {
|
||||
return await postJSON(this.getPath(`/campaign/recipient/${campaignRecipientID}/send`));
|
||||
},
|
||||
|
||||
/**
|
||||
* Send email to campaign recipient (alias for sendMessage for backward compatibility)
|
||||
*
|
||||
* @param {string} campaignRecipientID
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
sendEmail: async (campaignRecipientID) => {
|
||||
return await postJSON(this.getPath(`/campaign/recipient/${campaignRecipientID}/send`));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get campaign recipient landingpage URL.
|
||||
*
|
||||
|
||||
@@ -243,7 +243,9 @@
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<div class="fixed top-0 left-0 w-full h-full bg-cta-blue opacity-20 blur-xl" />
|
||||
<div
|
||||
class="fixed top-0 left-0 w-full h-full bg-cta-blue dark:bg-gray-900 opacity-20 blur-xl transition-colors duration-200"
|
||||
/>
|
||||
<div
|
||||
class="fixed top-0 left-0 w-full h-full flex justify-center items-center backdrop-blur-sm z-20"
|
||||
role="dialog"
|
||||
@@ -253,11 +255,11 @@
|
||||
>
|
||||
<section
|
||||
bind:this={alertElement}
|
||||
class="shadow-xl w-[32rem] bg-white opacity-100 rounded-md flex flex-col"
|
||||
class="shadow-xl dark:shadow-gray-900/70 w-[32rem] bg-white dark:bg-gray-800 opacity-100 rounded-md flex flex-col transition-colors duration-200"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="bg-red-700 text-white rounded-t-md py-4 px-8 flex items-center justify-between flex-shrink-0"
|
||||
class="bg-red-700 dark:bg-red-800 text-white rounded-t-md py-4 px-8 flex items-center justify-between flex-shrink-0 transition-colors duration-200"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<svg
|
||||
@@ -289,7 +291,10 @@
|
||||
|
||||
<!-- Content -->
|
||||
<div class="px-8 py-6">
|
||||
<div id="alert-description" class="text-gray-600">
|
||||
<div
|
||||
id="alert-description"
|
||||
class="text-gray-600 dark:text-gray-300 transition-colors duration-200"
|
||||
>
|
||||
{#if $$slots.default}
|
||||
<slot />
|
||||
{:else}
|
||||
@@ -312,13 +317,13 @@
|
||||
|
||||
<!-- Footer -->
|
||||
<div
|
||||
class="py-4 row-span-2 col-start-1 col-span-3 border-t-2 w-full flex flex-row justify-center items-center sm:justify-center md:justify-center lg:justify-end xl:justify-end 2xl:justify-end px-8 bg-gray-50 rounded-b-md"
|
||||
class="py-4 row-span-2 col-start-1 col-span-3 border-t-2 border-gray-200 dark:border-gray-600 w-full flex flex-row justify-center items-center sm:justify-center md:justify-center lg:justify-end xl:justify-end 2xl:justify-end px-8 bg-gray-50 dark:bg-gray-700 rounded-b-md transition-colors duration-200"
|
||||
>
|
||||
{#if !noCancel}
|
||||
<button
|
||||
type="reset"
|
||||
on:click={close}
|
||||
class="bg-slate-400 hover:bg-slate-300 text-sm mr-2 uppercase font-bold px-4 py-2 text-white rounded-md"
|
||||
class="bg-slate-400 hover:bg-slate-300 dark:bg-gray-600 dark:hover:bg-gray-500 text-sm mr-2 uppercase font-bold px-4 py-2 text-white rounded-md transition-colors duration-200"
|
||||
>
|
||||
{cancel}
|
||||
</button>
|
||||
|
||||
@@ -66,15 +66,20 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative h-2">
|
||||
<TextFieldSelect
|
||||
id="autoRefresh"
|
||||
value={$autoRefreshStore.enabled
|
||||
? options.byValue($autoRefreshStore.interval.toString())
|
||||
: 'Disabled'}
|
||||
onSelect={handleIntervalChange}
|
||||
options={options.keys()}
|
||||
inline={true}
|
||||
size={'small'}>Auto-Refresh</TextFieldSelect
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold text-slate-600 dark:text-gray-300 py-2 transition-colors duration-200"
|
||||
>Auto-Refresh</span
|
||||
>
|
||||
<div class="relative">
|
||||
<TextFieldSelect
|
||||
id="autoRefresh"
|
||||
value={$autoRefreshStore.enabled
|
||||
? options.byValue($autoRefreshStore.interval.toString())
|
||||
: 'Disabled'}
|
||||
onSelect={handleIntervalChange}
|
||||
options={options.keys()}
|
||||
inline={true}
|
||||
size={'small'}
|
||||
></TextFieldSelect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
|
||||
<button
|
||||
{disabled}
|
||||
class="self-start mt-6 bg-gradient-to-b from-blue-500 to-indigo-400 px-4 w-56 py-2 hover:from-blue-400 hover:to-indigo-400 text-white font-bold uppercase rounded-md mb-10"
|
||||
class="self-start mt-6 bg-gradient-to-b from-blue-500 to-indigo-400 dark:from-blue-600 dark:to-indigo-500 px-4 w-56 py-2 hover:from-blue-400 hover:to-indigo-400 dark:hover:from-blue-500 dark:hover:to-indigo-400 text-white font-bold uppercase rounded-md mb-10 transition-all duration-200"
|
||||
on:click
|
||||
class:opacity-50={disabled}
|
||||
class:dark:opacity-60={disabled}
|
||||
class:cursor-not-allowed={disabled}
|
||||
{type}
|
||||
>
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
<script>
|
||||
export let backgroundColor = 'bg-cta-blue';
|
||||
/*
|
||||
/*
|
||||
linear-gradient(#64a6e6, #9198e5);
|
||||
*/
|
||||
export let css = '';
|
||||
export let size = 'medium';
|
||||
export let disabled = false;
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="{backgroundColor} px-6 py-2 text-white rounded-md uppercase font-semibold hover:opacity-80 text-sm {css}"
|
||||
class="{backgroundColor} px-6 py-2 text-white rounded-md uppercase font-semibold hover:opacity-80 text-sm transition-all duration-200 dark:hover:opacity-90 {css}"
|
||||
class:w-32={size === 'small'}
|
||||
class:w-36={size === 'medium'}
|
||||
class:w-60={size === 'large'}
|
||||
class:disabled:opacity-50={disabled}
|
||||
class:dark:disabled:opacity-60={disabled}
|
||||
{disabled}
|
||||
on:click
|
||||
>
|
||||
<slot />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
export let disabled = false;
|
||||
/**
|
||||
* @type {"submit" | "button" | "reset"}
|
||||
|
||||
|
||||
*/
|
||||
export let type = 'submit';
|
||||
</script>
|
||||
@@ -11,7 +11,7 @@
|
||||
<button
|
||||
{disabled}
|
||||
{type}
|
||||
class="w-full p-2 text-white text-2xl bg-cta-blue rounded-lg font-phudu font-bold"
|
||||
class="w-full p-2 text-white text-2xl bg-cta-blue dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-700 rounded-lg font-phudu font-bold transition-colors duration-200 disabled:opacity-50 dark:disabled:opacity-60"
|
||||
>Login</button
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -41,11 +41,12 @@
|
||||
|
||||
let isGeneratingCalendar = false;
|
||||
|
||||
// Use consistent colors that match the design system - these work well in both light and dark modes
|
||||
const COLORS = {
|
||||
SCHEDULED: '#62aded',
|
||||
ACTIVE: '#5557f6',
|
||||
COMPLETED: '#69e1ab',
|
||||
SELF_MANAGED: '#9F7AEA'
|
||||
SCHEDULED: '#62aded', // campaign-scheduled
|
||||
ACTIVE: '#5557f6', // campaign-active
|
||||
COMPLETED: '#4cb5b5', // message-read - much more muted than bright green
|
||||
SELF_MANAGED: '#9F7AEA' // purple
|
||||
};
|
||||
|
||||
function sortCampaignsByPriority(campaigns, day) {
|
||||
@@ -306,18 +307,20 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="w-full bg-white rounded-lg shadow-sm p-4 border border-gray-200">
|
||||
<div
|
||||
class="w-full bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4 border border-gray-200 dark:border-gray-700 transition-colors duration-200"
|
||||
>
|
||||
<div class="space-y-4 max-w-5xl mx-auto min-h-[600px]">
|
||||
<!-- Navigation Controls -->
|
||||
<div class="flex justify-center items-center">
|
||||
<button
|
||||
class="p-2 rounded hover:bg-gray-100 mx-4"
|
||||
class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-700 mx-4 transition-colors duration-200"
|
||||
on:click={previousMonth}
|
||||
disabled={isLoadingNewMonth}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 text-gray-600"
|
||||
class="h-5 w-5 text-gray-600 dark:text-gray-300"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -331,18 +334,18 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<h2 class="text-lg font-semibold">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{format(currentMonth, 'MMMM yyyy')}
|
||||
</h2>
|
||||
|
||||
<button
|
||||
class="p-2 rounded hover:bg-gray-100 mx-4"
|
||||
class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-700 mx-4 transition-colors duration-200"
|
||||
on:click={nextMonth}
|
||||
disabled={isLoadingNewMonth}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 text-gray-600"
|
||||
class="h-5 w-5 text-gray-600 dark:text-gray-300"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -356,7 +359,7 @@
|
||||
<div class="flex justify-center flex-wrap gap-4 text-sm">
|
||||
{#each [{ key: 'SCHEDULED', color: COLORS.SCHEDULED, label: 'Scheduled' }, { key: 'ACTIVE', color: COLORS.ACTIVE, label: 'Active' }, { key: 'COMPLETED', color: COLORS.COMPLETED, label: 'Completed' }, { key: 'SELF_MANAGED', color: COLORS.SELF_MANAGED, label: 'Self-managed' }] as item}
|
||||
<button
|
||||
class="flex items-center cursor-pointer select-none"
|
||||
class="flex items-center cursor-pointer select-none hover:opacity-80 transition-opacity duration-200"
|
||||
on:click={() => toggleFilter(item.key)}
|
||||
>
|
||||
<div
|
||||
@@ -364,7 +367,7 @@
|
||||
style="background-color: {item.color}; opacity: {activeFilters[item.key] ? '1' : '0.3'}"
|
||||
></div>
|
||||
<span
|
||||
class="transition-opacity duration-200"
|
||||
class="transition-opacity duration-200 text-gray-700 dark:text-gray-300"
|
||||
style="opacity: {activeFilters[item.key] ? '1' : '0.5'}">{item.label}</span
|
||||
>
|
||||
</button>
|
||||
@@ -376,7 +379,7 @@
|
||||
<!-- Day headers -->
|
||||
<div class="grid grid-cols-7 text-center mb-1">
|
||||
{#each ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as day}
|
||||
<div class="text-sm font-medium text-gray-600">{day}</div>
|
||||
<div class="text-sm font-medium text-gray-600 dark:text-gray-300">{day}</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -384,8 +387,10 @@
|
||||
{#if !isInitialized || isGeneratingCalendar || isLoadingNewMonth}
|
||||
<div class="min-h-[450px] flex items-center justify-center">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
||||
<span class="text-gray-600">Loading calendar...</span>
|
||||
<div
|
||||
class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 dark:border-blue-400"
|
||||
></div>
|
||||
<span class="text-gray-600 dark:text-gray-300">Loading calendar...</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -395,17 +400,19 @@
|
||||
{#each week as day}
|
||||
<div
|
||||
class="calendar-day relative border rounded-md overflow-hidden {scrollBarClassesHorizontal} {day.isToday
|
||||
? 'border-gray-700 bg-gray-50'
|
||||
: 'border-gray-200'}
|
||||
{day.isCurrentMonth ? 'bg-white' : 'bg-gray-50'}"
|
||||
? 'border-gray-700 dark:border-gray-400 bg-gray-50 dark:bg-gray-700'
|
||||
: 'border-gray-200 dark:border-gray-600'}
|
||||
{day.isCurrentMonth
|
||||
? 'bg-white dark:bg-gray-800'
|
||||
: 'bg-gray-50 dark:bg-gray-700'} transition-colors duration-200"
|
||||
>
|
||||
<!-- Date number -->
|
||||
<div
|
||||
class="text-sm p-1 {day.isToday
|
||||
? 'font-bold text-gray-800'
|
||||
? 'font-bold text-gray-800 dark:text-white'
|
||||
: day.isCurrentMonth
|
||||
? 'text-gray-600'
|
||||
: 'text-gray-400'}"
|
||||
? 'text-gray-600 dark:text-gray-300'
|
||||
: 'text-gray-400 dark:text-gray-500'}"
|
||||
>
|
||||
{day.date.getDate()}
|
||||
</div>
|
||||
|
||||
@@ -58,8 +58,9 @@
|
||||
}
|
||||
|
||||
let chartContainer;
|
||||
let sizingContainer;
|
||||
let width = 300;
|
||||
let height = 200; // Balanced height to prevent overflow
|
||||
let height = 240; // Increased height for better label spacing
|
||||
let containerReady = false;
|
||||
|
||||
// User controls for N
|
||||
@@ -85,15 +86,17 @@
|
||||
openRate: true,
|
||||
clickRate: true,
|
||||
submissionRate: true,
|
||||
reportRate: true,
|
||||
'mavg-clickRate': true,
|
||||
'mavg-submissionRate': true
|
||||
'mavg-submissionRate': true,
|
||||
'mavg-reportRate': true
|
||||
};
|
||||
|
||||
// Responsive margins based on container width
|
||||
$: margin = {
|
||||
top: 15,
|
||||
right: Math.min(180, Math.max(160, width * 0.28)), // 28% of width, min 160px, max 180px
|
||||
bottom: 30,
|
||||
bottom: 50, // Increased bottom margin for better label spacing
|
||||
left: 50
|
||||
};
|
||||
|
||||
@@ -138,7 +141,8 @@
|
||||
const metrics = [
|
||||
{ key: 'openRate', label: 'Read Rate', color: '#4cb5b5', suffix: '%' },
|
||||
{ key: 'clickRate', label: 'Click Rate', color: '#f96dcf', suffix: '%' },
|
||||
{ key: 'submissionRate', label: 'Submission Rate', color: '#f42e41', suffix: '%' }
|
||||
{ key: 'submissionRate', label: 'Submission Rate', color: '#f42e41', suffix: '%' },
|
||||
{ key: 'reportRate', label: 'Report Rate', color: '#1e40af', suffix: '%' }
|
||||
];
|
||||
|
||||
// Toggle metric visibility
|
||||
@@ -159,7 +163,8 @@
|
||||
n,
|
||||
openRate: avg(slice, 'openRate'),
|
||||
clickRate: avg(slice, 'clickRate'),
|
||||
submissionRate: avg(slice, 'submissionRate')
|
||||
submissionRate: avg(slice, 'submissionRate'),
|
||||
reportRate: avg(slice, 'reportRate')
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -205,6 +210,7 @@
|
||||
clickRate: Math.round((stat.clickRate || 0) * (stat.clickRate > 1 ? 1 : 100) * 10) / 10,
|
||||
submissionRate:
|
||||
Math.round((stat.submissionRate || 0) * (stat.submissionRate > 1 ? 1 : 100) * 10) / 10,
|
||||
reportRate: Math.round((stat.reportRate || 0) * (stat.reportRate > 1 ? 1 : 100) * 10) / 10,
|
||||
totalRecipients: stat.totalRecipients
|
||||
}));
|
||||
}
|
||||
@@ -247,8 +253,8 @@
|
||||
createLine(svg, metric);
|
||||
}
|
||||
});
|
||||
// Only draw moving average for clickRate and submissionRate, using user-selected N
|
||||
['clickRate', 'submissionRate'].forEach((metricKey) => {
|
||||
// Only draw moving average for clickRate, submissionRate, and reportRate, using user-selected N
|
||||
['clickRate', 'submissionRate', 'reportRate'].forEach((metricKey) => {
|
||||
const metric = metrics.find((m) => m.key === metricKey);
|
||||
if (metric && visibleMetrics[`mavg-${metricKey}`]) {
|
||||
createMovingAverageLine(svg, metric, movingAvgN);
|
||||
@@ -309,6 +315,8 @@
|
||||
avgColor = '#93c5fd'; // light blue
|
||||
} else if (metric.key === 'submissionRate') {
|
||||
avgColor = '#ff6a91'; // lighter red, closer to #f42e41
|
||||
} else if (metric.key === 'reportRate') {
|
||||
avgColor = '#60a5fa'; // lighter blue for report rate
|
||||
}
|
||||
path.setAttribute('stroke', avgColor);
|
||||
path.setAttribute('stroke-width', '1.2');
|
||||
@@ -336,17 +344,26 @@
|
||||
|
||||
const stop1 = document.createElementNS('http://www.w3.org/2000/svg', 'stop');
|
||||
stop1.setAttribute('offset', '0%');
|
||||
stop1.setAttribute('stop-color', '#f8f9fa');
|
||||
stop1.setAttribute(
|
||||
'stop-color',
|
||||
document.documentElement.classList.contains('dark') ? '#374151' : '#f8f9fa'
|
||||
);
|
||||
stop1.setAttribute('stop-opacity', '0.3');
|
||||
|
||||
const stop2 = document.createElementNS('http://www.w3.org/2000/svg', 'stop');
|
||||
stop2.setAttribute('offset', '50%');
|
||||
stop2.setAttribute('stop-color', '#e9ecef');
|
||||
stop2.setAttribute(
|
||||
'stop-color',
|
||||
document.documentElement.classList.contains('dark') ? '#4b5563' : '#e9ecef'
|
||||
);
|
||||
stop2.setAttribute('stop-opacity', '0.5');
|
||||
|
||||
const stop3 = document.createElementNS('http://www.w3.org/2000/svg', 'stop');
|
||||
stop3.setAttribute('offset', '100%');
|
||||
stop3.setAttribute('stop-color', '#f8f9fa');
|
||||
stop3.setAttribute(
|
||||
'stop-color',
|
||||
document.documentElement.classList.contains('dark') ? '#374151' : '#f8f9fa'
|
||||
);
|
||||
stop3.setAttribute('stop-opacity', '0.3');
|
||||
|
||||
gradient.appendChild(stop1);
|
||||
@@ -364,7 +381,10 @@
|
||||
line.setAttribute('x2', (width - margin.right).toString());
|
||||
line.setAttribute('y1', y.toString());
|
||||
line.setAttribute('y2', y.toString());
|
||||
line.setAttribute('stroke', '#E5E7EB');
|
||||
line.setAttribute(
|
||||
'stroke',
|
||||
document.documentElement.classList.contains('dark') ? '#4b5563' : '#E5E7EB'
|
||||
);
|
||||
line.setAttribute('stroke-width', '1');
|
||||
line.setAttribute('opacity', '0.4');
|
||||
svg.appendChild(line);
|
||||
@@ -378,7 +398,10 @@
|
||||
vLine.setAttribute('x2', x.toString());
|
||||
vLine.setAttribute('y1', margin.top.toString());
|
||||
vLine.setAttribute('y2', (height - margin.bottom).toString());
|
||||
vLine.setAttribute('stroke', '#e5e7eb');
|
||||
vLine.setAttribute(
|
||||
'stroke',
|
||||
document.documentElement.classList.contains('dark') ? '#4b5563' : '#e5e7eb'
|
||||
);
|
||||
vLine.setAttribute('stroke-width', '0.5');
|
||||
vLine.setAttribute('opacity', '0.3');
|
||||
svg.appendChild(vLine);
|
||||
@@ -391,7 +414,10 @@
|
||||
yAxis.setAttribute('x2', margin.left.toString());
|
||||
yAxis.setAttribute('y1', margin.top.toString());
|
||||
yAxis.setAttribute('y2', (height - margin.bottom).toString());
|
||||
yAxis.setAttribute('stroke', '#6B7280');
|
||||
yAxis.setAttribute(
|
||||
'stroke',
|
||||
document.documentElement.classList.contains('dark') ? '#9ca3af' : '#6B7280'
|
||||
);
|
||||
yAxis.setAttribute('stroke-width', '2');
|
||||
svg.appendChild(yAxis);
|
||||
|
||||
@@ -400,7 +426,10 @@
|
||||
xAxis.setAttribute('x2', (width - margin.right).toString());
|
||||
xAxis.setAttribute('y1', (height - margin.bottom).toString());
|
||||
xAxis.setAttribute('y2', (height - margin.bottom).toString());
|
||||
xAxis.setAttribute('stroke', '#6B7280');
|
||||
xAxis.setAttribute(
|
||||
'stroke',
|
||||
document.documentElement.classList.contains('dark') ? '#9ca3af' : '#6B7280'
|
||||
);
|
||||
xAxis.setAttribute('stroke-width', '2');
|
||||
svg.appendChild(xAxis);
|
||||
|
||||
@@ -412,7 +441,10 @@
|
||||
text.setAttribute('y', (y - 2).toString());
|
||||
text.setAttribute('text-anchor', 'end');
|
||||
text.setAttribute('font-size', '11');
|
||||
text.setAttribute('fill', '#6B7280');
|
||||
text.setAttribute(
|
||||
'fill',
|
||||
document.documentElement.classList.contains('dark') ? '#d1d5db' : '#6B7280'
|
||||
);
|
||||
text.setAttribute('font-weight', '500');
|
||||
text.setAttribute('alignment-baseline', 'middle');
|
||||
text.textContent = `${value}%`;
|
||||
@@ -424,10 +456,13 @@
|
||||
const x = xScale(i);
|
||||
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
text.setAttribute('x', x.toString());
|
||||
text.setAttribute('y', (height - margin.bottom + 18).toString());
|
||||
text.setAttribute('y', (height - margin.bottom + 25).toString());
|
||||
text.setAttribute('text-anchor', 'middle');
|
||||
text.setAttribute('font-size', '11');
|
||||
text.setAttribute('fill', '#000');
|
||||
text.setAttribute(
|
||||
'fill',
|
||||
document.documentElement.classList.contains('dark') ? '#f9fafb' : '#000'
|
||||
);
|
||||
text.setAttribute('alignment-baseline', 'middle');
|
||||
// Show date (YYYY/MM) and campaign name on the same line
|
||||
const labelSpan = document.createElementNS('http://www.w3.org/2000/svg', 'tspan');
|
||||
@@ -526,9 +561,9 @@
|
||||
}
|
||||
|
||||
function createLegend(svg, svgRoot) {
|
||||
const legendY = margin.top - 5; // Move legend higher
|
||||
const legendY = margin.top + 5; // Align with chart top area
|
||||
const legendX = width - margin.right + 10;
|
||||
const legendSpacing = 12; // Reduce spacing between legend items
|
||||
const legendSpacing = 18; // More spacing between legend items
|
||||
|
||||
// Build a flat list of legend items (main and moving averages)
|
||||
const legendItems = [];
|
||||
@@ -545,18 +580,28 @@
|
||||
strokeDasharray: null,
|
||||
opacity: 1
|
||||
});
|
||||
if (metric.key === 'clickRate' || metric.key === 'submissionRate') {
|
||||
if (
|
||||
metric.key === 'clickRate' ||
|
||||
metric.key === 'submissionRate' ||
|
||||
metric.key === 'reportRate'
|
||||
) {
|
||||
// Use a lighter version of the main color for moving averages
|
||||
let avgColor = metric.color;
|
||||
let avgLabel = '';
|
||||
if (metric.key === 'clickRate') {
|
||||
avgColor = '#eea5fa'; // before-page-visited, lighter pink
|
||||
avgLabel = 'Click MA';
|
||||
} else if (metric.key === 'submissionRate') {
|
||||
avgColor = '#ff6a91'; // lighter red, closer to #f42e41
|
||||
avgLabel = 'Submit MA';
|
||||
} else if (metric.key === 'reportRate') {
|
||||
avgColor = '#60a5fa'; // lighter blue for report rate
|
||||
avgLabel = 'Report MA';
|
||||
}
|
||||
legendItems.push({
|
||||
type: 'mavg',
|
||||
key: metric.key,
|
||||
label: metric.key === 'clickRate' ? 'Click MA' : 'Submit MA',
|
||||
label: avgLabel,
|
||||
color: avgColor,
|
||||
class: `legend-line legend-mavg legend-mavg-${metric.key}`,
|
||||
labelClass: `legend-label legend-mavg legend-mavg-${metric.key}`,
|
||||
@@ -666,8 +711,14 @@
|
||||
|
||||
const tooltipRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||
tooltipRect.setAttribute('rx', '4');
|
||||
tooltipRect.setAttribute('fill', '#1F2937');
|
||||
tooltipRect.setAttribute('stroke', '#374151');
|
||||
tooltipRect.setAttribute(
|
||||
'fill',
|
||||
document.documentElement.classList.contains('dark') ? '#111827' : '#1F2937'
|
||||
);
|
||||
tooltipRect.setAttribute(
|
||||
'stroke',
|
||||
document.documentElement.classList.contains('dark') ? '#374151' : '#4b5563'
|
||||
);
|
||||
tooltipRect.setAttribute('opacity', '0.95');
|
||||
|
||||
const tooltipText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
@@ -760,9 +811,9 @@
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (chartContainer) {
|
||||
await tick(); // Wait for DOM/layout
|
||||
const containerWidth = chartContainer.clientWidth || 0;
|
||||
await tick(); // Wait for DOM/layout
|
||||
if (sizingContainer) {
|
||||
const containerWidth = sizingContainer.parentElement?.clientWidth || 0;
|
||||
width = Math.min(Math.max(containerWidth, 300), containerWidth); // Minimum 300px but never exceed container
|
||||
if (width > 0) containerReady = true;
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
@@ -774,13 +825,13 @@
|
||||
}
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(chartContainer);
|
||||
resizeObserver.observe(sizingContainer.parentElement || sizingContainer);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (resizeObserver && chartContainer) {
|
||||
resizeObserver.unobserve(chartContainer);
|
||||
if (resizeObserver && sizingContainer) {
|
||||
resizeObserver.unobserve(sizingContainer.parentElement || sizingContainer);
|
||||
}
|
||||
if (loadingTimeout) {
|
||||
clearTimeout(loadingTimeout);
|
||||
@@ -797,9 +848,9 @@
|
||||
</script>
|
||||
|
||||
<div class="w-full box-border" style="contain: layout style;">
|
||||
<!-- Always render a hidden chartContainer for ResizeObserver -->
|
||||
<!-- Hidden sizing element for ResizeObserver -->
|
||||
<div
|
||||
bind:this={chartContainer}
|
||||
bind:this={sizingContainer}
|
||||
class="chart-container w-full overflow-x-auto"
|
||||
style="height:0;overflow:hidden;visibility:hidden;position:absolute;"
|
||||
></div>
|
||||
@@ -811,18 +862,26 @@
|
||||
<div>
|
||||
{#if debouncedIsLoading}
|
||||
<div class="flex items-center justify-center h-64">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<span class="ml-2 text-gray-600">Loading trend data...</span>
|
||||
<div
|
||||
class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 dark:border-blue-400"
|
||||
></div>
|
||||
<span class="ml-2 text-gray-600 dark:text-gray-300 transition-colors duration-200"
|
||||
>Loading trend data...</span
|
||||
>
|
||||
</div>
|
||||
{:else if !hasAttemptedLoad && debouncedShowPending}
|
||||
<div class="flex items-center justify-center h-64">
|
||||
<span class="text-gray-400 text-sm">Preparing trend data...</span>
|
||||
<span class="text-gray-400 dark:text-gray-500 text-sm transition-colors duration-200"
|
||||
>Preparing trend data...</span
|
||||
>
|
||||
</div>
|
||||
{:else if hasAttemptedLoad && !isLoading && !debouncedIsLoading && chartData.length === 0}
|
||||
<div class="flex items-center justify-center h-64 bg-gray-50 rounded-lg">
|
||||
<div
|
||||
class="flex items-center justify-center h-64 bg-gray-50 dark:bg-gray-800 rounded-lg transition-colors duration-200"
|
||||
>
|
||||
<div class="text-center">
|
||||
<svg
|
||||
class="mx-auto h-12 w-12 text-gray-400"
|
||||
class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500 transition-colors duration-200"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -834,17 +893,23 @@
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No campaign data</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
<h3
|
||||
class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-200 transition-colors duration-200"
|
||||
>
|
||||
No campaign data
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400 transition-colors duration-200">
|
||||
Campaign statistics will appear here once campaigns are completed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if hasAttemptedLoad && !isLoading && !debouncedIsLoading && chartData.length === 1}
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-6">
|
||||
<div
|
||||
class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6 transition-colors duration-200"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<svg
|
||||
class="h-6 w-6 text-blue-600 mr-2"
|
||||
class="h-6 w-6 text-blue-600 dark:text-blue-400 mr-2 transition-colors duration-200"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -857,40 +922,78 @@
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-blue-900">Single Campaign Data</h4>
|
||||
<p class="text-sm text-blue-700">
|
||||
<h4
|
||||
class="text-sm font-medium text-blue-900 dark:text-blue-200 transition-colors duration-200"
|
||||
>
|
||||
Single Campaign Data
|
||||
</h4>
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300 transition-colors duration-200">
|
||||
Trends will appear when you have 2 or more completed campaigns.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-3 gap-4">
|
||||
<div class="grid grid-cols-4 gap-4">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-blue-600">{chartData[0].openRate}%</div>
|
||||
<div class="text-sm text-gray-600">Open Rate</div>
|
||||
<div
|
||||
class="text-2xl font-bold text-blue-600 dark:text-blue-400 transition-colors duration-200"
|
||||
>
|
||||
{chartData[0].openRate}%
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 transition-colors duration-200">
|
||||
Open Rate
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-green-600">{chartData[0].clickRate}%</div>
|
||||
<div class="text-sm text-gray-600">Click Rate</div>
|
||||
<div
|
||||
class="text-2xl font-bold text-green-600 dark:text-green-400 transition-colors duration-200"
|
||||
>
|
||||
{chartData[0].clickRate}%
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 transition-colors duration-200">
|
||||
Click Rate
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-yellow-600">{chartData[0].submissionRate}%</div>
|
||||
<div class="text-sm text-gray-600">Submission Rate</div>
|
||||
<div
|
||||
class="text-2xl font-bold text-yellow-600 dark:text-yellow-400 transition-colors duration-200"
|
||||
>
|
||||
{chartData[0].submissionRate}%
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 transition-colors duration-200">
|
||||
Submission Rate
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="text-2xl font-bold text-indigo-600 dark:text-indigo-400 transition-colors duration-200"
|
||||
>
|
||||
{chartData[0].reportRate}%
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 transition-colors duration-200">
|
||||
Report Rate
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if hasAttemptedLoad && !isLoading && !debouncedIsLoading && chartData.length >= 2}
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<div
|
||||
class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-600 p-6 transition-colors duration-200"
|
||||
>
|
||||
<!-- Trendline stats and controls above chart -->
|
||||
<div class="flex flex-row items-center justify-between mb-2 pb-0 flex-wrap gap-2">
|
||||
<h4 class="text-sm font-medium text-gray-600 m-0">
|
||||
<h4
|
||||
class="text-sm font-medium text-gray-600 dark:text-gray-300 m-0 transition-colors duration-200"
|
||||
>
|
||||
Trendline: Last {trendStats ? trendStats.n : chartData.length} Campaigns (average)
|
||||
</h4>
|
||||
<div class="flex flex-wrap items-center gap-2 mb-0">
|
||||
<label class="flex items-center gap-1 text-xs text-gray-700">
|
||||
<label
|
||||
class="flex items-center gap-1 text-xs text-gray-700 dark:text-gray-300 transition-colors duration-200"
|
||||
>
|
||||
Time range:
|
||||
<select
|
||||
bind:value={selectedTimeRange}
|
||||
class="border rounded px-1 py-0 text-xs"
|
||||
class="border border-gray-300 dark:border-gray-600 rounded px-1 py-0 text-xs bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 transition-colors duration-200"
|
||||
style="height: 1.5rem;"
|
||||
>
|
||||
{#each timeRanges as range}
|
||||
@@ -899,26 +1002,30 @@
|
||||
</select>
|
||||
</label>
|
||||
{#if chartData.length > 1}
|
||||
<label class="flex items-center gap-1 text-xs text-gray-700">
|
||||
<label
|
||||
class="flex items-center gap-1 text-xs text-gray-700 dark:text-gray-300 transition-colors duration-200"
|
||||
>
|
||||
Trendline N:
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={chartData.length}
|
||||
bind:value={trendN}
|
||||
class="border rounded px-1 py-0 w-10 text-xs"
|
||||
class="border border-gray-300 dark:border-gray-600 rounded px-1 py-0 w-10 text-xs bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 transition-colors duration-200"
|
||||
style="height: 1.5rem;"
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
<label class="flex items-center gap-1 text-xs text-gray-700">
|
||||
<label
|
||||
class="flex items-center gap-1 text-xs text-gray-700 dark:text-gray-300 transition-colors duration-200"
|
||||
>
|
||||
Moving Avg N:
|
||||
<input
|
||||
type="number"
|
||||
min="2"
|
||||
max={chartData.length}
|
||||
bind:value={movingAvgN}
|
||||
class="border rounded px-1 py-0 w-10 text-xs"
|
||||
class="border border-gray-300 dark:border-gray-600 rounded px-1 py-0 w-10 text-xs bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 transition-colors duration-200"
|
||||
style="height: 1.5rem;"
|
||||
/>
|
||||
</label>
|
||||
@@ -926,7 +1033,7 @@
|
||||
</div>
|
||||
<div style="height: 1.25rem;"></div>
|
||||
{#if chartData.length > 0}
|
||||
<div class="grid grid-cols-3 gap-2 sm:gap-4">
|
||||
<div class="grid grid-cols-4 gap-2 sm:gap-4">
|
||||
{#each metrics as metric}
|
||||
<div class="text-center">
|
||||
<div class="flex items-center justify-center">
|
||||
@@ -934,7 +1041,10 @@
|
||||
class="w-3 h-3 rounded-full mr-2"
|
||||
style="background-color: {metric.color}"
|
||||
></div>
|
||||
<span class="text-sm font-medium text-gray-700">{metric.label}</span>
|
||||
<span
|
||||
class="text-sm font-medium text-gray-700 dark:text-gray-300 transition-colors duration-200"
|
||||
>{metric.label}</span
|
||||
>
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
<span class="text-2xl font-bold" style="color: {metric.color}">
|
||||
@@ -949,7 +1059,9 @@
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-center text-gray-400 text-sm py-4">
|
||||
<div
|
||||
class="text-center text-gray-400 dark:text-gray-500 text-sm py-4 transition-colors duration-200"
|
||||
>
|
||||
No trendline stats to display (trendStats is null or not enough data).
|
||||
</div>
|
||||
{/if}
|
||||
@@ -957,7 +1069,7 @@
|
||||
{#if containerReady}
|
||||
<div
|
||||
bind:this={chartContainer}
|
||||
class="min-h-[180px] max-h-[220px] w-full box-border relative rounded-md bg-white m-1"
|
||||
class="min-h-[220px] max-h-[280px] w-full box-border relative rounded-md bg-white dark:bg-gray-800 m-1 transition-colors duration-200"
|
||||
style="contain: layout style;"
|
||||
></div>
|
||||
{/if}
|
||||
@@ -1016,6 +1128,10 @@
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
:global(.dark .campaign-trend-chart) {
|
||||
background: #1f2937 !important;
|
||||
}
|
||||
:global(.line-enhanced) {
|
||||
stroke-width: 5 !important;
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
class:items-center={inline}
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<p class="font-semibold text-slate-600 py-2">
|
||||
<p class="font-semibold text-slate-600 dark:text-gray-300 py-2 transition-colors duration-200">
|
||||
<slot />
|
||||
</p>
|
||||
{#if toolTipText.length > 0}
|
||||
@@ -55,8 +55,10 @@
|
||||
</ToolTip>
|
||||
{/if}
|
||||
{#if optional === true}
|
||||
<div class="bg-gray-100 ml-2 px-2 rounded-md">
|
||||
<p class="text-slate-600 text-xs">optional</p>
|
||||
<div class="bg-gray-100 dark:bg-gray-700 ml-2 px-2 rounded-md transition-colors duration-200">
|
||||
<p class="text-slate-600 dark:text-gray-300 text-xs transition-colors duration-200">
|
||||
optional
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -71,12 +73,12 @@
|
||||
on:change
|
||||
/>
|
||||
<div
|
||||
class="w-5 h-5 border-2 border-slate-300 rounded
|
||||
peer-checked:border-cta-blue peer-checked:bg-cta-blue
|
||||
class="w-5 h-5 border-2 border-slate-300 dark:border-gray-600 rounded
|
||||
peer-checked:border-cta-blue dark:peer-checked:border-blue-500 peer-checked:bg-cta-blue dark:peer-checked:bg-blue-500
|
||||
transition-all duration-200 ease-in-out
|
||||
flex items-center justify-center
|
||||
bg-slate-50
|
||||
focus-within:ring-2 focus-within:ring-cta-blue focus-within:ring-offset-2"
|
||||
bg-slate-50 dark:bg-gray-700
|
||||
focus-within:ring-2 focus-within:ring-cta-blue dark:focus-within:ring-blue-500 focus-within:ring-offset-2 dark:focus-within:ring-offset-gray-800"
|
||||
>
|
||||
{#if value}
|
||||
<svg class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -160,7 +160,9 @@
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<div class="fixed top-0 left-0 w-full h-full bg-cta-blue opacity-20 blur-xl" />
|
||||
<div
|
||||
class="fixed top-0 left-0 w-full h-full bg-cta-blue dark:bg-gray-900 opacity-20 blur-xl transition-colors duration-200"
|
||||
/>
|
||||
<div
|
||||
class="fixed top-0 left-0 w-full h-full flex justify-center items-center backdrop-blur-sm z-20"
|
||||
role="dialog"
|
||||
@@ -170,23 +172,34 @@
|
||||
>
|
||||
<section
|
||||
bind:this={confirmElement}
|
||||
class="flex flex-col items-center w-1/3 bg-slate-100 shadow-xl rounded-md"
|
||||
class="flex flex-col items-center w-1/3 bg-slate-100 dark:bg-gray-800 shadow-xl dark:shadow-gray-900/70 rounded-md transition-colors duration-200"
|
||||
>
|
||||
<div class="bg-cta-orange2 text-white rounded-tl-md rounded-tr-md w-full">
|
||||
<div
|
||||
class="bg-cta-orange2 dark:bg-orange-600 text-white rounded-tl-md rounded-tr-md w-full transition-colors duration-200"
|
||||
>
|
||||
<p id="confirm-title" class="uppercase font-bold text-center py">Confirm action</p>
|
||||
</div>
|
||||
<h1 class="uppercase font-bold text-center text-gray-500 text-4xl pt-10">Are you sure?</h1>
|
||||
<p id="confirm-description" class="text-center">{confirm_text}</p>
|
||||
<h1
|
||||
class="uppercase font-bold text-center text-gray-500 dark:text-gray-300 text-4xl pt-10 transition-colors duration-200"
|
||||
>
|
||||
Are you sure?
|
||||
</h1>
|
||||
<p
|
||||
id="confirm-description"
|
||||
class="text-center text-gray-700 dark:text-gray-200 transition-colors duration-200"
|
||||
>
|
||||
{confirm_text}
|
||||
</p>
|
||||
<div class="flex pt-4 pb-8">
|
||||
<button
|
||||
class="mt-6 bg-grayblue-dark w-40 py-2 mr-2 hover:bg-slate-300 text-white font-bold uppercase rounded-md"
|
||||
class="mt-6 bg-grayblue-dark dark:bg-gray-600 w-40 py-2 mr-2 hover:bg-slate-300 dark:hover:bg-gray-500 text-white font-bold uppercase rounded-md transition-colors duration-200"
|
||||
on:click={close}
|
||||
aria-label="Cancel action"
|
||||
>
|
||||
no
|
||||
</button>
|
||||
<button
|
||||
class="mt-6 bg-cta-blue w-56 py-2 hover:bg-blue-500 text-white font-bold uppercase rounded-md"
|
||||
class="mt-6 bg-cta-blue dark:bg-blue-600 w-56 py-2 hover:bg-blue-500 dark:hover:bg-blue-700 text-white font-bold uppercase rounded-md transition-colors duration-200"
|
||||
on:click={confirm}
|
||||
aria-label="Confirm action"
|
||||
>
|
||||
|
||||
@@ -59,7 +59,9 @@
|
||||
class:w-60={labelWidth === 'large'}
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<p class="font-semibold text-slate-600 py-2">
|
||||
<p
|
||||
class="font-semibold text-slate-600 dark:text-gray-300 py-2 transition-colors duration-200"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
{#if toolTipText.length > 0}
|
||||
@@ -68,8 +70,12 @@
|
||||
</ToolTip>
|
||||
{/if}
|
||||
{#if optional === true}
|
||||
<div class="bg-gray-100 ml-2 px-2 rounded-md">
|
||||
<p class="text-slate-600 text-xs">optional</p>
|
||||
<div
|
||||
class="bg-gray-100 dark:bg-gray-700 ml-2 px-2 rounded-md transition-colors duration-200"
|
||||
>
|
||||
<p class="text-slate-600 dark:text-gray-300 text-xs transition-colors duration-200">
|
||||
optional
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -86,7 +92,7 @@
|
||||
{required}
|
||||
{disabled}
|
||||
autocomplete="off"
|
||||
class="rounded-md text-center py-2 pl-2 text-gray-600 border border-transparent focus:outline-none focus:border-solid focus:border-slate-400 focus:bg-gray-100 bg-grayblue-light font-normal"
|
||||
class="rounded-md text-center py-2 pl-2 text-gray-600 dark:text-gray-200 border border-transparent dark:border-gray-600 focus:outline-none focus:border-solid focus:border-slate-400 dark:focus:border-blue-500 focus:bg-gray-100 dark:focus:bg-gray-600 bg-grayblue-light dark:bg-gray-700 font-normal transition-colors duration-200"
|
||||
class:text-left={textAlign == 'left'}
|
||||
class:text-center={textAlign == 'center'}
|
||||
class:text-right={textAlign == 'right'}
|
||||
|
||||
@@ -112,7 +112,7 @@
|
||||
class:w-60={labelWidth === 'large'}
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<p class="font-semibold text-slate-600 py-2">
|
||||
<p class="font-semibold text-slate-600 dark:text-gray-300 py-2 transition-colors duration-200">
|
||||
<slot />
|
||||
</p>
|
||||
{#if toolTipText.length > 0}
|
||||
@@ -121,8 +121,8 @@
|
||||
</ToolTip>
|
||||
{/if}
|
||||
{#if optional === true}
|
||||
<div class="bg-gray-100 ml-2 px-2 rounded-md">
|
||||
<p class="text-slate-600 text-xs">optional</p>
|
||||
<div class="bg-gray-100 dark:bg-gray-700 ml-2 px-2 rounded-md transition-colors duration-200">
|
||||
<p class="text-slate-600 dark:text-gray-300 text-xs">optional</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -139,7 +139,7 @@
|
||||
{readonly}
|
||||
{required}
|
||||
autocomplete="off"
|
||||
class="w-44 rounded-md py-2 pl-2 text-gray-600 border border-transparent focus:outline-none focus:border-solid focus:border-slate-400 focus:bg-gray-100 bg-grayblue-light font-normal"
|
||||
class="w-44 rounded-md py-2 pl-2 text-gray-600 dark:text-gray-300 border border-transparent focus:outline-none focus:border-solid focus:border-slate-400 dark:focus:border-gray-500 focus:bg-gray-100 dark:focus:bg-gray-600 bg-grayblue-light dark:bg-gray-700 font-normal transition-colors duration-200"
|
||||
class:opacity-90={readonly}
|
||||
/>
|
||||
<input
|
||||
@@ -153,7 +153,8 @@
|
||||
{readonly}
|
||||
{required}
|
||||
autocomplete="off"
|
||||
class="ml-2 rounded-md py-2 pl-2 text-gray-600 text-center border border-transparent focus:outline-none focus:border-solid focus:border-slate-400 focus:bg-gray-100 bg-grayblue-light font-normal"
|
||||
class="ml-2 rounded-md py-2 pl-2 text-gray-600 dark:text-gray-300 text-center border border-transparent focus:outline-none focus:border-solid focus:border-slate-400 dark:focus:border-gray-500 focus:bg-gray-100 dark:focus:bg-gray-600 bg-grayblue-light dark:bg-gray-700 font-normal transition-colors duration-200"
|
||||
class:bg-yellow-200={readonly}
|
||||
class:dark:bg-yellow-700={readonly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div class="text-gray-900 dark:text-gray-100 transition-colors duration-200">
|
||||
{#if date}
|
||||
{#if !hideHours}
|
||||
{date.toLocaleString()}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<div>
|
||||
<div>
|
||||
<button
|
||||
class="fixed border-2 border-slate-500 bg-white w-8 h-8 left-2 pb-1 bottom-4 text-white rounded-full hover:right-3 hover:bottom-3 hover:w-10 hover:h-10 transition-all"
|
||||
class="fixed border-2 border-slate-500 dark:border-slate-400 bg-white dark:bg-gray-700 w-8 h-8 left-2 pb-1 bottom-4 text-white rounded-full hover:right-3 hover:bottom-3 hover:w-10 hover:h-10 transition-all"
|
||||
on:click={() => (visible = !visible)}
|
||||
>
|
||||
{#if visible}
|
||||
@@ -41,46 +41,75 @@
|
||||
{#if visible}
|
||||
<div
|
||||
transition:fade={{ duration: 100 }}
|
||||
class="absolute right-0 h-auto bg-black text-white p-4 z-40"
|
||||
class="absolute right-0 h-auto bg-black dark:bg-gray-900 text-white p-4 z-40 border border-gray-600 dark:border-gray-500"
|
||||
>
|
||||
<h1 class="text-xl m-4">Developer Panel</h1>
|
||||
<h2 class="text-lg font-bold m-4">Links</h2>
|
||||
<ul class="m-4">
|
||||
<li>
|
||||
<a href="http://localhost:8101" target="_blank">Database</a>
|
||||
<a
|
||||
href="http://localhost:8101"
|
||||
target="_blank"
|
||||
class="text-blue-400 hover:text-blue-300 underline">Database</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="http://localhost:8102" target="_blank">Mailbox</a>
|
||||
<a
|
||||
href="http://localhost:8102"
|
||||
target="_blank"
|
||||
class="text-blue-400 hover:text-blue-300 underline">Mailbox</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="http://localhost:8103" target="_blank">Container logs</a>
|
||||
<a
|
||||
href="http://localhost:8103"
|
||||
target="_blank"
|
||||
class="text-blue-400 hover:text-blue-300 underline">Container logs</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="http://localhost:8104" target="_blank">Container stats</a>
|
||||
<a
|
||||
href="http://localhost:8104"
|
||||
target="_blank"
|
||||
class="text-blue-400 hover:text-blue-300 underline">Container stats</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
<h2 class="text-lg font-bold m-4">Toast</h2>
|
||||
<ul class="m-4">
|
||||
<li>
|
||||
<button on:click={() => triggerToast('Success')}>Trigger toast - Success</button>
|
||||
<button
|
||||
on:click={() => triggerToast('Success')}
|
||||
class="text-green-400 hover:text-green-300 underline">Trigger toast - Success</button
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<button on:click={() => triggerToast('Info')}>Trigger toast - Info</button>
|
||||
<button
|
||||
on:click={() => triggerToast('Info')}
|
||||
class="text-blue-400 hover:text-blue-300 underline">Trigger toast - Info</button
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<button on:click={() => triggerToast('Warning')}>Trigger toast - Warning</button>
|
||||
<button
|
||||
on:click={() => triggerToast('Warning')}
|
||||
class="text-yellow-400 hover:text-yellow-300 underline"
|
||||
>Trigger toast - Warning</button
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<button on:click={() => triggerToast('Error')}>Trigger toast - Error</button>
|
||||
<button
|
||||
on:click={() => triggerToast('Error')}
|
||||
class="text-red-400 hover:text-red-300 underline">Trigger toast - Error</button
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="pt-4">
|
||||
<h2 class="text-lg font-bold">Global State</h2>
|
||||
<table class="border-2">
|
||||
<table class="border-2 border-gray-400 dark:border-gray-500">
|
||||
{#each Object.entries(state) as [key, value]}
|
||||
<tr class="flex flex-col border-2 border-white">
|
||||
<td class="p-4 font-bold border-1 border-white">{key}</td>
|
||||
<td class="p-4 border-1 border-white w-full">
|
||||
<tr class="flex flex-col border-2 border-white dark:border-gray-600">
|
||||
<td class="p-4 font-bold border-1 border-white dark:border-gray-600">{key}</td>
|
||||
<td class="p-4 border-1 border-white dark:border-gray-600 w-full">
|
||||
{#if typeof value === 'object'}
|
||||
<pre class="whitespace-pre-wrap">{JSON.stringify(value, null, 2)}</pre>
|
||||
{:else}
|
||||
|
||||
@@ -70,7 +70,8 @@
|
||||
.attr('y1', (height - margin.top - margin.bottom) / 2)
|
||||
.attr('y2', (height - margin.top - margin.bottom) / 2)
|
||||
.attr('stroke', '#E2E8F0')
|
||||
.attr('stroke-width', 2);
|
||||
.attr('stroke-width', 2)
|
||||
.attr('class', 'timeline-line');
|
||||
|
||||
// X Axis
|
||||
g.append('g')
|
||||
@@ -340,7 +341,8 @@
|
||||
.attr('y1', (height - margin.top - margin.bottom) / 2)
|
||||
.attr('y2', (height - margin.top - margin.bottom) / 2)
|
||||
.attr('stroke', '#E2E8F0')
|
||||
.attr('stroke-width', 2);
|
||||
.attr('stroke-width', 2)
|
||||
.attr('class', 'timeline-line');
|
||||
|
||||
const ghostDots = 8;
|
||||
const ghostDotsData = Array(ghostDots).fill(null);
|
||||
@@ -355,6 +357,7 @@
|
||||
.attr('r', 6)
|
||||
.attr('fill', '#E2E8F0')
|
||||
.attr('stroke', '#fff')
|
||||
.attr('class', 'ghost-dot-fill')
|
||||
.attr('stroke-width', 2);
|
||||
}
|
||||
|
||||
@@ -378,10 +381,10 @@
|
||||
try {
|
||||
tooltip.innerHTML = `
|
||||
<div class="p-2">
|
||||
<div class="font-semibold text-lg text-gray-700 border-b-2">${toEvent(d.eventName).name}</div>
|
||||
<div class="">${d.recipient?.email ?? ''}</div>
|
||||
<div class="text-xs text-gray-600">${new Date(d.createdAt).toLocaleString()}</div>
|
||||
${d.data ? `<div class="text-xs mt-1">${d.data}</div>` : ''}
|
||||
<div class="font-semibold text-lg text-gray-700 dark:text-gray-200 border-b-2">${toEvent(d.eventName).name}</div>
|
||||
<div class="text-gray-600 dark:text-gray-300">${d.recipient?.email ?? ''}</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">${new Date(d.createdAt).toLocaleString()}</div>
|
||||
${d.data ? `<div class="text-xs mt-1 text-gray-500 dark:text-gray-400">${d.data}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
} catch (e) {
|
||||
@@ -435,7 +438,10 @@
|
||||
function getEventColor(eventName) {
|
||||
if (!eventName) return '#6B7280'; // Default gray for undefined events
|
||||
|
||||
const colorMap = {
|
||||
const isDark =
|
||||
typeof document !== 'undefined' && document.documentElement.classList.contains('dark');
|
||||
|
||||
const lightModeColors = {
|
||||
campaign_scheduled: '#4e68d8',
|
||||
campaign_active: '#53afe3',
|
||||
campaign_self_managed: '#303f9f',
|
||||
@@ -443,7 +449,7 @@
|
||||
campaign_recipient_scheduled: '#4e68d8',
|
||||
campaign_recipient_message_sent: '#94cae6',
|
||||
campaign_recipient_message_failed: '#f2bb58',
|
||||
campaign_recipient_message_read: '#4cb5b5',
|
||||
campaign_recipient_message_read: '#22c55e', // More muted green
|
||||
campaign_recipient_before_page_visited: '#eea5fa',
|
||||
campaign_recipient_page_visited: '#f96dcf',
|
||||
campaign_recipient_after_page_visited: '#f6287b',
|
||||
@@ -451,6 +457,25 @@
|
||||
campaign_recipient_cancelled: '#161692',
|
||||
default: '#6B7280'
|
||||
};
|
||||
|
||||
const darkModeColors = {
|
||||
campaign_scheduled: '#60a5fa',
|
||||
campaign_active: '#38bdf8',
|
||||
campaign_self_managed: '#6366f1',
|
||||
campaign_closed: '#a1a1aa',
|
||||
campaign_recipient_scheduled: '#60a5fa',
|
||||
campaign_recipient_message_sent: '#7dd3fc',
|
||||
campaign_recipient_message_failed: '#fbbf24',
|
||||
campaign_recipient_message_read: '#4ade80', // Softer green for dark mode
|
||||
campaign_recipient_before_page_visited: '#d8b4fe',
|
||||
campaign_recipient_page_visited: '#f472b6',
|
||||
campaign_recipient_after_page_visited: '#fb7185',
|
||||
campaign_recipient_submitted_data: '#f87171',
|
||||
campaign_recipient_cancelled: '#3730a3',
|
||||
default: '#9ca3af'
|
||||
};
|
||||
|
||||
const colorMap = isDark ? darkModeColors : lightModeColors;
|
||||
return colorMap[eventName] || colorMap.default;
|
||||
}
|
||||
|
||||
@@ -465,11 +490,11 @@
|
||||
bind:this={svg}
|
||||
width="100%"
|
||||
height={height + 20}
|
||||
class="bg-white rounded-lg shadow-sm p-2"
|
||||
class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-2 transition-colors duration-200"
|
||||
/>
|
||||
<div
|
||||
bind:this={tooltip}
|
||||
class="absolute hidden bg-white shadow-lg rounded-lg border border-gray-200 z-10 pointer-events-none"
|
||||
class="absolute hidden bg-white dark:bg-gray-800 shadow-lg rounded-lg border border-gray-200 dark:border-gray-600 z-10 pointer-events-none transition-colors duration-200"
|
||||
/>
|
||||
{#if !isGhost}
|
||||
<div class="absolute top-2 right-2 flex gap-2">
|
||||
@@ -478,23 +503,23 @@
|
||||
use24Hour = !use24Hour;
|
||||
updateTimeline();
|
||||
}}
|
||||
class="px-2 py-1 text-xs bg-white border border-slate-200 rounded shadow-sm hover:bg-slate-50 text-slate-600"
|
||||
class="px-2 py-1 text-xs bg-white dark:bg-gray-700 border border-slate-200 dark:border-gray-600 rounded shadow-sm hover:bg-slate-50 dark:hover:bg-gray-600 text-slate-600 dark:text-gray-300 transition-colors duration-200"
|
||||
>
|
||||
{use24Hour ? '12h' : '24h'}
|
||||
</button>
|
||||
<button
|
||||
on:click={resetZoom}
|
||||
class="px-2 py-1 text-xs bg-white border border-slate-200 rounded shadow-sm hover:bg-slate-50 text-slate-600"
|
||||
class="px-2 py-1 text-xs bg-white dark:bg-gray-700 border border-slate-200 dark:border-gray-600 rounded shadow-sm hover:bg-slate-50 dark:hover:bg-gray-600 text-slate-600 dark:text-gray-300 transition-colors duration-200"
|
||||
>
|
||||
Reset View
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="absolute top-0 left-1/2 transform -translate-x-1/2 bg-white px-3 py-1 rounded-b-lg shadow-sm border border-t-0 text-sm font-medium text-slate-700"
|
||||
class="absolute top-0 left-1/2 transform -translate-x-1/2 bg-white dark:bg-gray-800 px-3 py-1 rounded-b-lg shadow-sm border border-t-0 border-gray-200 dark:border-gray-600 text-sm font-medium text-slate-700 dark:text-gray-200 transition-colors duration-200"
|
||||
>
|
||||
{currentCenterDate}
|
||||
</div>
|
||||
<div class="absolute bottom-0 right-0 p-2 text-xs text-slate-500">
|
||||
<div class="absolute bottom-0 right-0 p-2 text-xs text-slate-500 dark:text-gray-400">
|
||||
Drag to pan • Scroll to zoom
|
||||
</div>
|
||||
{/if}
|
||||
@@ -507,12 +532,45 @@
|
||||
font-weight: 500;
|
||||
fill: #4a5568;
|
||||
}
|
||||
|
||||
:global(.dark .x-axis text) {
|
||||
fill: #d1d5db;
|
||||
}
|
||||
|
||||
:global(.x-axis line) {
|
||||
stroke: #e2e8f0;
|
||||
}
|
||||
|
||||
:global(.dark .x-axis line) {
|
||||
stroke: #4b5563;
|
||||
}
|
||||
|
||||
:global(.x-axis path) {
|
||||
stroke: #e2e8f0;
|
||||
}
|
||||
|
||||
:global(.dark .x-axis path) {
|
||||
stroke: #4b5563;
|
||||
}
|
||||
|
||||
:global(.timeline-line) {
|
||||
stroke: #e2e8f0;
|
||||
}
|
||||
|
||||
:global(.dark .timeline-line) {
|
||||
stroke: #4b5563;
|
||||
}
|
||||
|
||||
:global(.ghost-dot-fill) {
|
||||
fill: #e2e8f0;
|
||||
stroke: #fff;
|
||||
}
|
||||
|
||||
:global(.dark .ghost-dot-fill) {
|
||||
fill: #4b5563;
|
||||
stroke: #1f2937;
|
||||
}
|
||||
|
||||
:global(.ghost-dot) {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
|
||||
<label class="flex flex-col py-2 w-56">
|
||||
<div class="flex items-center">
|
||||
<p class="font-semibold text-slate-600 py-2">
|
||||
<p class="font-semibold text-slate-600 dark:text-gray-300 py-2 transition-colors duration-200">
|
||||
<slot />
|
||||
</p>
|
||||
{#if toolTipText.length > 0}
|
||||
@@ -52,8 +52,8 @@
|
||||
</ToolTip>
|
||||
{/if}
|
||||
{#if optional === true}
|
||||
<div class="bg-gray-100 ml-2 px-2 rounded-md">
|
||||
<p class="text-slate-600 text-xs">optional</p>
|
||||
<div class="bg-gray-100 dark:bg-gray-700 ml-2 px-2 rounded-md transition-colors duration-200">
|
||||
<p class="text-slate-600 dark:text-gray-300 text-xs">optional</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -68,6 +68,6 @@
|
||||
{multiple}
|
||||
{required}
|
||||
{placeholder}
|
||||
class="border-solid border-2 py-2 px-2 rounded-md file:px-4 file:py-2 file:text-white file:cursor-pointer file:text-sm file:font-semibold bg-white file:bg-cta-green hover:cursor-pointer file:hover:bg-teal-300 file:border-hidden file:rounded-md"
|
||||
class="border-solid border-2 border-gray-300 dark:border-gray-600 py-2 px-2 rounded-md file:px-4 file:py-2 file:text-white file:cursor-pointer file:text-sm file:font-semibold bg-white dark:bg-gray-700 file:bg-cta-blue hover:cursor-pointer file:hover:bg-blue-600 dark:file:bg-indigo-600 dark:file:hover:bg-indigo-700 file:border-hidden file:rounded-md text-gray-900 dark:text-white transition-colors duration-200"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<form
|
||||
on:submit|preventDefault
|
||||
class="flex w-60 flex-col"
|
||||
class="flex w-60 flex-col bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 transition-colors duration-200"
|
||||
class:h-full={fullHeight}
|
||||
class:w-full={fullWidth}
|
||||
bind:this={bindTo}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
class:w-32={size === 'small'}
|
||||
class:w-36={size === 'medium'}
|
||||
class:w-60={size === 'large'}
|
||||
class="bg-cta-blue px-2 py-2 self-end hover:bg-blue-500 text-white text-sm font-bold flex justify-center items-center uppercase rounded-md"
|
||||
class="bg-cta-blue dark:bg-blue-600 px-2 py-2 self-end hover:bg-blue-500 dark:hover:bg-blue-700 text-white text-sm font-bold flex justify-center items-center uppercase rounded-md transition-colors duration-200 disabled:opacity-50 dark:disabled:opacity-60"
|
||||
on:click
|
||||
>
|
||||
{#if isSubmitting}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="px-6 flex flex-col items-start sm:items-start md:items-start lg:items-start xl:items-start 2xl:items-start"
|
||||
class="px-6 flex flex-col items-start sm:items-start md:items-start lg:items-start xl:items-start 2xl:items-start text-gray-900 dark:text-gray-100 transition-colors duration-200"
|
||||
class:overflow-x-scroll={overflowX}
|
||||
>
|
||||
<slot />
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="scrollX col-start-1 col-span-3 row-start-1 row-span-5 py-8 flex-col flex sm:flex-col md:flex-col lg:flex-row justify-center"
|
||||
class="scrollX col-start-1 col-span-3 row-start-1 row-span-5 py-8 flex-col flex sm:flex-col md:flex-col lg:flex-row justify-center bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 transition-colors duration-200"
|
||||
{id}
|
||||
>
|
||||
<slot />
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
</script>
|
||||
|
||||
{#if message}
|
||||
<div class="flex col-span-12 justify-center pb-4">
|
||||
<div class="flex col-span-12 justify-center p-4">
|
||||
<div class="w-80 flex col-span-12 justify-center">
|
||||
<div
|
||||
class="flex items-center w-full bg-pleasant-gray rounded-md border text-center p-2 font-titilium"
|
||||
class="flex items-center w-full bg-pleasant-gray dark:bg-gray-700 rounded-md border border-gray-200 dark:border-gray-600 text-center p-2 font-titilium transition-colors duration-200"
|
||||
>
|
||||
<svg class="w-12 ml-4" transition:slide viewBox="0 0 32.94 32.94">
|
||||
<circle
|
||||
@@ -33,7 +33,7 @@
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<p class="pl-4">
|
||||
<p class="pl-4 text-gray-700 dark:text-gray-200 transition-colors duration-200">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
<form
|
||||
on:submit|preventDefault
|
||||
class="col-start-1 col-span-3 row-start-1 row-span-5 py-8 flex-col flex sm:flex-col md:flex-col lg:flex-row justify-start"
|
||||
class="col-start-1 col-span-3 row-start-1 row-span-5 py-8 flex-col flex sm:flex-col md:flex-col lg:flex-row justify-start bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 transition-colors duration-200"
|
||||
bind:this={bindTo}
|
||||
>
|
||||
<slot />
|
||||
|
||||
@@ -19,12 +19,12 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="py-4 row-span-2 col-start-1 col-span-3 border-t-2 w-full flex flex-row justify-center items-center sm:justify-center md:justify-center lg:justify-end xl:justify-end 2xl:justify-end"
|
||||
class="py-4 row-span-2 col-start-1 col-span-3 border-t-2 border-gray-200 dark:border-gray-700 w-full flex flex-row justify-center items-center sm:justify-center md:justify-center lg:justify-end xl:justify-end 2xl:justify-end bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 transition-colors duration-200"
|
||||
>
|
||||
<button
|
||||
type="reset"
|
||||
on:click|preventDefault={onCloseModal}
|
||||
class="bg-slate-400 hover:bg-slate-300 text-sm mr-2 uppercase font-bold px-4 py-2 text-white rounded-md"
|
||||
class="bg-slate-400 hover:bg-slate-300 dark:bg-slate-600 dark:hover:bg-slate-500 text-sm mr-2 uppercase font-bold px-4 py-2 text-white rounded-md transition-colors duration-200"
|
||||
>{closeText}</button
|
||||
>
|
||||
<FormButton {isSubmitting}>{okText}</FormButton>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<form
|
||||
on:submit|preventDefault
|
||||
inert={isSubmitting}
|
||||
class="grid grid-cols-3 grid-rows-1 w-full h-full flex-col"
|
||||
class="grid grid-cols-3 grid-rows-1 w-full h-full flex-col bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 transition-colors duration-200"
|
||||
class:opacity-70={isSubmitting}
|
||||
bind:this={bindTo}
|
||||
{novalidate}
|
||||
|
||||
@@ -4,18 +4,18 @@
|
||||
const widths = ['w-16', 'w-20', 'w-24'];
|
||||
let height = 'h-4';
|
||||
let width = widths[Math.floor(Math.random() * widths.length)];
|
||||
let gradient = 'from-gray-300 to-transparent bg-gradient-to-r';
|
||||
let gradient = 'from-gray-300 dark:from-gray-600 to-transparent bg-gradient-to-r';
|
||||
if (square) {
|
||||
width = 'h-4';
|
||||
height = 'w-4';
|
||||
gradient = 'bg-gray-300';
|
||||
gradient = 'bg-gray-300 dark:bg-gray-600';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if center}
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="{width} {height} {gradient} "> </div>
|
||||
<div class="{width} {height} {gradient} transition-colors duration-200"> </div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="{width} {height} {gradient}"> </div>
|
||||
<div class="{width} {height} {gradient} transition-colors duration-200"> </div>
|
||||
{/if}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
<div class="bg-gray-300 w-full h-1/10 flex justify-center">
|
||||
<img alt="logo" src="/logo-white.svg" class="w-1/3 sm:w-1/4 lg:w-2/10 xl:w-1/10 2xl:w-1/10" />
|
||||
</div>
|
||||
<div
|
||||
class="bg-gray-300 dark:bg-gray-700 w-full h-1/10 flex justify-center transition-colors duration-200"
|
||||
>
|
||||
<img alt="logo" src="/logo-white.svg" class="w-1/3 sm:w-1/4 lg:w-2/10 xl:w-1/10 2xl:w-1/10" />
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
<h1 class="text-3xl text-gray-600 font-bold uppercase">
|
||||
<h1
|
||||
class="text-3xl text-gray-600 dark:text-gray-300 font-bold uppercase transition-colors duration-200"
|
||||
>
|
||||
<slot />
|
||||
</h1>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script>
|
||||
export let name = 'World';
|
||||
console.log(name)
|
||||
console.log(name);
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<main class="text-gray-900 dark:text-gray-100 transition-colors duration-200">
|
||||
Hello {name}!
|
||||
</main>
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col w-full p-4 h-24">
|
||||
<label for={fieldName} class="text-md font-semibold font-titilium text-pc-darkblue"
|
||||
<label
|
||||
for={fieldName}
|
||||
class="text-md font-semibold font-titilium text-pc-darkblue dark:text-gray-200"
|
||||
>{fieldName}</label
|
||||
>
|
||||
<input
|
||||
@@ -32,6 +34,6 @@
|
||||
{type}
|
||||
id={fieldName}
|
||||
name={fieldName}
|
||||
class="w-full p-2 rounded bg-pc-lightblue focus:outline-none focus:ring-0 focus:border-cta-blue focus:border-2"
|
||||
class="w-full p-2 rounded bg-pc-lightblue dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:placeholder-gray-400 focus:outline-none focus:ring-0 focus:border-cta-blue dark:focus:border-blue-500 focus:border-2 transition-colors duration-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -15,13 +15,16 @@
|
||||
</script>
|
||||
|
||||
{#if $isLoading && isAnimating}
|
||||
<div class="fixed top-0 left-0 w-full h-full opacity-[0.5]" transition:blur={{ duration }} />
|
||||
<div
|
||||
class="fixed top-0 left-0 w-full h-full bg-black dark:bg-gray-900 opacity-[0.5] transition-colors duration-200"
|
||||
transition:blur={{ duration }}
|
||||
/>
|
||||
<div
|
||||
transition:blur={{ duration }}
|
||||
class="fixed top-0 left-0 w-full h-full flex justify-center items-center backdrop-blur-sm z-50"
|
||||
>
|
||||
<div
|
||||
class="w-20 h-20 border-t-8 border-t-cta-blue border-r-8 border-r-cta-blue border-b-cta-blue border-b-8 border-l-transparent border-l-8 rounded-full animate-spin"
|
||||
class="w-20 h-20 border-t-8 border-t-cta-blue dark:border-t-blue-500 border-r-8 border-r-cta-blue dark:border-r-blue-500 border-b-cta-blue dark:border-b-blue-500 border-b-8 border-l-transparent border-l-8 rounded-full animate-spin transition-colors duration-200"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -240,7 +240,9 @@
|
||||
|
||||
{#if visible}
|
||||
<div bind:this={bindTo}>
|
||||
<div class="fixed top-0 left-0 w-full h-full bg-cta-blue opacity-20 blur-xl" />
|
||||
<div
|
||||
class="fixed top-0 left-0 w-full h-full bg-cta-blue dark:bg-gray-900 opacity-20 blur-xl transition-colors duration-200"
|
||||
/>
|
||||
<div
|
||||
class="fixed top-0 left-0 w-full h-full flex justify-center items-center backdrop-blur-sm z-20"
|
||||
role="dialog"
|
||||
@@ -250,11 +252,11 @@
|
||||
>
|
||||
<section
|
||||
bind:this={modalElement}
|
||||
class="shadow-xl w-auto ml-20 mr-8 max-h-[90vh] bg-white opacity-100 rounded-md flex flex-col"
|
||||
class="shadow-xl dark:shadow-gray-900/70 w-auto ml-20 mr-8 max-h-[90vh] bg-white dark:bg-gray-800 opacity-100 rounded-md flex flex-col transition-colors duration-200"
|
||||
>
|
||||
<div
|
||||
class:opacity-20={isSubmitting}
|
||||
class="bg-cta-blue text-white rounded-t-md py-4 px-8 flex justify-between flex-shrink-0"
|
||||
class="bg-cta-blue dark:bg-blue-700 text-white rounded-t-md py-4 px-8 flex justify-between flex-shrink-0 transition-colors duration-200"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<h1 id="modal-title" class="uppercase mr-8 font-semibold text-2xl">{headerText}</h1>
|
||||
|
||||
@@ -43,12 +43,16 @@
|
||||
|
||||
<div class="flex items-center mb-8 mt-4">
|
||||
<button
|
||||
class="bg-highlight-blue w-8 text-white hover:bg-active-blue m-1 rounded-md py-1 px-1"
|
||||
class="bg-highlight-blue dark:bg-blue-600 w-8 text-white hover:bg-active-blue dark:hover:bg-blue-700 m-1 rounded-md py-1 px-1 transition-colors duration-200"
|
||||
on:click|preventDefault={previousPage}><<</button
|
||||
>
|
||||
<div class="w-8 text-center bg-grayblue-light rounded-md py-1 px-1">{currentPage}</div>
|
||||
<div
|
||||
class="w-8 text-center bg-grayblue-light dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-md py-1 px-1 transition-colors duration-200"
|
||||
>
|
||||
{currentPage}
|
||||
</div>
|
||||
<button
|
||||
class="bg-highlight-blue w-8 text-white hover:bg-active-blue m-1 rounded-md py-1 px-1"
|
||||
class="bg-highlight-blue dark:bg-blue-600 w-8 text-white hover:bg-active-blue dark:hover:bg-blue-700 m-1 rounded-md py-1 px-1 transition-colors duration-200"
|
||||
on:click|preventDefault={nextPage}>>></button
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
|
||||
<label class="flex flex-col py-2 w-60">
|
||||
<div class="flex items-center">
|
||||
<p class="font-semibold text-slate-600 py-2">
|
||||
<p class="font-semibold text-slate-600 dark:text-gray-300 py-2 transition-colors duration-200">
|
||||
<slot />
|
||||
</p>
|
||||
{#if toolTipText.length > 0}
|
||||
@@ -68,8 +68,10 @@
|
||||
</ToolTip>
|
||||
{/if}
|
||||
{#if optional === true}
|
||||
<div class="bg-gray-100 ml-2 px-2 rounded-md">
|
||||
<p class="text-slate-600 text-xs">optional</p>
|
||||
<div class="bg-gray-100 dark:bg-gray-700 ml-2 px-2 rounded-md transition-colors duration-200">
|
||||
<p class="text-slate-600 dark:text-gray-300 text-xs transition-colors duration-200">
|
||||
optional
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -86,7 +88,7 @@
|
||||
minlength={minLength}
|
||||
maxlength={maxLength}
|
||||
{required}
|
||||
class="text-ellipsis w-60 rounded-md py-2 pl-4 text-gray-600 border border-transparent focus:outline-none focus:border-solid focus:border-slate-400 focus:bg-gray-100 bg-grayblue-light font-normal"
|
||||
class="text-ellipsis w-60 rounded-md py-2 pl-4 pr-12 text-gray-600 dark:text-gray-200 border border-transparent dark:border-gray-600 focus:outline-none focus:border-solid focus:border-slate-400 dark:focus:border-blue-500 focus:bg-gray-100 dark:focus:bg-gray-600 bg-grayblue-light dark:bg-gray-700 font-normal transition-colors duration-200"
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
@@ -100,18 +102,26 @@
|
||||
{placeholder}
|
||||
autocomplete="off"
|
||||
{required}
|
||||
class="text-ellipsis w-60 rounded-md py-2 pl-4 text-gray-600 border border-transparent focus:outline-none focus:border-solid focus:border-slate-400 focus:bg-gray-100 bg-grayblue-light font-normal"
|
||||
class="text-ellipsis w-60 rounded-md py-2 pl-4 pr-12 text-gray-600 dark:text-gray-200 border border-transparent dark:border-gray-600 focus:outline-none focus:border-solid focus:border-slate-400 dark:focus:border-blue-500 focus:bg-gray-100 dark:focus:bg-gray-600 bg-grayblue-light dark:bg-gray-700 font-normal transition-colors duration-200"
|
||||
/>
|
||||
{/if}
|
||||
<button
|
||||
class="absolute w-8 mr-2 hover:opacity-70"
|
||||
class="absolute w-8 mr-2 hover:opacity-70 transition-opacity duration-200"
|
||||
on:click={handleClick}
|
||||
on:keyup={handleClick}
|
||||
>
|
||||
{#if viewPassword}
|
||||
<img src="/view.svg" alt="view" />
|
||||
<img
|
||||
src="/view.svg"
|
||||
alt="view"
|
||||
class="dark:filter dark:brightness-0 dark:invert transition-all duration-200"
|
||||
/>
|
||||
{:else}
|
||||
<img src="/toggle-view.svg" alt="toggle view" />
|
||||
<img
|
||||
src="/toggle-view.svg"
|
||||
alt="toggle view"
|
||||
class="dark:filter dark:brightness-0 dark:invert transition-all duration-200"
|
||||
/>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
: value instanceof Date
|
||||
? value.toLocaleString()
|
||||
: ''}
|
||||
class="text-gray-600 dark:text-gray-400 transition-colors duration-200"
|
||||
>
|
||||
{formattedTime}
|
||||
</span>
|
||||
|
||||
@@ -30,4 +30,6 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="w-full h-full flex border-4 border-pc-darkblue animate-pulse"></div>
|
||||
<div
|
||||
class="w-full h-full flex border-4 border-pc-darkblue dark:border-gray-600 animate-pulse transition-colors duration-200"
|
||||
></div>
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
setSearch();
|
||||
}
|
||||
}}
|
||||
class="bg-grayblue-light w-56 border text-gray-600 border-gray-300 pl-8 py-2 relative rounded-lg focus:outline-none focus:ring-0 focus:border-cta-blue focus:border"
|
||||
class="bg-grayblue-light dark:bg-gray-700 w-56 border text-gray-600 dark:text-gray-200 border-gray-300 dark:border-gray-600 pl-8 py-2 relative rounded-lg focus:outline-none focus:ring-0 focus:border-cta-blue dark:focus:border-blue-500 focus:border transition-colors duration-200 dark:placeholder-gray-400"
|
||||
placeholder="Search"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -21,10 +21,12 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div >
|
||||
<label for="pet-select">Show:</label>
|
||||
<div>
|
||||
<label for="pet-select" class="text-gray-600 dark:text-gray-300 transition-colors duration-200"
|
||||
>Show:</label
|
||||
>
|
||||
<select
|
||||
class="bg-grayblue-light px-2 py-1 rounded-md text-gray-600 border border-transparent focus:outline-none focus:border-solid focus:border focus:border-slate-400 focus:bg-gray-100"
|
||||
class="bg-grayblue-light dark:bg-gray-700 px-2 py-1 rounded-md text-gray-600 dark:text-gray-200 border border-transparent dark:border-gray-600 focus:outline-none focus:border-solid focus:border focus:border-slate-400 dark:focus:border-blue-500 focus:bg-gray-100 dark:focus:bg-gray-600 transition-colors duration-200"
|
||||
name="entries"
|
||||
id="entries"
|
||||
bind:value
|
||||
|
||||
@@ -15,15 +15,19 @@
|
||||
<div class="flex flex-col gap-2 py-2">
|
||||
{#if label}
|
||||
<div class="flex flex-row">
|
||||
<div class="font-semibold text-slate-600">{label}</div>
|
||||
<div class="font-semibold text-slate-600 dark:text-gray-300 transition-colors duration-200">
|
||||
{label}
|
||||
</div>
|
||||
{#if toolTipText.length > 0}
|
||||
<ToolTip>
|
||||
{toolTipText}
|
||||
</ToolTip>
|
||||
{/if}
|
||||
{#if optional}
|
||||
<div class="bg-gray-100 ml-2 px-2 rounded-md">
|
||||
<p class="text-slate-600 text-xs">optional</p>
|
||||
<div
|
||||
class="bg-gray-100 dark:bg-gray-700 ml-2 px-2 rounded-md transition-colors duration-200"
|
||||
>
|
||||
<p class="text-slate-600 dark:text-gray-300 text-xs">optional</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -42,11 +46,11 @@
|
||||
p-3 rounded-lg border-2 transition-all duration-200
|
||||
flex flex-col items-center justify-center text-center
|
||||
w-40
|
||||
hover:border-blue-300 hover:bg-blue-50
|
||||
hover:border-blue-300 dark:hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/30
|
||||
${
|
||||
value === option.value
|
||||
? 'border-green-500 bg-green-50 text-green-700 '
|
||||
: 'border-gray-200 bg-white text-gray-700 '
|
||||
? 'border-green-500 dark:border-green-400 bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300'
|
||||
: 'border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200'
|
||||
}
|
||||
`}
|
||||
on:click={() => {
|
||||
@@ -59,7 +63,9 @@
|
||||
{/if}
|
||||
<span class="font-medium text-sm">{option.label}</span>
|
||||
{#if option.description}
|
||||
<span class="text-xs text-gray-500 mt-1">{option.description}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 mt-1 transition-colors duration-200"
|
||||
>{option.description}</span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
@@ -43,12 +43,20 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="bg-white p-6 rounded-lg shadow-md border-l-[12px] {borderColor} hover:shadow-lg transition-shadow"
|
||||
class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md dark:shadow-gray-900/50 border-l-[12px] {borderColor} hover:shadow-lg dark:hover:shadow-gray-900/70 transition-all duration-200"
|
||||
>
|
||||
<div class="text-grayblue-dark text-sm font-semibold">{title}</div>
|
||||
<div
|
||||
class="text-grayblue-dark dark:text-gray-400 text-sm font-semibold transition-colors duration-200"
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<span class="text-3xl font-bold text-pc-darkblue {flash ? 'flash' : ''}">
|
||||
<span
|
||||
class="text-3xl font-bold text-pc-darkblue dark:text-gray-100 transition-colors duration-200 {flash
|
||||
? 'flash'
|
||||
: ''}"
|
||||
>
|
||||
{Math.floor($displayValue)}
|
||||
</span>
|
||||
<div class="ml-2">
|
||||
@@ -74,12 +82,14 @@
|
||||
|
||||
{#if validPercentages.length > 0}
|
||||
<button
|
||||
class="mt-2 text-sm text-gray-600 flex items-center"
|
||||
class="mt-2 text-sm text-gray-600 dark:text-gray-400 flex items-center transition-colors duration-200"
|
||||
on:click={cyclePercentage}
|
||||
class:cursor-pointer={validPercentages.length > 1}
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<span class="text-pc-darkblue font-semibold">
|
||||
<span
|
||||
class="text-pc-darkblue dark:text-gray-200 font-semibold transition-colors duration-200"
|
||||
>
|
||||
{validPercentages[currentPercentageIndex].value}%
|
||||
</span>
|
||||
<span class="ml-1">
|
||||
@@ -89,7 +99,7 @@
|
||||
{#if validPercentages.length > 1}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 ml-1 text-gray-400"
|
||||
class="h-4 w-4 ml-1 text-gray-400 dark:text-gray-500 transition-colors duration-200"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
<h2 class="text-lg text-gray-600 font-bold uppercase">
|
||||
<h2
|
||||
class="text-lg text-gray-600 dark:text-gray-300 font-bold uppercase transition-colors duration-200"
|
||||
>
|
||||
<slot />
|
||||
</h2>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
<span title="Test / Preview" class="select-none px-1 border-2 relative -top-1 rounded-lg text-xs">
|
||||
<span
|
||||
title="Test / Preview"
|
||||
class="select-none px-1 border-2 border-gray-400 dark:border-gray-500 relative -top-1 rounded-lg text-xs text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 transition-colors duration-200"
|
||||
>
|
||||
test
|
||||
</span>
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
|
||||
<label class="flex flex-col py-2">
|
||||
<div class="flex items-center">
|
||||
<p class="font-semibold text-slate-600 py-2">
|
||||
<p class="font-semibold text-slate-600 dark:text-gray-300 py-2 transition-colors duration-200">
|
||||
<slot />
|
||||
</p>
|
||||
{#if toolTipText.length > 0}
|
||||
@@ -68,8 +68,10 @@
|
||||
</ToolTip>
|
||||
{/if}
|
||||
{#if optional === true}
|
||||
<div class="bg-gray-100 ml-2 px-2 rounded-md">
|
||||
<p class="text-slate-600 text-xs">optional</p>
|
||||
<div class="bg-gray-100 dark:bg-gray-700 ml-2 px-2 rounded-md transition-colors duration-200">
|
||||
<p class="text-slate-600 dark:text-gray-300 text-xs transition-colors duration-200">
|
||||
optional
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -93,7 +95,7 @@
|
||||
{required}
|
||||
{placeholder}
|
||||
{pattern}
|
||||
class="text-ellipsis row-start-1 row-span-3 justify-self-center rounded-md py-2 pl-2 text-gray-600 border border-transparent focus:outline-none focus:border-solid focus:border-slate-400 focus:bg-gray-100 bg-grayblue-light font-normal"
|
||||
class="text-ellipsis row-start-1 row-span-3 justify-self-center rounded-md py-2 pl-2 text-gray-600 dark:text-gray-200 border border-transparent dark:border-gray-600 focus:outline-none focus:border-solid focus:border-slate-400 dark:focus:border-blue-500 focus:bg-gray-100 dark:focus:bg-gray-600 bg-grayblue-light dark:bg-gray-700 font-normal transition-colors duration-200"
|
||||
class:w-24={width === 'small'}
|
||||
class:w-60={width === 'medium'}
|
||||
class:w-95={width === 'large'}
|
||||
|
||||
@@ -117,7 +117,9 @@
|
||||
<div class="flex flex-col justify-start">
|
||||
<label class="flex flex-col py-2 relative">
|
||||
<div class="flex items-center">
|
||||
<p class="font-semibold text-slate-600 py-2">
|
||||
<p
|
||||
class="font-semibold text-slate-600 dark:text-gray-300 py-2 transition-colors duration-200"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
{#if toolTipText.length > 0}
|
||||
@@ -126,8 +128,10 @@
|
||||
</ToolTip>
|
||||
{/if}
|
||||
{#if optional === true}
|
||||
<div class="bg-gray-100 ml-2 px-2 rounded-md">
|
||||
<p class="text-slate-600 text-xs">optional</p>
|
||||
<div
|
||||
class="bg-gray-100 dark:bg-gray-700 ml-2 px-2 rounded-md transition-colors duration-200"
|
||||
>
|
||||
<p class="text-slate-600 dark:text-gray-300 text-xs">optional</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -151,7 +155,7 @@
|
||||
{id}
|
||||
required={required && !value.length}
|
||||
autocomplete="off"
|
||||
class="w-full relative rounded-md py-2 pl-4 focus:pl-10 text-gray-600 border border-transparent focus:outline-none focus:border-solid focus:border focus:border-slate-400 focus:bg-gray-100 bg-grayblue-light font-normal cursor-pointer focus:cursor-text"
|
||||
class="w-full relative rounded-md py-2 pl-4 focus:pl-10 text-gray-600 dark:text-gray-300 border border-transparent focus:outline-none focus:border-solid focus:border focus:border-slate-400 dark:focus:border-gray-500 focus:bg-gray-100 dark:focus:bg-gray-600 bg-grayblue-light dark:bg-gray-700 font-normal cursor-pointer focus:cursor-text transition-colors duration-200"
|
||||
/>
|
||||
{#if showSelection}
|
||||
<img
|
||||
@@ -165,13 +169,13 @@
|
||||
{#if showSelection}
|
||||
<div class="w-60 absolute top-10 z-50">
|
||||
<ul
|
||||
class="bg-gray-100 list-none mt-4 rounded-md min-w-fit shadow-md border max-h-40 overflow-y-scroll"
|
||||
class="bg-gray-100 dark:bg-gray-700 list-none mt-4 rounded-md min-w-fit shadow-md border border-gray-200 dark:border-gray-600 max-h-40 overflow-y-scroll transition-colors duration-200"
|
||||
>
|
||||
{#if options.length}
|
||||
{#each filteredOptions as option}
|
||||
<li>
|
||||
<button
|
||||
class="w-full text-left bg-slate-100 rounded-md text-gray-600 hover:bg-grayblue-dark hover:text-white py-2 px-2 cursor-pointer"
|
||||
class="w-full text-left bg-slate-100 dark:bg-gray-600 rounded-md text-gray-600 dark:text-gray-200 hover:bg-grayblue-dark dark:hover:bg-gray-500 hover:text-white py-2 px-2 cursor-pointer transition-colors duration-200"
|
||||
on:click={() => {
|
||||
onClickSelectedOption(option);
|
||||
}}
|
||||
@@ -181,7 +185,11 @@
|
||||
</li>
|
||||
{/each}
|
||||
{:else}
|
||||
<li class="w-full bg-slate-100 rounded-md text-gray-600 py-2 px-2">List is empty</li>
|
||||
<li
|
||||
class="w-full bg-slate-100 dark:bg-gray-600 rounded-md text-gray-600 dark:text-gray-300 py-2 px-2 transition-colors duration-200"
|
||||
>
|
||||
List is empty
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -192,7 +200,7 @@
|
||||
on:click|preventDefault={removeSelection}
|
||||
on:keypress|preventDefault={removeSelection}
|
||||
data-value={option}
|
||||
class="flex flex-row items-center bg-gray-100 hover:bg-gray-200 px-2 py-2 mt-2 mr-2 rounded-md"
|
||||
class="flex flex-row items-center bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 px-2 py-2 mt-2 mr-2 rounded-md text-gray-900 dark:text-gray-100 transition-colors duration-200"
|
||||
>
|
||||
{option}
|
||||
<img class="w-4 ml-2 pointer-events-none" src="/delete2.svg" alt="delete" />
|
||||
|
||||
@@ -71,7 +71,9 @@
|
||||
<div class="flex flex-col justify-start">
|
||||
<label class="flex flex-col py-2 relative">
|
||||
<div class="flex items-center">
|
||||
<p class="font-semibold text-slate-600 py-2">
|
||||
<p
|
||||
class="font-semibold text-slate-600 dark:text-gray-300 py-2 transition-colors duration-200"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
{#if toolTipText.length > 0}
|
||||
@@ -97,7 +99,7 @@
|
||||
on:keyup={_onKeyUp}
|
||||
on:click|stopPropagation={() => {}}
|
||||
autocomplete="off"
|
||||
class="w-full relative rounded-md py-2 pl-4 focus:pl-10 text-gray-600 border border-transparent focus:outline-none focus:border-solid focus:border focus:border-slate-400 focus:bg-gray-100 bg-grayblue-light font-normal cursor-pointer focus:cursor-text"
|
||||
class="w-full relative rounded-md py-2 pl-4 focus:pl-10 text-gray-600 dark:text-gray-100 border border-transparent focus:outline-none focus:border-solid focus:border focus:border-slate-400 dark:focus:border-gray-500 focus:bg-gray-100 dark:focus:bg-gray-700 bg-grayblue-light dark:bg-gray-600 font-normal cursor-pointer focus:cursor-text transition-colors duration-200"
|
||||
{id}
|
||||
{required}
|
||||
/>
|
||||
@@ -109,12 +111,12 @@
|
||||
{#if options.length && showSelection}
|
||||
<div class="w-96 absolute top-10 z-50">
|
||||
<ul
|
||||
class="bg-gray-100 list-none mt-4 rounded-md min-w-fit shadow-md border max-h-40 overflow-y-scroll"
|
||||
class="bg-gray-100 dark:bg-gray-700 list-none mt-4 rounded-md min-w-fit shadow-md dark:shadow-gray-900/50 border border-gray-200 dark:border-gray-600 max-h-40 overflow-y-scroll transition-colors duration-200"
|
||||
>
|
||||
{#each options as option}
|
||||
<li class="break-words">
|
||||
<button
|
||||
class="w-full text-left bg-slate-100 rounded-md text-gray-600 hover:bg-grayblue-dark hover:text-white py-2 px-2 cursor-pointer"
|
||||
class="w-full text-left bg-slate-100 dark:bg-gray-700 rounded-md text-gray-600 dark:text-gray-200 hover:bg-grayblue-dark dark:hover:bg-gray-600 hover:text-white py-2 px-2 cursor-pointer transition-colors duration-200"
|
||||
on:click|preventDefault={() => {
|
||||
value = '';
|
||||
showSelection = false;
|
||||
|
||||
@@ -223,7 +223,10 @@
|
||||
>
|
||||
<label class="flex flex-col py-2 relative" class:py-2={!inline} class:pr-2={inline}>
|
||||
<div class="flex items-center">
|
||||
<p id={labelId} class="font-semibold text-slate-600 py-1">
|
||||
<p
|
||||
id={labelId}
|
||||
class="font-semibold text-slate-600 dark:text-gray-300 py-1 transition-colors duration-200"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
{#if toolTipText.length > 0}
|
||||
@@ -232,8 +235,10 @@
|
||||
</ToolTip>
|
||||
{/if}
|
||||
{#if optional === true}
|
||||
<div class="bg-gray-100 ml-2 px-2 rounded-md">
|
||||
<p class="text-slate-600 text-xs">optional</p>
|
||||
<div
|
||||
class="bg-gray-100 dark:bg-gray-700 ml-2 px-2 rounded-md transition-colors duration-200"
|
||||
>
|
||||
<p class="text-slate-600 dark:text-gray-300 text-xs">optional</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -261,7 +266,7 @@
|
||||
on:keydown={handleKeyDown}
|
||||
on:click={handleFocus}
|
||||
autocomplete="off"
|
||||
class="w-full relative rounded-md py-2 pr-10 text-gray-600 border border-transparent focus:outline-none focus:border-solid focus:border focus:border-slate-400 focus:bg-gray-100 bg-grayblue-light font-normal cursor-pointer focus:cursor-text"
|
||||
class="w-full relative rounded-md py-2 pr-10 text-gray-600 dark:text-gray-300 border border-transparent focus:outline-none focus:border-solid focus:border focus:border-slate-400 dark:focus:border-gray-500 focus:bg-gray-100 dark:focus:bg-gray-600 bg-grayblue-light dark:bg-gray-700 font-normal cursor-pointer focus:cursor-text transition-colors duration-200"
|
||||
class:pl-10={showDropdown}
|
||||
class:pl-4={!showDropdown}
|
||||
class:text-gray-400={!hasValue && !showDropdown}
|
||||
@@ -314,7 +319,7 @@
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
aria-labelledby={labelId}
|
||||
class="bg-gray-100 list-none mt-4 z-[999] rounded-md min-w-fit shadow-md border max-h-40 overflow-y-scroll"
|
||||
class="bg-gray-100 dark:bg-gray-700 list-none mt-4 z-[999] rounded-md min-w-fit shadow-md border border-gray-200 dark:border-gray-600 max-h-40 overflow-y-scroll transition-colors duration-200"
|
||||
>
|
||||
{#if allOptions.length}
|
||||
{#each allOptions as option, index}
|
||||
@@ -323,7 +328,7 @@
|
||||
id="{listboxId}-option-{index}"
|
||||
role="option"
|
||||
aria-selected={value === option}
|
||||
class="w-full text-left bg-slate-100 rounded-md text-gray-600 hover:bg-grayblue-dark hover:text-white py-2 px-2 cursor-pointer focus:bg-grayblue-dark focus:text-white focus:outline-none"
|
||||
class="w-full text-left bg-slate-100 dark:bg-gray-600 rounded-md text-gray-600 dark:text-gray-200 hover:bg-grayblue-dark dark:hover:bg-gray-500 hover:text-white py-2 px-2 cursor-pointer focus:bg-grayblue-dark dark:focus:bg-gray-500 focus:text-white focus:outline-none transition-colors duration-200"
|
||||
on:click={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -337,7 +342,10 @@
|
||||
</li>
|
||||
{/each}
|
||||
{:else}
|
||||
<li role="none" class="w-full bg-slate-100 rounded-md text-gray-600 py-2 px-2">
|
||||
<li
|
||||
role="none"
|
||||
class="w-full bg-slate-100 dark:bg-gray-600 rounded-md text-gray-600 dark:text-gray-300 py-2 px-2 transition-colors duration-200"
|
||||
>
|
||||
No options available
|
||||
</li>
|
||||
{/if}
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
|
||||
<label class="flex flex-col py-2 w-60" class:w-full={fullWidth}>
|
||||
<div class="flex items-center">
|
||||
<p class="font-bold text-slate-600 py-2">
|
||||
<p class="font-bold text-slate-600 dark:text-gray-300 py-2 transition-colors duration-200">
|
||||
<slot />
|
||||
</p>
|
||||
{#if toolTipText.length > 0}
|
||||
@@ -61,8 +61,10 @@
|
||||
</ToolTip>
|
||||
{/if}
|
||||
{#if optional === true}
|
||||
<div class="bg-gray-100 ml-2 px-2 rounded-md">
|
||||
<p class="text-slate-600 text-xs">optional</p>
|
||||
<div class="bg-gray-100 dark:bg-gray-700 ml-2 px-2 rounded-md transition-colors duration-200">
|
||||
<p class="text-slate-600 dark:text-gray-300 text-xs transition-colors duration-200">
|
||||
optional
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -76,7 +78,7 @@
|
||||
maxlength={maxLength}
|
||||
{readonly}
|
||||
{placeholder}
|
||||
class=" focus:outline-none pl-2 border border-transparent rounded-md focus:border-solid text-gray-600 focus:bg-gray-100 font-light focus:border-slate-400 bg-grayblue-light"
|
||||
class=" focus:outline-none pl-2 border border-transparent dark:border-gray-600 rounded-md focus:border-solid text-gray-600 dark:text-gray-200 focus:bg-gray-100 dark:focus:bg-gray-600 font-light focus:border-slate-400 dark:focus:border-blue-500 bg-grayblue-light dark:bg-gray-700 transition-colors duration-200"
|
||||
class:h-16={height === 'small'}
|
||||
class:h-28={height === 'medium'}
|
||||
class:h-48={height === 'large'}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<script>
|
||||
import { theme, toggleMode, modeLight, modeDark } from '$lib/theme.js';
|
||||
|
||||
// reactive statement to determine current theme
|
||||
$: isDark = $theme === modeDark;
|
||||
|
||||
const handleToggle = () => {
|
||||
toggleMode();
|
||||
};
|
||||
</script>
|
||||
|
||||
<button
|
||||
on:click={handleToggle}
|
||||
class="relative inline-flex items-center justify-center w-6 h-6 rounded transition-colors duration-200 focus:outline-none"
|
||||
title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
<!-- sun icon for light mode -->
|
||||
<svg
|
||||
class="w-4 h-4 text-yellow-400 transition-all duration-200 {isDark
|
||||
? 'opacity-0 rotate-90 scale-0'
|
||||
: 'opacity-100 rotate-0 scale-100'} absolute"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- moon icon for dark mode -->
|
||||
<svg
|
||||
class="w-4 h-4 text-blue-300 transition-all duration-200 {isDark
|
||||
? 'opacity-100 rotate-0 scale-100'
|
||||
: 'opacity-0 -rotate-90 scale-0'} absolute"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
|
||||
</svg>
|
||||
|
||||
<!-- invisible placeholder to maintain button size -->
|
||||
<div class="w-4 h-4 opacity-0">
|
||||
<svg class="w-4 h-4" viewBox="0 0 20 20">
|
||||
<path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1z" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
@@ -1,6 +1,6 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 text-slate-400"
|
||||
class="h-5 w-5 text-slate-400 dark:text-gray-400 transition-colors duration-200"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
|
||||
|
Before Width: | Height: | Size: 322 B After Width: | Height: | Size: 372 B |
@@ -30,7 +30,7 @@
|
||||
}
|
||||
}}
|
||||
transition:slide|global
|
||||
class="flex items-center bg-pleasant-gray text-gray-500 shadow-lg capitalize text-xl p-4 first:mt-0 mt-4 min-w-max rounded-md justify-self-center w-full"
|
||||
class="flex items-center bg-pleasant-gray dark:bg-gray-700 text-gray-500 dark:text-gray-200 shadow-lg dark:shadow-gray-900/50 capitalize text-xl p-4 first:mt-0 mt-4 min-w-max rounded-md justify-self-center w-full transition-colors duration-200"
|
||||
>
|
||||
<!-- <img class="w-9 mr-6" draggable="false" src={src1} alt="checkmark success" /> -->
|
||||
<svg class="w-11 mr-6" viewBox="0 0 32.25 32.4">
|
||||
@@ -60,7 +60,7 @@
|
||||
}
|
||||
}}
|
||||
transition:slide|global
|
||||
class="flex items-center bg-pleasant-gray text-gray-500 shadow-lg capitalize text-xl p-4 first:mt-0 mt-4 min-w-max rounded-md justify-self-center w-full"
|
||||
class="flex items-center bg-pleasant-gray dark:bg-gray-700 text-gray-500 dark:text-gray-200 shadow-lg dark:shadow-gray-900/50 capitalize text-xl p-4 first:mt-0 mt-4 min-w-max rounded-md justify-self-center w-full transition-colors duration-200"
|
||||
>
|
||||
<!-- <img class="w-9 mr-6" draggable="false" src="/t-warning.svg" alt="checkmark warning" /> -->
|
||||
<svg class="w-11 mr-6" viewBox="0 0 36.26 32.41">
|
||||
@@ -101,7 +101,7 @@
|
||||
}
|
||||
}}
|
||||
transition:slide|global
|
||||
class="flex items-center bg-pleasant-gray text-gray-500 shadow-lg capitalize text-xl p-4 first:mt-0 mt-4 min-w-max rounded-md justify-self-center w-full"
|
||||
class="flex items-center bg-pleasant-gray dark:bg-gray-700 text-gray-500 dark:text-gray-200 shadow-lg dark:shadow-gray-900/50 capitalize text-xl p-4 first:mt-0 mt-4 min-w-max rounded-md justify-self-center w-full transition-colors duration-200"
|
||||
>
|
||||
<!-- <img class="w-9 mr-6" draggable="false" src="/t-error.svg" alt="checkmark error" /> -->
|
||||
<svg class="w-11 mr-6" viewBox="0 0 32.98 32.98">
|
||||
@@ -143,7 +143,7 @@
|
||||
removeToast(toast.id);
|
||||
}
|
||||
}}
|
||||
class="flex items-center bg-pleasant-gray text-gray-500 shadow-lg capitalize text-xl p-4 first:mt-0 mt-4 min-w-max rounded-md justify-self-center w-full"
|
||||
class="flex items-center bg-pleasant-gray dark:bg-gray-700 text-gray-500 dark:text-gray-200 shadow-lg dark:shadow-gray-900/50 capitalize text-xl p-4 first:mt-0 mt-4 min-w-max rounded-md justify-self-center w-full transition-colors duration-200"
|
||||
>
|
||||
<!-- <img class="w-9 mr-6" draggable="false" src="/t-info.svg" alt="checkmark info" /> -->
|
||||
<svg class="w-11 mr-6" viewBox="0 0 32.79 32.79">
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="rounded-full bg-gray-600 text-white w-4 h-4 z-30 text-center ml-2 relative cursor-pointer hover:bg-gray-500"
|
||||
class="rounded-full bg-gray-600 dark:bg-gray-500 text-white w-4 h-4 z-30 text-center ml-2 relative cursor-pointer hover:bg-gray-500 dark:hover:bg-gray-400 transition-colors duration-200"
|
||||
role="tooltip"
|
||||
aria-describedby={tooltipId}
|
||||
on:mouseenter={(e) => {
|
||||
@@ -27,7 +27,7 @@
|
||||
{#if showToolTip}
|
||||
<div
|
||||
id={tooltipId}
|
||||
class="bg-gray-600 text-white w-max mt-2 px-2 py-2 rounded-md shadow-xl z-40"
|
||||
class="bg-gray-600 dark:bg-gray-700 text-white w-max mt-2 px-2 py-2 rounded-md shadow-xl dark:shadow-gray-900/70 z-40 transition-colors duration-200"
|
||||
style={tooltipStyle}
|
||||
>
|
||||
<p><slot /></p>
|
||||
|
||||
@@ -374,8 +374,10 @@
|
||||
let isDetailsVisible = $$slots.default;
|
||||
</script>
|
||||
|
||||
<div class="w-80vw z-[9000] col-start-1 col-end-4 flex flex-col">
|
||||
<div class="">
|
||||
<div
|
||||
class="w-80vw z-[9000] col-start-1 col-end-4 flex flex-col bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 transition-colors duration-200"
|
||||
>
|
||||
<div class="bg-white dark:bg-gray-800 transition-colors duration-200">
|
||||
<div class="mt-4 flex items-center flex-wrap">
|
||||
<!-- mode tabs -->
|
||||
<div class="flex">
|
||||
@@ -385,9 +387,10 @@
|
||||
isDetailsVisible = true;
|
||||
}}
|
||||
type="button"
|
||||
class="h-8 border-2 rounded-md w-36 text-center cursor-pointer hover:opacity-80 flex items-center justify-center gap-2 mb-2"
|
||||
class="h-8 border-2 border-gray-300 dark:border-gray-600 rounded-md w-36 text-center cursor-pointer hover:opacity-80 flex items-center justify-center gap-2 mb-2 text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 transition-colors duration-200"
|
||||
class:font-bold={isDetailsVisible}
|
||||
class:bg-cta-blue={isDetailsVisible}
|
||||
class:dark:bg-indigo-600={isDetailsVisible}
|
||||
class:text-white={isDetailsVisible}
|
||||
>
|
||||
<svg
|
||||
@@ -410,9 +413,10 @@
|
||||
isDetailsVisible = false;
|
||||
}}
|
||||
type="button"
|
||||
class="h-8 border-2 rounded-md w-36 text-center cursor-pointer hover:opacity-80 ml-1 flex items-center justify-center gap-2"
|
||||
class="h-8 border-2 border-gray-300 dark:border-gray-600 rounded-md w-36 text-center cursor-pointer hover:opacity-80 ml-1 flex items-center justify-center gap-2 text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 transition-colors duration-200"
|
||||
class:font-bold={!isDetailsVisible}
|
||||
class:bg-cta-blue={!isDetailsVisible}
|
||||
class:dark:bg-indigo-600={!isDetailsVisible}
|
||||
class:text-white={!isDetailsVisible}
|
||||
>
|
||||
<svg
|
||||
@@ -443,7 +447,7 @@
|
||||
<button
|
||||
type="button"
|
||||
on:click={triggerFileInput}
|
||||
class="h-8 border-2 rounded-md px-3 text-center cursor-pointer hover:opacity-80 flex items-center justify-center gap-2"
|
||||
class="h-8 border-2 border-gray-300 dark:border-gray-600 rounded-md px-3 text-center cursor-pointer hover:opacity-80 flex items-center justify-center gap-2 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 transition-colors duration-200"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -476,9 +480,10 @@
|
||||
updatePreview();
|
||||
}
|
||||
}}
|
||||
class="h-8 border-2 rounded-md px-3 text-center cursor-pointer hover:opacity-80 flex items-center justify-center gap-2"
|
||||
class="h-8 border-2 border-gray-300 dark:border-gray-600 rounded-md px-3 text-center cursor-pointer hover:opacity-80 flex items-center justify-center gap-2 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 transition-colors duration-200"
|
||||
class:font-bold={isPreviewVisible}
|
||||
class:bg-cta-blue={isPreviewVisible}
|
||||
class:dark:bg-indigo-600={isPreviewVisible}
|
||||
class:text-white={isPreviewVisible}
|
||||
>
|
||||
<svg
|
||||
@@ -499,7 +504,7 @@
|
||||
|
||||
<!-- template selector -->
|
||||
<select
|
||||
class="h-8 border-2 rounded-md px-3 bg-white text-black cursor-pointer"
|
||||
class="h-8 border-2 border-gray-300 dark:border-gray-600 rounded-md px-3 bg-white dark:bg-gray-700 text-black dark:text-gray-200 cursor-pointer transition-colors duration-200"
|
||||
on:change={(e) => {
|
||||
const t = /** @type {HTMLSelectElement} */ (e.target);
|
||||
if (t.value) {
|
||||
@@ -524,7 +529,7 @@
|
||||
id="domain-select"
|
||||
bind:value={selectedDomain}
|
||||
on:change={selectPreviewDomain}
|
||||
class="h-8 border-2 rounded-md px-3 bg-white text-black cursor-pointer"
|
||||
class="h-8 border-2 border-gray-300 dark:border-gray-600 rounded-md px-3 bg-white dark:bg-gray-700 text-black dark:text-gray-200 cursor-pointer transition-colors duration-200"
|
||||
>
|
||||
<option value="">Select preview domain...</option>
|
||||
{#each domainMap.values() as domain}
|
||||
@@ -537,7 +542,7 @@
|
||||
<button
|
||||
type="button"
|
||||
on:click={openFullPagePreview}
|
||||
class="h-8 border-2 rounded-md px-3 text-center cursor-pointer hover:opacity-80 flex items-center justify-center gap-2"
|
||||
class="h-8 border-2 border-gray-300 dark:border-gray-600 rounded-md px-3 text-center cursor-pointer hover:opacity-80 flex items-center justify-center gap-2 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 transition-colors duration-200"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -563,8 +568,7 @@
|
||||
<!-- details -->
|
||||
{#if $$slots.default}
|
||||
<div
|
||||
class="flex flex-col lg:flex-row lg:items-center h-auto w-full justify-between mb-4"
|
||||
class:lg:h-28={isDetailsVisible}
|
||||
class="flex flex-col lg:flex-row lg:items-center h-auto w-full justify-between mb-4 bg-white dark:bg-gray-800 transition-colors duration-200"
|
||||
>
|
||||
{#if isDetailsVisible}
|
||||
<slot />
|
||||
@@ -575,21 +579,30 @@
|
||||
|
||||
<div class="flex h-full">
|
||||
<div
|
||||
class="flex flex-col border-2 border-black {!isPreviewVisible ? 'w-80vw' : 'w-1/2'}"
|
||||
class="flex flex-col border-2 border-black dark:border-gray-600 bg-white dark:bg-gray-900 {!isPreviewVisible
|
||||
? 'w-80vw'
|
||||
: 'w-1/2'} transition-colors duration-200"
|
||||
class:h-55vh={isDetailsVisible}
|
||||
class:h-67vh={!isDetailsVisible}
|
||||
>
|
||||
<div id="monaco-editor" class="h-full" />
|
||||
</div>
|
||||
<div class="bg-cta-blue cursor-move w-1" class:hidden={!isPreviewVisible}> </div>
|
||||
<div
|
||||
class="bg-cta-blue dark:bg-indigo-600 cursor-move w-1 transition-colors duration-200"
|
||||
class:hidden={!isPreviewVisible}
|
||||
>
|
||||
|
||||
</div>
|
||||
{#if isPreviewVisible}
|
||||
<div class="w-1/2 border-2 border-black">
|
||||
<div
|
||||
class="w-1/2 border-2 border-black dark:border-gray-600 bg-white transition-colors duration-200"
|
||||
>
|
||||
<iframe
|
||||
bind:this={previewFrame}
|
||||
sandbox="allow-forms allow-modals allow-popups allow-scripts allow-pointer-lock"
|
||||
title="preview"
|
||||
class="h-full w-full"
|
||||
style="color-scheme: normal"
|
||||
style="color-scheme: light;"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
let editor = null;
|
||||
let editorContainer = null;
|
||||
let isDark = false;
|
||||
|
||||
const heightClasses = {
|
||||
small: 'h-64',
|
||||
@@ -18,7 +19,38 @@
|
||||
large: 'h-96'
|
||||
};
|
||||
|
||||
// Check for dark mode
|
||||
const checkDarkMode = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
isDark = document.documentElement.classList.contains('dark');
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
checkDarkMode();
|
||||
|
||||
// Watch for dark mode changes
|
||||
const observer = new MutationObserver(() => {
|
||||
const newIsDark = document.documentElement.classList.contains('dark');
|
||||
if (newIsDark !== isDark) {
|
||||
isDark = newIsDark;
|
||||
if (editor) {
|
||||
monaco.editor.setTheme(isDark ? 'vs-dark' : 'vs-light');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class']
|
||||
});
|
||||
|
||||
const cleanup = () => {
|
||||
observer.disconnect();
|
||||
if (editor) {
|
||||
editor.dispose();
|
||||
}
|
||||
};
|
||||
self.MonacoEnvironment = {
|
||||
getWorker: function (_, label) {
|
||||
if (label === 'json') {
|
||||
@@ -31,7 +63,7 @@
|
||||
editor = monaco.editor.create(editorContainer, {
|
||||
value: value || '',
|
||||
language: language,
|
||||
theme: 'vs-dark',
|
||||
theme: isDark ? 'vs-dark' : 'vs-light',
|
||||
automaticLayout: true,
|
||||
minimap: {
|
||||
enabled: false
|
||||
@@ -60,11 +92,7 @@
|
||||
value = editor.getValue();
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (editor) {
|
||||
editor.dispose();
|
||||
}
|
||||
};
|
||||
return cleanup;
|
||||
});
|
||||
|
||||
// Watch for external value changes
|
||||
@@ -86,30 +114,38 @@
|
||||
<div class="w-full">
|
||||
<div
|
||||
bind:this={editorContainer}
|
||||
class="border border-gray-300 rounded-md {heightClasses[height]} w-full"
|
||||
class="border border-gray-300 dark:border-gray-600 rounded-md {heightClasses[
|
||||
height
|
||||
]} w-full transition-colors duration-200"
|
||||
></div>
|
||||
{#if placeholder}
|
||||
<div class="mt-2">
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => (showExample = !showExample)}
|
||||
class="text-xs text-blue-600 hover:text-blue-800 underline focus:outline-none"
|
||||
class="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline focus:outline-none transition-colors duration-200"
|
||||
>
|
||||
{showExample ? 'Hide' : 'Show'} example
|
||||
</button>
|
||||
{#if showExample}
|
||||
<div class="mt-2 p-3 bg-gray-50 border border-gray-200 rounded-md">
|
||||
<div
|
||||
class="mt-2 p-3 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-md transition-colors duration-200"
|
||||
>
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<span class="text-xs font-medium text-gray-700">Example:</span>
|
||||
<span
|
||||
class="text-xs font-medium text-gray-700 dark:text-gray-300 transition-colors duration-200"
|
||||
>Example:</span
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
on:click={loadExample}
|
||||
class="text-xs text-blue-600 hover:text-blue-800 underline"
|
||||
class="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline transition-colors duration-200"
|
||||
>
|
||||
Load example
|
||||
</button>
|
||||
</div>
|
||||
<pre class="text-xs text-gray-600 whitespace-pre-wrap">{placeholder}</pre>
|
||||
<pre
|
||||
class="text-xs text-gray-600 dark:text-gray-300 whitespace-pre-wrap transition-colors duration-200">{placeholder}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -92,19 +92,19 @@
|
||||
|
||||
<div class="flex">
|
||||
<nav
|
||||
class="hidden lg:flex flex-col transition-all fixed top-16 z-10 bg-gradient-to-b from-pc-darkblue to-indigo-400 rounded-br-lg overflow-y-auto overflow-x-hidden min-h-0 max-h-[calc(100vh-4rem)] box-content border-r-[1px] border-pc-darkblue"
|
||||
class="hidden lg:flex flex-col transition-all fixed top-16 z-10 bg-gradient-to-b from-pc-darkblue to-indigo-400 dark:from-gray-900 dark:to-gray-800 rounded-br-lg overflow-y-auto overflow-x-hidden min-h-0 max-h-[calc(100vh-4rem)] box-content border-r-[1px] border-pc-darkblue dark:border-gray-700"
|
||||
class:w-40={isExpanded}
|
||||
class:w-12={!isExpanded}
|
||||
>
|
||||
<div
|
||||
class="sticky top-0 bg-highlight-blue/20 border-b w-full border-blue-700/30 transform-none"
|
||||
class="sticky top-0 bg-highlight-blue/20 dark:bg-gray-800/70 border-b w-full border-blue-700/30 dark:border-gray-600 transform-none transition-colors duration-200"
|
||||
>
|
||||
<button
|
||||
class="w-full flex items-center justify-center rounded-md hover:bg-blue-600/30 transition-colors group px-3 py-2"
|
||||
class="w-full flex items-center justify-center rounded-md hover:bg-blue-600/30 dark:hover:bg-gray-700/70 transition-colors group px-3 py-2"
|
||||
on:click={() => (isExpanded = !isExpanded)}
|
||||
>
|
||||
<svg
|
||||
class="text-blue-100 duration-200 w-6"
|
||||
class="text-blue-100 dark:text-gray-100 duration-200 w-6 transition-colors"
|
||||
class:rotate-180={!isExpanded}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
@@ -123,13 +123,15 @@
|
||||
|
||||
<!-- Navigation Items -->
|
||||
<div
|
||||
class="flex flex-col py-4 flex-1 overflow-y-auto {scrollBarClassesVertical} [&::-webkit-scrollbar-track]:bg-cta-blue"
|
||||
class="flex flex-col py-4 flex-1 overflow-y-auto {scrollBarClassesVertical} [&::-webkit-scrollbar-track]:bg-cta-blue dark:[&::-webkit-scrollbar-track]:bg-gray-800"
|
||||
>
|
||||
{#each menu as link}
|
||||
{#if link.type === 'submenu'}
|
||||
<div class="py-1 mt-4 first:mt-0">
|
||||
{#if isExpanded}
|
||||
<div class="px-3 py-2 text-xs font-semibold text-blue-100 uppercase tracking-wider">
|
||||
<div
|
||||
class="px-3 py-2 text-xs font-semibold text-blue-100 dark:text-gray-200 uppercase tracking-wider transition-colors duration-200"
|
||||
>
|
||||
{link.label}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -139,8 +141,8 @@
|
||||
<a
|
||||
class="flex items-center px-3 py-2 text-sm transition-all duration-150 relative group
|
||||
{$page.url.pathname === item.route
|
||||
? 'text-white font-medium bg-active-blue shadow-md'
|
||||
: 'text-blue-100 hover:shadow-md hover:bg-highlight-blue hover:text-white'}"
|
||||
? 'text-white font-medium bg-active-blue dark:bg-indigo-600 shadow-md'
|
||||
: 'text-blue-100 dark:text-gray-200 hover:shadow-md hover:bg-highlight-blue dark:hover:bg-gray-700 hover:text-white dark:hover:text-gray-100'}"
|
||||
class:hidden={shouldHideMenuItem(item.route)}
|
||||
draggable="false"
|
||||
href={item.route}
|
||||
@@ -164,7 +166,7 @@
|
||||
{/if}
|
||||
|
||||
{#if $page.url.pathname === item.route}
|
||||
<div class="absolute left-0 top-0 bottom-0 w-1 bg-white"></div>
|
||||
<div class="absolute left-0 top-0 bottom-0 w-1 bg-white dark:bg-blue-400"></div>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
@@ -174,8 +176,8 @@
|
||||
<a
|
||||
class="flex items-center px-3 py-2 text-sm transition-all duration-150 relative group
|
||||
{$page.url.pathname === link.route
|
||||
? 'text-white font-medium bg-active-blue shadow-md'
|
||||
: 'text-blue-100 hover:text-white'}"
|
||||
? 'text-white font-medium bg-active-blue dark:bg-indigo-600 shadow-md'
|
||||
: 'text-blue-100 dark:text-gray-200 hover:text-white dark:hover:text-gray-100 dark:hover:bg-gray-700'}"
|
||||
draggable="false"
|
||||
href={link.route}
|
||||
>
|
||||
@@ -188,16 +190,16 @@
|
||||
<span class="ml-3 truncate">{link.label}</span>
|
||||
{:else}
|
||||
<div
|
||||
class="absolute left-14 rounded bg-gray-900 text-white px-2 py-1 ml-6 text-sm
|
||||
invisible opacity-0 -translate-x-3 group-hover:visible group-hover:opacity-100 group-hover:translate-x-0
|
||||
transition-all duration-150 whitespace-nowrap z-50 shadow-lg"
|
||||
class="absolute left-14 rounded bg-gray-900 dark:bg-gray-800 text-white dark:text-gray-100 px-2 py-1 ml-6 text-sm
|
||||
invisible opacity-0 -translate-x-3 group-hover:visible group-hover:opacity-100 group-hover:translate-x-0
|
||||
transition-all duration-150 whitespace-nowrap z-50 shadow-lg border dark:border-gray-600"
|
||||
>
|
||||
{link.label}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $page.url.pathname === link.route}
|
||||
<div class="absolute left-0 top-0 bottom-0 w-1 bg-white"></div>
|
||||
<div class="absolute left-0 top-0 bottom-0 w-1 bg-white dark:bg-blue-400"></div>
|
||||
{/if}
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { AppStateService } from '$lib/service/appState';
|
||||
import { onMount } from 'svelte';
|
||||
import Logo from './Logo.svelte';
|
||||
import ThemeToggle from '../ThemeToggle.svelte';
|
||||
|
||||
const appState = AppStateService.instance;
|
||||
|
||||
@@ -112,17 +113,19 @@
|
||||
$: initials = getInitials(username || 'U');
|
||||
</script>
|
||||
|
||||
<div class="sticky top-0 z-20 col-span-12 h-16 bg-pc-darkblue flex justify-between items-center">
|
||||
<div
|
||||
class="header-container sticky top-0 z-20 col-span-12 h-16 bg-pc-darkblue dark:bg-gray-800 border-b border-pc-darkblue/20 dark:border-gray-700 flex justify-between items-center"
|
||||
>
|
||||
<Logo />
|
||||
{#if isInstalled}
|
||||
<div class="hidden lg:flex flex-row items-center px-8 h-full justify-self-end">
|
||||
{#if context.current === AppStateService.CONTEXT.COMPANY}
|
||||
<p class="text-slate-300 uppercase font-bold text-lg mr-4">
|
||||
<p class="text-slate-300 dark:text-gray-300 uppercase font-bold text-lg mr-4">
|
||||
{context.companyName}
|
||||
</p>
|
||||
{/if}
|
||||
<button
|
||||
class="rounded-md h-3/4 px-8 text-white bg-indigo-500 hover:bg-cta-blue uppercase font-semibold mr-4"
|
||||
class="rounded-md h-3/4 px-8 text-white bg-indigo-500 hover:bg-cta-blue dark:bg-indigo-600 dark:hover:bg-indigo-700 uppercase font-semibold mr-4 transition-colors duration-200"
|
||||
on:click={toggleChangeCompanyModal}
|
||||
>
|
||||
Change company
|
||||
@@ -130,14 +133,15 @@
|
||||
|
||||
{#if isUpdateAvailable}
|
||||
<a
|
||||
class="flex items-center gap-2 mr-8 text-lg font-medium text-white bg-gradient-to-r from-indigo-500 to-purple-500 rounded-md px-4 py-2 transition-all duration-300 transform hover:-translate-y-0.5 focus:outline-none focus:ring-2 focus:ring-indigo-400 focus:ring-offset-2 active:scale-95 fixed bottom-4 right-2 shadow-md shadow-black"
|
||||
class="flex items-center gap-2 mr-8 text-lg font-medium text-white bg-gradient-to-r from-indigo-500 to-purple-500 dark:from-indigo-600 dark:to-purple-600 rounded-md px-4 py-2 transition-all duration-300 transform hover:-translate-y-0.5 focus:outline-none focus:ring-2 focus:ring-indigo-400 focus:ring-offset-2 dark:focus:ring-offset-gray-800 active:scale-95 fixed bottom-4 right-2 shadow-md shadow-black dark:shadow-gray-900"
|
||||
href={'/settings/update'}
|
||||
>
|
||||
<span class="">✨</span>
|
||||
<span>Update Available</span>
|
||||
</a>
|
||||
{/if}
|
||||
<div class="relative ml-10 flex items-center">
|
||||
<div class="relative ml-10 flex items-center gap-4">
|
||||
<ThemeToggle />
|
||||
<button
|
||||
id="toggle-profile-menu"
|
||||
class="group flex items-center"
|
||||
@@ -145,7 +149,7 @@
|
||||
>
|
||||
<!-- Main Circle with Initials -->
|
||||
<div
|
||||
class="w-10 h-10 rounded-full bg-cta-blue hover:bg-indigo-500 flex items-center justify-center text-white font-medium relative"
|
||||
class="w-10 h-10 rounded-full bg-cta-blue hover:bg-indigo-500 dark:bg-indigo-600 dark:hover:bg-indigo-700 flex items-center justify-center text-white font-medium relative transition-colors duration-200"
|
||||
>
|
||||
{initials}
|
||||
|
||||
@@ -154,7 +158,7 @@
|
||||
|
||||
<!-- Dropdown Indicator -->
|
||||
<svg
|
||||
class="w-4 h-4 ml-2 text-gray-300 transition-transform duration-200 group-hover:text-white"
|
||||
class="w-4 h-4 ml-2 text-gray-300 dark:text-gray-400 transition-transform duration-200 group-hover:text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -170,25 +174,40 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex lg:hidden items-center mr-4">
|
||||
<div class="flex lg:hidden items-center mr-4 gap-4">
|
||||
<div
|
||||
class="flex items-center justify-center w-10 h-10 rounded-lg hover:bg-white/10 transition-colors duration-200"
|
||||
>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<button
|
||||
class="rounded-md px-3 py-1 text-white bg-indigo-500 hover:bg-cta-blue uppercase font-semibold text-xs mr-3"
|
||||
class="rounded-md px-3 py-2 text-white bg-indigo-500 hover:bg-cta-blue dark:bg-indigo-600 dark:hover:bg-indigo-700 uppercase font-semibold text-xs transition-colors duration-200"
|
||||
on:click={toggleChangeCompanyModal}
|
||||
>
|
||||
Change company
|
||||
</button>
|
||||
<button class="flex w-14" on:click={() => (isMobileMenuVisible = !isMobileMenuVisible)}>
|
||||
<img class="" src="/mob-menu-button.svg" alt="toggle mobile menu" />
|
||||
<button
|
||||
class="flex items-center justify-center w-10 h-10 rounded-lg hover:bg-white/10 transition-colors duration-200"
|
||||
on:click={() => (isMobileMenuVisible = !isMobileMenuVisible)}
|
||||
>
|
||||
<img class="w-6 h-6" src="/mob-menu-button.svg" alt="toggle mobile menu" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
button {
|
||||
filter: contrast(1.1) saturate(1.2);
|
||||
/* Prevent any hover effects on the header */
|
||||
.header-container {
|
||||
background-color: #0b2063 !important;
|
||||
}
|
||||
button:hover {
|
||||
filter: contrast(1.2) saturate(1.3);
|
||||
.header-container:hover {
|
||||
background-color: #0b2063 !important;
|
||||
}
|
||||
:global(.dark) .header-container {
|
||||
background-color: #1f2937 !important;
|
||||
}
|
||||
:global(.dark) .header-container:hover {
|
||||
background-color: #1f2937 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,7 +7,22 @@
|
||||
on:keydown={(e) => e.key === 'Enter' && goto('/dashboard/')}
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="flex items-center w-40 sm:w-40 md:w-42 lg:w-56 justify-center py-4 my-6 rounded-md ml-4"
|
||||
class="flex items-center w-40 sm:w-40 md:w-42 lg:w-56 justify-center py-4 my-6 ml-4 cursor-pointer"
|
||||
>
|
||||
<img draggable="false" src="/logo-white.svg" alt="logo" />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
div {
|
||||
background: none !important;
|
||||
}
|
||||
div:hover {
|
||||
background: none !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
div:focus {
|
||||
background: none !important;
|
||||
background-color: transparent !important;
|
||||
outline: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,10 +5,13 @@
|
||||
</script>
|
||||
|
||||
<a
|
||||
class="py-2 px-4 text-white hover:bg-active-blue hover:text-white hover:rounded-md"
|
||||
class:bg-highlight-blue={$page.url.pathname === href}
|
||||
class:font-semibold={$page.url.pathname === href}
|
||||
class:rounded-md={$page.url.pathname === href}
|
||||
class="pl-5 py-2 text-white last:rounded-md first:rounded-t-md transition-colors duration-200"
|
||||
class:hover:shadow-md={$page.url.pathname !== href}
|
||||
class:hover:bg-highlight-blue={$page.url.pathname !== href}
|
||||
class:dark:hover:bg-gray-600={$page.url.pathname !== href}
|
||||
class:bg-active-blue={$page.url.pathname === href}
|
||||
class:dark:bg-gray-700={$page.url.pathname === href}
|
||||
class:shadow-md={$page.url.pathname === href}
|
||||
class:hidden
|
||||
{href}
|
||||
on:click
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { menu, mobileTopMenu } from '$lib/consts/navigation';
|
||||
import MenuLink from './MenuLink.svelte';
|
||||
import { shouldHideMenuItem } from '$lib/utils/common';
|
||||
import ThemeToggle from '../ThemeToggle.svelte';
|
||||
|
||||
export let visible = false;
|
||||
export let username = '';
|
||||
@@ -10,48 +11,91 @@
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<div class="fixed top-0 left-0 w-full h-full bg-pc-darkblue z-40 overflow-y-auto pb-4">
|
||||
<div class="flex justify-between h-16">
|
||||
<img class="w-40 sm:w-40 md:w-42 lg:w-56 ml-4" src="/logo-white.svg" alt="logo" />
|
||||
<button class="mr-4 w-14" on:click={() => (visible = !visible)}>
|
||||
<img class="w-3/4" src="/mob-menu-close.svg" alt="close mobile menu" />
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex flex-col px-4 py-4 rounded-b-xl">
|
||||
<div class="flex py-6 border-b-2 border-white mb-4">
|
||||
<!-- <div class="bg-slate-50 w-16 h-16 rounded-full" /> -->
|
||||
<div>
|
||||
<h1 class="font-bold text-3xl ml-6 text-white">{username ?? ''}</h1>
|
||||
<button
|
||||
on:click={onClickLogout}
|
||||
class="bg-cta-blue hover:bg-pc-lightblue uppercase font-bold ml-6 mt-2 py text-white rounded-md"
|
||||
>
|
||||
<p class="py px-8">Log Out</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col text-white">
|
||||
{#each mobileTopMenu as link}
|
||||
<a
|
||||
class="pl-5 py-2 hover:bg-cta-blue hover:text-white rounded-md"
|
||||
class:bg-gray-600={$page.url.pathname === link.route}
|
||||
class:hidden={shouldHideMenuItem(link.route)}
|
||||
on:click={() => (visible = !visible)}
|
||||
target={link.external ? '_blank' : '_self'}
|
||||
href={link.route}>{link.label}</a
|
||||
>
|
||||
{/each}
|
||||
<!-- Overlay -->
|
||||
<button
|
||||
class="fixed inset-0 bg-black bg-opacity-50 z-40 cursor-default"
|
||||
on:click={() => (visible = false)}
|
||||
aria-label="Close mobile menu"
|
||||
></button>
|
||||
|
||||
<!-- Mobile Menu -->
|
||||
<div
|
||||
class="mobile-menu-content fixed top-0 left-0 w-full h-full bg-pc-darkblue dark:bg-gray-900 z-50 overflow-y-auto shadow-xl transition-colors duration-200"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="mobile-menu-header flex justify-between h-20 items-center bg-pc-darkblue dark:bg-gray-800 px-6"
|
||||
>
|
||||
<img class="w-40 h-auto" src="/logo-white.svg" alt="logo" />
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="flex items-center justify-center w-12 h-12 rounded-lg hover:bg-white/10 dark:hover:bg-gray-600/30 transition-colors duration-200"
|
||||
>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<button
|
||||
class="flex items-center justify-center w-12 h-12 rounded-lg hover:bg-white/10 dark:hover:bg-gray-600/30 transition-colors duration-200"
|
||||
on:click={() => (visible = false)}
|
||||
>
|
||||
<img class="w-6 h-6" src="/mob-menu-close.svg" alt="close mobile menu" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex flex-col bg-cta-blue px-4 pt-4">
|
||||
|
||||
<!-- User Section -->
|
||||
<div class="p-6 border-b border-white dark:border-gray-700">
|
||||
<h1 class="font-bold text-xl text-white dark:text-gray-100 mb-4">
|
||||
{username ?? ''}
|
||||
</h1>
|
||||
<button
|
||||
on:click={onClickLogout}
|
||||
class="bg-cta-blue dark:bg-indigo-600 dark:hover:bg-indigo-700 uppercase font-bold py-3 px-6 rounded-md transition-colors duration-200 text-sm text-white"
|
||||
>
|
||||
Log Out
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Top Menu -->
|
||||
<div class="p-4">
|
||||
<div
|
||||
class="bg-gradient-to-b from-cta-blue to-indigo-500 dark:from-gray-800 dark:to-gray-700 rounded-md"
|
||||
>
|
||||
{#each mobileTopMenu as link}
|
||||
<a
|
||||
class="block text-center py-4 text-white text-lg font-medium first:rounded-t-md last:rounded-b-md transition-colors duration-200"
|
||||
class:bg-active-blue={$page.url.pathname === link.route}
|
||||
class:dark:bg-gray-700={$page.url.pathname === link.route}
|
||||
class:shadow-md={$page.url.pathname === link.route}
|
||||
class:hidden={shouldHideMenuItem(link.route)}
|
||||
on:click={() => (visible = false)}
|
||||
target={link.external ? '_blank' : '_self'}
|
||||
href={link.route}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Menu -->
|
||||
<div class="p-4 pt-0">
|
||||
<div
|
||||
class="bg-gradient-to-b from-cta-blue to-indigo-500 dark:from-gray-800 dark:to-gray-700 rounded-md"
|
||||
>
|
||||
{#each menu as link}
|
||||
{#if link.type === 'submenu'}
|
||||
<div class="text-white font-semibold text-xl">{link.label}</div>
|
||||
<div
|
||||
class="text-center py-4 text-white font-semibold text-lg border-b border-white/20 dark:border-gray-600"
|
||||
>
|
||||
{link.label}
|
||||
</div>
|
||||
{#each link.items as item, i (i)}
|
||||
<MenuLink href={item.route} on:click={() => (visible = !visible)}>
|
||||
<a
|
||||
class="block text-center py-3 text-white text-base transition-colors duration-200"
|
||||
class:last:rounded-b-md={i === link.items.length - 1}
|
||||
href={item.route}
|
||||
on:click={() => (visible = false)}
|
||||
>
|
||||
{#if i === 0}
|
||||
Overview
|
||||
{:else if item.singleLabel}
|
||||
@@ -59,13 +103,49 @@
|
||||
{:else}
|
||||
{item.label}
|
||||
{/if}
|
||||
</MenuLink>
|
||||
</a>
|
||||
{/each}
|
||||
{:else}
|
||||
<MenuLink href={link.route}>{link.label}</MenuLink>
|
||||
<a
|
||||
class="block text-center py-4 text-white text-lg font-medium transition-colors duration-200"
|
||||
href={link.route}
|
||||
on:click={() => (visible = false)}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Prevent any hover effects on the mobile menu header */
|
||||
:global(.mobile-menu-header) {
|
||||
background-color: #0b2063 !important;
|
||||
}
|
||||
:global(.mobile-menu-header:hover) {
|
||||
background-color: #0b2063 !important;
|
||||
}
|
||||
:global(.dark .mobile-menu-header) {
|
||||
background-color: #1f2937 !important;
|
||||
}
|
||||
:global(.dark .mobile-menu-header:hover) {
|
||||
background-color: #1f2937 !important;
|
||||
}
|
||||
|
||||
/* Prevent any hover effects on the mobile menu content */
|
||||
.mobile-menu-content {
|
||||
background-color: #0b2063 !important;
|
||||
}
|
||||
.mobile-menu-content:hover {
|
||||
background-color: #0b2063 !important;
|
||||
}
|
||||
:global(.dark) .mobile-menu-content {
|
||||
background-color: #111827 !important;
|
||||
}
|
||||
:global(.dark) .mobile-menu-content:hover {
|
||||
background-color: #111827 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -26,6 +26,14 @@
|
||||
document.removeEventListener('click', handleClickOutsideNavigation);
|
||||
}
|
||||
}
|
||||
|
||||
// Custom transition that only fades in
|
||||
function fadeIn(node, { duration = 150 }) {
|
||||
return {
|
||||
duration,
|
||||
css: (t) => `opacity: ${t}`
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
@@ -34,15 +42,17 @@
|
||||
class="lg:flex flex-col h-fit lg:col-start-10 lg:col-span-3 row-start-1 xl:col-start-11 xl:col-span-2 2xl:col-start-11 2xl:col-span-2 sticky top-20 z-30"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col bg-gradient-to-b from-cta-blue to-indigo-500 rounded-md"
|
||||
transition:fade={{ duration: 150 }}
|
||||
class="flex flex-col bg-gradient-to-b from-cta-blue to-indigo-500 dark:from-gray-800 dark:to-gray-700 rounded-md transition-colors duration-200"
|
||||
in:fadeIn={{ duration: 150 }}
|
||||
>
|
||||
{#each topMenu as item}
|
||||
<a
|
||||
class="pl-5 py-2 text-white last:rounded-md first:rounded-t-md"
|
||||
class="pl-5 py-2 text-white last:rounded-md first:rounded-t-md transition-colors duration-200"
|
||||
class:hover:shadow-md={$page.url.pathname !== item.route}
|
||||
class:hover:bg-highlight-blue={$page.url.pathname !== item.route}
|
||||
class:dark:hover:bg-gray-600={$page.url.pathname !== item.route}
|
||||
class:bg-active-blue={$page.url.pathname === item.route}
|
||||
class:dark:bg-gray-700={$page.url.pathname === item.route}
|
||||
class:shadow-md={$page.url.pathname === item.route}
|
||||
class:hidden={shouldHideMenuItem(item.route)}
|
||||
target={item.external ? '_blank' : '_self'}
|
||||
@@ -55,9 +65,11 @@
|
||||
{/each}
|
||||
<button
|
||||
on:click={logout}
|
||||
class="bg-white uppercase font-bold hover:bg-pc-lightblue py-2 mx-4 my-4 rounded-md"
|
||||
class="bg-white dark:bg-gray-800 uppercase font-bold hover:bg-pc-lightblue dark:hover:bg-gray-700 py-2 mx-4 my-4 rounded-md transition-colors duration-200"
|
||||
>
|
||||
<p class="text-cta-blue py px-8">Log Out</p>
|
||||
<p class="text-cta-blue dark:text-gray-100 py px-8 transition-colors duration-200">
|
||||
Log Out
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -117,17 +117,21 @@
|
||||
<div class="flex-grow p-6">
|
||||
{#if isLoadingCompanies}
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="text-gray-500">Loading companies...</div>
|
||||
<div class="text-gray-500 dark:text-gray-400 transition-colors duration-200">
|
||||
Loading companies...
|
||||
</div>
|
||||
</div>
|
||||
{:else if companies.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-8 text-center">
|
||||
<div class="text-gray-500 mb-4">No companies found.</div>
|
||||
<div class="text-sm text-gray-400 mb-4">
|
||||
<div class="text-gray-500 dark:text-gray-400 mb-4 transition-colors duration-200">
|
||||
No companies found.
|
||||
</div>
|
||||
<div class="text-sm text-gray-400 dark:text-gray-500 mb-4 transition-colors duration-200">
|
||||
You need to create a company first before you can switch to it.
|
||||
</div>
|
||||
<a
|
||||
href="/company/"
|
||||
class="bg-cta-blue hover:bg-blue-700 text-sm uppercase font-bold px-4 py-2 text-white rounded-md"
|
||||
class="bg-cta-blue dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-700 text-sm uppercase font-bold px-4 py-2 text-white rounded-md transition-colors duration-200"
|
||||
on:click={() => {
|
||||
visible = false;
|
||||
}}
|
||||
@@ -143,11 +147,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Button Section -->
|
||||
<div class="border-t p-6 mt-36 flex flex-wrap gap-4 justify-end">
|
||||
<div
|
||||
class="border-t border-gray-200 dark:border-gray-600 p-6 mt-36 flex flex-wrap gap-4 justify-end transition-colors duration-200"
|
||||
>
|
||||
{#if inContext}
|
||||
<button
|
||||
type="button"
|
||||
class="bg-slate-400 hover:bg-slate-300 text-sm mr-2 uppercase font-bold px-4 py-2 text-white rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
class="bg-slate-400 dark:bg-gray-600 hover:bg-slate-300 dark:hover:bg-gray-500 text-sm mr-2 uppercase font-bold px-4 py-2 text-white rounded-md disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
|
||||
disabled={isLoadingCompanies}
|
||||
on:click={onClickSwitchToAdministratorContext}
|
||||
>
|
||||
@@ -157,7 +163,7 @@
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="bg-cta-blue hover:bg-blue-700 text-sm uppercase font-bold px-4 py-2 text-white rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
class="bg-cta-blue dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-700 text-sm uppercase font-bold px-4 py-2 text-white rounded-md disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
|
||||
disabled={isLoadingCompanies || !selectedCompany}
|
||||
on:click={onClickSwitch}
|
||||
>
|
||||
|
||||
@@ -46,20 +46,20 @@
|
||||
<!--
|
||||
<h3 class="text-lg font-medium text-gray-900">Delete {type}</h3>
|
||||
-->
|
||||
<p class="mt-2 text-gray-600">
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-300">
|
||||
Are you sure you want to delete
|
||||
{#if name?.length > 30}
|
||||
<br />
|
||||
{/if}
|
||||
<span class="font-medium text-gray-900">"{name}"</span>?
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100">"{name}"</span>?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Impact Section -->
|
||||
{#if list.length}
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<p class="font-medium text-gray-900 mb-3">Side effects:</p>
|
||||
<ul class="space-y-2 ml-4 list-disc text-gray-600">
|
||||
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 transition-colors duration-200">
|
||||
<p class="font-medium text-gray-900 dark:text-gray-100 mb-3">Side effects:</p>
|
||||
<ul class="space-y-2 ml-4 list-disc text-gray-600 dark:text-gray-300">
|
||||
{#each list as line}
|
||||
<li>{line}</li>
|
||||
{/each}
|
||||
@@ -85,7 +85,7 @@
|
||||
{/if}
|
||||
|
||||
{#if permanent}
|
||||
<p class="text-red-700 font-medium">This action cannot be undone.</p>
|
||||
<p class="text-red-700 dark:text-red-400 font-medium">This action cannot be undone.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="hover:bg-gray-100 px-2 py-1 rounded-md transition-colors w-full text-left text-ellipsis overflow-hidden"
|
||||
class="hover:bg-gray-100 dark:hover:bg-gray-700 px-2 py-1 rounded-md transition-colors w-full text-left text-ellipsis overflow-hidden text-gray-900 dark:text-gray-100"
|
||||
title={text}
|
||||
on:click={() => onClickCopy(text)}
|
||||
>
|
||||
|
||||
@@ -5,12 +5,16 @@
|
||||
export let colspan = 100;
|
||||
</script>
|
||||
|
||||
<tr class="text-center bg-pleasant-gray">
|
||||
<tr class="text-center bg-pleasant-gray dark:bg-gray-700 transition-colors duration-200">
|
||||
<td class="p-24" {colspan}>
|
||||
{#if page === 1}
|
||||
<p class="text-lg text-gray-600">No {plural} found</p>
|
||||
<p class="text-lg text-gray-600 dark:text-gray-300 transition-colors duration-200">
|
||||
No {plural} found
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-lg text-gray-600">No more results found</p>
|
||||
<p class="text-lg text-gray-600 dark:text-gray-300 transition-colors duration-200">
|
||||
No more results found
|
||||
</p>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex items-center text-ellipsis">
|
||||
<div
|
||||
class="flex items-center text-ellipsis text-gray-900 dark:text-gray-100 transition-colors duration-200"
|
||||
>
|
||||
<div class="w-4 h-4 {event.color} mr-2 rounded-sm"></div>
|
||||
{event.name}
|
||||
</div>
|
||||
|
||||
@@ -56,10 +56,13 @@
|
||||
<div
|
||||
bind:this={tableWrapper}
|
||||
class="
|
||||
border-2 rounded-md px-4 py-4 overflow-x-auto
|
||||
border-2 border-gray-200 dark:border-gray-600 rounded-md px-4 py-4 overflow-x-auto bg-white dark:bg-gray-800 transition-colors duration-200
|
||||
{scrollBarClassesHorizontal}"
|
||||
>
|
||||
<table class="w-full table-fixed" class:animate-pulse={isGhost}>
|
||||
<table
|
||||
class="w-full table-fixed bg-white dark:bg-gray-800 transition-colors duration-200"
|
||||
class:animate-pulse={isGhost}
|
||||
>
|
||||
<TableHeader {isGhost} {columns} {sortable} {hasActions} {pagination} />
|
||||
{#if !hasData && !isGhost}
|
||||
<EmptyTableResult page={currentPage} {plural} colspan={columnsLength} />
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</script>
|
||||
|
||||
<td
|
||||
class={`pl-4 font-regular text-slate-600 text-ellipsis whitespace-nowrap overflow-hidden pr-4`}
|
||||
class={`pl-4 font-regular text-slate-600 dark:text-gray-300 text-ellipsis whitespace-nowrap overflow-hidden pr-4 transition-colors duration-200`}
|
||||
title={isDate ? '' : value}
|
||||
>
|
||||
{#if value}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<td class="pl-4 w-48 text-center border: hidden;">
|
||||
<p class="font-regular text-slate-600">
|
||||
<td class="w-48 text-center border: hidden;">
|
||||
<p class="font-regular text-slate-600 dark:text-gray-300 transition-colors duration-200">
|
||||
<slot />
|
||||
</p>
|
||||
</td>
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
</script>
|
||||
|
||||
<td class="pl-4 w-40 text-center border: hidden;">
|
||||
<p class="font-regular text-slate-600 flex justify-center">
|
||||
<p
|
||||
class="font-regular text-slate-600 dark:text-gray-300 flex justify-center transition-colors duration-200"
|
||||
>
|
||||
{value ? 'Yes' : 'No'}
|
||||
</p>
|
||||
</td>
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
<td class="pl-4 w-4 border: hidden;">
|
||||
|
||||
</td>
|
||||
<td class="pl-4 w-4 border: hidden; bg-white dark:bg-gray-800 transition-colors duration-200"> </td>
|
||||
|
||||
@@ -8,12 +8,16 @@
|
||||
</script>
|
||||
|
||||
{#if disabled}
|
||||
<button class="px py text-slate-300 cursor-not-allowed" {disabled} {title}>
|
||||
<button
|
||||
class="px py text-slate-300 dark:text-gray-500 cursor-not-allowed transition-colors duration-200"
|
||||
{disabled}
|
||||
{title}
|
||||
>
|
||||
<p class="ml-2 text-left">{name}</p>
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="px py-1 text-slate-600 hover:bg-red-400 hover:text-white cursor-pointer"
|
||||
class="px py-1 text-slate-600 dark:text-gray-300 hover:bg-red-400 dark:hover:bg-red-500 hover:text-white cursor-pointer transition-colors duration-200"
|
||||
on:click
|
||||
{title}
|
||||
>
|
||||
|
||||
@@ -16,12 +16,16 @@
|
||||
</script>
|
||||
|
||||
{#if disabled}
|
||||
<button class="px py-1 text-slate-300 cursor-not-allowed" {disabled} {title}>
|
||||
<button
|
||||
class="px py-1 text-slate-300 dark:text-gray-500 cursor-not-allowed transition-colors duration-200"
|
||||
{disabled}
|
||||
{title}
|
||||
>
|
||||
<p class="ml-2 text-left">{name}</p>
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="px py-1 text-slate-600 hover:bg-highlight-blue hover:text-white cursor-pointer"
|
||||
class="px py-1 text-slate-600 dark:text-gray-300 hover:bg-highlight-blue dark:hover:bg-blue-600 hover:text-white cursor-pointer transition-colors duration-200"
|
||||
on:click
|
||||
on:keydown={handleKeydown}
|
||||
{title}
|
||||
|
||||
@@ -128,16 +128,31 @@
|
||||
<div class="">
|
||||
<button
|
||||
bind:this={buttonRef}
|
||||
class="py-2 px-2"
|
||||
class="w-full h-full py-3 flex items-center justify-center"
|
||||
on:click|stopPropagation|preventDefault={toggle}
|
||||
on:keydown={handleKeydown}
|
||||
>
|
||||
<svg width="3.335557" height="16.465519" viewBox="0 0 0.88253281 4.3565019">
|
||||
<g transform="translate(-892.25669,88.863024)">
|
||||
<g transform="matrix(0,1.0139418,-1.0139418,0,802.48114,-807.2715)">
|
||||
<circle class="fill-cta-blue" cx="708.99603" cy="-88.976357" r="0.40846577" />
|
||||
<circle class="fill-cta-blue" cx="710.67859" cy="-88.976357" r="0.40846577" />
|
||||
<circle class="fill-cta-blue" cx="712.36115" cy="-88.976357" r="0.40846577" />
|
||||
<circle
|
||||
class="fill-cta-blue dark:fill-blue-500 transition-colors duration-200"
|
||||
cx="708.99603"
|
||||
cy="-88.976357"
|
||||
r="0.40846577"
|
||||
/>
|
||||
<circle
|
||||
class="fill-cta-blue dark:fill-blue-500 transition-colors duration-200"
|
||||
cx="710.67859"
|
||||
cy="-88.976357"
|
||||
r="0.40846577"
|
||||
/>
|
||||
<circle
|
||||
class="fill-cta-blue dark:fill-blue-500 transition-colors duration-200"
|
||||
cx="712.36115"
|
||||
cy="-88.976357"
|
||||
r="0.40846577"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
@@ -145,7 +160,7 @@
|
||||
|
||||
<div
|
||||
bind:this={menuRef}
|
||||
class="absolute bg-white drop-shadow-md z-20 w-48 rounded-md overflow-y-scroll {scrollBarClassesVertical}"
|
||||
class="absolute bg-white dark:bg-gray-800 drop-shadow-md dark:shadow-gray-900/50 border dark:border-gray-600 z-20 w-48 rounded-md overflow-y-scroll transition-colors duration-200 {scrollBarClassesVertical}"
|
||||
class:hidden={!isMenuVisible}
|
||||
>
|
||||
<ul class="flex flex-col text-left">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user