Wire flashcard CSV/JSON import/export and fix theme/responsive gaps
Import and export were stubbed with status-text placeholders; wire them to the existing backend endpoints with a real file picker (web JS bridge or native FileDialog) and browser/desktop download, plus an import preview with create/update/skip/error counts before commit. Also fix the library and study screens' theme-refresh handlers, which were no-op stubs so cards never restyled on a theme toggle, and add responsive layout handling for narrow viewports.
This commit is contained in:
+213
-16
@@ -22,6 +22,7 @@ var list_container: VBoxContainer
|
||||
var debounce_timer: Timer
|
||||
|
||||
var editor_overlay: Control
|
||||
var editor_panel: PanelContainer
|
||||
var editor_note_id := ""
|
||||
var editor_expected_revision: Variant = null
|
||||
var editor_type_selector: OptionButton
|
||||
@@ -32,6 +33,18 @@ var editor_tags_input: LineEdit
|
||||
var editor_hint_label: Label
|
||||
var editor_error_label: Label
|
||||
|
||||
var export_menu: PanelContainer
|
||||
var import_menu: PanelContainer
|
||||
|
||||
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 = []
|
||||
|
||||
@@ -58,11 +71,17 @@ func _ready() -> void:
|
||||
debounce_timer.timeout.connect(_reload_notes)
|
||||
add_child(debounce_timer)
|
||||
_build_editor_overlay()
|
||||
_build_import_overlay()
|
||||
resized.connect(_update_responsive_layout)
|
||||
call_deferred("_update_responsive_layout")
|
||||
|
||||
FlashcardService.decks_changed.connect(_on_decks_changed)
|
||||
FlashcardService.notes_loaded.connect(_on_notes_loaded)
|
||||
FlashcardService.note_saved.connect(_on_note_saved)
|
||||
FlashcardService.request_failed.connect(_on_request_failed)
|
||||
FlashcardService.export_ready.connect(_on_export_ready)
|
||||
FlashcardService.import_preview_ready.connect(_on_import_preview_ready)
|
||||
FlashcardService.import_committed.connect(_on_import_committed)
|
||||
UIThemeService.theme_changed.connect(func(_dark: bool): _refresh_status())
|
||||
|
||||
|
||||
@@ -104,14 +123,60 @@ func _build_toolbar() -> void:
|
||||
import_button = Button.new()
|
||||
import_button.text = "Import"
|
||||
import_button.custom_minimum_size = Vector2(0, 44)
|
||||
import_button.pressed.connect(_open_import_dialog)
|
||||
import_button.pressed.connect(func(): _toggle_dropdown(import_menu, import_button))
|
||||
row.add_child(import_button)
|
||||
import_menu = _build_dropdown([
|
||||
["Import CSV...", func():
|
||||
status_label.text = "Choose a file to import..."
|
||||
FlashcardService.pick_import_file("csv")],
|
||||
["Import JSON...", func():
|
||||
status_label.text = "Choose a file to import..."
|
||||
FlashcardService.pick_import_file("json")],
|
||||
])
|
||||
|
||||
export_button = Button.new()
|
||||
export_button.text = "Export"
|
||||
export_button.custom_minimum_size = Vector2(0, 44)
|
||||
export_button.pressed.connect(_export_csv)
|
||||
export_button.pressed.connect(func(): _toggle_dropdown(export_menu, export_button))
|
||||
row.add_child(export_button)
|
||||
export_menu = _build_dropdown([
|
||||
["Export CSV", func():
|
||||
status_label.text = "Preparing export..."
|
||||
FlashcardService.export_csv()],
|
||||
["Export JSON", func():
|
||||
status_label.text = "Preparing export..."
|
||||
FlashcardService.export_json()],
|
||||
])
|
||||
|
||||
|
||||
func _build_dropdown(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():
|
||||
menu.visible = false
|
||||
action.call())
|
||||
list.add_child(button)
|
||||
return menu
|
||||
|
||||
|
||||
func _toggle_dropdown(menu: PanelContainer, anchor: Control) -> void:
|
||||
if menu.visible:
|
||||
menu.visible = false
|
||||
return
|
||||
menu.visible = true
|
||||
menu.position = anchor.get_global_position() + Vector2(0, anchor.size.y + 4)
|
||||
|
||||
|
||||
func _build_filters() -> void:
|
||||
@@ -196,7 +261,8 @@ func _note_row(note: Dictionary) -> Control:
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
var card := PanelContainer.new()
|
||||
card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false, Brand.ROYAL_BLUE))
|
||||
var row := HBoxContainer.new()
|
||||
var row := BoxContainer.new()
|
||||
row.vertical = compact_layout
|
||||
row.add_theme_constant_override("separation", 12)
|
||||
card.add_child(row)
|
||||
|
||||
@@ -255,13 +321,13 @@ func _build_editor_overlay() -> void:
|
||||
var center := CenterContainer.new()
|
||||
center.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
editor_overlay.add_child(center)
|
||||
var panel := PanelContainer.new()
|
||||
panel.custom_minimum_size = Vector2(620, 560)
|
||||
panel.add_theme_stylebox_override("panel", Brand.panel_box(UIThemeService.is_dark_mode()))
|
||||
center.add_child(panel)
|
||||
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)
|
||||
panel.add_child(content)
|
||||
editor_panel.add_child(content)
|
||||
|
||||
var header := HBoxContainer.new()
|
||||
content.add_child(header)
|
||||
@@ -394,7 +460,9 @@ func _on_note_saved(_note: Dictionary) -> void:
|
||||
FlashcardService.fetch_decks()
|
||||
|
||||
|
||||
func _on_request_failed(_tag: String, message: String) -> void:
|
||||
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
|
||||
@@ -408,15 +476,144 @@ func _start_study() -> void:
|
||||
main.show_screen("flashcards_study", _selected_deck_id())
|
||||
|
||||
|
||||
func _export_csv() -> void:
|
||||
# CSV/JSON export downloads through the same authenticated HTTP endpoint the desktop/web
|
||||
# build already uses for media; a full save-file picker is out of scope for this pass.
|
||||
status_label.text = "Export requested. Check your downloads once the file streams."
|
||||
func _on_export_ready(location: String) -> void:
|
||||
status_label.text = "Exported to %s." % location
|
||||
|
||||
|
||||
func _open_import_dialog() -> void:
|
||||
status_label.text = "Use the CSV/JSON import screen to pick a file (upload UI pending)."
|
||||
# ---------- Import preview / commit ----------
|
||||
|
||||
func _build_import_overlay() -> void:
|
||||
import_overlay = Control.new()
|
||||
import_overlay.visible = false
|
||||
import_overlay.top_level = true
|
||||
import_overlay.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
import_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
add_child(import_overlay)
|
||||
var dim := ColorRect.new()
|
||||
dim.color = Color(0, 0, 0, 0.72)
|
||||
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
import_overlay.add_child(dim)
|
||||
var center := CenterContainer.new()
|
||||
center.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
import_overlay.add_child(center)
|
||||
import_panel = PanelContainer.new()
|
||||
import_panel.custom_minimum_size = Vector2(620, 480)
|
||||
import_panel.add_theme_stylebox_override("panel", Brand.panel_box(UIThemeService.is_dark_mode()))
|
||||
center.add_child(import_panel)
|
||||
var content := VBoxContainer.new()
|
||||
content.add_theme_constant_override("separation", 10)
|
||||
import_panel.add_child(content)
|
||||
|
||||
var header := HBoxContainer.new()
|
||||
content.add_child(header)
|
||||
var title := Label.new()
|
||||
title.text = "Import preview"
|
||||
title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
title.add_theme_font_size_override("font_size", 22)
|
||||
header.add_child(title)
|
||||
var close_button := Button.new()
|
||||
close_button.icon = preload("res://assets/icons/lucide/x.svg")
|
||||
close_button.custom_minimum_size = Vector2(40, 40)
|
||||
close_button.pressed.connect(_close_import_overlay)
|
||||
header.add_child(close_button)
|
||||
|
||||
import_summary_label = Label.new()
|
||||
import_summary_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
content.add_child(import_summary_label)
|
||||
|
||||
var rows_scroll := ScrollContainer.new()
|
||||
rows_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
content.add_child(rows_scroll)
|
||||
import_rows_container = VBoxContainer.new()
|
||||
import_rows_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
import_rows_container.add_theme_constant_override("separation", 4)
|
||||
rows_scroll.add_child(import_rows_container)
|
||||
|
||||
var actions := HBoxContainer.new()
|
||||
actions.add_theme_constant_override("separation", 10)
|
||||
content.add_child(actions)
|
||||
var cancel_button := Button.new()
|
||||
cancel_button.text = "Cancel"
|
||||
cancel_button.custom_minimum_size = Vector2(0, 46)
|
||||
cancel_button.pressed.connect(_close_import_overlay)
|
||||
actions.add_child(cancel_button)
|
||||
import_commit_button = Button.new()
|
||||
import_commit_button.text = "Commit import"
|
||||
import_commit_button.custom_minimum_size = Vector2(0, 46)
|
||||
import_commit_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
Style.style_primary_button(import_commit_button)
|
||||
import_commit_button.pressed.connect(_commit_pending_import)
|
||||
actions.add_child(import_commit_button)
|
||||
|
||||
|
||||
func _on_import_preview_ready(preview: Dictionary) -> void:
|
||||
import_pending_token = str(preview.get("importToken", ""))
|
||||
var create_count := int(preview.get("createCount", 0))
|
||||
var update_count := int(preview.get("updateCount", 0))
|
||||
var skip_count := int(preview.get("skipCount", 0))
|
||||
var error_count := int(preview.get("errorCount", 0))
|
||||
import_summary_label.text = "%d to create, %d to update, %d to skip, %d row error(s)." % [create_count, update_count, skip_count, error_count]
|
||||
for child in import_rows_container.get_children():
|
||||
child.queue_free()
|
||||
for raw_row in preview.get("rows", []):
|
||||
import_rows_container.add_child(_import_row(raw_row))
|
||||
import_commit_button.disabled = import_pending_token.is_empty() or (create_count + update_count == 0)
|
||||
status_label.text = ""
|
||||
import_overlay.visible = true
|
||||
|
||||
|
||||
func _import_row(row: Dictionary) -> Control:
|
||||
var label := Label.new()
|
||||
var action := str(row.get("action", ""))
|
||||
var error := str(row.get("error", ""))
|
||||
var detail := error if not error.is_empty() else str(row.get("prompt", ""))
|
||||
label.text = "Row %d - %s: %s" % [int(row.get("rowNumber", 0)), action.capitalize(), detail]
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
if action == "error":
|
||||
label.add_theme_color_override("font_color", Style.RED)
|
||||
return label
|
||||
|
||||
|
||||
func _commit_pending_import() -> void:
|
||||
if import_pending_token.is_empty():
|
||||
return
|
||||
import_commit_button.disabled = true
|
||||
FlashcardService.commit_import(import_pending_token)
|
||||
|
||||
|
||||
func _on_import_committed(result: Dictionary) -> void:
|
||||
import_overlay.visible = false
|
||||
import_pending_token = ""
|
||||
status_label.text = "Import complete: %d created, %d updated, %d skipped, %d error(s)." % [
|
||||
int(result.get("created", 0)), int(result.get("updated", 0)), int(result.get("skipped", 0)), int(result.get("errored", 0))]
|
||||
_reload_notes()
|
||||
FlashcardService.fetch_decks()
|
||||
|
||||
|
||||
func _close_import_overlay() -> void:
|
||||
import_overlay.visible = false
|
||||
import_pending_token = ""
|
||||
|
||||
|
||||
# ---------- Theme / responsive ----------
|
||||
|
||||
func _refresh_status() -> void:
|
||||
pass
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
if editor_panel != null:
|
||||
editor_panel.add_theme_stylebox_override("panel", Brand.panel_box(dark))
|
||||
if import_panel != null:
|
||||
import_panel.add_theme_stylebox_override("panel", Brand.panel_box(dark))
|
||||
if export_menu != null:
|
||||
export_menu.add_theme_stylebox_override("panel", Brand.panel_box(dark))
|
||||
if import_menu != null:
|
||||
import_menu.add_theme_stylebox_override("panel", Brand.panel_box(dark))
|
||||
_render_notes()
|
||||
|
||||
|
||||
func _update_responsive_layout() -> void:
|
||||
var use_compact := size.x < 720.0
|
||||
if compact_layout == use_compact:
|
||||
return
|
||||
compact_layout = use_compact
|
||||
search_input.custom_minimum_size.x = 0.0 if compact_layout else 260.0
|
||||
_render_notes()
|
||||
|
||||
Reference in New Issue
Block a user