They only closed via their own trigger button or a menu item - clicking outside, pressing Escape, or opening the other dropdown while one was open left it stuck on screen (confirmed opening both stacked on top of each other). Add a shared invisible backdrop (mirroring main.gd's drawer-backdrop pattern) that closes whichever dropdown is open on outside click or Escape, and have opening one close the other first.
651 lines
22 KiB
GDScript
651 lines
22 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 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_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_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 export_menu != null:
|
|
export_menu.visible = false
|
|
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 = [""]
|
|
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()
|