Files
step3/scripts/ui/flashcards_hub.gd
T
hz 4dfbd811b9 Wire flashcard CSV/JSON import/export and fix theme/responsive gaps
Import and export were stubbed with status-text placeholders; wire them
to the existing backend endpoints with a real file picker (web JS
bridge or native FileDialog) and browser/desktop download, plus an
import preview with create/update/skip/error counts before commit.

Also fix the library and study screens' theme-refresh handlers, which
were no-op stubs so cards never restyled on a theme toggle, and add
responsive layout handling for narrow viewports.
2026-07-21 16:41:26 -05:00

620 lines
21 KiB
GDScript

extends VBoxContainer
const Brand := preload("res://scripts/ui/medmind_style.gd")
const Style := preload("res://scripts/ui/ui_style.gd")
const Cloze := preload("res://scripts/ui/flashcard_cloze.gd")
var main
var deck_selector: OptionButton
var deck_ids: Array = []
var new_deck_button: Button
var study_button: Button
var export_button: Button
var import_button: Button
var search_input: LineEdit
var type_filter: OptionButton
var state_filter: OptionButton
var new_card_button: Button
var status_label: Label
var list_scroll: ScrollContainer
var list_container: VBoxContainer
var debounce_timer: Timer
var editor_overlay: Control
var editor_panel: PanelContainer
var editor_note_id := ""
var editor_expected_revision: Variant = null
var editor_type_selector: OptionButton
var editor_front_input: TextEdit
var editor_back_input: TextEdit
var editor_extra_input: TextEdit
var editor_tags_input: LineEdit
var editor_hint_label: Label
var editor_error_label: Label
var export_menu: PanelContainer
var import_menu: PanelContainer
var import_overlay: Control
var import_panel: PanelContainer
var import_summary_label: Label
var import_rows_container: VBoxContainer
var import_commit_button: Button
var import_pending_token := ""
var compact_layout := false
var decks: Array = []
var notes: Array = []
func _ready() -> void:
size_flags_horizontal = Control.SIZE_EXPAND_FILL
size_flags_vertical = Control.SIZE_EXPAND_FILL
add_theme_constant_override("separation", 14)
_build_toolbar()
_build_filters()
status_label = Label.new()
status_label.add_theme_font_override("font", Brand.font)
add_child(status_label)
list_scroll = ScrollContainer.new()
list_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
add_child(list_scroll)
list_container = VBoxContainer.new()
list_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL
list_container.add_theme_constant_override("separation", 8)
list_scroll.add_child(list_container)
debounce_timer = Timer.new()
debounce_timer.one_shot = true
debounce_timer.wait_time = 0.35
debounce_timer.timeout.connect(_reload_notes)
add_child(debounce_timer)
_build_editor_overlay()
_build_import_overlay()
resized.connect(_update_responsive_layout)
call_deferred("_update_responsive_layout")
FlashcardService.decks_changed.connect(_on_decks_changed)
FlashcardService.notes_loaded.connect(_on_notes_loaded)
FlashcardService.note_saved.connect(_on_note_saved)
FlashcardService.request_failed.connect(_on_request_failed)
FlashcardService.export_ready.connect(_on_export_ready)
FlashcardService.import_preview_ready.connect(_on_import_preview_ready)
FlashcardService.import_committed.connect(_on_import_committed)
UIThemeService.theme_changed.connect(func(_dark: bool): _refresh_status())
func on_show(_payload: Variant = null) -> void:
FlashcardService.fetch_decks()
_reload_notes()
func _build_toolbar() -> void:
var row := HFlowContainer.new()
row.add_theme_constant_override("separation", 10)
add_child(row)
deck_selector = OptionButton.new()
deck_selector.add_item("All decks")
deck_selector.custom_minimum_size = Vector2(200, 44)
deck_selector.item_selected.connect(func(_index: int): _reload_notes())
row.add_child(deck_selector)
new_deck_button = Button.new()
new_deck_button.text = "New Deck"
new_deck_button.custom_minimum_size = Vector2(0, 44)
new_deck_button.pressed.connect(_prompt_new_deck)
row.add_child(new_deck_button)
study_button = Button.new()
study_button.text = "Study"
study_button.custom_minimum_size = Vector2(0, 44)
Style.style_primary_button(study_button)
study_button.pressed.connect(_start_study)
row.add_child(study_button)
new_card_button = Button.new()
new_card_button.text = "New Card"
new_card_button.custom_minimum_size = Vector2(0, 44)
new_card_button.pressed.connect(func(): _open_editor("", {}))
row.add_child(new_card_button)
import_button = Button.new()
import_button.text = "Import"
import_button.custom_minimum_size = Vector2(0, 44)
import_button.pressed.connect(func(): _toggle_dropdown(import_menu, import_button))
row.add_child(import_button)
import_menu = _build_dropdown([
["Import CSV...", func():
status_label.text = "Choose a file to import..."
FlashcardService.pick_import_file("csv")],
["Import JSON...", func():
status_label.text = "Choose a file to import..."
FlashcardService.pick_import_file("json")],
])
export_button = Button.new()
export_button.text = "Export"
export_button.custom_minimum_size = Vector2(0, 44)
export_button.pressed.connect(func(): _toggle_dropdown(export_menu, export_button))
row.add_child(export_button)
export_menu = _build_dropdown([
["Export CSV", func():
status_label.text = "Preparing export..."
FlashcardService.export_csv()],
["Export JSON", func():
status_label.text = "Preparing export..."
FlashcardService.export_json()],
])
func _build_dropdown(items: Array) -> PanelContainer:
var menu := PanelContainer.new()
menu.visible = false
menu.top_level = true
menu.add_theme_stylebox_override("panel", Brand.panel_box(UIThemeService.is_dark_mode()))
add_child(menu)
var list := VBoxContainer.new()
list.add_theme_constant_override("separation", 2)
menu.add_child(list)
for item in items:
var button := Button.new()
button.text = str(item[0])
button.custom_minimum_size = Vector2(180, 40)
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
var action: Callable = item[1]
button.pressed.connect(func():
menu.visible = false
action.call())
list.add_child(button)
return menu
func _toggle_dropdown(menu: PanelContainer, anchor: Control) -> void:
if menu.visible:
menu.visible = false
return
menu.visible = true
menu.position = anchor.get_global_position() + Vector2(0, anchor.size.y + 4)
func _build_filters() -> void:
var row := HFlowContainer.new()
row.add_theme_constant_override("separation", 10)
add_child(row)
search_input = LineEdit.new()
search_input.placeholder_text = "Search prompt or answer"
search_input.custom_minimum_size = Vector2(260, 44)
search_input.size_flags_horizontal = Control.SIZE_EXPAND_FILL
search_input.text_changed.connect(func(_value: String): debounce_timer.start())
row.add_child(search_input)
type_filter = OptionButton.new()
type_filter.add_item("All types")
type_filter.add_item("Basic")
type_filter.add_item("Cloze")
type_filter.custom_minimum_size = Vector2(130, 44)
type_filter.item_selected.connect(func(_index: int): _reload_notes())
row.add_child(type_filter)
state_filter = OptionButton.new()
state_filter.add_item("All states")
state_filter.add_item("New")
state_filter.add_item("Learning")
state_filter.add_item("Review")
state_filter.add_item("Relearning")
state_filter.add_item("Archived")
state_filter.custom_minimum_size = Vector2(130, 44)
state_filter.item_selected.connect(func(_index: int): _reload_notes())
row.add_child(state_filter)
func _on_decks_changed(loaded_decks: Array) -> void:
decks = loaded_decks
var previous_selection: Variant = deck_ids[deck_selector.selected] if deck_selector.selected > 0 and deck_selector.selected < deck_ids.size() else null
deck_selector.clear()
deck_selector.add_item("All decks")
deck_ids = [""]
for summary in decks:
var deck: Dictionary = summary.get("deck", {})
var label := "%s (%d due, %d new)" % [str(deck.get("name", "Deck")), int(summary.get("due", 0)), int(summary.get("new", 0))]
deck_selector.add_item(label)
deck_ids.append(str(deck.get("id", "")))
if previous_selection != null:
var restored := deck_ids.find(previous_selection)
if restored >= 0:
deck_selector.select(restored)
func _selected_deck_id() -> String:
if deck_selector.selected <= 0 or deck_selector.selected >= deck_ids.size():
return ""
return str(deck_ids[deck_selector.selected])
func _reload_notes() -> void:
status_label.text = "Loading flashcards..."
var query := {
"deckId": _selected_deck_id(),
"search": search_input.text,
"type": ["", "basic", "cloze"][type_filter.selected],
"state": ["", "new", "learning", "review", "relearning", "archived"][state_filter.selected],
"page": 1,
"pageSize": 100
}
FlashcardService.search_notes(query)
func _on_notes_loaded(response: Dictionary) -> void:
notes = response.get("items", [])
_render_notes()
func _render_notes() -> void:
for child in list_container.get_children():
child.queue_free()
status_label.text = "" if notes.size() > 0 else "No flashcards match these filters yet."
for note in notes:
list_container.add_child(_note_row(note))
func _note_row(note: Dictionary) -> Control:
var dark := UIThemeService.is_dark_mode()
var card := PanelContainer.new()
card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false, Brand.ROYAL_BLUE))
var row := BoxContainer.new()
row.vertical = compact_layout
row.add_theme_constant_override("separation", 12)
card.add_child(row)
var content := VBoxContainer.new()
content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(content)
var type_str := str(note.get("type", "basic"))
var prompt_text := str(note.get("prompt", ""))
var preview := Cloze.render_back(prompt_text) if type_str == "cloze" else prompt_text
var prompt_label := Label.new()
prompt_label.text = preview
prompt_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
prompt_label.add_theme_font_override("font", Brand.font)
content.add_child(prompt_label)
var meta := Label.new()
var tags: Array = note.get("tags", [])
meta.text = "%s%s" % [type_str.capitalize(), (" | " + ", ".join(PackedStringArray(tags))) if tags.size() > 0 else ""]
meta.add_theme_font_size_override("font_size", 12)
meta.add_theme_color_override("font_color", Brand.muted_color(dark))
content.add_child(meta)
var edit_button := Button.new()
edit_button.text = "Edit"
edit_button.custom_minimum_size = Vector2(70, 40)
edit_button.pressed.connect(_open_editor.bind(str(note.get("id", "")), note))
row.add_child(edit_button)
var archive_button := Button.new()
archive_button.text = "Archive"
archive_button.custom_minimum_size = Vector2(85, 40)
archive_button.pressed.connect(func():
FlashcardService.archive_notes([str(note.get("id", ""))], true)
await get_tree().create_timer(0.3).timeout
_reload_notes())
row.add_child(archive_button)
return card
# ---------- Note editor ----------
func _build_editor_overlay() -> void:
editor_overlay = Control.new()
editor_overlay.visible = false
editor_overlay.top_level = true
editor_overlay.mouse_filter = Control.MOUSE_FILTER_STOP
editor_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(editor_overlay)
var dim := ColorRect.new()
dim.color = Color(0, 0, 0, 0.72)
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
dim.gui_input.connect(func(event: InputEvent):
if event is InputEventMouseButton and event.pressed:
_close_editor())
editor_overlay.add_child(dim)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
editor_overlay.add_child(center)
editor_panel = PanelContainer.new()
editor_panel.custom_minimum_size = Vector2(620, 560)
editor_panel.add_theme_stylebox_override("panel", Brand.panel_box(UIThemeService.is_dark_mode()))
center.add_child(editor_panel)
var content := VBoxContainer.new()
content.add_theme_constant_override("separation", 10)
editor_panel.add_child(content)
var header := HBoxContainer.new()
content.add_child(header)
var title := Label.new()
title.text = "Flashcard"
title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
title.add_theme_font_size_override("font_size", 22)
header.add_child(title)
var close_button := Button.new()
close_button.icon = preload("res://assets/icons/lucide/x.svg")
close_button.custom_minimum_size = Vector2(40, 40)
close_button.pressed.connect(_close_editor)
header.add_child(close_button)
editor_type_selector = OptionButton.new()
editor_type_selector.add_item("Basic")
editor_type_selector.add_item("Cloze")
editor_type_selector.item_selected.connect(_on_editor_type_changed)
content.add_child(editor_type_selector)
editor_hint_label = Label.new()
editor_hint_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
editor_hint_label.add_theme_font_size_override("font_size", 12)
editor_hint_label.add_theme_color_override("font_color", Brand.muted_color(UIThemeService.is_dark_mode()))
content.add_child(editor_hint_label)
content.add_child(_field_label("Front / Cloze text"))
editor_front_input = TextEdit.new()
editor_front_input.custom_minimum_size = Vector2(0, 100)
editor_front_input.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY
content.add_child(editor_front_input)
content.add_child(_field_label("Back / Extra takeaway"))
editor_back_input = TextEdit.new()
editor_back_input.custom_minimum_size = Vector2(0, 80)
editor_back_input.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY
content.add_child(editor_back_input)
content.add_child(_field_label("Extra"))
editor_extra_input = TextEdit.new()
editor_extra_input.custom_minimum_size = Vector2(0, 60)
editor_extra_input.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY
content.add_child(editor_extra_input)
content.add_child(_field_label("Tags (comma separated)"))
editor_tags_input = LineEdit.new()
content.add_child(editor_tags_input)
editor_error_label = Label.new()
editor_error_label.add_theme_color_override("font_color", Style.RED)
editor_error_label.visible = false
content.add_child(editor_error_label)
var save_button := Button.new()
save_button.text = "Save"
save_button.custom_minimum_size = Vector2(0, 46)
Style.style_primary_button(save_button)
save_button.pressed.connect(_save_editor)
content.add_child(save_button)
_on_editor_type_changed(0)
func _field_label(text: String) -> Label:
var label := Label.new()
label.text = text
label.add_theme_font_size_override("font_size", 13)
return label
func _on_editor_type_changed(index: int) -> void:
editor_hint_label.text = (
"Wrap the hidden answer in {{c1::answer}}, or {{c1::answer::hint}}. Reuse c1/c2/... to " +
"create independently scheduled cards from the same sentence."
if index == 1 else
"Front is the question stem. Back is the correct answer plus key takeaway.")
func _open_editor(note_id: String, note: Dictionary) -> void:
editor_note_id = note_id
editor_expected_revision = note.get("revision", null)
editor_type_selector.select(1 if str(note.get("type", "basic")) == "cloze" else 0)
editor_front_input.text = str(note.get("prompt", ""))
editor_back_input.text = str(note.get("answer", ""))
editor_extra_input.text = str(note.get("extra", ""))
editor_tags_input.text = ", ".join(PackedStringArray(note.get("tags", [])))
editor_error_label.visible = false
_on_editor_type_changed(editor_type_selector.selected)
editor_overlay.visible = true
func _close_editor() -> void:
editor_overlay.visible = false
func _save_editor() -> void:
var is_cloze := editor_type_selector.selected == 1
var prompt := editor_front_input.text.strip_edges()
if prompt.is_empty():
editor_error_label.text = "Front / cloze text is required."
editor_error_label.visible = true
return
if is_cloze and not RegEx.create_from_string("\\{\\{c\\d+::").search(prompt):
editor_error_label.text = "Cloze cards need at least one {{cN::answer}} marker."
editor_error_label.visible = true
return
var tags: Array = []
for tag in editor_tags_input.text.split(","):
var trimmed := tag.strip_edges()
if not trimmed.is_empty():
tags.append(trimmed)
var payload := {
"deckId": _selected_deck_id(),
"stableId": "",
"type": "cloze" if is_cloze else "basic",
"prompt": prompt,
"answer": editor_back_input.text,
"extra": editor_extra_input.text,
"tags": tags,
"images": [],
"sourceQuestionExternalId": "",
"expectedRevision": editor_expected_revision
}
FlashcardService.save_note(editor_note_id, payload)
func _on_note_saved(_note: Dictionary) -> void:
_close_editor()
_reload_notes()
FlashcardService.fetch_decks()
func _on_request_failed(tag: String, message: String) -> void:
if tag == "import_commit":
import_commit_button.disabled = false
editor_error_label.text = message
editor_error_label.visible = true
status_label.text = message
func _prompt_new_deck() -> void:
FlashcardService.create_deck("New Deck %d" % (decks.size() + 1), "")
func _start_study() -> void:
main.show_screen("flashcards_study", _selected_deck_id())
func _on_export_ready(location: String) -> void:
status_label.text = "Exported to %s." % location
# ---------- Import preview / commit ----------
func _build_import_overlay() -> void:
import_overlay = Control.new()
import_overlay.visible = false
import_overlay.top_level = true
import_overlay.mouse_filter = Control.MOUSE_FILTER_STOP
import_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(import_overlay)
var dim := ColorRect.new()
dim.color = Color(0, 0, 0, 0.72)
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
import_overlay.add_child(dim)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
import_overlay.add_child(center)
import_panel = PanelContainer.new()
import_panel.custom_minimum_size = Vector2(620, 480)
import_panel.add_theme_stylebox_override("panel", Brand.panel_box(UIThemeService.is_dark_mode()))
center.add_child(import_panel)
var content := VBoxContainer.new()
content.add_theme_constant_override("separation", 10)
import_panel.add_child(content)
var header := HBoxContainer.new()
content.add_child(header)
var title := Label.new()
title.text = "Import preview"
title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
title.add_theme_font_size_override("font_size", 22)
header.add_child(title)
var close_button := Button.new()
close_button.icon = preload("res://assets/icons/lucide/x.svg")
close_button.custom_minimum_size = Vector2(40, 40)
close_button.pressed.connect(_close_import_overlay)
header.add_child(close_button)
import_summary_label = Label.new()
import_summary_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
content.add_child(import_summary_label)
var rows_scroll := ScrollContainer.new()
rows_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
content.add_child(rows_scroll)
import_rows_container = VBoxContainer.new()
import_rows_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL
import_rows_container.add_theme_constant_override("separation", 4)
rows_scroll.add_child(import_rows_container)
var actions := HBoxContainer.new()
actions.add_theme_constant_override("separation", 10)
content.add_child(actions)
var cancel_button := Button.new()
cancel_button.text = "Cancel"
cancel_button.custom_minimum_size = Vector2(0, 46)
cancel_button.pressed.connect(_close_import_overlay)
actions.add_child(cancel_button)
import_commit_button = Button.new()
import_commit_button.text = "Commit import"
import_commit_button.custom_minimum_size = Vector2(0, 46)
import_commit_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
Style.style_primary_button(import_commit_button)
import_commit_button.pressed.connect(_commit_pending_import)
actions.add_child(import_commit_button)
func _on_import_preview_ready(preview: Dictionary) -> void:
import_pending_token = str(preview.get("importToken", ""))
var create_count := int(preview.get("createCount", 0))
var update_count := int(preview.get("updateCount", 0))
var skip_count := int(preview.get("skipCount", 0))
var error_count := int(preview.get("errorCount", 0))
import_summary_label.text = "%d to create, %d to update, %d to skip, %d row error(s)." % [create_count, update_count, skip_count, error_count]
for child in import_rows_container.get_children():
child.queue_free()
for raw_row in preview.get("rows", []):
import_rows_container.add_child(_import_row(raw_row))
import_commit_button.disabled = import_pending_token.is_empty() or (create_count + update_count == 0)
status_label.text = ""
import_overlay.visible = true
func _import_row(row: Dictionary) -> Control:
var label := Label.new()
var action := str(row.get("action", ""))
var error := str(row.get("error", ""))
var detail := error if not error.is_empty() else str(row.get("prompt", ""))
label.text = "Row %d - %s: %s" % [int(row.get("rowNumber", 0)), action.capitalize(), detail]
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
if action == "error":
label.add_theme_color_override("font_color", Style.RED)
return label
func _commit_pending_import() -> void:
if import_pending_token.is_empty():
return
import_commit_button.disabled = true
FlashcardService.commit_import(import_pending_token)
func _on_import_committed(result: Dictionary) -> void:
import_overlay.visible = false
import_pending_token = ""
status_label.text = "Import complete: %d created, %d updated, %d skipped, %d error(s)." % [
int(result.get("created", 0)), int(result.get("updated", 0)), int(result.get("skipped", 0)), int(result.get("errored", 0))]
_reload_notes()
FlashcardService.fetch_decks()
func _close_import_overlay() -> void:
import_overlay.visible = false
import_pending_token = ""
# ---------- Theme / responsive ----------
func _refresh_status() -> void:
var dark := UIThemeService.is_dark_mode()
if editor_panel != null:
editor_panel.add_theme_stylebox_override("panel", Brand.panel_box(dark))
if import_panel != null:
import_panel.add_theme_stylebox_override("panel", Brand.panel_box(dark))
if export_menu != null:
export_menu.add_theme_stylebox_override("panel", Brand.panel_box(dark))
if import_menu != null:
import_menu.add_theme_stylebox_override("panel", Brand.panel_box(dark))
_render_notes()
func _update_responsive_layout() -> void:
var use_compact := size.x < 720.0
if compact_layout == use_compact:
return
compact_layout = use_compact
search_input.custom_minimum_size.x = 0.0 if compact_layout else 260.0
_render_notes()