diff --git a/client/app.go b/client/app.go index 03d6860..a999d5c 100644 --- a/client/app.go +++ b/client/app.go @@ -8,6 +8,7 @@ import ( "crypto/cipher" "crypto/rand" "encoding/json" + "encoding/base64" "errors" "fmt" "html" @@ -17,6 +18,7 @@ import ( "net/http" "net/url" "os" + "os/exec" "path/filepath" "strings" "sync" @@ -85,9 +87,10 @@ type App struct { ctx context.Context student *StudentData mu sync.Mutex - sessionPath string - statsPath string - wsConn *websocket.Conn + sessionPath string + statsPath string + webviewDataPath string + wsConn *websocket.Conn wsConnected bool wifiSSID string wifiBSSID string @@ -165,11 +168,14 @@ func NewApp() *App { configDir = filepath.Dir(exePath) } appDir := filepath.Join(configDir, "SimpleCare") + webviewDir := filepath.Join(appDir, "WebView2") _ = os.MkdirAll(appDir, 0755) + _ = os.MkdirAll(webviewDir, 0755) return &App{ - sessionPath: filepath.Join(appDir, "student_session.json"), - statsPath: filepath.Join(appDir, "student_stats.json"), + sessionPath: filepath.Join(appDir, "student_session.json"), + statsPath: filepath.Join(appDir, "student_stats.json"), + webviewDataPath: webviewDir, lastSyncTime: time.Now(), expectingLogin: false, } @@ -1284,17 +1290,36 @@ func (a *App) examStudentContext() (int64, *StudentExamSnapshot, error) { return a.student.StudentID, a.dashboard.Exam, nil } +// ReturnToDashboard quay lại giao diện chính của app (giữ cookie WebView2). +func (a *App) ReturnToDashboard() { + runtime.WindowReloadApp(a.ctx) +} + +func isLocalExamURL(raw string) bool { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || u.Host == "" { + return false + } + host := strings.ToLower(u.Hostname()) + return host == "127.0.0.1" || host == "localhost" +} + func (a *App) openExamWebView(targetURL, title string) error { targetURL = strings.TrimSpace(targetURL) if targetURL == "" { return errors.New("không có nội dung để mở") } - wrapper := fmt.Sprintf( - "http://127.0.0.1:34115/exam-view?url=%s&title=%s", - url.QueryEscape(targetURL), - url.QueryEscape(title), - ) - runtime.WindowExecJS(a.ctx, fmt.Sprintf("window.location.href = %q", wrapper)) + if isLocalExamURL(targetURL) { + wrapper := fmt.Sprintf( + "http://127.0.0.1:34115/exam-view?url=%s&title=%s", + url.QueryEscape(targetURL), + url.QueryEscape(title), + ) + runtime.WindowExecJS(a.ctx, fmt.Sprintf("window.location.href = %q", wrapper)) + return nil + } + // Trang thi bên ngoài: mở trực tiếp trong WebView (first-party cookies → giữ phiên đăng nhập). + runtime.WindowExecJS(a.ctx, fmt.Sprintf("window.location.href = %q", targetURL)) return nil } @@ -1306,7 +1331,7 @@ func (a *App) GetExamPaperViewURL() (string, error) { if !exam.PaperSent { return "", errors.New("Giảng viên chưa gửi đề") } - return fmt.Sprintf("http://127.0.0.1:8080/api/student/exam/download?studentRkId=%d&kind=pdf", studentID), nil + return fmt.Sprintf("http://127.0.0.1:8080/api/student/exam/download?studentRkId=%d&kind=pdf&view=1", studentID), nil } func (a *App) GetExamQuizViewURL() (string, error) { @@ -1355,6 +1380,9 @@ func (a *App) GetExamPaperFiles() (map[string]any, error) { if u, ok := m["url"].(string); ok && u != "" && !strings.HasPrefix(u, "http") { m["url"] = base + u } + if u, ok := m["downloadUrl"].(string); ok && u != "" && !strings.HasPrefix(u, "http") { + m["downloadUrl"] = base + u + } raw[i] = m } payload["resources"] = raw @@ -1393,7 +1421,7 @@ func (a *App) OpenExamResource(fileID uint) error { return errors.New("tài nguyên không hợp lệ") } viewURL := fmt.Sprintf( - "http://127.0.0.1:8080/api/student/exam/download?studentRkId=%d&kind=resource&fileId=%d", + "http://127.0.0.1:8080/api/student/exam/download?studentRkId=%d&kind=resource&fileId=%d&view=1", studentID, fileID, ) title := "Tài nguyên đề thi" @@ -1418,8 +1446,47 @@ func (a *App) OpenExamResource(fileID uint) error { return a.openExamWebView(viewURL, title) } +func examResourceDownloadDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + candidates := []string{ + filepath.Join(home, "Downloads"), + filepath.Join(home, "Desktop"), + home, + } + for _, dir := range candidates { + if st, err := os.Stat(dir); err == nil && st.IsDir() { + return dir, nil + } + } + return "", errors.New("không tìm thấy thư mục Downloads hoặc Desktop") +} + +func uniqueFilePath(path string) string { + if _, err := os.Stat(path); os.IsNotExist(err) { + return path + } + ext := filepath.Ext(path) + base := strings.TrimSuffix(filepath.Base(path), ext) + dir := filepath.Dir(path) + for i := 1; i < 100; i++ { + candidate := filepath.Join(dir, fmt.Sprintf("%s (%d)%s", base, i, ext)) + if _, err := os.Stat(candidate); os.IsNotExist(err) { + return candidate + } + } + return filepath.Join(dir, fmt.Sprintf("%s_%d%s", base, time.Now().Unix(), ext)) +} + +func openFolderInExplorer(filePath string) { + dir := filepath.Dir(filePath) + _ = exec.Command("explorer", dir).Start() +} + func (a *App) DownloadExamResource(fileID uint) (string, error) { - studentID, exam, err := a.examStudentContext() + studentID, _, err := a.examStudentContext() if err != nil { return "", err } @@ -1457,15 +1524,11 @@ func (a *App) DownloadExamResource(fileID uint) (string, error) { if resp.StatusCode != http.StatusOK { return "", errors.New("không tải được tài nguyên") } - configDir, err := os.UserConfigDir() + dir, err := examResourceDownloadDir() if err != nil { return "", err } - dir := filepath.Join(configDir, "SimpleCare", "ExamResources", fmt.Sprintf("room_%d", exam.ExamRoomID)) - if err := os.MkdirAll(dir, 0755); err != nil { - return "", err - } - dest := filepath.Join(dir, fileName) + dest := uniqueFilePath(filepath.Join(dir, fileName)) f, err := os.Create(dest) if err != nil { return "", err @@ -1475,9 +1538,45 @@ func (a *App) DownloadExamResource(fileID uint) (string, error) { return "", err } f.Close() + openFolderInExplorer(dest) return dest, nil } +// LoadExamViewFile tải file đề/tài nguyên qua Go (không mở URL trực tiếp trong WebView). +func (a *App) LoadExamViewFile(fileURL string) (map[string]any, error) { + fileURL = strings.TrimSpace(fileURL) + if !strings.HasPrefix(fileURL, "http://127.0.0.1:8080/api/student/exam/download") { + return nil, errors.New("URL không hợp lệ") + } + if _, _, err := a.examStudentContext(); err != nil { + return nil, err + } + resp, err := http.Get(fileURL) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, errors.New("không tải được file") + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + mime := strings.TrimSpace(resp.Header.Get("Content-Type")) + if mime == "" { + mime = "application/octet-stream" + } + // Bỏ charset nếu có + if i := strings.Index(mime, ";"); i >= 0 { + mime = strings.TrimSpace(mime[:i]) + } + return map[string]any{ + "data": base64.StdEncoding.EncodeToString(body), + "mime": mime, + }, nil +} + func (a *App) SubmitExamWork() (string, error) { a.mu.Lock() student := a.student diff --git a/client/frontend/package-lock.json b/client/frontend/package-lock.json index fe03314..fdfde65 100644 --- a/client/frontend/package-lock.json +++ b/client/frontend/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "frontend", "version": "0.0.0", + "dependencies": { + "pdfjs-dist": "^3.11.174" + }, "devDependencies": { "vite": "^3.0.7" } @@ -45,6 +48,202 @@ "node": ">=12" } }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC", + "optional": true + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "license": "ISC", + "optional": true + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT", + "optional": true + }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/canvas": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-2.11.2.tgz", + "integrity": "sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.0", + "nan": "^2.17.0", + "simple-get": "^3.0.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "optional": true, + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT", + "optional": true + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC", + "optional": true + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz", + "integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==", + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT", + "optional": true + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "optional": true + }, "node_modules/es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", @@ -433,6 +632,39 @@ "node": ">=12" } }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC", + "optional": true + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -458,6 +690,57 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC", + "optional": true + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -471,6 +754,39 @@ "node": ">= 0.4" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "optional": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC", + "optional": true + }, "node_modules/is-core-module": { "version": "2.16.2", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", @@ -487,6 +803,132 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/mimic-response": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz", + "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "optional": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/nan": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", + "integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==", + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { "version": "3.3.15", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", @@ -506,6 +948,87 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "optional": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "optional": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -513,6 +1036,29 @@ "dev": true, "license": "MIT" }, + "node_modules/path2d-polyfill": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path2d-polyfill/-/path2d-polyfill-2.0.1.tgz", + "integrity": "sha512-ad/3bsalbbWhmBo0D6FZ4RNMwsLsPpL6gnvhuSaU5Vm7b06Kr5ubSltQQ0T7YKsiJQO+g22zJ4dJKNTXIyOXtA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/pdfjs-dist": { + "version": "3.11.174", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz", + "integrity": "sha512-TdTZPf1trZ8/UFu5Cx/GXB7GZM30LT+wWUNfsi6Bq8ePLnb+woNKtDymI2mxZYBpMbonNFqKmiz684DIfnd8dA==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "canvas": "^2.11.2", + "path2d-polyfill": "^2.0.1" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -549,6 +1095,21 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -571,6 +1132,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/rollup": { "version": "2.80.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", @@ -587,6 +1165,87 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC", + "optional": true + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC", + "optional": true + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-3.1.1.tgz", + "integrity": "sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==", + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^4.2.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -597,6 +1256,44 @@ "node": ">=0.10.0" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "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", @@ -610,6 +1307,39 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": true, + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT", + "optional": true + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT", + "optional": true + }, "node_modules/vite": { "version": "3.2.11", "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.11.tgz", @@ -659,6 +1389,48 @@ "optional": true } } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC", + "optional": true + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC", + "optional": true } } } diff --git a/client/frontend/package.json b/client/frontend/package.json index a1b6f8e..5a3c773 100644 --- a/client/frontend/package.json +++ b/client/frontend/package.json @@ -9,5 +9,8 @@ }, "devDependencies": { "vite": "^3.0.7" + }, + "dependencies": { + "pdfjs-dist": "^3.11.174" } -} \ No newline at end of file +} diff --git a/client/frontend/package.json.md5 b/client/frontend/package.json.md5 index d4671a1..35df6d0 100644 --- a/client/frontend/package.json.md5 +++ b/client/frontend/package.json.md5 @@ -1 +1 @@ -5fbf12469d224a93954efecb5886e8a6 \ No newline at end of file +967eadb7b0ec47c936144695be4b0b15 \ No newline at end of file diff --git a/client/frontend/src/app.css b/client/frontend/src/app.css index ad00a4a..edbfe3b 100644 --- a/client/frontend/src/app.css +++ b/client/frontend/src/app.css @@ -430,6 +430,147 @@ body { margin-bottom: 0.5rem; } +.exam-resources-hint { + margin: 0; + font-size: 0.82rem; + color: var(--text-muted); +} + +.exam-viewer-overlay { + position: fixed; + inset: 0; + z-index: 10050; + background: rgba(15, 23, 42, 0.72); + display: none; + align-items: stretch; + justify-content: center; + padding: 0.75rem; + outline: none; + user-select: none; +} + +.exam-viewer-overlay.is-open { + display: flex; +} + +.exam-viewer-shell { + width: min(1100px, 100%); + margin: 0 auto; + background: #fff; + border-radius: 12px; + overflow: hidden; + display: flex; + flex-direction: column; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.35); + max-height: calc(100vh - 1.5rem); +} + +.exam-viewer-bar { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.65rem 0.85rem; + background: #0f172a; + color: #f8fafc; + flex-shrink: 0; +} + +.exam-viewer-title { + font-size: 0.9rem; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.exam-viewer-body { + flex: 1; + min-height: 0; + background: #e2e8f0; + position: relative; + height: calc(100vh - 6.5rem); +} + +.exam-viewer-content { + position: absolute; + inset: 0; + overflow: auto; + padding: 1rem; + background: #cbd5e1; + opacity: 0; + pointer-events: none; +} + +.exam-viewer-content.is-ready { + opacity: 1; + pointer-events: auto; +} + +.exam-viewer-loading, +.exam-viewer-error { + display: none; + position: absolute; + inset: 0; + z-index: 2; + align-items: center; + justify-content: center; + padding: 1.5rem; + color: #475569; + font-size: 0.92rem; + background: #e2e8f0; +} + +.exam-viewer-loading.is-active, +.exam-viewer-error.is-active { + display: flex; +} + +.exam-viewer-error { + color: #b91c1c; +} + +.exam-viewer-error.is-active { + display: flex; +} + +.exam-pdf-page-wrap { + display: flex; + justify-content: center; + margin: 0 auto 1rem; +} + +.exam-pdf-page { + display: block; + max-width: 100%; + background: #fff; + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.18); + pointer-events: none; +} + +.exam-view-image { + display: block; + width: 100%; + max-width: 100%; + height: auto; + margin: 0 auto; + background: #fff; + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.18); + pointer-events: none; +} + +.exam-view-text { + max-width: 900px; + margin: 0 auto; + padding: 1rem 1.25rem; + background: #fff; + border-radius: 8px; + white-space: pre-wrap; + word-break: break-word; + font-size: 0.88rem; + line-height: 1.55; + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12); +} + .exam-resource-row { display: flex; align-items: center; diff --git a/client/frontend/src/examViewer.js b/client/frontend/src/examViewer.js new file mode 100644 index 0000000..1570f00 --- /dev/null +++ b/client/frontend/src/examViewer.js @@ -0,0 +1,78 @@ +import * as pdfjsLib from 'pdfjs-dist/build/pdf'; +import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.min.js?url'; + +pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker; + +function decodeBase64(b64) { + const bin = atob(b64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i += 1) bytes[i] = bin.charCodeAt(i); + return bytes; +} + +export function detectExamViewKind(fileName, mime, bytes) { + if (bytes && bytes.length >= 4) { + if (bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46) return 'pdf'; + if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return 'image'; + if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image'; + if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return 'image'; + } + const name = String(fileName || '').toLowerCase(); + const type = String(mime || '').toLowerCase(); + if (type.includes('pdf') || name.endsWith('.pdf')) return 'pdf'; + if (type.startsWith('image/') || /\.(png|jpe?g|gif|webp)$/.test(name)) return 'image'; + if (type.startsWith('text/') || name.endsWith('.txt')) return 'text'; + return 'unsupported'; +} + +export async function renderSecurePdf(container, bytes) { + container.innerHTML = ''; + const pdf = await pdfjsLib.getDocument({ data: bytes }).promise; + const pad = 16; + const width = container.clientWidth || container.parentElement?.clientWidth || 900; + const maxWidth = Math.max(360, width - pad * 2); + + for (let pageNum = 1; pageNum <= pdf.numPages; pageNum += 1) { + const page = await pdf.getPage(pageNum); + const base = page.getViewport({ scale: 1 }); + const scale = Math.min(1.6, maxWidth / base.width); + const viewport = page.getViewport({ scale }); + + const wrap = document.createElement('div'); + wrap.className = 'exam-pdf-page-wrap'; + const canvas = document.createElement('canvas'); + canvas.className = 'exam-pdf-page'; + canvas.width = viewport.width; + canvas.height = viewport.height; + wrap.appendChild(canvas); + container.appendChild(wrap); + + await page.render({ + canvasContext: canvas.getContext('2d'), + viewport, + }).promise; + } +} + +export function renderSecureImage(container, bytes, mime) { + container.innerHTML = ''; + const blob = new Blob([bytes], { type: mime || 'image/png' }); + const img = document.createElement('img'); + img.className = 'exam-view-image'; + img.alt = 'Tài nguyên'; + img.draggable = false; + img.src = URL.createObjectURL(blob); + container.appendChild(img); +} + +export function renderSecureText(container, bytes) { + container.innerHTML = ''; + const pre = document.createElement('pre'); + pre.className = 'exam-view-text'; + pre.textContent = new TextDecoder('utf-8').decode(bytes); + container.appendChild(pre); +} + +export function decodeExamFilePayload(payload) { + return decodeBase64(payload.data || payload); +} diff --git a/client/frontend/src/main.js b/client/frontend/src/main.js index 2953ac3..3f5c87d 100644 --- a/client/frontend/src/main.js +++ b/client/frontend/src/main.js @@ -1,6 +1,13 @@ import './style.css'; import './app.css'; import logoUrl from './assets/logo.jpeg'; +import { + decodeExamFilePayload, + detectExamViewKind, + renderSecureImage, + renderSecurePdf, + renderSecureText, +} from './examViewer.js'; const brandLogoHtml = `Simple Care`; @@ -68,9 +75,12 @@ function init() { }); window.runtime.EventsOn('exam:paper-sent', () => { if (loggedIn) { + examPaperFiles = null; + examPaperFilesRoomId = 0; + lastExamPanelKey = ''; window.go.main.App.GetStats().then((s) => { stats = { ...stats, ...s }; - updateExamPanel(); + updateExamPanel(true); }).catch(console.error); } }); @@ -87,6 +97,7 @@ async function checkLogin() { startStatsTicker(); ensureChatWidget(); updateChatBadge(); + if (stats.monitorMode === 'exam') updateExamPanel(); } else { loggedIn = false; renderLoginPrompt(); @@ -179,36 +190,197 @@ function renderShiftsTable(shifts) { } let examPaperFiles = null; +let examPaperFilesRoomId = 0; +let examPaperFilesLoading = false; +let lastExamPanelKey = ''; +let examViewerObjectUrl = null; + +function wireExamViewerGuards(overlay) { + overlay.addEventListener('contextmenu', (e) => e.preventDefault()); + overlay.addEventListener('keydown', (e) => { + const key = e.key.toLowerCase(); + if ((e.ctrlKey || e.metaKey) && (key === 'p' || key === 's')) { + e.preventDefault(); + } + }); +} + +function ensureExamViewer() { + let overlay = document.getElementById('exam-viewer-overlay'); + if (overlay) return overlay; + overlay = document.createElement('div'); + overlay.id = 'exam-viewer-overlay'; + overlay.className = 'exam-viewer-overlay'; + overlay.innerHTML = ` +
+
+ + +
+
+
+
Đang tải...
+
+
+
+ `; + document.body.appendChild(overlay); + overlay.querySelector('#exam-viewer-close').addEventListener('click', closeExamViewer); + wireExamViewerGuards(overlay); + return overlay; +} + +function setExamViewerLoading(loading) { + const loadingEl = document.getElementById('exam-viewer-loading'); + const contentEl = document.getElementById('exam-viewer-content'); + const errorEl = document.getElementById('exam-viewer-error'); + if (loadingEl) loadingEl.classList.toggle('is-active', loading); + if (contentEl) contentEl.classList.toggle('is-ready', !loading); + if (errorEl) errorEl.classList.remove('is-active'); +} + +function setExamViewerError(message) { + const loadingEl = document.getElementById('exam-viewer-loading'); + const contentEl = document.getElementById('exam-viewer-content'); + const errorEl = document.getElementById('exam-viewer-error'); + if (loadingEl) loadingEl.classList.remove('is-active'); + if (contentEl) contentEl.classList.remove('is-ready'); + if (errorEl) { + errorEl.classList.add('is-active'); + errorEl.textContent = message; + } +} + +function resetExamViewerPanels() { + const loadingEl = document.getElementById('exam-viewer-loading'); + const contentEl = document.getElementById('exam-viewer-content'); + const errorEl = document.getElementById('exam-viewer-error'); + if (loadingEl) loadingEl.classList.remove('is-active'); + if (contentEl) contentEl.classList.remove('is-ready'); + if (errorEl) errorEl.classList.remove('is-active'); +} + +function clearExamViewerContent() { + if (examViewerObjectUrl) { + URL.revokeObjectURL(examViewerObjectUrl); + examViewerObjectUrl = null; + } + const contentEl = document.getElementById('exam-viewer-content'); + if (contentEl) contentEl.innerHTML = ''; +} + +async function showExamViewer(url, title, fileName = '') { + const overlay = ensureExamViewer(); + document.getElementById('exam-viewer-title').textContent = title || 'Xem trong app'; + overlay.classList.add('is-open'); + overlay.focus(); + clearExamViewerContent(); + resetExamViewerPanels(); + setExamViewerLoading(true); + + const contentEl = document.getElementById('exam-viewer-content'); + if (!contentEl) return; + + try { + const payload = await window.go.main.App.LoadExamViewFile(url); + const bytes = decodeExamFilePayload(payload); + const mime = payload?.mime || ''; + const kind = detectExamViewKind(fileName || title, mime, bytes); + + if (kind === 'pdf') { + await new Promise((resolve) => requestAnimationFrame(resolve)); + await renderSecurePdf(contentEl, bytes); + } else if (kind === 'image') { + renderSecureImage(contentEl, bytes, mime); + const img = contentEl.querySelector('img'); + if (img?.src?.startsWith('blob:')) examViewerObjectUrl = img.src; + if (img) { + await new Promise((resolve, reject) => { + if (img.complete) resolve(); + else { + img.onload = () => resolve(); + img.onerror = () => reject(new Error('Không hiển thị được ảnh')); + } + }); + } + } else if (kind === 'text') { + renderSecureText(contentEl, bytes); + } else { + setExamViewerError('Chỉ hỗ trợ xem PDF, ảnh hoặc file text trong app.'); + return; + } + setExamViewerLoading(false); + } catch (e) { + setExamViewerError(e?.message || e || 'Không mở được file'); + } +} + +function closeExamViewer() { + const overlay = document.getElementById('exam-viewer-overlay'); + if (!overlay) return; + overlay.classList.remove('is-open'); + clearExamViewerContent(); + resetExamViewerPanels(); +} function renderExamResources() { - if (!examPaperFiles?.resources?.length) return ''; - const rows = examPaperFiles.resources.map((r) => ` + const ex = stats.exam; + if (!ex?.paperSent) return ''; + + if (examPaperFilesLoading && !examPaperFiles) { + return ` +
+
📎 Tài nguyên kèm đề
+

Đang tải danh sách tài nguyên...

+
+ `; + } + + const resources = Array.isArray(examPaperFiles?.resources) ? examPaperFiles.resources : []; + if (!resources.length) { + return ` +
+
📎 Tài nguyên kèm đề
+

Gói đề này không có tài nguyên đính kèm.

+
+ `; + } + + const rows = resources.map((r) => `
${escapeHtml(r.fileName || 'Tài nguyên')} -
- - -
+
`).join(''); return `
-
Tài nguyên kèm đề
+
📎 Tài nguyên kèm đề (${resources.length})
${rows}
`; } -async function loadExamPaperFiles() { +async function loadExamPaperFiles(force = false) { const ex = stats.exam; if (!ex || stats.monitorMode !== 'exam' || !ex.paperSent) { examPaperFiles = null; + examPaperFilesRoomId = 0; + examPaperFilesLoading = false; return; } + if (!force && examPaperFiles && examPaperFilesRoomId === ex.examRoomId) { + return; + } + examPaperFilesLoading = true; try { examPaperFiles = await window.go.main.App.GetExamPaperFiles(); - } catch { - examPaperFiles = null; + examPaperFilesRoomId = ex.examRoomId; + } catch (err) { + console.error('GetExamPaperFiles failed:', err); + examPaperFiles = { resources: [] }; + examPaperFilesRoomId = ex.examRoomId; + } finally { + examPaperFilesLoading = false; } } @@ -229,7 +401,7 @@ function renderExamPanel() {
📝 ${ex.examName || 'Phòng thi'}
-

Bạn đang trong giờ thi. Đề, link thi và tài nguyên mở trong app — không dùng trình duyệt ngoài.

+

Đề PDF và tài nguyên xem ngay trong app. Link trắc nghiệm mở trang thi — xong thì menu Simple Care → Về trang chính (Ctrl+H).

${paperBtn} ${quizBtn} @@ -241,9 +413,15 @@ function renderExamPanel() { } function wireExamPanel() { + const ex = stats.exam; const paper = document.getElementById('btn-exam-paper'); - if (paper) paper.addEventListener('click', () => { - window.go.main.App.OpenExamPaper().catch((e) => alert(e?.message || e)); + if (paper) paper.addEventListener('click', async () => { + try { + const url = await window.go.main.App.GetExamPaperViewURL(); + showExamViewer(url, ex?.paperTitle || 'Đề thi', ex?.paperTitle || 'de.pdf'); + } catch (e) { + alert(e?.message || e || 'Không mở được đề'); + } }); const quiz = document.getElementById('btn-exam-quiz'); if (quiz) quiz.addEventListener('click', () => { @@ -262,18 +440,11 @@ function wireExamPanel() { alert(e?.message || e || 'Nộp bài thất bại'); } }); - document.querySelectorAll('.btn-exam-res-view').forEach((btn) => { - btn.addEventListener('click', () => { - const id = Number(btn.getAttribute('data-id')); - window.go.main.App.OpenExamResource(id).catch((e) => alert(e?.message || e)); - }); - }); document.querySelectorAll('.btn-exam-res-dl').forEach((btn) => { btn.addEventListener('click', async () => { const id = Number(btn.getAttribute('data-id')); try { - const path = await window.go.main.App.DownloadExamResource(id); - alert(`Đã tải về:\n${path}`); + await window.go.main.App.DownloadExamResource(id); } catch (e) { alert(e?.message || e || 'Tải thất bại'); } @@ -281,14 +452,26 @@ function wireExamPanel() { }); } -async function updateExamPanel() { +async function updateExamPanel(forceReloadFiles = false) { const host = document.getElementById('exam-panel-host'); if (!host) return; - await loadExamPaperFiles(); + if (stats.monitorMode !== 'exam' || !stats.exam) { + host.innerHTML = ''; + lastExamPanelKey = ''; + return; + } + await loadExamPaperFiles(forceReloadFiles); host.innerHTML = renderExamPanel(); wireExamPanel(); } +function examPanelKey() { + const ex = stats.exam; + if (!ex) return ''; + const resCount = Array.isArray(examPaperFiles?.resources) ? examPaperFiles.resources.length : -1; + return `${ex.examRoomId}:${ex.paperSent}:${ex.submitted}:${resCount}:${examPaperFilesLoading}`; +} + function renderDashboard() { if (!studentInfo) return; @@ -629,7 +812,17 @@ function startStatsTicker() { } } - updateExamPanel(); + if (stats.monitorMode === 'exam' && stats.exam) { + const panelKey = examPanelKey(); + if (panelKey !== lastExamPanelKey) { + lastExamPanelKey = panelKey; + updateExamPanel(); + } + } else if (lastExamPanelKey !== '') { + lastExamPanelKey = ''; + const host = document.getElementById('exam-panel-host'); + if (host) host.innerHTML = ''; + } const onlineEl = document.getElementById('clock-online'); if (onlineEl) onlineEl.innerText = formatDuration(stats.onlineSecs || 0); diff --git a/client/frontend/wailsjs/go/main/App.d.ts b/client/frontend/wailsjs/go/main/App.d.ts index 2988c00..4756aec 100644 --- a/client/frontend/wailsjs/go/main/App.d.ts +++ b/client/frontend/wailsjs/go/main/App.d.ts @@ -23,6 +23,8 @@ export function GetStats():Promise>; export function GetStudentInfo():Promise>; +export function LoadExamViewFile(arg1:string):Promise>; + export function Logout():Promise; export function NavigateToLogin():Promise; @@ -33,6 +35,8 @@ export function OpenExamQuiz():Promise; export function OpenExamResource(arg1:number):Promise; +export function ReturnToDashboard():Promise; + export function SendChatMessage(arg1:string):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 index e0d6634..15890ec 100644 --- a/client/frontend/wailsjs/go/main/App.js +++ b/client/frontend/wailsjs/go/main/App.js @@ -46,6 +46,10 @@ export function GetStudentInfo() { return window['go']['main']['App']['GetStudentInfo'](); } +export function LoadExamViewFile(arg1) { + return window['go']['main']['App']['LoadExamViewFile'](arg1); +} + export function Logout() { return window['go']['main']['App']['Logout'](); } @@ -66,6 +70,10 @@ export function OpenExamResource(arg1) { return window['go']['main']['App']['OpenExamResource'](arg1); } +export function ReturnToDashboard() { + return window['go']['main']['App']['ReturnToDashboard'](); +} + export function SendChatMessage(arg1) { return window['go']['main']['App']['SendChatMessage'](arg1); } diff --git a/client/main.go b/client/main.go index 17bc58a..601be59 100644 --- a/client/main.go +++ b/client/main.go @@ -4,8 +4,11 @@ import ( "embed" "github.com/wailsapp/wails/v2" + "github.com/wailsapp/wails/v2/pkg/menu" + "github.com/wailsapp/wails/v2/pkg/menu/keys" "github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options/assetserver" + "github.com/wailsapp/wails/v2/pkg/options/windows" ) //go:embed all:frontend/dist @@ -15,16 +18,26 @@ func main() { // Create an instance of the app structure app := NewApp() + appMenu := menu.NewMenu() + fileMenu := appMenu.AddSubmenu("Simple Care") + fileMenu.AddText("Về trang chính", keys.CmdOrCtrl("h"), func(_ *menu.CallbackData) { + app.ReturnToDashboard() + }) + // Create application with options err := wails.Run(&options.App{ Title: "Simple Care — Rikkei Education", Width: 1024, Height: 768, + Menu: appMenu, AssetServer: &assetserver.Options{ Assets: assets, }, BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1}, OnStartup: app.startup, + Windows: &windows.Options{ + WebviewUserDataPath: app.webviewDataPath, + }, Bind: []interface{}{ app, }, diff --git a/management/src/App.tsx b/management/src/App.tsx index 19653b9..40b72d4 100644 --- a/management/src/App.tsx +++ b/management/src/App.tsx @@ -140,7 +140,7 @@ function App() { onClick={() => navigate('learning')} > - Giám sát & Lịch học + Phòng Học
  • diff --git a/management/src/components/ExamRoomWorkspace.tsx b/management/src/components/ExamRoomWorkspace.tsx index e0dafb6..2aafa6a 100644 --- a/management/src/components/ExamRoomWorkspace.tsx +++ b/management/src/components/ExamRoomWorkspace.tsx @@ -816,53 +816,70 @@ export const ExamRoomWorkspace: React.FC = ({ examId, onBack }) => { )} {studentPickerOpen && ( -
    -
    -
    +
    setStudentPickerOpen(false)}> +
    e.stopPropagation()}> +

    Thêm sinh viên

    -

    - Tìm theo mã lớp, họ tên, mã sinh viên -

    +

    Tìm theo mã lớp, họ tên, mã sinh viên

    - +
    -
    - setSearchQ(e.target.value)} - autoFocus - /> -
    +
    +
    + setSearchQ(e.target.value)} + autoFocus + /> +
    +
    {searchQ.trim() === '' ? ( -

    Gõ ít nhất vài ký tự để tìm.

    +
    + 🔍 +

    Gõ ít nhất vài ký tự để tìm sinh viên.

    +
    ) : searchHits.length === 0 ? ( -

    Không tìm thấy sinh viên phù hợp.

    +
    + 📭 +

    Không tìm thấy sinh viên phù hợp.

    +
    ) : ( -
      - {searchHits.map((h) => { - const added = enrolledIds.has(h.studentRkId); - return ( -
    • -
      -
      {h.studentCode}
      -
      {h.fullName}
      - {(h.classCodes || h.email) && ( -
      - {[h.classCodes, h.email].filter(Boolean).join(' · ')} -
      - )} + searchHits.map((h) => { + const added = enrolledIds.has(h.studentRkId); + const initials = h.fullName + .split(/\s+/) + .filter(Boolean) + .slice(-2) + .map((w) => w[0]?.toUpperCase() || '') + .join(''); + return ( +
      + +
      + {h.fullName} +
      + Mã SV: {h.studentCode} + {h.classCodes ? <> · Lớp: {h.classCodes} : null} + {h.email ? <> · {h.email} : null}
      - -
    • - ); - })} -
    +
    + +
    + ); + }) )}
    diff --git a/management/src/index.css b/management/src/index.css index a95774c..75caa56 100644 --- a/management/src/index.css +++ b/management/src/index.css @@ -4212,6 +4212,21 @@ input:checked + .slider:before { box-shadow: 0 2px 10px rgba(187, 33, 38, 0.08); } +.exam-student-hit-avatar { + width: 42px; + height: 42px; + border-radius: 50%; + background: linear-gradient(135deg, #fff5f5 0%, #fee2e2 100%); + color: var(--accent); + font-weight: 700; + font-size: 0.82rem; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + border: 1px solid rgba(187, 33, 38, 0.12); +} + .exam-student-hit-info { min-width: 0; } diff --git a/server/internal/handlers/handlers_exam.go b/server/internal/handlers/handlers_exam.go index 5ca8dbf..8cbb97a 100644 --- a/server/internal/handlers/handlers_exam.go +++ b/server/internal/handlers/handlers_exam.go @@ -919,12 +919,13 @@ func GetStudentExamPaperFilesHandler(db *gorm.DB) fiber.Handler { resList = append(resList, fiber.Map{ "id": r.ID, "fileName": r.FileName, - "url": fmt.Sprintf("/api/student/exam/download?studentRkId=%d&kind=resource&fileId=%d", studentID, r.ID), + "url": fmt.Sprintf("/api/student/exam/download?studentRkId=%d&kind=resource&fileId=%d&view=1", studentID, r.ID), + "downloadUrl": fmt.Sprintf("/api/student/exam/download?studentRkId=%d&kind=resource&fileId=%d", studentID, r.ID), }) } return c.JSON(fiber.Map{ "paperTitle": info.Paper.Title, - "pdfUrl": fmt.Sprintf("/api/student/exam/download?studentRkId=%d&kind=pdf", studentID), + "pdfUrl": fmt.Sprintf("/api/student/exam/download?studentRkId=%d&kind=pdf&view=1", studentID), "resources": resList, }) } @@ -941,19 +942,61 @@ func DownloadStudentExamFileHandler(db *gorm.DB) fiber.Handler { if info == nil || info.Enrollment.PaperSentAt == nil || info.Paper == nil { return c.Status(403).JSON(fiber.Map{"error": "Không có quyền"}) } + inline := c.Query("view") == "1" || c.Query("inline") == "1" kind := c.Query("kind", "pdf") if kind == "pdf" { - return c.Download(info.Paper.PdfPath, filepath.Base(info.Paper.PdfPath)) + name := filepath.Base(info.Paper.PdfPath) + if inline { + return sendExamFileInline(c, info.Paper.PdfPath, name) + } + return c.Download(info.Paper.PdfPath, name) } fileID, _ := strconv.ParseUint(c.Query("fileId", "0"), 10, 64) var res models.ExamPaperResource if err := db.Where("id = ? AND exam_paper_id = ?", fileID, info.Paper.ID).First(&res).Error; err != nil { return c.Status(404).JSON(fiber.Map{"error": "File không tồn tại"}) } + if inline && examFileCanViewInline(res.FileName) { + return sendExamFileInline(c, res.FilePath, res.FileName) + } return c.Download(res.FilePath, res.FileName) } } +func examFileCanViewInline(fileName string) bool { + switch strings.ToLower(filepath.Ext(fileName)) { + case ".pdf", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".txt": + return true + default: + return false + } +} + +func examViewMimeType(fileName string) string { + switch strings.ToLower(filepath.Ext(fileName)) { + case ".pdf": + return "application/pdf" + case ".png": + return "image/png" + case ".jpg", ".jpeg": + return "image/jpeg" + case ".gif": + return "image/gif" + case ".webp": + return "image/webp" + case ".txt": + return "text/plain; charset=utf-8" + default: + return "application/octet-stream" + } +} + +func sendExamFileInline(c *fiber.Ctx, filePath, fileName string) error { + c.Set("Content-Type", examViewMimeType(fileName)) + c.Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", fileName)) + return c.SendFile(filePath) +} + // POST /api/student/exam/submit — multipart zip func SubmitStudentExamHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { diff --git a/server/uploads/exams/2/submissions/596/20260630_125349_596.zip b/server/uploads/exams/2/submissions/596/20260630_125349_596.zip new file mode 100644 index 0000000..afa3861 Binary files /dev/null and b/server/uploads/exams/2/submissions/596/20260630_125349_596.zip differ diff --git a/server/uploads/exams/3/papers/4/main.pdf b/server/uploads/exams/3/papers/4/main.pdf new file mode 100644 index 0000000..cffc71b Binary files /dev/null and b/server/uploads/exams/3/papers/4/main.pdf differ diff --git a/server/uploads/exams/3/papers/4/resources/Gemini_Generated_Image_5lgiwd5lgiwd5lgi-removebg-preview.png b/server/uploads/exams/3/papers/4/resources/Gemini_Generated_Image_5lgiwd5lgiwd5lgi-removebg-preview.png new file mode 100644 index 0000000..e2391a4 Binary files /dev/null and b/server/uploads/exams/3/papers/4/resources/Gemini_Generated_Image_5lgiwd5lgiwd5lgi-removebg-preview.png differ diff --git a/server/uploads/exams/3/papers/4/resources/gunny-admin-profiles-20260629-150338.json b/server/uploads/exams/3/papers/4/resources/gunny-admin-profiles-20260629-150338.json new file mode 100644 index 0000000..2bfc0ce --- /dev/null +++ b/server/uploads/exams/3/papers/4/resources/gunny-admin-profiles-20260629-150338.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "exportedAt": "2026-06-29T08:03:38.6066807Z", + "profiles": [ + { + "id": "4e08fbd2-15ff-4f37-ba79-44d9f633a248", + "name": "Gà Chill", + "createdAt": "2026-06-27T03:50:45.9698419Z", + "updatedAt": "2026-06-29T01:46:39.7112252Z", + "sshHost": "103.245.236.191", + "sshPort": 22, + "sshUser": "Administrator", + "sshPassword": "pass@123123aA@", + "remoteDbHost": "127.0.0.1", + "remoteDbPort": 1433, + "dbUser": "sa", + "dbPassword": "123123aA@", + "dbTank41": "Db_Tank41", + "dbTank": "Db_Tank", + "dbMember": "Db_Member", + "remoteRequestHost": "127.0.0.1", + "remoteRequestPort": 81, + "resourceUrl": "https://resource.gunnychill.net", + "deployRoot": "C:\\Gunny", + "devRootXml": "E:\\gunny3" + } + ] +} \ No newline at end of file