mirror of
https://github.com/sissbruecker/linkding.git
synced 2026-02-28 06:53:12 +08:00
Preserve page and scroll position when editing tags (#1291)
This commit is contained in:
@@ -3,6 +3,8 @@
|
|||||||
<div>No bookmarks match the current bundle.</div>
|
<div>No bookmarks match the current bundle.</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="mb-4">Found {{ bookmark_list.bookmarks_total }} bookmarks matching this bundle.</div>
|
<div class="mb-4">Found {{ bookmark_list.bookmarks_total }} bookmarks matching this bundle.</div>
|
||||||
{% include 'bookmarks/bookmark_list.html' %}
|
{% with pagination_frame="preview" %}
|
||||||
|
{% include 'bookmarks/bookmark_list.html' %}
|
||||||
|
{% endwith %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</turbo-frame>
|
</turbo-frame>
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
<ul class="pagination">
|
<ul class="pagination">
|
||||||
{% if prev_link %}
|
{% if prev_link %}
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
<a href="{{ prev_link }}" tabindex="-1">Previous</a>
|
<a href="{{ prev_link }}"
|
||||||
|
tabindex="-1"
|
||||||
|
data-turbo-frame="{{ pagination_frame }}">Previous</a>
|
||||||
</li>
|
</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li class="page-item disabled">
|
<li class="page-item disabled">
|
||||||
@@ -12,7 +14,7 @@
|
|||||||
{% for page_link in page_links %}
|
{% for page_link in page_links %}
|
||||||
{% if page_link %}
|
{% if page_link %}
|
||||||
<li class="page-item {% if page_link.active %}active{% endif %}">
|
<li class="page-item {% if page_link.active %}active{% endif %}">
|
||||||
<a href="{{ page_link.link }}">{{ page_link.number }}</a>
|
<a href="{{ page_link.link }}" data-turbo-frame="{{ pagination_frame }}">{{ page_link.number }}</a>
|
||||||
</li>
|
</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
@@ -22,7 +24,9 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if next_link %}
|
{% if next_link %}
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
<a href="{{ next_link }}" tabindex="-1">Next</a>
|
<a href="{{ next_link }}"
|
||||||
|
tabindex="-1"
|
||||||
|
data-turbo-frame="{{ pagination_frame }}">Next</a>
|
||||||
</li>
|
</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li class="page-item disabled">
|
<li class="page-item disabled">
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<turbo-frame id="tag-modal">
|
<turbo-frame id="tag-modal">
|
||||||
<form method="post"
|
<form method="post"
|
||||||
action="{% url 'linkding:tags.edit' tag.id %}"
|
action="{% url 'linkding:tags.edit' tag.id %}?{{ request.GET.urlencode }}"
|
||||||
data-turbo-frame="_top"
|
data-turbo-frame="tag-main"
|
||||||
novalidate>
|
novalidate>
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<ld-modal class="modal tag-edit-modal active"
|
<ld-modal class="modal tag-edit-modal active"
|
||||||
data-close-url="{% url 'linkding:tags.index' %}"
|
data-close-url="{% url 'linkding:tags.index' %}?{{ request.GET.urlencode }}"
|
||||||
data-turbo-frame="tag-modal">
|
data-turbo-frame="tag-modal">
|
||||||
<div class="modal-overlay" data-close-modal></div>
|
<div class="modal-overlay" data-close-modal></div>
|
||||||
<div class="modal-container" role="dialog" aria-modal="true">
|
<div class="modal-container" role="dialog" aria-modal="true">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="tags-page crud-page">
|
<div class="tags-page crud-page">
|
||||||
|
<turbo-frame id="tag-main">
|
||||||
<main aria-labelledby="main-heading">
|
<main aria-labelledby="main-heading">
|
||||||
<div class="crud-header">
|
<div class="crud-header">
|
||||||
<h1 id="main-heading">Tags</h1>
|
<h1 id="main-heading">Tags</h1>
|
||||||
@@ -97,7 +98,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<a class="btn btn-link"
|
<a class="btn btn-link"
|
||||||
href="{% url 'linkding:tags.edit' tag.id %}"
|
href="{% url 'linkding:tags.edit' tag.id %}?{{ request.GET.urlencode }}"
|
||||||
data-turbo-frame="tag-modal">Edit</a>
|
data-turbo-frame="tag-modal">Edit</a>
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
name="delete_tag"
|
name="delete_tag"
|
||||||
@@ -123,6 +124,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</main>
|
</main>
|
||||||
|
<turbo-frame id="tag-modal"></turbo-frame>
|
||||||
|
</turbo-frame>
|
||||||
</div>
|
</div>
|
||||||
<turbo-frame id="tag-modal"></turbo-frame>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ register = template.Library()
|
|||||||
@register.inclusion_tag("shared/pagination.html", name="pagination", takes_context=True)
|
@register.inclusion_tag("shared/pagination.html", name="pagination", takes_context=True)
|
||||||
def pagination(context, page: Page):
|
def pagination(context, page: Page):
|
||||||
request = context["request"]
|
request = context["request"]
|
||||||
|
pagination_frame = context.get("pagination_frame", "_top")
|
||||||
base_url = request.path
|
base_url = request.path
|
||||||
|
|
||||||
# remove page number and details from query parameters
|
# remove page number and details from query parameters
|
||||||
@@ -51,6 +52,7 @@ def pagination(context, page: Page):
|
|||||||
"prev_link": prev_link,
|
"prev_link": prev_link,
|
||||||
"next_link": next_link,
|
"next_link": next_link,
|
||||||
"page_links": page_links,
|
"page_links": page_links,
|
||||||
|
"pagination_frame": pagination_frame,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ from bookmarks.tests.helpers import BookmarkFactoryMixin
|
|||||||
|
|
||||||
class PaginationTagTest(TestCase, BookmarkFactoryMixin):
|
class PaginationTagTest(TestCase, BookmarkFactoryMixin):
|
||||||
def render_template(
|
def render_template(
|
||||||
self, num_items: int, page_size: int, current_page: int, url: str = "/test"
|
self,
|
||||||
|
num_items: int,
|
||||||
|
page_size: int,
|
||||||
|
current_page: int,
|
||||||
|
url: str = "/test",
|
||||||
|
frame: str = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
rf = RequestFactory()
|
rf = RequestFactory()
|
||||||
request = rf.get(url)
|
request = rf.get(url)
|
||||||
@@ -16,7 +21,11 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
|
|||||||
paginator = Paginator(range(0, num_items), page_size)
|
paginator = Paginator(range(0, num_items), page_size)
|
||||||
page = paginator.page(current_page)
|
page = paginator.page(current_page)
|
||||||
|
|
||||||
context = RequestContext(request, {"page": page})
|
context_dict = {"page": page}
|
||||||
|
if frame:
|
||||||
|
context_dict["pagination_frame"] = frame
|
||||||
|
context = RequestContext(request, context_dict)
|
||||||
|
|
||||||
template_to_render = Template("{% load pagination %}{% pagination page %}")
|
template_to_render = Template("{% load pagination %}{% pagination page %}")
|
||||||
return template_to_render.render(context)
|
return template_to_render.render(context)
|
||||||
|
|
||||||
@@ -30,12 +39,14 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
|
|||||||
html,
|
html,
|
||||||
)
|
)
|
||||||
|
|
||||||
def assertPrevLink(self, html: str, page_number: int, href: str = None):
|
def assertPrevLink(
|
||||||
|
self, html: str, page_number: int, href: str = None, frame: str = "_top"
|
||||||
|
):
|
||||||
href = href if href else f"/test?page={page_number}"
|
href = href if href else f"/test?page={page_number}"
|
||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
f"""
|
f"""
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
<a href="{href}" tabindex="-1">Previous</a>
|
<a href="{href}" tabindex="-1" data-turbo-frame="{frame}">Previous</a>
|
||||||
</li>
|
</li>
|
||||||
""",
|
""",
|
||||||
html,
|
html,
|
||||||
@@ -51,12 +62,14 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
|
|||||||
html,
|
html,
|
||||||
)
|
)
|
||||||
|
|
||||||
def assertNextLink(self, html: str, page_number: int, href: str = None):
|
def assertNextLink(
|
||||||
|
self, html: str, page_number: int, href: str = None, frame: str = "_top"
|
||||||
|
):
|
||||||
href = href if href else f"/test?page={page_number}"
|
href = href if href else f"/test?page={page_number}"
|
||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
f"""
|
f"""
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
<a href="{href}" tabindex="-1">Next</a>
|
<a href="{href}" tabindex="-1" data-turbo-frame="{frame}">Next</a>
|
||||||
</li>
|
</li>
|
||||||
""",
|
""",
|
||||||
html,
|
html,
|
||||||
@@ -69,13 +82,14 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
|
|||||||
active: bool,
|
active: bool,
|
||||||
count: int = 1,
|
count: int = 1,
|
||||||
href: str = None,
|
href: str = None,
|
||||||
|
frame: str = "_top",
|
||||||
):
|
):
|
||||||
active_class = "active" if active else ""
|
active_class = "active" if active else ""
|
||||||
href = href if href else f"/test?page={page_number}"
|
href = href if href else f"/test?page={page_number}"
|
||||||
self.assertInHTML(
|
self.assertInHTML(
|
||||||
f"""
|
f"""
|
||||||
<li class="page-item {active_class}">
|
<li class="page-item {active_class}">
|
||||||
<a href="{href}">{page_number}</a>
|
<a href="{href}" data-turbo-frame="{frame}">{page_number}</a>
|
||||||
</li>
|
</li>
|
||||||
""",
|
""",
|
||||||
html,
|
html,
|
||||||
@@ -188,3 +202,10 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
|
|||||||
self.assertPageLink(rendered_template, 1, False, href="/test?page=1")
|
self.assertPageLink(rendered_template, 1, False, href="/test?page=1")
|
||||||
self.assertPageLink(rendered_template, 2, True, href="/test?page=2")
|
self.assertPageLink(rendered_template, 2, True, href="/test?page=2")
|
||||||
self.assertNextLink(rendered_template, 3, href="/test?page=3")
|
self.assertNextLink(rendered_template, 3, href="/test?page=3")
|
||||||
|
|
||||||
|
def test_respects_pagination_frame(self):
|
||||||
|
rendered_template = self.render_template(100, 10, 2, frame="my_frame")
|
||||||
|
self.assertPrevLink(rendered_template, 1, frame="my_frame")
|
||||||
|
self.assertPageLink(rendered_template, 1, False, frame="my_frame")
|
||||||
|
self.assertPageLink(rendered_template, 2, True, frame="my_frame")
|
||||||
|
self.assertNextLink(rendered_template, 3, frame="my_frame")
|
||||||
|
|||||||
@@ -90,20 +90,17 @@ class TagsEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
tag2.refresh_from_db()
|
tag2.refresh_from_db()
|
||||||
self.assertEqual(tag2.name, "tag1")
|
self.assertEqual(tag2.name, "tag1")
|
||||||
|
|
||||||
def test_update_shows_success_message(self):
|
def test_update_tag_preserves_query_parameters(self):
|
||||||
tag = self.setup_tag(name="old_name")
|
tag = self.setup_tag(name="old_name")
|
||||||
|
|
||||||
response = self.client.post(
|
url = (
|
||||||
reverse("linkding:tags.edit", args=[tag.id]),
|
reverse("linkding:tags.edit", args=[tag.id])
|
||||||
{"name": "new_name"},
|
+ "?search=search&unused=true&page=2&sort=name-desc"
|
||||||
follow=True,
|
|
||||||
)
|
)
|
||||||
|
response = self.client.post(url, {"name": "new_name"})
|
||||||
|
|
||||||
self.assertInHTML(
|
expected_redirect = (
|
||||||
"""
|
reverse("linkding:tags.index")
|
||||||
<div class="toast toast-success" role="alert">
|
+ "?search=search&unused=true&page=2&sort=name-desc"
|
||||||
Tag "new_name" updated successfully.
|
|
||||||
</div>
|
|
||||||
""",
|
|
||||||
response.content.decode(),
|
|
||||||
)
|
)
|
||||||
|
self.assertRedirects(response, expected_redirect)
|
||||||
|
|||||||
@@ -153,24 +153,6 @@ class TagsIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||||||
self.assertRedirects(response, reverse("linkding:tags.index"))
|
self.assertRedirects(response, reverse("linkding:tags.index"))
|
||||||
self.assertFalse(Tag.objects.filter(id=tag.id).exists())
|
self.assertFalse(Tag.objects.filter(id=tag.id).exists())
|
||||||
|
|
||||||
def test_tag_delete_action_shows_success_message(self):
|
|
||||||
tag = self.setup_tag(name="tag_to_delete")
|
|
||||||
|
|
||||||
response = self.client.post(
|
|
||||||
reverse("linkding:tags.index"), {"delete_tag": tag.id}, follow=True
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
self.assertInHTML(
|
|
||||||
"""
|
|
||||||
<div class="toast toast-success" role="alert">
|
|
||||||
Tag "tag_to_delete" deleted successfully.
|
|
||||||
</div>
|
|
||||||
""",
|
|
||||||
response.content.decode(),
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_tag_delete_action_preserves_query_parameters(self):
|
def test_tag_delete_action_preserves_query_parameters(self):
|
||||||
tag = self.setup_tag(name="search_tag")
|
tag = self.setup_tag(name="search_tag")
|
||||||
|
|
||||||
|
|||||||
@@ -113,9 +113,6 @@ class TagManagementE2ETestCase(LinkdingE2ETestCase):
|
|||||||
# Verify modal is closed
|
# Verify modal is closed
|
||||||
expect(modal).not_to_be_visible()
|
expect(modal).not_to_be_visible()
|
||||||
|
|
||||||
# Verify the success message is shown
|
|
||||||
self.verify_success_message('Tag "new-name" updated successfully.')
|
|
||||||
|
|
||||||
# Verify the updated tag is shown in the list
|
# Verify the updated tag is shown in the list
|
||||||
expect(self.locate_tag_row("new-name")).to_be_visible()
|
expect(self.locate_tag_row("new-name")).to_be_visible()
|
||||||
expect(self.locate_tag_row("old-name")).not_to_be_visible()
|
expect(self.locate_tag_row("old-name")).not_to_be_visible()
|
||||||
@@ -157,6 +154,89 @@ class TagManagementE2ETestCase(LinkdingE2ETestCase):
|
|||||||
tag.refresh_from_db()
|
tag.refresh_from_db()
|
||||||
self.assertEqual(tag.name, "tag-to-edit")
|
self.assertEqual(tag.name, "tag-to-edit")
|
||||||
|
|
||||||
|
def test_edit_tag_preserves_query_and_scroll_position(self):
|
||||||
|
# Create enough tags to have multiple pages (50 per page)
|
||||||
|
for i in range(70):
|
||||||
|
self.setup_tag(name=f"test-tag-{i:02d}")
|
||||||
|
|
||||||
|
# Open tags page 2 with search query
|
||||||
|
url = reverse("linkding:tags.index") + "?search=test&page=2"
|
||||||
|
self.open(url)
|
||||||
|
|
||||||
|
# Verify we're on page 2
|
||||||
|
expect(self.locate_tag_row("test-tag-00")).not_to_be_visible()
|
||||||
|
expect(self.locate_tag_row("test-tag-50")).to_be_visible()
|
||||||
|
expect(self.locate_tag_row("test-tag-60")).to_be_visible()
|
||||||
|
|
||||||
|
# Scroll down
|
||||||
|
self.page.evaluate("window.scrollTo(0, 300)")
|
||||||
|
initial_scroll = self.page.evaluate("window.scrollY")
|
||||||
|
self.assertGreater(initial_scroll, 0)
|
||||||
|
|
||||||
|
# Edit tag
|
||||||
|
tag_row = self.locate_tag_row("test-tag-55")
|
||||||
|
tag_row.get_by_role("link", name="Edit").click()
|
||||||
|
|
||||||
|
modal = self.locate_tag_modal()
|
||||||
|
|
||||||
|
name_input = modal.get_by_label("Name")
|
||||||
|
name_input.fill("test-tag-55-edited")
|
||||||
|
|
||||||
|
modal.get_by_text("Save").click()
|
||||||
|
|
||||||
|
expect(modal).not_to_be_visible()
|
||||||
|
|
||||||
|
# Verify query parameters and scroll position are preserved
|
||||||
|
current_url = self.page.url
|
||||||
|
self.assertIn("search=test", current_url)
|
||||||
|
self.assertIn("page=2", current_url)
|
||||||
|
|
||||||
|
expect(self.locate_tag_row("test-tag-00")).not_to_be_visible()
|
||||||
|
expect(self.locate_tag_row("test-tag-50")).to_be_visible()
|
||||||
|
expect(self.locate_tag_row("test-tag-55-edited")).to_be_visible()
|
||||||
|
expect(self.locate_tag_row("test-tag-60")).to_be_visible()
|
||||||
|
|
||||||
|
final_scroll = self.page.evaluate("window.scrollY")
|
||||||
|
self.assertEqual(initial_scroll, final_scroll)
|
||||||
|
|
||||||
|
def test_delete_tag_preserves_query_and_scroll_position(self):
|
||||||
|
# Create enough tags to have multiple pages (50 per page)
|
||||||
|
for i in range(70):
|
||||||
|
self.setup_tag(name=f"test-tag-{i:02d}")
|
||||||
|
|
||||||
|
# Open tags page 2 with search query
|
||||||
|
url = reverse("linkding:tags.index") + "?search=test&page=2"
|
||||||
|
self.open(url)
|
||||||
|
|
||||||
|
# Verify we're on page 2
|
||||||
|
expect(self.locate_tag_row("test-tag-00")).not_to_be_visible()
|
||||||
|
expect(self.locate_tag_row("test-tag-50")).to_be_visible()
|
||||||
|
expect(self.locate_tag_row("test-tag-55")).to_be_visible()
|
||||||
|
expect(self.locate_tag_row("test-tag-60")).to_be_visible()
|
||||||
|
|
||||||
|
# Scroll down
|
||||||
|
self.page.evaluate("window.scrollTo(0, 300)")
|
||||||
|
initial_scroll = self.page.evaluate("window.scrollY")
|
||||||
|
self.assertGreater(initial_scroll, 0)
|
||||||
|
|
||||||
|
# Delete tag
|
||||||
|
tag_row = self.locate_tag_row("test-tag-55")
|
||||||
|
tag_row.get_by_role("button", name="Remove").click()
|
||||||
|
self.locate_confirm_dialog().get_by_text("Confirm").click()
|
||||||
|
|
||||||
|
# Verify query parameters and scroll position are preserved
|
||||||
|
current_url = self.page.url
|
||||||
|
self.assertIn("search=test", current_url)
|
||||||
|
self.assertIn("page=2", current_url)
|
||||||
|
|
||||||
|
expect(self.locate_tag_row("test-tag-00")).not_to_be_visible()
|
||||||
|
expect(self.locate_tag_row("test-tag-50")).to_be_visible()
|
||||||
|
expect(self.locate_tag_row("test-tag-55")).not_to_be_visible()
|
||||||
|
expect(self.locate_tag_row("test-tag-60")).to_be_visible()
|
||||||
|
|
||||||
|
final_scroll = self.page.evaluate("window.scrollY")
|
||||||
|
self.assertEqual(initial_scroll, final_scroll)
|
||||||
|
|
||||||
def test_merge_tags(self):
|
def test_merge_tags(self):
|
||||||
target_tag = self.setup_tag(name="target-tag")
|
target_tag = self.setup_tag(name="target-tag")
|
||||||
merge_tag1 = self.setup_tag(name="merge-tag1")
|
merge_tag1 = self.setup_tag(name="merge-tag1")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from django.urls import reverse
|
|||||||
from bookmarks.forms import TagForm, TagMergeForm
|
from bookmarks.forms import TagForm, TagMergeForm
|
||||||
from bookmarks.models import Bookmark, Tag
|
from bookmarks.models import Bookmark, Tag
|
||||||
from bookmarks.type_defs import HttpRequest
|
from bookmarks.type_defs import HttpRequest
|
||||||
|
from bookmarks.utils import redirect_with_query
|
||||||
from bookmarks.views import turbo
|
from bookmarks.views import turbo
|
||||||
|
|
||||||
|
|
||||||
@@ -18,15 +19,8 @@ def tags_index(request: HttpRequest):
|
|||||||
if request.method == "POST" and "delete_tag" in request.POST:
|
if request.method == "POST" and "delete_tag" in request.POST:
|
||||||
tag_id = request.POST.get("delete_tag")
|
tag_id = request.POST.get("delete_tag")
|
||||||
tag = get_object_or_404(Tag, id=tag_id, owner=request.user)
|
tag = get_object_or_404(Tag, id=tag_id, owner=request.user)
|
||||||
tag_name = tag.name
|
|
||||||
tag.delete()
|
tag.delete()
|
||||||
messages.success(request, f'Tag "{tag_name}" deleted successfully.')
|
return redirect_with_query(request, reverse("linkding:tags.index"))
|
||||||
|
|
||||||
redirect_url = reverse("linkding:tags.index")
|
|
||||||
if request.GET:
|
|
||||||
redirect_url += "?" + request.GET.urlencode()
|
|
||||||
|
|
||||||
return HttpResponseRedirect(redirect_url)
|
|
||||||
|
|
||||||
search = request.GET.get("search", "").strip()
|
search = request.GET.get("search", "").strip()
|
||||||
unused_only = request.GET.get("unused", "") == "true"
|
unused_only = request.GET.get("unused", "") == "true"
|
||||||
@@ -100,8 +94,7 @@ def tag_edit(request: HttpRequest, tag_id: int):
|
|||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
form.save()
|
form.save()
|
||||||
messages.success(request, f'Tag "{tag.name}" updated successfully.')
|
return redirect_with_query(request, reverse("linkding:tags.index"))
|
||||||
return HttpResponseRedirect(reverse("linkding:tags.index"))
|
|
||||||
else:
|
else:
|
||||||
return turbo.stream(
|
return turbo.stream(
|
||||||
turbo.replace(
|
turbo.replace(
|
||||||
|
|||||||
Reference in New Issue
Block a user