diff --git a/api/QuestionsApi/Controllers/QuestionsController.cs b/api/QuestionsApi/Controllers/QuestionsController.cs index f05103b..d615e0f 100644 --- a/api/QuestionsApi/Controllers/QuestionsController.cs +++ b/api/QuestionsApi/Controllers/QuestionsController.cs @@ -14,12 +14,18 @@ public class QuestionsController : ControllerBase private readonly QuestionStore _questions; private readonly QuestionAttemptStore _attempts; private readonly RatingStore _ratings; + private readonly QuestionMediaStore _media; - public QuestionsController(QuestionStore questions, QuestionAttemptStore attempts, RatingStore ratings) + public QuestionsController( + QuestionStore questions, + QuestionAttemptStore attempts, + RatingStore ratings, + QuestionMediaStore media) { _questions = questions; _attempts = attempts; _ratings = ratings; + _media = media; } [HttpGet("systems")] @@ -101,6 +107,48 @@ public class QuestionsController : ControllerBase }); } + [HttpPost("media")] + [Authorize(Roles = "SUPER")] + [RequestSizeLimit(5_000_000)] + public async Task UploadMedia(IFormFile file) + { + if (file is null || file.Length == 0) + return BadRequest("file required"); + if (file.Length > 5_000_000) + return BadRequest("file must be 5 MB or smaller"); + + var contentType = file.ContentType.Trim().ToLowerInvariant(); + var allowedTypes = new HashSet + { + "image/png", + "image/jpeg", + "image/webp" + }; + if (!allowedTypes.Contains(contentType)) + return BadRequest("file must be a png, jpeg, or webp image"); + + await using var stream = file.OpenReadStream(); + using var memory = new MemoryStream(); + await stream.CopyToAsync(memory); + + var asset = await _media.CreateAsync(file.FileName, contentType, memory.ToArray()); + return Ok(new + { + id = asset.Id ?? "", + url = $"/api/questions/media/{asset.Id}" + }); + } + + [HttpGet("media/{id}")] + public async Task GetMedia(string id) + { + var asset = await _media.GetAsync(id.Trim()); + if (asset is null) + return NotFound(); + + return File(asset.Bytes, asset.ContentType); + } + [HttpPost("{externalId}/skip")] [Authorize(Roles = "USER,SUPER")] public async Task Skip(string externalId) diff --git a/api/QuestionsApi/Models/QuestionMediaAsset.cs b/api/QuestionsApi/Models/QuestionMediaAsset.cs new file mode 100644 index 0000000..bbbd12a --- /dev/null +++ b/api/QuestionsApi/Models/QuestionMediaAsset.cs @@ -0,0 +1,16 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace QuestionsApi.Models; + +public class QuestionMediaAsset +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + + public string FileName { get; set; } = ""; + public string ContentType { get; set; } = ""; + public byte[] Bytes { get; set; } = []; + public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; +} diff --git a/api/QuestionsApi/Program.cs b/api/QuestionsApi/Program.cs index fd2a56d..9abc1d1 100644 --- a/api/QuestionsApi/Program.cs +++ b/api/QuestionsApi/Program.cs @@ -11,6 +11,7 @@ builder.Services.AddControllers(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => diff --git a/api/QuestionsApi/Services/QuestionMediaStore.cs b/api/QuestionsApi/Services/QuestionMediaStore.cs new file mode 100644 index 0000000..05dab59 --- /dev/null +++ b/api/QuestionsApi/Services/QuestionMediaStore.cs @@ -0,0 +1,34 @@ +using MongoDB.Driver; +using QuestionsApi.Models; + +namespace QuestionsApi.Services; + +public class QuestionMediaStore +{ + private readonly IMongoCollection _col; + + public QuestionMediaStore(IConfiguration cfg) + { + var cs = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017"; + var dbName = cfg["MongoDB:DatabaseName"] ?? "GameDb"; + var client = new MongoClient(cs); + var db = client.GetDatabase(dbName); + _col = db.GetCollection("QuestionMediaAssets"); + } + + public async Task CreateAsync(string fileName, string contentType, byte[] bytes) + { + var asset = new QuestionMediaAsset + { + FileName = fileName.Trim(), + ContentType = contentType.Trim(), + Bytes = bytes, + CreatedUtc = DateTime.UtcNow + }; + await _col.InsertOneAsync(asset); + return asset; + } + + public async Task GetAsync(string id) => + await _col.Find(asset => asset.Id == id).FirstOrDefaultAsync(); +} diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd index d3e9fc6..130f192 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -39,11 +39,13 @@ var flag_button: Button var report_button: Button var nav_palette: HBoxContainer var question_content: VBoxContainer +var media_container: VBoxContainer var timer_bar: ProgressBar var feedback_fog: ColorRect var labs_overlay: Control var labs_panel: PanelContainer var labs_content: VBoxContainer +var media_requests: Array[HTTPRequest] = [] var red_fog_time := 0.0 var green_fog_time := 0.0 var correct_advance_delay := 0.0 @@ -151,6 +153,12 @@ func _ready() -> void: stem_label.custom_minimum_size = Vector2(0, 96) question_content.add_child(stem_label) + media_container = VBoxContainer.new() + media_container.visible = false + media_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL + media_container.add_theme_constant_override("separation", 10) + question_content.add_child(media_container) + lead_label = Label.new() lead_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART Style.set_label(lead_label, 21, Style.TEXT) @@ -336,6 +344,7 @@ func _load_question() -> void: _update_timer_bar() stem_plain_text = str(question.get("stem", "")) _render_stem() + _load_question_media(question) lead_label.text = question.get("lead_in", "") _refresh_labs_button() @@ -471,8 +480,6 @@ func _submit_or_next() -> void: current_answer = {} next_button.text = "Submit" next_button.disabled = true - if skip_button != null: - skip_button.disabled = true _trigger_wrong_feedback() _apply_saved_choice_state() return @@ -697,6 +704,112 @@ func _current_question_has_labs() -> bool: return not labs.is_empty() +func _load_question_media(question: Dictionary) -> void: + _clear_question_media() + var media: Array = question.get("media", []) + if media.is_empty(): + return + + media_container.visible = true + var question_id := str(question.get("id", "")) + for item in media: + var media_url := _resolve_media_url(str(item)) + if media_url == "": + continue + var frame := PanelContainer.new() + frame.size_flags_horizontal = Control.SIZE_EXPAND_FILL + frame.add_theme_stylebox_override("panel", Style.panel_box(Color("111a2a"), Color("344463"))) + media_container.add_child(frame) + + var image_box := VBoxContainer.new() + image_box.add_theme_constant_override("separation", 8) + frame.add_child(image_box) + + var status := Label.new() + status.text = "Loading image..." + status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + Style.set_label(status, 15, Style.MUTED) + image_box.add_child(status) + + var texture_rect := TextureRect.new() + texture_rect.visible = false + texture_rect.expand_mode = TextureRect.EXPAND_FIT_WIDTH + texture_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + texture_rect.custom_minimum_size = Vector2(0, 260) + texture_rect.size_flags_horizontal = Control.SIZE_EXPAND_FILL + image_box.add_child(texture_rect) + + var request := HTTPRequest.new() + request.request_completed.connect( + func(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray): + _on_media_loaded(result, response_code, body, question_id, texture_rect, status) + ) + media_requests.append(request) + add_child(request) + var err := request.request(media_url) + if err != OK: + status.text = "Image unavailable." + + +func _clear_question_media() -> void: + for request in media_requests: + if is_instance_valid(request): + request.queue_free() + media_requests.clear() + if media_container == null: + return + for child in media_container.get_children(): + media_container.remove_child(child) + child.queue_free() + media_container.visible = false + + +func _resolve_media_url(value: String) -> String: + var media_url := value.strip_edges() + if media_url == "": + return "" + if media_url.begins_with("http://") or media_url.begins_with("https://"): + return media_url + if not media_url.begins_with("/"): + media_url = "/%s" % media_url + + var base_url := QuestionBankService.question_api_base_url + var scheme_end := base_url.find("://") + if scheme_end < 0: + return media_url + var path_start := base_url.find("/", scheme_end + 3) + var origin := base_url if path_start < 0 else base_url.substr(0, path_start) + return "%s%s" % [origin, media_url] + + +func _on_media_loaded( + result: int, + response_code: int, + body: PackedByteArray, + question_id: String, + texture_rect: TextureRect, + status: Label) -> void: + if str(BlockSessionService.get_current_question().get("id", "")) != question_id: + return + if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: + status.text = "Image unavailable." + return + + var image := Image.new() + var err := image.load_png_from_buffer(body) + if err != OK: + err = image.load_jpg_from_buffer(body) + if err != OK: + err = image.load_webp_from_buffer(body) + if err != OK: + status.text = "Image unavailable." + return + + texture_rect.texture = ImageTexture.create_from_image(image) + texture_rect.visible = true + status.visible = false + + func _build_labs_modal() -> void: labs_overlay = Control.new() labs_overlay.visible = false