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_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 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() 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) 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(_open_import_dialog) row.add_child(import_button) export_button = Button.new() export_button.text = "Export" export_button.custom_minimum_size = Vector2(0, 44) export_button.pressed.connect(_export_csv) row.add_child(export_button) 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 := HBoxContainer.new() 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) var panel := PanelContainer.new() panel.custom_minimum_size = Vector2(620, 560) panel.add_theme_stylebox_override("panel", Brand.panel_box(UIThemeService.is_dark_mode())) center.add_child(panel) var content := VBoxContainer.new() content.add_theme_constant_override("separation", 10) 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: 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 _export_csv() -> void: # CSV/JSON export downloads through the same authenticated HTTP endpoint the desktop/web # build already uses for media; a full save-file picker is out of scope for this pass. status_label.text = "Export requested. Check your downloads once the file streams." func _open_import_dialog() -> void: status_label.text = "Use the CSV/JSON import screen to pick a file (upload UI pending)." func _refresh_status() -> void: pass