diff --git a/client/.gitignore b/client/.gitignore new file mode 100644 index 0000000..129d522 --- /dev/null +++ b/client/.gitignore @@ -0,0 +1,3 @@ +build/bin +node_modules +frontend/dist diff --git a/client/README.md b/client/README.md new file mode 100644 index 0000000..397b08b --- /dev/null +++ b/client/README.md @@ -0,0 +1,19 @@ +# README + +## About + +This is the official Wails Vanilla template. + +You can configure the project by editing `wails.json`. More information about the project settings can be found +here: https://wails.io/docs/reference/project-config + +## Live Development + +To run in live development mode, run `wails dev` in the project directory. This will run a Vite development +server that will provide very fast hot reload of your frontend changes. If you want to develop in a browser +and have access to your Go methods, there is also a dev server that runs on http://localhost:34115. Connect +to this in your browser, and you can call your Go code from devtools. + +## Building + +To build a redistributable, production mode package, use `wails build`. diff --git a/client/app.go b/client/app.go new file mode 100644 index 0000000..00b7589 --- /dev/null +++ b/client/app.go @@ -0,0 +1,921 @@ +package main + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "client/internal/blocker" + "client/internal/screen" + "client/internal/winapi" + + "github.com/gorilla/websocket" + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +var blockedReportLast sync.Map // processName -> time.Time +var wifiReportLast sync.Map // ssidKey -> time.Time + +var encryptionKey = []byte("simple-care-proctor-secretkey32!") // 32-byte key for AES-256 + +func encrypt(data []byte) ([]byte, error) { + block, err := aes.NewCipher(encryptionKey) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } + ciphertext := gcm.Seal(nonce, nonce, data, nil) + return ciphertext, nil +} + +func decrypt(data []byte) ([]byte, error) { + block, err := aes.NewCipher(encryptionKey) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + nonceSize := gcm.NonceSize() + if len(data) < nonceSize { + return nil, errors.New("ciphertext too short") + } + nonce, ciphertext := data[:nonceSize], data[nonceSize:] + return gcm.Open(nil, nonce, ciphertext, nil) +} + +type StudentData struct { + StudentID int64 `json:"studentId"` + FullName string `json:"fullName"` + Email string `json:"email"` + Avatar string `json:"avatar"` + Dob string `json:"dob"` + StudentCode string `json:"studentCode"` + Phone string `json:"phone"` + SystemID int64 `json:"systemId"` +} + +type App struct { + ctx context.Context + student *StudentData + mu sync.Mutex + sessionPath string + statsPath string + wsConn *websocket.Conn + wsConnected bool + wifiSSID string + wifiBSSID string + allowedApps string + onlineSecs int + offlineSecs int + unsyncedOn int + unsyncedOff int + lastSyncTime time.Time + isStreamingSc bool + streamScStop chan struct{} + statusMsg string + expectingLogin bool + backendOnline bool + dashboard StudentDashboardSnapshot + wifiEnforce bool + acceptedWifi map[string]bool + wifiRejected bool +} + +type LocalStats struct { + OnlineSecs int `json:"onlineSecs"` + OfflineSecs int `json:"offlineSecs"` + UnsyncedOn int `json:"unsyncedOn"` + UnsyncedOff int `json:"unsyncedOff"` +} + +type StudentShiftSnapshot struct { + Period int `json:"period"` + CourseName string `json:"courseName"` + StartTime string `json:"startTime"` + EndTime string `json:"endTime"` + IsActiveNow bool `json:"isActiveNow"` + OnlineSeconds int `json:"onlineSeconds"` + OfflineSeconds int `json:"offlineSeconds"` + AttendanceStatus int `json:"attendanceStatus"` + AttendanceLabel string `json:"attendanceLabel"` +} + +type StudentDashboardSnapshot struct { + SessionDate string `json:"sessionDate"` + MonitorMode string `json:"monitorMode"` + MonitorLabel string `json:"monitorLabel"` + ClassRkID int64 `json:"classRkId"` + ClassName string `json:"className"` + ClassCode string `json:"classCode"` + CurrentPeriod int `json:"currentPeriod"` + CurrentCourseName string `json:"currentCourseName"` + CurrentShiftStart string `json:"currentShiftStart"` + CurrentShiftEnd string `json:"currentShiftEnd"` + InScheduleNow bool `json:"inScheduleNow"` + BlockerActive bool `json:"blockerActive"` + Shifts []StudentShiftSnapshot `json:"shifts"` +} + +func NewApp() *App { + configDir, err := os.UserConfigDir() + if err != nil { + // Fallback to executable folder if UserConfigDir is unavailable + exePath, _ := os.Executable() + configDir = filepath.Dir(exePath) + } + appDir := filepath.Join(configDir, "SimpleCare") + _ = os.MkdirAll(appDir, 0755) + + return &App{ + sessionPath: filepath.Join(appDir, "student_session.json"), + statsPath: filepath.Join(appDir, "student_stats.json"), + lastSyncTime: time.Now(), + expectingLogin: false, + } +} + +func (a *App) startup(ctx context.Context) { + a.ctx = ctx + a.loadSession() + a.loadStats() + + // Register blocker callbacks + blocker.Instance.OnBlocked = func(procName string, title string) { + go a.reportBlockedApp(procName, title) + } + blocker.Instance.OnKill = func(procName string, title string) { + _, _ = runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{ + Type: runtime.WarningDialog, + Title: "Ứng dụng bị đóng", + Message: fmt.Sprintf("Hệ thống đã tự động đóng ứng dụng '%s' (%s) do không nằm trong danh sách các ứng dụng được phép sử dụng trong giờ học.", procName, title), + }) + } + + // Khởi chạy HTTP server nhận callback login thành công + a.startLocalServer() + + // Khởi chạy vòng lặp giám sát định kỳ (mỗi 10 giây) + go a.monitorLoop() + + // Khởi chạy vòng lặp đọc local storage của trang Rikkei Portal khi chưa đăng nhập + go a.authStorageScanner() +} + +// startLocalServer khởi chạy server lắng nghe callback nhận thông tin sinh viên từ webview +func (a *App) startLocalServer() { + mux := http.NewServeMux() + mux.HandleFunc("/login-success", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + if r.Method == "OPTIONS" { + return + } + + data := r.URL.Query().Get("data") + if data == "" { + w.WriteHeader(http.StatusBadRequest) + return + } + + a.mu.Lock() + expecting := a.expectingLogin + a.mu.Unlock() + if !expecting { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ignored")) + return + } + + var st StudentData + if err := json.Unmarshal([]byte(data), &st); err == nil && st.StudentID > 0 { + a.saveSession(&st) + log.Printf("[LOCALSERVER] Student %s logged in successfully.", st.FullName) + + a.mu.Lock() + a.expectingLogin = false + a.mu.Unlock() + + blocker.Instance.Start() + + // Kiểm tra điều kiện app được phép và giờ học trước khi tải giao diện + go a.fetchAllowedApps(st.SystemID) + go a.fetchStudentStatus(st.StudentID) + go a.fetchWifiPolicy() + + // Chuyển hướng WebView về trang dashboard của app bằng cách reload app assets + runtime.WindowReloadApp(a.ctx) + w.Write([]byte("ok")) + } else { + w.WriteHeader(http.StatusBadRequest) + } + }) + + server := &http.Server{ + Addr: "127.0.0.1:34115", + Handler: mux, + } + + go func() { + _ = server.ListenAndServe() + }() + log.Println("[LOCALSERVER] Callback server started on http://127.0.0.1:34115") +} + +// loadSession nạp session từ file cục bộ +func (a *App) loadSession() { + a.mu.Lock() + defer a.mu.Unlock() + + data, err := os.ReadFile(a.sessionPath) + if err == nil { + decrypted, errDec := decrypt(data) + if errDec == nil { + data = decrypted + } + var s StudentData + if err := json.Unmarshal(data, &s); err == nil { + a.student = &s + log.Printf("[APP] Loaded local session: %s (%s)", s.FullName, s.StudentCode) + blocker.Instance.Start() + + go a.fetchAllowedApps(s.SystemID) + go a.fetchStudentStatus(s.StudentID) + go a.fetchWifiPolicy() + go a.syncProfileToServer(&s) + } + } +} + +// saveSession lưu session ra file +func (a *App) saveSession(s *StudentData) { + a.mu.Lock() + defer a.mu.Unlock() + + a.student = s + data, _ := json.MarshalIndent(s, "", " ") + encrypted, err := encrypt(data) + if err == nil { + _ = os.WriteFile(a.sessionPath, encrypted, 0644) + } else { + _ = os.WriteFile(a.sessionPath, data, 0644) + } + go a.syncProfileToServer(s) +} + +func (a *App) syncProfileToServer(s *StudentData) { + if s == nil || s.Avatar == "" { + return + } + payload := map[string]any{ + "studentRkId": s.StudentID, + "classRkId": s.SystemID, + "sessionDate": time.Now().Format("2006-01-02"), + "avatar": s.Avatar, + } + bodyBytes, _ := json.Marshal(payload) + client := http.Client{Timeout: 5 * time.Second} + resp, err := client.Post("http://127.0.0.1:8080/api/student/sync-log", "application/json", bytes.NewBuffer(bodyBytes)) + if err != nil { + return + } + _ = resp.Body.Close() +} + +// loadStats nạp stats +func (a *App) loadStats() { + a.mu.Lock() + defer a.mu.Unlock() + + data, err := os.ReadFile(a.statsPath) + if err == nil { + var ls LocalStats + if err := json.Unmarshal(data, &ls); err == nil { + a.onlineSecs = ls.OnlineSecs + a.offlineSecs = ls.OfflineSecs + a.unsyncedOn = ls.UnsyncedOn + a.unsyncedOff = ls.UnsyncedOff + } + } +} + +// saveStats lưu stats ra file +func (a *App) saveStats() { + a.mu.Lock() + defer a.mu.Unlock() + + ls := LocalStats{ + OnlineSecs: a.onlineSecs, + OfflineSecs: a.offlineSecs, + UnsyncedOn: a.unsyncedOn, + UnsyncedOff: a.unsyncedOff, + } + data, _ := json.Marshal(ls) + _ = os.WriteFile(a.statsPath, data, 0644) +} + +// CheckLoginStatus kiểm tra xem sinh viên đã đăng nhập chưa +func (a *App) CheckLoginStatus() bool { + a.mu.Lock() + defer a.mu.Unlock() + return a.student != nil +} + +// GetStudentInfo trả về thông tin sinh viên +func (a *App) GetStudentInfo() map[string]any { + a.mu.Lock() + defer a.mu.Unlock() + if a.student == nil { + return nil + } + return map[string]any{ + "studentId": a.student.StudentID, + "fullName": a.student.FullName, + "email": a.student.Email, + "avatar": a.student.Avatar, + "dob": a.student.Dob, + "studentCode": a.student.StudentCode, + "phone": a.student.Phone, + "systemId": a.student.SystemID, + } +} + +// NavigateToLogin chuyển WebView đến trang đăng nhập Rikkei Portal +func (a *App) NavigateToLogin() { + a.mu.Lock() + a.expectingLogin = true + a.mu.Unlock() + runtime.WindowExecJS(a.ctx, "window.location.href = 'https://portal.rikkei.edu.vn/dangnhap'") +} + +// Logout đăng xuất sinh viên +func (a *App) Logout() { + a.mu.Lock() + a.student = nil + a.expectingLogin = false + a.mu.Unlock() + + _ = os.Remove(a.sessionPath) + _ = os.Remove(a.statsPath) + + a.mu.Lock() + a.onlineSecs = 0 + a.offlineSecs = 0 + a.unsyncedOn = 0 + a.unsyncedOff = 0 + a.mu.Unlock() + + blocker.Instance.Stop() + a.disconnectWS() + a.stopScreenshotStream() + + runtime.WindowReloadApp(a.ctx) +} + +// GetStats trả về Wifi, thời gian online/offline và trạng thái giám sát +func (a *App) GetStats() map[string]any { + a.mu.Lock() + defer a.mu.Unlock() + + shifts := make([]StudentShiftSnapshot, len(a.dashboard.Shifts)) + copy(shifts, a.dashboard.Shifts) + for i := range shifts { + if shifts[i].IsActiveNow { + shifts[i].OnlineSeconds += a.unsyncedOn + shifts[i].OfflineSeconds += a.unsyncedOff + } + } + + return map[string]any{ + "wifiSSID": a.wifiSSID, + "onlineSecs": a.onlineSecs, + "offlineSecs": a.offlineSecs, + "wsConnected": a.wsConnected, + "serverReachable": a.backendOnline, + "allowedApps": a.allowedApps, + "statusMsg": a.statusMsg, + "sessionDate": a.dashboard.SessionDate, + "monitorMode": a.dashboard.MonitorMode, + "monitorLabel": a.dashboard.MonitorLabel, + "className": a.dashboard.ClassName, + "classCode": a.dashboard.ClassCode, + "currentPeriod": a.dashboard.CurrentPeriod, + "currentCourseName": a.dashboard.CurrentCourseName, + "currentShiftStart": a.dashboard.CurrentShiftStart, + "currentShiftEnd": a.dashboard.CurrentShiftEnd, + "inScheduleNow": a.dashboard.InScheduleNow, + "blockerActive": a.dashboard.BlockerActive, + "shifts": shifts, + } +} + +// SendWebcamFrame truyền webcam frame từ JS frontend lên máy chủ qua WS +func (a *App) SendWebcamFrame(frameBase64 string) { + a.mu.Lock() + conn := a.wsConn + a.mu.Unlock() + + if conn != nil { + _ = conn.WriteJSON(map[string]any{ + "event": "webcam_stream_frame", + "data": map[string]any{ + "imageBuffer": frameBase64, + }, + }) + } +} + +// authStorageScanner chỉ quét localStorage khi người dùng chủ động mở trang đăng nhập +func (a *App) authStorageScanner() { + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + <-ticker.C + + a.mu.Lock() + expecting := a.expectingLogin + loggedIn := a.student != nil + a.mu.Unlock() + + if loggedIn || !expecting { + continue + } + + runtime.WindowExecJS(a.ctx, ` + try { + const st = localStorage.getItem("student"); + if (st) { + fetch("http://127.0.0.1:34115/login-success?data=" + encodeURIComponent(st)); + } + } catch(e) {} + `) + } +} + +// monitorLoop định kỳ 10s: check wifi, ping backend, cập nhật online/offline seconds và đồng bộ lên server +func (a *App) monitorLoop() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for { + <-ticker.C + + if !a.CheckLoginStatus() { + continue + } + + wifi := winapi.GetWifiConnection() + a.mu.Lock() + a.wifiSSID = wifi.SSID + a.wifiBSSID = wifi.BSSID + a.mu.Unlock() + + backendOnline := a.pingBackend() + + if backendOnline && wifi.SSID != "" && wifi.BSSID != "" { + go a.reportWifi(wifi.SSID, wifi.BSSID) + a.fetchWifiPolicy() + if !a.isWifiAllowed(wifi.SSID, wifi.BSSID) { + a.rejectUnauthorizedWifi(wifi.SSID, wifi.BSSID) + continue + } + } + + a.mu.Lock() + a.backendOnline = backendOnline + if backendOnline { + a.onlineSecs += 10 + a.unsyncedOn += 10 + } else { + a.offlineSecs += 10 + a.unsyncedOff += 10 + } + a.mu.Unlock() + + a.saveStats() + + if backendOnline { + a.connectWS() + a.syncLogsToServer() + } else { + a.disconnectWS() + } + } +} + +func (a *App) pingBackend() bool { + client := http.Client{Timeout: 3 * time.Second} + resp, err := client.Get("http://127.0.0.1:8080/api/stats") + if err != nil { + return false + } + _ = resp.Body.Close() + return resp.StatusCode == http.StatusOK +} + +func (a *App) syncLogsToServer() { + a.mu.Lock() + student := a.student + unsyncedOn := a.unsyncedOn + unsyncedOff := a.unsyncedOff + ssid := a.wifiSSID + a.mu.Unlock() + + if student == nil { + return + } + + go a.fetchStudentStatus(student.StudentID) + go a.fetchAllowedApps(student.SystemID) + + payload := map[string]any{ + "studentRkId": student.StudentID, + "classRkId": student.SystemID, + "sessionDate": time.Now().Format("2006-01-02"), + "addOnlineSeconds": unsyncedOn, + "addOfflineSeconds": unsyncedOff, + "wifiSsid": ssid, + "avatar": student.Avatar, + } + + bodyBytes, _ := json.Marshal(payload) + client := http.Client{Timeout: 5 * time.Second} + resp, err := client.Post("http://127.0.0.1:8080/api/student/sync-log", "application/json", bytes.NewBuffer(bodyBytes)) + if err != nil { + return + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + a.mu.Lock() + a.unsyncedOn -= unsyncedOn + a.unsyncedOff -= unsyncedOff + a.mu.Unlock() + a.saveStats() + log.Printf("[SYNC] Synced %ds online, %ds offline to server.", unsyncedOn, unsyncedOff) + } +} + +func (a *App) reportBlockedApp(procName string, title string) { + a.mu.Lock() + student := a.student + a.mu.Unlock() + if student == nil || student.StudentID <= 0 { + return + } + + procKey := strings.ToLower(strings.TrimSpace(procName)) + if procKey == "" { + return + } + if v, ok := blockedReportLast.Load(procKey); ok { + if time.Since(v.(time.Time)) < 20*time.Second { + return + } + } + blockedReportLast.Store(procKey, time.Now()) + + payload := map[string]any{ + "studentRkId": student.StudentID, + "processName": procName, + "windowTitle": title, + } + bodyBytes, _ := json.Marshal(payload) + client := http.Client{Timeout: 4 * time.Second} + resp, err := client.Post("http://127.0.0.1:8080/api/student/report-blocked-app", "application/json", bytes.NewBuffer(bodyBytes)) + if err != nil { + log.Printf("[CLIENT] report-blocked-app failed: %v", err) + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + log.Printf("[CLIENT] report-blocked-app status %d: %s", resp.StatusCode, string(body)) + return + } + log.Printf("[CLIENT] report-blocked-app ok: %s", procName) +} + +func (a *App) reportWifi(ssid, bssid string) { + a.mu.Lock() + student := a.student + a.mu.Unlock() + if student == nil || strings.TrimSpace(ssid) == "" || strings.TrimSpace(bssid) == "" { + return + } + key := winapi.BSSIDKey(bssid) + if len(key) != 12 { + return + } + if v, ok := wifiReportLast.Load(key); ok { + if time.Since(v.(time.Time)) < 30*time.Second { + return + } + } + wifiReportLast.Store(key, time.Now()) + + payload := map[string]any{ + "studentRkId": student.StudentID, + "ssid": ssid, + "bssid": bssid, + } + bodyBytes, _ := json.Marshal(payload) + client := http.Client{Timeout: 4 * time.Second} + resp, err := client.Post("http://127.0.0.1:8080/api/student/report-wifi", "application/json", bytes.NewBuffer(bodyBytes)) + if err != nil { + return + } + _ = resp.Body.Close() +} + +func (a *App) fetchWifiPolicy() { + client := http.Client{Timeout: 4 * time.Second} + resp, err := client.Get("http://127.0.0.1:8080/api/student/wifi-policy") + if err != nil { + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return + } + var res struct { + Enforce bool `json:"enforce"` + Accepted []struct { + SSID string `json:"ssid"` + BSSID string `json:"bssid"` + } `json:"accepted"` + } + if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { + return + } + allowed := make(map[string]bool, len(res.Accepted)) + for _, item := range res.Accepted { + k := winapi.BSSIDKey(item.BSSID) + if len(k) == 12 { + allowed[k] = true + } + } + a.mu.Lock() + a.wifiEnforce = res.Enforce + a.acceptedWifi = allowed + a.mu.Unlock() +} + +func (a *App) isWifiAllowed(ssid, bssid string) bool { + a.mu.Lock() + enforce := a.wifiEnforce + allowed := a.acceptedWifi + a.mu.Unlock() + + if !enforce || len(allowed) == 0 { + return true + } + bKey := winapi.BSSIDKey(bssid) + if bKey == "" || len(bKey) != 12 { + return false + } + return allowed[bKey] +} + +func (a *App) rejectUnauthorizedWifi(ssid, bssid string) { + a.mu.Lock() + if a.wifiRejected { + a.mu.Unlock() + return + } + a.wifiRejected = true + a.mu.Unlock() + + blocker.Instance.Stop() + a.disconnectWS() + + _, _ = runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{ + Type: runtime.WarningDialog, + Title: "WiFi không được phép", + Message: fmt.Sprintf("Điểm phát WiFi \"%s\" (%s) không được phép.\n\nCó thể là mạng giả mạo (hotspot trùng tên). Hãy kết nối đúng WiFi của trường rồi mở lại ứng dụng.", ssid, bssid), + }) + runtime.Quit(a.ctx) +} + +func (a *App) fetchAllowedApps(classId int64) { + a.mu.Lock() + studentID := int64(0) + if a.student != nil { + studentID = a.student.StudentID + } + a.mu.Unlock() + + client := http.Client{Timeout: 4 * time.Second} + resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:8080/api/classes/%d/allowed-apps?studentId=%d", classId, studentID)) + if err != nil { + return + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + var res struct { + Keywords string `json:"keywords"` + Exit bool `json:"exit"` + Reason string `json:"reason"` + } + if err := json.NewDecoder(resp.Body).Decode(&res); err == nil { + if res.Exit { + reason := res.Reason + if reason == "" { + reason = "Không đáp ứng điều kiện học tập (chưa cấu hình ứng dụng được phép hoặc ngoài giờ học)." + } + log.Printf("[CLIENT] Conditions not met: %s. Blocker suspended.", reason) + + a.mu.Lock() + a.allowedApps = "" + a.statusMsg = reason + a.mu.Unlock() + + blocker.Instance.Stop() + return + } + a.mu.Lock() + a.allowedApps = res.Keywords + if a.dashboard.MonitorLabel != "" { + a.statusMsg = a.dashboard.MonitorLabel + } else { + a.statusMsg = "Đang trong giờ học" + } + a.mu.Unlock() + + blocker.Instance.SetKeywords(res.Keywords) + blocker.Instance.Start() + } + } +} + +func (a *App) fetchStudentStatus(studentID int64) { + if studentID <= 0 { + return + } + client := http.Client{Timeout: 4 * time.Second} + resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:8080/api/student/status?studentRkId=%d", studentID)) + if err != nil { + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return + } + + var res StudentDashboardSnapshot + if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { + return + } + + a.mu.Lock() + a.dashboard = res + if res.MonitorLabel != "" { + a.statusMsg = res.MonitorLabel + } + if res.ClassName != "" && res.MonitorMode == "learning" { + a.statusMsg = fmt.Sprintf("%s · %s", res.ClassName, res.MonitorLabel) + } else if res.ClassName != "" && res.MonitorMode == "outside_schedule" { + a.statusMsg = fmt.Sprintf("%s · Ngoài giờ học", res.ClassName) + } else if res.ClassName != "" && res.MonitorMode == "not_configured" { + a.statusMsg = fmt.Sprintf("%s · %s", res.ClassName, res.MonitorLabel) + } + a.mu.Unlock() +} + +func (a *App) connectWS() { + a.mu.Lock() + if a.wsConnected || a.student == nil { + a.mu.Unlock() + return + } + student := a.student + a.mu.Unlock() + + wsUrl := fmt.Sprintf("ws://127.0.0.1:8080/ws?role=student&studentId=%d&classId=%d", student.StudentID, student.SystemID) + dialer := websocket.Dialer{HandshakeTimeout: 4 * time.Second} + conn, _, err := dialer.Dial(wsUrl, nil) + if err != nil { + return + } + + a.mu.Lock() + a.wsConn = conn + a.wsConnected = true + a.mu.Unlock() + + log.Println("[WS] Connected to proctor websocket hub.") + + go func() { + defer a.disconnectWS() + for { + var msg struct { + Event string `json:"event"` + Data map[string]any `json:"data"` + } + err := conn.ReadJSON(&msg) + if err != nil { + break + } + + switch msg.Event { + case "start_screenshot_stream": + a.startScreenshotStream() + case "stop_screenshot_stream": + a.stopScreenshotStream() + case "start_webcam_stream": + runtime.EventsEmit(a.ctx, "start_webcam_stream") + case "stop_webcam_stream": + runtime.EventsEmit(a.ctx, "stop_webcam_stream") + } + } + }() +} + +func (a *App) disconnectWS() { + a.mu.Lock() + defer a.mu.Unlock() + + if a.wsConn != nil { + _ = a.wsConn.Close() + a.wsConn = nil + } + a.wsConnected = false +} + +func (a *App) startScreenshotStream() { + a.mu.Lock() + if a.isStreamingSc { + a.mu.Unlock() + return + } + a.isStreamingSc = true + a.streamScStop = make(chan struct{}) + a.mu.Unlock() + + go func() { + ticker := time.NewTicker(250 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + frame, err := screen.CaptureScreen() + if err != nil { + continue + } + + a.mu.Lock() + conn := a.wsConn + a.mu.Unlock() + + if conn != nil { + _ = conn.WriteJSON(map[string]any{ + "event": "screenshot_stream_frame", + "data": map[string]any{ + "imageBuffer": frame, + }, + }) + } + case <-a.streamScStop: + return + } + } + }() + log.Println("[WS] Screenshot screen-streaming started.") +} + +func (a *App) stopScreenshotStream() { + a.mu.Lock() + defer a.mu.Unlock() + + if !a.isStreamingSc { + return + } + a.isStreamingSc = false + close(a.streamScStop) + log.Println("[WS] Screenshot screen-streaming stopped.") +} diff --git a/client/build.ps1 b/client/build.ps1 new file mode 100644 index 0000000..e2997e2 --- /dev/null +++ b/client/build.ps1 @@ -0,0 +1,62 @@ +# PowerShell script to build Wails client +$ErrorActionPreference = "Stop" + +Write-Host "=============================================" -ForegroundColor Cyan +Write-Host " BUILDING WAILS CLIENT " -ForegroundColor Cyan +Write-Host "=============================================" -ForegroundColor Cyan + +# Check if wails is installed +$wailsInstalled = $null -ne (Get-Command wails -ErrorAction SilentlyContinue) + +if ($wailsInstalled) { + Write-Host "[*] Found Wails CLI. Building via Wails..." -ForegroundColor Green + try { + wails build + Write-Host "[+] Build completed successfully using Wails CLI!" -ForegroundColor Green + exit 0 + } catch { + Write-Host "[-] Wails build failed. Attempting manual fallback build..." -ForegroundColor Yellow + } +} else { + Write-Host "[!] Wails CLI not found. Falling back to manual build..." -ForegroundColor Yellow +} + +# Check for Go +if ($null -eq (Get-Command go -ErrorAction SilentlyContinue)) { + Write-Host "[!] Go is not installed or not in PATH." -ForegroundColor Red + exit 1 +} + +# Check for npm +if ($null -eq (Get-Command npm -ErrorAction SilentlyContinue)) { + Write-Host "[!] Node.js/npm is not installed or not in PATH." -ForegroundColor Red + exit 1 +} + +# Step 1: Build Frontend +Write-Host "[*] Building frontend assets..." -ForegroundColor Cyan +Push-Location frontend + +try { + if (-not (Test-Path "node_modules")) { + Write-Host "[*] node_modules not found. Running 'npm install'..." -ForegroundColor Cyan + npm install + } + + Write-Host "[*] Running 'npm run build'..." -ForegroundColor Cyan + npm run build +} finally { + Pop-Location +} + +# Step 2: Build Backend Go binary +Write-Host "[*] Building Go application..." -ForegroundColor Cyan + +try { + # -H windowsgui: prevents console window from flashing on startup + go build -ldflags="-s -w -H windowsgui" -o client.exe main.go app.go + Write-Host "[+] Build completed successfully! Generated client.exe" -ForegroundColor Green +} catch { + Write-Host "[-] Go build failed." -ForegroundColor Red + exit 1 +} diff --git a/client/build/README.md b/client/build/README.md new file mode 100644 index 0000000..1ae2f67 --- /dev/null +++ b/client/build/README.md @@ -0,0 +1,35 @@ +# Build Directory + +The build directory is used to house all the build files and assets for your application. + +The structure is: + +* bin - Output directory +* darwin - macOS specific files +* windows - Windows specific files + +## Mac + +The `darwin` directory holds files specific to Mac builds. +These may be customised and used as part of the build. To return these files to the default state, simply delete them +and +build with `wails build`. + +The directory contains the following files: + +- `Info.plist` - the main plist file used for Mac builds. It is used when building using `wails build`. +- `Info.dev.plist` - same as the main plist file but used when building using `wails dev`. + +## Windows + +The `windows` directory contains the manifest and rc files used when building with `wails build`. +These may be customised for your application. To return these files to the default state, simply delete them and +build with `wails build`. + +- `icon.ico` - The icon used for the application. This is used when building using `wails build`. If you wish to + use a different icon, simply replace this file with your own. If it is missing, a new `icon.ico` file + will be created using the `appicon.png` file in the build directory. +- `installer/*` - The files used to create the Windows installer. These are used when building using `wails build`. +- `info.json` - Application details used for Windows builds. The data here will be used by the Windows installer, + as well as the application itself (right click the exe -> properties -> details) +- `wails.exe.manifest` - The main application manifest file. \ No newline at end of file diff --git a/client/build/appicon.png b/client/build/appicon.png new file mode 100644 index 0000000..63617fe Binary files /dev/null and b/client/build/appicon.png differ diff --git a/client/build/darwin/Info.dev.plist b/client/build/darwin/Info.dev.plist new file mode 100644 index 0000000..14121ef --- /dev/null +++ b/client/build/darwin/Info.dev.plist @@ -0,0 +1,68 @@ + + + + CFBundlePackageType + APPL + CFBundleName + {{.Info.ProductName}} + CFBundleExecutable + {{.OutputFilename}} + CFBundleIdentifier + com.wails.{{.Name}} + CFBundleVersion + {{.Info.ProductVersion}} + CFBundleGetInfoString + {{.Info.Comments}} + CFBundleShortVersionString + {{.Info.ProductVersion}} + CFBundleIconFile + iconfile + LSMinimumSystemVersion + 10.13.0 + NSHighResolutionCapable + true + NSHumanReadableCopyright + {{.Info.Copyright}} + {{if .Info.FileAssociations}} + CFBundleDocumentTypes + + {{range .Info.FileAssociations}} + + CFBundleTypeExtensions + + {{.Ext}} + + CFBundleTypeName + {{.Name}} + CFBundleTypeRole + {{.Role}} + CFBundleTypeIconFile + {{.IconName}} + + {{end}} + + {{end}} + {{if .Info.Protocols}} + CFBundleURLTypes + + {{range .Info.Protocols}} + + CFBundleURLName + com.wails.{{.Scheme}} + CFBundleURLSchemes + + {{.Scheme}} + + CFBundleTypeRole + {{.Role}} + + {{end}} + + {{end}} + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + + diff --git a/client/build/darwin/Info.plist b/client/build/darwin/Info.plist new file mode 100644 index 0000000..d17a747 --- /dev/null +++ b/client/build/darwin/Info.plist @@ -0,0 +1,63 @@ + + + + CFBundlePackageType + APPL + CFBundleName + {{.Info.ProductName}} + CFBundleExecutable + {{.OutputFilename}} + CFBundleIdentifier + com.wails.{{.Name}} + CFBundleVersion + {{.Info.ProductVersion}} + CFBundleGetInfoString + {{.Info.Comments}} + CFBundleShortVersionString + {{.Info.ProductVersion}} + CFBundleIconFile + iconfile + LSMinimumSystemVersion + 10.13.0 + NSHighResolutionCapable + true + NSHumanReadableCopyright + {{.Info.Copyright}} + {{if .Info.FileAssociations}} + CFBundleDocumentTypes + + {{range .Info.FileAssociations}} + + CFBundleTypeExtensions + + {{.Ext}} + + CFBundleTypeName + {{.Name}} + CFBundleTypeRole + {{.Role}} + CFBundleTypeIconFile + {{.IconName}} + + {{end}} + + {{end}} + {{if .Info.Protocols}} + CFBundleURLTypes + + {{range .Info.Protocols}} + + CFBundleURLName + com.wails.{{.Scheme}} + CFBundleURLSchemes + + {{.Scheme}} + + CFBundleTypeRole + {{.Role}} + + {{end}} + + {{end}} + + diff --git a/client/build/windows/icon.ico b/client/build/windows/icon.ico new file mode 100644 index 0000000..f334798 Binary files /dev/null and b/client/build/windows/icon.ico differ diff --git a/client/build/windows/info.json b/client/build/windows/info.json new file mode 100644 index 0000000..9727946 --- /dev/null +++ b/client/build/windows/info.json @@ -0,0 +1,15 @@ +{ + "fixed": { + "file_version": "{{.Info.ProductVersion}}" + }, + "info": { + "0000": { + "ProductVersion": "{{.Info.ProductVersion}}", + "CompanyName": "{{.Info.CompanyName}}", + "FileDescription": "{{.Info.ProductName}}", + "LegalCopyright": "{{.Info.Copyright}}", + "ProductName": "{{.Info.ProductName}}", + "Comments": "{{.Info.Comments}}" + } + } +} \ No newline at end of file diff --git a/client/build/windows/installer/project.nsi b/client/build/windows/installer/project.nsi new file mode 100644 index 0000000..654ae2e --- /dev/null +++ b/client/build/windows/installer/project.nsi @@ -0,0 +1,114 @@ +Unicode true + +#### +## Please note: Template replacements don't work in this file. They are provided with default defines like +## mentioned underneath. +## If the keyword is not defined, "wails_tools.nsh" will populate them with the values from ProjectInfo. +## If they are defined here, "wails_tools.nsh" will not touch them. This allows to use this project.nsi manually +## from outside of Wails for debugging and development of the installer. +## +## For development first make a wails nsis build to populate the "wails_tools.nsh": +## > wails build --target windows/amd64 --nsis +## Then you can call makensis on this file with specifying the path to your binary: +## For a AMD64 only installer: +## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe +## For a ARM64 only installer: +## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe +## For a installer with both architectures: +## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe +#### +## The following information is taken from the ProjectInfo file, but they can be overwritten here. +#### +## !define INFO_PROJECTNAME "MyProject" # Default "{{.Name}}" +## !define INFO_COMPANYNAME "MyCompany" # Default "{{.Info.CompanyName}}" +## !define INFO_PRODUCTNAME "MyProduct" # Default "{{.Info.ProductName}}" +## !define INFO_PRODUCTVERSION "1.0.0" # Default "{{.Info.ProductVersion}}" +## !define INFO_COPYRIGHT "Copyright" # Default "{{.Info.Copyright}}" +### +## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe" +## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}" +#### +## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html +#### +## Include the wails tools +#### +!include "wails_tools.nsh" + +# The version information for this two must consist of 4 parts +VIProductVersion "${INFO_PRODUCTVERSION}.0" +VIFileVersion "${INFO_PRODUCTVERSION}.0" + +VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}" +VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer" +VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}" +VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}" +VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}" +VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}" + +# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware +ManifestDPIAware true + +!include "MUI.nsh" + +!define MUI_ICON "..\icon.ico" +!define MUI_UNICON "..\icon.ico" +# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314 +!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps +!define MUI_ABORTWARNING # This will warn the user if they exit from the installer. + +!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page. +# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer +!insertmacro MUI_PAGE_DIRECTORY # In which folder install page. +!insertmacro MUI_PAGE_INSTFILES # Installing page. +!insertmacro MUI_PAGE_FINISH # Finished installation page. + +!insertmacro MUI_UNPAGE_INSTFILES # Uinstalling page + +!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer + +## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1 +#!uninstfinalize 'signtool --file "%1"' +#!finalize 'signtool --file "%1"' + +Name "${INFO_PRODUCTNAME}" +OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file. +InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" # Default installing folder ($PROGRAMFILES is Program Files folder). +ShowInstDetails show # This will always show the installation details. + +Function .onInit + !insertmacro wails.checkArchitecture +FunctionEnd + +Section + !insertmacro wails.setShellContext + + !insertmacro wails.webview2runtime + + SetOutPath $INSTDIR + + !insertmacro wails.files + + CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}" + CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}" + + !insertmacro wails.associateFiles + !insertmacro wails.associateCustomProtocols + + !insertmacro wails.writeUninstaller +SectionEnd + +Section "uninstall" + !insertmacro wails.setShellContext + + RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath + + RMDir /r $INSTDIR + + Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" + Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk" + + !insertmacro wails.unassociateFiles + !insertmacro wails.unassociateCustomProtocols + + !insertmacro wails.deleteUninstaller +SectionEnd diff --git a/client/build/windows/installer/wails_tools.nsh b/client/build/windows/installer/wails_tools.nsh new file mode 100644 index 0000000..2f6d321 --- /dev/null +++ b/client/build/windows/installer/wails_tools.nsh @@ -0,0 +1,249 @@ +# DO NOT EDIT - Generated automatically by `wails build` + +!include "x64.nsh" +!include "WinVer.nsh" +!include "FileFunc.nsh" + +!ifndef INFO_PROJECTNAME + !define INFO_PROJECTNAME "{{.Name}}" +!endif +!ifndef INFO_COMPANYNAME + !define INFO_COMPANYNAME "{{.Info.CompanyName}}" +!endif +!ifndef INFO_PRODUCTNAME + !define INFO_PRODUCTNAME "{{.Info.ProductName}}" +!endif +!ifndef INFO_PRODUCTVERSION + !define INFO_PRODUCTVERSION "{{.Info.ProductVersion}}" +!endif +!ifndef INFO_COPYRIGHT + !define INFO_COPYRIGHT "{{.Info.Copyright}}" +!endif +!ifndef PRODUCT_EXECUTABLE + !define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe" +!endif +!ifndef UNINST_KEY_NAME + !define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}" +!endif +!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}" + +!ifndef REQUEST_EXECUTION_LEVEL + !define REQUEST_EXECUTION_LEVEL "admin" +!endif + +RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}" + +!ifdef ARG_WAILS_AMD64_BINARY + !define SUPPORTS_AMD64 +!endif + +!ifdef ARG_WAILS_ARM64_BINARY + !define SUPPORTS_ARM64 +!endif + +!ifdef SUPPORTS_AMD64 + !ifdef SUPPORTS_ARM64 + !define ARCH "amd64_arm64" + !else + !define ARCH "amd64" + !endif +!else + !ifdef SUPPORTS_ARM64 + !define ARCH "arm64" + !else + !error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY" + !endif +!endif + +!macro wails.checkArchitecture + !ifndef WAILS_WIN10_REQUIRED + !define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later." + !endif + + !ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED + !define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}" + !endif + + ${If} ${AtLeastWin10} + !ifdef SUPPORTS_AMD64 + ${if} ${IsNativeAMD64} + Goto ok + ${EndIf} + !endif + + !ifdef SUPPORTS_ARM64 + ${if} ${IsNativeARM64} + Goto ok + ${EndIf} + !endif + + IfSilent silentArch notSilentArch + silentArch: + SetErrorLevel 65 + Abort + notSilentArch: + MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}" + Quit + ${else} + IfSilent silentWin notSilentWin + silentWin: + SetErrorLevel 64 + Abort + notSilentWin: + MessageBox MB_OK "${WAILS_WIN10_REQUIRED}" + Quit + ${EndIf} + + ok: +!macroend + +!macro wails.files + !ifdef SUPPORTS_AMD64 + ${if} ${IsNativeAMD64} + File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}" + ${EndIf} + !endif + + !ifdef SUPPORTS_ARM64 + ${if} ${IsNativeARM64} + File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}" + ${EndIf} + !endif +!macroend + +!macro wails.writeUninstaller + WriteUninstaller "$INSTDIR\uninstall.exe" + + SetRegView 64 + WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}" + WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\"" + WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S" + + ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2 + IntFmt $0 "0x%08X" $0 + WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0" +!macroend + +!macro wails.deleteUninstaller + Delete "$INSTDIR\uninstall.exe" + + SetRegView 64 + DeleteRegKey HKLM "${UNINST_KEY}" +!macroend + +!macro wails.setShellContext + ${If} ${REQUEST_EXECUTION_LEVEL} == "admin" + SetShellVarContext all + ${else} + SetShellVarContext current + ${EndIf} +!macroend + +# Install webview2 by launching the bootstrapper +# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment +!macro wails.webview2runtime + !ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT + !define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime" + !endif + + SetRegView 64 + # If the admin key exists and is not empty then webview2 is already installed + ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" + ${If} $0 != "" + Goto ok + ${EndIf} + + ${If} ${REQUEST_EXECUTION_LEVEL} == "user" + # If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed + ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" + ${If} $0 != "" + Goto ok + ${EndIf} + ${EndIf} + + SetDetailsPrint both + DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}" + SetDetailsPrint listonly + + InitPluginsDir + CreateDirectory "$pluginsdir\webview2bootstrapper" + SetOutPath "$pluginsdir\webview2bootstrapper" + File "tmp\MicrosoftEdgeWebview2Setup.exe" + ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install' + + SetDetailsPrint both + ok: +!macroend + +# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b +!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND + ; Backup the previously associated file class + ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" "" + WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0" + + WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}" + + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}` + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}` + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open" + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}` + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}` +!macroend + +!macro APP_UNASSOCIATE EXT FILECLASS + ; Backup the previously associated file class + ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup` + WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0" + + DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}` +!macroend + +!macro wails.associateFiles + ; Create file associations + {{range .Info.FileAssociations}} + !insertmacro APP_ASSOCIATE "{{.Ext}}" "{{.Name}}" "{{.Description}}" "$INSTDIR\{{.IconName}}.ico" "Open with ${INFO_PRODUCTNAME}" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\"" + + File "..\{{.IconName}}.ico" + {{end}} +!macroend + +!macro wails.unassociateFiles + ; Delete app associations + {{range .Info.FileAssociations}} + !insertmacro APP_UNASSOCIATE "{{.Ext}}" "{{.Name}}" + + Delete "$INSTDIR\{{.IconName}}.ico" + {{end}} +!macroend + +!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND + DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" "" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" "" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" "" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}" +!macroend + +!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL + DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}" +!macroend + +!macro wails.associateCustomProtocols + ; Create custom protocols associations + {{range .Info.Protocols}} + !insertmacro CUSTOM_PROTOCOL_ASSOCIATE "{{.Scheme}}" "{{.Description}}" "$INSTDIR\${PRODUCT_EXECUTABLE},0" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\"" + + {{end}} +!macroend + +!macro wails.unassociateCustomProtocols + ; Delete app custom protocol associations + {{range .Info.Protocols}} + !insertmacro CUSTOM_PROTOCOL_UNASSOCIATE "{{.Scheme}}" + {{end}} +!macroend diff --git a/client/build/windows/wails.exe.manifest b/client/build/windows/wails.exe.manifest new file mode 100644 index 0000000..17e1a23 --- /dev/null +++ b/client/build/windows/wails.exe.manifest @@ -0,0 +1,15 @@ + + + + + + + + + + + true/pm + permonitorv2,permonitor + + + \ No newline at end of file diff --git a/client/client.exe b/client/client.exe new file mode 100644 index 0000000..7964a4a Binary files /dev/null and b/client/client.exe differ diff --git a/client/frontend/index.html b/client/frontend/index.html new file mode 100644 index 0000000..3addd1f --- /dev/null +++ b/client/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Simple Care — Rikkei Education + + +
+ + + diff --git a/client/frontend/package-lock.json b/client/frontend/package-lock.json new file mode 100644 index 0000000..fe03314 --- /dev/null +++ b/client/frontend/package-lock.json @@ -0,0 +1,664 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "devDependencies": { + "vite": "^3.0.7" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.15.18.tgz", + "integrity": "sha512-5GT+kcs2WVGjVs7+boataCkO5Fg0y4kCjzkB5bAip7H4jfnOS3dA6KPiww9W1OEKTKeAcUVhdZGvgI65OXmUnw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.15.18.tgz", + "integrity": "sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.15.18.tgz", + "integrity": "sha512-x/R72SmW3sSFRm5zrrIjAhCeQSAWoni3CmHEqfQrZIQTM3lVCdehdwuIqaOtfC2slvpdlLa62GYoN8SxT23m6Q==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.15.18", + "@esbuild/linux-loong64": "0.15.18", + "esbuild-android-64": "0.15.18", + "esbuild-android-arm64": "0.15.18", + "esbuild-darwin-64": "0.15.18", + "esbuild-darwin-arm64": "0.15.18", + "esbuild-freebsd-64": "0.15.18", + "esbuild-freebsd-arm64": "0.15.18", + "esbuild-linux-32": "0.15.18", + "esbuild-linux-64": "0.15.18", + "esbuild-linux-arm": "0.15.18", + "esbuild-linux-arm64": "0.15.18", + "esbuild-linux-mips64le": "0.15.18", + "esbuild-linux-ppc64le": "0.15.18", + "esbuild-linux-riscv64": "0.15.18", + "esbuild-linux-s390x": "0.15.18", + "esbuild-netbsd-64": "0.15.18", + "esbuild-openbsd-64": "0.15.18", + "esbuild-sunos-64": "0.15.18", + "esbuild-windows-32": "0.15.18", + "esbuild-windows-64": "0.15.18", + "esbuild-windows-arm64": "0.15.18" + } + }, + "node_modules/esbuild-android-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.15.18.tgz", + "integrity": "sha512-wnpt3OXRhcjfIDSZu9bnzT4/TNTDsOUvip0foZOUBG7QbSt//w3QV4FInVJxNhKc/ErhUxc5z4QjHtMi7/TbgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-android-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.15.18.tgz", + "integrity": "sha512-G4xu89B8FCzav9XU8EjsXacCKSG2FT7wW9J6hOc18soEHJdtWu03L3TQDGf0geNxfLTtxENKBzMSq9LlbjS8OQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-darwin-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.15.18.tgz", + "integrity": "sha512-2WAvs95uPnVJPuYKP0Eqx+Dl/jaYseZEUUT1sjg97TJa4oBtbAKnPnl3b5M9l51/nbx7+QAEtuummJZW0sBEmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-darwin-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.15.18.tgz", + "integrity": "sha512-tKPSxcTJ5OmNb1btVikATJ8NftlyNlc8BVNtyT/UAr62JFOhwHlnoPrhYWz09akBLHI9nElFVfWSTSRsrZiDUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-freebsd-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.15.18.tgz", + "integrity": "sha512-TT3uBUxkteAjR1QbsmvSsjpKjOX6UkCstr8nMr+q7zi3NuZ1oIpa8U41Y8I8dJH2fJgdC3Dj3CXO5biLQpfdZA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-freebsd-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.15.18.tgz", + "integrity": "sha512-R/oVr+X3Tkh+S0+tL41wRMbdWtpWB8hEAMsOXDumSSa6qJR89U0S/PpLXrGF7Wk/JykfpWNokERUpCeHDl47wA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-32": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.15.18.tgz", + "integrity": "sha512-lphF3HiCSYtaa9p1DtXndiQEeQDKPl9eN/XNoBf2amEghugNuqXNZA/ZovthNE2aa4EN43WroO0B85xVSjYkbg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.15.18.tgz", + "integrity": "sha512-hNSeP97IviD7oxLKFuii5sDPJ+QHeiFTFLoLm7NZQligur8poNOWGIgpQ7Qf8Balb69hptMZzyOBIPtY09GZYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-arm": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.15.18.tgz", + "integrity": "sha512-UH779gstRblS4aoS2qpMl3wjg7U0j+ygu3GjIeTonCcN79ZvpPee12Qun3vcdxX+37O5LFxz39XeW2I9bybMVA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.15.18.tgz", + "integrity": "sha512-54qr8kg/6ilcxd+0V3h9rjT4qmjc0CccMVWrjOEM/pEcUzt8X62HfBSeZfT2ECpM7104mk4yfQXkosY8Quptug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-mips64le": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.15.18.tgz", + "integrity": "sha512-Mk6Ppwzzz3YbMl/ZZL2P0q1tnYqh/trYZ1VfNP47C31yT0K8t9s7Z077QrDA/guU60tGNp2GOwCQnp+DYv7bxQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-ppc64le": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.15.18.tgz", + "integrity": "sha512-b0XkN4pL9WUulPTa/VKHx2wLCgvIAbgwABGnKMY19WhKZPT+8BxhZdqz6EgkqCLld7X5qiCY2F/bfpUUlnFZ9w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-riscv64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.15.18.tgz", + "integrity": "sha512-ba2COaoF5wL6VLZWn04k+ACZjZ6NYniMSQStodFKH/Pu6RxzQqzsmjR1t9QC89VYJxBeyVPTaHuBMCejl3O/xg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-s390x": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.15.18.tgz", + "integrity": "sha512-VbpGuXEl5FCs1wDVp93O8UIzl3ZrglgnSQ+Hu79g7hZu6te6/YHgVJxCM2SqfIila0J3k0csfnf8VD2W7u2kzQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-netbsd-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.15.18.tgz", + "integrity": "sha512-98ukeCdvdX7wr1vUYQzKo4kQ0N2p27H7I11maINv73fVEXt2kyh4K4m9f35U1K43Xc2QGXlzAw0K9yoU7JUjOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-openbsd-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.15.18.tgz", + "integrity": "sha512-yK5NCcH31Uae076AyQAXeJzt/vxIo9+omZRKj1pauhk3ITuADzuOx5N2fdHrAKPxN+zH3w96uFKlY7yIn490xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-sunos-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.15.18.tgz", + "integrity": "sha512-On22LLFlBeLNj/YF3FT+cXcyKPEI263nflYlAhz5crxtp3yRG1Ugfr7ITyxmCmjm4vbN/dGrb/B7w7U8yJR9yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-windows-32": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.15.18.tgz", + "integrity": "sha512-o+eyLu2MjVny/nt+E0uPnBxYuJHBvho8vWsC2lV61A7wwTWC3jkN2w36jtA+yv1UgYkHRihPuQsL23hsCYGcOQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-windows-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.15.18.tgz", + "integrity": "sha512-qinug1iTTaIIrCorAUjR0fcBk24fjzEedFYhhispP8Oc7SFvs+XeW3YpAKiKp8dRpizl4YYAhxMjlftAMJiaUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-windows-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.15.18.tgz", + "integrity": "sha512-q9bsYzegpZcLziq0zgUi5KqGVtfhjxGbnksaBFYmWLxeV/S1fK4OLdq2DFYnXcLMjlZw2L0jLsk1eGoB522WXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rollup": { + "version": "2.80.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", + "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", + "dev": true, + "license": "MIT", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/vite": { + "version": "3.2.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.11.tgz", + "integrity": "sha512-K/jGKL/PgbIgKCiJo5QbASQhFiV02X9Jh+Qq0AKCRCRKZtOTVi4t6wh75FDpGf2N9rYOnzH87OEFQNaFy6pdxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.15.9", + "postcss": "^8.4.18", + "resolve": "^1.22.1", + "rollup": "^2.79.1" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@types/node": ">= 14", + "less": "*", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + } + } +} diff --git a/client/frontend/package.json b/client/frontend/package.json new file mode 100644 index 0000000..a1b6f8e --- /dev/null +++ b/client/frontend/package.json @@ -0,0 +1,13 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "vite": "^3.0.7" + } +} \ No newline at end of file diff --git a/client/frontend/package.json.md5 b/client/frontend/package.json.md5 new file mode 100644 index 0000000..d4671a1 --- /dev/null +++ b/client/frontend/package.json.md5 @@ -0,0 +1 @@ +5fbf12469d224a93954efecb5886e8a6 \ No newline at end of file diff --git a/client/frontend/src/app.css b/client/frontend/src/app.css new file mode 100644 index 0000000..04a7db6 --- /dev/null +++ b/client/frontend/src/app.css @@ -0,0 +1,691 @@ +@import url('https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:wght@400;500;600;700;800&family=Share+Tech+Mono&display=swap'); + +:root { + --bg-main: #f5f7fa; + --bg-card: #ffffff; + --bg-subtle: #f0f3f7; + --border-color: #e4e9f0; + --accent: #bb2126; + --accent-dark: #9e1c22; + --accent-light: #fde8e9; + --accent-glow: rgba(187, 33, 38, 0.12); + --text-primary: #1a2332; + --text-secondary: #5a6a7e; + --text-muted: #8b99a8; + --online-color: #0d9f6e; + --online-light: #e6f7f1; + --offline-color: #d63031; + --offline-light: #fdeaea; + --shadow-sm: 0 1px 3px rgba(26, 35, 50, 0.05); + --shadow-md: 0 4px 16px rgba(26, 35, 50, 0.07); + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --transition: 0.2s ease; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + height: 100%; + overflow: hidden; +} + +body { + margin: 0; + background-color: var(--bg-main); + color: var(--text-primary); + font-family: 'Be Vietnam Pro', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + overflow: hidden; + height: 100%; + width: 100%; + -webkit-font-smoothing: antialiased; +} + +#app { + width: 100%; + height: 100%; + max-height: 100vh; + display: flex; + overflow: hidden; +} + +/* ── Cards ── */ +.card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + padding: 1.75rem; + transition: var(--transition); +} + +/* ── Login Screen ── */ +.login-prompt-container { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + padding: 1.5rem; + background: var(--bg-main); + overflow: hidden; +} + +.login-prompt-container .card { + max-width: 420px; + width: 100%; + max-height: calc(100vh - 3rem); + overflow-y: auto; + text-align: center; + animation: floatIn 0.4s ease forwards; +} + +.login-brand-row { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + margin-bottom: 1.5rem; +} + +.login-logo { + width: 48px; + height: 48px; + background: var(--accent); + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; + font-weight: 800; + font-size: 0.9rem; + color: white; + flex-shrink: 0; +} + +.login-brand-text { + text-align: left; +} + +.login-brand-name { + font-size: 1.1rem; + font-weight: 800; + color: var(--text-primary); + letter-spacing: -0.3px; + line-height: 1.2; +} + +.login-brand-sub { + font-size: 0.72rem; + font-weight: 500; + color: var(--accent); +} + +.login-title { + font-size: 1.35rem; + font-weight: 800; + margin-bottom: 0.6rem; + color: var(--text-primary); + letter-spacing: -0.3px; +} + +.login-desc { + color: var(--text-secondary); + font-size: 0.875rem; + line-height: 1.65; + margin-bottom: 1.75rem; +} + +/* ── Dashboard Layout ── */ +.dashboard-wrapper { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + max-height: 100vh; + padding: 1.25rem 1.5rem; + box-sizing: border-box; + overflow: hidden; + animation: fadeIn 0.35s ease-out; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + padding-bottom: 0.85rem; + border-bottom: 1px solid var(--border-color); + flex-shrink: 0; +} + +.brand { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.brand-logo { + width: 38px; + height: 38px; + background: var(--accent); + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; + font-weight: 800; + font-size: 0.85rem; + color: white; + flex-shrink: 0; +} + +.brand-text { + display: flex; + flex-direction: column; + gap: 0.05rem; +} + +.brand-name { + font-weight: 800; + font-size: 1.05rem; + letter-spacing: -0.3px; + color: var(--text-primary); + line-height: 1.2; +} + +.brand-sub { + font-size: 0.7rem; + font-weight: 500; + color: var(--accent); +} + +.main-grid { + display: grid; + grid-template-columns: minmax(220px, 260px) 1fr; + gap: 1rem; + flex: 1; + min-height: 0; + overflow: hidden; +} + +/* ── Profile Card ── */ +.profile-card { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + height: 100%; + min-height: 0; + overflow-y: auto; + justify-content: flex-start; + padding-top: 1rem; +} + +.avatar-container { + position: relative; + margin-bottom: 1.25rem; +} + +.avatar { + width: 84px; + height: 84px; + border-radius: 50%; + object-fit: cover; + border: 3px solid var(--accent-light); + padding: 3px; + background: var(--bg-subtle); +} + +.status-indicator { + position: absolute; + bottom: 6px; + right: 6px; + width: 14px; + height: 14px; + border-radius: 50%; + border: 2.5px solid #fff; +} + +.status-indicator.online { + background-color: var(--online-color); + animation: pulseGreen 2s infinite; +} + +.status-indicator.offline { + background-color: var(--offline-color); + animation: pulseRed 2s infinite; +} + +.student-name { + margin: 0; + font-size: 1.2rem; + font-weight: 700; + color: var(--text-primary); + letter-spacing: -0.3px; +} + +.student-code { + background: var(--accent-light); + color: var(--accent); + padding: 3px 10px; + border-radius: 6px; + font-size: 0.8rem; + font-weight: 700; + margin-top: 0.4rem; + margin-bottom: 0.5rem; + border: 1px solid rgba(187, 33, 38, 0.15); + letter-spacing: 0.3px; + display: inline-block; +} + +.student-email { + margin: 0; + color: var(--text-muted); + font-size: 0.82rem; +} + +.divider { + width: 100%; + height: 1px; + background: var(--border-color); + margin: 1.25rem 0; +} + +.profile-info-row { + display: flex; + justify-content: space-between; + width: 100%; + font-size: 0.82rem; + margin-bottom: 0.6rem; +} + +.profile-info-row span { + color: var(--text-muted); +} + +.profile-info-row strong { + color: var(--text-primary); + font-weight: 600; +} + +/* ── Right Column ── */ +.right-column { + display: flex; + flex-direction: column; + gap: 0.85rem; + overflow: hidden; + height: 100%; + min-height: 0; +} + +.status-banner { + padding: 0.75rem 1rem; + display: flex; + align-items: flex-start; + gap: 0.75rem; + border-left: 3px solid var(--accent); + flex-shrink: 0; +} + +.status-banner-main { + flex: 1; + min-width: 0; +} + +.status-banner-top { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem 0.65rem; + margin-bottom: 0.25rem; +} + +.monitor-mode-tag { + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0.15rem 0.45rem; + border-radius: 4px; + background: var(--accent-light); + color: var(--accent); +} + +.status-class-line { + font-size: 0.78rem; + font-weight: 600; + color: var(--text-secondary); +} + +.status-banner-sub { + font-size: 0.78rem; + color: var(--text-muted); + margin-top: 0.2rem; +} + +.status-banner.mode-learning { border-left-color: var(--online-color); } +.status-banner.mode-learning .monitor-mode-tag { background: var(--online-light); color: var(--online-color); } +.status-banner.mode-outside { border-left-color: #94a3b8; } +.status-banner.mode-outside .monitor-mode-tag { background: #f1f5f9; color: #64748b; } +.status-banner.mode-config { border-left-color: #f59e0b; } +.status-banner.mode-config .monitor-mode-tag { background: #fffbeb; color: #b45309; } +.status-banner.mode-exam { border-left-color: #7c3aed; } +.status-banner.mode-exam .monitor-mode-tag { background: #f5f3ff; color: #6d28d9; } + +.stats-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.65rem; +} + +.stats-grid--two { + grid-template-columns: 1fr 1fr; +} + +.connectivity-note { + font-size: 0.75rem; + line-height: 1.45; + color: var(--text-muted); + padding: 0.65rem 0.85rem; + background: #f8fafc; + border: 1px solid var(--border-color); +} + +.connectivity-note strong { + color: var(--text-primary); +} + +.conn-split { + font-weight: 600; + color: var(--online-color); +} + +.conn-split.offline-text { + color: var(--offline-color); +} + +.card-title-row { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 0.5rem; + margin-bottom: 0.65rem; +} + +.card-title-sub { + font-size: 0.75rem; + color: var(--text-muted); + font-family: monospace; +} + +.clock-display-summary { + margin-bottom: 0.75rem; + padding-bottom: 0.75rem; + border-bottom: 1px dashed var(--border-color); +} + +.shifts-wrap { + overflow: auto; + max-height: min(32vh, 220px); +} + +.shifts-table { + width: 100%; + border-collapse: collapse; + font-size: 0.76rem; +} + +.shifts-table th { + text-align: left; + padding: 0.4rem 0.35rem; + color: var(--text-muted); + font-weight: 600; + border-bottom: 1px solid var(--border-color); + position: sticky; + top: 0; + background: #fff; +} + +.shifts-table td { + padding: 0.45rem 0.35rem; + border-bottom: 1px solid #f1f5f9; + vertical-align: middle; +} + +.shift-row-active { + background: #f0fdf8; +} + +.shift-now { + font-size: 0.65rem; + color: var(--online-color); + font-weight: 700; + text-transform: uppercase; +} + +.shift-time { + font-family: monospace; + font-size: 0.72rem; + color: var(--text-muted); +} + +.time-online { color: var(--online-color); font-family: monospace; font-weight: 600; } +.time-offline { color: var(--offline-color); font-family: monospace; } + +.att-badge { + display: inline-block; + padding: 0.12rem 0.4rem; + border-radius: 4px; + font-size: 0.68rem; + font-weight: 600; + white-space: nowrap; +} + +.att-ok { background: var(--online-light); color: var(--online-color); } +.att-late { background: #fffbeb; color: #b45309; } +.att-leave { background: #eff6ff; color: #2563eb; } +.att-absent { background: var(--offline-light); color: var(--offline-color); } +.att-pending { background: #f1f5f9; color: #64748b; } + +.shifts-empty { + padding: 1rem; + text-align: center; + color: var(--text-muted); + font-size: 0.82rem; +} + +.status-banner-icon { + width: 36px; + height: 36px; + background: var(--accent-light); + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.1rem; + flex-shrink: 0; +} + +.status-banner-label { + font-size: 0.7rem; + color: var(--text-muted); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.status-banner-value { + font-size: 0.9rem; + font-weight: 600; + color: var(--text-primary); +} + +.stat-card { + display: flex; + align-items: center; + gap: 0.85rem; + padding: 0.9rem 1rem; +} + +.stat-icon-wrap { + width: 40px; + height: 40px; + background: var(--bg-subtle); + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.2rem; + flex-shrink: 0; +} + +.stat-info { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; +} + +.stat-title { + font-size: 0.72rem; + color: var(--text-muted); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.stat-value { + font-size: 0.95rem; + font-weight: 700; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── Clocks ── */ +.clocks-card { + display: flex; + flex-direction: column; + gap: 0.65rem; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.card-title { + font-size: 0.85rem; + font-weight: 700; + color: var(--text-primary); + letter-spacing: -0.2px; + flex-shrink: 0; +} + +.clock-display { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.85rem; + flex: 1; + min-height: 0; +} + +.clock-item { + background: var(--bg-subtle); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 0.75rem 0.85rem; + text-align: center; + transition: var(--transition); + display: flex; + flex-direction: column; + justify-content: center; +} + +.clock-label { + font-size: 0.68rem; + color: var(--text-muted); + font-weight: 600; + margin-bottom: 0.4rem; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.clock-value { + font-size: clamp(1.4rem, 3.5vw, 1.85rem); + font-weight: 800; + font-family: 'Share Tech Mono', monospace; + letter-spacing: 1px; +} + +.clock-value.online { + color: var(--online-color); +} + +.clock-value.offline { + color: var(--offline-color); +} + +/* ── Buttons ── */ +.btn { + padding: 0.65rem 1.15rem; + border-radius: var(--radius-sm); + border: none; + font-weight: 600; + font-size: 0.875rem; + font-family: 'Be Vietnam Pro', sans-serif; + cursor: pointer; + transition: var(--transition); +} + +.btn-primary { + background: var(--accent); + color: white; + box-shadow: 0 2px 8px var(--accent-glow); +} + +.btn-primary:hover { + background: var(--accent-dark); + transform: translateY(-1px); + box-shadow: 0 4px 14px rgba(187, 33, 38, 0.25); +} + +.btn-logout { + background: var(--bg-subtle); + color: var(--text-secondary); + border: 1px solid var(--border-color); + padding: 0.5rem 0.9rem; + font-size: 0.82rem; +} + +.btn-logout:hover { + background: var(--offline-light); + color: var(--offline-color); + border-color: rgba(214, 48, 49, 0.25); +} + +/* ── Animations ── */ +@keyframes floatIn { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes pulseGreen { + 0% { box-shadow: 0 0 0 0 rgba(13, 159, 110, 0.35); } + 70% { box-shadow: 0 0 0 8px rgba(13, 159, 110, 0); } + 100% { box-shadow: 0 0 0 0 rgba(13, 159, 110, 0); } +} + +@keyframes pulseRed { + 0% { box-shadow: 0 0 0 0 rgba(214, 48, 49, 0.35); } + 70% { box-shadow: 0 0 0 8px rgba(214, 48, 49, 0); } + 100% { box-shadow: 0 0 0 0 rgba(214, 48, 49, 0); } +} diff --git a/client/frontend/src/assets/fonts/OFL.txt b/client/frontend/src/assets/fonts/OFL.txt new file mode 100644 index 0000000..9cac04c --- /dev/null +++ b/client/frontend/src/assets/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com), + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/client/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2 b/client/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2 new file mode 100644 index 0000000..2f9cc59 Binary files /dev/null and b/client/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2 differ diff --git a/client/frontend/src/assets/images/logo-universal.png b/client/frontend/src/assets/images/logo-universal.png new file mode 100644 index 0000000..d63303b Binary files /dev/null and b/client/frontend/src/assets/images/logo-universal.png differ diff --git a/client/frontend/src/main.js b/client/frontend/src/main.js new file mode 100644 index 0000000..256a713 --- /dev/null +++ b/client/frontend/src/main.js @@ -0,0 +1,368 @@ +import './style.css'; +import './app.css'; + +// Trạng thái cục bộ +let loggedIn = false; +let studentInfo = null; +let stats = { + wifiSSID: '', + onlineSecs: 0, + offlineSecs: 0, + wsConnected: false, + serverReachable: false, + allowedApps: '', + monitorMode: '', + monitorLabel: '', + className: '', + classCode: '', + currentPeriod: 0, + currentCourseName: '', + shifts: [] +}; + +// Quản lý webcam +let webcamStream = null; +let webcamInterval = null; +const webcamVideo = document.createElement('video'); +webcamVideo.autoplay = true; +webcamVideo.playsInline = true; +webcamVideo.style.display = 'none'; +document.body.appendChild(webcamVideo); + +const webcamCanvas = document.createElement('canvas'); +webcamCanvas.width = 320; +webcamCanvas.height = 240; +webcamCanvas.style.display = 'none'; +document.body.appendChild(webcamCanvas); + +function init() { + if (typeof window.go === 'undefined' || typeof window.go.main === 'undefined') { + setTimeout(init, 200); + return; + } + + window.runtime.EventsOn('start_webcam_stream', startWebcam); + window.runtime.EventsOn('stop_webcam_stream', stopWebcam); + checkLogin(); +} + +async function checkLogin() { + try { + const isLogged = await window.go.main.App.CheckLoginStatus(); + if (isLogged) { + loggedIn = true; + studentInfo = await window.go.main.App.GetStudentInfo(); + renderDashboard(); + startStatsTicker(); + } else { + loggedIn = false; + renderLoginPrompt(); + } + } catch (err) { + console.error('Error checking login status:', err); + } +} + +function renderLoginPrompt() { + document.querySelector('#app').innerHTML = ` +
+
+ + + + +
+
+ `; + + document.getElementById('btn-goto-login').addEventListener('click', () => { + window.go.main.App.NavigateToLogin(); + }); +} + +function monitorModeMeta(mode) { + switch (mode) { + case 'learning': + return { icon: '🛡️', label: 'Đang giám sát', cls: 'mode-learning' }; + case 'exam': + return { icon: '📝', label: 'Phòng thi', cls: 'mode-exam' }; + case 'outside_schedule': + return { icon: '😴', label: 'Ngoài giờ học', cls: 'mode-outside' }; + case 'not_configured': + return { icon: '⚙️', label: 'Chưa sẵn sàng', cls: 'mode-config' }; + default: + return { icon: '⏳', label: 'Đang kết nối', cls: 'mode-pending' }; + } +} + +function attendanceClass(status) { + if (status === 4) return 'att-ok'; + if (status === 3) return 'att-late'; + if (status === 1 || status === 2) return 'att-leave'; + if (status === 0) return 'att-absent'; + return 'att-pending'; +} + +function renderShiftsTable(shifts) { + if (!shifts || shifts.length === 0) { + return '
Hôm nay không có ca học theo lịch lớp.
'; + } + return ` + + + + + + + + + + + + + ${shifts.map(s => ` + + + + + + + + + `).join('')} + +
CaMôn họcGiờTrực tuyếnNgoại tuyếnĐiểm danh
Ca ${s.period}${s.isActiveNow ? ' đang học' : ''}${s.courseName || '—'}${s.startTime}–${s.endTime}${formatDuration(s.onlineSeconds || 0)}${formatDuration(s.offlineSeconds || 0)}${s.attendanceLabel || 'Chưa tính'}
+ `; +} + +function renderDashboard() { + if (!studentInfo) return; + + const mode = monitorModeMeta(stats.monitorMode); + const classLine = stats.className + ? `${stats.className}${stats.classCode ? ` (${stats.classCode})` : ''}` + : 'Đang xác định lớp...'; + + document.querySelector('#app').innerHTML = ` +
+
+
+ +
+ Simple Care + Rikkei Education +
+
+ +
+ +
+
+
+ Avatar +
+
+

${studentInfo.fullName}

+
${studentInfo.studentCode}
+

${studentInfo.email}

+
+
+ Lớp + ${classLine} +
+
+ Điện thoại + ${studentInfo.phone || 'Chưa cung cấp'} +
+
+ +
+
+
${mode.icon}
+
+
+ ${mode.label} + ${classLine} +
+
${stats.monitorLabel || stats.statusMsg || 'Đang kết nối...'}
+
+ ${stats.currentPeriod > 0 && stats.currentCourseName + ? `Ca ${stats.currentPeriod} · ${stats.currentCourseName}` + : 'Theo dõi theo lịch học của lớp'} +
+
+
+ +
+
+
📡
+
+
WiFi
+
${stats.wifiSSID || 'Đang quét...'}
+
+
+
+
🖥️
+
+
Máy chủ
+
+ ${stats.serverReachable ? 'Đã kết nối' : 'Mất kết nối'} +
+
+
+
+ +
+
+
Hôm nay — theo ca
+
${stats.sessionDate || ''}
+
+
+
+
Tổng trực tuyến
+
00:00:00
+
+
+
Tổng ngoại tuyến
+
00:00:00
+
+
+
+ ${renderShiftsTable(stats.shifts)} +
+
+
+
+
+ `; + + document.getElementById('btn-logout').addEventListener('click', async () => { + if (confirm('Bạn có chắc chắn muốn đăng xuất khỏi ứng dụng giám sát?')) { + loggedIn = false; + studentInfo = null; + stopWebcam(); + await window.go.main.App.Logout(); + } + }); +} + +function formatDuration(totalSeconds) { + const hrs = Math.floor(totalSeconds / 3600); + const mins = Math.floor((totalSeconds % 3600) / 60); + const secs = totalSeconds % 60; + const pad = (num) => String(num).padStart(2, '0'); + return `${pad(hrs)}:${pad(mins)}:${pad(secs)}`; +} + +function startStatsTicker() { + setInterval(async () => { + if (!loggedIn) return; + try { + const freshStats = await window.go.main.App.GetStats(); + stats = { ...stats, ...freshStats }; + if (!Array.isArray(stats.shifts)) stats.shifts = []; + + const wifiEl = document.getElementById('wifi-ssid'); + if (wifiEl) wifiEl.innerText = stats.wifiSSID || 'Không có WiFi'; + + const serverEl = document.getElementById('server-status'); + if (serverEl) { + serverEl.innerText = stats.serverReachable ? 'Đã kết nối' : 'Mất kết nối'; + serverEl.style.color = stats.serverReachable ? 'var(--online-color)' : 'var(--offline-color)'; + } + + const statusMsgEl = document.getElementById('status-message'); + if (statusMsgEl) { + statusMsgEl.innerText = stats.monitorLabel || stats.statusMsg || 'Đang kết nối...'; + } + + const mode = monitorModeMeta(stats.monitorMode); + const statusIconEl = document.getElementById('status-icon'); + if (statusIconEl) statusIconEl.innerText = mode.icon; + + const modeTagEl = document.getElementById('monitor-mode-tag'); + if (modeTagEl) modeTagEl.innerText = mode.label; + + const banner = document.querySelector('.status-banner'); + if (banner) banner.className = `card status-banner ${mode.cls}`; + + const classLine = stats.className + ? `${stats.className}${stats.classCode ? ` (${stats.classCode})` : ''}` + : 'Đang xác định lớp...'; + const classLineEl = document.getElementById('status-class-line'); + if (classLineEl) classLineEl.innerText = classLine; + const profileClassEl = document.getElementById('profile-class'); + if (profileClassEl) profileClassEl.innerText = classLine; + + const statusSubEl = document.getElementById('status-sub'); + if (statusSubEl) { + statusSubEl.innerText = stats.currentPeriod > 0 && stats.currentCourseName + ? `Ca ${stats.currentPeriod} · ${stats.currentCourseName}` + : 'Theo dõi theo lịch học của lớp'; + } + + const onlineEl = document.getElementById('clock-online'); + if (onlineEl) onlineEl.innerText = formatDuration(stats.onlineSecs || 0); + + const offlineEl = document.getElementById('clock-offline'); + if (offlineEl) offlineEl.innerText = formatDuration(stats.offlineSecs || 0); + + const shiftsWrap = document.getElementById('shifts-wrap'); + if (shiftsWrap) shiftsWrap.innerHTML = renderShiftsTable(stats.shifts); + + const sessionDateEl = document.getElementById('session-date'); + if (sessionDateEl && stats.sessionDate) sessionDateEl.innerText = stats.sessionDate; + + const indicator = document.querySelector('.status-indicator'); + if (indicator) { + indicator.className = `status-indicator ${stats.serverReachable ? 'online' : 'offline'}`; + } + } catch (err) { + console.error('Error fetching stats:', err); + } + }, 1000); +} + +async function startWebcam() { + if (webcamStream) return; + try { + webcamStream = await navigator.mediaDevices.getUserMedia({ + video: { width: 320, height: 240, frameRate: { max: 10 } } + }); + webcamVideo.srcObject = webcamStream; + webcamInterval = setInterval(() => { + const ctx = webcamCanvas.getContext('2d'); + if (ctx) { + ctx.drawImage(webcamVideo, 0, 0, webcamCanvas.width, webcamCanvas.height); + const dataUrl = webcamCanvas.toDataURL('image/jpeg', 0.4); + window.go.main.App.SendWebcamFrame(dataUrl); + } + }, 250); + } catch (err) { + console.error('Failed to open webcam:', err); + } +} + +function stopWebcam() { + if (webcamInterval) { + clearInterval(webcamInterval); + webcamInterval = null; + } + if (webcamStream) { + webcamStream.getTracks().forEach(track => track.stop()); + webcamStream = null; + } + webcamVideo.srcObject = null; +} + +init(); diff --git a/client/frontend/src/style.css b/client/frontend/src/style.css new file mode 100644 index 0000000..3c0f7ba --- /dev/null +++ b/client/frontend/src/style.css @@ -0,0 +1,14 @@ +html { + height: 100%; + overflow: hidden; +} + +body { + margin: 0; + height: 100%; + overflow: hidden; +} + +#app { + height: 100%; +} diff --git a/client/frontend/wailsjs/go/main/App.d.ts b/client/frontend/wailsjs/go/main/App.d.ts new file mode 100644 index 0000000..f5b9cba --- /dev/null +++ b/client/frontend/wailsjs/go/main/App.d.ts @@ -0,0 +1,14 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export function CheckLoginStatus():Promise; + +export function GetStats():Promise>; + +export function GetStudentInfo():Promise>; + +export function Logout():Promise; + +export function NavigateToLogin():Promise; + +export function SendWebcamFrame(arg1:string):Promise; diff --git a/client/frontend/wailsjs/go/main/App.js b/client/frontend/wailsjs/go/main/App.js new file mode 100644 index 0000000..0f9cd8f --- /dev/null +++ b/client/frontend/wailsjs/go/main/App.js @@ -0,0 +1,27 @@ +// @ts-check +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export function CheckLoginStatus() { + return window['go']['main']['App']['CheckLoginStatus'](); +} + +export function GetStats() { + return window['go']['main']['App']['GetStats'](); +} + +export function GetStudentInfo() { + return window['go']['main']['App']['GetStudentInfo'](); +} + +export function Logout() { + return window['go']['main']['App']['Logout'](); +} + +export function NavigateToLogin() { + return window['go']['main']['App']['NavigateToLogin'](); +} + +export function SendWebcamFrame(arg1) { + return window['go']['main']['App']['SendWebcamFrame'](arg1); +} diff --git a/client/frontend/wailsjs/runtime/package.json b/client/frontend/wailsjs/runtime/package.json new file mode 100644 index 0000000..1e7c8a5 --- /dev/null +++ b/client/frontend/wailsjs/runtime/package.json @@ -0,0 +1,24 @@ +{ + "name": "@wailsapp/runtime", + "version": "2.0.0", + "description": "Wails Javascript runtime library", + "main": "runtime.js", + "types": "runtime.d.ts", + "scripts": { + }, + "repository": { + "type": "git", + "url": "git+https://github.com/wailsapp/wails.git" + }, + "keywords": [ + "Wails", + "Javascript", + "Go" + ], + "author": "Lea Anthony ", + "license": "MIT", + "bugs": { + "url": "https://github.com/wailsapp/wails/issues" + }, + "homepage": "https://github.com/wailsapp/wails#readme" +} diff --git a/client/frontend/wailsjs/runtime/runtime.d.ts b/client/frontend/wailsjs/runtime/runtime.d.ts new file mode 100644 index 0000000..3bbea84 --- /dev/null +++ b/client/frontend/wailsjs/runtime/runtime.d.ts @@ -0,0 +1,330 @@ +/* + _ __ _ __ +| | / /___ _(_) /____ +| | /| / / __ `/ / / ___/ +| |/ |/ / /_/ / / (__ ) +|__/|__/\__,_/_/_/____/ +The electron alternative for Go +(c) Lea Anthony 2019-present +*/ + +export interface Position { + x: number; + y: number; +} + +export interface Size { + w: number; + h: number; +} + +export interface Screen { + isCurrent: boolean; + isPrimary: boolean; + width : number + height : number +} + +// Environment information such as platform, buildtype, ... +export interface EnvironmentInfo { + buildType: string; + platform: string; + arch: string; +} + +// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit) +// emits the given event. Optional data may be passed with the event. +// This will trigger any event listeners. +export function EventsEmit(eventName: string, ...data: any): void; + +// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name. +export function EventsOn(eventName: string, callback: (...data: any) => void): () => void; + +// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple) +// sets up a listener for the given event name, but will only trigger a given number times. +export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void; + +// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce) +// sets up a listener for the given event name, but will only trigger once. +export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void; + +// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff) +// unregisters the listener for the given event name. +export function EventsOff(eventName: string, ...additionalEventNames: string[]): void; + +// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall) +// unregisters all listeners. +export function EventsOffAll(): void; + +// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint) +// logs the given message as a raw message +export function LogPrint(message: string): void; + +// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace) +// logs the given message at the `trace` log level. +export function LogTrace(message: string): void; + +// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug) +// logs the given message at the `debug` log level. +export function LogDebug(message: string): void; + +// [LogError](https://wails.io/docs/reference/runtime/log#logerror) +// logs the given message at the `error` log level. +export function LogError(message: string): void; + +// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal) +// logs the given message at the `fatal` log level. +// The application will quit after calling this method. +export function LogFatal(message: string): void; + +// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo) +// logs the given message at the `info` log level. +export function LogInfo(message: string): void; + +// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning) +// logs the given message at the `warning` log level. +export function LogWarning(message: string): void; + +// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload) +// Forces a reload by the main application as well as connected browsers. +export function WindowReload(): void; + +// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp) +// Reloads the application frontend. +export function WindowReloadApp(): void; + +// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop) +// Sets the window AlwaysOnTop or not on top. +export function WindowSetAlwaysOnTop(b: boolean): void; + +// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme) +// *Windows only* +// Sets window theme to system default (dark/light). +export function WindowSetSystemDefaultTheme(): void; + +// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme) +// *Windows only* +// Sets window to light theme. +export function WindowSetLightTheme(): void; + +// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme) +// *Windows only* +// Sets window to dark theme. +export function WindowSetDarkTheme(): void; + +// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter) +// Centers the window on the monitor the window is currently on. +export function WindowCenter(): void; + +// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle) +// Sets the text in the window title bar. +export function WindowSetTitle(title: string): void; + +// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen) +// Makes the window full screen. +export function WindowFullscreen(): void; + +// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen) +// Restores the previous window dimensions and position prior to full screen. +export function WindowUnfullscreen(): void; + +// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen) +// Returns the state of the window, i.e. whether the window is in full screen mode or not. +export function WindowIsFullscreen(): Promise; + +// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize) +// Sets the width and height of the window. +export function WindowSetSize(width: number, height: number): void; + +// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize) +// Gets the width and height of the window. +export function WindowGetSize(): Promise; + +// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize) +// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions. +// Setting a size of 0,0 will disable this constraint. +export function WindowSetMaxSize(width: number, height: number): void; + +// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize) +// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions. +// Setting a size of 0,0 will disable this constraint. +export function WindowSetMinSize(width: number, height: number): void; + +// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition) +// Sets the window position relative to the monitor the window is currently on. +export function WindowSetPosition(x: number, y: number): void; + +// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition) +// Gets the window position relative to the monitor the window is currently on. +export function WindowGetPosition(): Promise; + +// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide) +// Hides the window. +export function WindowHide(): void; + +// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow) +// Shows the window, if it is currently hidden. +export function WindowShow(): void; + +// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise) +// Maximises the window to fill the screen. +export function WindowMaximise(): void; + +// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise) +// Toggles between Maximised and UnMaximised. +export function WindowToggleMaximise(): void; + +// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise) +// Restores the window to the dimensions and position prior to maximising. +export function WindowUnmaximise(): void; + +// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised) +// Returns the state of the window, i.e. whether the window is maximised or not. +export function WindowIsMaximised(): Promise; + +// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise) +// Minimises the window. +export function WindowMinimise(): void; + +// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise) +// Restores the window to the dimensions and position prior to minimising. +export function WindowUnminimise(): void; + +// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised) +// Returns the state of the window, i.e. whether the window is minimised or not. +export function WindowIsMinimised(): Promise; + +// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal) +// Returns the state of the window, i.e. whether the window is normal or not. +export function WindowIsNormal(): Promise; + +// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour) +// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels. +export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void; + +// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall) +// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system. +export function ScreenGetAll(): Promise; + +// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl) +// Opens the given URL in the system browser. +export function BrowserOpenURL(url: string): void; + +// [Environment](https://wails.io/docs/reference/runtime/intro#environment) +// Returns information about the environment +export function Environment(): Promise; + +// [Quit](https://wails.io/docs/reference/runtime/intro#quit) +// Quits the application. +export function Quit(): void; + +// [Hide](https://wails.io/docs/reference/runtime/intro#hide) +// Hides the application. +export function Hide(): void; + +// [Show](https://wails.io/docs/reference/runtime/intro#show) +// Shows the application. +export function Show(): void; + +// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext) +// Returns the current text stored on clipboard +export function ClipboardGetText(): Promise; + +// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext) +// Sets a text on the clipboard +export function ClipboardSetText(text: string): Promise; + +// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop) +// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings. +export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void + +// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff) +// OnFileDropOff removes the drag and drop listeners and handlers. +export function OnFileDropOff() :void + +// Check if the file path resolver is available +export function CanResolveFilePaths(): boolean; + +// Resolves file paths for an array of files +export function ResolveFilePaths(files: File[]): void + +// Notification types +export interface NotificationOptions { + id: string; + title: string; + subtitle?: string; // macOS and Linux only + body?: string; + categoryId?: string; + data?: { [key: string]: any }; +} + +export interface NotificationAction { + id?: string; + title?: string; + destructive?: boolean; // macOS-specific +} + +export interface NotificationCategory { + id?: string; + actions?: NotificationAction[]; + hasReplyField?: boolean; + replyPlaceholder?: string; + replyButtonTitle?: string; +} + +// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications) +// Initializes the notification service for the application. +// This must be called before sending any notifications. +export function InitializeNotifications(): Promise; + +// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications) +// Cleans up notification resources and releases any held connections. +export function CleanupNotifications(): Promise; + +// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable) +// Checks if notifications are available on the current platform. +export function IsNotificationAvailable(): Promise; + +// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization) +// Requests notification authorization from the user (macOS only). +export function RequestNotificationAuthorization(): Promise; + +// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization) +// Checks the current notification authorization status (macOS only). +export function CheckNotificationAuthorization(): Promise; + +// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification) +// Sends a basic notification with the given options. +export function SendNotification(options: NotificationOptions): Promise; + +// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions) +// Sends a notification with action buttons. Requires a registered category. +export function SendNotificationWithActions(options: NotificationOptions): Promise; + +// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory) +// Registers a notification category that can be used with SendNotificationWithActions. +export function RegisterNotificationCategory(category: NotificationCategory): Promise; + +// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory) +// Removes a previously registered notification category. +export function RemoveNotificationCategory(categoryId: string): Promise; + +// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications) +// Removes all pending notifications from the notification center. +export function RemoveAllPendingNotifications(): Promise; + +// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification) +// Removes a specific pending notification by its identifier. +export function RemovePendingNotification(identifier: string): Promise; + +// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications) +// Removes all delivered notifications from the notification center. +export function RemoveAllDeliveredNotifications(): Promise; + +// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification) +// Removes a specific delivered notification by its identifier. +export function RemoveDeliveredNotification(identifier: string): Promise; + +// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification) +// Removes a notification by its identifier (cross-platform convenience function). +export function RemoveNotification(identifier: string): Promise; \ No newline at end of file diff --git a/client/frontend/wailsjs/runtime/runtime.js b/client/frontend/wailsjs/runtime/runtime.js new file mode 100644 index 0000000..556621e --- /dev/null +++ b/client/frontend/wailsjs/runtime/runtime.js @@ -0,0 +1,298 @@ +/* + _ __ _ __ +| | / /___ _(_) /____ +| | /| / / __ `/ / / ___/ +| |/ |/ / /_/ / / (__ ) +|__/|__/\__,_/_/_/____/ +The electron alternative for Go +(c) Lea Anthony 2019-present +*/ + +export function LogPrint(message) { + window.runtime.LogPrint(message); +} + +export function LogTrace(message) { + window.runtime.LogTrace(message); +} + +export function LogDebug(message) { + window.runtime.LogDebug(message); +} + +export function LogInfo(message) { + window.runtime.LogInfo(message); +} + +export function LogWarning(message) { + window.runtime.LogWarning(message); +} + +export function LogError(message) { + window.runtime.LogError(message); +} + +export function LogFatal(message) { + window.runtime.LogFatal(message); +} + +export function EventsOnMultiple(eventName, callback, maxCallbacks) { + return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks); +} + +export function EventsOn(eventName, callback) { + return EventsOnMultiple(eventName, callback, -1); +} + +export function EventsOff(eventName, ...additionalEventNames) { + return window.runtime.EventsOff(eventName, ...additionalEventNames); +} + +export function EventsOffAll() { + return window.runtime.EventsOffAll(); +} + +export function EventsOnce(eventName, callback) { + return EventsOnMultiple(eventName, callback, 1); +} + +export function EventsEmit(eventName) { + let args = [eventName].slice.call(arguments); + return window.runtime.EventsEmit.apply(null, args); +} + +export function WindowReload() { + window.runtime.WindowReload(); +} + +export function WindowReloadApp() { + window.runtime.WindowReloadApp(); +} + +export function WindowSetAlwaysOnTop(b) { + window.runtime.WindowSetAlwaysOnTop(b); +} + +export function WindowSetSystemDefaultTheme() { + window.runtime.WindowSetSystemDefaultTheme(); +} + +export function WindowSetLightTheme() { + window.runtime.WindowSetLightTheme(); +} + +export function WindowSetDarkTheme() { + window.runtime.WindowSetDarkTheme(); +} + +export function WindowCenter() { + window.runtime.WindowCenter(); +} + +export function WindowSetTitle(title) { + window.runtime.WindowSetTitle(title); +} + +export function WindowFullscreen() { + window.runtime.WindowFullscreen(); +} + +export function WindowUnfullscreen() { + window.runtime.WindowUnfullscreen(); +} + +export function WindowIsFullscreen() { + return window.runtime.WindowIsFullscreen(); +} + +export function WindowGetSize() { + return window.runtime.WindowGetSize(); +} + +export function WindowSetSize(width, height) { + window.runtime.WindowSetSize(width, height); +} + +export function WindowSetMaxSize(width, height) { + window.runtime.WindowSetMaxSize(width, height); +} + +export function WindowSetMinSize(width, height) { + window.runtime.WindowSetMinSize(width, height); +} + +export function WindowSetPosition(x, y) { + window.runtime.WindowSetPosition(x, y); +} + +export function WindowGetPosition() { + return window.runtime.WindowGetPosition(); +} + +export function WindowHide() { + window.runtime.WindowHide(); +} + +export function WindowShow() { + window.runtime.WindowShow(); +} + +export function WindowMaximise() { + window.runtime.WindowMaximise(); +} + +export function WindowToggleMaximise() { + window.runtime.WindowToggleMaximise(); +} + +export function WindowUnmaximise() { + window.runtime.WindowUnmaximise(); +} + +export function WindowIsMaximised() { + return window.runtime.WindowIsMaximised(); +} + +export function WindowMinimise() { + window.runtime.WindowMinimise(); +} + +export function WindowUnminimise() { + window.runtime.WindowUnminimise(); +} + +export function WindowSetBackgroundColour(R, G, B, A) { + window.runtime.WindowSetBackgroundColour(R, G, B, A); +} + +export function ScreenGetAll() { + return window.runtime.ScreenGetAll(); +} + +export function WindowIsMinimised() { + return window.runtime.WindowIsMinimised(); +} + +export function WindowIsNormal() { + return window.runtime.WindowIsNormal(); +} + +export function BrowserOpenURL(url) { + window.runtime.BrowserOpenURL(url); +} + +export function Environment() { + return window.runtime.Environment(); +} + +export function Quit() { + window.runtime.Quit(); +} + +export function Hide() { + window.runtime.Hide(); +} + +export function Show() { + window.runtime.Show(); +} + +export function ClipboardGetText() { + return window.runtime.ClipboardGetText(); +} + +export function ClipboardSetText(text) { + return window.runtime.ClipboardSetText(text); +} + +/** + * Callback for OnFileDrop returns a slice of file path strings when a drop is finished. + * + * @export + * @callback OnFileDropCallback + * @param {number} x - x coordinate of the drop + * @param {number} y - y coordinate of the drop + * @param {string[]} paths - A list of file paths. + */ + +/** + * OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings. + * + * @export + * @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished. + * @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target) + */ +export function OnFileDrop(callback, useDropTarget) { + return window.runtime.OnFileDrop(callback, useDropTarget); +} + +/** + * OnFileDropOff removes the drag and drop listeners and handlers. + */ +export function OnFileDropOff() { + return window.runtime.OnFileDropOff(); +} + +export function CanResolveFilePaths() { + return window.runtime.CanResolveFilePaths(); +} + +export function ResolveFilePaths(files) { + return window.runtime.ResolveFilePaths(files); +} + +export function InitializeNotifications() { + return window.runtime.InitializeNotifications(); +} + +export function CleanupNotifications() { + return window.runtime.CleanupNotifications(); +} + +export function IsNotificationAvailable() { + return window.runtime.IsNotificationAvailable(); +} + +export function RequestNotificationAuthorization() { + return window.runtime.RequestNotificationAuthorization(); +} + +export function CheckNotificationAuthorization() { + return window.runtime.CheckNotificationAuthorization(); +} + +export function SendNotification(options) { + return window.runtime.SendNotification(options); +} + +export function SendNotificationWithActions(options) { + return window.runtime.SendNotificationWithActions(options); +} + +export function RegisterNotificationCategory(category) { + return window.runtime.RegisterNotificationCategory(category); +} + +export function RemoveNotificationCategory(categoryId) { + return window.runtime.RemoveNotificationCategory(categoryId); +} + +export function RemoveAllPendingNotifications() { + return window.runtime.RemoveAllPendingNotifications(); +} + +export function RemovePendingNotification(identifier) { + return window.runtime.RemovePendingNotification(identifier); +} + +export function RemoveAllDeliveredNotifications() { + return window.runtime.RemoveAllDeliveredNotifications(); +} + +export function RemoveDeliveredNotification(identifier) { + return window.runtime.RemoveDeliveredNotification(identifier); +} + +export function RemoveNotification(identifier) { + return window.runtime.RemoveNotification(identifier); +} \ No newline at end of file diff --git a/client/go.mod b/client/go.mod new file mode 100644 index 0000000..272525a --- /dev/null +++ b/client/go.mod @@ -0,0 +1,44 @@ +module client + +go 1.23.0 + +require ( + github.com/gorilla/websocket v1.5.3 + github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018 + github.com/wailsapp/wails/v2 v2.12.0 +) + +require ( + git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect + github.com/bep/debounce v1.2.1 // indirect + github.com/gen2brain/shm v0.1.0 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect + github.com/jezek/xgb v1.1.1 // indirect + github.com/labstack/echo/v4 v4.13.3 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/leaanthony/go-ansi-parser v1.6.1 // indirect + github.com/leaanthony/gosod v1.0.4 // indirect + github.com/leaanthony/slicer v1.6.0 // indirect + github.com/leaanthony/u v1.1.1 // indirect + github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/samber/lo v1.49.1 // indirect + github.com/tkrajina/go-reflector v0.5.8 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/wailsapp/go-webview2 v1.0.22 // indirect + github.com/wailsapp/mimetype v1.4.1 // indirect + golang.org/x/crypto v0.33.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect +) + +// replace github.com/wailsapp/wails/v2 v2.12.0 => C:\Users\PhuocNTB\go\pkg\mod diff --git a/client/go.sum b/client/go.sum new file mode 100644 index 0000000..5d68d93 --- /dev/null +++ b/client/go.sum @@ -0,0 +1,92 @@ +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= +github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= +github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gen2brain/shm v0.1.0 h1:MwPeg+zJQXN0RM9o+HqaSFypNoNEcNpeoGp0BTSx2YY= +github.com/gen2brain/shm v0.1.0/go.mod h1:UgIcVtvmOu+aCJpqJX7GOtiN7X2ct+TKLg4RTxwPIUA= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4= +github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= +github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018 h1:NQYgMY188uWrS+E/7xMVpydsI48PMHcc7SfR4OxkDF4= +github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018/go.mod h1:Pmpz2BLf55auQZ67u3rvyI2vAQvNetkK/4zYUmpauZQ= +github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= +github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc= +github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA= +github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A= +github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= +github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI= +github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw= +github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js= +github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= +github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= +github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= +github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc= +github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= +github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= +github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58= +github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= +github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= +github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= +github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c= +github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/client/internal/blocker/blocker.go b/client/internal/blocker/blocker.go new file mode 100644 index 0000000..1b42d4b --- /dev/null +++ b/client/internal/blocker/blocker.go @@ -0,0 +1,317 @@ +package blocker + +import ( + "log" + "os" + "strings" + "sync" + "syscall" + "time" + "unsafe" +) + +var ( + user32 = syscall.NewLazyDLL("user32.dll") + procEnumWindows = user32.NewProc("EnumWindows") + procIsWindowVisible = user32.NewProc("IsWindowVisible") + procGetWindowTextW = user32.NewProc("GetWindowTextW") + procGetWindowTextLengthW = user32.NewProc("GetWindowTextLengthW") + procGetWindowThreadProcessId = user32.NewProc("GetWindowThreadProcessId") + procGetWindow = user32.NewProc("GetWindow") + procGetWindowLongW = user32.NewProc("GetWindowLongW") + + dwmapi = syscall.NewLazyDLL("dwmapi.dll") + procDwmGetWindowAttribute = dwmapi.NewProc("DwmGetWindowAttribute") + + kernel32 = syscall.NewLazyDLL("kernel32.dll") +) + +const ( + GW_OWNER = 4 + WS_EX_TOOLWINDOW = 0x00000080 + DWMWA_CLOAKED = 14 +) + +func isRealGUIWindow(hwnd uintptr) bool { + // 1. Phải đang hiển thị + ret, _, _ := procIsWindowVisible.Call(hwnd) + if ret == 0 { + return false + } + + // 2. Không được có chủ sở hữu (phải là cửa sổ chính - top-level) + owner, _, _ := procGetWindow.Call(hwnd, GW_OWNER) + if owner != 0 { + return false + } + + // 3. Không phải là tool window (WS_EX_TOOLWINDOW) + gwlExStyle := int32(-20) // GWL_EXSTYLE = -20 + style, _, _ := procGetWindowLongW.Call(hwnd, uintptr(gwlExStyle)) + if (style & WS_EX_TOOLWINDOW) != 0 { + return false + } + + // 4. Không bị cloaked bởi DWM (ví dụ: app UWP bị treo/chạy ngầm, màn hình ảo) + var cloaked uint32 + hr, _, _ := procDwmGetWindowAttribute.Call(hwnd, DWMWA_CLOAKED, uintptr(unsafe.Pointer(&cloaked)), 4) + if hr == 0 && cloaked != 0 { + return false + } + + return true +} + +type WindowInfo struct { + PID uint32 + Title string + ProcessName string +} + +type Blocker struct { + mu sync.Mutex + allowedKeywords []string + running bool + stopChan chan struct{} + OnBlocked func(procName string, title string) + OnKill func(procName string, title string) +} + +var Instance = &Blocker{ + allowedKeywords: []string{"chrome", "idea64", "vscode", "wails", "simple_care", "client"}, + stopChan: make(chan struct{}), +} + +func getProcessMap() (map[uint32]string, error) { + snapshot, err := syscall.CreateToolhelp32Snapshot(syscall.TH32CS_SNAPPROCESS, 0) + if err != nil { + return nil, err + } + defer syscall.CloseHandle(snapshot) + + var pe syscall.ProcessEntry32 + pe.Size = uint32(unsafe.Sizeof(pe)) + + err = syscall.Process32First(snapshot, &pe) + if err != nil { + return nil, err + } + + pm := make(map[uint32]string) + for { + name := syscall.UTF16ToString(pe.ExeFile[:]) + pm[pe.ProcessID] = name + + err = syscall.Process32Next(snapshot, &pe) + if err != nil { + break + } + } + return pm, nil +} + +func getWindowText(hwnd uintptr) string { + length, _, _ := procGetWindowTextLengthW.Call(hwnd) + if length == 0 { + return "" + } + buf := make([]uint16, length+1) + procGetWindowTextW.Call(hwnd, uintptr(unsafe.Pointer(&buf[0])), length+1) + return syscall.UTF16ToString(buf) +} + +var ( + enumWindowsMutex sync.Mutex + enumWindowsList []WindowInfo + enumProcessMap map[uint32]string +) + +var enumWindowsCallback = syscall.NewCallback(func(hwnd uintptr, lParam uintptr) uintptr { + defer func() { + if r := recover(); r != nil { + log.Printf("[BLOCKER] Callback panic recovered: %v", r) + } + }() + + if !isRealGUIWindow(hwnd) { + return 1 + } + + title := getWindowText(hwnd) + if title == "" { + return 1 + } + + var pid uint32 + procGetWindowThreadProcessId.Call(hwnd, uintptr(unsafe.Pointer(&pid))) + + procName := enumProcessMap[pid] + if procName == "" { + procName = "Unknown" + } + + enumWindowsList = append(enumWindowsList, WindowInfo{ + PID: pid, + Title: title, + ProcessName: procName, + }) + + return 1 +}) + +func EnumerateGUIWindows() ([]WindowInfo, error) { + pMap, err := getProcessMap() + if err != nil { + return nil, err + } + + enumWindowsMutex.Lock() + defer enumWindowsMutex.Unlock() + + enumWindowsList = make([]WindowInfo, 0, 100) + enumProcessMap = pMap + + procEnumWindows.Call(enumWindowsCallback, 0) + + // Clean up map reference so GC can reclaim it + enumProcessMap = nil + + // Copy to a new slice to return safely + res := make([]WindowInfo, len(enumWindowsList)) + copy(res, enumWindowsList) + return res, nil +} + +func (b *Blocker) SetKeywords(keywords string) { + b.mu.Lock() + defer b.mu.Unlock() + + parts := strings.Split(keywords, ",") + var list []string + for _, p := range parts { + trimmed := strings.TrimSpace(strings.ToLower(p)) + if trimmed != "" { + list = append(list, trimmed) + } + } + b.allowedKeywords = list + log.Printf("[BLOCKER] Keywords updated: %v", b.allowedKeywords) +} + +var systemAllowed = map[string]bool{ + "explorer.exe": true, + "taskmgr.exe": true, + "cmd.exe": true, + "powershell.exe": true, + "conhost.exe": true, + "client.exe": true, // App Wails của ta + "wails.exe": true, + "msedgewebview2.exe": true, // WebView2 runtime của Wails + "code.exe": true, // VSCode + "cursor.exe": true, // Cursor + "windsurf.exe": true, // Windsurf + "goland.exe": true, // GoLand + "goland64.exe": true, // GoLand + "idea64.exe": true, // IntelliJ IDEA + "clion64.exe": true, // CLion + "webstorm64.exe": true, // WebStorm + "pycharm64.exe": true, // PyCharm + "rider64.exe": true, // Rider + "studio64.exe": true, // Android Studio + "eclipse.exe": true, // Eclipse + "sublime_text.exe": true, // Sublime Text + "notepad++.exe": true, // Notepad++ + "devenv.exe": true, // Visual Studio + "git-bash.exe": true, // Git Bash + "bash.exe": true, // Bash +} + +func (b *Blocker) checkAndKill() { + b.mu.Lock() + keywords := make([]string, len(b.allowedKeywords)) + copy(keywords, b.allowedKeywords) + b.mu.Unlock() + + // Nếu không cấu hình keyword thì không chặn gì cả + if len(keywords) == 0 { + return + } + + windows, err := EnumerateGUIWindows() + if err != nil { + return + } + + for _, w := range windows { + pNameLower := strings.ToLower(w.ProcessName) + wTitleLower := strings.ToLower(w.Title) + + // 1. Luôn cho phép hệ thống/app cốt lõi hoặc chính tiến trình này + if w.PID == uint32(os.Getpid()) || systemAllowed[pNameLower] || strings.Contains(pNameLower, "antigravity") || strings.Contains(wTitleLower, "antigravity") { + continue + } + + // 2. Kiểm tra xem có chứa bất kỳ từ khóa nào được cho phép không + allowed := false + for _, kw := range keywords { + if strings.Contains(pNameLower, kw) || strings.Contains(wTitleLower, kw) { + allowed = true + break + } + } + + // 3. Nếu không nằm trong whitelist, tắt ứng dụng + if !allowed { + if b.OnBlocked != nil { + b.OnBlocked(w.ProcessName, w.Title) + } + log.Printf("[BLOCKER] KILLED unauthorized application: %s (PID: %d, Title: %s)", w.ProcessName, w.PID, w.Title) + h, err := syscall.OpenProcess(syscall.PROCESS_TERMINATE, false, w.PID) + if err == nil { + errTerm := syscall.TerminateProcess(h, 0) + _ = syscall.CloseHandle(h) + if errTerm == nil && b.OnKill != nil { + b.OnKill(w.ProcessName, w.Title) + } + } + } + } +} + +func (b *Blocker) Start() { + b.mu.Lock() + if b.running { + b.mu.Unlock() + return + } + b.running = true + b.stopChan = make(chan struct{}) + b.mu.Unlock() + + go func() { + ticker := time.NewTicker(3 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + b.checkAndKill() + case <-b.stopChan: + return + } + } + }() + log.Println("[BLOCKER] Application Blocker Daemon Started.") +} + +func (b *Blocker) Stop() { + b.mu.Lock() + defer b.mu.Unlock() + + if !b.running { + return + } + b.running = false + close(b.stopChan) + log.Println("[BLOCKER] Application Blocker Daemon Stopped.") +} diff --git a/client/internal/screen/screen.go b/client/internal/screen/screen.go new file mode 100644 index 0000000..c98f9ca --- /dev/null +++ b/client/internal/screen/screen.go @@ -0,0 +1,35 @@ +package screen + +import ( + "bytes" + "encoding/base64" + "fmt" + "image/jpeg" + + "github.com/kbinani/screenshot" +) + +// CaptureScreen chụp màn hình chính và trả về chuỗi Base64 dạng "data:image/jpeg;base64,..." +func CaptureScreen() (string, error) { + n := screenshot.NumActiveDisplays() + if n <= 0 { + return "", fmt.Errorf("no active displays found") + } + + // Chụp màn hình chính (index 0) + bounds := screenshot.GetDisplayBounds(0) + img, err := screenshot.CaptureRect(bounds) + if err != nil { + return "", fmt.Errorf("failed to capture screen: %w", err) + } + + var buf bytes.Buffer + // Nén chất lượng JPEG khoảng 50% để truyền tải mượt mà qua mạng + err = jpeg.Encode(&buf, img, &jpeg.Options{Quality: 50}) + if err != nil { + return "", fmt.Errorf("failed to encode jpeg: %w", err) + } + + encoded := base64.StdEncoding.EncodeToString(buf.Bytes()) + return "data:image/jpeg;base64," + encoded, nil +} diff --git a/client/internal/winapi/winapi.go b/client/internal/winapi/winapi.go new file mode 100644 index 0000000..203c92f --- /dev/null +++ b/client/internal/winapi/winapi.go @@ -0,0 +1,82 @@ +package winapi + +import ( + "bytes" + "os/exec" + "strings" + "syscall" +) + +// WifiConnection — SSID + BSSID (MAC) của điểm phát đang kết nối +type WifiConnection struct { + SSID string + BSSID string +} + +// GetWifiSSID trả về SSID Wifi đang kết nối +func GetWifiSSID() string { + return GetWifiConnection().SSID +} + +// GetWifiConnection đọc SSID và BSSID từ netsh (Windows) +func GetWifiConnection() WifiConnection { + cmd := exec.Command("netsh", "wlan", "show", "interfaces") + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + + var out bytes.Buffer + cmd.Stdout = &out + if err := cmd.Run(); err != nil { + return WifiConnection{} + } + + var conn WifiConnection + for _, line := range strings.Split(out.String(), "\n") { + trimmed := strings.TrimSpace(line) + lower := strings.ToLower(trimmed) + if strings.HasPrefix(lower, "ssid") && !strings.Contains(lower, "bssid") { + if v := valueAfterColon(trimmed); v != "" { + conn.SSID = v + } + } + // Windows: "AP BSSID" (EN) hoặc dòng có chứa "bssid" (locale khác) + if strings.Contains(lower, "bssid") { + if v := valueAfterColon(trimmed); v != "" { + conn.BSSID = normalizeMAC(v) + } + } + } + return conn +} + +func valueAfterColon(line string) string { + idx := strings.Index(line, ":") + if idx < 0 { + return "" + } + return strings.TrimSpace(line[idx+1:]) +} + +func normalizeMAC(mac string) string { + var hex []rune + for _, c := range strings.ToLower(mac) { + if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') { + hex = append(hex, c) + } + } + if len(hex) != 12 { + return strings.TrimSpace(mac) + } + return string(hex[0:2]) + ":" + string(hex[2:4]) + ":" + string(hex[4:6]) + ":" + + string(hex[6:8]) + ":" + string(hex[8:10]) + ":" + string(hex[10:12]) +} + +// BSSIDKey chuẩn hóa MAC để so khớp (12 ký tự hex) +func BSSIDKey(bssid string) string { + var hex []rune + for _, c := range strings.ToLower(bssid) { + if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') { + hex = append(hex, c) + } + } + return string(hex) +} diff --git a/client/main.go b/client/main.go new file mode 100644 index 0000000..17bc58a --- /dev/null +++ b/client/main.go @@ -0,0 +1,36 @@ +package main + +import ( + "embed" + + "github.com/wailsapp/wails/v2" + "github.com/wailsapp/wails/v2/pkg/options" + "github.com/wailsapp/wails/v2/pkg/options/assetserver" +) + +//go:embed all:frontend/dist +var assets embed.FS + +func main() { + // Create an instance of the app structure + app := NewApp() + + // Create application with options + err := wails.Run(&options.App{ + Title: "Simple Care — Rikkei Education", + Width: 1024, + Height: 768, + AssetServer: &assetserver.Options{ + Assets: assets, + }, + BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1}, + OnStartup: app.startup, + Bind: []interface{}{ + app, + }, + }) + + if err != nil { + println("Error:", err.Error()) + } +} diff --git a/client/wails.json b/client/wails.json new file mode 100644 index 0000000..bbda809 --- /dev/null +++ b/client/wails.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://wails.io/schemas/config.v2.json", + "name": "Simple Care by Rikkei Edu", + "outputfilename": "simple_care_v1.0", + "frontend:install": "npm install", + "frontend:build": "npm run build", + "frontend:dev:watcher": "npm run dev", + "frontend:dev:serverUrl": "auto", + "author": { + "name": "PhuocNTB", + "email": "phuocntb@mieusoft.com" + } +} \ No newline at end of file diff --git a/management/index.html b/management/index.html index da9b56c..f97c7f5 100644 --- a/management/index.html +++ b/management/index.html @@ -1,10 +1,10 @@ - + - management + Simple Care — Rikkei Education
diff --git a/management/src/App.tsx b/management/src/App.tsx index c49aab9..66ac63e 100644 --- a/management/src/App.tsx +++ b/management/src/App.tsx @@ -1,65 +1,165 @@ -import { useState } from 'react'; +import { useEffect } from 'react'; import { DashboardTab } from './components/DashboardTab'; import { ClassesTab } from './components/ClassesTab'; import { StudentsTab } from './components/StudentsTab'; +import { LearningTab } from './components/LearningTab'; +import { NetworkTab } from './components/NetworkTab'; +import { ClassWorkspace } from './components/ClassWorkspace'; +import { NavHistoryBar, useRoute } from './components/NavHistoryBar'; +import { goBack, navigate, parseRoute, pushNav, TAB_LABELS, type TabId } from './navigation'; + +const IconDashboard = () => ( + + + + +); + +const IconClass = () => ( + + + + +); + +const IconStudent = () => ( + + + + + +); + +const IconLearning = () => ( + + + + +); + +const IconNetwork = () => ( + + + + + + +); function App() { - const [activeTab, setActiveTab] = useState('dashboard'); + const route = useRoute(); + + useEffect(() => { + const initial = parseRoute(); + if (initial.classId) { + return; + } + pushNav({ kind: 'tab', tab: initial.tab, label: TAB_LABELS[initial.tab] }); + }, []); + + const setActiveTab = (tab: string) => { + navigate(tab as TabId); + }; + + const handleBackFromClass = () => { + goBack(route.tab); + }; return ( <> - {/* Sidebar navigation panel */} - {/* Main dashboard content container */}
- {activeTab === 'dashboard' && } - {activeTab === 'classes' && } - {activeTab === 'students' && } +
+ {route.classId ? ( + ( + + )} + /> + ) : ( + <> + {route.tab === 'dashboard' && } + {route.tab === 'classes' && } + {route.tab === 'students' && } + {route.tab === 'learning' && } + {route.tab === 'network' && } + + )} +
); diff --git a/management/src/api.ts b/management/src/api.ts index c27196e..3800dfd 100644 --- a/management/src/api.ts +++ b/management/src/api.ts @@ -28,6 +28,7 @@ export interface StudentItem { location?: string; systemId?: number; systemName?: string; + avatar?: string; } export interface StatsResponse { @@ -54,6 +55,55 @@ export interface SyncStatus { pageSize?: number; } +export interface ClassScheduleItem { + id?: number; + classRkId?: number; + dayOfWeek: number; + period: number; + startTime: string; + endTime: string; + courseId: number; + courseName: string; + isActive: boolean; +} + +export interface ClassCourseItem { + id: number; + name: string; + courseCode?: string; +} + +export interface AttendanceRow { + studentRkId: number; + studentCode: string; + fullName: string; + email: string; + status: number; + statusLabel: string; + onlineMinutes: number; + statusEditedByTeacher: boolean; +} + +export const ATTENDANCE_STATUS_OPTIONS = [ + { value: 0, label: 'Nghỉ không phép' }, + { value: 1, label: 'Nghỉ có phép' }, + { value: 2, label: 'Nghỉ nửa buổi' }, + { value: 3, label: 'Đi học muộn' }, + { value: 4, label: 'Đi học đầy đủ' }, +]; + +export interface StudentSessionLogItem { + id: number; + studentRkId: number; + studentCode: string; + fullName: string; + email: string; + onlineSeconds: number; + offlineSeconds: number; + wifiSsids: string; + lastActiveAt?: string; +} + export const api = { getStats: async (): Promise => { const res = await fetch(`${API_BASE}/stats`); @@ -87,7 +137,10 @@ export const api = { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ isStudying }), }); - if (!res.ok) throw new Error('Failed to update class studying status'); + if (!res.ok) { + const errData = await res.json().catch(() => ({})); + throw new Error(errData.error || 'Failed to update class studying status'); + } return res.json(); }, @@ -143,3 +196,199 @@ export const api = { return res.json(); }, }; + +// Export individual learning functions to simplify imports in components +export const apiFetchActiveSchedules = async (): Promise<{ data: any[] }> => { + const res = await fetch(`${API_BASE}/classes/schedules`); + if (!res.ok) throw new Error('Failed to fetch active schedules'); + return res.json(); +}; + +export const apiFetchClassSchedule = async (rkId: number): Promise<{ data: ClassScheduleItem[] }> => { + const res = await fetch(`${API_BASE}/classes/${rkId}/schedule`); + if (!res.ok) throw new Error('Failed to fetch class schedule'); + return res.json(); +}; + +export const apiSaveClassSchedule = async (rkId: number, schedules: ClassScheduleItem[]): Promise => { + const res = await fetch(`${API_BASE}/classes/${rkId}/schedule`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ schedules }), + }); + if (!res.ok) throw new Error('Failed to save class schedule'); + return res.json(); +}; + +export const apiDeleteClassSchedule = async (rkId: number): Promise => { + const res = await fetch(`${API_BASE}/classes/${rkId}/schedule`, { method: 'DELETE' }); + if (!res.ok) throw new Error('Failed to delete class schedule'); + return res.json(); +}; + +export const apiFetchAllowedApps = async (rkId: number): Promise<{ classRkId: number; keywords: string }> => { + const res = await fetch(`${API_BASE}/classes/${rkId}/allowed-apps`); + if (!res.ok) throw new Error('Failed to fetch allowed apps keywords'); + return res.json(); +}; + +export const apiSaveAllowedApps = async (rkId: number, keywords: string): Promise => { + const res = await fetch(`${API_BASE}/classes/${rkId}/allowed-apps`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ keywords }), + }); + if (!res.ok) throw new Error('Failed to save allowed apps keywords'); + return res.json(); +}; + +export interface AppPoolItem { + id: number; + processName: string; + windowTitle: string; + keyword: string; + hitCount: number; + lastSeenAt: string; + lastStudentRkId?: number; + lastClassRkId?: number; +} + +export const apiFetchAppPool = async (q = '', limit = 50): Promise<{ data: AppPoolItem[]; q?: string; limit?: number }> => { + const params = new URLSearchParams(); + if (q.trim()) params.set('q', q.trim()); + params.set('limit', String(limit)); + const query = params.toString() ? `?${params.toString()}` : ''; + const res = await fetch(`${API_BASE}/app-pool${query}`); + if (!res.ok) throw new Error('Failed to fetch app pool'); + return res.json(); +}; + +export interface WifiPoolItem { + id: number; + ssid: string; + bssid: string; + hitCount: number; + lastSeenAt: string; + lastStudentRkId?: number; +} + +export interface AcceptedWifiItem { + id: number; + ssid: string; + bssid: string; + createdAt: string; +} + +export type WifiAcceptItem = { ssid: string; bssid: string }; + +export const apiFetchWifiPool = async (q = '', limit = 50): Promise<{ data: WifiPoolItem[] }> => { + const params = new URLSearchParams(); + if (q.trim()) params.set('q', q.trim()); + params.set('limit', String(limit)); + const query = params.toString() ? `?${params.toString()}` : ''; + const res = await fetch(`${API_BASE}/wifi-pool${query}`); + if (!res.ok) throw new Error('Failed to fetch wifi pool'); + return res.json(); +}; + +export const apiFetchAcceptedWifis = async (): Promise<{ data: AcceptedWifiItem[] }> => { + const res = await fetch(`${API_BASE}/network/accepted-wifis`); + if (!res.ok) throw new Error('Failed to fetch accepted wifis'); + return res.json(); +}; + +export const apiSaveAcceptedWifis = async (items: WifiAcceptItem[]): Promise => { + const res = await fetch(`${API_BASE}/network/accepted-wifis`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ items }), + }); + if (!res.ok) throw new Error('Failed to save accepted wifis'); + return res.json(); +}; + +export const apiFetchClassStudents = api.getClassStudents; +export const apiFetchClasses = api.getClasses; + +export const apiFetchClassSessionLogs = async ( + rkId: number, + date?: string, + period?: number +): Promise<{ data: StudentSessionLogItem[]; period?: number; date?: string }> => { + const params = new URLSearchParams(); + if (date) params.set('date', date); + if (period) params.set('period', String(period)); + const query = params.toString() ? `?${params.toString()}` : ''; + const res = await fetch(`${API_BASE}/classes/${rkId}/session-logs${query}`); + if (!res.ok) throw new Error('Failed to fetch class session logs'); + return res.json(); +}; + +export const apiFetchOnlineStudents = async (rkId: number): Promise<{ onlineStudentIds: number[] }> => { + const res = await fetch(`${API_BASE}/classes/${rkId}/online-students`); + if (!res.ok) throw new Error('Failed to fetch online students list'); + return res.json(); +}; + +export const apiFetchClassCourses = async (rkId: number): Promise<{ + data: ClassCourseItem[]; + source?: string; + cached?: boolean; + warning?: string; + hint?: string; +}> => { + const res = await fetch(`${API_BASE}/classes/${rkId}/courses`); + const body = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(body.error || body.hint || 'Không tải được danh sách môn học từ QLĐT'); + } + return body; +}; + +export const apiApplyScheduleTemplate = async (rkId: number): Promise<{ created: number; skipped: number }> => { + const res = await fetch(`${API_BASE}/classes/${rkId}/schedule/apply-template`, { method: 'POST' }); + if (!res.ok) throw new Error('Failed to apply schedule template'); + return res.json(); +}; + +export const apiFetchAttendanceShifts = async (rkId: number, date?: string) => { + const q = date ? `?date=${date}` : ''; + const res = await fetch(`${API_BASE}/classes/${rkId}/attendance/shifts${q}`); + if (!res.ok) throw new Error('Failed to fetch attendance shifts'); + return res.json(); +}; + +export const apiFetchAttendance = async (rkId: number, date: string, period: number) => { + const res = await fetch(`${API_BASE}/classes/${rkId}/attendance?date=${date}&period=${period}`); + if (!res.ok) throw new Error('Failed to fetch attendance'); + return res.json(); +}; + +export const apiUpdateAttendanceStatus = async ( + rkId: number, + payload: { date: string; period: number; studentRkId: number; status: number } +) => { + const res = await fetch(`${API_BASE}/classes/${rkId}/attendance/status`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || 'Failed to update attendance status'); + } + return res.json(); +}; + +export const apiPushAttendanceQLDT = async (rkId: number, date: string, period: number) => { + const res = await fetch(`${API_BASE}/classes/${rkId}/attendance/push-qldt`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ date, period }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || 'Failed to push attendance to QLĐT'); + } + return res.json(); +}; diff --git a/management/src/components/AppPoolModal.tsx b/management/src/components/AppPoolModal.tsx new file mode 100644 index 0000000..f43c4ad --- /dev/null +++ b/management/src/components/AppPoolModal.tsx @@ -0,0 +1,167 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { apiFetchAppPool } from '../api'; +import type { AppPoolItem } from '../api'; + +interface AppPoolModalProps { + open: boolean; + onClose: () => void; + onSelect: (keyword: string) => void; + allowedApps: string; +} + +const formatWhen = (iso: string) => { + if (!iso) return '—'; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return '—'; + return d.toLocaleString('vi-VN', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); +}; + +export const AppPoolModal: React.FC = ({ + open, + onClose, + onSelect, + allowedApps, +}) => { + const [search, setSearch] = useState(''); + const [debouncedQ, setDebouncedQ] = useState(''); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const allowedSet = useMemo(() => { + return new Set( + allowedApps.split(',').map(s => s.trim().toLowerCase()).filter(Boolean) + ); + }, [allowedApps]); + + useEffect(() => { + if (!open) return; + const t = setTimeout(() => setDebouncedQ(search.trim()), 300); + return () => clearTimeout(t); + }, [search, open]); + + const loadPool = useCallback(async (q: string) => { + try { + setLoading(true); + setError(null); + const res = await apiFetchAppPool(q, 80); + setItems(res.data || []); + } catch (e: any) { + setItems([]); + setError(e?.message || 'Không tải được kho ứng dụng'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (!open) return; + loadPool(debouncedQ); + const interval = setInterval(() => loadPool(debouncedQ), 20000); + return () => clearInterval(interval); + }, [open, debouncedQ, loadPool]); + + useEffect(() => { + if (!open) { + setSearch(''); + setDebouncedQ(''); + setItems([]); + setError(null); + } + }, [open]); + + if (!open) return null; + + const handleSelect = (keyword: string) => { + const kw = keyword.trim().toLowerCase(); + if (!kw || allowedSet.has(kw)) return; + onSelect(kw); + }; + + return ( +
+
e.stopPropagation()}> +
+
+

Kho ứng dụng

+

+ Toàn hệ thống — app bị chặn từ mọi lớp. Chọn để thêm vào whitelist lớp hiện tại. +

+
+ +
+ +
+ setSearch(e.target.value)} + autoFocus + /> + +
+ +
+ {error ? ( +
{error}
+ ) : loading && items.length === 0 ? ( +
Đang tải...
+ ) : items.length === 0 ? ( +
+ {debouncedQ ? `Không tìm thấy "${debouncedQ}"` : 'Chưa có app nào trong kho.'} +
+ ) : ( + + + + + + + + + + + + + {items.map(app => { + const added = allowedSet.has(app.keyword.toLowerCase()); + return ( + + + + + + + + + ); + })} + +
KeywordTiến trìnhTiêu đềLần chặnGần nhất
{app.keyword}{app.processName}{app.windowTitle || '—'}{app.hitCount}×{formatWhen(app.lastSeenAt)} + {added ? ( + Đã có + ) : ( + + )} +
+ )} +
+ +
+ Hiển thị {items.length} kết quả{debouncedQ ? ` cho "${debouncedQ}"` : ''} (tối đa 80 mỗi lần tải) +
+
+
+ ); +}; diff --git a/management/src/components/AttendancePanel.tsx b/management/src/components/AttendancePanel.tsx new file mode 100644 index 0000000..8666e35 --- /dev/null +++ b/management/src/components/AttendancePanel.tsx @@ -0,0 +1,163 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { + ATTENDANCE_STATUS_OPTIONS, + apiFetchAttendance, + apiFetchAttendanceShifts, + apiPushAttendanceQLDT, + apiUpdateAttendanceStatus, + type AttendanceRow, +} from '../api'; + +interface AttendancePanelProps { + classId: number; +} + +export const AttendancePanel: React.FC = ({ classId }) => { + const today = new Date().toISOString().slice(0, 10); + const [date, setDate] = useState(today); + const [period, setPeriod] = useState(1); + const [rows, setRows] = useState([]); + const [shifts, setShifts] = useState([]); + const [shiftInfo, setShiftInfo] = useState(null); + const [loading, setLoading] = useState(false); + const [pushing, setPushing] = useState(false); + + const loadShifts = useCallback(async () => { + try { + const res = await apiFetchAttendanceShifts(classId, date); + setShifts(res.data || []); + if (res.data?.length && !res.data.find((s: any) => s.period === period)) { + setPeriod(res.data[0].period || 1); + } + } catch { + setShifts([]); + } + }, [classId, date, period]); + + const loadAttendance = useCallback(async () => { + try { + setLoading(true); + const res = await apiFetchAttendance(classId, date, period); + setRows(res.data || []); + setShiftInfo(res.shift || null); + } catch (e: any) { + alert(e.message || 'Không tải được điểm danh'); + } finally { + setLoading(false); + } + }, [classId, date, period]); + + useEffect(() => { loadShifts(); }, [loadShifts]); + useEffect(() => { loadAttendance(); }, [loadAttendance]); + + const handleStatusChange = async (studentRkId: number, status: number) => { + try { + await apiUpdateAttendanceStatus(classId, { date, period, studentRkId, status }); + await loadAttendance(); + } catch (e: any) { + alert(e.message || 'Cập nhật trạng thái thất bại'); + } + }; + + const handlePushQLDT = async () => { + if (!shiftInfo?.courseId) { + alert('Ca học này chưa chọn môn học. Vui lòng cấu hình trong lịch học.'); + return; + } + if (!confirm(`Đẩy điểm danh Ca ${period} ngày ${date} lên QLĐT?`)) return; + try { + setPushing(true); + const res = await apiPushAttendanceQLDT(classId, date, period); + alert(res.message || 'Đã đẩy lên QLĐT'); + } catch (e: any) { + alert(e.message || 'Đẩy QLĐT thất bại'); + } finally { + setPushing(false); + } + }; + + const currentShift = shifts.find(s => s.period === period); + + return ( +
+
+ + + + +
+ + {currentShift && ( +
+ {currentShift.startTime}–{currentShift.endTime} + {currentShift.courseName || 'Chưa chọn môn'} + {!currentShift.isActive && Ca tắt} +
+ )} + +

+ Sửa trạng thái thủ công sẽ được khóa — hệ thống tự tính sẽ không ghi đè. +

+ +
+ {loading ? ( +
+ ) : ( + + + + + + + + + + + {rows.length === 0 ? ( + + ) : rows.map(row => ( + + + + + + + ))} + +
Sinh viênOnline (phút)Trạng tháiGhi chú
Chưa có dữ liệu điểm danh
+
{row.fullName}
+
{row.studentCode}
+
{row.onlineMinutes} + + + {row.statusEditedByTeacher && ( + Đã khóa + )} +
+ )} +
+
+ ); +}; diff --git a/management/src/components/ClassWorkspace.tsx b/management/src/components/ClassWorkspace.tsx new file mode 100644 index 0000000..879aa14 --- /dev/null +++ b/management/src/components/ClassWorkspace.tsx @@ -0,0 +1,547 @@ +import React, { useEffect, useState } from 'react'; +import { + api, + apiSaveAllowedApps, + apiFetchClassStudents, + apiFetchOnlineStudents, + apiFetchClassSessionLogs, + apiFetchClassSchedule, + apiFetchAllowedApps, + apiFetchAttendanceShifts, +} from '../api'; +import type { + ClassItem, + StudentItem, + ClassScheduleItem, + StudentSessionLogItem, +} from '../api'; +import { BASE_APP_SUGGESTIONS } from '../constants'; +import { pushNav, type TabId } from '../navigation'; +import { ScheduleEditor } from './ScheduleEditor'; +import { AttendancePanel } from './AttendancePanel'; +import { StudentAvatar } from './StudentAvatar'; +import { StudentDetailModal } from './StudentDetailModal'; +import { AppPoolModal } from './AppPoolModal'; + +interface ClassWorkspaceProps { + classId: number; + sourceTab?: TabId; + onBack?: () => void; + renderNavBar?: (className: string) => React.ReactNode; +} + +export const ClassWorkspace: React.FC = ({ classId, sourceTab = 'classes', renderNavBar }) => { + const [classInfo, setClassInfo] = useState(null); + const [students, setStudents] = useState([]); + const [onlineIds, setOnlineIds] = useState([]); + const [allowedApps, setAllowedApps] = useState(''); + const [schedules, setSchedules] = useState([]); + const [sessionLogs, setSessionLogs] = useState([]); + const [searchQuery, setSearchQuery] = useState(''); + const today = new Date().toISOString().slice(0, 10); + const [logDate, setLogDate] = useState(today); + const [logPeriod, setLogPeriod] = useState(1); + const [logShifts, setLogShifts] = useState([]); + const [selectedStudent, setSelectedStudent] = useState(null); + + // Loading & tab states + const [loading, setLoading] = useState(true); + const [logsLoading, setLogsLoading] = useState(false); + const [savingApps, setSavingApps] = useState(false); + const [activeSubTab, setActiveSubTab] = useState<'roster' | 'logs' | 'attendance'>('roster'); + const [configOpen, setConfigOpen] = useState(false); + const [configTab, setConfigTab] = useState<'apps' | 'schedule'>('schedule'); + const [appPoolOpen, setAppPoolOpen] = useState(false); + + const loadAllData = async () => { + try { + setLoading(true); + + // Fetch specific class detail directly + const targetClassRes = await fetch(`http://127.0.0.1:8080/api/classes/${classId}`); + if (targetClassRes.ok) { + const classData = await targetClassRes.json(); + setClassInfo(classData); + pushNav({ + kind: 'class', + tab: sourceTab, + classId, + label: classData.name || `Lớp ${classId}`, + }); + } + + // Fetch students roster + const studentsRes = await apiFetchClassStudents(classId); + setStudents(studentsRes.data); + + // Fetch allowed apps + try { + const appsRes = await apiFetchAllowedApps(classId); + setAllowedApps(appsRes.keywords || ''); + } catch (e) { + console.log("No allowed apps configured yet"); + } + + // Fetch schedules + try { + const schedRes = await apiFetchClassSchedule(classId); + setSchedules(schedRes.data || []); + } catch (e) { + console.log("No schedule configured yet"); + } + + // Fetch online status + await fetchOnlineStatus(); + + // Fetch logs + await fetchLogs(); + + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + }; + + const fetchOnlineStatus = async () => { + try { + const res = await apiFetchOnlineStudents(classId); + setOnlineIds(res.onlineStudentIds || []); + } catch (e) { + console.error("Failed to load online students list", e); + } + }; + + const fetchLogs = async (date = logDate, period = logPeriod) => { + try { + setLogsLoading(true); + const res = await apiFetchClassSessionLogs(classId, date, period); + setSessionLogs(res.data || []); + if (res.period) setLogPeriod(res.period); + } catch (e) { + console.error("Failed to load session logs", e); + } finally { + setLogsLoading(false); + } + }; + + const loadLogShifts = async (date = logDate) => { + try { + const res = await apiFetchAttendanceShifts(classId, date); + setLogShifts(res.data || []); + } catch { + setLogShifts([]); + } + }; + + useEffect(() => { + loadAllData(); + }, [classId]); + + useEffect(() => { + const interval = setInterval(() => { + fetchOnlineStatus(); + fetchLogs(logDate, logPeriod); + }, 5000); + return () => clearInterval(interval); + }, [classId, logDate, logPeriod]); + + useEffect(() => { + loadLogShifts(logDate); + if (activeSubTab === 'logs') { + fetchLogs(logDate, logPeriod); + } + }, [activeSubTab, logDate, logPeriod, classId]); + + useEffect(() => { + fetchLogs(logDate, logPeriod); + loadLogShifts(logDate); + }, [classId]); + + const handleToggleStudying = async () => { + if (!classInfo) return; + const nextVal = !classInfo.isStudying; + try { + setClassInfo(prev => prev ? { ...prev, isStudying: nextVal } : null); + await api.updateClassStudying(classId, nextVal); + } catch (err: any) { + setClassInfo(prev => prev ? { ...prev, isStudying: !nextVal } : null); + alert(err.message || 'Không thể cập nhật trạng thái lớp học'); + } + }; + + const addAppKeyword = (kw: string) => { + const trimmed = kw.trim().toLowerCase(); + if (!trimmed) return; + setAllowedApps(prev => { + const parts = prev.split(',').map(s => s.trim().toLowerCase()).filter(Boolean); + if (parts.includes(trimmed)) return prev; + return prev ? `${prev}, ${trimmed}` : trimmed; + }); + }; + + const handleSaveAllowedApps = async () => { + try { + setSavingApps(true); + await apiSaveAllowedApps(classId, allowedApps); + alert('Đã lưu cấu hình ứng dụng được phép thành công!'); + } catch (e: any) { + alert(e.message || 'Lỗi khi lưu cấu hình ứng dụng'); + } finally { + setSavingApps(false); + } + }; + + const reloadSchedules = async () => { + try { + const schedRes = await apiFetchClassSchedule(classId); + setSchedules(schedRes.data || []); + } catch { + setSchedules([]); + } + }; + + const filteredStudents = students.filter(st => { + const term = searchQuery.toLowerCase().trim(); + if (!term) return true; + return ( + st.fullName.toLowerCase().includes(term) || + st.studentCode.toLowerCase().includes(term) || + st.email.toLowerCase().includes(term) || + (st.phone && st.phone.includes(term)) + ); + }); + + const formatDuration = (totalSeconds: number) => { + const hrs = Math.floor(totalSeconds / 3600); + const mins = Math.floor((totalSeconds % 3600) / 60); + const secs = totalSeconds % 60; + const pad = (num: number) => String(num).padStart(2, '0'); + return `${pad(hrs)}:${pad(mins)}:${pad(secs)}`; + }; + + if (loading) { + return ( +
+
+
Đang tải không gian làm việc của lớp...
+
+ ); + } + + if (!classInfo) { + return ( +
+
🏫
+
Không tìm thấy thông tin lớp học
+ +
+ ); + } + + const onlineCount = students.filter(st => onlineIds.includes(st.rkId)).length; + + const getSessionLogForStudent = (rkId: number) => + sessionLogs.find(log => log.studentRkId === rkId) || null; + + return ( +
+ {renderNavBar && classInfo && renderNavBar(classInfo.name)} + + {/* Header section */} +
+
+
+
🏫
+
+

{classInfo.name}

+
+ CODE: {classInfo.classCode} + ID: {classInfo.rkId} + {classInfo.systemName || 'Hệ thống'} +
+
+
+
+ +
+
+
+ Trạng thái dạy + + {classInfo.isStudying ? 'Đang học' : 'Tạm dừng'} + +
+ +
+ +
+
Online / Sĩ số
+
+ {onlineCount} + / {students.length} SV +
+
+
+
+ + {/* Main area */} +
+ {configOpen && ( +
setConfigOpen(false)} aria-hidden /> + )} + +