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.
846 lines
30 KiB
GDScript
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()
|