Compare commits
No commits in common. "main" and "0.1.0" have entirely different histories.
13 changed files with 82 additions and 161 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -1,6 +1,3 @@
|
||||||
# Godot 4+ specific ignores
|
# Godot 4+ specific ignores
|
||||||
.godot/
|
.godot/
|
||||||
/android/
|
/android/
|
||||||
/keystore/
|
|
||||||
/dist/
|
|
||||||
|
|
||||||
|
|
|
@ -9,7 +9,7 @@ custom_features=""
|
||||||
export_filter="all_resources"
|
export_filter="all_resources"
|
||||||
include_filter=""
|
include_filter=""
|
||||||
exclude_filter=""
|
exclude_filter=""
|
||||||
export_path="dist/Repeat-After-Me-1.0.1.apk"
|
export_path="./Repeat After Me.apk"
|
||||||
patches=PackedStringArray()
|
patches=PackedStringArray()
|
||||||
encryption_include_filters=""
|
encryption_include_filters=""
|
||||||
encryption_exclude_filters=""
|
encryption_exclude_filters=""
|
||||||
|
|
|
@ -11,7 +11,7 @@ config_version=5
|
||||||
[application]
|
[application]
|
||||||
|
|
||||||
config/name="Repeat After Me"
|
config/name="Repeat After Me"
|
||||||
config/version="1.0.1"
|
config/version="0.1.0"
|
||||||
run/main_scene="uid://cv38ubwerjlpx"
|
run/main_scene="uid://cv38ubwerjlpx"
|
||||||
config/features=PackedStringArray("4.4", "Mobile")
|
config/features=PackedStringArray("4.4", "Mobile")
|
||||||
run/low_processor_mode=true
|
run/low_processor_mode=true
|
||||||
|
|
|
@ -179,10 +179,17 @@ offset_right = -40.0
|
||||||
offset_bottom = 320.0
|
offset_bottom = 320.0
|
||||||
alignment = 1
|
alignment = 1
|
||||||
keep_editing_on_text_submit = true
|
keep_editing_on_text_submit = true
|
||||||
context_menu_enabled = false
|
|
||||||
middle_mouse_paste_enabled = false
|
[node name="SubmitButton" type="Button" parent="."]
|
||||||
selecting_enabled = false
|
unique_name_in_owner = true
|
||||||
drag_and_drop_selection_enabled = false
|
layout_mode = 2
|
||||||
|
anchor_left = 0.5
|
||||||
|
anchor_right = 0.5
|
||||||
|
offset_left = -40.0
|
||||||
|
offset_top = 344.0
|
||||||
|
offset_right = 40.0
|
||||||
|
offset_bottom = 376.0
|
||||||
|
text = "Submit"
|
||||||
|
|
||||||
[node name="SettingsMenu" parent="." instance=ExtResource("8_85g3d")]
|
[node name="SettingsMenu" parent="." instance=ExtResource("8_85g3d")]
|
||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
|
@ -207,7 +214,6 @@ offset_right = 32.0
|
||||||
offset_bottom = -33.7
|
offset_bottom = -33.7
|
||||||
grow_horizontal = 2
|
grow_horizontal = 2
|
||||||
grow_vertical = 0
|
grow_vertical = 0
|
||||||
focus_mode = 2
|
|
||||||
theme_override_styles/panel = SubResource("StyleBoxFlat_ya4ey")
|
theme_override_styles/panel = SubResource("StyleBoxFlat_ya4ey")
|
||||||
script = ExtResource("11_ya4ey")
|
script = ExtResource("11_ya4ey")
|
||||||
|
|
||||||
|
@ -222,3 +228,4 @@ vertical_alignment = 1
|
||||||
[connection signal="pressed" from="ShowSettingsButton" to="." method="_on_settings_button_pressed"]
|
[connection signal="pressed" from="ShowSettingsButton" to="." method="_on_settings_button_pressed"]
|
||||||
[connection signal="pressed" from="ShowPhrasesButton" to="." method="_on_show_phrases_button_pressed"]
|
[connection signal="pressed" from="ShowPhrasesButton" to="." method="_on_show_phrases_button_pressed"]
|
||||||
[connection signal="text_changed" from="AnswerInput" to="." method="_on_answer_input_text_changed"]
|
[connection signal="text_changed" from="AnswerInput" to="." method="_on_answer_input_text_changed"]
|
||||||
|
[connection signal="pressed" from="SubmitButton" to="." method="_on_submit_button_pressed"]
|
||||||
|
|
|
@ -57,10 +57,6 @@ offset_top = 56.0
|
||||||
offset_right = 184.0
|
offset_right = 184.0
|
||||||
offset_bottom = 75.0
|
offset_bottom = 75.0
|
||||||
keep_editing_on_text_submit = true
|
keep_editing_on_text_submit = true
|
||||||
context_menu_enabled = false
|
|
||||||
middle_mouse_paste_enabled = false
|
|
||||||
selecting_enabled = false
|
|
||||||
drag_and_drop_selection_enabled = false
|
|
||||||
|
|
||||||
[node name="AddPhraseButton" type="Button" parent="."]
|
[node name="AddPhraseButton" type="Button" parent="."]
|
||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
|
|
|
@ -22,13 +22,6 @@ offset_bottom = 16.0
|
||||||
clip_text = true
|
clip_text = true
|
||||||
text_overrun_behavior = 3
|
text_overrun_behavior = 3
|
||||||
|
|
||||||
[node name="LabelButton" type="TextureButton" parent="."]
|
|
||||||
layout_mode = 1
|
|
||||||
anchors_preset = -1
|
|
||||||
anchor_right = 1.143
|
|
||||||
offset_right = -48.032
|
|
||||||
offset_bottom = 16.0
|
|
||||||
|
|
||||||
[node name="RemoveButton" type="TextureButton" parent="."]
|
[node name="RemoveButton" type="TextureButton" parent="."]
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
anchor_left = 1.036
|
anchor_left = 1.036
|
||||||
|
@ -39,5 +32,4 @@ offset_bottom = 16.0
|
||||||
texture_normal = ExtResource("2_cv30m")
|
texture_normal = ExtResource("2_cv30m")
|
||||||
stretch_mode = 0
|
stretch_mode = 0
|
||||||
|
|
||||||
[connection signal="pressed" from="LabelButton" to="." method="_on_label_button_pressed"]
|
|
||||||
[connection signal="pressed" from="RemoveButton" to="." method="_on_remove_button_pressed"]
|
[connection signal="pressed" from="RemoveButton" to="." method="_on_remove_button_pressed"]
|
||||||
|
|
|
@ -32,15 +32,6 @@ theme = ExtResource("2_lwwgp")
|
||||||
theme_override_styles/panel = SubResource("StyleBoxTexture_choun")
|
theme_override_styles/panel = SubResource("StyleBoxTexture_choun")
|
||||||
script = ExtResource("2_labj1")
|
script = ExtResource("2_labj1")
|
||||||
|
|
||||||
[node name="MenuLabel" type="Label" parent="."]
|
|
||||||
layout_mode = 0
|
|
||||||
offset_left = 16.0
|
|
||||||
offset_top = 16.0
|
|
||||||
offset_right = 104.0
|
|
||||||
offset_bottom = 39.0
|
|
||||||
text = "Settings"
|
|
||||||
label_settings = SubResource("LabelSettings_labj1")
|
|
||||||
|
|
||||||
[node name="CloseSettingsButton" type="TextureButton" parent="."]
|
[node name="CloseSettingsButton" type="TextureButton" parent="."]
|
||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
anchors_preset = 1
|
anchors_preset = 1
|
||||||
|
@ -54,44 +45,22 @@ grow_horizontal = 0
|
||||||
texture_normal = ExtResource("2_wswnh")
|
texture_normal = ExtResource("2_wswnh")
|
||||||
stretch_mode = 0
|
stretch_mode = 0
|
||||||
|
|
||||||
[node name="ImportExportLabel" type="Label" parent="."]
|
[node name="Label" type="Label" parent="."]
|
||||||
layout_mode = 0
|
layout_mode = 0
|
||||||
offset_left = 16.0
|
offset_left = 16.0
|
||||||
offset_top = 80.0
|
offset_top = 16.0
|
||||||
offset_right = 144.0
|
offset_right = 104.0
|
||||||
offset_bottom = 114.8
|
offset_bottom = 39.0
|
||||||
text = "Import | Export
|
text = "Settings"
|
||||||
to/from Clipboard"
|
label_settings = SubResource("LabelSettings_labj1")
|
||||||
|
|
||||||
[node name="ImportButton" type="Button" parent="ImportExportLabel"]
|
[node name="ResetXPButton" type="Button" parent="."]
|
||||||
layout_mode = 0
|
|
||||||
offset_top = 48.0
|
|
||||||
offset_right = 61.0
|
|
||||||
offset_bottom = 72.0
|
|
||||||
text = "Import"
|
|
||||||
|
|
||||||
[node name="ExportButton" type="Button" parent="ImportExportLabel"]
|
|
||||||
layout_mode = 0
|
|
||||||
offset_left = 75.0
|
|
||||||
offset_top = 48.0
|
|
||||||
offset_right = 136.0
|
|
||||||
offset_bottom = 72.0
|
|
||||||
text = "Export"
|
|
||||||
|
|
||||||
[node name="ResetLabel" type="Label" parent="."]
|
|
||||||
layout_mode = 0
|
layout_mode = 0
|
||||||
offset_left = 16.0
|
offset_left = 16.0
|
||||||
offset_top = 192.0
|
offset_top = 72.0
|
||||||
offset_right = 91.0
|
offset_right = 92.0
|
||||||
offset_bottom = 207.9
|
offset_bottom = 96.0
|
||||||
text = "Reset Data"
|
text = "Reset XP"
|
||||||
|
|
||||||
[node name="ResetXpAndStatsButton" type="Button" parent="ResetLabel"]
|
|
||||||
layout_mode = 0
|
|
||||||
offset_top = 24.0
|
|
||||||
offset_right = 76.0
|
|
||||||
offset_bottom = 48.0
|
|
||||||
text = "Reset XP & Stats"
|
|
||||||
|
|
||||||
[node name="Info Footer" type="Label" parent="."]
|
[node name="Info Footer" type="Label" parent="."]
|
||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
|
@ -115,6 +84,4 @@ text_overrun_behavior = 3
|
||||||
script = ExtResource("5_lwwgp")
|
script = ExtResource("5_lwwgp")
|
||||||
|
|
||||||
[connection signal="pressed" from="CloseSettingsButton" to="." method="_on_close_settings_button_pressed"]
|
[connection signal="pressed" from="CloseSettingsButton" to="." method="_on_close_settings_button_pressed"]
|
||||||
[connection signal="pressed" from="ImportExportLabel/ImportButton" to="." method="_on_import_button_pressed"]
|
[connection signal="pressed" from="ResetXPButton" to="." method="_on_reset_xp_button_pressed"]
|
||||||
[connection signal="pressed" from="ImportExportLabel/ExportButton" to="." method="_on_export_button_pressed"]
|
|
||||||
[connection signal="pressed" from="ResetLabel/ResetXpAndStatsButton" to="." method="_on_reset_xp_and_stats_button_pressed"]
|
|
||||||
|
|
|
@ -14,13 +14,15 @@ var _last_autocleanup: int = 0 # ms since engine start
|
||||||
var current_phrase: String = ""
|
var current_phrase: String = ""
|
||||||
var current_status: String = ""
|
var current_status: String = ""
|
||||||
|
|
||||||
# represents the order of last played phrases
|
var last_played_phrases: Dictionary = {} # p: [timestamp1, timestamp2, ...]
|
||||||
var last_played_phrases: Array = []
|
|
||||||
|
|
||||||
func register_current_phrase_played():
|
func register_current_phrase_played():
|
||||||
|
var t = Time.get_unix_time_from_system()
|
||||||
if current_phrase in last_played_phrases:
|
if current_phrase in last_played_phrases:
|
||||||
last_played_phrases.erase(current_phrase)
|
last_played_phrases[current_phrase].append(t)
|
||||||
last_played_phrases.append(current_phrase)
|
else:
|
||||||
|
last_played_phrases[current_phrase] = [t]
|
||||||
SaveManager.save_game()
|
SaveManager.save_game()
|
||||||
|
|
||||||
func answer(p_in: String) -> bool:
|
func answer(p_in: String) -> bool:
|
||||||
|
@ -36,33 +38,23 @@ func next_phrase() -> void:
|
||||||
current_phrase = ""
|
current_phrase = ""
|
||||||
current_status = STATUS_NONE_AVAILABLE
|
current_status = STATUS_NONE_AVAILABLE
|
||||||
return
|
return
|
||||||
# pick a random non-played phrase, if possible
|
# search for a non played phrase
|
||||||
var phrases_not_played = []
|
|
||||||
for p in PhrasesManager.phrases:
|
for p in PhrasesManager.phrases:
|
||||||
if not p in last_played_phrases:
|
if not p in last_played_phrases:
|
||||||
phrases_not_played.append(p)
|
|
||||||
if len(phrases_not_played) > 0:
|
|
||||||
current_phrase = phrases_not_played.pick_random()
|
|
||||||
current_status = STATUS_PLEASE_REPEAT
|
|
||||||
return
|
|
||||||
# find the half of phrases that were repeated longest ago
|
|
||||||
var i_max = max(1, roundi(float(len(last_played_phrases)) / 2))
|
|
||||||
var phrases_played_longest_ago = last_played_phrases.slice(0, i_max)
|
|
||||||
# pick random phrase
|
|
||||||
var phrase = phrases_played_longest_ago.pick_random()
|
|
||||||
if phrase == null: # this shouldn't happen!
|
|
||||||
current_phrase = ""
|
|
||||||
current_status = STATUS_NONE_AVAILABLE
|
|
||||||
else:
|
|
||||||
current_phrase = phrase
|
|
||||||
current_status = STATUS_PLEASE_REPEAT
|
|
||||||
|
|
||||||
func try_overwrite_next_phrase(p: String):
|
|
||||||
if p in PhrasesManager.phrases:
|
|
||||||
current_phrase = p
|
current_phrase = p
|
||||||
current_status = STATUS_PLEASE_REPEAT
|
current_status = STATUS_PLEASE_REPEAT
|
||||||
else: # if this didn't work, choose one automatically
|
return
|
||||||
next_phrase()
|
# find the phrase that was played the longest ago
|
||||||
|
var phrases_last_ts: Dictionary = {} # timestamp: phrase
|
||||||
|
var phrases_last_ts_keys: Array[float] = []
|
||||||
|
for p in last_played_phrases:
|
||||||
|
if p in PhrasesManager.phrases:
|
||||||
|
var t_max = last_played_phrases[p].max()
|
||||||
|
phrases_last_ts[t_max] = p
|
||||||
|
phrases_last_ts_keys.append(t_max)
|
||||||
|
# return the phrase with the smallest timestamp (-> longest ago)
|
||||||
|
current_phrase = phrases_last_ts[phrases_last_ts_keys.min()]
|
||||||
|
current_status = STATUS_PLEASE_REPEAT
|
||||||
|
|
||||||
func cleanup_last_played_phrases():
|
func cleanup_last_played_phrases():
|
||||||
var changed = false
|
var changed = false
|
||||||
|
|
|
@ -5,33 +5,13 @@ const SAVEFILE = "user://save.dat"
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
load_game()
|
load_game()
|
||||||
|
|
||||||
func _to_dict() -> Dictionary:
|
func save_game():
|
||||||
return {
|
var data: Dictionary = {
|
||||||
"player_xp": XpLevelManager.player_xp,
|
"player_xp": XpLevelManager.player_xp,
|
||||||
"phrases": PhrasesManager.phrases,
|
"phrases": PhrasesManager.phrases,
|
||||||
"last_played_phrases": CoreGameplayManager.last_played_phrases
|
"last_played_phrases": CoreGameplayManager.last_played_phrases
|
||||||
}
|
}
|
||||||
|
var data_json = JSON.stringify(data)
|
||||||
func _from_dict(data: Dictionary) -> int:
|
|
||||||
var successfully_set = 0
|
|
||||||
# set variables
|
|
||||||
if "player_xp" in data:
|
|
||||||
XpLevelManager.loading = true
|
|
||||||
XpLevelManager.player_xp = data["player_xp"]
|
|
||||||
XpLevelManager.loading = false
|
|
||||||
successfully_set += 1 #!
|
|
||||||
if "phrases" in data and data["phrases"] is Array:
|
|
||||||
PhrasesManager.phrases = []
|
|
||||||
for p in data["phrases"]:
|
|
||||||
PhrasesManager.phrases.append(p)
|
|
||||||
successfully_set += 1 #!
|
|
||||||
if "last_played_phrases" in data and data["last_played_phrases"] is Array:
|
|
||||||
CoreGameplayManager.last_played_phrases = data["last_played_phrases"]
|
|
||||||
successfully_set += 1 #!
|
|
||||||
return successfully_set
|
|
||||||
|
|
||||||
func save_game():
|
|
||||||
var data_json = JSON.stringify(_to_dict())
|
|
||||||
var f = FileAccess.open(SAVEFILE, FileAccess.WRITE)
|
var f = FileAccess.open(SAVEFILE, FileAccess.WRITE)
|
||||||
f.store_string(data_json)
|
f.store_string(data_json)
|
||||||
f.close()
|
f.close()
|
||||||
|
@ -40,19 +20,14 @@ func load_game():
|
||||||
if FileAccess.file_exists(SAVEFILE):
|
if FileAccess.file_exists(SAVEFILE):
|
||||||
var data_json = FileAccess.get_file_as_string(SAVEFILE)
|
var data_json = FileAccess.get_file_as_string(SAVEFILE)
|
||||||
var data = JSON.parse_string(data_json)
|
var data = JSON.parse_string(data_json)
|
||||||
_from_dict(data)
|
# set variables
|
||||||
|
if "player_xp" in data:
|
||||||
func export_to_base64() -> String:
|
XpLevelManager.loading = true
|
||||||
var data_json = JSON.stringify(_to_dict())
|
XpLevelManager.player_xp = data["player_xp"]
|
||||||
return Marshalls.utf8_to_base64(data_json)
|
XpLevelManager.loading = false
|
||||||
|
if "phrases" in data and data["phrases"] is Array:
|
||||||
func import_from_base64(encoded_data: String) -> bool:
|
PhrasesManager.phrases = []
|
||||||
var data_json = Marshalls.base64_to_utf8(encoded_data)
|
for p in data["phrases"]:
|
||||||
var data_dict = JSON.parse_string(data_json)
|
PhrasesManager.phrases.append(p)
|
||||||
if data_dict == null:
|
if "last_played_phrases" in data and data["last_played_phrases"] is Dictionary:
|
||||||
return false
|
CoreGameplayManager.last_played_phrases = data["last_played_phrases"]
|
||||||
var n_successful: int = _from_dict(data_dict) > 0
|
|
||||||
if n_successful > 0:
|
|
||||||
save_game()
|
|
||||||
return true
|
|
||||||
else: return false
|
|
||||||
|
|
|
@ -2,6 +2,7 @@ extends Control
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
CoreGameplayManager.next_phrase()
|
CoreGameplayManager.next_phrase()
|
||||||
|
%SubmitButton.hide()
|
||||||
%AnswerInput.hide()
|
%AnswerInput.hide()
|
||||||
|
|
||||||
func _on_settings_button_pressed() -> void:
|
func _on_settings_button_pressed() -> void:
|
||||||
|
@ -23,10 +24,17 @@ func _process(_delta: float) -> void:
|
||||||
else:
|
else:
|
||||||
%CurrentPhrase.text = ""
|
%CurrentPhrase.text = ""
|
||||||
%AnswerInput.hide()
|
%AnswerInput.hide()
|
||||||
|
%SubmitButton.hide()
|
||||||
if last_known_status != CoreGameplayManager.current_status:
|
if last_known_status != CoreGameplayManager.current_status:
|
||||||
last_known_status = CoreGameplayManager.current_status
|
last_known_status = CoreGameplayManager.current_status
|
||||||
%CurrentStatus.text = last_known_status
|
%CurrentStatus.text = last_known_status
|
||||||
|
|
||||||
func _on_answer_input_text_changed(new_text: String) -> void:
|
func _on_submit_button_pressed() -> void:
|
||||||
if CoreGameplayManager.answer(new_text):
|
if CoreGameplayManager.answer(%AnswerInput.text):
|
||||||
%AnswerInput.clear()
|
%AnswerInput.clear()
|
||||||
|
|
||||||
|
func _on_answer_input_text_changed(new_text: String) -> void:
|
||||||
|
if new_text.to_lower() == CoreGameplayManager.current_phrase.to_lower():
|
||||||
|
%SubmitButton.show()
|
||||||
|
else:
|
||||||
|
%SubmitButton.hide()
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
extends PanelContainer
|
extends PanelContainer
|
||||||
|
|
||||||
|
var showing_notification: bool = false
|
||||||
var notification_started: int = 0 # in ms
|
var notification_started: int = 0 # in ms
|
||||||
var current_notification_timeout: int = 0 # in ms
|
var current_notification_timeout: int = 0 # in ms
|
||||||
|
|
||||||
|
@ -8,14 +9,18 @@ func _ready() -> void:
|
||||||
|
|
||||||
func _process(_delta: float) -> void:
|
func _process(_delta: float) -> void:
|
||||||
var t = Time.get_ticks_msec()
|
var t = Time.get_ticks_msec()
|
||||||
if visible:
|
if showing_notification:
|
||||||
if t - notification_started > current_notification_timeout:
|
if t - notification_started > current_notification_timeout:
|
||||||
hide()
|
showing_notification = false
|
||||||
else:
|
else:
|
||||||
var n = NotificationQueue.get_next() # [text, timeout] or null
|
var n = NotificationQueue.get_next() # [text, timeout] or null
|
||||||
if n != null:
|
if n != null:
|
||||||
|
showing_notification = true
|
||||||
notification_started = t
|
notification_started = t
|
||||||
$Label.text = n[0]
|
$Label.text = n[0]
|
||||||
current_notification_timeout = n[1]
|
current_notification_timeout = n[1]
|
||||||
|
#
|
||||||
|
if not showing_notification and visible:
|
||||||
|
hide()
|
||||||
|
elif showing_notification and not visible:
|
||||||
show()
|
show()
|
||||||
grab_focus()
|
|
||||||
|
|
|
@ -8,6 +8,3 @@ var text: String:
|
||||||
|
|
||||||
func _on_remove_button_pressed() -> void:
|
func _on_remove_button_pressed() -> void:
|
||||||
PhrasesManager.remove_phrase(text)
|
PhrasesManager.remove_phrase(text)
|
||||||
|
|
||||||
func _on_label_button_pressed() -> void:
|
|
||||||
CoreGameplayManager.try_overwrite_next_phrase(text)
|
|
||||||
|
|
|
@ -6,22 +6,7 @@ func _ready() -> void:
|
||||||
func _on_close_settings_button_pressed() -> void:
|
func _on_close_settings_button_pressed() -> void:
|
||||||
hide()
|
hide()
|
||||||
|
|
||||||
func _on_reset_xp_and_stats_button_pressed() -> void:
|
func _on_reset_xp_button_pressed() -> void:
|
||||||
XpLevelManager.player_xp = 0
|
XpLevelManager.player_xp = 0
|
||||||
CoreGameplayManager.last_played_phrases = []
|
|
||||||
SaveManager.save_game()
|
SaveManager.save_game()
|
||||||
CoreGameplayManager.next_phrase()
|
NotificationQueue.add("Reset XP.")
|
||||||
NotificationQueue.add("Reset XP & Stats.")
|
|
||||||
|
|
||||||
func _on_import_button_pressed() -> void:
|
|
||||||
var data = DisplayServer.clipboard_get()
|
|
||||||
if SaveManager.import_from_base64(data):
|
|
||||||
NotificationQueue.add("Import successful", 4000)
|
|
||||||
CoreGameplayManager.next_phrase()
|
|
||||||
else:
|
|
||||||
NotificationQueue.add("Import failed", 4000)
|
|
||||||
|
|
||||||
func _on_export_button_pressed() -> void:
|
|
||||||
var data = SaveManager.export_to_base64()
|
|
||||||
DisplayServer.clipboard_set(data)
|
|
||||||
NotificationQueue.add("Exported to clipboard", 4000)
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue