├── demo.png ├── node_helper.js ├── package.json ├── LICENSE ├── README.md ├── MMM-NouncilVotes.css └── MMM-NouncilVotes.js /demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XppaiCyberr/MMM-NouncilVotes/HEAD/demo.png -------------------------------------------------------------------------------- /node_helper.js: -------------------------------------------------------------------------------- 1 | const NodeHelper = require("node_helper"); 2 | 3 | module.exports = NodeHelper.create({ 4 | start: function() { 5 | console.log("Starting node helper for: " + this.name); 6 | }, 7 | 8 | socketNotificationReceived: function(notification, payload) { 9 | // Handle socket notifications from the module here if needed in the future 10 | } 11 | }); 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mmm-nouncilvotes", 3 | "version": "1.0.1", 4 | "description": "A MagicMirror module to display Nouncil voting participation data", 5 | "main": "MMM-NouncilVotes.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [ 10 | "magicmirror", 11 | "module", 12 | "nouncil", 13 | "voting", 14 | "participation" 15 | ], 16 | "author": "XppaiCyber", 17 | "license": "MIT", 18 | "dependencies": {}, 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/XppaiCyberr/MMM-NouncilVotes.git" 22 | }, 23 | "bugs": { 24 | "url": "https://github.com/XppaiCyberr/MMM-NouncilVotes/issues" 25 | }, 26 | "homepage": "https://github.com/XppaiCyberr/MMM-NouncilVotes#readme" 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 XppaiCyber 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MMM-NouncilVotes 2 | 3 | A MagicMirror module that displays Nouncil voting participation data in a clean, informative interface. 4 | 5 | ![demo](demo.png) 6 | 7 | ## Features 8 | 9 | - Real-time Nouncil voting participation tracking 10 | - Configurable display options 11 | - Pagination and cycling of voter data 12 | - Highlighting of top voters 13 | - Responsive and visually appealing design 14 | 15 | ## Installation 16 | 17 | 1. Navigate to your MagicMirror's `modules` directory 18 | 2. Clone this repository: 19 | ```bash 20 | git clone https://github.com/XppaiCyberr/MMM-NouncilVotes.git 21 | ``` 22 | 3. Install dependencies: 23 | ```bash 24 | cd MMM-NouncilVotes 25 | npm install 26 | ``` 27 | 28 | ## Configuration 29 | 30 | Add the module to your `config.js` file: 31 | 32 | ```javascript 33 | { 34 | module: 'MMM-NouncilVotes', 35 | position: 'top_right', 36 | config: { 37 | updateInterval: 5 * 60 * 1000, // Update every 5 minutes 38 | maxEntries: 10, // Number of entries visible at once 39 | cycleInterval: 15 * 1000, // Cycle through data every 15 seconds 40 | showTwitter: false, // Show Twitter handles 41 | showWallet: false, // Show wallet addresses 42 | minParticipationRate: 0, // Minimum participation rate to show 43 | highlightTopVoters: true, // Highlight top 3 voters 44 | animationSpeed: 2000, // Speed of transitions 45 | showLastUpdated: true // Show when data was last updated 46 | } 47 | } 48 | ``` 49 | 50 | ## Configuration Options 51 | 52 | | Option | Description | Default | Type | 53 | |--------|-------------|---------|------| 54 | | `updateInterval` | How often to fetch new data (in milliseconds) | `5 * 60 * 1000` | Number | 55 | | `maxEntries` | Number of entries to display per page | `10` | Number | 56 | | `cycleInterval` | Time between page changes (in milliseconds) | `15 * 1000` | Number | 57 | | `showTwitter` | Display Twitter handles | `false` | Boolean | 58 | | `showWallet` | Display wallet addresses | `false` | Boolean | 59 | | `minParticipationRate` | Minimum participation rate to display (0-100) | `0` | Number | 60 | | `highlightTopVoters` | Highlight top 3 voters | `true` | Boolean | 61 | | `animationSpeed` | Speed of UI transitions (in milliseconds) | `2000` | Number | 62 | | `showLastUpdated` | Show when data was last updated | `true` | Boolean | 63 | 64 | ## Dependencies 65 | 66 | - MagicMirror 67 | - Fetch API support 68 | 69 | ## API 70 | 71 | This module uses the Nouncil API at `https://api.nouncil.wtf` to retrieve voting participation data. 72 | 73 | ## Contributing 74 | 75 | Contributions are welcome! Please submit pull requests or open issues on the GitHub repository. 76 | 77 | 78 | ## Credits 79 | 80 | Created by XppaiCyber 81 | -------------------------------------------------------------------------------- /MMM-NouncilVotes.css: -------------------------------------------------------------------------------- 1 | .mmm-nouncilvotes { 2 | width: 100%; 3 | max-width: 350px; 4 | margin: 0 auto; 5 | font-family: 'Roboto Condensed', 'Roboto', 'Helvetica Neue', Arial, sans-serif; 6 | color: #eee; 7 | background-color: rgba(0, 0, 0, 0.2); 8 | border-radius: 8px; 9 | padding: 10px; 10 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3); 11 | font-size: 0.9em; 12 | } 13 | 14 | /* Header styling */ 15 | .mmm-nouncilvotes .module-header { 16 | font-size: 1em; 17 | font-weight: bold; 18 | text-align: center; 19 | margin-bottom: 10px; 20 | text-transform: uppercase; 21 | letter-spacing: 1px; 22 | color: #fff; 23 | text-shadow: 0 0 8px rgba(255, 255, 255, 0.5); 24 | } 25 | 26 | /* Table container with scrolling ability if needed */ 27 | .mmm-nouncilvotes .table-container { 28 | width: 100%; 29 | overflow: hidden; 30 | } 31 | 32 | .mmm-nouncilvotes table { 33 | width: 100%; 34 | border-spacing: 0; 35 | border-collapse: separate; 36 | table-layout: fixed; 37 | border-radius: 8px; 38 | overflow: hidden; 39 | } 40 | 41 | /* Header cells */ 42 | .mmm-nouncilvotes th { 43 | text-align: left; 44 | padding: 8px 10px; 45 | background-color: rgba(30, 30, 30, 0.7); 46 | font-weight: bold; 47 | text-transform: uppercase; 48 | font-size: 0.7em; 49 | letter-spacing: 1px; 50 | color: #fff; 51 | border-bottom: 2px solid #444; 52 | } 53 | 54 | .mmm-nouncilvotes th:first-child { 55 | width: 50%; /* Username column takes half the space */ 56 | border-top-left-radius: 8px; 57 | } 58 | 59 | .mmm-nouncilvotes th:nth-child(2) { 60 | width: 20%; /* Votes column takes 20% space */ 61 | text-align: center; 62 | } 63 | 64 | .mmm-nouncilvotes th:last-child { 65 | width: 30%; /* Participation column takes 30% space */ 66 | border-top-right-radius: 8px; 67 | } 68 | 69 | /* Table cells */ 70 | .mmm-nouncilvotes td { 71 | padding: 6px 10px; 72 | vertical-align: middle; 73 | border-bottom: 1px solid rgba(255, 255, 255, 0.1); 74 | transition: background-color 0.3s ease; 75 | font-size: 0.75em; 76 | } 77 | 78 | .mmm-nouncilvotes .align-left { 79 | text-align: left; 80 | white-space: nowrap; 81 | overflow: hidden; 82 | text-overflow: ellipsis; 83 | } 84 | 85 | /* Row styling */ 86 | .mmm-nouncilvotes tr { 87 | transition: transform 0.2s ease, background-color 0.3s ease; 88 | } 89 | 90 | .mmm-nouncilvotes tbody tr:nth-child(even) { 91 | background-color: rgba(255, 255, 255, 0.05); 92 | } 93 | 94 | .mmm-nouncilvotes tbody tr:hover { 95 | background-color: rgba(255, 255, 255, 0.15); 96 | transform: translateX(5px); 97 | } 98 | 99 | /* Top voters highlight */ 100 | .mmm-nouncilvotes .top-voter { 101 | font-weight: bold; 102 | } 103 | 104 | .mmm-nouncilvotes .rank-1 { 105 | background-color: rgba(218, 165, 32, 0.2) !important; /* Gold */ 106 | } 107 | 108 | .mmm-nouncilvotes .rank-2 { 109 | background-color: rgba(192, 192, 192, 0.2) !important; /* Silver */ 110 | } 111 | 112 | .mmm-nouncilvotes .rank-3 { 113 | background-color: rgba(205, 127, 50, 0.2) !important; /* Bronze */ 114 | } 115 | 116 | /* Rank number styling */ 117 | .mmm-nouncilvotes .rank { 118 | font-weight: bold; 119 | margin-right: 5px; 120 | } 121 | 122 | /* Twitter handle styling */ 123 | .mmm-nouncilvotes .twitter { 124 | color: #1DA1F2; 125 | opacity: 0.8; 126 | font-size: 0.8em; 127 | } 128 | 129 | /* Progress bar styling */ 130 | .mmm-nouncilvotes .progress-bar { 131 | height: 4px; 132 | background-color: rgba(255, 255, 255, 0.1); 133 | border-radius: 3px; 134 | margin-top: 3px; 135 | overflow: hidden; 136 | } 137 | 138 | .mmm-nouncilvotes .progress-fill { 139 | height: 100%; 140 | border-radius: 3px; 141 | transition: width 1s ease-in-out; 142 | } 143 | 144 | .mmm-nouncilvotes .high-participation { 145 | background: linear-gradient(to right, #4CAF50, #8BC34A); 146 | } 147 | 148 | .mmm-nouncilvotes .medium-participation { 149 | background: linear-gradient(to right, #FFC107, #FFEB3B); 150 | } 151 | 152 | .mmm-nouncilvotes .low-participation { 153 | background: linear-gradient(to right, #FF5722, #F44336); 154 | } 155 | 156 | /* Pagination indicator */ 157 | .mmm-nouncilvotes .pagination { 158 | text-align: center; 159 | margin-top: 6px; 160 | font-size: 0.7em; 161 | color: rgba(255, 255, 255, 0.7); 162 | } 163 | 164 | /* Last updated timestamp */ 165 | .mmm-nouncilvotes .last-updated { 166 | text-align: right; 167 | margin-top: 6px; 168 | font-size: 0.7em; 169 | font-style: italic; 170 | color: rgba(255, 255, 255, 0.5); 171 | } 172 | 173 | /* Loading screen */ 174 | .mmm-nouncilvotes .loading-container { 175 | text-align: center; 176 | padding: 12px; 177 | color: rgba(255, 255, 255, 0.7); 178 | } 179 | 180 | .mmm-nouncilvotes .loading-spinner { 181 | display: inline-block; 182 | width: 20px; 183 | height: 20px; 184 | border: 2px solid rgba(255, 255, 255, 0.3); 185 | border-radius: 50%; 186 | border-top-color: #fff; 187 | animation: spin 1s ease-in-out infinite; 188 | margin-bottom: 6px; 189 | } 190 | 191 | .mmm-nouncilvotes .loading-text { 192 | font-style: italic; 193 | font-size: 0.8em; 194 | } 195 | 196 | @keyframes spin { 197 | to { transform: rotate(360deg); } 198 | } 199 | 200 | /* Animation for page transitions */ 201 | @keyframes fadeIn { 202 | from { opacity: 0; transform: translateY(10px); } 203 | to { opacity: 1; transform: translateY(0); } 204 | } 205 | 206 | .mmm-nouncilvotes tbody tr { 207 | animation: fadeIn 0.5s ease-out forwards; 208 | } 209 | 210 | /* Staggered animation for rows */ 211 | .mmm-nouncilvotes tbody tr:nth-child(1) { animation-delay: 0.05s; } 212 | .mmm-nouncilvotes tbody tr:nth-child(2) { animation-delay: 0.1s; } 213 | .mmm-nouncilvotes tbody tr:nth-child(3) { animation-delay: 0.15s; } 214 | .mmm-nouncilvotes tbody tr:nth-child(4) { animation-delay: 0.2s; } 215 | .mmm-nouncilvotes tbody tr:nth-child(5) { animation-delay: 0.25s; } 216 | .mmm-nouncilvotes tbody tr:nth-child(6) { animation-delay: 0.3s; } 217 | .mmm-nouncilvotes tbody tr:nth-child(7) { animation-delay: 0.35s; } 218 | .mmm-nouncilvotes tbody tr:nth-child(8) { animation-delay: 0.4s; } 219 | .mmm-nouncilvotes tbody tr:nth-child(9) { animation-delay: 0.45s; } 220 | .mmm-nouncilvotes tbody tr:nth-child(10) { animation-delay: 0.5s; } 221 | 222 | /* Smooth transition for page changes */ 223 | .mmm-nouncilvotes.mm-animate { 224 | transition: opacity 0.5s ease-in-out; 225 | } 226 | -------------------------------------------------------------------------------- /MMM-NouncilVotes.js: -------------------------------------------------------------------------------- 1 | Module.register("MMM-NouncilVotes", { 2 | defaults: { 3 | updateInterval: 5 * 60 * 1000, // Update every 5 minutes 4 | maxEntries: 10, // Number of entries visible at once 5 | cycleInterval: 15 * 1000, // Cycle through data every 10 seconds 6 | showTwitter: false, // Whether to show Twitter handles 7 | showWallet: false, // Whether to show wallet addresses 8 | minParticipationRate: 0, // Minimum participation rate to show (0-100) 9 | highlightTopVoters: true, // Whether to highlight top 3 voters 10 | animationSpeed: 2000, // Speed of transitions in milliseconds 11 | showLastUpdated: true // Whether to show when data was last updated 12 | }, 13 | 14 | start: function() { 15 | Log.info("Starting module: " + this.name); 16 | this.voters = []; 17 | this.loaded = false; 18 | this.currentPage = 0; 19 | this.lastUpdated = null; 20 | this.scheduleUpdate(); 21 | }, 22 | 23 | getStyles: function() { 24 | return ["MMM-NouncilVotes.css"]; 25 | }, 26 | 27 | getDom: function() { 28 | const wrapper = document.createElement("div"); 29 | wrapper.className = "mmm-nouncilvotes"; 30 | 31 | if (!this.loaded) { 32 | const loadingDiv = document.createElement("div"); 33 | loadingDiv.className = "loading-container"; 34 | loadingDiv.innerHTML = "
Loading Noun voting data...
"; 35 | wrapper.appendChild(loadingDiv); 36 | return wrapper; 37 | } 38 | 39 | // Header 40 | const header = document.createElement("div"); 41 | header.className = "module-header"; 42 | header.innerHTML = "⌐◨-◨ Nouncil Voting Participation"; 43 | wrapper.appendChild(header); 44 | 45 | // Container for the table 46 | const tableContainer = document.createElement("div"); 47 | tableContainer.className = "table-container"; 48 | 49 | const table = document.createElement("table"); 50 | table.className = "voter-table"; 51 | 52 | // Table header 53 | const thead = document.createElement("thead"); 54 | const headerRow = document.createElement("tr"); 55 | ["Username", "Votes", "Participation"].forEach(text => { 56 | const th = document.createElement("th"); 57 | th.innerHTML = text; 58 | headerRow.appendChild(th); 59 | }); 60 | thead.appendChild(headerRow); 61 | table.appendChild(thead); 62 | 63 | // Table body 64 | const tbody = document.createElement("tbody"); 65 | 66 | // Calculate page boundaries 67 | const totalVoters = this.voters.filter(voter => voter.participationRate >= this.config.minParticipationRate); 68 | const pageCount = Math.ceil(totalVoters.length / this.config.maxEntries); 69 | const startIndex = this.currentPage * this.config.maxEntries; 70 | const endIndex = Math.min(startIndex + this.config.maxEntries, totalVoters.length); 71 | 72 | // Display current page of voters 73 | totalVoters.slice(startIndex, endIndex).forEach((voter, index) => { 74 | const row = document.createElement("tr"); 75 | 76 | // Add class for top voters if highlighting is enabled 77 | if (this.config.highlightTopVoters && startIndex + index < 3) { 78 | row.classList.add("top-voter"); 79 | row.classList.add(`rank-${startIndex + index + 1}`); 80 | } 81 | 82 | // Username cell 83 | const nameCell = document.createElement("td"); 84 | nameCell.className = "align-left"; 85 | 86 | // Add rank number 87 | const rankSpan = document.createElement("span"); 88 | rankSpan.className = "rank"; 89 | rankSpan.innerHTML = (startIndex + index + 1) + ". "; 90 | nameCell.appendChild(rankSpan); 91 | 92 | // Add username 93 | const nameSpan = document.createElement("span"); 94 | nameSpan.className = "username"; 95 | nameSpan.innerHTML = voter.username; 96 | nameCell.appendChild(nameSpan); 97 | 98 | // Add Twitter handle if enabled 99 | if (this.config.showTwitter && voter.twitterAddress) { 100 | const twitterSpan = document.createElement("span"); 101 | twitterSpan.className = "twitter"; 102 | twitterSpan.innerHTML = ` @${voter.twitterAddress}`; 103 | nameCell.appendChild(twitterSpan); 104 | } 105 | 106 | row.appendChild(nameCell); 107 | 108 | // Votes cell 109 | const votesCell = document.createElement("td"); 110 | votesCell.innerHTML = `${voter.votesParticipated}/${voter.votesEligible}`; 111 | row.appendChild(votesCell); 112 | 113 | // Participation rate cell with visual indicator 114 | const rateCell = document.createElement("td"); 115 | 116 | // Add percentage text 117 | const percentText = document.createElement("span"); 118 | percentText.innerHTML = voter.participationRate.toFixed(1) + "%"; 119 | rateCell.appendChild(percentText); 120 | 121 | // Add visual bar 122 | const progressBar = document.createElement("div"); 123 | progressBar.className = "progress-bar"; 124 | 125 | const progressFill = document.createElement("div"); 126 | progressFill.className = "progress-fill"; 127 | progressFill.style.width = `${voter.participationRate}%`; 128 | 129 | // Color based on participation rate 130 | if (voter.participationRate >= 80) { 131 | progressFill.classList.add("high-participation"); 132 | } else if (voter.participationRate >= 50) { 133 | progressFill.classList.add("medium-participation"); 134 | } else { 135 | progressFill.classList.add("low-participation"); 136 | } 137 | 138 | progressBar.appendChild(progressFill); 139 | rateCell.appendChild(progressBar); 140 | 141 | row.appendChild(rateCell); 142 | 143 | tbody.appendChild(row); 144 | }); 145 | 146 | table.appendChild(tbody); 147 | tableContainer.appendChild(table); 148 | wrapper.appendChild(tableContainer); 149 | 150 | // Add pagination indicator if there are multiple pages 151 | if (pageCount > 1) { 152 | const paginationDiv = document.createElement("div"); 153 | paginationDiv.className = "pagination"; 154 | paginationDiv.innerHTML = `Page ${this.currentPage + 1}/${pageCount}`; 155 | wrapper.appendChild(paginationDiv); 156 | } 157 | 158 | // Add last updated timestamp if enabled 159 | if (this.config.showLastUpdated && this.lastUpdated) { 160 | const timestampDiv = document.createElement("div"); 161 | timestampDiv.className = "last-updated"; 162 | 163 | const timeAgo = this.getTimeAgo(this.lastUpdated); 164 | timestampDiv.innerHTML = `Last updated: ${timeAgo}`; 165 | wrapper.appendChild(timestampDiv); 166 | } 167 | 168 | return wrapper; 169 | }, 170 | 171 | getTimeAgo: function(timestamp) { 172 | const now = new Date(); 173 | const diff = Math.floor((now - timestamp) / 1000); 174 | 175 | if (diff < 60) return `${diff} seconds ago`; 176 | if (diff < 3600) return `${Math.floor(diff / 60)} minutes ago`; 177 | if (diff < 86400) return `${Math.floor(diff / 3600)} hours ago`; 178 | return `${Math.floor(diff / 86400)} days ago`; 179 | }, 180 | 181 | scheduleUpdate: function() { 182 | const self = this; 183 | // Schedule data updates 184 | setInterval(function() { 185 | self.updateVoters(); 186 | }, this.config.updateInterval); 187 | 188 | // Schedule UI cycling for pagination 189 | setInterval(function() { 190 | if (self.loaded) { 191 | const eligibleVoters = self.voters.filter(voter => 192 | voter.participationRate >= self.config.minParticipationRate 193 | ); 194 | const pageCount = Math.ceil(eligibleVoters.length / self.config.maxEntries); 195 | 196 | if (pageCount > 1) { 197 | self.currentPage = (self.currentPage + 1) % pageCount; 198 | self.updateDom(self.config.animationSpeed); 199 | } 200 | } 201 | }, this.config.cycleInterval); 202 | 203 | // Initial update 204 | this.updateVoters(); 205 | }, 206 | 207 | updateVoters: function() { 208 | const self = this; 209 | fetch("https://api.nouncil.wtf") 210 | .then(response => response.json()) 211 | .then(data => { 212 | self.voters = data.sort((a, b) => b.participationRate - a.participationRate); 213 | self.loaded = true; 214 | self.lastUpdated = new Date(); 215 | self.updateDom(self.config.animationSpeed); 216 | }) 217 | .catch(error => { 218 | Log.error("Error fetching Noun voting data: " + error); 219 | }); 220 | } 221 | }); 222 | --------------------------------------------------------------------------------