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.
810 lines
29 KiB
GDScript
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()
|