commit 7a7e0a0d7fc6893f0aa8447dbaba4034b454bf92 Author: cryptocommuniums-afk Date: Sun Feb 1 11:33:59 2026 +0800 feat: homework multi-image upload and crop diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9f83f25 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +DOMAIN=homework.hao.work + +LLM_BASE_URL=https://one.hao.work/v1/chat/completions +LLM_API_KEY=your_api_key_here +LLM_MODEL=qwen3-max +LLM_TIMEOUT_SECONDS=90 +LLM_MAX_RETRIES=3 +UPLOAD_DIR=/data/uploads + +ADMIN_USER=admin +ADMIN_PASS=whoami139 + +BACKEND_PORT=8080 +NEXT_PUBLIC_API_BASE=/api diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ff1fb1f --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env +backend/data/ +backend/homework-backend +frontend/.next/ +frontend/node_modules/ +backend/bin/ diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..9b03097 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,7 @@ +{} { + encode gzip + + reverse_proxy /api/* backend:8080 + reverse_proxy /backend* backend:8080 + reverse_proxy /* frontend:3000 +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..83c3520 --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# 作业工坊 + +前端使用 Next.js,后端使用 Go + SQLite。前端用户无需登录,通过输入用户名管理自己的作业;支持多张图片上传或在线拍照,可选裁剪,直接提交给 LLM 批改并输出 Markdown。 + +## 快速启动(生产) + +1. 确保 `homework.hao.work` DNS 指向部署服务器。 +2. 在根目录配置 `.env`(已提供示例)。 +3. 运行: + +```bash +docker compose up -d --build +``` + +4. Nginx 反向代理(示例已配置在 `/etc/nginx/sites-enabled/homework.hao.work`): + - 前台代理:`http://127.0.0.1:3000` + - 后端代理:`http://127.0.0.1:8080` + - HTTPS 证书由 certbot 自动续期(证书路径 `/etc/letsencrypt/live/homework.hao.work/`)。 + +访问: +- 前台:https://homework.hao.work +- 后台:https://homework.hao.work/backend(默认账号:admin / whoami139) + +## 主要接口 + +- `GET /api/assignments?username=xxx` +- `POST /api/assignments` `multipart/form-data`:`username`、`title`、`images`(可多张,按上传顺序) +- `GET /api/assignments/{id}?username=xxx` +- `DELETE /api/assignments/{id}?username=xxx` + +## 说明 + +- SQLite 数据库存储在 `backend/data/homework.db`,图片存储在 `backend/data/uploads`。 +- LLM 500 错误会自动重试(次数由 `.env` 控制)。 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..52fd614 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,21 @@ +FROM golang:1.22-alpine AS build + +RUN apk add --no-cache gcc musl-dev + +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=1 GOOS=linux go build -o /bin/homework-backend ./ + +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates + +WORKDIR /app +ENV DB_PATH=/data/homework.db + +COPY --from=build /bin/homework-backend /app/server +EXPOSE 8080 + +CMD ["/app/server"] diff --git a/backend/admin.html b/backend/admin.html new file mode 100644 index 0000000..ead8d3d --- /dev/null +++ b/backend/admin.html @@ -0,0 +1,91 @@ + + + + + + 作业后台 + + + +
+

作业管理后台

+

基础账号: admin / whoami139

