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 stats_row: HFlowContainer var stat_due_value: Label var stat_new_value: Label var stat_decks_value: Label var list_card: PanelContainer var list_section_title: Label var deck_selector: OptionButton var deck_ids: Array = [] var new_deck_button: Button var study_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_deck_label: Label var editor_note_id := "" var editor_target_deck_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 import_menu: PanelContainer var dropdown_backdrop: Control 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_dropdown_backdrop() _build_stats() _build_toolbar() _build_filters() status_label = Label.new() status_label.add_theme_font_override("font", Brand.font) add_child(status_label) list_card = PanelContainer.new() list_card.size_flags_vertical = Control.SIZE_EXPAND_FILL list_card.add_theme_stylebox_override("panel", _list_card_box(UIThemeService.is_dark_mode())) add_child(list_card) var list_card_content := VBoxContainer.new() list_card_content.add_theme_constant_override("separation", 10) list_card.add_child(list_card_content) list_section_title = _section_title("Your flashcards") list_card_content.add_child(list_section_title) list_scroll = ScrollContainer.new() list_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL list_card_content.add_child(list_scroll) list_container = VBoxContainer.new() list_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL list_container.add_theme_constant_override("separation", 14) 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.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_stats() -> void: var previous_due := stat_due_value.text if stat_due_value != null else "--" var previous_new := stat_new_value.text if stat_new_value != null else "--" var previous_decks := stat_decks_value.text if stat_decks_value != null else "--" if stats_row == null: stats_row = HFlowContainer.new() stats_row.add_theme_constant_override("separation", 24) add_child(stats_row) move_child(stats_row, 0) for child in stats_row.get_children(): child.queue_free() stat_due_value = _add_stat(stats_row, "Due today", "clock-3", Brand.AMBER) stat_new_value = _add_stat(stats_row, "New available", "clipboard-plus", Brand.ROYAL_BLUE) stat_decks_value = _add_stat(stats_row, "Decks", "clipboard-list", Brand.PURPLE) stat_due_value.text = previous_due stat_new_value.text = previous_new stat_decks_value.text = previous_decks func _add_stat(row: HFlowContainer, title: String, icon_name: String, accent: Color) -> Label: var dark := UIThemeService.is_dark_mode() var cell := HBoxContainer.new() cell.custom_minimum_size = Vector2(200, 56) cell.add_theme_constant_override("separation", 12) row.add_child(cell) var icon_panel := PanelContainer.new() icon_panel.custom_minimum_size = Vector2(48, 48) icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, accent, 24)) var icon := TextureRect.new() icon.texture = load("res://assets/icons/lucide/%s.svg" % icon_name) icon.custom_minimum_size = Vector2(22, 22) icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED icon_panel.add_child(icon) cell.add_child(icon_panel) var copy := VBoxContainer.new() copy.size_flags_vertical = Control.SIZE_SHRINK_CENTER var caption := Label.new() caption.text = title caption.add_theme_font_size_override("font_size", 13) caption.add_theme_color_override("font_color", Brand.muted_color(dark)) copy.add_child(caption) var value := Label.new() value.text = "--" value.add_theme_font_size_override("font_size", 24) value.add_theme_color_override("font_color", Brand.text_color(dark)) copy.add_child(value) cell.add_child(copy) return value func _list_card_box(dark: bool) -> StyleBoxFlat: var box := StyleBoxFlat.new() box.bg_color = Color("081426") if dark else Color.WHITE box.border_color = Color("26344f") if dark else Brand.LIGHT_BORDER box.set_border_width_all(1) box.set_corner_radius_all(8) box.content_margin_left = 18 box.content_margin_right = 18 box.content_margin_top = 16 box.content_margin_bottom = 16 return box func _section_title(text: String) -> Label: var label := Label.new() label.text = text label.add_theme_font_size_override("font_size", 16) label.add_theme_color_override("font_color", Brand.text_color(UIThemeService.is_dark_mode())) return label 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(110, 44) _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")], ]) func _style_primary_button(button: Button) -> void: var normal := StyleBoxFlat.new() normal.bg_color = Brand.ROYAL_BLUE normal.set_corner_radius_all(6) var hover := normal.duplicate() as StyleBoxFlat hover.bg_color = Color("3474ee") button.add_theme_stylebox_override("normal", normal) button.add_theme_stylebox_override("hover", hover) button.add_theme_stylebox_override("pressed", normal) button.add_theme_color_override("font_color", Color.WHITE) button.add_theme_color_override("font_hover_color", Color.WHITE) func _build_dropdown_backdrop() -> void: dropdown_backdrop = Control.new() dropdown_backdrop.visible = false dropdown_backdrop.top_level = true dropdown_backdrop.mouse_filter = Control.MOUSE_FILTER_STOP dropdown_backdrop.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) dropdown_backdrop.gui_input.connect(func(event: InputEvent): if event is InputEventMouseButton and event.pressed: _close_dropdowns()) add_child(dropdown_backdrop) 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(): _close_dropdowns() action.call()) list.add_child(button) return menu func _toggle_dropdown(menu: PanelContainer, anchor: Control) -> void: var was_visible := menu.visible _close_dropdowns() if was_visible: return menu.visible = true dropdown_backdrop.visible = true menu.position = anchor.get_global_position() + Vector2(0, anchor.size.y + 4) func _close_dropdowns() -> void: if import_menu != null: import_menu.visible = false if dropdown_backdrop != null: dropdown_backdrop.visible = false func _unhandled_input(event: InputEvent) -> void: if event.is_action_pressed("ui_cancel") and dropdown_backdrop != null and dropdown_backdrop.visible: _close_dropdowns() get_viewport().set_input_as_handled() 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 = [""] var total_due := 0 var total_new := 0 var my_decks := decks.filter(func(s): return not bool(s.get("deck", {}).get("isSystem", false))) var official_decks := decks.filter(func(s): return bool(s.get("deck", {}).get("isSystem", false))) if my_decks.size() > 0: deck_selector.add_separator("My Decks") deck_ids.append("") for summary in my_decks: var deck: Dictionary = summary.get("deck", {}) deck_selector.add_item(str(deck.get("name", "Deck"))) deck_ids.append(str(deck.get("id", ""))) if official_decks.size() > 0: deck_selector.add_separator("Official Decks") deck_ids.append("") for summary in official_decks: var deck: Dictionary = summary.get("deck", {}) deck_selector.add_item(str(deck.get("name", "Deck"))) deck_ids.append(str(deck.get("id", ""))) for summary in decks: total_due += int(summary.get("due", 0)) total_new += int(summary.get("new", 0)) if previous_selection != null: var restored := deck_ids.find(previous_selection) if restored >= 0: deck_selector.select(restored) if stat_due_value != null: stat_due_value.text = str(total_due) stat_new_value.text = str(total_new) stat_decks_value.text = str(decks.size()) 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 _is_system_deck(id: String) -> bool: if id.is_empty(): return false for summary in decks: var deck: Dictionary = summary.get("deck", {}) if str(deck.get("id", "")) == id: return bool(deck.get("isSystem", false)) return false 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() var dark := UIThemeService.is_dark_mode() if list_section_title != null: list_section_title.text = "Your flashcards (%d)" % notes.size() status_label.text = "" if notes.is_empty(): var empty_label := Label.new() empty_label.text = "No flashcards match these filters yet." empty_label.add_theme_color_override("font_color", Brand.muted_color(dark)) list_container.add_child(empty_label) return for index in notes.size(): if index > 0: list_container.add_child(_row_separator(dark)) list_container.add_child(_note_row(notes[index])) func _note_row(note: Dictionary) -> Control: var dark := UIThemeService.is_dark_mode() var type_str := str(note.get("type", "basic")) var accent := Brand.TEAL if type_str == "cloze" else Brand.ROYAL_BLUE var row := BoxContainer.new() row.vertical = compact_layout row.add_theme_constant_override("separation", 14) var icon_panel := PanelContainer.new() icon_panel.custom_minimum_size = Vector2(40, 40) icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, accent, 20)) var icon := TextureRect.new() icon.texture = load("res://assets/icons/lucide/%s.svg" % ("notebook-pen" if type_str == "cloze" else "clipboard-list")) icon.custom_minimum_size = Vector2(18, 18) icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED icon_panel.add_child(icon) row.add_child(icon_panel) var content := VBoxContainer.new() content.size_flags_horizontal = Control.SIZE_EXPAND_FILL content.add_theme_constant_override("separation", 4) row.add_child(content) var header_row := HBoxContainer.new() header_row.add_theme_constant_override("separation", 8) content.add_child(header_row) var type_label := Label.new() type_label.text = type_str.capitalize() type_label.add_theme_font_size_override("font_size", 12) type_label.add_theme_color_override("font_color", accent) header_row.add_child(type_label) var is_system := bool(note.get("isSystem", false)) if is_system: var official_label := Label.new() official_label.text = "Official" official_label.add_theme_font_size_override("font_size", 12) official_label.add_theme_color_override("font_color", Brand.PURPLE) header_row.add_child(official_label) var tags: Array = note.get("tags", []) if tags.size() > 0: var tags_label := Label.new() tags_label.text = ", ".join(PackedStringArray(tags)) tags_label.add_theme_font_size_override("font_size", 12) tags_label.add_theme_color_override("font_color", Brand.muted_color(dark)) header_row.add_child(tags_label) 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 actions := HBoxContainer.new() actions.add_theme_constant_override("separation", 8) row.add_child(actions) if not is_system: var edit_button := Button.new() edit_button.text = "Edit" edit_button.custom_minimum_size = Vector2(70, 40) edit_button.add_theme_stylebox_override("normal", Brand.secondary_button_box(dark)) edit_button.add_theme_stylebox_override("hover", Brand.secondary_button_box(dark, true)) edit_button.pressed.connect(_open_editor.bind(str(note.get("id", "")), note)) actions.add_child(edit_button) var archive_button := Button.new() archive_button.text = "Archive" archive_button.custom_minimum_size = Vector2(85, 40) archive_button.add_theme_stylebox_override("normal", Brand.secondary_button_box(dark)) archive_button.add_theme_stylebox_override("hover", Brand.secondary_button_box(dark, true)) archive_button.add_theme_color_override("font_color", Color("ff5f76") if dark else Color("be123c")) archive_button.pressed.connect(func(): FlashcardService.archive_notes([str(note.get("id", ""))], true) await get_tree().create_timer(0.3).timeout _reload_notes()) actions.add_child(archive_button) return row func _row_separator(dark: bool) -> ColorRect: var line := ColorRect.new() line.custom_minimum_size = Vector2(0, 1) line.color = Color("1c2b49") if dark else Brand.LIGHT_BORDER return line # ---------- 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_deck_label = Label.new() editor_deck_label.add_theme_font_size_override("font_size", 13) editor_deck_label.add_theme_color_override("font_color", Brand.muted_color(UIThemeService.is_dark_mode())) content.add_child(editor_deck_label) 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("Answer")) 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 (optional teaching pearl)")) 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_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 if note_id.is_empty(): editor_target_deck_id = "" if _is_system_deck(_selected_deck_id()) else _selected_deck_id() else: editor_target_deck_id = str(note.get("deckId", "")) editor_deck_label.text = "Saving to: %s" % _deck_name_for_id(editor_target_deck_id) _on_editor_type_changed(editor_type_selector.selected) editor_overlay.visible = true func _deck_name_for_id(id: String) -> String: if not id.is_empty(): for summary in decks: var deck: Dictionary = summary.get("deck", {}) if str(deck.get("id", "")) == id: return str(deck.get("name", "Default")) return "Default" 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": editor_target_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()) # ---------- 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_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 editor_deck_label != null: editor_deck_label.add_theme_color_override("font_color", Brand.muted_color(dark)) if import_panel != null: import_panel.add_theme_stylebox_override("panel", Brand.panel_box(dark)) if import_menu != null: import_menu.add_theme_stylebox_override("panel", Brand.panel_box(dark)) if list_card != null: list_card.add_theme_stylebox_override("panel", _list_card_box(dark)) if list_section_title != null: list_section_title.add_theme_color_override("font_color", Brand.text_color(dark)) _build_stats() _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()