Files
step3/scripts/ui/flashcards_hub.gd
hz 7f76ca0fdc Add system (curated) vs. user-uploaded flashcards
System decks/notes are owned by a reserved sentinel user id, reusing
all existing per-user store/index plumbing unchanged. Regular users
see them under a separate "Official Decks" section, can study them
with fully independent per-user FSRS progress (lazily materialized on
first visit), but can never edit or archive them - enforced both in
the client (no Edit/Archive controls) and server-side (ownership guard
on every mutating endpoint). Includes a small idempotent seed command
(`dotnet run -- seed-flashcards`) for testing, since there's no in-app
authoring UI yet.
2026-07-22 15:52:19 -05:00

846 lines
30 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_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()