this repo has no description

mvp chrome extension

+47
tasty_chrome/README.md
··· 1 + # Tasty Bookmarks Chrome Extension 2 + 3 + Chrome extension for the Tasty Bookmarks application. 4 + 5 + ## Features 6 + 7 + - Save the current page as a bookmark with one click 8 + - Add a description and tags to your bookmarks 9 + - Quickly access your bookmarks from any device 10 + 11 + ## Development Setup 12 + 13 + 1. Clone this repository 14 + 2. Navigate to `chrome://extensions/` in Chrome 15 + 3. Enable "Developer mode" in the top right 16 + 4. Click "Load unpacked" and select the `tasty_chrome` folder 17 + 5. The extension is now installed 18 + 19 + ## Building for Production 20 + 21 + When ready for production: 22 + 23 + 1. Generate icons (if using the provided SVG): 24 + ``` 25 + python generate_icons.py 26 + ``` 27 + 28 + 2. Package the extension: 29 + - Zip all files excluding `generate_icons.py` and `README.md` 30 + - Upload to the Chrome Web Store 31 + 32 + ## Authentication 33 + 34 + This extension requires an authentication token from the Tasty Bookmarks application: 35 + 36 + 1. Log into your Tasty Bookmarks account at `http://localhost:4000` 37 + 2. Go to your profile page 38 + 3. Click "Generate WebSocket Token" 39 + 4. Copy the token and paste it into the extension 40 + 41 + ## Contributing 42 + 43 + Contributions are welcome! Please feel free to submit a Pull Request. 44 + 45 + ## License 46 + 47 + MIT
+35
tasty_chrome/background.js
··· 1 + // Background service worker for Tasty Bookmarks extension 2 + 3 + // Listen for installation event 4 + chrome.runtime.onInstalled.addListener(() => { 5 + console.log('Tasty Bookmarks extension installed'); 6 + }); 7 + 8 + // Context menu for quick bookmarking 9 + chrome.runtime.onInstalled.addListener(() => { 10 + chrome.contextMenus.create({ 11 + id: 'tastyBookmark', 12 + title: 'Save to Tasty Bookmarks', 13 + contexts: ['page', 'link'] 14 + }); 15 + }); 16 + 17 + chrome.contextMenus.onClicked.addListener((info, tab) => { 18 + if (info.menuItemId === 'tastyBookmark') { 19 + // Get the URL from the context 20 + const url = info.linkUrl || tab.url; 21 + const title = tab.title || url; 22 + 23 + // Open the popup with pre-filled information 24 + chrome.storage.local.set({ 25 + quickBookmark: { 26 + url, 27 + title 28 + } 29 + }, () => { 30 + chrome.action.openPopup(); 31 + }); 32 + } 33 + }); 34 + 35 + // Handle any icon badge updates or notifications here
+70
tasty_chrome/generate_icons.py
··· 1 + #!/usr/bin/env python3 2 + """ 3 + Generate PNG icons from SVG for Chrome extension 4 + """ 5 + 6 + import os 7 + import subprocess 8 + from pathlib import Path 9 + 10 + # Sizes needed for Chrome extension 11 + ICON_SIZES = [16, 32, 48, 128] 12 + 13 + def main(): 14 + """Generate icons in different sizes from SVG""" 15 + script_dir = Path(__file__).parent 16 + images_dir = script_dir / "images" 17 + svg_path = images_dir / "icon.svg" 18 + 19 + if not svg_path.exists(): 20 + print(f"Error: SVG file not found at {svg_path}") 21 + return 22 + 23 + for size in ICON_SIZES: 24 + output_path = images_dir / f"icon{size}.png" 25 + print(f"Generating {output_path}...") 26 + 27 + try: 28 + if os.name == 'nt': # Windows 29 + # For Windows, you might need ImageMagick installed 30 + subprocess.run([ 31 + "magick", 32 + "convert", 33 + "-background", "none", 34 + "-size", f"{size}x{size}", 35 + str(svg_path), 36 + str(output_path) 37 + ], check=True) 38 + else: # macOS/Linux 39 + # Try using native macOS tools first 40 + try: 41 + subprocess.run([ 42 + "sips", 43 + "-z", str(size), str(size), 44 + "-s", "format", "png", 45 + str(svg_path), 46 + "--out", str(output_path) 47 + ], check=True) 48 + except (subprocess.SubprocessError, FileNotFoundError): 49 + # Fallback to convert if available (requires ImageMagick) 50 + subprocess.run([ 51 + "convert", 52 + "-background", "none", 53 + "-size", f"{size}x{size}", 54 + str(svg_path), 55 + str(output_path) 56 + ], check=True) 57 + 58 + print(f"✓ Generated {output_path}") 59 + except (subprocess.SubprocessError, FileNotFoundError) as e: 60 + print(f"Error generating {output_path}: {e}") 61 + print("Make sure you have ImageMagick installed for image conversion.") 62 + 63 + print("\nIcon generation complete!") 64 + print("You can install ImageMagick with:") 65 + print("- macOS: brew install imagemagick") 66 + print("- Linux: sudo apt install imagemagick") 67 + print("- Windows: Download from https://imagemagick.org/script/download.php") 68 + 69 + if __name__ == "__main__": 70 + main()
+3
tasty_chrome/images/bookmark.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon"> 2 + <path stroke-linecap="round" stroke-linejoin="round" d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0 1 11.186 0Z"/> 3 + </svg>
+3
tasty_chrome/images/icon.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#6366f1" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> 2 + <path d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0 1 11.186 0Z" /> 3 + </svg>
+30
tasty_chrome/manifest.json
··· 1 + { 2 + "name": "Tasty Bookmarks", 3 + "version": "1.0.0", 4 + "description": "Save bookmarks to your Tasty application", 5 + "manifest_version": 3, 6 + "permissions": [ 7 + "activeTab", 8 + "storage", 9 + "tabs" 10 + ], 11 + "host_permissions": [ 12 + "http://localhost:4000/*" 13 + ], 14 + "background": { 15 + "service_worker": "background.js" 16 + }, 17 + "action": { 18 + "default_popup": "popup.html", 19 + "default_icon": { 20 + "16": "images/icon16.png", 21 + "48": "images/icon48.png", 22 + "128": "images/icon128.png" 23 + } 24 + }, 25 + "icons": { 26 + "16": "images/icon16.png", 27 + "48": "images/icon48.png", 28 + "128": "images/icon128.png" 29 + } 30 + }
+601
tasty_chrome/phoenix.js
··· 1 + /** 2 + * Phoenix WebSocket Client 3 + * 4 + * This is a minimal version of the Phoenix WebSocket client 5 + * Adapted from https://github.com/phoenixframework/phoenix/blob/master/assets/js/phoenix.js 6 + */ 7 + 8 + (function(global, factory) { 9 + typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : 10 + typeof define === 'function' && define.amd ? define(factory) : 11 + (global = global || self, global.Phoenix = factory()); 12 + }(this, function() { 'use strict'; 13 + 14 + // Phoenix WebSocket client library 15 + const VSN = "2.0.0"; 16 + const SOCKET_STATES = {connecting: 0, open: 1, closing: 2, closed: 3}; 17 + const DEFAULT_TIMEOUT = 10000; 18 + const WS_CLOSE_NORMAL = 1000; 19 + 20 + // Utility functions 21 + const closure = (value) => { 22 + if(typeof value === "function"){ 23 + return value 24 + } else { 25 + let closure = function(){ return value } 26 + return closure 27 + } 28 + } 29 + 30 + // Channel constructor 31 + class Channel { 32 + constructor(topic, params, socket) { 33 + this.topic = topic 34 + this.params = params || {} 35 + this.socket = socket 36 + this.bindings = [] 37 + this.bindingRef = 0 38 + this.timeout = this.socket.timeout 39 + this.joinedOnce = false 40 + this.joinPush = new Push(this, CHANNEL_EVENTS.join, this.params, this.timeout) 41 + this.pushBuffer = [] 42 + this.rejoinTimer = new Timer( 43 + () => this.rejoinUntilConnected(), 44 + this.socket.reconnectAfterMs 45 + ) 46 + this.stateChangeRefs = [] 47 + 48 + this.joinPush.receive("ok", () => { 49 + this.state = CHANNEL_STATES.joined 50 + this.rejoinTimer.reset() 51 + this.pushBuffer.forEach(pushEvent => pushEvent.send()) 52 + this.pushBuffer = [] 53 + }) 54 + this.joinPush.receive("error", () => { 55 + this.state = CHANNEL_STATES.errored 56 + if(this.socket.isConnected()){ this.rejoinTimer.scheduleTimeout() } 57 + }) 58 + this.onClose(() => { 59 + this.rejoinTimer.reset() 60 + if(this.socket.hasLogger()) this.socket.log("channel", `close ${this.topic} ${this.joinRef()}`) 61 + this.state = CHANNEL_STATES.closed 62 + this.socket.remove(this) 63 + }) 64 + this.onError(reason => { 65 + if(this.socket.hasLogger()) this.socket.log("channel", `error ${this.topic}`, reason) 66 + if(this.isJoining()){ this.joinPush.reset() } 67 + this.state = CHANNEL_STATES.errored 68 + if(this.socket.isConnected()){ this.rejoinTimer.scheduleTimeout() } 69 + }) 70 + this.joinPush.receive("timeout", () => { 71 + if(this.socket.hasLogger()) this.socket.log("channel", `timeout ${this.topic} (${this.joinRef()})`, this.joinPush.timeout) 72 + let leavePush = new Push(this, CHANNEL_EVENTS.leave, closure({}), this.timeout) 73 + leavePush.send() 74 + this.state = CHANNEL_STATES.errored 75 + this.joinPush.reset() 76 + if(this.socket.isConnected()){ this.rejoinTimer.scheduleTimeout() } 77 + }) 78 + this.on(CHANNEL_EVENTS.reply, (payload, ref) => { 79 + this.trigger(this.replyEventName(ref), payload) 80 + }) 81 + } 82 + 83 + join(timeout = this.timeout){ 84 + if(this.joinedOnce){ 85 + throw new Error(`tried to join multiple times. 'join' can only be called a single time per channel instance`) 86 + } else { 87 + this.timeout = timeout 88 + this.joinedOnce = true 89 + this.rejoin() 90 + return this.joinPush 91 + } 92 + } 93 + 94 + push(event, payload, timeout = this.timeout){ 95 + payload = payload || {} 96 + if(!this.joinedOnce){ 97 + throw new Error(`tried to push '${event}' to '${this.topic}' before joining. Use channel.join() before pushing events`) 98 + } 99 + let pushEvent = new Push(this, event, payload, timeout) 100 + if(this.canPush()){ 101 + pushEvent.send() 102 + } else { 103 + pushEvent.startTimeout() 104 + this.pushBuffer.push(pushEvent) 105 + } 106 + 107 + return pushEvent 108 + } 109 + 110 + leave(timeout = this.timeout){ 111 + this.rejoinTimer.reset() 112 + this.state = CHANNEL_STATES.leaving 113 + let onClose = () => { 114 + if(this.socket.hasLogger()) this.socket.log("channel", `leave ${this.topic}`) 115 + this.trigger(CHANNEL_EVENTS.close, "leave") 116 + } 117 + let leavePush = new Push(this, CHANNEL_EVENTS.leave, closure({}), timeout) 118 + leavePush.receive("ok", () => onClose()) 119 + leavePush.receive("timeout", () => onClose()) 120 + leavePush.send() 121 + if(!this.canPush()){ leavePush.trigger("ok", {}) } 122 + 123 + return leavePush 124 + } 125 + 126 + on(event, callback){ 127 + let ref = this.bindingRef++ 128 + this.bindings.push({event, ref, callback}) 129 + return ref 130 + } 131 + 132 + off(event, ref){ 133 + this.bindings = this.bindings.filter((bind) => { 134 + return !(bind.event === event && (typeof ref === "undefined" || ref === bind.ref)) 135 + }) 136 + } 137 + 138 + canPush(){ return this.socket.isConnected() && this.state === CHANNEL_STATES.joined } 139 + 140 + replyEventName(ref){ return `chan_reply_${ref}` } 141 + 142 + isMember(topic, event, payload, joinRef){ 143 + if(this.topic !== topic){ return false } 144 + let isLifecycleEvent = CHANNEL_LIFECYCLE_EVENTS.indexOf(event) >= 0 145 + 146 + if(joinRef && isLifecycleEvent && joinRef !== this.joinRef()){ 147 + if(this.socket.hasLogger()) this.socket.log("channel", "dropping outdated message", {topic, event, payload, joinRef}) 148 + return false 149 + } else { 150 + return true 151 + } 152 + } 153 + 154 + joinRef(){ return this.joinPush.ref } 155 + 156 + rejoin(timeout = this.timeout){ 157 + if(this.isLeaving()){ return } 158 + this.state = CHANNEL_STATES.joining 159 + this.joinPush.resend(timeout) 160 + } 161 + 162 + trigger(event, payload, ref, joinRef){ 163 + let handledPayload = {event, payload, ref, joinRef} 164 + this.bindings 165 + .filter(bind => bind.event === event) 166 + .map(bind => bind.callback(handledPayload.payload, handledPayload.ref, handledPayload.event)) 167 + return handledPayload 168 + } 169 + 170 + isJoined() { return this.state === CHANNEL_STATES.joined } 171 + isJoining() { return this.state === CHANNEL_STATES.joining } 172 + isLeaving() { return this.state === CHANNEL_STATES.leaving } 173 + isClosed() { return this.state === CHANNEL_STATES.closed } 174 + isErrored() { return this.state === CHANNEL_STATES.errored } 175 + isConnected() { return this.socket.isConnected() } 176 + 177 + rejoinUntilConnected(){ 178 + this.rejoinTimer.scheduleTimeout() 179 + if(this.socket.isConnected()){ 180 + this.rejoin() 181 + } 182 + } 183 + } 184 + 185 + // Channel-related constants 186 + const CHANNEL_EVENTS = { 187 + close: "phx_close", 188 + error: "phx_error", 189 + join: "phx_join", 190 + reply: "phx_reply", 191 + leave: "phx_leave" 192 + } 193 + 194 + const CHANNEL_STATES = { 195 + closed: "closed", 196 + errored: "errored", 197 + joined: "joined", 198 + joining: "joining", 199 + leaving: "leaving", 200 + } 201 + 202 + const CHANNEL_LIFECYCLE_EVENTS = [ 203 + CHANNEL_EVENTS.close, 204 + CHANNEL_EVENTS.error, 205 + CHANNEL_EVENTS.join, 206 + CHANNEL_EVENTS.reply, 207 + CHANNEL_EVENTS.leave 208 + ] 209 + 210 + // Push constructor 211 + class Push { 212 + constructor(channel, event, payload, timeout) { 213 + this.channel = channel 214 + this.event = event 215 + this.payload = payload || {} 216 + this.receivedResp = null 217 + this.timeout = timeout 218 + this.timeoutTimer = null 219 + this.recHooks = [] 220 + this.sent = false 221 + this.ref = this.channel.socket.makeRef() 222 + } 223 + 224 + resend(timeout) { 225 + this.timeout = timeout 226 + this.reset() 227 + this.send() 228 + } 229 + 230 + send() { 231 + if(this.hasReceived("timeout")) { return } 232 + this.startTimeout() 233 + this.sent = true 234 + this.channel.socket.push({ 235 + topic: this.channel.topic, 236 + event: this.event, 237 + payload: this.payload, 238 + ref: this.ref, 239 + join_ref: this.channel.joinRef() 240 + }) 241 + } 242 + 243 + receive(status, callback) { 244 + if(this.hasReceived(status)) { 245 + callback(this.receivedResp.response) 246 + } 247 + 248 + this.recHooks.push({status, callback}) 249 + return this 250 + } 251 + 252 + reset() { 253 + this.cancelRefEvent() 254 + this.ref = null 255 + this.refEvent = null 256 + this.receivedResp = null 257 + this.sent = false 258 + } 259 + 260 + matchReceive({status, response, ref}) { 261 + this.recHooks.filter(h => h.status === status) 262 + .forEach(h => h.callback(response)) 263 + } 264 + 265 + cancelRefEvent() { 266 + if(!this.refEvent) { return } 267 + this.channel.off(this.refEvent) 268 + } 269 + 270 + cancelTimeout() { 271 + clearTimeout(this.timeoutTimer) 272 + this.timeoutTimer = null 273 + } 274 + 275 + startTimeout() { 276 + if(this.timeoutTimer) { this.cancelTimeout() } 277 + this.ref = this.channel.socket.makeRef() 278 + this.refEvent = this.channel.replyEventName(this.ref) 279 + 280 + this.channel.on(this.refEvent, payload => { 281 + this.cancelRefEvent() 282 + this.cancelTimeout() 283 + this.receivedResp = payload 284 + this.matchReceive(payload) 285 + }) 286 + 287 + this.timeoutTimer = setTimeout(() => { 288 + this.trigger("timeout", {}) 289 + }, this.timeout) 290 + } 291 + 292 + hasReceived(status) { 293 + return this.receivedResp && this.receivedResp.status === status 294 + } 295 + 296 + trigger(status, response) { 297 + this.channel.trigger(this.refEvent, {status, response}) 298 + } 299 + } 300 + 301 + // Timer constructor 302 + class Timer { 303 + constructor(callback, timerCalc) { 304 + this.callback = callback 305 + this.timerCalc = timerCalc 306 + this.timer = null 307 + this.tries = 0 308 + } 309 + 310 + reset() { 311 + this.tries = 0 312 + clearTimeout(this.timer) 313 + } 314 + 315 + scheduleTimeout() { 316 + clearTimeout(this.timer) 317 + this.timer = setTimeout(() => { 318 + this.tries = this.tries + 1 319 + this.callback() 320 + }, this.timerCalc(this.tries + 1)) 321 + } 322 + } 323 + 324 + // Socket constructor 325 + class Socket { 326 + constructor(endPoint, opts = {}){ 327 + this.stateChangeCallbacks = {open: [], close: [], error: [], message: []} 328 + this.channels = [] 329 + this.sendBuffer = [] 330 + this.ref = 0 331 + this.timeout = opts.timeout || DEFAULT_TIMEOUT 332 + this.transport = opts.transport || global.WebSocket || WebSocket 333 + this.establishedConnections = 0 334 + this.defaultEncoder = (payload, callback) => callback(JSON.stringify(payload)) 335 + this.defaultDecoder = (payload, callback) => callback(JSON.parse(payload)) 336 + this.closeWasClean = false 337 + this.unloaded = false 338 + this.binaryType = opts.binaryType || "arraybuffer" 339 + this.connectClock = 1 340 + if(this.transport !== WebSocket){ 341 + this.encode = opts.encode || this.defaultEncoder 342 + this.decode = opts.decode || this.defaultDecoder 343 + } else { 344 + this.encode = this.defaultEncoder 345 + this.decode = this.defaultDecoder 346 + } 347 + if(opts.params){ this.params = opts.params } 348 + this.logger = opts.logger || null 349 + this.longpollerTimeout = opts.longpollerTimeout || 20000 350 + this.heartbeatIntervalMs = opts.heartbeatIntervalMs || 30000 351 + this.reconnectAfterMs = opts.reconnectAfterMs || function(tries){ 352 + return [1000, 2000, 5000, 10000][tries - 1] || 10000 353 + } 354 + this.encode = opts.encode || this.defaultEncoder 355 + this.decode = opts.decode || this.defaultDecoder 356 + const vsn = opts.vsn || VSN 357 + this.vsn = vsn 358 + this.heartbeatTimer = null 359 + this.pendingHeartbeatRef = null 360 + this.reconnectTimer = new Timer(() => { 361 + this.teardown(() => this.connect()) 362 + }, this.reconnectAfterMs) 363 + this.endPoint = `${endPoint}/${TRANSPORTS.websocket}` 364 + } 365 + 366 + connect() { 367 + if(this.conn){ return } 368 + this.connectClock++ 369 + this.closeWasClean = false 370 + this.conn = new this.transport(this.endPointURL()) 371 + this.conn.binaryType = this.binaryType 372 + this.conn.timeout = this.longpollerTimeout 373 + this.conn.onopen = () => this.onConnOpen() 374 + this.conn.onerror = error => this.onConnError(error) 375 + this.conn.onmessage = event => this.onConnMessage(event) 376 + this.conn.onclose = event => this.onConnClose(event) 377 + } 378 + 379 + disconnect(callback, code, reason){ 380 + this.connectClock++ 381 + this.closeWasClean = true 382 + this.reconnectTimer.reset() 383 + this.teardown(callback, code, reason) 384 + } 385 + 386 + teardown(callback, code, reason){ 387 + if(!this.conn){ 388 + return callback && callback() 389 + } 390 + 391 + this.waitForBufferDone(() => { 392 + if(this.conn){ 393 + if(code){ this.conn.close(code, reason || "") } else { this.conn.close() } 394 + } 395 + 396 + this.waitForSocketClosed(() => { 397 + this.conn = null 398 + callback && callback() 399 + }) 400 + }) 401 + } 402 + 403 + waitForBufferDone(callback, tries = 1){ 404 + if(tries === 5 || !this.conn || !this.conn.bufferedAmount){ 405 + callback() 406 + return 407 + } 408 + 409 + setTimeout(() => this.waitForBufferDone(callback, tries + 1), 150 * tries) 410 + } 411 + 412 + waitForSocketClosed(callback, tries = 1){ 413 + if(tries === 5 || !this.conn || this.conn.readyState === SOCKET_STATES.closed){ 414 + callback() 415 + return 416 + } 417 + 418 + setTimeout(() => this.waitForSocketClosed(callback, tries + 1), 150 * tries) 419 + } 420 + 421 + onConnOpen(){ 422 + if(this.hasLogger()) this.log("transport", `connected to ${this.endPointURL()}`) 423 + this.unloaded = false 424 + this.closeWasClean = false 425 + this.establishedConnections++ 426 + this.flushSendBuffer() 427 + this.reconnectTimer.reset() 428 + this.resetHeartbeat() 429 + this.stateChangeCallbacks.open.forEach(callback => callback()) 430 + } 431 + 432 + resetHeartbeat(){ if(this.conn && this.heartbeatIntervalMs > 0){ 433 + this.stopHeartbeat() 434 + this.heartbeatTimer = setTimeout(() => this.sendHeartbeat(), this.heartbeatIntervalMs) 435 + }} 436 + 437 + stopHeartbeat(){ 438 + clearTimeout(this.heartbeatTimer) 439 + this.heartbeatTimer = null 440 + clearTimeout(this.pendingHeartbeatRef) 441 + this.pendingHeartbeatRef = null 442 + } 443 + 444 + onConnClose(event){ 445 + if(this.hasLogger()) this.log("transport", "close", event) 446 + this.triggerChanError() 447 + this.stopHeartbeat() 448 + this.stateChangeCallbacks.close.forEach(callback => callback(event)) 449 + if(this.closeWasClean){ 450 + this.reconnectTimer.reset() 451 + } else { 452 + this.reconnectTimer.scheduleTimeout() 453 + } 454 + } 455 + 456 + onConnError(error){ 457 + if(this.hasLogger()) this.log("transport", error) 458 + this.triggerChanError() 459 + this.stateChangeCallbacks.error.forEach(callback => callback(error)) 460 + } 461 + 462 + triggerChanError(){ 463 + this.channels.forEach(channel => { 464 + if(!(channel.isErrored() || channel.isLeaving() || channel.isClosed())){ 465 + channel.trigger(CHANNEL_EVENTS.error) 466 + } 467 + }) 468 + } 469 + 470 + connectionState(){ 471 + switch(this.conn && this.conn.readyState){ 472 + case SOCKET_STATES.connecting: return "connecting" 473 + case SOCKET_STATES.open: return "open" 474 + case SOCKET_STATES.closing: return "closing" 475 + default: return "closed" 476 + } 477 + } 478 + 479 + isConnected(){ return this.connectionState() === "open" } 480 + 481 + remove(channel){ 482 + this.channels = this.channels.filter(c => c.joinRef() !== channel.joinRef()) 483 + } 484 + 485 + channel(topic, chanParams = {}){ 486 + let chan = new Channel(topic, chanParams, this) 487 + this.channels.push(chan) 488 + return chan 489 + } 490 + 491 + push(data){ 492 + if(this.hasLogger()){ 493 + let {topic, event, payload, ref, join_ref} = data 494 + this.log("push", `${topic} ${event} (${join_ref}, ${ref})`, payload) 495 + } 496 + 497 + if(this.isConnected()){ 498 + this.encode(data, result => this.conn.send(result)) 499 + } else { 500 + this.sendBuffer.push(() => this.encode(data, result => this.conn.send(result))) 501 + } 502 + } 503 + 504 + makeRef(){ 505 + let newRef = this.ref + 1 506 + if(newRef === this.ref){ this.ref = 0 } else { this.ref = newRef } 507 + 508 + return this.ref.toString() 509 + } 510 + 511 + sendHeartbeat(){ 512 + if(!this.isConnected()){ return } 513 + if(this.pendingHeartbeatRef){ return } 514 + this.pendingHeartbeatRef = this.makeRef() 515 + this.push({topic: "phoenix", event: "heartbeat", payload: {}, ref: this.pendingHeartbeatRef}) 516 + this.heartbeatTimer = setTimeout(() => this.heartbeatTimeout(), this.heartbeatIntervalMs) 517 + } 518 + 519 + heartbeatTimeout(){ 520 + if(this.pendingHeartbeatRef){ 521 + this.pendingHeartbeatRef = null 522 + if(this.hasLogger()) this.log("transport", "heartbeat timeout. Attempting to re-establish connection") 523 + this.abnormalClose("heartbeat timeout") 524 + } 525 + } 526 + 527 + abnormalClose(reason){ 528 + this.closeWasClean = false 529 + if(this.conn){ 530 + this.conn.close(WS_CLOSE_NORMAL, reason) 531 + } 532 + } 533 + 534 + flushSendBuffer(){ 535 + if(this.isConnected() && this.sendBuffer.length > 0){ 536 + this.sendBuffer.forEach(callback => callback()) 537 + this.sendBuffer = [] 538 + } 539 + } 540 + 541 + onConnMessage(rawMessage){ 542 + this.decode(rawMessage.data, msg => { 543 + let {topic, event, payload, ref, join_ref} = msg 544 + if(ref && ref === this.pendingHeartbeatRef){ 545 + this.pendingHeartbeatRef = null 546 + clearTimeout(this.heartbeatTimer) 547 + this.heartbeatTimer = setTimeout(() => this.sendHeartbeat(), this.heartbeatIntervalMs) 548 + } 549 + 550 + if(this.hasLogger()) this.log("receive", `${payload.status || ""} ${topic} ${event} ${ref && "(" + ref + ")" || ""}`, payload) 551 + 552 + for(let i = 0; i < this.channels.length; i++){ 553 + const channel = this.channels[i] 554 + if(!channel.isMember(topic, event, payload, join_ref)){ continue } 555 + channel.trigger(event, payload, ref, join_ref) 556 + } 557 + 558 + for(let i = 0; i < this.stateChangeCallbacks.message.length; i++){ 559 + let [, callback] = this.stateChangeCallbacks.message[i] 560 + callback(msg) 561 + } 562 + }) 563 + } 564 + 565 + leaveOpenTopic(topic){ 566 + let dupChannel = this.channels.find(c => c.topic === topic && (c.isJoined() || c.isJoining())) 567 + if(dupChannel){ 568 + if(this.hasLogger()) this.log("transport", `leaving duplicate topic "${topic}"`) 569 + dupChannel.leave() 570 + } 571 + } 572 + 573 + endPointURL(){ 574 + return this.appendParams(this.endPoint, Object.assign({}, this.params, {vsn: this.vsn})) 575 + } 576 + 577 + appendParams(url, params){ 578 + if(Object.keys(params).length === 0){ return url } 579 + 580 + let prefix = url.match(/\?/) ? "&" : "?" 581 + return `${url}${prefix}${this.serializeParams(params)}` 582 + } 583 + 584 + serializeParams(params){ 585 + return params ? Object.entries(params).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&") : "" 586 + } 587 + 588 + hasLogger(){ return this.logger !== null } 589 + 590 + log(kind, msg, data){ this.logger(kind, msg, data) } 591 + } 592 + 593 + // Transport-related constants 594 + const TRANSPORTS = { 595 + longpoll: "longpoll", 596 + websocket: "websocket" 597 + } 598 + 599 + // Export Socket and Channel classes 600 + return { Channel, Socket } 601 + }));
+198
tasty_chrome/popup.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <title>Tasty Bookmarks</title> 5 + <meta charset="utf-8"> 6 + <style> 7 + body { 8 + width: 360px; 9 + min-height: 400px; 10 + margin: 0; 11 + padding: 0; 12 + font-family: 'Courier New', monospace; 13 + background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%); 14 + color: #e0e0e0; 15 + } 16 + .container { 17 + padding: 16px; 18 + } 19 + .header { 20 + margin-bottom: 16px; 21 + text-align: center; 22 + } 23 + .title { 24 + font-size: 20px; 25 + font-weight: bold; 26 + color: #4ade80; 27 + margin: 0; 28 + display: flex; 29 + align-items: center; 30 + justify-content: center; 31 + } 32 + .title span { 33 + color: #f59e0b; 34 + } 35 + .form-group { 36 + margin-bottom: 12px; 37 + } 38 + label { 39 + display: block; 40 + margin-bottom: 4px; 41 + color: #9ca3af; 42 + } 43 + input[type="text"], input[type="url"], textarea { 44 + width: 100%; 45 + padding: 8px; 46 + border: 1px solid #4c1d95; 47 + border-radius: 4px; 48 + background-color: #111827; 49 + color: #e0e0e0; 50 + font-family: 'Courier New', monospace; 51 + box-sizing: border-box; 52 + } 53 + textarea { 54 + resize: vertical; 55 + min-height: 80px; 56 + } 57 + .tags-input { 58 + width: 100%; 59 + padding: 8px; 60 + border: 1px solid #4c1d95; 61 + border-radius: 4px; 62 + background-color: #111827; 63 + color: #e0e0e0; 64 + font-family: 'Courier New', monospace; 65 + box-sizing: border-box; 66 + } 67 + .tags-container { 68 + display: flex; 69 + flex-wrap: wrap; 70 + margin-top: 8px; 71 + gap: 4px; 72 + } 73 + .tag { 74 + background-color: #4c1d95; 75 + color: #e0e0e0; 76 + padding: 2px 8px; 77 + border-radius: 12px; 78 + font-size: 12px; 79 + display: flex; 80 + align-items: center; 81 + } 82 + .tag .remove { 83 + margin-left: 4px; 84 + cursor: pointer; 85 + color: #d1d5db; 86 + } 87 + .button { 88 + width: 100%; 89 + padding: 10px; 90 + background: linear-gradient(90deg, #4ade80 0%, #3b82f6 100%); 91 + border: none; 92 + border-radius: 4px; 93 + color: white; 94 + font-weight: bold; 95 + cursor: pointer; 96 + transition: transform 0.2s; 97 + margin-top: 8px; 98 + } 99 + .button:hover { 100 + transform: scale(1.02); 101 + } 102 + .button:disabled { 103 + background: #6b7280; 104 + cursor: not-allowed; 105 + transform: none; 106 + } 107 + .status { 108 + margin-top: 12px; 109 + text-align: center; 110 + font-size: 14px; 111 + } 112 + .error { 113 + color: #ef4444; 114 + } 115 + .success { 116 + color: #10b981; 117 + } 118 + .token-input { 119 + margin-bottom: 16px; 120 + } 121 + .loader { 122 + border: 3px solid #1f2937; 123 + border-top: 3px solid #3b82f6; 124 + border-radius: 50%; 125 + width: 16px; 126 + height: 16px; 127 + animation: spin 1s linear infinite; 128 + margin: 0 auto; 129 + display: none; 130 + } 131 + @keyframes spin { 132 + 0% { transform: rotate(0deg); } 133 + 100% { transform: rotate(360deg); } 134 + } 135 + .cursor { 136 + display: inline-block; 137 + width: 8px; 138 + height: 16px; 139 + background-color: #4ade80; 140 + margin-left: 4px; 141 + animation: blink 1s step-end infinite; 142 + } 143 + @keyframes blink { 144 + from, to { opacity: 1; } 145 + 50% { opacity: 0; } 146 + } 147 + </style> 148 + </head> 149 + <body> 150 + <div class="container"> 151 + <div class="header"> 152 + <h1 class="title"> 153 + Tasty<span>@</span>Bookmarks<div class="cursor"></div> 154 + </h1> 155 + </div> 156 + 157 + <div class="form-group token-input"> 158 + <label for="token">Authentication Token:</label> 159 + <input type="text" id="token" placeholder="Paste your WebSocket token here..."> 160 + </div> 161 + 162 + <form id="bookmark-form"> 163 + <div class="form-group"> 164 + <label for="title">Title:</label> 165 + <input type="text" id="title" required> 166 + </div> 167 + 168 + <div class="form-group"> 169 + <label for="url">URL:</label> 170 + <input type="url" id="url" required> 171 + </div> 172 + 173 + <div class="form-group"> 174 + <label for="description">Description (optional):</label> 175 + <textarea id="description"></textarea> 176 + </div> 177 + 178 + <div class="form-group"> 179 + <label for="tags">Tags (optional):</label> 180 + <input type="text" id="tags-input" class="tags-input" placeholder="Add tags separated by commas..."> 181 + <div class="tags-container" id="tags-container"></div> 182 + </div> 183 + 184 + <button type="submit" class="button" id="save-button"> 185 + Save Bookmark <span>▶</span> 186 + </button> 187 + </form> 188 + 189 + <div class="status" id="status"> 190 + <div class="loader" id="loader"></div> 191 + <div id="message"></div> 192 + </div> 193 + </div> 194 + 195 + <script src="phoenix.js"></script> 196 + <script src="popup.js"></script> 197 + </body> 198 + </html>
+165
tasty_chrome/popup.js
··· 1 + document.addEventListener('DOMContentLoaded', () => { 2 + // DOM elements 3 + const form = document.getElementById('bookmark-form'); 4 + const titleInput = document.getElementById('title'); 5 + const urlInput = document.getElementById('url'); 6 + const descriptionInput = document.getElementById('description'); 7 + const tagsInput = document.getElementById('tags-input'); 8 + const tagsContainer = document.getElementById('tags-container'); 9 + const tokenInput = document.getElementById('token'); 10 + const saveButton = document.getElementById('save-button'); 11 + const loader = document.getElementById('loader'); 12 + const message = document.getElementById('message'); 13 + 14 + // Get current tab information and fill the form 15 + chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { 16 + const currentTab = tabs[0]; 17 + titleInput.value = currentTab.title || ''; 18 + urlInput.value = currentTab.url || ''; 19 + }); 20 + 21 + // Load saved token from storage 22 + chrome.storage.local.get(['token'], (result) => { 23 + if (result.token) { 24 + tokenInput.value = result.token; 25 + } 26 + }); 27 + 28 + // Save token when it changes 29 + tokenInput.addEventListener('change', () => { 30 + const token = tokenInput.value.trim(); 31 + if (token) { 32 + chrome.storage.local.set({ token }); 33 + } 34 + }); 35 + 36 + // Tags handling 37 + const tags = []; 38 + 39 + function updateTagsDisplay() { 40 + tagsContainer.innerHTML = ''; 41 + tags.forEach((tag, index) => { 42 + const tagElement = document.createElement('div'); 43 + tagElement.className = 'tag'; 44 + tagElement.innerHTML = ` 45 + #${tag} 46 + <span class="remove" data-index="${index}">×</span> 47 + `; 48 + tagsContainer.appendChild(tagElement); 49 + }); 50 + 51 + // Add click event to remove buttons 52 + document.querySelectorAll('.tag .remove').forEach(button => { 53 + button.addEventListener('click', (e) => { 54 + const index = parseInt(e.target.getAttribute('data-index')); 55 + tags.splice(index, 1); 56 + updateTagsDisplay(); 57 + }); 58 + }); 59 + } 60 + 61 + tagsInput.addEventListener('keydown', (e) => { 62 + if (e.key === 'Enter' || e.key === ',') { 63 + e.preventDefault(); 64 + const value = tagsInput.value.trim(); 65 + if (value && !tags.includes(value)) { 66 + tags.push(value); 67 + tagsInput.value = ''; 68 + updateTagsDisplay(); 69 + } 70 + } 71 + }); 72 + 73 + tagsInput.addEventListener('blur', () => { 74 + const value = tagsInput.value.trim(); 75 + if (value) { 76 + const newTags = value.split(',').map(tag => tag.trim()).filter(tag => tag && !tags.includes(tag)); 77 + tags.push(...newTags); 78 + tagsInput.value = ''; 79 + updateTagsDisplay(); 80 + } 81 + }); 82 + 83 + // Form submission 84 + form.addEventListener('submit', async (e) => { 85 + e.preventDefault(); 86 + 87 + const token = tokenInput.value.trim(); 88 + if (!token) { 89 + showMessage('Please enter your authentication token', 'error'); 90 + return; 91 + } 92 + 93 + // Disable form and show loader 94 + saveButton.disabled = true; 95 + loader.style.display = 'block'; 96 + message.textContent = ''; 97 + 98 + try { 99 + // Connect to Phoenix socket 100 + const socket = new Phoenix.Socket("ws://localhost:4000/socket", { 101 + params: { token } 102 + }); 103 + 104 + socket.connect(); 105 + 106 + // Join the client channel 107 + const channel = socket.channel(`bookmark:client:${token}`); 108 + 109 + channel.join() 110 + .receive("ok", resp => { 111 + console.log("Joined successfully", resp); 112 + sendBookmark(channel); 113 + }) 114 + .receive("error", resp => { 115 + console.error("Unable to join", resp); 116 + showMessage(`Error connecting: ${resp.reason || 'Unauthorized'}`, 'error'); 117 + resetForm(); 118 + }); 119 + 120 + } catch (error) { 121 + console.error('Connection error:', error); 122 + showMessage(`Connection error: ${error.message}`, 'error'); 123 + resetForm(); 124 + } 125 + }); 126 + 127 + function sendBookmark(channel) { 128 + const payload = { 129 + title: titleInput.value, 130 + url: urlInput.value, 131 + description: descriptionInput.value, 132 + tags: tags 133 + }; 134 + 135 + channel.push("bookmark:create", payload) 136 + .receive("ok", resp => { 137 + console.log("Bookmark created", resp); 138 + showMessage('Bookmark saved successfully!', 'success'); 139 + setTimeout(() => window.close(), 1500); 140 + }) 141 + .receive("error", resp => { 142 + console.error("Failed to create bookmark", resp); 143 + showMessage(`Error: ${formatErrors(resp.errors)}`, 'error'); 144 + resetForm(); 145 + }); 146 + } 147 + 148 + function formatErrors(errors) { 149 + if (!errors) return 'Unknown error'; 150 + 151 + return Object.entries(errors) 152 + .map(([field, messages]) => `${field}: ${Array.isArray(messages) ? messages.join(', ') : messages}`) 153 + .join('; '); 154 + } 155 + 156 + function showMessage(text, type) { 157 + message.textContent = text; 158 + message.className = type; 159 + } 160 + 161 + function resetForm() { 162 + saveButton.disabled = false; 163 + loader.style.display = 'none'; 164 + } 165 + });
+180
tasty_chrome/styles.css
··· 1 + /* Tasty Bookmarks - Extension Styles */ 2 + 3 + :root { 4 + --color-primary: #6366f1; 5 + --color-primary-hover: #4f46e5; 6 + --color-text: #0f172a; 7 + --color-text-light: #64748b; 8 + --color-background: #f8fafc; 9 + --color-border: #e2e8f0; 10 + --color-success: #10b981; 11 + --color-error: #ef4444; 12 + --radius: 6px; 13 + --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); 14 + } 15 + 16 + * { 17 + box-sizing: border-box; 18 + margin: 0; 19 + padding: 0; 20 + } 21 + 22 + body { 23 + width: 380px; 24 + min-height: 400px; 25 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; 26 + background: var(--color-background); 27 + color: var(--color-text); 28 + font-size: 14px; 29 + line-height: 1.5; 30 + padding: 16px; 31 + } 32 + 33 + .container { 34 + display: flex; 35 + flex-direction: column; 36 + gap: 16px; 37 + } 38 + 39 + header { 40 + text-align: center; 41 + padding-bottom: 12px; 42 + border-bottom: 1px solid var(--color-border); 43 + } 44 + 45 + h1 { 46 + font-size: 1.5rem; 47 + font-weight: 600; 48 + margin-bottom: 4px; 49 + color: var(--color-primary); 50 + } 51 + 52 + .subtitle { 53 + color: var(--color-text-light); 54 + font-size: 0.9rem; 55 + } 56 + 57 + .form-group { 58 + display: flex; 59 + flex-direction: column; 60 + gap: 6px; 61 + } 62 + 63 + .form-group label { 64 + font-weight: 500; 65 + font-size: 0.9rem; 66 + } 67 + 68 + input, textarea { 69 + padding: 8px 12px; 70 + border: 1px solid var(--color-border); 71 + border-radius: var(--radius); 72 + font-size: 0.9rem; 73 + transition: border-color 0.15s ease; 74 + width: 100%; 75 + } 76 + 77 + input:focus, textarea:focus { 78 + border-color: var(--color-primary); 79 + outline: none; 80 + box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2); 81 + } 82 + 83 + textarea { 84 + resize: vertical; 85 + min-height: 80px; 86 + } 87 + 88 + .tags-input-container { 89 + display: flex; 90 + flex-direction: column; 91 + gap: 8px; 92 + } 93 + 94 + .tags-container { 95 + display: flex; 96 + flex-wrap: wrap; 97 + gap: 6px; 98 + } 99 + 100 + .tag { 101 + background: rgba(99, 102, 241, 0.1); 102 + color: var(--color-primary); 103 + border-radius: var(--radius); 104 + padding: 4px 8px; 105 + font-size: 0.85rem; 106 + display: flex; 107 + align-items: center; 108 + gap: 4px; 109 + } 110 + 111 + .tag .remove { 112 + cursor: pointer; 113 + font-weight: bold; 114 + font-size: 1.1rem; 115 + margin-left: 2px; 116 + } 117 + 118 + .tag .remove:hover { 119 + color: var(--color-error); 120 + } 121 + 122 + button { 123 + cursor: pointer; 124 + background: var(--color-primary); 125 + color: white; 126 + border: none; 127 + border-radius: var(--radius); 128 + padding: 10px 16px; 129 + font-weight: 500; 130 + transition: background-color 0.15s ease; 131 + } 132 + 133 + button:hover { 134 + background: var(--color-primary-hover); 135 + } 136 + 137 + button:disabled { 138 + opacity: 0.6; 139 + cursor: not-allowed; 140 + } 141 + 142 + .loader { 143 + display: none; 144 + margin: 0 auto; 145 + width: 24px; 146 + height: 24px; 147 + border: 3px solid rgba(99, 102, 241, 0.3); 148 + border-radius: 50%; 149 + border-top-color: var(--color-primary); 150 + animation: spin 1s linear infinite; 151 + } 152 + 153 + @keyframes spin { 154 + 0% { transform: rotate(0deg); } 155 + 100% { transform: rotate(360deg); } 156 + } 157 + 158 + .message { 159 + padding: 8px 12px; 160 + border-radius: var(--radius); 161 + font-size: 0.9rem; 162 + text-align: center; 163 + } 164 + 165 + .message.success { 166 + background-color: rgba(16, 185, 129, 0.1); 167 + color: var(--color-success); 168 + } 169 + 170 + .message.error { 171 + background-color: rgba(239, 68, 68, 0.1); 172 + color: var(--color-error); 173 + } 174 + 175 + footer { 176 + margin-top: 8px; 177 + font-size: 0.8rem; 178 + color: var(--color-text-light); 179 + text-align: center; 180 + }