diff --git a/donate.md b/donate.md deleted file mode 100644 index f64f99b..0000000 --- a/donate.md +++ /dev/null @@ -1,10 +0,0 @@ -# Donate - -Thank you for considering donating, this helps support my work on this project 😁 - -## How can I donate? - -- Bitcoin: bc1qy22smke8n4c54evdxmp7lpy9p0e6m9tavtlg2q -- Ethereum: 0xc64CFbA5D0BF7664158c5671F64d446395b3bF3D -- Buy me a Coffee: [https://ko-fi.com/liamcottle](https://ko-fi.com/liamcottle) -- Sponsor on GitHub: [https://github.com/sponsors/liamcottle](https://github.com/sponsors/liamcottle) diff --git a/meshchat.py b/meshchat.py index 256f1fe..94627fe 100644 --- a/meshchat.py +++ b/meshchat.py @@ -1,6 +1,7 @@ #!/usr/bin/env python import argparse +import copy import io import json import os @@ -20,7 +21,7 @@ import asyncio import base64 import webbrowser -from peewee import SqliteDatabase +from peewee import SqliteDatabase, fn from serial.tools import list_ports import psutil @@ -281,6 +282,157 @@ class ReticulumMeshChat: print("failed to enable or disable propagation node") pass + def _get_reticulum_section(self): + try: + reticulum_config = self.reticulum.config["reticulum"] + except Exception: + reticulum_config = None + + if not isinstance(reticulum_config, dict): + reticulum_config = {} + self.reticulum.config["reticulum"] = reticulum_config + + return reticulum_config + + def _get_interfaces_section(self): + + try: + interfaces = self.reticulum.config["interfaces"] + except Exception: + interfaces = None + + if not isinstance(interfaces, dict): + interfaces = {} + self.reticulum.config["interfaces"] = interfaces + + return interfaces + + def _get_interfaces_snapshot(self): + snapshot = {} + interfaces = self._get_interfaces_section() + for name, interface in interfaces.items(): + try: + snapshot[name] = copy.deepcopy(dict(interface)) + except Exception: + try: + snapshot[name] = copy.deepcopy(interface) + except Exception: + snapshot[name] = {} + return snapshot + + def _write_reticulum_config(self): + try: + self.reticulum.config.write() + return True + except Exception as e: + print(f"Failed to write Reticulum config: {e}") + return False + + def build_user_guidance_messages(self): + guidance = [] + + interfaces = self._get_interfaces_section() + if len(interfaces) == 0: + guidance.append({ + "id": "no_interfaces", + "title": "No Reticulum interfaces configured", + "description": "Add at least one Reticulum interface so MeshChat can talk to your radio or transport.", + "action_route": "/interfaces/add", + "action_label": "Add Interface", + "severity": "warning", + }) + + if not self.reticulum.transport_enabled(): + guidance.append({ + "id": "transport_disabled", + "title": "Transport mode is disabled", + "description": "Enable transport to allow MeshChat to relay traffic over your configured interfaces.", + "action_route": "/settings", + "action_label": "Open Settings", + "severity": "info", + }) + + if not self.config.auto_announce_enabled.get(): + guidance.append({ + "id": "announce_disabled", + "title": "Auto announcements are turned off", + "description": "Automatic announces make it easier for other peers to discover you. Enable them if you want to stay visible.", + "action_route": "/settings", + "action_label": "Manage Announce Settings", + "severity": "info", + }) + + return guidance + + def _conversation_messages_query(self, destination_hash: str): + local_hash = self.local_lxmf_destination.hexhash + return (database.LxmfMessage + .select() + .where( + ((database.LxmfMessage.source_hash == local_hash) & (database.LxmfMessage.destination_hash == destination_hash)) + | ((database.LxmfMessage.destination_hash == local_hash) & (database.LxmfMessage.source_hash == destination_hash)) + )) + + def get_conversation_latest_message(self, destination_hash: str): + return (self._conversation_messages_query(destination_hash) + .order_by(database.LxmfMessage.id.desc()) + .get_or_none()) + + def conversation_has_attachments(self, destination_hash: str): + query = (self._conversation_messages_query(destination_hash) + .where( + database.LxmfMessage.fields.contains('"image"') + | database.LxmfMessage.fields.contains('"audio"') + | database.LxmfMessage.fields.contains('"file_attachments"') + ) + .limit(1)) + return query.exists() + + def message_fields_have_attachments(self, fields_json: str | None): + if not fields_json: + return False + try: + fields = json.loads(fields_json) + except Exception: + return False + if "image" in fields or "audio" in fields: + return True + if "file_attachments" in fields and isinstance(fields["file_attachments"], list): + return len(fields["file_attachments"]) > 0 + return False + + def search_destination_hashes_by_message(self, search_term: str): + if search_term is None or search_term.strip() == "": + return set() + + local_hash = self.local_lxmf_destination.hexhash + like_term = f"%{search_term}%" + + matches = set() + query = (database.LxmfMessage + .select(database.LxmfMessage.source_hash, database.LxmfMessage.destination_hash) + .where( + ((database.LxmfMessage.source_hash == local_hash) | (database.LxmfMessage.destination_hash == local_hash)) + & ( + database.LxmfMessage.title ** like_term + | database.LxmfMessage.content ** like_term + ) + )) + + for message in query: + if message.source_hash == local_hash: + matches.add(message.destination_hash) + else: + matches.add(message.source_hash) + + return matches + + def parse_bool_query_param(self, value: str | None) -> bool: + if value is None: + return False + value = value.lower() + return value in {"1", "true", "yes", "on"} + # handle receiving a new audio call def on_incoming_audio_call(self, audio_call: AudioCall): print("on_incoming_audio_call: {}".format(audio_call.link.hash.hex())) @@ -340,13 +492,11 @@ class ReticulumMeshChat: @routes.get("/api/v1/reticulum/interfaces") async def index(request): - interfaces = {} - if "interfaces" in self.reticulum.config: - interfaces = self.reticulum.config["interfaces"] + interfaces = self._get_interfaces_snapshot() processed_interfaces = {} for interface_name, interface in interfaces.items(): - interface_data = interface.copy() + interface_data = copy.deepcopy(interface) # handle sub-interfaces for RNodeMultiInterface if interface_data.get("type") == "RNodeMultiInterface": @@ -377,23 +527,35 @@ class ReticulumMeshChat: data = await request.json() interface_name = data.get('name') - # enable interface - if "interfaces" in self.reticulum.config: - interface = self.reticulum.config["interfaces"][interface_name] - if "enabled" in interface: - interface["enabled"] = "true" - if "interface_enabled" in interface: - interface["interface_enabled"] = "true" + if interface_name is None or interface_name == "": + return web.json_response({ + "message": "Interface name is required", + }, status=422) - keys_to_remove = [] - for key, value in interface.items(): - if value is None: - keys_to_remove.append(key) - for key in keys_to_remove: - del interface[key] + # enable interface + interfaces = self._get_interfaces_section() + if interface_name not in interfaces: + return web.json_response({ + "message": "Interface not found", + }, status=404) + interface = interfaces[interface_name] + if "enabled" in interface: + interface["enabled"] = "true" + if "interface_enabled" in interface: + interface["interface_enabled"] = "true" + + keys_to_remove = [] + for key, value in interface.items(): + if value is None: + keys_to_remove.append(key) + for key in keys_to_remove: + del interface[key] # save config - self.reticulum.config.write() + if not self._write_reticulum_config(): + return web.json_response({ + "message": "Failed to write Reticulum config", + }, status=500) return web.json_response({ "message": "Interface is now enabled", @@ -407,23 +569,35 @@ class ReticulumMeshChat: data = await request.json() interface_name = data.get('name') - # disable interface - if "interfaces" in self.reticulum.config: - interface = self.reticulum.config["interfaces"][interface_name] - if "enabled" in interface: - interface["enabled"] = "false" - if "interface_enabled" in interface: - interface["interface_enabled"] = "false" + if interface_name is None or interface_name == "": + return web.json_response({ + "message": "Interface name is required", + }, status=422) - keys_to_remove = [] - for key, value in interface.items(): - if value is None: - keys_to_remove.append(key) - for key in keys_to_remove: - del interface[key] + # disable interface + interfaces = self._get_interfaces_section() + if interface_name not in interfaces: + return web.json_response({ + "message": "Interface not found", + }, status=404) + interface = interfaces[interface_name] + if "enabled" in interface: + interface["enabled"] = "false" + if "interface_enabled" in interface: + interface["interface_enabled"] = "false" + + keys_to_remove = [] + for key, value in interface.items(): + if value is None: + keys_to_remove.append(key) + for key in keys_to_remove: + del interface[key] # save config - self.reticulum.config.write() + if not self._write_reticulum_config(): + return web.json_response({ + "message": "Failed to write Reticulum config", + }, status=500) return web.json_response({ "message": "Interface is now disabled", @@ -437,12 +611,25 @@ class ReticulumMeshChat: data = await request.json() interface_name = data.get('name') + if interface_name is None or interface_name == "": + return web.json_response({ + "message": "Interface name is required", + }, status=422) + + interfaces = self._get_interfaces_section() + if interface_name not in interfaces: + return web.json_response({ + "message": "Interface not found", + }, status=404) + # delete interface - if "interfaces" in self.reticulum.config: - del self.reticulum.config["interfaces"][interface_name] + del interfaces[interface_name] # save config - self.reticulum.config.write() + if not self._write_reticulum_config(): + return web.json_response({ + "message": "Failed to write Reticulum config", + }, status=500) return web.json_response({ "message": "Interface has been deleted", @@ -471,9 +658,7 @@ class ReticulumMeshChat: }, status=422) # get existing interfaces - interfaces = {} - if "interfaces" in self.reticulum.config: - interfaces = self.reticulum.config["interfaces"] + interfaces = self._get_interfaces_section() # ensure name is not for an existing interface, to prevent overwriting if allow_overwriting_interface is False and interface_name in interfaces: @@ -684,7 +869,7 @@ class ReticulumMeshChat: # remove any existing sub interfaces, which can be found by finding keys that contain a dict value # this allows us to replace all sub interfaces with the ones we are about to add, while also ensuring # that we do not remove any existing config values from the main interface config - for key in interface_details: + for key in list(interface_details.keys()): value = interface_details[key] if isinstance(value, dict): del interface_details[key] @@ -783,10 +968,11 @@ class ReticulumMeshChat: # merge new interface into existing interfaces interfaces[interface_name] = interface_details - self.reticulum.config["interfaces"] = interfaces - # save config - self.reticulum.config.write() + if not self._write_reticulum_config(): + return web.json_response({ + "message": "Failed to write Reticulum config", + }, status=500) if allow_overwriting_interface: return web.json_response({ @@ -813,7 +999,8 @@ class ReticulumMeshChat: # format interfaces for export output = [] - for interface_name, interface in self.reticulum.config["interfaces"].items(): + interfaces = self._get_interfaces_snapshot() + for interface_name, interface in interfaces.items(): # skip interface if not selected if selected_interface_names is not None and selected_interface_names != "": @@ -913,8 +1100,12 @@ class ReticulumMeshChat: del interface_config[interface_name]["enabled"] # update reticulum config with new interfaces - self.reticulum.config["interfaces"].update(interface_config) - self.reticulum.config.write() + interfaces = self._get_interfaces_section() + interfaces.update(interface_config) + if not self._write_reticulum_config(): + return web.json_response({ + "message": "Failed to write Reticulum config", + }, status=500) return web.json_response({ "message": "Interfaces imported successfully", @@ -1024,6 +1215,7 @@ class ReticulumMeshChat: "download_stats": { "avg_download_speed_bps": avg_download_speed_bps, }, + "user_guidance": self.build_user_guidance_messages(), }, }) @@ -1053,8 +1245,12 @@ class ReticulumMeshChat: async def index(request): # enable transport mode - self.reticulum.config["reticulum"]["enable_transport"] = True - self.reticulum.config.write() + reticulum_config = self._get_reticulum_section() + reticulum_config["enable_transport"] = True + if not self._write_reticulum_config(): + return web.json_response({ + "message": "Failed to write Reticulum config", + }, status=500) return web.json_response({ "message": "Transport has been enabled. MeshChat must be restarted for this change to take effect.", @@ -1065,8 +1261,12 @@ class ReticulumMeshChat: async def index(request): # disable transport mode - self.reticulum.config["reticulum"]["enable_transport"] = False - self.reticulum.config.write() + reticulum_config = self._get_reticulum_section() + reticulum_config["enable_transport"] = False + if not self._write_reticulum_config(): + return web.json_response({ + "message": "Failed to write Reticulum config", + }, status=500) return web.json_response({ "message": "Transport has been disabled. MeshChat must be restarted for this change to take effect.", @@ -2032,6 +2232,15 @@ class ReticulumMeshChat: @routes.get("/api/v1/lxmf/conversations") async def index(request): + search_query = request.query.get("search", None) + filter_unread = self.parse_bool_query_param(request.query.get("filter_unread")) + filter_failed = self.parse_bool_query_param(request.query.get("filter_failed")) + filter_has_attachments = self.parse_bool_query_param(request.query.get("filter_has_attachments")) + + search_destination_hashes = set() + if search_query is not None and search_query != "": + search_destination_hashes = self.search_destination_hashes_by_message(search_query) + # sql query to fetch unique source/destination hash pairs ordered by the most recently updated message query = """ WITH NormalizedMessages AS ( @@ -2068,6 +2277,19 @@ class ReticulumMeshChat: else: other_user_hash = source_hash + latest_message = self.get_conversation_latest_message(other_user_hash) + latest_message_title = None + latest_message_preview = None + latest_message_created_at = None + latest_message_has_attachments = False + if latest_message is not None: + latest_message_title = latest_message.title + latest_message_preview = latest_message.content + latest_message_created_at = latest_message.created_at + latest_message_has_attachments = self.message_fields_have_attachments(latest_message.fields) + + has_attachments = self.conversation_has_attachments(other_user_hash) + # find lxmf user icon from database lxmf_user_icon = None db_lxmf_user_icon = database.LxmfUserIcon.get_or_none(database.LxmfUserIcon.destination_hash == other_user_hash) @@ -2085,12 +2307,40 @@ class ReticulumMeshChat: "destination_hash": other_user_hash, "is_unread": self.is_lxmf_conversation_unread(other_user_hash), "failed_messages_count": self.lxmf_conversation_failed_messages_count(other_user_hash), + "has_attachments": has_attachments, + "latest_message_title": latest_message_title, + "latest_message_preview": latest_message_preview, + "latest_message_created_at": latest_message_created_at, + "latest_message_has_attachments": latest_message_has_attachments, "lxmf_user_icon": lxmf_user_icon, # we say the conversation was updated when the latest message was created # otherwise this will go crazy when sending a message, as the updated_at on the latest message changes very frequently "updated_at": created_at, }) + if search_query is not None and search_query != "": + lowered_query = search_query.lower() + filtered = [] + for conversation in conversations: + matches_display = conversation["display_name"] and lowered_query in conversation["display_name"].lower() + matches_custom = conversation["custom_display_name"] and lowered_query in conversation["custom_display_name"].lower() + matches_destination = conversation["destination_hash"] and lowered_query in conversation["destination_hash"].lower() + matches_latest_title = conversation["latest_message_title"] and lowered_query in conversation["latest_message_title"].lower() + matches_latest_preview = conversation["latest_message_preview"] and lowered_query in conversation["latest_message_preview"].lower() + matches_history = conversation["destination_hash"] in search_destination_hashes + if matches_display or matches_custom or matches_destination or matches_latest_title or matches_latest_preview or matches_history: + filtered.append(conversation) + conversations = filtered + + if filter_unread: + conversations = [c for c in conversations if c["is_unread"]] + + if filter_failed: + conversations = [c for c in conversations if c["failed_messages_count"] > 0] + + if filter_has_attachments: + conversations = [c for c in conversations if c["has_attachments"]] + return web.json_response({ "conversations": conversations, }) diff --git a/package-lock.json b/package-lock.json index 35a862e..570da38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3025,6 +3025,7 @@ "integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "builder-util": "24.13.1", diff --git a/package.json b/package.json index 547b562..db91b21 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,8 @@ "electron-builder": "^24.6.3" }, "build": { - "appId": "com.liamcottle.reticulummeshchat", - "productName": "Reticulum MeshChat", + "appId": "com.sudoivan.reticulummeshchat", + "productName": "Reticulum MeshChatX", "asar": false, "files": [ "electron/**/*" @@ -74,7 +74,7 @@ "AppImage", "deb" ], - "maintainer": "Liam Cottle ", + "maintainer": "Sudo-Ivan", "extraFiles": [ { "from": "build/exe", diff --git a/setup.py b/setup.py index a6dfba5..109f874 100644 --- a/setup.py +++ b/setup.py @@ -1,15 +1,15 @@ from cx_Freeze import setup, Executable setup( - name='ReticulumMeshChat', + name='ReticulumMeshChatX', version='1.0.0', description='A simple mesh network communications app powered by the Reticulum Network Stack', executables=[ Executable( script='meshchat.py', # this script to run base=None, # we are running a console application, not a gui - target_name='ReticulumMeshChat', # creates ReticulumMeshChat.exe - shortcut_name='ReticulumMeshChat', # name shown in shortcut + target_name='ReticulumMeshChatX', # creates ReticulumMeshChatX.exe + shortcut_name='ReticulumMeshChatX', # name shown in shortcut shortcut_dir='ProgramMenuFolder', # put the shortcut in windows start menu icon='logo/icon.ico', # set the icon for the exe copyright='Copyright (c) 2024 Liam Cottle', diff --git a/src/backend/interface_config_parser.py b/src/backend/interface_config_parser.py index 94bf823..6451720 100644 --- a/src/backend/interface_config_parser.py +++ b/src/backend/interface_config_parser.py @@ -8,16 +8,24 @@ class InterfaceConfigParser: # get lines from provided text lines = text.splitlines() + stripped_lines = [line.strip() for line in lines] # ensure [interfaces] section exists - if "[interfaces]" not in lines: + if "[interfaces]" not in stripped_lines: lines.insert(0, "[interfaces]") + stripped_lines.insert(0, "[interfaces]") - # parse lines as rns config object - config = RNS.vendor.configobj.ConfigObj(lines) + try: + # parse lines as rns config object + config = RNS.vendor.configobj.ConfigObj(lines) + except Exception as e: + print(f"Failed to parse interface config with ConfigObj: {e}") + return InterfaceConfigParser._parse_best_effort(lines) # get interfaces from config - config_interfaces = config.get("interfaces") + config_interfaces = config.get("interfaces", {}) + if config_interfaces is None: + return [] # process interfaces interfaces = [] @@ -29,3 +37,58 @@ class InterfaceConfigParser: interfaces.append(interface_config) return interfaces + + @staticmethod + def _parse_best_effort(lines): + interfaces = [] + current_interface_name = None + current_interface = {} + current_sub_name = None + current_sub = None + + def commit_sub(): + nonlocal current_sub_name, current_sub + if current_sub_name and current_sub is not None: + current_interface[current_sub_name] = current_sub + current_sub_name = None + current_sub = None + + def commit_interface(): + nonlocal current_interface_name, current_interface + if current_interface_name: + # shallow copy to avoid future mutation + interfaces.append(dict(current_interface)) + current_interface_name = None + current_interface = {} + + for raw_line in lines: + line = raw_line.strip() + if line == "" or line.startswith("#"): + continue + + if line.lower() == "[interfaces]": + continue + + if line.startswith("[[[") and line.endswith("]]]"): + commit_sub() + current_sub_name = line[3:-3].strip() + current_sub = {} + continue + + if line.startswith("[[") and line.endswith("]]"): + commit_sub() + commit_interface() + current_interface_name = line[2:-2].strip() + current_interface = {"name": current_interface_name} + continue + + if "=" in line and current_interface_name is not None: + key, value = line.split("=", 1) + target = current_sub if current_sub is not None else current_interface + target[key.strip()] = value.strip() + + # commit any pending sections + commit_sub() + commit_interface() + + return interfaces diff --git a/src/frontend/call.html b/src/frontend/call.html index 55f6c28..c8632f1 100644 --- a/src/frontend/call.html +++ b/src/frontend/call.html @@ -7,12 +7,6 @@ Phone | Reticulum MeshChat - - - - - -
diff --git a/src/frontend/call.js b/src/frontend/call.js index 4b225b1..4f0bf64 100644 --- a/src/frontend/call.js +++ b/src/frontend/call.js @@ -1,10 +1,16 @@ import axios from 'axios'; -import {createApp} from 'vue'; +import { createApp } from 'vue'; import "./style.css"; import CallPage from "./components/call/CallPage.vue"; +import { ensureCodec2ScriptsLoaded } from "./js/Codec2Loader"; // provide axios globally window.axios = axios; -createApp(CallPage) - .mount('#app'); +async function bootstrap() { + await ensureCodec2ScriptsLoaded(); + createApp(CallPage) + .mount('#app'); +} + +bootstrap(); diff --git a/src/frontend/components/App.vue b/src/frontend/components/App.vue index 3e69308..73f878a 100644 --- a/src/frontend/components/App.vue +++ b/src/frontend/components/App.vue @@ -1,54 +1,86 @@ @@ -326,6 +359,7 @@ export default { return { reloadInterval: null, + appInfoInterval: null, isShowingMyIdentitySection: true, isShowingAnnounceSection: true, @@ -343,6 +377,7 @@ export default { beforeUnmount() { clearInterval(this.reloadInterval); + clearInterval(this.appInfoInterval); // stop listening for websocket messages WebSocketConnection.off("message", this.onWebsocketMessage); @@ -354,6 +389,7 @@ export default { WebSocketConnection.on("message", this.onWebsocketMessage); this.getAppInfo(); + this.getConfig(); this.updateCallsList(); this.updatePropagationNodeStatus(); @@ -362,9 +398,52 @@ export default { this.updateCallsList(); this.updatePropagationNodeStatus(); }, 3000); + this.appInfoInterval = setInterval(() => { + this.getAppInfo(); + }, 15000); }, + computed: { + currentPopoutType() { + if(this.$route?.meta?.popoutType){ + return this.$route.meta.popoutType; + } + return this.$route?.query?.popout ?? this.getHashPopoutValue(); + }, + isPopoutMode() { + return this.currentPopoutType != null; + }, + hasGuidanceMessages() { + return this.guidanceMessages.length > 0; + }, + guidanceMessages() { + if (!this.appInfo || !Array.isArray(this.appInfo.user_guidance)) { + return []; + } + return this.appInfo.user_guidance; + }, + }, methods: { + guidanceCardClass(message) { + switch(message.severity){ + case 'warning': + return 'border-amber-200 bg-white text-amber-900 dark:bg-transparent dark:border-amber-300/40'; + case 'info': + default: + return 'border-amber-100 bg-white text-amber-900 dark:bg-transparent dark:border-amber-200/30'; + } + }, + navigateTo(routePath) { + if (!routePath) { + return; + } + this.$router.push(routePath); + }, + getHashPopoutValue() { + const hash = window.location.hash || ""; + const match = hash.match(/popout=([^&]+)/); + return match ? decodeURIComponent(match[1]) : null; + }, async onWebsocketMessage(message) { const json = JSON.parse(message.data); switch(json.type){ diff --git a/src/frontend/components/about/AboutPage.vue b/src/frontend/components/about/AboutPage.vue index bed9a76..0d80d4f 100644 --- a/src/frontend/components/about/AboutPage.vue +++ b/src/frontend/components/about/AboutPage.vue @@ -1,253 +1,200 @@ @@ -255,8 +202,13 @@ + + diff --git a/src/frontend/components/interfaces/ImportInterfacesModal.vue b/src/frontend/components/interfaces/ImportInterfacesModal.vue index bf3da53..f1fbe36 100644 --- a/src/frontend/components/interfaces/ImportInterfacesModal.vue +++ b/src/frontend/components/interfaces/ImportInterfacesModal.vue @@ -125,9 +125,10 @@ export default { this.importableInterfaces = []; this.selectedInterfaces = []; }, - dismiss() { + dismiss(result = false) { this.isShowing = false; - this.$emit("dismissed"); + const imported = result === true; + this.$emit("dismissed", imported); }, clearSelectedFile() { this.selectedFile = null; @@ -221,7 +222,7 @@ export default { }); // dismiss modal - this.dismiss(); + this.dismiss(true); // tell user interfaces were imported DialogUtils.alert("Interfaces imported successfully. MeshChat must be restarted for these changes to take effect."); diff --git a/src/frontend/components/interfaces/Interface.vue b/src/frontend/components/interfaces/Interface.vue index afa9595..7d27f47 100644 --- a/src/frontend/components/interfaces/Interface.vue +++ b/src/frontend/components/interfaces/Interface.vue @@ -1,207 +1,117 @@