Merge release 1.3.0

This commit is contained in:
Ronni Skansing
2025-09-19 13:09:55 +02:00
126 changed files with 4338 additions and 858 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -1,5 +1,5 @@
# development docker file
FROM golang:1.24.5
FROM golang:1.25.1
EXPOSE 8000 8001
+15 -3
View File
@@ -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)
+6
View File
@@ -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,
}
}
+7 -5
View File
@@ -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)
}
+11
View File
@@ -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,
}
}
+1
View File
@@ -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,
+74
View File
@@ -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)
}
+90
View File
@@ -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",
})
}
+2
View File
@@ -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,
}
+2
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -5,11 +5,11 @@ import (
"bytes"
"embed"
"fmt"
"html/template"
"os"
"os/exec"
"path/filepath"
"strings"
"text/template"
"time"
)
+1
View File
@@ -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())
+1
View File
@@ -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"`
}
+26
View File
@@ -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
}
+12
View File
@@ -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{}).
+3 -4
View File
@@ -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())
+582
View File
@@ -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
View File
@@ -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
}
+24
View File
@@ -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 {
+19 -4
View File
@@ -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)
+13
View File
@@ -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
+134 -42
View File
@@ -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"
+2
View File
@@ -0,0 +1,2 @@
Reported by,Date reported(UTC+02:00)
alice@black-boat.test,2025-09-17T20:11:24
1 Reported by Date reported(UTC+02:00)
2 alice@black-boat.test 2025-09-17T20:11:24
+285 -2
View File
@@ -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;
}
+57
View File
@@ -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.
*
+11 -6
View File
@@ -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>
+15 -10
View File
@@ -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>
+2 -1
View File
@@ -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}
>
+6 -2
View File
@@ -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 -2
View File
@@ -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"
>
+10 -4
View File
@@ -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>
+1 -1
View File
@@ -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;
}
+4 -4
View File
@@ -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>
+1 -1
View File
@@ -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 />
+3 -3
View File
@@ -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>
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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 -4
View File
@@ -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} ">&nbsp;</div>
<div class="{width} {height} {gradient} transition-colors duration-200">&nbsp;</div>
</div>
{:else}
<div class="{width} {height} {gradient}">&nbsp;</div>
<div class="{width} {height} {gradient} transition-colors duration-200">&nbsp;</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>
+3 -1
View File
@@ -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>
+2 -2
View File
@@ -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>
+4 -2
View File
@@ -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>
+5 -2
View File
@@ -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}
+5 -3
View File
@@ -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}>&lt;&lt;</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}>&gt;&gt;</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>
+1 -1
View File
@@ -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>
+5 -3
View File
@@ -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}
+16 -6
View File
@@ -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>
+4 -1
View File
@@ -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>
+6 -4
View File
@@ -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 -1
View File
@@ -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

+4 -4
View File
@@ -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">
+2 -2
View File
@@ -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}>&nbsp;</div>
<div
class="bg-cta-blue dark:bg-indigo-600 cursor-move w-1 transition-colors duration-200"
class:hidden={!isPreviewVisible}
>
&nbsp;
</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>
+16 -1
View File
@@ -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