+
+
+ + + + + + + + + + + + {{range .Assignments}} + + + + + + + + {{end}} + +
ID用户名标题评分创建时间
{{.ID}}{{.Username}}{{.Title}}{{.Score}}{{.CreatedAt.Format "2006-01-02 15:04"}}
+
+ + diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..aa780b6 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,8 @@ +module homework-backend + +go 1.22 + +require ( + github.com/go-chi/chi/v5 v5.1.0 + github.com/mattn/go-sqlite3 v1.14.22 +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..9fc5d04 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,4 @@ +github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= +github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= diff --git a/backend/main.go b/backend/main.go new file mode 100644 index 0000000..6212674 --- /dev/null +++ b/backend/main.go @@ -0,0 +1,884 @@ +package main + +import ( + "bytes" + "context" + "crypto/rand" + "database/sql" + _ "embed" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "html/template" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + _ "github.com/mattn/go-sqlite3" +) + +//go:embed admin.html +var adminHTML string + +const defaultTitlePrefix = "作业" + +var adminTemplate = template.Must(template.New("admin").Parse(adminHTML)) + +type Config struct { + Port string + DBPath string + LLMBaseURL string + LLMAPIKey string + LLMModel string + LLMTimeout time.Duration + LLMMaxRetries int + UploadDir string + AdminUser string + AdminPass string +} + +type Assignment struct { + ID int64 `json:"id"` + Username string `json:"username"` + Title string `json:"title"` + OCRText string `json:"ocrText"` + Markdown string `json:"markdown"` + Feedback string `json:"feedback"` + Score int `json:"score"` + ImagePath string `json:"imagePath"` + ImagePaths []string `json:"imagePaths"` + CreatedAt time.Time `json:"createdAt"` +} + +type createAssignmentRequest struct { + Username string `json:"username"` + Title string `json:"title"` + OCRText string `json:"ocrText"` +} + +type LLMResult struct { + Markdown string `json:"markdown"` + Feedback string `json:"feedback"` + Score int `json:"score"` +} + +type LLMImage struct { + MimeType string + Base64 string +} + +type LLMClient struct { + baseURL string + apiKey string + model string + httpClient *http.Client + maxRetries int +} + +func main() { + cfg := loadConfig() + + db, err := initDB(cfg.DBPath) + if err != nil { + log.Fatalf("db init failed: %v", err) + } + defer db.Close() + + if err := os.MkdirAll(cfg.UploadDir, 0o755); err != nil { + log.Fatalf("upload dir init failed: %v", err) + } + + llmClient := &LLMClient{ + baseURL: cfg.LLMBaseURL, + apiKey: cfg.LLMAPIKey, + model: cfg.LLMModel, + httpClient: &http.Client{ + Timeout: cfg.LLMTimeout, + }, + maxRetries: cfg.LLMMaxRetries, + } + + router := chi.NewRouter() + router.Use(middleware.RequestID) + router.Use(middleware.RealIP) + router.Use(middleware.Logger) + router.Use(middleware.Recoverer) + router.Use(corsMiddleware) + + router.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + + router.Route("/api", func(r chi.Router) { + r.Get("/assignments", handleListAssignments(db)) + r.Get("/assignments/{id}", handleGetAssignment(db)) + r.Get("/assignments/{id}/image", handleGetAssignmentImage(db, cfg.UploadDir)) + r.Post("/assignments", handleCreateAssignment(db, llmClient, cfg.UploadDir)) + r.Delete("/assignments/{id}", handleDeleteAssignment(db)) + }) + + router.Route("/backend", func(r chi.Router) { + r.Use(basicAuth(cfg.AdminUser, cfg.AdminPass)) + r.Get("/", handleAdminDashboard(db)) + }) + + router.With(basicAuth(cfg.AdminUser, cfg.AdminPass)).Get("/backend", handleAdminDashboard(db)) + + addr := ":" + cfg.Port + log.Printf("backend listening on %s", addr) + if err := http.ListenAndServe(addr, router); err != nil { + log.Fatalf("server error: %v", err) + } +} + +func loadConfig() Config { + timeoutSeconds := getEnvAsInt("LLM_TIMEOUT_SECONDS", 90) + maxRetries := getEnvAsInt("LLM_MAX_RETRIES", 3) + + return Config{ + Port: getEnv("BACKEND_PORT", "8080"), + DBPath: getEnv("DB_PATH", "/data/homework.db"), + LLMBaseURL: getEnv("LLM_BASE_URL", ""), + LLMAPIKey: getEnv("LLM_API_KEY", ""), + LLMModel: getEnv("LLM_MODEL", "qwen3-max"), + LLMTimeout: time.Duration(timeoutSeconds) * time.Second, + LLMMaxRetries: maxRetries, + UploadDir: getEnv("UPLOAD_DIR", "/data/uploads"), + AdminUser: getEnv("ADMIN_USER", "admin"), + AdminPass: getEnv("ADMIN_PASS", "whoami139"), + } +} + +func initDB(path string) (*sql.DB, error) { + db, err := sql.Open("sqlite3", path) + if err != nil { + return nil, err + } + if _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS assignments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + title TEXT NOT NULL, + ocr_text TEXT, + markdown TEXT, + feedback TEXT, + score INTEGER, + image_path TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + `); err != nil { + return nil, err + } + if err := ensureColumn(db, "assignments", "image_path", "TEXT"); err != nil { + return nil, err + } + return db, nil +} + +func handleListAssignments(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + username := strings.TrimSpace(r.URL.Query().Get("username")) + if username == "" { + writeError(w, http.StatusBadRequest, "username is required") + return + } + + rows, err := db.Query(` + SELECT id, username, title, score, image_path, created_at + FROM assignments + WHERE username = ? + ORDER BY created_at DESC + `, username) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to query assignments") + return + } + defer rows.Close() + + assignments := make([]Assignment, 0) + for rows.Next() { + var a Assignment + var rawImagePath string + if err := rows.Scan(&a.ID, &a.Username, &a.Title, &a.Score, &rawImagePath, &a.CreatedAt); err != nil { + writeError(w, http.StatusInternalServerError, "failed to read assignments") + return + } + setImageFields(&a, rawImagePath) + assignments = append(assignments, a) + } + + writeJSON(w, http.StatusOK, assignments) + } +} + +func handleGetAssignment(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + username := strings.TrimSpace(r.URL.Query().Get("username")) + if username == "" { + writeError(w, http.StatusBadRequest, "username is required") + return + } + + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid assignment id") + return + } + + var a Assignment + var rawImagePath string + row := db.QueryRow(` + SELECT id, username, title, ocr_text, markdown, feedback, score, image_path, created_at + FROM assignments + WHERE id = ? AND username = ? + LIMIT 1 + `, id, username) + if err := row.Scan(&a.ID, &a.Username, &a.Title, &a.OCRText, &a.Markdown, &a.Feedback, &a.Score, &rawImagePath, &a.CreatedAt); err != nil { + if errors.Is(err, sql.ErrNoRows) { + writeError(w, http.StatusNotFound, "assignment not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to query assignment") + return + } + setImageFields(&a, rawImagePath) + + writeJSON(w, http.StatusOK, a) + } +} + +func handleGetAssignmentImage(db *sql.DB, uploadDir string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + username := strings.TrimSpace(r.URL.Query().Get("username")) + if username == "" { + writeError(w, http.StatusBadRequest, "username is required") + return + } + + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid assignment id") + return + } + + var imagePath sql.NullString + row := db.QueryRow(` + SELECT image_path + FROM assignments + WHERE id = ? AND username = ? + LIMIT 1 + `, id, username) + if err := row.Scan(&imagePath); err != nil { + if errors.Is(err, sql.ErrNoRows) { + writeError(w, http.StatusNotFound, "assignment not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to query image") + return + } + + if !imagePath.Valid || strings.TrimSpace(imagePath.String) == "" { + writeError(w, http.StatusNotFound, "image not found") + return + } + + paths := parseImagePaths(imagePath.String) + if len(paths) == 0 { + writeError(w, http.StatusNotFound, "image not found") + return + } + + index := 0 + if indexStr := r.URL.Query().Get("index"); indexStr != "" { + if parsed, err := strconv.Atoi(indexStr); err == nil { + index = parsed + } + } + if index < 0 || index >= len(paths) { + writeError(w, http.StatusNotFound, "image not found") + return + } + + absImage, err := filepath.Abs(paths[index]) + if err != nil { + writeError(w, http.StatusInternalServerError, "invalid image path") + return + } + absUpload, err := filepath.Abs(uploadDir) + if err != nil { + writeError(w, http.StatusInternalServerError, "invalid upload dir") + return + } + if !strings.HasPrefix(absImage, absUpload+string(os.PathSeparator)) && absImage != absUpload { + writeError(w, http.StatusForbidden, "invalid image path") + return + } + + if _, err := os.Stat(absImage); err != nil { + writeError(w, http.StatusNotFound, "image not found") + return + } + + w.Header().Set("Cache-Control", "private, max-age=3600") + http.ServeFile(w, r, absImage) + } +} + +func handleCreateAssignment(db *sql.DB, llm *LLMClient, uploadDir string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + contentType := r.Header.Get("Content-Type") + if strings.HasPrefix(contentType, "multipart/form-data") { + handleCreateAssignmentWithImage(db, llm, uploadDir, w, r) + return + } + + var req createAssignmentRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json") + return + } + + req.Username = strings.TrimSpace(req.Username) + req.Title = strings.TrimSpace(req.Title) + req.OCRText = strings.TrimSpace(req.OCRText) + + if req.Username == "" { + writeError(w, http.StatusBadRequest, "username is required") + return + } + if req.OCRText == "" { + writeError(w, http.StatusBadRequest, "ocrText is required") + return + } + if req.Title == "" { + req.Title = fmt.Sprintf("%s %s", defaultTitlePrefix, time.Now().Format("2006-01-02 15:04")) + } + + markdown, feedback, score, err := llm.FormatAndGrade(r.Context(), req.Title, req.OCRText) + if err != nil { + writeError(w, http.StatusBadGateway, fmt.Sprintf("llm failed: %v", err)) + return + } + + result, err := db.Exec(` + INSERT INTO assignments (username, title, ocr_text, markdown, feedback, score, image_path) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, req.Username, req.Title, req.OCRText, markdown, feedback, score, "[]") + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to save assignment") + return + } + + id, _ := result.LastInsertId() + assignment := Assignment{ + ID: id, + Username: req.Username, + Title: req.Title, + OCRText: req.OCRText, + Markdown: markdown, + Feedback: feedback, + Score: score, + ImagePath: "", + ImagePaths: []string{}, + CreatedAt: time.Now(), + } + + writeJSON(w, http.StatusOK, assignment) + } +} + +func handleCreateAssignmentWithImage(db *sql.DB, llm *LLMClient, uploadDir string, w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 50<<20) + if err := r.ParseMultipartForm(60 << 20); err != nil { + writeError(w, http.StatusBadRequest, "invalid multipart form") + return + } + + username := strings.TrimSpace(r.FormValue("username")) + title := strings.TrimSpace(r.FormValue("title")) + if username == "" { + writeError(w, http.StatusBadRequest, "username is required") + return + } + if title == "" { + title = fmt.Sprintf("%s %s", defaultTitlePrefix, time.Now().Format("2006-01-02 15:04")) + } + + files := r.MultipartForm.File["images"] + if len(files) == 0 { + files = r.MultipartForm.File["image"] + } + if len(files) == 0 { + writeError(w, http.StatusBadRequest, "image is required") + return + } + + if err := os.MkdirAll(uploadDir, 0o755); err != nil { + writeError(w, http.StatusInternalServerError, "failed to prepare upload dir") + return + } + + imagePaths := make([]string, 0, len(files)) + images := make([]LLMImage, 0, len(files)) + for _, header := range files { + file, err := header.Open() + if err != nil { + writeError(w, http.StatusBadRequest, "failed to read image") + return + } + + data, err := io.ReadAll(file) + file.Close() + if err != nil || len(data) == 0 { + writeError(w, http.StatusBadRequest, "failed to read image") + return + } + + mimeType := header.Header.Get("Content-Type") + if mimeType == "" { + mimeType = http.DetectContentType(data) + } + if !strings.HasPrefix(mimeType, "image/") { + writeError(w, http.StatusBadRequest, "invalid image type") + return + } + + filename := fmt.Sprintf("%s_%s%s", time.Now().Format("20060102_150405"), randomToken(6), extFromMime(mimeType)) + imagePath := filepath.Join(uploadDir, filename) + if err := os.WriteFile(imagePath, data, 0o644); err != nil { + writeError(w, http.StatusInternalServerError, "failed to save image") + return + } + + imagePaths = append(imagePaths, imagePath) + images = append(images, LLMImage{ + MimeType: mimeType, + Base64: base64.StdEncoding.EncodeToString(data), + }) + } + + markdown, feedback, score, err := llm.FormatAndGradeFromImages(r.Context(), title, images) + if err != nil { + writeError(w, http.StatusBadGateway, fmt.Sprintf("llm failed: %v", err)) + return + } + + imagePathsJSON, _ := json.Marshal(imagePaths) + + result, err := db.Exec(` + INSERT INTO assignments (username, title, ocr_text, markdown, feedback, score, image_path) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, username, title, "", markdown, feedback, score, string(imagePathsJSON)) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to save assignment") + return + } + + id, _ := result.LastInsertId() + assignment := Assignment{ + ID: id, + Username: username, + Title: title, + OCRText: "", + Markdown: markdown, + Feedback: feedback, + Score: score, + ImagePath: firstPath(imagePaths), + ImagePaths: imagePaths, + CreatedAt: time.Now(), + } + + writeJSON(w, http.StatusOK, assignment) +} + +func handleDeleteAssignment(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + username := strings.TrimSpace(r.URL.Query().Get("username")) + if username == "" { + writeError(w, http.StatusBadRequest, "username is required") + return + } + + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid assignment id") + return + } + + res, err := db.Exec(`DELETE FROM assignments WHERE id = ? AND username = ?`, id, username) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to delete assignment") + return + } + affected, _ := res.RowsAffected() + if affected == 0 { + writeError(w, http.StatusNotFound, "assignment not found") + return + } + + writeJSON(w, http.StatusOK, map[string]any{"deleted": id}) + } +} + +func handleAdminDashboard(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + rows, err := db.Query(` + SELECT id, username, title, score, created_at + FROM assignments + ORDER BY created_at DESC + LIMIT 200 + `) + if err != nil { + http.Error(w, "failed to load assignments", http.StatusInternalServerError) + return + } + defer rows.Close() + + assignments := make([]Assignment, 0) + for rows.Next() { + var a Assignment + if err := rows.Scan(&a.ID, &a.Username, &a.Title, &a.Score, &a.CreatedAt); err != nil { + http.Error(w, "failed to read assignments", http.StatusInternalServerError) + return + } + assignments = append(assignments, a) + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := adminTemplate.Execute(w, map[string]any{"Assignments": assignments}); err != nil { + http.Error(w, "failed to render", http.StatusInternalServerError) + return + } + } +} + +func (c *LLMClient) FormatAndGrade(ctx context.Context, title, ocrText string) (string, string, int, error) { + if c.baseURL == "" || c.apiKey == "" { + return "", "", 0, errors.New("LLM_BASE_URL or LLM_API_KEY missing") + } + + systemPrompt := "你是一位细致的老师。请把 OCR 文字整理成清晰的 Markdown,保持原有题号、分点、段落与格式。然后给出作业批改意见。只输出 JSON,包含 markdown、feedback、score(0-100整数)。不要加代码块或多余文字。" + userPrompt := fmt.Sprintf("标题: %s\n\nOCR文本:\n%s", title, ocrText) + + payload := map[string]any{ + "model": c.model, + "messages": []map[string]string{ + {"role": "system", "content": systemPrompt}, + {"role": "user", "content": userPrompt}, + }, + "stream": false, + } + + content, err := c.chatCompletionWithRetry(ctx, payload) + if err != nil { + return "", "", 0, err + } + + result := LLMResult{} + if err := decodeLLMResult(content, &result); err != nil { + fallback := strings.TrimSpace(content) + if fallback == "" { + fallback = ocrText + } + return fallback, "LLM 返回无法解析为 JSON,已返回原始内容。", 0, nil + } + + markdown := strings.TrimSpace(result.Markdown) + feedback := strings.TrimSpace(result.Feedback) + score := result.Score + if markdown == "" { + markdown = ocrText + } + + return markdown, feedback, score, nil +} + +func (c *LLMClient) FormatAndGradeFromImage(ctx context.Context, title, mimeType, imageBase64 string) (string, string, int, error) { + return c.FormatAndGradeFromImages(ctx, title, []LLMImage{{MimeType: mimeType, Base64: imageBase64}}) +} + +func (c *LLMClient) FormatAndGradeFromImages(ctx context.Context, title string, images []LLMImage) (string, string, int, error) { + if c.baseURL == "" || c.apiKey == "" { + return "", "", 0, errors.New("LLM_BASE_URL or LLM_API_KEY missing") + } + if len(images) == 0 { + return "", "", 0, errors.New("no images provided") + } + + systemPrompt := "你是一位细致的老师。请根据作业图片识别内容,整理为清晰的 Markdown,保持题号、分点、段落与格式。然后给出作业批改意见。只输出 JSON,包含 markdown、feedback、score(0-100整数)。不要加代码块或多余文字。" + userText := fmt.Sprintf("标题: %s\n以下图片按上传顺序依次为第1页、第2页……请综合批改。", title) + + contentParts := make([]map[string]any, 0, len(images)+1) + contentParts = append(contentParts, map[string]any{"type": "text", "text": userText}) + for _, img := range images { + contentParts = append(contentParts, map[string]any{ + "type": "image_url", + "image_url": map[string]string{ + "url": fmt.Sprintf("data:%s;base64,%s", img.MimeType, img.Base64), + }, + }) + } + + payload := map[string]any{ + "model": c.model, + "messages": []map[string]any{ + {"role": "system", "content": systemPrompt}, + {"role": "user", "content": contentParts}, + }, + "stream": false, + } + + content, err := c.chatCompletionWithRetry(ctx, payload) + if err != nil { + return "", "", 0, err + } + + result := LLMResult{} + if err := decodeLLMResult(content, &result); err != nil { + return "", "LLM 返回无法解析为 JSON。", 0, nil + } + + markdown := strings.TrimSpace(result.Markdown) + feedback := strings.TrimSpace(result.Feedback) + score := result.Score + if markdown == "" { + markdown = "未识别到有效内容。" + } + + return markdown, feedback, score, nil +} + +func (c *LLMClient) chatCompletionWithRetry(ctx context.Context, payload map[string]any) (string, error) { + var lastErr error + + for attempt := 0; attempt <= c.maxRetries; attempt++ { + if attempt > 0 { + sleep := time.Duration(1<= 500 { + lastErr = fmt.Errorf("llm server error: %d", status) + continue + } + return content, nil + } + + lastErr = err + if status >= 500 || status == 0 { + continue + } + break + } + + if lastErr == nil { + lastErr = errors.New("llm request failed") + } + return "", lastErr +} + +func (c *LLMClient) chatCompletion(ctx context.Context, payload map[string]any) (string, int, error) { + body, err := json.Marshal(payload) + if err != nil { + return "", 0, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL, bytes.NewReader(body)) + if err != nil { + return "", 0, err + } + req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", 0, err + } + defer resp.Body.Close() + + var result struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", resp.StatusCode, err + } + if len(result.Choices) == 0 { + return "", resp.StatusCode, errors.New("llm response missing choices") + } + + return result.Choices[0].Message.Content, resp.StatusCode, nil +} + +func decodeLLMResult(content string, out *LLMResult) error { + content = strings.TrimSpace(content) + if content == "" { + return errors.New("empty content") + } + + if json.Unmarshal([]byte(content), out) == nil { + return nil + } + + start := strings.Index(content, "{") + end := strings.LastIndex(content, "}") + if start == -1 || end == -1 || end <= start { + return errors.New("json not found") + } + + snippet := content[start : end+1] + if err := json.Unmarshal([]byte(snippet), out); err != nil { + return err + } + return nil +} + +func ensureColumn(db *sql.DB, table, column, columnType string) error { + rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s);", table)) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var cid int + var name string + var ctype string + var notnull int + var dfltValue sql.NullString + var pk int + if err := rows.Scan(&cid, &name, &ctype, ¬null, &dfltValue, &pk); err != nil { + return err + } + if name == column { + return nil + } + } + + _, err = db.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s;", table, column, columnType)) + return err +} + +func parseImagePaths(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + if strings.HasPrefix(raw, "[") { + var paths []string + if json.Unmarshal([]byte(raw), &paths) == nil { + return paths + } + } + return []string{raw} +} + +func setImageFields(a *Assignment, raw string) { + paths := parseImagePaths(raw) + a.ImagePaths = paths + a.ImagePath = firstPath(paths) +} + +func firstPath(paths []string) string { + if len(paths) == 0 { + return "" + } + return paths[0] +} + +func randomToken(length int) string { + const charset = "abcdefghijklmnopqrstuvwxyz0123456789" + buf := make([]byte, length) + if _, err := rand.Read(buf); err != nil { + return fmt.Sprintf("%d", time.Now().UnixNano()) + } + for i, b := range buf { + buf[i] = charset[int(b)%len(charset)] + } + return string(buf) +} + +func extFromMime(mimeType string) string { + switch mimeType { + case "image/png": + return ".png" + case "image/webp": + return ".webp" + case "image/gif": + return ".gif" + case "image/jpeg": + return ".jpg" + default: + return ".img" + } +} + +func basicAuth(user, pass string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + if !ok || username != user || password != pass { + w.Header().Set("WWW-Authenticate", `Basic realm="Homework Admin"`) + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + next.ServeHTTP(w, r) + }) + } +} + +func corsMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} + +func writeJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(payload); err != nil { + log.Printf("failed to write json: %v", err) + } +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{"error": message}) +} + +func getEnv(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} + +func getEnvAsInt(key string, fallback int) int { + if value := os.Getenv(key); value != "" { + if parsed, err := strconv.Atoi(value); err == nil { + return parsed + } + } + return fallback +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2e88599 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +services: + backend: + build: ./backend + restart: unless-stopped + env_file: ./.env + volumes: + - ./backend/data:/data + ports: + - '127.0.0.1:8080:8080' + + frontend: + build: ./frontend + restart: unless-stopped + env_file: ./.env + ports: + - '127.0.0.1:3000:3000' diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..ca6405c --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,22 @@ +FROM node:20-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm install --no-audit --no-fund + +FROM node:20-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +ENV NEXT_PUBLIC_API_BASE=/api +RUN npm run build + +FROM node:20-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production + +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static + +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 0000000..8481ea6 --- /dev/null +++ b/frontend/app/globals.css @@ -0,0 +1,384 @@ +:root { + --ink: #1f1d1a; + --paper: #f8f1e5; + --paper-2: #f2e6d2; + --accent: #f05a28; + --accent-dark: #c03d12; + --teal: #0f4c5c; + --shadow: rgba(31, 29, 26, 0.18); + --radius: 22px; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: radial-gradient(1200px 600px at 10% 10%, rgba(240, 90, 40, 0.08), transparent 60%), + radial-gradient(1000px 500px at 90% 20%, rgba(15, 76, 92, 0.12), transparent 60%), + var(--paper); + color: var(--ink); + font-family: var(--font-body), serif; + line-height: 1.6; + position: relative; + overflow-x: hidden; +} + +body::before { + content: ""; + position: fixed; + inset: 0; + background-image: repeating-linear-gradient( + 0deg, + rgba(31, 29, 26, 0.03), + rgba(31, 29, 26, 0.03) 1px, + transparent 1px, + transparent 24px + ), + repeating-linear-gradient( + 90deg, + rgba(31, 29, 26, 0.02), + rgba(31, 29, 26, 0.02) 1px, + transparent 1px, + transparent 24px + ); + mix-blend-mode: multiply; + pointer-events: none; + opacity: 0.4; +} + +main { + position: relative; + padding: 64px 8vw 80px; +} + +h1, h2, h3 { + font-family: var(--font-display), serif; + margin: 0; + letter-spacing: -0.01em; +} + +a { + color: inherit; + text-decoration: none; +} + +button { + font-family: var(--font-mono), monospace; +} + +.page { + display: flex; + flex-direction: column; + gap: 32px; +} + +.hero { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr); + gap: 24px; + align-items: center; + padding: 36px 40px; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.85), rgba(246, 232, 210, 0.75)); + border-radius: var(--radius); + box-shadow: 0 30px 80px var(--shadow); + border: 1px solid rgba(31, 29, 26, 0.08); +} + +.hero-title { + font-size: clamp(2.4rem, 4vw, 3.4rem); +} + +.hero p { + margin: 12px 0 0; + font-size: 1.05rem; + color: rgba(31, 29, 26, 0.78); +} + +.hero .stamp { + justify-self: end; + border: 2px dashed var(--accent); + padding: 16px 20px; + border-radius: 18px; + transform: rotate(-2deg); + background: rgba(240, 90, 40, 0.08); + font-family: var(--font-mono), monospace; + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 0.75rem; +} + +.grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 24px; +} + +.card { + background: rgba(255, 255, 255, 0.85); + border-radius: var(--radius); + padding: 24px; + border: 1px solid rgba(31, 29, 26, 0.08); + box-shadow: 0 20px 50px rgba(31, 29, 26, 0.12); + backdrop-filter: blur(8px); +} + +.card header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.section-title { + font-size: 1.4rem; +} + +.tag { + font-family: var(--font-mono), monospace; + font-size: 0.72rem; + padding: 6px 10px; + border-radius: 999px; + border: 1px solid rgba(31, 29, 26, 0.1); + background: rgba(15, 76, 92, 0.08); + color: var(--teal); +} + +.username-card { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; +} + +.input { + flex: 1 1 220px; + padding: 12px 14px; + border-radius: 14px; + border: 1px solid rgba(31, 29, 26, 0.2); + background: #fffefb; + font-size: 0.98rem; + font-family: var(--font-body), serif; +} + +textarea.input { + resize: vertical; +} + +.button { + padding: 12px 18px; + border-radius: 14px; + border: none; + background: var(--accent); + color: #fff; + font-weight: 600; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.button.secondary { + background: rgba(15, 76, 92, 0.12); + color: var(--teal); + border: 1px solid rgba(15, 76, 92, 0.3); +} + +.button.ghost { + background: transparent; + border: 1px dashed rgba(31, 29, 26, 0.3); + color: var(--ink); +} + +.button:disabled { + opacity: 0.6; + cursor: not-allowed; + box-shadow: none; +} + +.button:not(:disabled):hover { + transform: translateY(-2px); + box-shadow: 0 10px 24px rgba(240, 90, 40, 0.25); +} + +.upload-area { + border: 2px dashed rgba(31, 29, 26, 0.25); + border-radius: 18px; + padding: 18px; + display: grid; + gap: 12px; + background: rgba(255, 255, 255, 0.7); +} + +.preview { + width: 100%; + border-radius: 16px; + border: 1px solid rgba(31, 29, 26, 0.1); + object-fit: cover; + max-height: 260px; +} + +.thumbnail-grid { + display: grid; + gap: 12px; +} + +.thumb-card { + background: rgba(255, 255, 255, 0.7); + border: 1px solid rgba(31, 29, 26, 0.08); + border-radius: 16px; + padding: 12px; + display: grid; + gap: 10px; +} + +.thumb-actions { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.thumb-buttons { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.muted { + color: rgba(31, 29, 26, 0.65); + font-size: 0.92rem; +} + +.assignments { + display: grid; + gap: 14px; + max-height: 520px; + overflow: auto; + padding-right: 6px; +} + +.image-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; +} + +.image-card { + display: grid; + gap: 8px; +} + +.crop-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + z-index: 1000; +} + +.crop-modal { + width: min(90vw, 720px); + background: #fff; + border-radius: 18px; + padding: 16px; + display: grid; + gap: 12px; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.2); +} + +.crop-area { + position: relative; + width: 100%; + height: 360px; + border-radius: 12px; + overflow: hidden; + background: #111; +} + +.crop-controls { + display: grid; + gap: 10px; +} + +.crop-buttons { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.assignment-item { + border: 1px solid rgba(31, 29, 26, 0.08); + padding: 14px 16px; + border-radius: 16px; + background: rgba(255, 255, 255, 0.7); + cursor: pointer; + transition: border 0.2s ease, transform 0.2s ease; +} + +.assignment-item.active { + border-color: var(--accent); + transform: translateY(-2px); + box-shadow: 0 12px 22px rgba(240, 90, 40, 0.18); +} + +.assignment-meta { + display: flex; + justify-content: space-between; + align-items: center; + font-family: var(--font-mono), monospace; + font-size: 0.75rem; + color: rgba(31, 29, 26, 0.6); +} + +.markdown { + background: #fffefb; + border-radius: 18px; + padding: 20px; + border: 1px solid rgba(31, 29, 26, 0.08); +} + +.markdown h1, .markdown h2, .markdown h3 { + margin-top: 1em; +} + +.markdown code { + font-family: var(--font-mono), monospace; + background: rgba(31, 29, 26, 0.08); + padding: 2px 6px; + border-radius: 6px; +} + +.feedback { + border-left: 4px solid var(--accent); + padding: 12px 16px; + background: rgba(240, 90, 40, 0.08); + border-radius: 12px; +} + +.camera { + display: grid; + gap: 12px; +} + +.camera video { + width: 100%; + border-radius: 16px; + border: 1px solid rgba(31, 29, 26, 0.1); +} + +@media (max-width: 960px) { + .hero { + grid-template-columns: 1fr; + } + .hero .stamp { + justify-self: start; + } + .grid { + grid-template-columns: 1fr; + } +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000..96dfb0b --- /dev/null +++ b/frontend/app/layout.tsx @@ -0,0 +1,39 @@ +import "./globals.css"; +import { Fraunces, Source_Serif_4, IBM_Plex_Mono } from "next/font/google"; + +const display = Fraunces({ + subsets: ["latin"], + variable: "--font-display", + weight: ["400", "500", "600", "700"], +}); + +const body = Source_Serif_4({ + subsets: ["latin"], + variable: "--font-body", + weight: ["400", "500", "600"], +}); + +const mono = IBM_Plex_Mono({ + subsets: ["latin"], + variable: "--font-mono", + weight: ["400", "500"], +}); + +export const metadata = { + title: "作业工坊", + description: "拍照或上传作业,自动转为 Markdown 并批改", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100644 index 0000000..97cd43f --- /dev/null +++ b/frontend/app/page.tsx @@ -0,0 +1,638 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import clsx from "clsx"; +import ReactMarkdown from "react-markdown"; +import Cropper from "react-easy-crop"; + +const apiBase = process.env.NEXT_PUBLIC_API_BASE || "/api"; + +type AssignmentSummary = { + id: number; + username: string; + title: string; + score: number; + imagePath?: string; + imagePaths?: string[]; + createdAt: string; +}; + +type AssignmentDetail = AssignmentSummary & { + ocrText?: string; + markdown: string; + feedback: string; + imagePath?: string; + imagePaths?: string[]; +}; + +type ImageItem = { + id: string; + name: string; + file: Blob; + previewUrl: string; + croppedBlob?: Blob; + croppedUrl?: string; +}; + +export default function HomePage() { + const [username, setUsername] = useState(""); + const [savedUser, setSavedUser] = useState(""); + const [assignments, setAssignments] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [selected, setSelected] = useState(null); + + const [title, setTitle] = useState(""); + const [images, setImages] = useState([]); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(""); + const [info, setInfo] = useState(""); + + const videoRef = useRef(null); + const streamRef = useRef(null); + const imagesRef = useRef([]); + + const [cropTarget, setCropTarget] = useState(null); + const [crop, setCrop] = useState({ x: 0, y: 0 }); + const [zoom, setZoom] = useState(1); + const [croppedAreaPixels, setCroppedAreaPixels] = useState<{ + x: number; + y: number; + width: number; + height: number; + } | null>(null); + + useEffect(() => { + const stored = window.localStorage.getItem("hw_username"); + if (stored) { + setUsername(stored); + setSavedUser(stored); + void loadAssignments(stored); + } + return () => { + if (streamRef.current) { + streamRef.current.getTracks().forEach((track) => track.stop()); + } + imagesRef.current.forEach((item) => { + URL.revokeObjectURL(item.previewUrl); + if (item.croppedUrl) URL.revokeObjectURL(item.croppedUrl); + }); + }; + }, []); + + useEffect(() => { + imagesRef.current = images; + }, [images]); + + const loadAssignments = async (user: string) => { + setError(""); + try { + const res = await fetch(`${apiBase}/assignments?username=${encodeURIComponent(user)}`); + if (!res.ok) throw new Error("加载作业失败"); + const data = (await res.json()) as AssignmentSummary[]; + setAssignments(data); + if (data.length > 0) { + setSelectedId(data[0].id); + await loadAssignmentDetail(data[0].id, user); + } + } catch (err) { + setError(err instanceof Error ? err.message : "加载失败"); + } + }; + + const loadAssignmentDetail = async (id: number, user = savedUser) => { + setError(""); + try { + const res = await fetch( + `${apiBase}/assignments/${id}?username=${encodeURIComponent(user)}` + ); + if (!res.ok) throw new Error("加载详情失败"); + const data = (await res.json()) as AssignmentDetail; + setSelected(data); + } catch (err) { + setError(err instanceof Error ? err.message : "加载失败"); + } + }; + + const handleSaveUser = async () => { + if (!username.trim()) return; + const user = username.trim(); + setSavedUser(user); + window.localStorage.setItem("hw_username", user); + await loadAssignments(user); + }; + + const createId = () => + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(16).slice(2)}`; + + const revokeItemUrls = (item: ImageItem) => { + URL.revokeObjectURL(item.previewUrl); + if (item.croppedUrl) URL.revokeObjectURL(item.croppedUrl); + }; + + const createImageItem = (file: Blob, name: string): ImageItem => ({ + id: createId(), + name, + file, + previewUrl: URL.createObjectURL(file), + }); + + const handleFileChange = (event: React.ChangeEvent) => { + setError(""); + const files = Array.from(event.target.files ?? []); + if (files.length === 0) return; + const newItems = files.map((file) => + createImageItem(file, file.name || `upload-${Date.now()}.png`) + ); + setImages((prev) => [...prev, ...newItems]); + setInfo(`已选择 ${files.length} 张图片,可继续追加上传。`); + event.target.value = ""; + }; + + const clearImages = () => { + setImages((prev) => { + prev.forEach(revokeItemUrls); + return []; + }); + setCropTarget(null); + setCroppedAreaPixels(null); + }; + + const removeImage = (id: string) => { + setImages((prev) => { + const target = prev.find((item) => item.id === id); + if (target) revokeItemUrls(target); + return prev.filter((item) => item.id !== id); + }); + if (cropTarget?.id === id) { + setCropTarget(null); + setCroppedAreaPixels(null); + } + }; + + const onCropComplete = useCallback((_: unknown, areaPixels: any) => { + setCroppedAreaPixels(areaPixels); + }, []); + + const createImage = (url: string) => + new Promise((resolve, reject) => { + const image = new Image(); + image.addEventListener("load", () => resolve(image)); + image.addEventListener("error", (err) => reject(err)); + image.setAttribute("crossOrigin", "anonymous"); + image.src = url; + }); + + const getCroppedImg = async (imageSrc: string, cropArea: { x: number; y: number; width: number; height: number }) => { + const image = await createImage(imageSrc); + const canvas = document.createElement("canvas"); + canvas.width = cropArea.width; + canvas.height = cropArea.height; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("Canvas not supported"); + + ctx.drawImage( + image, + cropArea.x, + cropArea.y, + cropArea.width, + cropArea.height, + 0, + 0, + cropArea.width, + cropArea.height + ); + + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (!blob) { + reject(new Error("Crop failed")); + return; + } + resolve(blob); + }, "image/png"); + }); + }; + + const applyCrop = async () => { + if (!cropTarget || !croppedAreaPixels) return; + try { + const blob = await getCroppedImg(cropTarget.previewUrl, croppedAreaPixels); + const croppedUrl = URL.createObjectURL(blob); + setImages((prev) => + prev.map((item) => { + if (item.id !== cropTarget.id) return item; + if (item.croppedUrl) URL.revokeObjectURL(item.croppedUrl); + return { ...item, croppedBlob: blob, croppedUrl }; + }) + ); + setInfo("裁剪已应用,可直接提交或继续裁剪。"); + } catch { + setError("裁剪失败,请重试"); + } finally { + setCropTarget(null); + setCroppedAreaPixels(null); + setZoom(1); + setCrop({ x: 0, y: 0 }); + } + }; + + const openCrop = (item: ImageItem) => { + setCropTarget(item); + setCrop({ x: 0, y: 0 }); + setZoom(1); + setCroppedAreaPixels(null); + }; + + const restoreCrop = (id: string) => { + setImages((prev) => + prev.map((item) => { + if (item.id !== id) return item; + if (item.croppedUrl) URL.revokeObjectURL(item.croppedUrl); + return { ...item, croppedBlob: undefined, croppedUrl: undefined }; + }) + ); + }; + + const startCamera = async () => { + setError(""); + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: "environment" }, + }); + streamRef.current = stream; + if (videoRef.current) { + videoRef.current.srcObject = stream; + await videoRef.current.play(); + } + } catch (err) { + setError("无法打开摄像头,请检查权限设置"); + } + }; + + const capturePhoto = async () => { + setError(""); + if (!videoRef.current) return; + const video = videoRef.current; + const canvas = document.createElement("canvas"); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + const dataUrl = canvas.toDataURL("image/png"); + const blob = await (await fetch(dataUrl)).blob(); + const item = createImageItem(blob, `camera-${Date.now()}.png`); + setImages((prev) => [...prev, item]); + setInfo("拍照完成,可直接提交或继续拍照。"); + }; + + const stopCamera = () => { + if (streamRef.current) { + streamRef.current.getTracks().forEach((track) => track.stop()); + streamRef.current = null; + } + }; + + const handleSubmit = async () => { + if (!savedUser) { + setError("请先设置用户名"); + return; + } + if (images.length === 0) { + setError("请先上传或拍照作业图片"); + return; + } + setError(""); + setIsSubmitting(true); + setInfo("正在提交图片并调用 LLM 批改,请稍候..."); + try { + const formData = new FormData(); + formData.append("username", savedUser); + if (title.trim()) { + formData.append("title", title.trim()); + } + images.forEach((item, index) => { + const blob = item.croppedBlob ?? item.file; + const name = item.name || `page-${index + 1}.png`; + formData.append("images", blob, name); + }); + + const res = await fetch(`${apiBase}/assignments`, { + method: "POST", + body: formData, + }); + if (!res.ok) { + const payload = await res.json().catch(() => ({})); + throw new Error(payload.error || "提交失败"); + } + const created = (await res.json()) as AssignmentDetail; + setAssignments((prev) => [created, ...prev]); + setSelectedId(created.id); + setSelected(created); + setTitle(""); + clearImages(); + setInfo("批改完成,可在右侧查看结果。"); + } catch (err) { + setError(err instanceof Error ? err.message : "提交失败"); + } finally { + setIsSubmitting(false); + } + }; + + const handleDelete = async (id: number) => { + if (!savedUser) return; + try { + const res = await fetch( + `${apiBase}/assignments/${id}?username=${encodeURIComponent(savedUser)}`, + { method: "DELETE" } + ); + if (!res.ok) throw new Error("删除失败"); + setAssignments((prev) => prev.filter((item) => item.id !== id)); + if (selectedId === id) { + setSelectedId(null); + setSelected(null); + } + } catch (err) { + setError(err instanceof Error ? err.message : "删除失败"); + } + }; + + const imageUrlFor = (id: number, index = 0) => + `${apiBase}/assignments/${id}/image?username=${encodeURIComponent(savedUser)}&index=${index}`; + + const imageCountFor = (item: AssignmentSummary) => + item.imagePaths?.length ?? (item.imagePath ? 1 : 0); + + const selectedImagePaths = + selected?.imagePaths && selected.imagePaths.length > 0 + ? selected.imagePaths + : selected?.imagePath + ? [selected.imagePath] + : []; + + return ( +
+
+
+
+

作业工坊

+

+ 上传或拍照作业,直接交给 LLM 识别与批改,返回 Markdown 与反馈。 + 前端无需登录,只需一个用户名即可管理自己的作业记录。 +

+
+
Homework Atelier
+
+ +
+
+

我的身份

+ NO LOGIN REQUIRED +
+
+ setUsername(event.target.value)} + /> + + {savedUser && ( + 当前用户:{savedUser} + )} +
+
+ +
+
+
+

上传作业

+ IMAGE + LLM +
+
+ + setTitle(event.target.value)} + /> + + + +
默认不裁剪,可选择“裁剪”后提交。
+ +
+ +
+ + {images.length > 0 && ( +
+ {images.map((item, index) => ( +
+ {`第${index +
+ 第 {index + 1} 张 +
+ + {item.croppedBlob && ( + + )} + +
+
+
+ ))} +
+ )} + + {images.length > 0 && ( + + )} + + + + {error &&
{error}
} + {info &&
{info}
} +
+
+ +
+
+

我的作业档案

+ HISTORY +
+
+ {assignments.length === 0 && ( +
暂无作业记录
+ )} + {assignments.map((item) => ( +
{ + setSelectedId(item.id); + await loadAssignmentDetail(item.id); + }} + > + {item.title} +
+ 评分:{item.score || "-"} + {new Date(item.createdAt).toLocaleString()} +
+
+ {imageCountFor(item) > 0 && ( + event.stopPropagation()} + > + 保存图片{imageCountFor(item) > 1 ? ` (${imageCountFor(item)}张)` : ""} + + )} + +
+
+ ))} +
+
+
+ +
+
+

批改结果

+ MARKDOWN +
+ {!selected &&
选择一条作业查看详情
} + {selected && ( +
+
+ 标题:{selected.title} + 评分:{selected.score || "-"} +
+ {selectedImagePaths.length > 0 && ( +
+
+ 原始图片(共 {selectedImagePaths.length} 张) +
+
+ {selectedImagePaths.map((_, index) => ( + + ))} +
+
+ )} +
+ {selected.markdown} +
+ {selected.feedback && ( +
+ 批改意见: +
{selected.feedback}
+
+ )} +
+ )} +
+
+ + {cropTarget && ( +
+
+
+ +
+
+ + setZoom(Number(event.target.value))} + /> +
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts new file mode 100644 index 0000000..40c3d68 --- /dev/null +++ b/frontend/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/frontend/next.config.js b/frontend/next.config.js new file mode 100644 index 0000000..eca788d --- /dev/null +++ b/frontend/next.config.js @@ -0,0 +1,7 @@ +/** @type {import("next").NextConfig} */ +const nextConfig = { + reactStrictMode: true, + output: "standalone" +}; + +module.exports = nextConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..026b522 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1730 @@ +{ + "name": "homework-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "homework-frontend", + "version": "1.0.0", + "dependencies": { + "clsx": "^2.1.1", + "next": "14.2.10", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-easy-crop": "^5.0.6", + "react-markdown": "^9.0.1" + }, + "devDependencies": { + "@types/node": "^20.11.30", + "@types/react": "^18.2.66", + "@types/react-dom": "^18.2.22", + "typescript": "^5.4.5" + } + }, + "node_modules/@next/env": { + "version": "14.2.10", + "resolved": "https://registry.npmmirror.com/@next/env/-/env-14.2.10.tgz", + "integrity": "sha512-dZIu93Bf5LUtluBXIv4woQw2cZVZ2DJTjax5/5DOs3lzEOeKLy7GxRSr4caK9/SCPdaW6bCgpye6+n4Dh9oJPw==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "14.2.10", + "resolved": "https://registry.npmmirror.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.10.tgz", + "integrity": "sha512-V3z10NV+cvMAfxQUMhKgfQnPbjw+Ew3cnr64b0lr8MDiBJs3eLnM6RpGC46nhfMZsiXgQngCJKWGTC/yDcgrDQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "14.2.10", + "resolved": "https://registry.npmmirror.com/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.10.tgz", + "integrity": "sha512-Y0TC+FXbFUQ2MQgimJ/7Ina2mXIKhE7F+GUe1SgnzRmwFY3hX2z8nyVCxE82I2RicspdkZnSWMn4oTjIKz4uzA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "14.2.10", + "resolved": "https://registry.npmmirror.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.10.tgz", + "integrity": "sha512-ZfQ7yOy5zyskSj9rFpa0Yd7gkrBnJTkYVSya95hX3zeBG9E55Z6OTNPn1j2BTFWvOVVj65C3T+qsjOyVI9DQpA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "14.2.10", + "resolved": "https://registry.npmmirror.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.10.tgz", + "integrity": "sha512-n2i5o3y2jpBfXFRxDREr342BGIQCJbdAUi/K4q6Env3aSx8erM9VuKXHw5KNROK9ejFSPf0LhoSkU/ZiNdacpQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "14.2.10", + "resolved": "https://registry.npmmirror.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.10.tgz", + "integrity": "sha512-GXvajAWh2woTT0GKEDlkVhFNxhJS/XdDmrVHrPOA83pLzlGPQnixqxD8u3bBB9oATBKB//5e4vpACnx5Vaxdqg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "14.2.10", + "resolved": "https://registry.npmmirror.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.10.tgz", + "integrity": "sha512-opFFN5B0SnO+HTz4Wq4HaylXGFV+iHrVxd3YvREUX9K+xfc4ePbRrxqOuPOFjtSuiVouwe6uLeDtabjEIbkmDA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "14.2.10", + "resolved": "https://registry.npmmirror.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.10.tgz", + "integrity": "sha512-9NUzZuR8WiXTvv+EiU/MXdcQ1XUvFixbLIMNQiVHuzs7ZIFrJDLJDaOF1KaqttoTujpcxljM/RNAOmw1GhPPQQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-ia32-msvc": { + "version": "14.2.10", + "resolved": "https://registry.npmmirror.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.10.tgz", + "integrity": "sha512-fr3aEbSd1GeW3YUMBkWAu4hcdjZ6g4NBl1uku4gAn661tcxd1bHs1THWYzdsbTRLcCKLjrDZlNp6j2HTfrw+Bg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "14.2.10", + "resolved": "https://registry.npmmirror.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.10.tgz", + "integrity": "sha512-UjeVoRGKNL2zfbcQ6fscmgjBAS/inHBh63mjIlfPg/NG8Yn2ztqylXt5qilYb6hoHIwaU2ogHknHWWmahJjgZQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmmirror.com/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, + "node_modules/@swc/helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmmirror.com/@swc/helpers/-/helpers-0.5.5.tgz", + "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==", + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmmirror.com/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.30", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-20.19.30.tgz", + "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.27", + "resolved": "https://registry.npmmirror.com/@types/react/-/react-18.3.27.tgz", + "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001766", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", + "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmmirror.com/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmmirror.com/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmmirror.com/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmmirror.com/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "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/next": { + "version": "14.2.10", + "resolved": "https://registry.npmmirror.com/next/-/next-14.2.10.tgz", + "integrity": "sha512-sDDExXnh33cY3RkS9JuFEKaS4HmlWmDKP1VJioucCG6z5KuA008DPsDZOzi8UfqEk3Ii+2NCQSJrfbEWtZZfww==", + "license": "MIT", + "dependencies": { + "@next/env": "14.2.10", + "@swc/helpers": "0.5.5", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001579", + "graceful-fs": "^4.2.11", + "postcss": "8.4.31", + "styled-jsx": "5.1.1" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=18.17.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "14.2.10", + "@next/swc-darwin-x64": "14.2.10", + "@next/swc-linux-arm64-gnu": "14.2.10", + "@next/swc-linux-arm64-musl": "14.2.10", + "@next/swc-linux-x64-gnu": "14.2.10", + "@next/swc-linux-x64-musl": "14.2.10", + "@next/swc-win32-arm64-msvc": "14.2.10", + "@next/swc-win32-ia32-msvc": "14.2.10", + "@next/swc-win32-x64-msvc": "14.2.10" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.41.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/normalize-wheel": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/normalize-wheel/-/normalize-wheel-1.0.1.tgz", + "integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==", + "license": "BSD-3-Clause" + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "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.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmmirror.com/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-easy-crop": { + "version": "5.5.6", + "resolved": "https://registry.npmmirror.com/react-easy-crop/-/react-easy-crop-5.5.6.tgz", + "integrity": "sha512-Jw3/ozs8uXj3NpL511Suc4AHY+mLRO23rUgipXvNYKqezcFSYHxe4QXibBymkOoY6oOtLVMPO2HNPRHYvMPyTw==", + "license": "MIT", + "dependencies": { + "normalize-wheel": "^1.0.1", + "tslib": "^2.0.1" + }, + "peerDependencies": { + "react": ">=16.4.0", + "react-dom": ">=16.4.0" + } + }, + "node_modules/react-markdown": { + "version": "9.1.0", + "resolved": "https://registry.npmmirror.com/react-markdown/-/react-markdown-9.1.0.tgz", + "integrity": "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmmirror.com/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmmirror.com/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmmirror.com/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmmirror.com/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/styled-jsx/-/styled-jsx-5.1.1.tgz", + "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmmirror.com/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..73b1084 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,25 @@ +{ + "name": "homework-frontend", + "private": true, + "version": "1.0.0", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start -p 3000", + "lint": "next lint" + }, + "dependencies": { + "clsx": "^2.1.1", + "next": "14.2.10", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-easy-crop": "^5.0.6", + "react-markdown": "^9.0.1" + }, + "devDependencies": { + "@types/node": "^20.11.30", + "@types/react": "^18.2.66", + "@types/react-dom": "^18.2.22", + "typescript": "^5.4.5" + } +} diff --git a/frontend/public/.keep b/frontend/public/.keep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..3ee3e5d --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,38 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "types": [ + "node" + ], + "plugins": [ + { + "name": "next" + } + ] + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +}