feat: Implement bug reporting feature with screenshot capture
- Added a new utility for capturing screenshots on Windows. - Introduced a bug report dialog in the frontend with fields for name, email, and bug description. - Integrated screenshot capture functionality into the bug report dialog. - Added localization for bug report messages in English and Italian. - Updated package dependencies to include html2canvas for potential future use. - Created a new UI dialog component structure for better organization and reusability. - Enhanced the app layout to accommodate the new bug report feature.
This commit is contained in:
368
app.go
368
app.go
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -24,14 +25,15 @@ import (
|
|||||||
|
|
||||||
// App struct
|
// App struct
|
||||||
type App struct {
|
type App struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
StartupFilePath string
|
StartupFilePath string
|
||||||
openImagesMux sync.Mutex
|
CurrentMailFilePath string // Tracks the currently loaded mail file (from startup or file dialog)
|
||||||
openImages map[string]bool
|
openImagesMux sync.Mutex
|
||||||
openPDFsMux sync.Mutex
|
openImages map[string]bool
|
||||||
openPDFs map[string]bool
|
openPDFsMux sync.Mutex
|
||||||
openEMLsMux sync.Mutex
|
openPDFs map[string]bool
|
||||||
openEMLs map[string]bool
|
openEMLsMux sync.Mutex
|
||||||
|
openEMLs map[string]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewApp creates a new App application struct
|
// NewApp creates a new App application struct
|
||||||
@@ -48,6 +50,11 @@ func NewApp() *App {
|
|||||||
func (a *App) startup(ctx context.Context) {
|
func (a *App) startup(ctx context.Context) {
|
||||||
a.ctx = ctx
|
a.ctx = ctx
|
||||||
|
|
||||||
|
// Set CurrentMailFilePath to StartupFilePath if a file was opened via command line
|
||||||
|
if a.StartupFilePath != "" {
|
||||||
|
a.CurrentMailFilePath = a.StartupFilePath
|
||||||
|
}
|
||||||
|
|
||||||
isViewer := false
|
isViewer := false
|
||||||
for _, arg := range os.Args {
|
for _, arg := range os.Args {
|
||||||
if strings.Contains(arg, "--view-image") || strings.Contains(arg, "--view-pdf") {
|
if strings.Contains(arg, "--view-image") || strings.Contains(arg, "--view-pdf") {
|
||||||
@@ -102,6 +109,16 @@ func (a *App) GetStartupFile() string {
|
|||||||
return a.StartupFilePath
|
return a.StartupFilePath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetCurrentMailFilePath sets the path of the currently loaded mail file
|
||||||
|
func (a *App) SetCurrentMailFilePath(filePath string) {
|
||||||
|
a.CurrentMailFilePath = filePath
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCurrentMailFilePath returns the path of the currently loaded mail file
|
||||||
|
func (a *App) GetCurrentMailFilePath() string {
|
||||||
|
return a.CurrentMailFilePath
|
||||||
|
}
|
||||||
|
|
||||||
// ReadEML reads a .eml file and returns the email data
|
// ReadEML reads a .eml file and returns the email data
|
||||||
func (a *App) ReadEML(filePath string) (*internal.EmailData, error) {
|
func (a *App) ReadEML(filePath string) (*internal.EmailData, error) {
|
||||||
return internal.ReadEmlFile(filePath)
|
return internal.ReadEmlFile(filePath)
|
||||||
@@ -534,3 +551,338 @@ func (a *App) ConvertToUTF8(s string) string {
|
|||||||
}
|
}
|
||||||
return decoded
|
return decoded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ScreenshotResult contains the screenshot data and metadata
|
||||||
|
type ScreenshotResult struct {
|
||||||
|
Data string `json:"data"` // Base64-encoded PNG data
|
||||||
|
Width int `json:"width"` // Image width in pixels
|
||||||
|
Height int `json:"height"` // Image height in pixels
|
||||||
|
Filename string `json:"filename"` // Suggested filename
|
||||||
|
}
|
||||||
|
|
||||||
|
// TakeScreenshot captures the current Wails application window and returns it as base64 PNG
|
||||||
|
func (a *App) TakeScreenshot() (*ScreenshotResult, error) {
|
||||||
|
// Get the window title to find our window
|
||||||
|
windowTitle := "EMLy - EML Viewer for 3gIT"
|
||||||
|
|
||||||
|
// Check if we're in viewer mode
|
||||||
|
for _, arg := range os.Args {
|
||||||
|
if strings.Contains(arg, "--view-image") {
|
||||||
|
windowTitle = "EMLy Image Viewer"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if strings.Contains(arg, "--view-pdf") {
|
||||||
|
windowTitle = "EMLy PDF Viewer"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
img, err := utils.CaptureWindowByTitle(windowTitle)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to capture window: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
base64Data, err := utils.ScreenshotToBase64PNG(img)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to encode screenshot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bounds := img.Bounds()
|
||||||
|
timestamp := time.Now().Format("20060102_150405")
|
||||||
|
|
||||||
|
return &ScreenshotResult{
|
||||||
|
Data: base64Data,
|
||||||
|
Width: bounds.Dx(),
|
||||||
|
Height: bounds.Dy(),
|
||||||
|
Filename: fmt.Sprintf("emly_screenshot_%s.png", timestamp),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveScreenshot captures and saves the screenshot to a file, returning the file path
|
||||||
|
func (a *App) SaveScreenshot() (string, error) {
|
||||||
|
result, err := a.TakeScreenshot()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode base64 data
|
||||||
|
data, err := base64.StdEncoding.DecodeString(result.Data)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decode screenshot data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to temp directory
|
||||||
|
tempDir := os.TempDir()
|
||||||
|
filePath := filepath.Join(tempDir, result.Filename)
|
||||||
|
|
||||||
|
if err := os.WriteFile(filePath, data, 0644); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to save screenshot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filePath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveScreenshotAs opens a save dialog and saves the screenshot to the selected location
|
||||||
|
func (a *App) SaveScreenshotAs() (string, error) {
|
||||||
|
result, err := a.TakeScreenshot()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open save dialog
|
||||||
|
savePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
|
||||||
|
DefaultFilename: result.Filename,
|
||||||
|
Title: "Save Screenshot",
|
||||||
|
Filters: []runtime.FileFilter{
|
||||||
|
{
|
||||||
|
DisplayName: "PNG Images (*.png)",
|
||||||
|
Pattern: "*.png",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to open save dialog: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if savePath == "" {
|
||||||
|
return "", nil // User cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode base64 data
|
||||||
|
data, err := base64.StdEncoding.DecodeString(result.Data)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decode screenshot data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(savePath, data, 0644); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to save screenshot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return savePath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BugReportResult contains paths to the bug report files
|
||||||
|
type BugReportResult struct {
|
||||||
|
FolderPath string `json:"folderPath"` // Path to the bug report folder
|
||||||
|
ScreenshotPath string `json:"screenshotPath"` // Path to the screenshot file
|
||||||
|
MailFilePath string `json:"mailFilePath"` // Path to the copied mail file (empty if no mail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBugReportFolder creates a folder in temp with screenshot and optionally the current mail file
|
||||||
|
func (a *App) CreateBugReportFolder() (*BugReportResult, error) {
|
||||||
|
// Create timestamp for unique folder name
|
||||||
|
timestamp := time.Now().Format("20060102_150405")
|
||||||
|
folderName := fmt.Sprintf("emly_bugreport_%s", timestamp)
|
||||||
|
|
||||||
|
// Create folder in temp directory
|
||||||
|
tempDir := os.TempDir()
|
||||||
|
bugReportFolder := filepath.Join(tempDir, folderName)
|
||||||
|
|
||||||
|
if err := os.MkdirAll(bugReportFolder, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create bug report folder: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &BugReportResult{
|
||||||
|
FolderPath: bugReportFolder,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take and save screenshot
|
||||||
|
screenshotResult, err := a.TakeScreenshot()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to take screenshot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
screenshotData, err := base64.StdEncoding.DecodeString(screenshotResult.Data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode screenshot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
screenshotPath := filepath.Join(bugReportFolder, screenshotResult.Filename)
|
||||||
|
if err := os.WriteFile(screenshotPath, screenshotData, 0644); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to save screenshot: %w", err)
|
||||||
|
}
|
||||||
|
result.ScreenshotPath = screenshotPath
|
||||||
|
|
||||||
|
// Check if there's a mail file loaded and copy it
|
||||||
|
if a.CurrentMailFilePath != "" {
|
||||||
|
// Read the original mail file
|
||||||
|
mailData, err := os.ReadFile(a.CurrentMailFilePath)
|
||||||
|
if err != nil {
|
||||||
|
// Log but don't fail - screenshot is still valid
|
||||||
|
Log("Failed to read mail file for bug report:", err)
|
||||||
|
} else {
|
||||||
|
// Get the original filename
|
||||||
|
mailFilename := filepath.Base(a.CurrentMailFilePath)
|
||||||
|
mailFilePath := filepath.Join(bugReportFolder, mailFilename)
|
||||||
|
|
||||||
|
if err := os.WriteFile(mailFilePath, mailData, 0644); err != nil {
|
||||||
|
Log("Failed to copy mail file for bug report:", err)
|
||||||
|
} else {
|
||||||
|
result.MailFilePath = mailFilePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BugReportInput contains the user-provided bug report details
|
||||||
|
type BugReportInput struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
ScreenshotData string `json:"screenshotData"` // Base64-encoded PNG screenshot
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitBugReportResult contains the result of submitting a bug report
|
||||||
|
type SubmitBugReportResult struct {
|
||||||
|
ZipPath string `json:"zipPath"` // Path to the created zip file
|
||||||
|
FolderPath string `json:"folderPath"` // Path to the bug report folder
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitBugReport creates a complete bug report with user input, saves it, and zips the folder
|
||||||
|
func (a *App) SubmitBugReport(input BugReportInput) (*SubmitBugReportResult, error) {
|
||||||
|
// Create timestamp for unique folder name
|
||||||
|
timestamp := time.Now().Format("20060102_150405")
|
||||||
|
folderName := fmt.Sprintf("emly_bugreport_%s", timestamp)
|
||||||
|
|
||||||
|
// Create folder in temp directory
|
||||||
|
tempDir := os.TempDir()
|
||||||
|
bugReportFolder := filepath.Join(tempDir, folderName)
|
||||||
|
|
||||||
|
if err := os.MkdirAll(bugReportFolder, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create bug report folder: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the pre-captured screenshot
|
||||||
|
if input.ScreenshotData != "" {
|
||||||
|
screenshotData, err := base64.StdEncoding.DecodeString(input.ScreenshotData)
|
||||||
|
if err != nil {
|
||||||
|
Log("Failed to decode screenshot:", err)
|
||||||
|
} else {
|
||||||
|
screenshotPath := filepath.Join(bugReportFolder, fmt.Sprintf("emly_screenshot_%s.png", timestamp))
|
||||||
|
if err := os.WriteFile(screenshotPath, screenshotData, 0644); err != nil {
|
||||||
|
Log("Failed to save screenshot:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy the mail file if one is loaded
|
||||||
|
if a.CurrentMailFilePath != "" {
|
||||||
|
mailData, err := os.ReadFile(a.CurrentMailFilePath)
|
||||||
|
if err != nil {
|
||||||
|
Log("Failed to read mail file for bug report:", err)
|
||||||
|
} else {
|
||||||
|
mailFilename := filepath.Base(a.CurrentMailFilePath)
|
||||||
|
mailFilePath := filepath.Join(bugReportFolder, mailFilename)
|
||||||
|
if err := os.WriteFile(mailFilePath, mailData, 0644); err != nil {
|
||||||
|
Log("Failed to copy mail file for bug report:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the report.txt file with user's description
|
||||||
|
reportContent := fmt.Sprintf(`EMLy Bug Report
|
||||||
|
================
|
||||||
|
|
||||||
|
Name: %s
|
||||||
|
Email: %s
|
||||||
|
|
||||||
|
Description:
|
||||||
|
%s
|
||||||
|
|
||||||
|
Generated: %s
|
||||||
|
`, input.Name, input.Email, input.Description, time.Now().Format("2006-01-02 15:04:05"))
|
||||||
|
|
||||||
|
reportPath := filepath.Join(bugReportFolder, "report.txt")
|
||||||
|
if err := os.WriteFile(reportPath, []byte(reportContent), 0644); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to save report file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get machine info and save it
|
||||||
|
machineInfo, err := utils.GetMachineInfo()
|
||||||
|
if err == nil && machineInfo != nil {
|
||||||
|
sysInfoContent := fmt.Sprintf(`System Information
|
||||||
|
==================
|
||||||
|
|
||||||
|
Hostname: %s
|
||||||
|
OS: %s
|
||||||
|
Version: %s
|
||||||
|
Hardware ID: %s
|
||||||
|
External IP: %s
|
||||||
|
`, machineInfo.Hostname, machineInfo.OS, machineInfo.Version, machineInfo.HWID, machineInfo.ExternalIP)
|
||||||
|
|
||||||
|
sysInfoPath := filepath.Join(bugReportFolder, "system_info.txt")
|
||||||
|
if err := os.WriteFile(sysInfoPath, []byte(sysInfoContent), 0644); err != nil {
|
||||||
|
Log("Failed to save system info:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zip the folder
|
||||||
|
zipPath := bugReportFolder + ".zip"
|
||||||
|
if err := zipFolder(bugReportFolder, zipPath); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create zip file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &SubmitBugReportResult{
|
||||||
|
ZipPath: zipPath,
|
||||||
|
FolderPath: bugReportFolder,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// zipFolder creates a zip archive of the given folder
|
||||||
|
func zipFolder(sourceFolder, destZip string) error {
|
||||||
|
zipFile, err := os.Create(destZip)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer zipFile.Close()
|
||||||
|
|
||||||
|
zipWriter := zip.NewWriter(zipFile)
|
||||||
|
defer zipWriter.Close()
|
||||||
|
|
||||||
|
// Walk through the folder and add all files to the zip
|
||||||
|
return filepath.Walk(sourceFolder, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip the root folder itself
|
||||||
|
if path == sourceFolder {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get relative path for the zip entry
|
||||||
|
relPath, err := filepath.Rel(sourceFolder, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip directories (they'll be created implicitly)
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the file in the zip
|
||||||
|
writer, err := zipWriter.Create(relPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the file content
|
||||||
|
fileContent, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to zip
|
||||||
|
_, err = writer.Write(fileContent)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenFolderInExplorer opens the specified folder in Windows Explorer
|
||||||
|
func (a *App) OpenFolderInExplorer(folderPath string) error {
|
||||||
|
cmd := exec.Command("explorer", folderPath)
|
||||||
|
return cmd.Start()
|
||||||
|
}
|
||||||
|
|||||||
259
backend/utils/screenshot_windows.go
Normal file
259
backend/utils/screenshot_windows.go
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/png"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
user32 = syscall.NewLazyDLL("user32.dll")
|
||||||
|
gdi32 = syscall.NewLazyDLL("gdi32.dll")
|
||||||
|
dwmapi = syscall.NewLazyDLL("dwmapi.dll")
|
||||||
|
|
||||||
|
// user32 functions
|
||||||
|
getForegroundWindow = user32.NewProc("GetForegroundWindow")
|
||||||
|
getWindowRect = user32.NewProc("GetWindowRect")
|
||||||
|
getClientRect = user32.NewProc("GetClientRect")
|
||||||
|
getDC = user32.NewProc("GetDC")
|
||||||
|
releaseDC = user32.NewProc("ReleaseDC")
|
||||||
|
findWindowW = user32.NewProc("FindWindowW")
|
||||||
|
getWindowDC = user32.NewProc("GetWindowDC")
|
||||||
|
printWindow = user32.NewProc("PrintWindow")
|
||||||
|
clientToScreen = user32.NewProc("ClientToScreen")
|
||||||
|
|
||||||
|
// gdi32 functions
|
||||||
|
createCompatibleDC = gdi32.NewProc("CreateCompatibleDC")
|
||||||
|
createCompatibleBitmap = gdi32.NewProc("CreateCompatibleBitmap")
|
||||||
|
selectObject = gdi32.NewProc("SelectObject")
|
||||||
|
bitBlt = gdi32.NewProc("BitBlt")
|
||||||
|
deleteDC = gdi32.NewProc("DeleteDC")
|
||||||
|
deleteObject = gdi32.NewProc("DeleteObject")
|
||||||
|
getDIBits = gdi32.NewProc("GetDIBits")
|
||||||
|
|
||||||
|
// dwmapi functions
|
||||||
|
dwmGetWindowAttribute = dwmapi.NewProc("DwmGetWindowAttribute")
|
||||||
|
)
|
||||||
|
|
||||||
|
// RECT structure for Windows API
|
||||||
|
type RECT struct {
|
||||||
|
Left int32
|
||||||
|
Top int32
|
||||||
|
Right int32
|
||||||
|
Bottom int32
|
||||||
|
}
|
||||||
|
|
||||||
|
// POINT structure for Windows API
|
||||||
|
type POINT struct {
|
||||||
|
X int32
|
||||||
|
Y int32
|
||||||
|
}
|
||||||
|
|
||||||
|
// BITMAPINFOHEADER structure
|
||||||
|
type BITMAPINFOHEADER struct {
|
||||||
|
BiSize uint32
|
||||||
|
BiWidth int32
|
||||||
|
BiHeight int32
|
||||||
|
BiPlanes uint16
|
||||||
|
BiBitCount uint16
|
||||||
|
BiCompression uint32
|
||||||
|
BiSizeImage uint32
|
||||||
|
BiXPelsPerMeter int32
|
||||||
|
BiYPelsPerMeter int32
|
||||||
|
BiClrUsed uint32
|
||||||
|
BiClrImportant uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
// BITMAPINFO structure
|
||||||
|
type BITMAPINFO struct {
|
||||||
|
BmiHeader BITMAPINFOHEADER
|
||||||
|
BmiColors [1]uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
SRCCOPY = 0x00CC0020
|
||||||
|
DIB_RGB_COLORS = 0
|
||||||
|
BI_RGB = 0
|
||||||
|
PW_CLIENTONLY = 1
|
||||||
|
PW_RENDERFULLCONTENT = 2
|
||||||
|
DWMWA_EXTENDED_FRAME_BOUNDS = 9
|
||||||
|
)
|
||||||
|
|
||||||
|
// CaptureWindowByHandle captures a screenshot of a specific window by its handle
|
||||||
|
func CaptureWindowByHandle(hwnd uintptr) (*image.RGBA, error) {
|
||||||
|
if hwnd == 0 {
|
||||||
|
return nil, fmt.Errorf("invalid window handle")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get the actual window bounds using DWM (handles DPI scaling better)
|
||||||
|
var rect RECT
|
||||||
|
ret, _, _ := dwmGetWindowAttribute.Call(
|
||||||
|
hwnd,
|
||||||
|
uintptr(DWMWA_EXTENDED_FRAME_BOUNDS),
|
||||||
|
uintptr(unsafe.Pointer(&rect)),
|
||||||
|
uintptr(unsafe.Sizeof(rect)),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fallback to GetWindowRect if DWM fails
|
||||||
|
if ret != 0 {
|
||||||
|
ret, _, err := getWindowRect.Call(hwnd, uintptr(unsafe.Pointer(&rect)))
|
||||||
|
if ret == 0 {
|
||||||
|
return nil, fmt.Errorf("GetWindowRect failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
width := int(rect.Right - rect.Left)
|
||||||
|
height := int(rect.Bottom - rect.Top)
|
||||||
|
|
||||||
|
if width <= 0 || height <= 0 {
|
||||||
|
return nil, fmt.Errorf("invalid window dimensions: %dx%d", width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get window DC
|
||||||
|
hdcWindow, _, err := getWindowDC.Call(hwnd)
|
||||||
|
if hdcWindow == 0 {
|
||||||
|
return nil, fmt.Errorf("GetWindowDC failed: %v", err)
|
||||||
|
}
|
||||||
|
defer releaseDC.Call(hwnd, hdcWindow)
|
||||||
|
|
||||||
|
// Create compatible DC
|
||||||
|
hdcMem, _, err := createCompatibleDC.Call(hdcWindow)
|
||||||
|
if hdcMem == 0 {
|
||||||
|
return nil, fmt.Errorf("CreateCompatibleDC failed: %v", err)
|
||||||
|
}
|
||||||
|
defer deleteDC.Call(hdcMem)
|
||||||
|
|
||||||
|
// Create compatible bitmap
|
||||||
|
hBitmap, _, err := createCompatibleBitmap.Call(hdcWindow, uintptr(width), uintptr(height))
|
||||||
|
if hBitmap == 0 {
|
||||||
|
return nil, fmt.Errorf("CreateCompatibleBitmap failed: %v", err)
|
||||||
|
}
|
||||||
|
defer deleteObject.Call(hBitmap)
|
||||||
|
|
||||||
|
// Select bitmap into DC
|
||||||
|
oldBitmap, _, _ := selectObject.Call(hdcMem, hBitmap)
|
||||||
|
defer selectObject.Call(hdcMem, oldBitmap)
|
||||||
|
|
||||||
|
// Try PrintWindow first (works better with layered/composited windows)
|
||||||
|
ret, _, _ = printWindow.Call(hwnd, hdcMem, PW_RENDERFULLCONTENT)
|
||||||
|
if ret == 0 {
|
||||||
|
// Fallback to BitBlt
|
||||||
|
ret, _, err = bitBlt.Call(
|
||||||
|
hdcMem, 0, 0, uintptr(width), uintptr(height),
|
||||||
|
hdcWindow, 0, 0,
|
||||||
|
SRCCOPY,
|
||||||
|
)
|
||||||
|
if ret == 0 {
|
||||||
|
return nil, fmt.Errorf("BitBlt failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare BITMAPINFO
|
||||||
|
bmi := BITMAPINFO{
|
||||||
|
BmiHeader: BITMAPINFOHEADER{
|
||||||
|
BiSize: uint32(unsafe.Sizeof(BITMAPINFOHEADER{})),
|
||||||
|
BiWidth: int32(width),
|
||||||
|
BiHeight: -int32(height), // Negative for top-down DIB
|
||||||
|
BiPlanes: 1,
|
||||||
|
BiBitCount: 32,
|
||||||
|
BiCompression: BI_RGB,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allocate buffer for pixel data
|
||||||
|
pixelDataSize := width * height * 4
|
||||||
|
pixelData := make([]byte, pixelDataSize)
|
||||||
|
|
||||||
|
// Get the bitmap bits
|
||||||
|
ret, _, err = getDIBits.Call(
|
||||||
|
hdcMem,
|
||||||
|
hBitmap,
|
||||||
|
0,
|
||||||
|
uintptr(height),
|
||||||
|
uintptr(unsafe.Pointer(&pixelData[0])),
|
||||||
|
uintptr(unsafe.Pointer(&bmi)),
|
||||||
|
DIB_RGB_COLORS,
|
||||||
|
)
|
||||||
|
if ret == 0 {
|
||||||
|
return nil, fmt.Errorf("GetDIBits failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert BGRA to RGBA
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||||
|
for i := 0; i < len(pixelData); i += 4 {
|
||||||
|
img.Pix[i+0] = pixelData[i+2] // R <- B
|
||||||
|
img.Pix[i+1] = pixelData[i+1] // G <- G
|
||||||
|
img.Pix[i+2] = pixelData[i+0] // B <- R
|
||||||
|
img.Pix[i+3] = pixelData[i+3] // A <- A
|
||||||
|
}
|
||||||
|
|
||||||
|
return img, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CaptureForegroundWindow captures the currently focused window
|
||||||
|
func CaptureForegroundWindow() (*image.RGBA, error) {
|
||||||
|
hwnd, _, _ := getForegroundWindow.Call()
|
||||||
|
if hwnd == 0 {
|
||||||
|
return nil, fmt.Errorf("no foreground window found")
|
||||||
|
}
|
||||||
|
return CaptureWindowByHandle(hwnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CaptureWindowByTitle captures a window by its title
|
||||||
|
func CaptureWindowByTitle(title string) (*image.RGBA, error) {
|
||||||
|
titlePtr, err := syscall.UTF16PtrFromString(title)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to convert title: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hwnd, _, _ := findWindowW.Call(0, uintptr(unsafe.Pointer(titlePtr)))
|
||||||
|
if hwnd == 0 {
|
||||||
|
return nil, fmt.Errorf("window with title '%s' not found", title)
|
||||||
|
}
|
||||||
|
return CaptureWindowByHandle(hwnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScreenshotToBase64PNG captures a window and returns it as a base64-encoded PNG string
|
||||||
|
func ScreenshotToBase64PNG(img *image.RGBA) (string, error) {
|
||||||
|
if img == nil {
|
||||||
|
return "", fmt.Errorf("nil image provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := png.Encode(&buf, img); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to encode PNG: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return base64.StdEncoding.EncodeToString(buf.Bytes()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CaptureWindowToBase64 is a convenience function that captures a window and returns base64 PNG
|
||||||
|
func CaptureWindowToBase64(hwnd uintptr) (string, error) {
|
||||||
|
img, err := CaptureWindowByHandle(hwnd)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return ScreenshotToBase64PNG(img)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CaptureForegroundWindowToBase64 captures the foreground window and returns base64 PNG
|
||||||
|
func CaptureForegroundWindowToBase64() (string, error) {
|
||||||
|
img, err := CaptureForegroundWindow()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return ScreenshotToBase64PNG(img)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CaptureWindowByTitleToBase64 captures a window by title and returns base64 PNG
|
||||||
|
func CaptureWindowByTitleToBase64(title string) (string, error) {
|
||||||
|
img, err := CaptureWindowByTitle(title)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return ScreenshotToBase64PNG(img)
|
||||||
|
}
|
||||||
@@ -89,5 +89,25 @@
|
|||||||
"mail_pdf_already_open": "The PDF is already open in another window.",
|
"mail_pdf_already_open": "The PDF is already open in another window.",
|
||||||
"settings_danger_debugger_protection_label": "Enable attached debugger protection",
|
"settings_danger_debugger_protection_label": "Enable attached debugger protection",
|
||||||
"settings_danger_debugger_protection_hint": "This will prevent the app from being debugged by an attached debugger.",
|
"settings_danger_debugger_protection_hint": "This will prevent the app from being debugged by an attached debugger.",
|
||||||
"settings_danger_debugger_protection_info": "Info: This actions are currently not configurable and is always enabled for private builds."
|
"settings_danger_debugger_protection_info": "Info: This actions are currently not configurable and is always enabled for private builds.",
|
||||||
|
"bugreport_title": "Report a Bug",
|
||||||
|
"bugreport_description": "Describe what you were doing when the bug occurred and what you expected to happen instead.",
|
||||||
|
"bugreport_name_label": "Name",
|
||||||
|
"bugreport_name_placeholder": "Your name",
|
||||||
|
"bugreport_email_label": "Email",
|
||||||
|
"bugreport_email_placeholder": "your.email@example.com",
|
||||||
|
"bugreport_text_label": "Bug Description",
|
||||||
|
"bugreport_text_placeholder": "Describe the bug in detail...",
|
||||||
|
"bugreport_info": "Your message, email file (if loaded), screenshot, and system information will be included in the report.",
|
||||||
|
"bugreport_screenshot_label": "Attached Screenshot:",
|
||||||
|
"bugreport_cancel": "Cancel",
|
||||||
|
"bugreport_submit": "Submit Report",
|
||||||
|
"bugreport_submitting": "Creating report...",
|
||||||
|
"bugreport_success_title": "Bug Report Created",
|
||||||
|
"bugreport_success_message": "Your bug report has been saved to:",
|
||||||
|
"bugreport_copy_path": "Copy Path",
|
||||||
|
"bugreport_open_folder": "Open Folder",
|
||||||
|
"bugreport_close": "Close",
|
||||||
|
"bugreport_error": "Failed to create bug report.",
|
||||||
|
"bugreport_copied": "Path copied to clipboard!"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,5 +89,25 @@
|
|||||||
"mail_pdf_already_open": "Il file PDF è già aperto in una finestra separata.",
|
"mail_pdf_already_open": "Il file PDF è già aperto in una finestra separata.",
|
||||||
"settings_danger_debugger_protection_label": "Abilita protezione da debugger",
|
"settings_danger_debugger_protection_label": "Abilita protezione da debugger",
|
||||||
"settings_danger_debugger_protection_hint": "Questo impedirà che il debug dell'app venga eseguito da un debugger collegato.",
|
"settings_danger_debugger_protection_hint": "Questo impedirà che il debug dell'app venga eseguito da un debugger collegato.",
|
||||||
"settings_danger_debugger_protection_info": "Info: Questa azione non è attualmente configurabile ed è sempre abilitata per le build private."
|
"settings_danger_debugger_protection_info": "Info: Questa azione non è attualmente configurabile ed è sempre abilitata per le build private.",
|
||||||
|
"bugreport_title": "Segnala un Bug",
|
||||||
|
"bugreport_description": "Descrivi cosa stavi facendo quando si è verificato il bug e cosa ti aspettavi che accadesse.",
|
||||||
|
"bugreport_name_label": "Nome",
|
||||||
|
"bugreport_name_placeholder": "Il tuo nome",
|
||||||
|
"bugreport_email_label": "Email",
|
||||||
|
"bugreport_email_placeholder": "tua.email@esempio.com",
|
||||||
|
"bugreport_text_label": "Descrizione del Bug",
|
||||||
|
"bugreport_text_placeholder": "Descrivi il bug in dettaglio...",
|
||||||
|
"bugreport_info": "Il tuo messaggio, il file email (se caricato), lo screenshot e le informazioni di sistema saranno inclusi nella segnalazione.",
|
||||||
|
"bugreport_screenshot_label": "Screenshot Allegato:",
|
||||||
|
"bugreport_cancel": "Annulla",
|
||||||
|
"bugreport_submit": "Invia Segnalazione",
|
||||||
|
"bugreport_submitting": "Creazione segnalazione...",
|
||||||
|
"bugreport_success_title": "Segnalazione Bug Creata",
|
||||||
|
"bugreport_success_message": "La tua segnalazione bug è stata salvata in:",
|
||||||
|
"bugreport_copy_path": "Copia Percorso",
|
||||||
|
"bugreport_open_folder": "Apri Cartella",
|
||||||
|
"bugreport_close": "Chiudi",
|
||||||
|
"bugreport_error": "Impossibile creare la segnalazione bug.",
|
||||||
|
"bugreport_copied": "Percorso copiato negli appunti!"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,9 @@
|
|||||||
"vite-plugin-devtools-json": "^1.0.0"
|
"vite-plugin-devtools-json": "^1.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/html2canvas": "^1.0.0",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
"pdfjs-dist": "^5.4.624",
|
"pdfjs-dist": "^5.4.624",
|
||||||
"svelte-flags": "^3.0.1",
|
"svelte-flags": "^3.0.1",
|
||||||
"svelte-sonner": "^1.0.7"
|
"svelte-sonner": "^1.0.7"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { X, MailOpen, Image, FileText, File, ShieldCheck, Shield, Signature, FileCode, Loader2 } from "@lucide/svelte";
|
import { X, MailOpen, Image, FileText, File, ShieldCheck, Shield, Signature, FileCode, Loader2 } from "@lucide/svelte";
|
||||||
import { ShowOpenFileDialog, ReadEML, OpenPDF, OpenImageWindow, OpenPDFWindow, OpenImage, ReadMSG, ReadPEC, OpenEMLWindow, ConvertToUTF8 } from "$lib/wailsjs/go/main/App";
|
import { ShowOpenFileDialog, ReadEML, OpenPDF, OpenImageWindow, OpenPDFWindow, OpenImage, ReadMSG, ReadPEC, OpenEMLWindow, ConvertToUTF8, SetCurrentMailFilePath } from "$lib/wailsjs/go/main/App";
|
||||||
import type { internal } from "$lib/wailsjs/go/models";
|
import type { internal } from "$lib/wailsjs/go/models";
|
||||||
import { sidebarOpen } from "$lib/stores/app";
|
import { sidebarOpen } from "$lib/stores/app";
|
||||||
import { onDestroy, onMount } from "svelte";
|
import { onDestroy, onMount } from "svelte";
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if(dev) {
|
if(dev) {
|
||||||
console.log(mailState.currentEmail)
|
console.debug("emailObj:", mailState.currentEmail)
|
||||||
}
|
}
|
||||||
console.info("Current email changed:", mailState.currentEmail?.subject);
|
console.info("Current email changed:", mailState.currentEmail?.subject);
|
||||||
if(mailState.currentEmail !== null) {
|
if(mailState.currentEmail !== null) {
|
||||||
@@ -183,6 +183,8 @@
|
|||||||
} else {
|
} else {
|
||||||
email = await ReadEML(result);
|
email = await ReadEML(result);
|
||||||
}
|
}
|
||||||
|
// Track the current mail file path for bug reports
|
||||||
|
await SetCurrentMailFilePath(result);
|
||||||
mailState.setParams(email);
|
mailState.setParams(email);
|
||||||
sidebarOpen.set(false);
|
sidebarOpen.set(false);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: DialogPrimitive.CloseProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {...restProps} />
|
||||||
45
frontend/src/lib/components/ui/dialog/dialog-content.svelte
Normal file
45
frontend/src/lib/components/ui/dialog/dialog-content.svelte
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
import DialogPortal from "./dialog-portal.svelte";
|
||||||
|
import XIcon from "@lucide/svelte/icons/x";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import * as Dialog from "./index.js";
|
||||||
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
|
import type { ComponentProps } from "svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
portalProps,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
|
||||||
|
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DialogPortal>>;
|
||||||
|
children: Snippet;
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPortal {...portalProps}>
|
||||||
|
<Dialog.Overlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
data-slot="dialog-content"
|
||||||
|
class={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
{#if showCloseButton}
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
class="ring-offset-background focus:ring-ring absolute end-4 top-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
{/if}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DialogPrimitive.DescriptionProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
bind:ref
|
||||||
|
data-slot="dialog-description"
|
||||||
|
class={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
20
frontend/src/lib/components/ui/dialog/dialog-footer.svelte
Normal file
20
frontend/src/lib/components/ui/dialog/dialog-footer.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
20
frontend/src/lib/components/ui/dialog/dialog-header.svelte
Normal file
20
frontend/src/lib/components/ui/dialog/dialog-header.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="dialog-header"
|
||||||
|
class={cn("flex flex-col gap-2 text-center sm:text-start", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
20
frontend/src/lib/components/ui/dialog/dialog-overlay.svelte
Normal file
20
frontend/src/lib/components/ui/dialog/dialog-overlay.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DialogPrimitive.OverlayProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
bind:ref
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
class={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { ...restProps }: DialogPrimitive.PortalProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Portal {...restProps} />
|
||||||
17
frontend/src/lib/components/ui/dialog/dialog-title.svelte
Normal file
17
frontend/src/lib/components/ui/dialog/dialog-title.svelte
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DialogPrimitive.TitleProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
bind:ref
|
||||||
|
data-slot="dialog-title"
|
||||||
|
class={cn("text-lg leading-none font-semibold", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: DialogPrimitive.TriggerProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {...restProps} />
|
||||||
7
frontend/src/lib/components/ui/dialog/dialog.svelte
Normal file
7
frontend/src/lib/components/ui/dialog/dialog.svelte
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { open = $bindable(false), ...restProps }: DialogPrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Root bind:open {...restProps} />
|
||||||
34
frontend/src/lib/components/ui/dialog/index.ts
Normal file
34
frontend/src/lib/components/ui/dialog/index.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import Root from "./dialog.svelte";
|
||||||
|
import Portal from "./dialog-portal.svelte";
|
||||||
|
import Title from "./dialog-title.svelte";
|
||||||
|
import Footer from "./dialog-footer.svelte";
|
||||||
|
import Header from "./dialog-header.svelte";
|
||||||
|
import Overlay from "./dialog-overlay.svelte";
|
||||||
|
import Content from "./dialog-content.svelte";
|
||||||
|
import Description from "./dialog-description.svelte";
|
||||||
|
import Trigger from "./dialog-trigger.svelte";
|
||||||
|
import Close from "./dialog-close.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Title,
|
||||||
|
Portal,
|
||||||
|
Footer,
|
||||||
|
Header,
|
||||||
|
Trigger,
|
||||||
|
Overlay,
|
||||||
|
Content,
|
||||||
|
Description,
|
||||||
|
Close,
|
||||||
|
//
|
||||||
|
Root as Dialog,
|
||||||
|
Title as DialogTitle,
|
||||||
|
Portal as DialogPortal,
|
||||||
|
Footer as DialogFooter,
|
||||||
|
Header as DialogHeader,
|
||||||
|
Trigger as DialogTrigger,
|
||||||
|
Overlay as DialogOverlay,
|
||||||
|
Content as DialogContent,
|
||||||
|
Description as DialogDescription,
|
||||||
|
Close as DialogClose,
|
||||||
|
};
|
||||||
7
frontend/src/lib/components/ui/textarea/index.ts
Normal file
7
frontend/src/lib/components/ui/textarea/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import Root from "./textarea.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Textarea,
|
||||||
|
};
|
||||||
23
frontend/src/lib/components/ui/textarea/textarea.svelte
Normal file
23
frontend/src/lib/components/ui/textarea/textarea.svelte
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
|
||||||
|
import type { HTMLTextareaAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
value = $bindable(),
|
||||||
|
class: className,
|
||||||
|
"data-slot": dataSlot = "textarea",
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildren<WithElementRef<HTMLTextareaAttributes>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot={dataSlot}
|
||||||
|
class={cn(
|
||||||
|
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
bind:value
|
||||||
|
{...restProps}
|
||||||
|
></textarea>
|
||||||
@@ -5,6 +5,7 @@ const storedDebug = browser ? sessionStorage.getItem("debugWindowInSettings") ==
|
|||||||
export const dangerZoneEnabled = writable<boolean>(storedDebug);
|
export const dangerZoneEnabled = writable<boolean>(storedDebug);
|
||||||
export const unsavedChanges = writable<boolean>(false);
|
export const unsavedChanges = writable<boolean>(false);
|
||||||
export const sidebarOpen = writable<boolean>(true);
|
export const sidebarOpen = writable<boolean>(true);
|
||||||
|
export const bugReportDialogOpen = writable<boolean>(false);
|
||||||
|
|
||||||
export type AppEvent = {
|
export type AppEvent = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { page, navigating } from "$app/state";
|
import { page, navigating } from "$app/state";
|
||||||
import { beforeNavigate, goto } from "$app/navigation";
|
import { beforeNavigate, goto } from "$app/navigation";
|
||||||
import { locales, localizeHref } from "$lib/paraglide/runtime";
|
import { locales, localizeHref } from "$lib/paraglide/runtime";
|
||||||
import { unsavedChanges, sidebarOpen } from "$lib/stores/app";
|
import { unsavedChanges, sidebarOpen, bugReportDialogOpen } from "$lib/stores/app";
|
||||||
import "../layout.css";
|
import "../layout.css";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import * as m from "$lib/paraglide/messages.js";
|
import * as m from "$lib/paraglide/messages.js";
|
||||||
@@ -17,10 +17,20 @@
|
|||||||
PanelRightOpen,
|
PanelRightOpen,
|
||||||
House,
|
House,
|
||||||
Settings,
|
Settings,
|
||||||
|
Bug,
|
||||||
|
Loader2,
|
||||||
|
Copy,
|
||||||
|
FolderOpen,
|
||||||
|
CheckCircle,
|
||||||
|
Camera,
|
||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
import { Separator } from "$lib/components/ui/separator/index.js";
|
import { Separator } from "$lib/components/ui/separator/index.js";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
import { Button, buttonVariants } from "$lib/components/ui/button/index.js";
|
||||||
|
import * as Dialog from "$lib/components/ui/dialog/index.js";
|
||||||
|
import { Input } from "$lib/components/ui/input/index.js";
|
||||||
|
import { Label } from "$lib/components/ui/label/index.js";
|
||||||
|
import { Textarea } from "$lib/components/ui/textarea/index.js";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
WindowMinimise,
|
WindowMinimise,
|
||||||
@@ -30,7 +40,7 @@
|
|||||||
Quit,
|
Quit,
|
||||||
} from "$lib/wailsjs/runtime/runtime";
|
} from "$lib/wailsjs/runtime/runtime";
|
||||||
import { RefreshCcwDot } from "@lucide/svelte";
|
import { RefreshCcwDot } from "@lucide/svelte";
|
||||||
import { IsDebuggerRunning, QuitApp } from "$lib/wailsjs/go/main/App";
|
import { IsDebuggerRunning, QuitApp, TakeScreenshot, SubmitBugReport, OpenFolderInExplorer } from "$lib/wailsjs/go/main/App";
|
||||||
import { settingsStore } from "$lib/stores/settings.svelte.js";
|
import { settingsStore } from "$lib/stores/settings.svelte.js";
|
||||||
|
|
||||||
let versionInfo: utils.Config | null = $state(null);
|
let versionInfo: utils.Config | null = $state(null);
|
||||||
@@ -38,6 +48,20 @@
|
|||||||
let isDebugerOn: boolean = $state(false);
|
let isDebugerOn: boolean = $state(false);
|
||||||
let isDebbugerProtectionOn: boolean = $state(true);
|
let isDebbugerProtectionOn: boolean = $state(true);
|
||||||
|
|
||||||
|
// Bug report form state
|
||||||
|
let userName = $state("");
|
||||||
|
let userEmail = $state("");
|
||||||
|
let bugDescription = $state("");
|
||||||
|
|
||||||
|
// Bug report screenshot state
|
||||||
|
let screenshotData = $state("");
|
||||||
|
let isCapturing = $state(false);
|
||||||
|
|
||||||
|
// Bug report UI state
|
||||||
|
let isSubmitting = $state(false);
|
||||||
|
let isSuccess = $state(false);
|
||||||
|
let resultZipPath = $state("");
|
||||||
|
|
||||||
async function syncMaxState() {
|
async function syncMaxState() {
|
||||||
isMaximized = await WindowIsMaximised();
|
isMaximized = await WindowIsMaximised();
|
||||||
}
|
}
|
||||||
@@ -126,6 +150,92 @@
|
|||||||
applyTheme(stored === "light" ? "light" : "dark");
|
applyTheme(stored === "light" ? "light" : "dark");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Bug report dialog effects
|
||||||
|
$effect(() => {
|
||||||
|
if ($bugReportDialogOpen) {
|
||||||
|
// Capture screenshot immediately when dialog opens
|
||||||
|
captureScreenshot();
|
||||||
|
} else {
|
||||||
|
// Reset form when dialog closes
|
||||||
|
resetBugReportForm();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function captureScreenshot() {
|
||||||
|
isCapturing = true;
|
||||||
|
try {
|
||||||
|
const result = await TakeScreenshot();
|
||||||
|
screenshotData = result.data;
|
||||||
|
console.log("Screenshot captured:", result.width, "x", result.height);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to capture screenshot:", err);
|
||||||
|
} finally {
|
||||||
|
isCapturing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetBugReportForm() {
|
||||||
|
userName = "";
|
||||||
|
userEmail = "";
|
||||||
|
bugDescription = "";
|
||||||
|
screenshotData = "";
|
||||||
|
isCapturing = false;
|
||||||
|
isSubmitting = false;
|
||||||
|
isSuccess = false;
|
||||||
|
resultZipPath = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBugReportSubmit(event: Event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!bugDescription.trim()) {
|
||||||
|
toast.error("Please provide a bug description.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSubmitting = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await SubmitBugReport({
|
||||||
|
name: userName,
|
||||||
|
email: userEmail,
|
||||||
|
description: bugDescription,
|
||||||
|
screenshotData: screenshotData
|
||||||
|
});
|
||||||
|
|
||||||
|
resultZipPath = result.zipPath;
|
||||||
|
isSuccess = true;
|
||||||
|
console.log("Bug report created:", result.zipPath);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to create bug report:", err);
|
||||||
|
toast.error(m.bugreport_error());
|
||||||
|
} finally {
|
||||||
|
isSubmitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyBugReportPath() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(resultZipPath);
|
||||||
|
toast.success(m.bugreport_copied());
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to copy path:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openBugReportFolder() {
|
||||||
|
try {
|
||||||
|
const folderPath = resultZipPath.replace(/\.zip$/, "");
|
||||||
|
await OpenFolderInExplorer(folderPath);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to open folder:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeBugReportDialog() {
|
||||||
|
$bugReportDialogOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
syncMaxState();
|
syncMaxState();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -246,6 +356,16 @@
|
|||||||
class="hover:opacity-100 transition-opacity"
|
class="hover:opacity-100 transition-opacity"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Separator orientation="vertical" />
|
||||||
|
<Bug
|
||||||
|
size="16"
|
||||||
|
onclick={() => {
|
||||||
|
$bugReportDialogOpen = !$bugReportDialogOpen;
|
||||||
|
}}
|
||||||
|
style="cursor: pointer; opacity: 0.7;"
|
||||||
|
class="hover:opacity-100 transition-opacity"
|
||||||
|
/>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
data-sveltekit-reload
|
data-sveltekit-reload
|
||||||
href="/"
|
href="/"
|
||||||
@@ -256,6 +376,7 @@
|
|||||||
>
|
>
|
||||||
<RefreshCcwDot />
|
<RefreshCcwDot />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:none">
|
<div style="display:none">
|
||||||
@@ -265,6 +386,135 @@
|
|||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Bug Report Dialog -->
|
||||||
|
<Dialog.Root bind:open={$bugReportDialogOpen}>
|
||||||
|
<Dialog.Content class="sm:max-w-[500px] w-full max-h-[80vh] overflow-y-auto custom-scrollbar">
|
||||||
|
{#if isSuccess}
|
||||||
|
<!-- Success State -->
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title class="flex items-center gap-2">
|
||||||
|
<CheckCircle class="h-5 w-5 text-green-500" />
|
||||||
|
{m.bugreport_success_title()}
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
{m.bugreport_success_message()}
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
<div class="grid gap-4 py-4">
|
||||||
|
<div class="bg-muted rounded-md p-3">
|
||||||
|
<code class="text-xs break-all select-all">{resultZipPath}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button variant="outline" class="flex-1" onclick={copyBugReportPath}>
|
||||||
|
<Copy class="h-4 w-4 mr-2" />
|
||||||
|
{m.bugreport_copy_path()}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" class="flex-1" onclick={openBugReportFolder}>
|
||||||
|
<FolderOpen class="h-4 w-4 mr-2" />
|
||||||
|
{m.bugreport_open_folder()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button onclick={closeBugReportDialog}>
|
||||||
|
{m.bugreport_close()}
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
{:else}
|
||||||
|
<!-- Form State -->
|
||||||
|
<form onsubmit={handleBugReportSubmit}>
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>{m.bugreport_title()}</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
{m.bugreport_description()}
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
<div class="grid gap-4 py-4">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="bug-name">{m.bugreport_name_label()}</Label>
|
||||||
|
<Input
|
||||||
|
id="bug-name"
|
||||||
|
placeholder={m.bugreport_name_placeholder()}
|
||||||
|
bind:value={userName}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="bug-email">{m.bugreport_email_label()}</Label>
|
||||||
|
<Input
|
||||||
|
id="bug-email"
|
||||||
|
type="email"
|
||||||
|
placeholder={m.bugreport_email_placeholder()}
|
||||||
|
bind:value={userEmail}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="bug-description">{m.bugreport_text_label()}</Label>
|
||||||
|
<Textarea
|
||||||
|
id="bug-description"
|
||||||
|
placeholder={m.bugreport_text_placeholder()}
|
||||||
|
bind:value={bugDescription}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
class="min-h-[120px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Screenshot Preview -->
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label class="flex items-center gap-2">
|
||||||
|
<Camera class="h-4 w-4" />
|
||||||
|
{m.bugreport_screenshot_label()}
|
||||||
|
</Label>
|
||||||
|
{#if isCapturing}
|
||||||
|
<div class="flex items-center gap-2 text-muted-foreground text-sm">
|
||||||
|
<Loader2 class="h-4 w-4 animate-spin" />
|
||||||
|
Capturing...
|
||||||
|
</div>
|
||||||
|
{:else if screenshotData}
|
||||||
|
<div class="border rounded-md overflow-hidden">
|
||||||
|
<img
|
||||||
|
src="data:image/png;base64,{screenshotData}"
|
||||||
|
alt="Screenshot preview"
|
||||||
|
class="w-full h-32 object-cover object-top opacity-80 hover:opacity-100 transition-opacity cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="text-muted-foreground text-sm">
|
||||||
|
No screenshot available
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{m.bugreport_info()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog.Footer>
|
||||||
|
<button type="button" class={buttonVariants({ variant: "outline" })} disabled={isSubmitting} onclick={closeBugReportDialog}>
|
||||||
|
{m.bugreport_cancel()}
|
||||||
|
</button>
|
||||||
|
<Button type="submit" disabled={isSubmitting || isCapturing}>
|
||||||
|
{#if isSubmitting}
|
||||||
|
<Loader2 class="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
{m.bugreport_submitting()}
|
||||||
|
{:else}
|
||||||
|
{m.bugreport_submit()}
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -498,4 +748,26 @@
|
|||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(.custom-scrollbar::-webkit-scrollbar) {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.custom-scrollbar::-webkit-scrollbar-track) {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.custom-scrollbar::-webkit-scrollbar-thumb) {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.custom-scrollbar::-webkit-scrollbar-thumb:hover) {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.custom-scrollbar::-webkit-scrollbar-corner) {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<section class="center" aria-label="Overview">
|
<section class="center" aria-label="Overview" id="main-content-app">
|
||||||
<MailViewer />
|
<MailViewer />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -36,26 +36,4 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-corner {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
Reference in New Issue
Block a user