experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add wonderwall OAuth account manager feature

+788 -1
+1 -1
backend/electron/main.ts
··· 74 74 75 75 // Built-in extensions that load in consolidated mode (iframes) 76 76 // External extensions (including 'example') load in separate windows 77 - const CONSOLIDATED_EXTENSION_IDS = ['cmd', 'editor', 'groups', 'hud', 'lex', 'lists', 'peeks', 'search', 'slides', 'websearch', 'windows', 'page', 'files', 'pagestream', 'sheets', 'tags', 'feeds', 'helpdocs', 'entities', 'scripts', 'timers']; 77 + const CONSOLIDATED_EXTENSION_IDS = ['cmd', 'editor', 'groups', 'hud', 'lex', 'lists', 'peeks', 'search', 'slides', 'websearch', 'windows', 'page', 'files', 'pagestream', 'sheets', 'tags', 'feeds', 'helpdocs', 'entities', 'scripts', 'timers', 'wonderwall']; 78 78 79 79 // Extensions that must load eagerly (not lazy) — needed at startup 80 80 const EAGER_EXTENSION_IDS = new Set(['cmd', 'hud', 'entities']);
+60
features/wonderwall/background.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline'; connect-src https: http:;"> 6 + <title>Wonderwall Extension</title> 7 + </head> 8 + <body> 9 + <script type="module"> 10 + import extension from './background.js'; 11 + 12 + // Feature detection - check if Peek API is available 13 + const hasPeekAPI = typeof window.app !== 'undefined'; 14 + const api = hasPeekAPI ? window.app : null; 15 + const extId = extension.id; 16 + 17 + console.log(`[ext:${extId}] background.html loaded`); 18 + console.log(`[ext:${extId}] Peek API available:`, hasPeekAPI); 19 + 20 + if (hasPeekAPI) { 21 + // Initialize extension 22 + if (extension.init) { 23 + console.log(`[ext:${extId}] calling init()`); 24 + await extension.init(); 25 + } 26 + 27 + // Collect registered command topics for assertion verification 28 + const registeredTopics = Object.keys(window._cmdHandlers || {}) 29 + .map(name => `cmd:execute:${name}`); 30 + 31 + // Signal ready to main process (after init so handlers are registered) 32 + api.publish('ext:ready', { 33 + id: extId, 34 + registeredTopics, 35 + manifest: { 36 + id: extension.id, 37 + labels: extension.labels, 38 + version: '1.0.0' 39 + } 40 + }, api.scopes.SYSTEM); 41 + 42 + // Handle shutdown request from main process 43 + api.subscribe('app:shutdown', () => { 44 + console.log(`[ext:${extId}] received shutdown`); 45 + if (extension.uninit) { 46 + extension.uninit(); 47 + } 48 + }, api.scopes.SYSTEM); 49 + 50 + // Handle extension-specific shutdown 51 + api.subscribe(`ext:${extId}:shutdown`, () => { 52 + console.log(`[ext:${extId}] received extension shutdown`); 53 + if (extension.uninit) { 54 + extension.uninit(); 55 + } 56 + }, api.scopes.SYSTEM); 57 + } 58 + </script> 59 + </body> 60 + </html>
+49
features/wonderwall/background.js
··· 1 + /** 2 + * Wonderwall Background Script 3 + * 4 + * OAuth account manager for connected services. 5 + * Registers the "accounts" command to open the management UI. 6 + */ 7 + 8 + const hasPeekAPI = typeof window.app !== 'undefined'; 9 + const api = hasPeekAPI ? window.app : null; 10 + 11 + function openWonderwall() { 12 + if (hasPeekAPI) { 13 + api.window.open('peek://ext/wonderwall/home.html', { 14 + role: 'workspace', 15 + key: 'wonderwall-home', 16 + width: 800, 17 + height: 600, 18 + title: 'Wonderwall' 19 + }); 20 + } 21 + } 22 + 23 + const extension = { 24 + id: 'wonderwall', 25 + labels: { 26 + name: 'Wonderwall' 27 + }, 28 + 29 + init() { 30 + if (!hasPeekAPI) return; 31 + 32 + api.commands.register({ 33 + name: 'accounts', 34 + description: 'Open the connected accounts manager', 35 + execute: async () => { 36 + openWonderwall(); 37 + return { success: true, message: 'Opening Wonderwall' }; 38 + } 39 + }); 40 + }, 41 + 42 + uninit() { 43 + if (hasPeekAPI) { 44 + api.commands.unregister('accounts'); 45 + } 46 + } 47 + }; 48 + 49 + export default extension;
+197
features/wonderwall/home.css
··· 1 + /* Import theme variables */ 2 + @import url('peek://theme/variables.css'); 3 + 4 + * { 5 + box-sizing: border-box; 6 + margin: 0; 7 + padding: 0; 8 + } 9 + 10 + html { 11 + font-family: var(--theme-font-sans); 12 + -webkit-font-smoothing: antialiased; 13 + font-size: 14px; 14 + line-height: 1.5; 15 + } 16 + 17 + body { 18 + background: var(--base00); 19 + color: var(--base05); 20 + min-height: 100vh; 21 + } 22 + 23 + #app { 24 + max-width: 720px; 25 + margin: 0 auto; 26 + padding: 32px 24px; 27 + } 28 + 29 + /* ==================== Header ==================== */ 30 + 31 + .header { 32 + margin-bottom: 28px; 33 + } 34 + 35 + .title { 36 + font-size: 22px; 37 + font-weight: 600; 38 + color: var(--base05); 39 + margin-bottom: 4px; 40 + } 41 + 42 + .subtitle { 43 + font-size: 13px; 44 + color: var(--base04); 45 + } 46 + 47 + /* ==================== Services Grid ==================== */ 48 + 49 + .services-grid { 50 + display: grid; 51 + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); 52 + gap: 16px; 53 + } 54 + 55 + /* ==================== Service Card ==================== */ 56 + 57 + .service-card { 58 + background: var(--base01); 59 + border: 1px solid var(--base02); 60 + border-radius: 10px; 61 + padding: 20px; 62 + display: flex; 63 + flex-direction: column; 64 + gap: 16px; 65 + transition: border-color 0.15s; 66 + } 67 + 68 + .service-card:hover { 69 + border-color: var(--base03); 70 + } 71 + 72 + .service-card-header { 73 + display: flex; 74 + align-items: center; 75 + gap: 12px; 76 + } 77 + 78 + .service-icon { 79 + width: 40px; 80 + height: 40px; 81 + border-radius: 8px; 82 + display: flex; 83 + align-items: center; 84 + justify-content: center; 85 + font-size: 18px; 86 + color: #fff; 87 + flex-shrink: 0; 88 + } 89 + 90 + .service-name { 91 + font-size: 15px; 92 + font-weight: 600; 93 + color: var(--base05); 94 + } 95 + 96 + .service-status { 97 + display: flex; 98 + align-items: center; 99 + gap: 6px; 100 + font-size: 12px; 101 + color: var(--base04); 102 + } 103 + 104 + .status-dot { 105 + width: 8px; 106 + height: 8px; 107 + border-radius: 50%; 108 + flex-shrink: 0; 109 + } 110 + 111 + .status-dot.connected { 112 + background: var(--base0B); 113 + } 114 + 115 + .status-dot.disconnected { 116 + background: var(--base03); 117 + } 118 + 119 + .service-info { 120 + font-size: 12px; 121 + color: var(--base04); 122 + min-height: 18px; 123 + } 124 + 125 + /* ==================== Buttons ==================== */ 126 + 127 + .btn { 128 + padding: 8px 16px; 129 + font-size: 13px; 130 + font-weight: 500; 131 + font-family: var(--theme-font-sans); 132 + border: none; 133 + border-radius: 6px; 134 + cursor: pointer; 135 + transition: all 0.15s; 136 + width: 100%; 137 + text-align: center; 138 + } 139 + 140 + .btn:disabled { 141 + opacity: 0.5; 142 + cursor: not-allowed; 143 + } 144 + 145 + .btn-connect { 146 + background: var(--base0D); 147 + color: var(--base00); 148 + } 149 + 150 + .btn-connect:hover:not(:disabled) { 151 + filter: brightness(1.1); 152 + } 153 + 154 + .btn-disconnect { 155 + background: var(--base02); 156 + color: var(--base05); 157 + } 158 + 159 + .btn-disconnect:hover:not(:disabled) { 160 + background: var(--base08); 161 + color: var(--base00); 162 + } 163 + 164 + /* ==================== Status Bar ==================== */ 165 + 166 + .status-bar { 167 + margin-top: 24px; 168 + padding: 10px 14px; 169 + border-radius: 8px; 170 + font-size: 13px; 171 + text-align: center; 172 + } 173 + 174 + .status-bar.success { 175 + background: color-mix(in srgb, var(--base0B) 15%, transparent); 176 + border: 1px solid var(--base0B); 177 + color: var(--base0B); 178 + } 179 + 180 + .status-bar.error { 181 + background: color-mix(in srgb, var(--base08) 15%, transparent); 182 + border: 1px solid var(--base08); 183 + color: var(--base08); 184 + } 185 + 186 + .status-bar.info { 187 + background: color-mix(in srgb, var(--base0D) 15%, transparent); 188 + border: 1px solid var(--base0D); 189 + color: var(--base0D); 190 + } 191 + 192 + /* ==================== Loading ==================== */ 193 + 194 + .connecting { 195 + opacity: 0.6; 196 + pointer-events: none; 197 + }
+31
features/wonderwall/home.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline'; connect-src https: http:; img-src https: data:;"> 6 + <meta name="viewport" content="width=device-width,user-scalable=no,initial-scale=1"> 7 + <title>Wonderwall</title> 8 + <link rel="stylesheet" type="text/css" href="home.css"> 9 + <script type="module"> 10 + import 'peek://app/components/peek-card.js'; 11 + import 'peek://app/components/peek-grid.js'; 12 + import 'peek://app/components/peek-button.js'; 13 + </script> 14 + </head> 15 + <body> 16 + <div id="app"> 17 + <div class="header"> 18 + <h1 class="title">Wonderwall</h1> 19 + <p class="subtitle">Manage your connected accounts</p> 20 + </div> 21 + 22 + <div id="services-grid" class="services-grid"></div> 23 + 24 + <div id="status-bar" class="status-bar" style="display:none"> 25 + <span id="status-message"></span> 26 + </div> 27 + </div> 28 + 29 + <script type="module" src="home.js"></script> 30 + </body> 31 + </html>
+153
features/wonderwall/home.js
··· 1 + /** 2 + * Wonderwall Home UI 3 + * 4 + * Renders a grid of service cards showing connection status. 5 + * Each card has Connect / Disconnect actions. 6 + */ 7 + 8 + import services from './services/index.js'; 9 + 10 + const api = typeof window !== 'undefined' && window.app ? window.app : null; 11 + 12 + // ============================================================================ 13 + // DOM references 14 + // ============================================================================ 15 + 16 + const servicesGrid = document.getElementById('services-grid'); 17 + const statusBar = document.getElementById('status-bar'); 18 + const statusMessage = document.getElementById('status-message'); 19 + 20 + // ============================================================================ 21 + // Status bar helpers 22 + // ============================================================================ 23 + 24 + function showStatus(message, type = 'info') { 25 + statusBar.className = `status-bar ${type}`; 26 + statusMessage.textContent = message; 27 + statusBar.style.display = 'block'; 28 + 29 + if (type === 'success') { 30 + setTimeout(() => { statusBar.style.display = 'none'; }, 3000); 31 + } 32 + } 33 + 34 + function hideStatus() { 35 + statusBar.style.display = 'none'; 36 + } 37 + 38 + // ============================================================================ 39 + // Render a single service card 40 + // ============================================================================ 41 + 42 + function createServiceCard(service, tokenData) { 43 + const connected = tokenData !== null; 44 + 45 + const card = document.createElement('div'); 46 + card.className = 'service-card'; 47 + card.dataset.serviceId = service.id; 48 + 49 + // Determine status info text 50 + let infoText = ''; 51 + if (connected && tokenData.connectedAt) { 52 + const date = new Date(tokenData.connectedAt); 53 + infoText = `Connected ${date.toLocaleDateString()}`; 54 + } 55 + 56 + // Check if token is expired 57 + let expired = false; 58 + if (connected && tokenData.expiresAt && tokenData.expiresAt < Date.now()) { 59 + expired = true; 60 + infoText = 'Token expired — reconnect'; 61 + } 62 + 63 + card.innerHTML = ` 64 + <div class="service-card-header"> 65 + <div class="service-icon" style="background: ${service.color}"> 66 + ${service.icon} 67 + </div> 68 + <div class="service-name">${service.name}</div> 69 + </div> 70 + <div class="service-status"> 71 + <span class="status-dot ${connected && !expired ? 'connected' : 'disconnected'}"></span> 72 + <span>${connected && !expired ? 'Connected' : expired ? 'Expired' : 'Not connected'}</span> 73 + </div> 74 + <div class="service-info">${infoText}</div> 75 + <button class="btn ${connected ? 'btn-disconnect' : 'btn-connect'}" data-action="${connected ? 'disconnect' : 'connect'}"> 76 + ${connected ? 'Disconnect' : 'Connect'} 77 + </button> 78 + `; 79 + 80 + // Wire up button 81 + const btn = card.querySelector('.btn'); 82 + btn.addEventListener('click', async () => { 83 + if (btn.dataset.action === 'connect') { 84 + await handleConnect(service, card); 85 + } else { 86 + await handleDisconnect(service, card); 87 + } 88 + }); 89 + 90 + return card; 91 + } 92 + 93 + // ============================================================================ 94 + // Connect / Disconnect handlers 95 + // ============================================================================ 96 + 97 + async function handleConnect(service, card) { 98 + const btn = card.querySelector('.btn'); 99 + btn.disabled = true; 100 + btn.textContent = 'Connecting...'; 101 + card.classList.add('connecting'); 102 + hideStatus(); 103 + 104 + try { 105 + await service.connect(); 106 + showStatus(`Connected to ${service.name}`, 'success'); 107 + await renderServices(); // re-render all cards to update status 108 + } catch (err) { 109 + console.error(`[wonderwall] Connect error for ${service.id}:`, err); 110 + showStatus(`Failed to connect ${service.name}: ${err.message}`, 'error'); 111 + btn.disabled = false; 112 + btn.textContent = 'Connect'; 113 + card.classList.remove('connecting'); 114 + } 115 + } 116 + 117 + async function handleDisconnect(service, card) { 118 + const btn = card.querySelector('.btn'); 119 + btn.disabled = true; 120 + btn.textContent = 'Disconnecting...'; 121 + hideStatus(); 122 + 123 + try { 124 + await service.disconnect(); 125 + showStatus(`Disconnected from ${service.name}`, 'success'); 126 + await renderServices(); // re-render all cards 127 + } catch (err) { 128 + console.error(`[wonderwall] Disconnect error for ${service.id}:`, err); 129 + showStatus(`Failed to disconnect ${service.name}: ${err.message}`, 'error'); 130 + btn.disabled = false; 131 + btn.textContent = 'Disconnect'; 132 + } 133 + } 134 + 135 + // ============================================================================ 136 + // Render all service cards 137 + // ============================================================================ 138 + 139 + async function renderServices() { 140 + servicesGrid.innerHTML = ''; 141 + 142 + for (const service of services) { 143 + const tokenData = await service.getToken(); 144 + const card = createServiceCard(service, tokenData); 145 + servicesGrid.appendChild(card); 146 + } 147 + } 148 + 149 + // ============================================================================ 150 + // Init 151 + // ============================================================================ 152 + 153 + renderServices();
+26
features/wonderwall/manifest.json
··· 1 + { 2 + "id": "wonderwall", 3 + "shortname": "wonderwall", 4 + "name": "Wonderwall", 5 + "description": "OAuth account manager for connected services", 6 + "version": "1.0.0", 7 + "background": "background.html", 8 + "builtin": true, 9 + "commands": [ 10 + { 11 + "name": "accounts", 12 + "description": "Open the connected accounts manager", 13 + "action": { 14 + "type": "window", 15 + "url": "peek://ext/wonderwall/home.html", 16 + "options": { 17 + "role": "workspace", 18 + "key": "wonderwall-home", 19 + "width": 800, 20 + "height": 600, 21 + "title": "Wonderwall" 22 + } 23 + } 24 + } 25 + ] 26 + }
+174
features/wonderwall/services/base.js
··· 1 + /** 2 + * Base service interface for OAuth integrations. 3 + * 4 + * All service modules follow this pattern. To add a new service: 5 + * 1. Create a new file in services/ that exports a class extending this pattern 6 + * 2. Define the OAuth config (authUrl, tokenUrl, scopes, clientId) 7 + * 3. Import and register in services/index.js 8 + * 9 + * Token storage uses api.settings.setKey() / getKey() with a per-service key. 10 + */ 11 + 12 + const api = typeof window !== 'undefined' && window.app ? window.app : null; 13 + 14 + /** 15 + * Create a service definition object. 16 + * Each service must provide: 17 + * id - unique identifier (e.g., 'youtube') 18 + * name - display name 19 + * icon - emoji or short label for the card 20 + * color - hex color for the card accent 21 + * oauth - { authUrl, tokenUrl, clientId, scopes, extraParams? } 22 + */ 23 + export function createService({ id, name, icon, color, oauth }) { 24 + const STORAGE_KEY = `wonderwall:${id}`; 25 + 26 + return { 27 + id, 28 + name, 29 + icon, 30 + color, 31 + oauth, 32 + 33 + /** Retrieve stored token data (or null). */ 34 + async getToken() { 35 + if (!api) return null; 36 + try { 37 + const result = await api.settings.getKey(STORAGE_KEY); 38 + if (result.success && result.data) return result.data; 39 + } catch (err) { 40 + console.warn(`[wonderwall:${id}] Failed to load token:`, err); 41 + } 42 + return null; 43 + }, 44 + 45 + /** Persist token data. */ 46 + async setToken(tokenData) { 47 + if (!api) return; 48 + await api.settings.setKey(STORAGE_KEY, tokenData); 49 + }, 50 + 51 + /** Remove stored token data (disconnect). */ 52 + async clearToken() { 53 + if (!api) return; 54 + await api.settings.setKey(STORAGE_KEY, null); 55 + }, 56 + 57 + /** Check whether the service has a stored token. */ 58 + async isConnected() { 59 + const token = await this.getToken(); 60 + return token !== null; 61 + }, 62 + 63 + /** 64 + * Start the OAuth authorization flow. 65 + * Uses the generic loopback server from the Electron backend. 66 + * 67 + * @returns {Promise<Object>} Token response data from the provider. 68 + */ 69 + async connect() { 70 + if (!api) throw new Error('Peek API not available'); 71 + 72 + // 1. Start loopback server 73 + const loopback = await api.oauth.startLoopback(); 74 + if (!loopback.success) { 75 + throw new Error(loopback.error || 'Failed to start loopback server'); 76 + } 77 + const port = loopback.port; 78 + const redirectUri = `http://127.0.0.1:${port}/callback`; 79 + 80 + try { 81 + // 2. Generate state for CSRF protection 82 + const stateArray = new Uint8Array(16); 83 + crypto.getRandomValues(stateArray); 84 + const state = Array.from(stateArray, b => b.toString(16).padStart(2, '0')).join(''); 85 + 86 + // 3. Build authorization URL 87 + const params = new URLSearchParams({ 88 + client_id: oauth.clientId, 89 + redirect_uri: redirectUri, 90 + response_type: 'code', 91 + scope: oauth.scopes, 92 + state, 93 + ...(oauth.extraParams || {}) 94 + }); 95 + const authUrl = `${oauth.authUrl}?${params.toString()}`; 96 + 97 + // 4. Open auth window 98 + api.window.open(authUrl, { 99 + width: 600, 100 + height: 700, 101 + role: 'modal', 102 + title: `Connect ${name}`, 103 + }); 104 + 105 + // 5. Wait for callback 106 + const callbackResult = await api.oauth.awaitCallback(port); 107 + if (!callbackResult.success) { 108 + throw new Error(callbackResult.error || 'OAuth callback failed'); 109 + } 110 + const callbackParams = callbackResult.params; 111 + 112 + // 6. Verify state 113 + if (callbackParams.state !== state) { 114 + throw new Error('OAuth state mismatch'); 115 + } 116 + if (callbackParams.error) { 117 + throw new Error(callbackParams.error_description || callbackParams.error); 118 + } 119 + const code = callbackParams.code; 120 + if (!code) throw new Error('No authorization code received'); 121 + 122 + // 7. Exchange code for tokens 123 + const tokenBody = new URLSearchParams({ 124 + grant_type: 'authorization_code', 125 + code, 126 + redirect_uri: redirectUri, 127 + client_id: oauth.clientId, 128 + }); 129 + 130 + // Include client_secret if provided (some services require it) 131 + if (oauth.clientSecret) { 132 + tokenBody.set('client_secret', oauth.clientSecret); 133 + } 134 + 135 + const tokenRes = await fetch(oauth.tokenUrl, { 136 + method: 'POST', 137 + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 138 + body: tokenBody.toString(), 139 + }); 140 + 141 + if (!tokenRes.ok) { 142 + const errBody = await tokenRes.json().catch(() => ({})); 143 + throw new Error(errBody.error_description || errBody.error || `Token exchange failed (${tokenRes.status})`); 144 + } 145 + 146 + const tokenData = await tokenRes.json(); 147 + 148 + // 8. Store the token 149 + const stored = { 150 + accessToken: tokenData.access_token, 151 + refreshToken: tokenData.refresh_token || null, 152 + expiresAt: tokenData.expires_in 153 + ? Date.now() + tokenData.expires_in * 1000 154 + : null, 155 + scope: tokenData.scope || oauth.scopes, 156 + connectedAt: Date.now(), 157 + }; 158 + 159 + await this.setToken(stored); 160 + return stored; 161 + 162 + } catch (err) { 163 + // Cancel the loopback server if still pending 164 + try { await api.oauth.awaitCallback(port); } catch {} 165 + throw err; 166 + } 167 + }, 168 + 169 + /** Disconnect: remove stored tokens. */ 170 + async disconnect() { 171 + await this.clearToken(); 172 + }, 173 + }; 174 + }
+18
features/wonderwall/services/index.js
··· 1 + /** 2 + * Service registry for Wonderwall. 3 + * 4 + * Import all service modules here. The home UI iterates this array 5 + * to render service cards and drive connect/disconnect flows. 6 + * 7 + * To add a new service: 8 + * 1. Create services/myservice.js using createService() from base.js 9 + * 2. Import it here and add to the array 10 + */ 11 + 12 + import youtube from './youtube.js'; 13 + import soundcloud from './soundcloud.js'; 14 + import reddit from './reddit.js'; 15 + 16 + const services = [youtube, soundcloud, reddit]; 17 + 18 + export default services;
+29
features/wonderwall/services/reddit.js
··· 1 + /** 2 + * Reddit OAuth service integration. 3 + * 4 + * To use: create an app at https://www.reddit.com/prefs/apps 5 + * (type: "installed app"), then replace the placeholder CLIENT_ID. 6 + * 7 + * Reddit uses a slightly different OAuth flow: duration=permanent for 8 + * refresh tokens, and the token endpoint requires Basic auth header 9 + * instead of client_secret in the body. The base service handles the 10 + * code exchange, and the extraParams here configure the auth request. 11 + */ 12 + 13 + import { createService } from './base.js'; 14 + 15 + export default createService({ 16 + id: 'reddit', 17 + name: 'Reddit', 18 + icon: '\u2B24', // filled circle 19 + color: '#FF4500', 20 + oauth: { 21 + authUrl: 'https://www.reddit.com/api/v1/authorize', 22 + tokenUrl: 'https://www.reddit.com/api/v1/access_token', 23 + clientId: 'YOUR_REDDIT_CLIENT_ID', 24 + scopes: 'identity read', 25 + extraParams: { 26 + duration: 'permanent', 27 + }, 28 + }, 29 + });
+24
features/wonderwall/services/soundcloud.js
··· 1 + /** 2 + * SoundCloud OAuth service integration. 3 + * 4 + * To use: register an app at https://soundcloud.com/you/apps 5 + * and replace the placeholder CLIENT_ID below. 6 + */ 7 + 8 + import { createService } from './base.js'; 9 + 10 + export default createService({ 11 + id: 'soundcloud', 12 + name: 'SoundCloud', 13 + icon: '\u266B', // beamed eighth notes 14 + color: '#FF5500', 15 + oauth: { 16 + authUrl: 'https://soundcloud.com/connect', 17 + tokenUrl: 'https://api.soundcloud.com/oauth2/token', 18 + clientId: 'YOUR_SOUNDCLOUD_CLIENT_ID', 19 + scopes: '', 20 + extraParams: { 21 + display: 'popup', 22 + }, 23 + }, 24 + });
+26
features/wonderwall/services/youtube.js
··· 1 + /** 2 + * YouTube (Google OAuth) service integration. 3 + * 4 + * To use: register a Google Cloud project, enable the YouTube Data API v3, 5 + * and create an OAuth 2.0 Client ID (Desktop app type). Replace the 6 + * placeholder CLIENT_ID below with your actual client ID. 7 + */ 8 + 9 + import { createService } from './base.js'; 10 + 11 + export default createService({ 12 + id: 'youtube', 13 + name: 'YouTube', 14 + icon: '\u25B6', // play button triangle 15 + color: '#FF0000', 16 + oauth: { 17 + authUrl: 'https://accounts.google.com/o/oauth2/v2/auth', 18 + tokenUrl: 'https://oauth2.googleapis.com/token', 19 + clientId: 'YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com', 20 + scopes: 'https://www.googleapis.com/auth/youtube.readonly', 21 + extraParams: { 22 + access_type: 'offline', 23 + prompt: 'consent', 24 + }, 25 + }, 26 + });