feat(config): add gitea base URL and documentation download URLs configuration; update related components and logic for dynamic URL handling
This commit is contained in:
@@ -201,6 +201,8 @@ class ReticulumMeshChat:
|
|||||||
auth_enabled: bool = False,
|
auth_enabled: bool = False,
|
||||||
public_dir: str | None = None,
|
public_dir: str | None = None,
|
||||||
emergency: bool = False,
|
emergency: bool = False,
|
||||||
|
gitea_base_url: str | None = None,
|
||||||
|
docs_download_urls: str | None = None,
|
||||||
):
|
):
|
||||||
self.running = True
|
self.running = True
|
||||||
self.reticulum_config_dir = reticulum_config_dir
|
self.reticulum_config_dir = reticulum_config_dir
|
||||||
@@ -210,6 +212,8 @@ class ReticulumMeshChat:
|
|||||||
self.emergency = emergency
|
self.emergency = emergency
|
||||||
self.auth_enabled_initial = auth_enabled
|
self.auth_enabled_initial = auth_enabled
|
||||||
self.public_dir_override = public_dir
|
self.public_dir_override = public_dir
|
||||||
|
self.gitea_base_url_override = gitea_base_url
|
||||||
|
self.docs_download_urls_override = docs_download_urls
|
||||||
self.websocket_clients: list[web.WebSocketResponse] = []
|
self.websocket_clients: list[web.WebSocketResponse] = []
|
||||||
|
|
||||||
# track announce timestamps for rate calculation
|
# track announce timestamps for rate calculation
|
||||||
@@ -1177,11 +1181,15 @@ class ReticulumMeshChat:
|
|||||||
def get_app_version() -> str:
|
def get_app_version() -> str:
|
||||||
return app_version
|
return app_version
|
||||||
|
|
||||||
def get_lxst_version(self) -> str:
|
@staticmethod
|
||||||
|
def get_package_version(package_name: str, default: str = "unknown") -> str:
|
||||||
try:
|
try:
|
||||||
return importlib.metadata.version("lxst")
|
return importlib.metadata.version(package_name)
|
||||||
except Exception:
|
except Exception:
|
||||||
return getattr(LXST, "__version__", "unknown")
|
return default
|
||||||
|
|
||||||
|
def get_lxst_version(self) -> str:
|
||||||
|
return self.get_package_version("lxst", getattr(LXST, "__version__", "unknown"))
|
||||||
|
|
||||||
# automatically announces based on user config
|
# automatically announces based on user config
|
||||||
async def announce_loop(self, session_id, context=None):
|
async def announce_loop(self, session_id, context=None):
|
||||||
@@ -2272,9 +2280,16 @@ class ReticulumMeshChat:
|
|||||||
if not url:
|
if not url:
|
||||||
return web.json_response({"error": "URL is required"}, status=400)
|
return web.json_response({"error": "URL is required"}, status=400)
|
||||||
|
|
||||||
# Restrict to GitHub for safety
|
# Restrict to allowed sources for safety
|
||||||
if not url.startswith("https://git.quad4.io/") and not url.startswith(
|
gitea_url = "https://git.quad4.io"
|
||||||
"https://objects.githubusercontent.com/"
|
if self.current_context and self.current_context.config:
|
||||||
|
gitea_url = self.current_context.config.gitea_base_url.get()
|
||||||
|
|
||||||
|
if (
|
||||||
|
not url.startswith(gitea_url + "/")
|
||||||
|
and not url.startswith("https://git.quad4.io/")
|
||||||
|
and not url.startswith("https://github.com/")
|
||||||
|
and not url.startswith("https://objects.githubusercontent.com/")
|
||||||
):
|
):
|
||||||
return web.json_response({"error": "Invalid download URL"}, status=403)
|
return web.json_response({"error": "Invalid download URL"}, status=403)
|
||||||
|
|
||||||
@@ -3172,21 +3187,21 @@ class ReticulumMeshChat:
|
|||||||
"lxst_version": self.get_lxst_version(),
|
"lxst_version": self.get_lxst_version(),
|
||||||
"python_version": platform.python_version(),
|
"python_version": platform.python_version(),
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"aiohttp": importlib.metadata.version("aiohttp"),
|
"aiohttp": self.get_package_version("aiohttp"),
|
||||||
"aiohttp_session": importlib.metadata.version(
|
"aiohttp_session": self.get_package_version(
|
||||||
"aiohttp-session"
|
"aiohttp-session"
|
||||||
),
|
),
|
||||||
"cryptography": importlib.metadata.version("cryptography"),
|
"cryptography": self.get_package_version("cryptography"),
|
||||||
"psutil": importlib.metadata.version("psutil"),
|
"psutil": self.get_package_version("psutil"),
|
||||||
"requests": importlib.metadata.version("requests"),
|
"requests": self.get_package_version("requests"),
|
||||||
"websockets": importlib.metadata.version("websockets"),
|
"websockets": self.get_package_version("websockets"),
|
||||||
"audioop_lts": (
|
"audioop_lts": (
|
||||||
importlib.metadata.version("audioop-lts")
|
self.get_package_version("audioop-lts")
|
||||||
if sys.version_info >= (3, 13)
|
if sys.version_info >= (3, 13)
|
||||||
else "n/a"
|
else "n/a"
|
||||||
),
|
),
|
||||||
"ply": importlib.metadata.version("ply"),
|
"ply": self.get_package_version("ply"),
|
||||||
"bcrypt": importlib.metadata.version("bcrypt"),
|
"bcrypt": self.get_package_version("bcrypt"),
|
||||||
},
|
},
|
||||||
"storage_path": self.storage_path,
|
"storage_path": self.storage_path,
|
||||||
"database_path": self.database_path,
|
"database_path": self.database_path,
|
||||||
@@ -7290,16 +7305,59 @@ class ReticulumMeshChat:
|
|||||||
|
|
||||||
response.headers["X-XSS-Protection"] = "1; mode=block"
|
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||||
|
|
||||||
# CSP: allow localhost for development and Electron, websockets, and blob URLs
|
# CSP: allow localhost for development and Electron, websockets, and blob URLs
|
||||||
# Add 'unsafe-inline' and 'unsafe-eval' for some legacy doc scripts if needed,
|
# Add 'unsafe-inline' and 'unsafe-eval' for some legacy doc scripts if needed,
|
||||||
# and allow framing ourselves for the docs page.
|
# and allow framing ourselves for the docs page.
|
||||||
|
gitea_url = "https://git.quad4.io"
|
||||||
|
connect_sources = [
|
||||||
|
"'self'",
|
||||||
|
"ws://localhost:*",
|
||||||
|
"wss://localhost:*",
|
||||||
|
"blob:",
|
||||||
|
"https://*.tile.openstreetmap.org",
|
||||||
|
"https://tile.openstreetmap.org",
|
||||||
|
"https://nominatim.openstreetmap.org",
|
||||||
|
]
|
||||||
|
|
||||||
|
if self.current_context and self.current_context.config:
|
||||||
|
# Add configured Gitea base URL
|
||||||
|
gitea_url = self.current_context.config.gitea_base_url.get()
|
||||||
|
if gitea_url not in connect_sources:
|
||||||
|
connect_sources.append(gitea_url)
|
||||||
|
|
||||||
|
# Add configured docs download URLs domains
|
||||||
|
docs_urls_str = self.current_context.config.docs_download_urls.get()
|
||||||
|
docs_urls = [
|
||||||
|
u.strip()
|
||||||
|
for u in docs_urls_str.replace("\n", ",").split(",")
|
||||||
|
if u.strip()
|
||||||
|
]
|
||||||
|
for url in docs_urls:
|
||||||
|
try:
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.netloc:
|
||||||
|
domain = f"{parsed.scheme}://{parsed.netloc}"
|
||||||
|
if domain not in connect_sources:
|
||||||
|
connect_sources.append(domain)
|
||||||
|
|
||||||
|
# If GitHub is used, also allow objects.githubusercontent.com for redirects
|
||||||
|
if "github.com" in domain:
|
||||||
|
content_domain = "https://objects.githubusercontent.com"
|
||||||
|
if content_domain not in connect_sources:
|
||||||
|
connect_sources.append(content_domain)
|
||||||
|
except Exception: # noqa: S110
|
||||||
|
pass
|
||||||
|
|
||||||
csp = (
|
csp = (
|
||||||
"default-src 'self'; "
|
"default-src 'self'; "
|
||||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
|
||||||
"style-src 'self' 'unsafe-inline'; "
|
"style-src 'self' 'unsafe-inline'; "
|
||||||
"img-src 'self' data: blob: https://*.tile.openstreetmap.org https://tile.openstreetmap.org; "
|
"img-src 'self' data: blob: https://*.tile.openstreetmap.org https://tile.openstreetmap.org; "
|
||||||
"font-src 'self' data:; "
|
"font-src 'self' data:; "
|
||||||
"connect-src 'self' ws://localhost:* wss://localhost:* blob: https://*.tile.openstreetmap.org https://tile.openstreetmap.org https://nominatim.openstreetmap.org https://git.quad4.io; "
|
f"connect-src {' '.join(connect_sources)}; "
|
||||||
"media-src 'self' blob:; "
|
"media-src 'self' blob:; "
|
||||||
"worker-src 'self' blob:; "
|
"worker-src 'self' blob:; "
|
||||||
"frame-src 'self'; "
|
"frame-src 'self'; "
|
||||||
@@ -10153,6 +10211,18 @@ def main():
|
|||||||
default=os.environ.get("MESHCHAT_PUBLIC_DIR"),
|
default=os.environ.get("MESHCHAT_PUBLIC_DIR"),
|
||||||
help="Path to the directory containing the frontend static files (default: bundled public folder). Can also be set via MESHCHAT_PUBLIC_DIR environment variable.",
|
help="Path to the directory containing the frontend static files (default: bundled public folder). Can also be set via MESHCHAT_PUBLIC_DIR environment variable.",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--gitea-base-url",
|
||||||
|
type=str,
|
||||||
|
default=os.environ.get("MESHCHAT_GITEA_BASE_URL"),
|
||||||
|
help="Base URL for Gitea instance (default: https://git.quad4.io). Can also be set via MESHCHAT_GITEA_BASE_URL environment variable.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--docs-download-urls",
|
||||||
|
type=str,
|
||||||
|
default=os.environ.get("MESHCHAT_DOCS_DOWNLOAD_URLS"),
|
||||||
|
help="Comma-separated list of URLs to download documentation from. Can also be set via MESHCHAT_DOCS_DOWNLOAD_URLS environment variable.",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--test-exception-message",
|
"--test-exception-message",
|
||||||
type=str,
|
type=str,
|
||||||
@@ -10278,6 +10348,8 @@ def main():
|
|||||||
auth_enabled=args.auth,
|
auth_enabled=args.auth,
|
||||||
public_dir=args.public_dir,
|
public_dir=args.public_dir,
|
||||||
emergency=args.emergency,
|
emergency=args.emergency,
|
||||||
|
gitea_base_url=args.gitea_base_url,
|
||||||
|
docs_download_urls=args.docs_download_urls,
|
||||||
)
|
)
|
||||||
|
|
||||||
# update recovery with known paths
|
# update recovery with known paths
|
||||||
|
|||||||
@@ -118,6 +118,14 @@ class ConfigManager:
|
|||||||
"initial_docs_download_attempted",
|
"initial_docs_download_attempted",
|
||||||
False,
|
False,
|
||||||
)
|
)
|
||||||
|
self.gitea_base_url = self.StringConfig(
|
||||||
|
self, "gitea_base_url", "https://git.quad4.io"
|
||||||
|
)
|
||||||
|
self.docs_download_urls = self.StringConfig(
|
||||||
|
self,
|
||||||
|
"docs_download_urls",
|
||||||
|
"https://git.quad4.io/Reticulum/reticulum_website/archive/main.zip,https://github.com/markqvist/reticulum_website/archive/refs/heads/main.zip",
|
||||||
|
)
|
||||||
|
|
||||||
# desktop config
|
# desktop config
|
||||||
self.desktop_open_calls_in_separate_window = self.BoolConfig(
|
self.desktop_open_calls_in_separate_window = self.BoolConfig(
|
||||||
|
|||||||
@@ -86,7 +86,10 @@ class DocsManager:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Try symlink first as it's efficient
|
# Try symlink first as it's efficient
|
||||||
os.symlink(version_path, self.docs_dir)
|
# We use a relative path for the symlink target to make the storage directory portable
|
||||||
|
# version_path is relative to CWD, so we need it relative to the parent of self.docs_dir
|
||||||
|
rel_target = os.path.relpath(version_path, os.path.dirname(self.docs_dir))
|
||||||
|
os.symlink(rel_target, self.docs_dir)
|
||||||
except (OSError, AttributeError):
|
except (OSError, AttributeError):
|
||||||
# Fallback to copy
|
# Fallback to copy
|
||||||
shutil.copytree(version_path, self.docs_dir)
|
shutil.copytree(version_path, self.docs_dir)
|
||||||
@@ -440,54 +443,68 @@ class DocsManager:
|
|||||||
self.download_progress = 0
|
self.download_progress = 0
|
||||||
self.last_error = None
|
self.last_error = None
|
||||||
|
|
||||||
try:
|
# Get URLs from config
|
||||||
# We use the reticulum_website repository which contains the built HTML docs
|
urls_str = self.config.docs_download_urls.get()
|
||||||
# Default to git.quad4.io as requested
|
urls = [u.strip() for u in urls_str.replace("\n", ",").split(",") if u.strip()]
|
||||||
url = "https://git.quad4.io/Reticulum/reticulum_website/archive/main.zip"
|
if not urls:
|
||||||
zip_path = os.path.join(self.docs_base_dir, "website.zip")
|
urls = ["https://git.quad4.io/Reticulum/reticulum_website/archive/main.zip"]
|
||||||
|
|
||||||
# Download ZIP
|
last_exception = None
|
||||||
response = requests.get(url, stream=True, timeout=60)
|
for url in urls:
|
||||||
response.raise_for_status()
|
try:
|
||||||
|
logging.info(f"Attempting to download docs from {url}")
|
||||||
|
zip_path = os.path.join(self.docs_base_dir, "website.zip")
|
||||||
|
|
||||||
total_size = int(response.headers.get("content-length", 0))
|
# Download ZIP
|
||||||
downloaded_size = 0
|
response = requests.get(url, stream=True, timeout=60)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
with open(zip_path, "wb") as f:
|
total_size = int(response.headers.get("content-length", 0))
|
||||||
for chunk in response.iter_content(chunk_size=8192):
|
downloaded_size = 0
|
||||||
if chunk:
|
|
||||||
f.write(chunk)
|
|
||||||
downloaded_size += len(chunk)
|
|
||||||
if total_size > 0:
|
|
||||||
self.download_progress = int(
|
|
||||||
(downloaded_size / total_size) * 90
|
|
||||||
)
|
|
||||||
|
|
||||||
# Extract
|
with open(zip_path, "wb") as f:
|
||||||
self.download_status = "extracting"
|
for chunk in response.iter_content(chunk_size=8192):
|
||||||
# For automatic downloads from git, we'll use a timestamp as version if none provided
|
if chunk:
|
||||||
if version == "latest":
|
f.write(chunk)
|
||||||
import time
|
downloaded_size += len(chunk)
|
||||||
|
if total_size > 0:
|
||||||
|
self.download_progress = int(
|
||||||
|
(downloaded_size / total_size) * 90
|
||||||
|
)
|
||||||
|
|
||||||
version = f"git-{int(time.time())}"
|
# Extract
|
||||||
|
self.download_status = "extracting"
|
||||||
|
# For automatic downloads from git, we'll use a timestamp as version if none provided
|
||||||
|
if version == "latest":
|
||||||
|
import time
|
||||||
|
|
||||||
self._extract_docs(zip_path, version)
|
version = f"git-{int(time.time())}"
|
||||||
|
|
||||||
# Cleanup
|
self._extract_docs(zip_path, version)
|
||||||
if os.path.exists(zip_path):
|
|
||||||
os.remove(zip_path)
|
|
||||||
|
|
||||||
self.config.docs_downloaded.set(True)
|
# Cleanup
|
||||||
self.download_progress = 100
|
if os.path.exists(zip_path):
|
||||||
self.download_status = "completed"
|
os.remove(zip_path)
|
||||||
|
|
||||||
# Switch to the new version
|
self.config.docs_downloaded.set(True)
|
||||||
self.switch_version(version)
|
self.download_progress = 100
|
||||||
|
self.download_status = "completed"
|
||||||
|
|
||||||
except Exception as e:
|
# Switch to the new version
|
||||||
self.last_error = str(e)
|
self.switch_version(version)
|
||||||
self.download_status = "error"
|
return # Success, exit task
|
||||||
logging.exception(f"Failed to update docs: {e}")
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.warning(f"Failed to download docs from {url}: {e}")
|
||||||
|
last_exception = e
|
||||||
|
if os.path.exists(os.path.join(self.docs_base_dir, "website.zip")):
|
||||||
|
os.remove(os.path.join(self.docs_base_dir, "website.zip"))
|
||||||
|
continue # Try next URL
|
||||||
|
|
||||||
|
# If we got here, all URLs failed
|
||||||
|
self.last_error = str(last_exception)
|
||||||
|
self.download_status = "error"
|
||||||
|
logging.error(f"All docs download sources failed. Last error: {last_exception}")
|
||||||
|
|
||||||
def upload_zip(self, zip_bytes, version):
|
def upload_zip(self, zip_bytes, version):
|
||||||
self.download_status = "extracting"
|
self.download_status = "extracting"
|
||||||
|
|||||||
@@ -129,6 +129,19 @@ class IdentityContext:
|
|||||||
|
|
||||||
# 3. Initialize Config and Managers
|
# 3. Initialize Config and Managers
|
||||||
self.config = ConfigManager(self.database)
|
self.config = ConfigManager(self.database)
|
||||||
|
|
||||||
|
# Apply overrides from CLI/ENV if provided
|
||||||
|
if (
|
||||||
|
hasattr(self.app, "gitea_base_url_override")
|
||||||
|
and self.app.gitea_base_url_override
|
||||||
|
):
|
||||||
|
self.config.gitea_base_url.set(self.app.gitea_base_url_override)
|
||||||
|
if (
|
||||||
|
hasattr(self.app, "docs_download_urls_override")
|
||||||
|
and self.app.docs_download_urls_override
|
||||||
|
):
|
||||||
|
self.config.docs_download_urls.set(self.app.docs_download_urls_override)
|
||||||
|
|
||||||
self.message_handler = MessageHandler(self.database)
|
self.message_handler = MessageHandler(self.database)
|
||||||
self.announce_manager = AnnounceManager(self.database)
|
self.announce_manager = AnnounceManager(self.database)
|
||||||
self.archiver_manager = ArchiverManager(self.database)
|
self.archiver_manager = ArchiverManager(self.database)
|
||||||
|
|||||||
@@ -141,19 +141,12 @@ class TelephoneManager:
|
|||||||
self.call_start_time = time.time()
|
self.call_start_time = time.time()
|
||||||
self.call_was_established = True
|
self.call_was_established = True
|
||||||
|
|
||||||
# Clear initiation status as soon as call is established
|
|
||||||
self._update_initiation_status(None, None)
|
|
||||||
|
|
||||||
# Track per-call stats from the active link (uses RNS Link counters)
|
# Track per-call stats from the active link (uses RNS Link counters)
|
||||||
link = getattr(self.telephone, "active_call", None)
|
link = getattr(self.telephone, "active_call", None)
|
||||||
self.call_stats = {
|
self.call_stats = {
|
||||||
"link": link,
|
"link": link,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Recording disabled for now due to stability issues with LXST
|
|
||||||
# if self.config_manager and self.config_manager.call_recording_enabled.get():
|
|
||||||
# self.start_recording()
|
|
||||||
|
|
||||||
if self.on_established_callback:
|
if self.on_established_callback:
|
||||||
self.on_established_callback(caller_identity)
|
self.on_established_callback(caller_identity)
|
||||||
|
|
||||||
@@ -351,6 +344,14 @@ class TelephoneManager:
|
|||||||
await asyncio.sleep(3)
|
await asyncio.sleep(3)
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
|
# If call was successful, keep status for a moment to prevent UI flicker
|
||||||
|
# while the frontend picks up the new active_call state
|
||||||
|
if (
|
||||||
|
self.telephone
|
||||||
|
and self.telephone.active_call
|
||||||
|
and self.telephone.call_status == 6
|
||||||
|
):
|
||||||
|
await asyncio.sleep(1.5)
|
||||||
self._update_initiation_status(None, None)
|
self._update_initiation_status(None, None)
|
||||||
|
|
||||||
def mute_transmit(self):
|
def mute_transmit(self):
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
{{ $t("app.custom_fork_by") }}
|
{{ $t("app.custom_fork_by") }}
|
||||||
<a
|
<a
|
||||||
target="_blank"
|
target="_blank"
|
||||||
href="https://git.quad4.io/Sudo-Ivan"
|
:href="`${giteaBaseUrl}/Sudo-Ivan`"
|
||||||
class="text-blue-500 dark:text-blue-300 hover:underline"
|
class="text-blue-500 dark:text-blue-300 hover:underline"
|
||||||
>Sudo-Ivan</a
|
>Sudo-Ivan</a
|
||||||
>
|
>
|
||||||
@@ -573,6 +573,9 @@ export default {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
giteaBaseUrl() {
|
||||||
|
return this.config?.gitea_base_url || "https://git.quad4.io";
|
||||||
|
},
|
||||||
currentPopoutType() {
|
currentPopoutType() {
|
||||||
if (this.$route?.meta?.popoutType) {
|
if (this.$route?.meta?.popoutType) {
|
||||||
return this.$route.meta.popoutType;
|
return this.$route.meta.popoutType;
|
||||||
|
|||||||
@@ -2304,7 +2304,6 @@ export default {
|
|||||||
const response = await window.axios.get("/api/v1/telephone/status");
|
const response = await window.axios.get("/api/v1/telephone/status");
|
||||||
const oldCall = this.activeCall;
|
const oldCall = this.activeCall;
|
||||||
const newCall = response.data.active_call;
|
const newCall = response.data.active_call;
|
||||||
const callStatus = response.data.call_status;
|
|
||||||
|
|
||||||
// Sync local mute state from backend
|
// Sync local mute state from backend
|
||||||
if (newCall) {
|
if (newCall) {
|
||||||
@@ -2319,14 +2318,6 @@ export default {
|
|||||||
this.initiationTargetHash = response.data.initiation_target_hash;
|
this.initiationTargetHash = response.data.initiation_target_hash;
|
||||||
this.initiationTargetName = response.data.initiation_target_name;
|
this.initiationTargetName = response.data.initiation_target_name;
|
||||||
|
|
||||||
// If no active call and status is idle/busy/rejected/available, clear stale initiation UI
|
|
||||||
const isIdleState = !this.activeCall && ![2, 4, 5].includes(callStatus);
|
|
||||||
if (isIdleState && this.initiationStatus) {
|
|
||||||
this.initiationStatus = null;
|
|
||||||
this.initiationTargetHash = null;
|
|
||||||
this.initiationTargetName = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.activeCall?.is_voicemail) {
|
if (this.activeCall?.is_voicemail) {
|
||||||
this.wasVoicemail = true;
|
this.wasVoicemail = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -367,12 +367,48 @@
|
|||||||
<MaterialDesignIcon icon-name="alert-circle-outline" class="w-12 h-12 mx-auto mb-3" />
|
<MaterialDesignIcon icon-name="alert-circle-outline" class="w-12 h-12 mx-auto mb-3" />
|
||||||
<div class="text-lg font-bold mb-2">{{ $t("docs.error") }}</div>
|
<div class="text-lg font-bold mb-2">{{ $t("docs.error") }}</div>
|
||||||
<div class="text-sm opacity-80">{{ status.last_error }}</div>
|
<div class="text-sm opacity-80">{{ status.last_error }}</div>
|
||||||
<button
|
<div class="flex flex-col gap-4 mt-6">
|
||||||
class="mt-6 px-6 py-2 bg-red-600 text-white rounded-xl text-xs font-bold hover:bg-red-700 transition-colors"
|
<button
|
||||||
@click="updateDocs"
|
class="px-6 py-2.5 bg-red-600 text-white rounded-xl text-xs font-bold hover:bg-red-700 transition-colors shadow-lg"
|
||||||
>
|
@click="updateDocs"
|
||||||
Retry Download
|
>
|
||||||
</button>
|
Retry Download
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="relative py-2">
|
||||||
|
<div class="absolute inset-0 flex items-center" aria-hidden="true">
|
||||||
|
<div class="w-full border-t border-red-200 dark:border-red-900/50"></div>
|
||||||
|
</div>
|
||||||
|
<div class="relative flex justify-center text-[10px] uppercase font-bold">
|
||||||
|
<span class="bg-red-50 dark:bg-zinc-900 px-2 text-red-400"
|
||||||
|
>or use alternate source</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<input
|
||||||
|
v-model="alternateDocsUrl"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-4 py-2.5 bg-white dark:bg-zinc-800 border border-red-200 dark:border-red-900/50 rounded-xl text-xs focus:outline-none focus:ring-2 focus:ring-red-500/20 text-gray-900 dark:text-zinc-100"
|
||||||
|
placeholder="https://mirror.example.com/reticulum_docs.zip"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="w-full px-6 py-2.5 bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-xl text-xs font-bold hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||||
|
:disabled="!alternateDocsUrl"
|
||||||
|
@click="addAndRetryDocs"
|
||||||
|
>
|
||||||
|
Add Source & Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RouterLink
|
||||||
|
:to="{ name: 'settings' }"
|
||||||
|
class="text-[10px] font-bold text-red-500/60 hover:text-red-500 uppercase tracking-widest transition-colors"
|
||||||
|
>
|
||||||
|
Manage all sources in settings
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -535,6 +571,7 @@ export default {
|
|||||||
meshchatxDocs: [],
|
meshchatxDocs: [],
|
||||||
selectedDocPath: null,
|
selectedDocPath: null,
|
||||||
selectedDocContent: null,
|
selectedDocContent: null,
|
||||||
|
alternateDocsUrl: "",
|
||||||
languages: {
|
languages: {
|
||||||
en: "English",
|
en: "English",
|
||||||
de: "Deutsch",
|
de: "Deutsch",
|
||||||
@@ -636,6 +673,27 @@ export default {
|
|||||||
console.error("Failed to trigger docs update:", error);
|
console.error("Failed to trigger docs update:", error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async addAndRetryDocs() {
|
||||||
|
if (!this.alternateDocsUrl) return;
|
||||||
|
try {
|
||||||
|
// Get current config
|
||||||
|
const configResponse = await window.axios.get("/api/v1/config");
|
||||||
|
const currentUrls = configResponse.data.config.docs_download_urls || "";
|
||||||
|
const newUrls = currentUrls ? `${currentUrls},${this.alternateDocsUrl}` : this.alternateDocsUrl;
|
||||||
|
|
||||||
|
// Update config
|
||||||
|
await window.axios.patch("/api/v1/config", {
|
||||||
|
docs_download_urls: newUrls,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear input and retry
|
||||||
|
this.alternateDocsUrl = "";
|
||||||
|
await this.updateDocs();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to add alternate source:", error);
|
||||||
|
ToastUtils.error("Failed to update documentation sources");
|
||||||
|
}
|
||||||
|
},
|
||||||
async switchVersion(version) {
|
async switchVersion(version) {
|
||||||
try {
|
try {
|
||||||
await window.axios.post("/api/v1/docs/switch", { version });
|
await window.axios.post("/api/v1/docs/switch", { version });
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<label :for="id" class="relative inline-flex items-center cursor-pointer">
|
<label
|
||||||
|
:for="id"
|
||||||
|
class="relative inline-flex items-center"
|
||||||
|
:class="disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'"
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
:id="id"
|
:id="id"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:checked="modelValue"
|
:checked="modelValue"
|
||||||
|
:disabled="disabled"
|
||||||
class="sr-only peer"
|
class="sr-only peer"
|
||||||
@change="$emit('update:modelValue', $event.target.checked)"
|
@change="!disabled && $emit('update:modelValue', $event.target.checked)"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-zinc-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600 dark:peer-checked:bg-blue-600"
|
class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-zinc-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600 dark:peer-checked:bg-blue-600"
|
||||||
@@ -30,6 +35,10 @@ export default {
|
|||||||
type: String,
|
type: String,
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
emits: ["update:modelValue"],
|
emits: ["update:modelValue"],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -881,47 +881,96 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- no peer selected -->
|
<!-- no peer selected -->
|
||||||
<div v-else class="flex flex-col h-full items-center justify-center">
|
<div v-else class="flex flex-col h-full overflow-y-auto bg-gray-50/50 dark:bg-zinc-950/50">
|
||||||
<div class="w-full max-w-md px-4">
|
<div class="max-w-4xl mx-auto w-full px-4 py-8 sm:py-12 flex flex-col items-center">
|
||||||
<div class="mb-6 text-center">
|
<!-- welcome header -->
|
||||||
|
<div class="text-center mb-12">
|
||||||
<div
|
<div
|
||||||
class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-blue-100 to-blue-200 dark:from-blue-900/30 dark:to-blue-800/30 flex items-center justify-center"
|
class="inline-flex items-center justify-center p-4 rounded-3xl bg-blue-600 shadow-xl shadow-blue-500/20 mb-6"
|
||||||
>
|
>
|
||||||
<svg
|
<MaterialDesignIcon icon-name="message-text" class="size-10 text-white" />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="1.5"
|
|
||||||
stroke="currentColor"
|
|
||||||
class="w-8 h-8 text-blue-600 dark:text-blue-400"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h1 class="text-3xl font-black text-gray-900 dark:text-white tracking-tight mb-3">
|
||||||
|
{{ $t("messages.no_active_chat") }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-gray-500 dark:text-zinc-400 max-w-sm mx-auto">
|
||||||
|
{{ $t("messages.select_peer_or_enter_address") }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-zinc-100 mb-1">
|
|
||||||
{{ $t("messages.no_active_chat") }}
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm text-gray-500 dark:text-zinc-400 mb-8">
|
|
||||||
{{ $t("messages.select_peer_or_enter_address") }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- latest chats grid (desktop only) -->
|
<!-- main actions grid -->
|
||||||
<div v-if="!isMobile && latestConversations.length > 0" class="w-full max-w-2xl mb-8">
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 w-full mb-12">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<button
|
||||||
<h4 class="text-xs font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest">
|
type="button"
|
||||||
Latest Chats
|
class="flex flex-col items-center gap-3 p-6 rounded-3xl bg-white dark:bg-zinc-900 border border-gray-100 dark:border-zinc-800 hover:border-blue-500/50 hover:shadow-xl hover:shadow-blue-500/5 transition-all group"
|
||||||
</h4>
|
@click="focusComposeInput"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="size-12 rounded-2xl bg-blue-50 dark:bg-blue-900/20 text-blue-600 flex items-center justify-center group-hover:scale-110 transition-transform"
|
||||||
|
>
|
||||||
|
<MaterialDesignIcon icon-name="plus" class="size-6" />
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-bold text-gray-900 dark:text-zinc-100">New Message</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex flex-col items-center gap-3 p-6 rounded-3xl bg-white dark:bg-zinc-900 border border-gray-100 dark:border-zinc-800 hover:border-blue-500/50 hover:shadow-xl hover:shadow-blue-500/5 transition-all group"
|
||||||
|
@click="syncPropagationNode"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="size-12 rounded-2xl bg-indigo-50 dark:bg-indigo-900/20 text-indigo-600 flex items-center justify-center group-hover:scale-110 transition-transform"
|
||||||
|
:class="{ 'animate-spin': isSyncingPropagationNode }"
|
||||||
|
>
|
||||||
|
<MaterialDesignIcon icon-name="sync" class="size-6" />
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-bold text-gray-900 dark:text-zinc-100">{{
|
||||||
|
isSyncingPropagationNode ? "Syncing..." : "Sync Node"
|
||||||
|
}}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex flex-col items-center gap-3 p-6 rounded-3xl bg-white dark:bg-zinc-900 border border-gray-100 dark:border-zinc-800 hover:border-blue-500/50 hover:shadow-xl hover:shadow-blue-500/5 transition-all group"
|
||||||
|
@click="copyMyAddress"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="size-12 rounded-2xl bg-emerald-50 dark:bg-emerald-900/20 text-emerald-600 flex items-center justify-center group-hover:scale-110 transition-transform"
|
||||||
|
>
|
||||||
|
<MaterialDesignIcon icon-name="content-copy" class="size-6" />
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-bold text-gray-900 dark:text-zinc-100">My Address</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex flex-col items-center gap-3 p-6 rounded-3xl bg-white dark:bg-zinc-900 border border-gray-100 dark:border-zinc-800 hover:border-blue-500/50 hover:shadow-xl hover:shadow-blue-500/5 transition-all group"
|
||||||
|
@click="$router.push({ name: 'identities' })"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="size-12 rounded-2xl bg-purple-50 dark:bg-purple-900/20 text-purple-600 flex items-center justify-center group-hover:scale-110 transition-transform"
|
||||||
|
>
|
||||||
|
<MaterialDesignIcon icon-name="account-multiple" class="size-6" />
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-bold text-gray-900 dark:text-zinc-100">Identities</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- latest chats section -->
|
||||||
|
<div v-if="latestConversations.length > 0" class="w-full mb-12">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h2
|
||||||
|
class="text-sm font-black text-gray-400 dark:text-zinc-500 uppercase tracking-widest flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<MaterialDesignIcon icon-name="history" class="size-4" />
|
||||||
|
Latest Conversations
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div
|
<div
|
||||||
v-for="chat in latestConversations"
|
v-for="chat in latestConversations"
|
||||||
:key="chat.destination_hash"
|
:key="chat.destination_hash"
|
||||||
class="group cursor-pointer p-4 bg-white dark:bg-zinc-900/50 border border-gray-100 dark:border-zinc-800 rounded-2xl hover:border-blue-500/50 hover:shadow-xl hover:shadow-blue-500/5 transition-all duration-300 flex items-center gap-4"
|
class="group cursor-pointer p-4 bg-white dark:bg-zinc-900 border border-gray-100 dark:border-zinc-800 rounded-3xl hover:border-blue-500/50 hover:shadow-xl transition-all flex items-center gap-4"
|
||||||
@click="$emit('update:selectedPeer', chat)"
|
@click="$emit('update:selectedPeer', chat)"
|
||||||
>
|
>
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
@@ -933,90 +982,103 @@
|
|||||||
: 'account'
|
: 'account'
|
||||||
"
|
"
|
||||||
:icon-foreground-colour="
|
:icon-foreground-colour="
|
||||||
chat.lxmf_user_icon && chat.lxmf_user_icon.foreground_colour
|
chat.lxmf_user_icon ? chat.lxmf_user_icon.foreground_colour : ''
|
||||||
? chat.lxmf_user_icon.foreground_colour
|
|
||||||
: ''
|
|
||||||
"
|
"
|
||||||
:icon-background-colour="
|
:icon-background-colour="
|
||||||
chat.lxmf_user_icon && chat.lxmf_user_icon.background_colour
|
chat.lxmf_user_icon ? chat.lxmf_user_icon.background_colour : ''
|
||||||
? chat.lxmf_user_icon.background_colour
|
|
||||||
: ''
|
|
||||||
"
|
"
|
||||||
icon-class="size-12 sm:size-14"
|
icon-class="size-12 sm:size-14"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="font-bold text-gray-900 dark:text-zinc-100 truncate">
|
<div class="flex items-center justify-between gap-2">
|
||||||
{{ chat.custom_display_name ?? chat.display_name }}
|
<div class="font-bold text-gray-900 dark:text-zinc-100 truncate">
|
||||||
|
{{ chat.custom_display_name ?? chat.display_name }}
|
||||||
|
</div>
|
||||||
|
<div class="text-[10px] text-gray-400 dark:text-zinc-500 whitespace-nowrap">
|
||||||
|
{{ formatTimeAgo(chat.updated_at) }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-gray-500 dark:text-zinc-500 truncate mt-0.5">
|
<div class="text-xs text-gray-500 dark:text-zinc-500 truncate mt-0.5">
|
||||||
{{ chat.latest_message_preview || chat.latest_message_title || "No messages yet" }}
|
{{ chat.latest_message_preview || chat.latest_message_title || "No messages yet" }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<v-icon
|
<MaterialDesignIcon
|
||||||
icon="mdi-chevron-right"
|
icon-name="chevron-right"
|
||||||
size="18"
|
class="size-5 text-gray-300 dark:text-zinc-700 group-hover:text-blue-500 transition-colors"
|
||||||
class="text-gray-300 dark:text-zinc-700 group-hover:text-blue-500 transition-colors"
|
/>
|
||||||
></v-icon>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- compose message input -->
|
<!-- address input composer -->
|
||||||
<div class="w-full relative">
|
<div class="w-full max-w-xl">
|
||||||
<input
|
<div class="relative group">
|
||||||
id="compose-input"
|
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||||
ref="compose-input"
|
<MaterialDesignIcon
|
||||||
v-model="composeAddress"
|
icon-name="at"
|
||||||
:readonly="isSendingMessage"
|
class="size-5 text-gray-400 group-focus-within:text-blue-500 transition-colors"
|
||||||
type="text"
|
/>
|
||||||
class="w-full bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 px-4 py-2.5 shadow-sm transition-all placeholder:text-gray-400 dark:placeholder:text-zinc-500"
|
</div>
|
||||||
placeholder="Enter LXMF address..."
|
<input
|
||||||
@keydown.enter.exact.prevent="onComposeEnterPressed"
|
id="compose-input"
|
||||||
@keydown.up.prevent="handleComposeInputUp"
|
ref="compose-input"
|
||||||
@keydown.down.prevent="handleComposeInputDown"
|
v-model="composeAddress"
|
||||||
@focus="isComposeInputFocused = true"
|
:readonly="isSendingMessage"
|
||||||
@blur="onComposeInputBlur"
|
type="text"
|
||||||
/>
|
class="w-full bg-white dark:bg-zinc-900 border-2 border-gray-100 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-base rounded-3xl focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 pl-12 pr-4 py-4 shadow-sm transition-all placeholder:text-gray-400 dark:placeholder:text-zinc-600 font-medium"
|
||||||
<!-- Suggestions Dropdown -->
|
placeholder="Enter LXMF address to start a conversation..."
|
||||||
<div
|
@keydown.enter.exact.prevent="onComposeEnterPressed"
|
||||||
v-if="isComposeInputFocused && composeSuggestions.length > 0"
|
@keydown.up.prevent="handleComposeInputUp"
|
||||||
class="absolute z-50 left-0 right-0 bottom-full mb-1 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-xl shadow-xl overflow-hidden animate-in fade-in slide-in-from-bottom-2 duration-200"
|
@keydown.down.prevent="handleComposeInputDown"
|
||||||
>
|
@focus="isComposeInputFocused = true"
|
||||||
|
@blur="onComposeInputBlur"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Suggestions Dropdown -->
|
||||||
<div
|
<div
|
||||||
v-for="(suggestion, index) in composeSuggestions"
|
v-if="isComposeInputFocused && composeSuggestions.length > 0"
|
||||||
:key="suggestion.hash"
|
class="absolute z-50 left-0 right-0 bottom-full mb-4 bg-white dark:bg-zinc-900 border border-gray-100 dark:border-zinc-800 rounded-3xl shadow-2xl overflow-hidden animate-in fade-in slide-in-from-bottom-4 duration-300"
|
||||||
class="px-4 py-2.5 flex items-center gap-3 cursor-pointer transition-colors"
|
|
||||||
:class="[
|
|
||||||
index === selectedComposeSuggestionIndex
|
|
||||||
? 'bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400'
|
|
||||||
: 'hover:bg-gray-50 dark:hover:bg-zinc-800/50 text-gray-700 dark:text-zinc-300',
|
|
||||||
]"
|
|
||||||
@mousedown.prevent="selectComposeSuggestion(suggestion)"
|
|
||||||
>
|
>
|
||||||
<div
|
<div class="p-2 space-y-1">
|
||||||
class="shrink-0 size-8 rounded-full flex items-center justify-center text-xs"
|
<div
|
||||||
:class="
|
v-for="(suggestion, index) in composeSuggestions"
|
||||||
suggestion.type === 'contact'
|
:key="suggestion.hash"
|
||||||
? 'bg-blue-100 dark:bg-blue-900/40 text-blue-600'
|
class="px-4 py-3 flex items-center gap-3 cursor-pointer rounded-2xl transition-all"
|
||||||
: 'bg-gray-100 dark:bg-zinc-800 text-gray-500'
|
:class="[
|
||||||
"
|
index === selectedComposeSuggestionIndex
|
||||||
>
|
? 'bg-blue-600 text-white shadow-lg'
|
||||||
<MaterialDesignIcon :icon-name="suggestion.icon" class="size-4" />
|
: 'hover:bg-gray-50 dark:hover:bg-zinc-800/50 text-gray-700 dark:text-zinc-300',
|
||||||
</div>
|
]"
|
||||||
<div class="flex-1 min-w-0">
|
@mousedown.prevent="selectComposeSuggestion(suggestion)"
|
||||||
<div class="text-sm font-bold truncate">
|
>
|
||||||
{{ suggestion.name }}
|
<div
|
||||||
|
class="shrink-0 size-10 rounded-xl flex items-center justify-center"
|
||||||
|
:class="[
|
||||||
|
index === selectedComposeSuggestionIndex
|
||||||
|
? 'bg-white/20'
|
||||||
|
: suggestion.type === 'contact'
|
||||||
|
? 'bg-blue-100 dark:bg-blue-900/40 text-blue-600'
|
||||||
|
: 'bg-gray-100 dark:bg-zinc-800 text-gray-500',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<MaterialDesignIcon :icon-name="suggestion.icon" class="size-5" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-sm font-bold truncate">
|
||||||
|
{{ suggestion.name }}
|
||||||
|
</div>
|
||||||
|
<div class="text-[10px] font-mono opacity-60 truncate">
|
||||||
|
{{ formatDestinationHash(suggestion.hash) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="suggestion.type === 'contact'"
|
||||||
|
class="text-[10px] uppercase font-black tracking-widest opacity-40 px-2 py-1 rounded-md bg-black/5"
|
||||||
|
>
|
||||||
|
Contact
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-[10px] font-mono opacity-50 truncate">
|
|
||||||
{{ formatDestinationHash(suggestion.hash) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="suggestion.type === 'contact'"
|
|
||||||
class="text-[10px] uppercase font-bold tracking-widest opacity-30"
|
|
||||||
>
|
|
||||||
Contact
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1097,10 +1159,157 @@
|
|||||||
<MaterialDesignIcon icon-name="close" class="size-6" />
|
<MaterialDesignIcon icon-name="close" class="size-6" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6 overflow-y-auto font-mono text-xs bg-gray-50 dark:bg-black/40">
|
<div class="p-0 overflow-y-auto bg-gray-50 dark:bg-zinc-950 flex-grow">
|
||||||
<pre class="whitespace-pre-wrap break-all text-gray-800 dark:text-zinc-300">{{
|
<div class="p-6 space-y-6">
|
||||||
JSON.stringify(rawMessageData, null, 2)
|
<!-- header / status info -->
|
||||||
}}</pre>
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label
|
||||||
|
class="text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500"
|
||||||
|
>Message ID</label
|
||||||
|
>
|
||||||
|
<div class="text-sm font-mono text-gray-900 dark:text-zinc-200">
|
||||||
|
{{ rawMessageData.id }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label
|
||||||
|
class="text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500"
|
||||||
|
>State</label
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset"
|
||||||
|
:class="
|
||||||
|
rawMessageData.state === 'delivered'
|
||||||
|
? 'bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-900/30 dark:text-green-400'
|
||||||
|
: 'bg-blue-50 text-blue-700 ring-blue-700/10 dark:bg-blue-900/30 dark:text-blue-400'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ rawMessageData.state }}
|
||||||
|
</span>
|
||||||
|
<span v-if="rawMessageData.is_incoming" class="text-[10px] text-gray-400"
|
||||||
|
>Incoming</span
|
||||||
|
>
|
||||||
|
<span v-else class="text-[10px] text-gray-400">Outbound</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label
|
||||||
|
class="text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500"
|
||||||
|
>Message Hash</label
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="text-sm font-mono break-all text-gray-900 dark:text-zinc-200 bg-white dark:bg-zinc-900 p-2 rounded border border-gray-100 dark:border-zinc-800"
|
||||||
|
>
|
||||||
|
{{ rawMessageData.hash }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label
|
||||||
|
class="text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500"
|
||||||
|
>Source Hash</label
|
||||||
|
>
|
||||||
|
<div class="text-xs font-mono break-all text-gray-900 dark:text-zinc-200">
|
||||||
|
{{ rawMessageData.source_hash }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label
|
||||||
|
class="text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500"
|
||||||
|
>Destination Hash</label
|
||||||
|
>
|
||||||
|
<div class="text-xs font-mono break-all text-gray-900 dark:text-zinc-200">
|
||||||
|
{{ rawMessageData.destination_hash }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label
|
||||||
|
class="text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500"
|
||||||
|
>Method</label
|
||||||
|
>
|
||||||
|
<div class="text-sm text-gray-900 dark:text-zinc-200 capitalize">
|
||||||
|
{{ rawMessageData.method }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label
|
||||||
|
class="text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500"
|
||||||
|
>RSSI</label
|
||||||
|
>
|
||||||
|
<div class="text-sm text-gray-900 dark:text-zinc-200">
|
||||||
|
{{ rawMessageData.rssi || "N/A" }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label
|
||||||
|
class="text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500"
|
||||||
|
>SNR</label
|
||||||
|
>
|
||||||
|
<div class="text-sm text-gray-900 dark:text-zinc-200">
|
||||||
|
{{ rawMessageData.snr || "N/A" }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label
|
||||||
|
class="text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500"
|
||||||
|
>Attempts</label
|
||||||
|
>
|
||||||
|
<div class="text-sm text-gray-900 dark:text-zinc-200">
|
||||||
|
{{ rawMessageData.delivery_attempts }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label
|
||||||
|
class="text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500"
|
||||||
|
>Content / App Data</label
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="text-xs font-mono bg-white dark:bg-zinc-900 p-3 rounded border border-gray-100 dark:border-zinc-800 whitespace-pre-wrap break-all text-gray-800 dark:text-zinc-300"
|
||||||
|
>
|
||||||
|
{{ rawMessageData.content }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="rawMessageData.raw_uri" class="space-y-1">
|
||||||
|
<label
|
||||||
|
class="text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500"
|
||||||
|
>Raw LXMF URI</label
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="text-[10px] font-mono bg-white dark:bg-zinc-900 p-2 rounded border border-gray-100 dark:border-zinc-800 break-all text-gray-600 dark:text-zinc-400"
|
||||||
|
>
|
||||||
|
{{ rawMessageData.raw_uri }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- JSON fallback for full detail -->
|
||||||
|
<details class="group">
|
||||||
|
<summary
|
||||||
|
class="flex items-center gap-2 cursor-pointer text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500 hover:text-gray-600 dark:hover:text-zinc-300 transition-colors"
|
||||||
|
>
|
||||||
|
<MaterialDesignIcon
|
||||||
|
icon-name="chevron-right"
|
||||||
|
class="size-4 group-open:rotate-90 transition-transform"
|
||||||
|
/>
|
||||||
|
View Full JSON Object
|
||||||
|
</summary>
|
||||||
|
<div class="mt-2 p-4 bg-black/5 dark:bg-black/20 rounded-lg overflow-x-auto">
|
||||||
|
<pre class="text-[10px] font-mono text-gray-600 dark:text-zinc-400">{{
|
||||||
|
JSON.stringify(rawMessageData, null, 2)
|
||||||
|
}}</pre>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-6 py-4 border-t border-gray-100 dark:border-zinc-800 flex justify-end shrink-0">
|
<div class="px-6 py-4 border-t border-gray-100 dark:border-zinc-800 flex justify-end shrink-0">
|
||||||
<button
|
<button
|
||||||
@@ -1233,9 +1442,21 @@ export default {
|
|||||||
rawMessageData: null,
|
rawMessageData: null,
|
||||||
hasTranslator: false,
|
hasTranslator: false,
|
||||||
translatorLanguages: [],
|
translatorLanguages: [],
|
||||||
|
propagationNodeStatus: null,
|
||||||
|
propagationStatusInterval: null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
isSyncingPropagationNode() {
|
||||||
|
return [
|
||||||
|
"path_requested",
|
||||||
|
"link_establishing",
|
||||||
|
"link_established",
|
||||||
|
"request_sent",
|
||||||
|
"receiving",
|
||||||
|
"response_received",
|
||||||
|
].includes(this.propagationNodeStatus?.state);
|
||||||
|
},
|
||||||
blockedDestinations() {
|
blockedDestinations() {
|
||||||
return GlobalState.blockedDestinations;
|
return GlobalState.blockedDestinations;
|
||||||
},
|
},
|
||||||
@@ -1387,6 +1608,9 @@ export default {
|
|||||||
// stop listening for websocket messages
|
// stop listening for websocket messages
|
||||||
WebSocketConnection.off("message", this.onWebsocketMessage);
|
WebSocketConnection.off("message", this.onWebsocketMessage);
|
||||||
GlobalEmitter.off("compose-new-message", this.onComposeNewMessageEvent);
|
GlobalEmitter.off("compose-new-message", this.onComposeNewMessageEvent);
|
||||||
|
if (this.propagationStatusInterval) {
|
||||||
|
clearInterval(this.propagationStatusInterval);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
// listen for websocket messages
|
// listen for websocket messages
|
||||||
@@ -1400,8 +1624,42 @@ export default {
|
|||||||
|
|
||||||
// fetch contacts for suggestions
|
// fetch contacts for suggestions
|
||||||
this.fetchContacts();
|
this.fetchContacts();
|
||||||
|
|
||||||
|
// fetch propagation status
|
||||||
|
this.updatePropagationNodeStatus();
|
||||||
|
this.propagationStatusInterval = setInterval(() => {
|
||||||
|
this.updatePropagationNodeStatus();
|
||||||
|
}, 2000);
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
async updatePropagationNodeStatus() {
|
||||||
|
try {
|
||||||
|
const response = await window.axios.get("/api/v1/lxmf/propagation-node/status");
|
||||||
|
this.propagationNodeStatus = response.data.propagation_node_status;
|
||||||
|
} catch {
|
||||||
|
// do nothing on error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async syncPropagationNode() {
|
||||||
|
GlobalEmitter.emit("sync-propagation-node");
|
||||||
|
},
|
||||||
|
async copyMyAddress() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(this.myLxmfAddressHash);
|
||||||
|
ToastUtils.success("Your LXMF address copied to clipboard");
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
ToastUtils.error("Failed to copy address");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
focusComposeInput() {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
const input = document.getElementById("compose-input");
|
||||||
|
if (input) {
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
async fetchContacts() {
|
async fetchContacts() {
|
||||||
try {
|
try {
|
||||||
const response = await window.axios.get("/api/v1/telephone/contacts");
|
const response = await window.axios.get("/api/v1/telephone/contacts");
|
||||||
|
|||||||
@@ -34,7 +34,10 @@
|
|||||||
class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r border-gray-200 dark:border-zinc-700 overflow-hidden min-h-0"
|
class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r border-gray-200 dark:border-zinc-700 overflow-hidden min-h-0"
|
||||||
>
|
>
|
||||||
<!-- search + filters -->
|
<!-- search + filters -->
|
||||||
<div v-if="conversations.length > 0" class="p-1 border-b border-gray-300 dark:border-zinc-700 space-y-2">
|
<div
|
||||||
|
v-if="conversations.length > 0 || isFilterActive"
|
||||||
|
class="p-1 border-b border-gray-300 dark:border-zinc-700 space-y-2"
|
||||||
|
>
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<input
|
<input
|
||||||
:value="conversationSearchTerm"
|
:value="conversationSearchTerm"
|
||||||
@@ -188,7 +191,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- no conversations at all -->
|
<!-- no conversations at all -->
|
||||||
<div v-else-if="conversations.length === 0" class="flex flex-col text-gray-900 dark:text-gray-100">
|
<div
|
||||||
|
v-else-if="conversations.length === 0 && !isFilterActive"
|
||||||
|
class="flex flex-col text-gray-900 dark:text-gray-100"
|
||||||
|
>
|
||||||
<div class="mx-auto mb-1 text-gray-500">
|
<div class="mx-auto mb-1 text-gray-500">
|
||||||
<MaterialDesignIcon icon-name="tray-remove" class="size-6" />
|
<MaterialDesignIcon icon-name="tray-remove" class="size-6" />
|
||||||
</div>
|
</div>
|
||||||
@@ -196,11 +202,8 @@
|
|||||||
<div>Discover peers on the Announces tab</div>
|
<div>Discover peers on the Announces tab</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- is searching, but no results -->
|
<!-- is searching or filtering, but no results -->
|
||||||
<div
|
<div v-else-if="isFilterActive" class="flex flex-col text-gray-900 dark:text-gray-100">
|
||||||
v-else-if="conversationSearchTerm !== ''"
|
|
||||||
class="flex flex-col text-gray-900 dark:text-gray-100"
|
|
||||||
>
|
|
||||||
<div class="mx-auto mb-1 text-gray-500">
|
<div class="mx-auto mb-1 text-gray-500">
|
||||||
<MaterialDesignIcon icon-name="magnify-close" class="size-6" />
|
<MaterialDesignIcon icon-name="magnify-close" class="size-6" />
|
||||||
</div>
|
</div>
|
||||||
@@ -418,6 +421,14 @@ export default {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
isFilterActive() {
|
||||||
|
return (
|
||||||
|
this.conversationSearchTerm !== "" ||
|
||||||
|
this.filterUnreadOnly ||
|
||||||
|
this.filterFailedOnly ||
|
||||||
|
this.filterHasAttachmentsOnly
|
||||||
|
);
|
||||||
|
},
|
||||||
blockedDestinations() {
|
blockedDestinations() {
|
||||||
return GlobalState.blockedDestinations;
|
return GlobalState.blockedDestinations;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -183,19 +183,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="glass-card__body space-y-4">
|
<div class="glass-card__body space-y-4">
|
||||||
<label class="setting-toggle">
|
<label class="setting-toggle opacity-50 cursor-not-allowed">
|
||||||
<Toggle
|
<Toggle
|
||||||
id="desktop-open-calls-in-separate-window"
|
id="desktop-open-calls-in-separate-window"
|
||||||
v-model="config.desktop_open_calls_in_separate_window"
|
:model-value="false"
|
||||||
@update:model-value="onDesktopOpenCallsInSeparateWindowChange"
|
:disabled="true"
|
||||||
/>
|
/>
|
||||||
<span class="setting-toggle__label">
|
<span class="setting-toggle__label">
|
||||||
<span class="setting-toggle__title">{{
|
<span class="setting-toggle__title">{{
|
||||||
$t("app.desktop_open_calls_in_separate_window")
|
$t("app.desktop_open_calls_in_separate_window")
|
||||||
}}</span>
|
}}</span>
|
||||||
<span class="setting-toggle__description">{{
|
<span class="setting-toggle__description">
|
||||||
$t("app.desktop_open_calls_in_separate_window_description")
|
{{ $t("app.desktop_open_calls_in_separate_window_description") }}
|
||||||
}}</span>
|
<span class="text-blue-500 font-bold block mt-1">(Phased out for now)</span>
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -615,6 +616,47 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Sources & Infrastructure -->
|
||||||
|
<section class="glass-card break-inside-avoid">
|
||||||
|
<header class="glass-card__header">
|
||||||
|
<div>
|
||||||
|
<div class="glass-card__eyebrow">Infrastructure</div>
|
||||||
|
<h2>Sources & Mirroring</h2>
|
||||||
|
<p>Customize URLs for documentation and external resources.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="glass-card__body space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Gitea Base URL</div>
|
||||||
|
<input
|
||||||
|
v-model="config.gitea_base_url"
|
||||||
|
type="text"
|
||||||
|
placeholder="https://git.quad4.io"
|
||||||
|
class="input-field"
|
||||||
|
@input="onGiteaConfigChange"
|
||||||
|
/>
|
||||||
|
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
The base URL for your preferred Gitea instance.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
Documentation Download URLs
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
v-model="config.docs_download_urls"
|
||||||
|
placeholder="Enter one URL per line (or comma-separated)"
|
||||||
|
class="input-field min-h-[100px] text-xs font-mono"
|
||||||
|
@input="onGiteaConfigChange"
|
||||||
|
></textarea>
|
||||||
|
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
List of ZIP URLs to try when downloading documentation. One URL per line.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Messages -->
|
<!-- Messages -->
|
||||||
<section class="glass-card break-inside-avoid">
|
<section class="glass-card break-inside-avoid">
|
||||||
<header class="glass-card__header">
|
<header class="glass-card__header">
|
||||||
@@ -904,6 +946,8 @@ export default {
|
|||||||
blackhole_integration_enabled: true,
|
blackhole_integration_enabled: true,
|
||||||
telephone_tone_generator_enabled: true,
|
telephone_tone_generator_enabled: true,
|
||||||
telephone_tone_generator_volume: 50,
|
telephone_tone_generator_volume: 50,
|
||||||
|
gitea_base_url: "https://git.quad4.io",
|
||||||
|
docs_download_urls: "",
|
||||||
},
|
},
|
||||||
saveTimeouts: {},
|
saveTimeouts: {},
|
||||||
shortcuts: [],
|
shortcuts: [],
|
||||||
@@ -1257,6 +1301,18 @@ export default {
|
|||||||
);
|
);
|
||||||
}, 1000);
|
}, 1000);
|
||||||
},
|
},
|
||||||
|
async onGiteaConfigChange() {
|
||||||
|
if (this.saveTimeouts.gitea) clearTimeout(this.saveTimeouts.gitea);
|
||||||
|
this.saveTimeouts.gitea = setTimeout(async () => {
|
||||||
|
await this.updateConfig(
|
||||||
|
{
|
||||||
|
gitea_base_url: this.config.gitea_base_url,
|
||||||
|
docs_download_urls: this.config.docs_download_urls,
|
||||||
|
},
|
||||||
|
"Infrastructure"
|
||||||
|
);
|
||||||
|
}, 1000);
|
||||||
|
},
|
||||||
async flushArchivedPages() {
|
async flushArchivedPages() {
|
||||||
if (
|
if (
|
||||||
!(await DialogUtils.confirm(
|
!(await DialogUtils.confirm(
|
||||||
|
|||||||
@@ -396,13 +396,13 @@
|
|||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<a
|
<a
|
||||||
target="_blank"
|
target="_blank"
|
||||||
href="https://git.quad4.io/Reticulum/rnode-flasher"
|
:href="`${giteaBaseUrl}/Reticulum/rnode-flasher`"
|
||||||
class="text-blue-500 hover:underline text-sm font-bold"
|
class="text-blue-500 hover:underline text-sm font-bold"
|
||||||
>RNode Flasher GH</a
|
>RNode Flasher GH</a
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
target="_blank"
|
target="_blank"
|
||||||
href="https://git.quad4.io/Reticulum/RNode_Firmware"
|
:href="`${giteaBaseUrl}/Reticulum/RNode_Firmware`"
|
||||||
class="text-blue-500 hover:underline text-sm font-bold"
|
class="text-blue-500 hover:underline text-sm font-bold"
|
||||||
>RNode Firmware GH</a
|
>RNode Firmware GH</a
|
||||||
>
|
>
|
||||||
@@ -420,6 +420,7 @@ import Nrf52DfuFlasher from "../../js/rnode/Nrf52DfuFlasher.js";
|
|||||||
import RNodeUtils from "../../js/rnode/RNodeUtils.js";
|
import RNodeUtils from "../../js/rnode/RNodeUtils.js";
|
||||||
import products from "../../js/rnode/products.js";
|
import products from "../../js/rnode/products.js";
|
||||||
import ToastUtils from "../../js/ToastUtils.js";
|
import ToastUtils from "../../js/ToastUtils.js";
|
||||||
|
import GlobalState from "../../js/GlobalState";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "RNodeFlasherPage",
|
name: "RNodeFlasherPage",
|
||||||
@@ -459,6 +460,9 @@ export default {
|
|||||||
recommendedFirmwareFilename() {
|
recommendedFirmwareFilename() {
|
||||||
return this.selectedModel?.firmware_filename ?? this.selectedProduct?.firmware_filename;
|
return this.selectedModel?.firmware_filename ?? this.selectedProduct?.firmware_filename;
|
||||||
},
|
},
|
||||||
|
giteaBaseUrl() {
|
||||||
|
return GlobalState.config?.gitea_base_url || "https://git.quad4.io";
|
||||||
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
selectedProduct() {
|
selectedProduct() {
|
||||||
@@ -473,7 +477,7 @@ export default {
|
|||||||
async fetchLatestRelease() {
|
async fetchLatestRelease() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
"https://git.quad4.io/api/v1/repos/Reticulum/RNode_Firmware/releases/latest"
|
`${this.giteaBaseUrl}/api/v1/repos/Reticulum/RNode_Firmware/releases/latest`
|
||||||
);
|
);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
this.latestRelease = await response.json();
|
this.latestRelease = await response.json();
|
||||||
|
|||||||
Reference in New Issue
Block a user