+47
tasty_chrome/README.md
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+
}