Add question image media support
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user