diff --git a/bookmarks/static/live-reload.js b/bookmarks/static/live-reload.js
new file mode 100644
index 0000000..32f41b7
--- /dev/null
+++ b/bookmarks/static/live-reload.js
@@ -0,0 +1,44 @@
+const RELOAD_URL = "/live_reload";
+
+let eventSource = null;
+let serverId = null;
+
+function connect() {
+ console.debug("[live-reload] Connecting to", RELOAD_URL);
+
+ eventSource = new EventSource(RELOAD_URL);
+
+ eventSource.addEventListener("connected", (event) => {
+ const data = JSON.parse(event.data);
+
+ if (serverId && serverId !== data.server_id) {
+ console.log("[live-reload] Server restarted, reloading page");
+ window.location.reload();
+ return;
+ }
+
+ console.debug("[live-reload] Connected, server ID:", data.server_id);
+ serverId = data.server_id;
+ });
+
+ eventSource.addEventListener("file_change", (event) => {
+ const data = JSON.parse(event.data);
+ console.log("[live-reload] File changed:", data);
+
+ if (data.file_path.endsWith(".css") || data.file_path.endsWith(".js")) {
+ console.log("[live-reload] Asset changed, reloading page");
+ window.location.reload();
+ }
+ });
+
+ eventSource.onerror = (error) => {
+ console.debug("[live-reload] Disconnected", error);
+ eventSource.close();
+ eventSource = null;
+
+ // Reconnect after a delay
+ setTimeout(connect, 1000);
+ };
+}
+
+connect();
diff --git a/bookmarks/templates/shared/head.html b/bookmarks/templates/shared/head.html
index 75c9204..f7505ca 100644
--- a/bookmarks/templates/shared/head.html
+++ b/bookmarks/templates/shared/head.html
@@ -61,4 +61,5 @@
{% if not request.global_settings.enable_link_prefetch %}{% endif %}
{% if rss_feed_url %}{% endif %}
+ {% if debug %}{% endif %}
diff --git a/bookmarks/urls.py b/bookmarks/urls.py
index c614c46..3ec6ad4 100644
--- a/bookmarks/urls.py
+++ b/bookmarks/urls.py
@@ -106,6 +106,12 @@ urlpatterns = [
path("opensearch.xml", views.opensearch, name="opensearch"),
]
+# Live reload (debug only)
+if settings.DEBUG:
+ from bookmarks.views import reload
+
+ urlpatterns.append(path("live_reload", reload.live_reload, name="live_reload"))
+
# Put all linkding URLs into a linkding namespace
urlpatterns = [path("", include((urlpatterns, "linkding")))]
diff --git a/bookmarks/views/__init__.py b/bookmarks/views/__init__.py
index 7c61067..a779358 100644
--- a/bookmarks/views/__init__.py
+++ b/bookmarks/views/__init__.py
@@ -10,3 +10,4 @@ from .manifest import manifest
from .custom_css import custom_css
from .root import root
from .opensearch import opensearch
+
diff --git a/bookmarks/views/reload.py b/bookmarks/views/reload.py
new file mode 100644
index 0000000..a12836a
--- /dev/null
+++ b/bookmarks/views/reload.py
@@ -0,0 +1,63 @@
+import json
+import threading
+import uuid
+from pathlib import Path
+from queue import Empty, Queue
+
+from django.dispatch import receiver
+from django.http import StreamingHttpResponse
+from django.utils.autoreload import autoreload_started, file_changed
+
+_styles_dir = Path(__file__).resolve().parent.parent / "styles"
+_static_dir = Path(__file__).resolve().parent.parent / "static"
+
+_server_id = str(uuid.uuid4())
+
+_active_connections = set()
+_connections_lock = threading.Lock()
+
+
+def _event_stream():
+ client_queue = Queue()
+
+ with _connections_lock:
+ _active_connections.add(client_queue)
+
+ try:
+ data = json.dumps({"server_id": _server_id})
+ yield f"event: connected\ndata: {data}\n\n"
+
+ while True:
+ try:
+ data = client_queue.get(timeout=30)
+ yield f"event: file_change\ndata: {data}\n\n"
+ except Empty:
+ yield ": keepalive\n\n"
+ finally:
+ with _connections_lock:
+ _active_connections.discard(client_queue)
+
+
+def live_reload(request):
+ response = StreamingHttpResponse(_event_stream(), content_type="text/event-stream")
+ response["Cache-Control"] = "no-cache"
+ return response
+
+
+@receiver(autoreload_started)
+def handle_auto_reload(sender, **kwargs):
+ sender.watch_dir(_styles_dir, "**/*.css")
+ sender.watch_dir(_static_dir, "bundle.js")
+
+
+@receiver(file_changed)
+def handle_file_changed(sender, file_path, **kwargs):
+ print(f"File changed: {file_path}")
+ data = json.dumps({"file_path": str(file_path)})
+ with _connections_lock:
+ for queue in _active_connections:
+ queue.put(data)
+
+ # Return True for CSS/JS files to prevent Django server restart
+ if file_path.suffix in (".css", ".js"):
+ return True