Add question image media support

This commit is contained in:
hz
2026-07-11 03:12:46 -05:00
parent 5ed96ae3b8
commit 9c8775d740
5 changed files with 215 additions and 3 deletions
@@ -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<IActionResult> 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<string>
{
"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<IActionResult> 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<IActionResult> Skip(string externalId)
@@ -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;
}
+1
View File
@@ -11,6 +11,7 @@ builder.Services.AddControllers();
builder.Services.AddSingleton<QuestionStore>();
builder.Services.AddSingleton<QuestionAttemptStore>();
builder.Services.AddSingleton<RatingStore>();
builder.Services.AddSingleton<QuestionMediaStore>();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
@@ -0,0 +1,34 @@
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public class QuestionMediaStore
{
private readonly IMongoCollection<QuestionMediaAsset> _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<QuestionMediaAsset>("QuestionMediaAssets");
}
public async Task<QuestionMediaAsset> 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<QuestionMediaAsset?> GetAsync(string id) =>
await _col.Find(asset => asset.Id == id).FirstOrDefaultAsync();
}
+115 -2
View File
@@ -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