Files
step3/scripts/ui/flashcards_hub.gd
T
hz 09b670d5d4 Close the gap between the flashcards study screen and the mockup
Add real session-history navigation (Previous/Next/Shuffle), compact
side panels, and a segmented progress row to match the mockup layout.
Flashcards now opens straight into Study, with library management
moved into the Options menu ("Manage Flashcards") and Export hidden
from the UI (Import stays). Shuffle is backed by a real, due-before-new
randomized pick on the backend, not a decorative toggle.
2026-07-22 00:43:52 -05:00

810 lines
29 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 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_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
for summary in decks:
var deck: Dictionary = summary.get("deck", {})
deck_selector.add_item(str(deck.get("name", "Deck")))
deck_ids.append(str(deck.get("id", "")))
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 _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 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)
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
editor_deck_label.text = "Saving to: %s" % _deck_name_for_id(str(note.get("deckId", "")) if not note_id.is_empty() else _selected_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": _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())
# ---------- 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()