mirror of
https://github.com/Dvorinka/Trackeep.git
synced 2026-06-03 20:12:58 +00:00
small fix, don't worry about it
This commit is contained in:
@@ -5,6 +5,7 @@ This folder contains a WebExtension (Manifest v3) that lets you:
|
||||
- **Save the current page or video** as a **Trackeep bookmark**.
|
||||
- **Upload a local file** directly to Trackeep.
|
||||
- **Right‑click** any page, link, selection, image, or video and choose **“Save to Trackeep”** from the context menu.
|
||||
- **Auto-detect YouTube videos** and show an in-page prompt asking whether to save to Trackeep.
|
||||
|
||||
It is designed to work in **Chrome**, **Microsoft Edge**, and **Firefox** (Manifest v3 where available).
|
||||
|
||||
@@ -14,8 +15,9 @@ It is designed to work in **Chrome**, **Microsoft Edge**, and **Firefox** (Manif
|
||||
|
||||
- `manifest.json` – WebExtension manifest (v3) with background service worker and context menu.
|
||||
- `popup.html` / `popup.js` – Popup UI and logic to save bookmarks and upload files.
|
||||
- `options.html` / `options.js` – Options page to configure API URL and auth token.
|
||||
- `background.js` – Service worker that creates and handles the context menu.
|
||||
- `options.html` / `options.js` – Options page to configure API URL, key/token, and YouTube auto-prompt.
|
||||
- `background.js` – Service worker for context menus, quick-save, and YouTube auto-save integration.
|
||||
- `youtube-content.js` – Content script for YouTube page detection and in-page save prompt.
|
||||
- `icons/` – Placeholder icon files (copied from the repo favicon).
|
||||
- `README.md` – This documentation.
|
||||
|
||||
@@ -62,14 +64,16 @@ It is designed to work in **Chrome**, **Microsoft Edge**, and **Firefox** (Manif
|
||||
- Example for production:
|
||||
- `https://your-trackeep-domain.example.com/api/v1`
|
||||
|
||||
3. **Get your Trackeep auth token**
|
||||
- Log into Trackeep in your browser.
|
||||
- Open **DevTools → Application → Local Storage**.
|
||||
- Select your Trackeep origin (e.g. `http://localhost:5173` or your production domain).
|
||||
- Find the key `trackeep_token` and copy its **value**.
|
||||
- Paste this value into the **Auth token** field in the options page.
|
||||
3. **Set your Trackeep API key or token**
|
||||
- Recommended: create an API key in Trackeep and paste it in the options page.
|
||||
- You can also use a JWT token if your instance expects that flow.
|
||||
- The extension stores this value and uses it for bookmark/file requests and YouTube quick-save.
|
||||
|
||||
4. **Save settings**
|
||||
4. **YouTube auto prompt (optional)**
|
||||
- Keep **YouTube Auto Prompt** enabled to get an in-page prompt on YouTube video pages.
|
||||
- Click **Save now** in the prompt to store the video directly in Trackeep.
|
||||
|
||||
5. **Save settings**
|
||||
- Click **Save settings**.
|
||||
- The popup will now use these values to call the API.
|
||||
|
||||
|
||||
+486
-194
@@ -1,217 +1,509 @@
|
||||
/* global chrome, browser */
|
||||
|
||||
// Browser compatibility polyfill
|
||||
if (typeof browser === 'undefined' && typeof chrome !== 'undefined') {
|
||||
if (typeof browser === "undefined" && typeof chrome !== "undefined") {
|
||||
browser = chrome;
|
||||
}
|
||||
|
||||
// Handle keyboard commands
|
||||
browser.commands.onCommand.addListener((command) => {
|
||||
if (command === 'quick-save') {
|
||||
// Get current tab and trigger quick save
|
||||
browser.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
const tab = tabs && tabs[0];
|
||||
if (tab) {
|
||||
browser.storage.local.set({
|
||||
contextMenuData: {
|
||||
url: tab.url,
|
||||
title: tab.title,
|
||||
selection: '',
|
||||
timestamp: Date.now(),
|
||||
isQuickSave: true
|
||||
}
|
||||
}, () => {
|
||||
browser.action.openPopup();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
const YOUTUBE_PROMPT_HISTORY_KEY = "trackeepYoutubePromptHistory";
|
||||
const YOUTUBE_AUTO_PROMPT_KEY = "youtubeAutoPrompt";
|
||||
const YOUTUBE_DISMISS_COOLDOWN_MS = 6 * 60 * 60 * 1000;
|
||||
const YOUTUBE_PROMPT_COOLDOWN_MS = 30 * 60 * 1000;
|
||||
const YOUTUBE_HISTORY_LIMIT = 400;
|
||||
|
||||
// Handle first-time install
|
||||
browser.runtime.onInstalled.addListener((details) => {
|
||||
if (details.reason === 'install') {
|
||||
// Set up first-time install flag
|
||||
browser.storage.sync.set({
|
||||
isFirstInstall: true,
|
||||
installDate: new Date().toISOString()
|
||||
}, () => {
|
||||
// Open options page for first-time setup
|
||||
browser.runtime.openOptionsPage();
|
||||
});
|
||||
}
|
||||
|
||||
// Create context menus
|
||||
browser.contextMenus.create({
|
||||
id: 'save-to-trackeep',
|
||||
title: 'Save to Trackeep',
|
||||
contexts: ['page', 'link', 'selection', 'image', 'video']
|
||||
function storageSyncGet(keys) {
|
||||
return new Promise((resolve) => {
|
||||
browser.storage.sync.get(keys, (items) => resolve(items || {}));
|
||||
});
|
||||
|
||||
// Quick save menu
|
||||
browser.contextMenus.create({
|
||||
id: 'quick-save-to-trackeep',
|
||||
title: 'Quick Save to Trackeep',
|
||||
contexts: ['page']
|
||||
}
|
||||
|
||||
function storageLocalGet(keys) {
|
||||
return new Promise((resolve) => {
|
||||
browser.storage.local.get(keys, (items) => resolve(items || {}));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Handle context menu click
|
||||
browser.contextMenus.onClicked.addListener(async (info, tab) => {
|
||||
if (info.menuItemId !== 'save-to-trackeep' && info.menuItemId !== 'quick-save-to-trackeep') return;
|
||||
function storageLocalSet(values) {
|
||||
return new Promise((resolve) => {
|
||||
browser.storage.local.set(values, () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
// Detect content type and get smart data
|
||||
const smartData = await detectContentType(info, tab);
|
||||
|
||||
// Open popup with pre-filled data based on context
|
||||
const url = info.linkUrl || info.srcUrl || tab?.url || '';
|
||||
const title = tab?.title || '';
|
||||
const selection = info.selectionText || '';
|
||||
function parseResponseError(status, body) {
|
||||
if (!body) {
|
||||
return `Request failed with status ${status}.`;
|
||||
}
|
||||
|
||||
// Store temporary data for popup to read
|
||||
browser.storage.local.set({
|
||||
contextMenuData: {
|
||||
url,
|
||||
title,
|
||||
selection,
|
||||
timestamp: Date.now(),
|
||||
isQuickSave: info.menuItemId === 'quick-save-to-trackeep',
|
||||
smartData
|
||||
try {
|
||||
const parsed = JSON.parse(body);
|
||||
if (parsed && typeof parsed.error === "string" && parsed.error.trim()) {
|
||||
return parsed.error.trim();
|
||||
}
|
||||
}, () => {
|
||||
// Open popup (or focus it if already open)
|
||||
browser.action.openPopup();
|
||||
} catch (_) {
|
||||
// Keep raw body fallback.
|
||||
}
|
||||
|
||||
const trimmed = String(body).trim();
|
||||
if (!trimmed) {
|
||||
return `Request failed with status ${status}.`;
|
||||
}
|
||||
|
||||
return trimmed.slice(0, 180);
|
||||
}
|
||||
|
||||
function parseYouTubeVideoMeta(rawUrl, rawTitle) {
|
||||
if (!rawUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let url;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const host = url.hostname.toLowerCase();
|
||||
const path = url.pathname || "";
|
||||
let videoId = "";
|
||||
|
||||
if (host === "youtu.be") {
|
||||
videoId = path.slice(1).split("/")[0];
|
||||
} else if (path.startsWith("/watch")) {
|
||||
videoId = url.searchParams.get("v") || "";
|
||||
} else if (path.startsWith("/shorts/")) {
|
||||
videoId = path.split("/")[2] || "";
|
||||
} else if (path.startsWith("/embed/")) {
|
||||
videoId = path.split("/")[2] || "";
|
||||
}
|
||||
|
||||
if (!videoId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = (rawTitle || "").replace(/\s*-\s*YouTube$/i, "").trim();
|
||||
return {
|
||||
videoId,
|
||||
url: `https://www.youtube.com/watch?v=${videoId}`,
|
||||
title: title || "YouTube video",
|
||||
};
|
||||
}
|
||||
|
||||
function cleanupHistory(history) {
|
||||
const entries = Object.entries(history || {});
|
||||
if (entries.length <= YOUTUBE_HISTORY_LIMIT) {
|
||||
return history || {};
|
||||
}
|
||||
|
||||
entries.sort((a, b) => {
|
||||
const aData = a[1] || {};
|
||||
const bData = b[1] || {};
|
||||
const aStamp = Math.max(aData.savedAt || 0, aData.dismissedAt || 0, aData.promptedAt || 0);
|
||||
const bStamp = Math.max(bData.savedAt || 0, bData.dismissedAt || 0, bData.promptedAt || 0);
|
||||
return bStamp - aStamp;
|
||||
});
|
||||
|
||||
const compact = {};
|
||||
for (const [videoId, value] of entries.slice(0, YOUTUBE_HISTORY_LIMIT)) {
|
||||
compact[videoId] = value;
|
||||
}
|
||||
return compact;
|
||||
}
|
||||
|
||||
async function getYouTubeHistory() {
|
||||
const data = await storageLocalGet([YOUTUBE_PROMPT_HISTORY_KEY]);
|
||||
return data[YOUTUBE_PROMPT_HISTORY_KEY] || {};
|
||||
}
|
||||
|
||||
async function updateYouTubeHistory(videoId, patch) {
|
||||
if (!videoId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const history = await getYouTubeHistory();
|
||||
const current = history[videoId] || {};
|
||||
history[videoId] = { ...current, ...patch };
|
||||
const compact = cleanupHistory(history);
|
||||
await storageLocalSet({ [YOUTUBE_PROMPT_HISTORY_KEY]: compact });
|
||||
}
|
||||
|
||||
async function getTrackeepConfig() {
|
||||
const items = await storageSyncGet([
|
||||
"trackeepApiBaseUrl",
|
||||
"trackeepApiKey",
|
||||
"trackeepAuthToken",
|
||||
YOUTUBE_AUTO_PROMPT_KEY,
|
||||
]);
|
||||
|
||||
const apiBaseUrl = String(items.trackeepApiBaseUrl || "").trim().replace(/\/+$/, "");
|
||||
const authToken = String(items.trackeepApiKey || items.trackeepAuthToken || "").trim();
|
||||
const youtubeAutoPrompt =
|
||||
typeof items[YOUTUBE_AUTO_PROMPT_KEY] === "boolean"
|
||||
? items[YOUTUBE_AUTO_PROMPT_KEY]
|
||||
: true;
|
||||
|
||||
return {
|
||||
apiBaseUrl,
|
||||
authToken,
|
||||
youtubeAutoPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
async function shouldPromptForYouTubeVideo(video) {
|
||||
if (!video || !video.videoId || !video.url) {
|
||||
return { showPrompt: false, reason: "invalid-video" };
|
||||
}
|
||||
|
||||
const config = await getTrackeepConfig();
|
||||
if (!config.youtubeAutoPrompt) {
|
||||
return { showPrompt: false, reason: "disabled" };
|
||||
}
|
||||
|
||||
if (!config.apiBaseUrl || !config.authToken) {
|
||||
return { showPrompt: false, reason: "missing-config" };
|
||||
}
|
||||
|
||||
const history = await getYouTubeHistory();
|
||||
const entry = history[video.videoId] || {};
|
||||
const now = Date.now();
|
||||
|
||||
if (entry.savedAt) {
|
||||
return { showPrompt: false, reason: "already-saved" };
|
||||
}
|
||||
|
||||
if (entry.dismissedAt && now - entry.dismissedAt < YOUTUBE_DISMISS_COOLDOWN_MS) {
|
||||
return { showPrompt: false, reason: "dismissed-recently" };
|
||||
}
|
||||
|
||||
if (entry.promptedAt && now - entry.promptedAt < YOUTUBE_PROMPT_COOLDOWN_MS) {
|
||||
return { showPrompt: false, reason: "prompted-recently" };
|
||||
}
|
||||
|
||||
await updateYouTubeHistory(video.videoId, {
|
||||
promptedAt: now,
|
||||
title: video.title,
|
||||
url: video.url,
|
||||
});
|
||||
|
||||
return { showPrompt: true };
|
||||
}
|
||||
|
||||
async function saveYouTubeBookmark(video) {
|
||||
if (!video || !video.videoId || !video.url) {
|
||||
return { ok: false, error: "Invalid YouTube video metadata." };
|
||||
}
|
||||
|
||||
const config = await getTrackeepConfig();
|
||||
if (!config.apiBaseUrl || !config.authToken) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Trackeep API URL or token missing. Open extension options first.",
|
||||
};
|
||||
}
|
||||
|
||||
const title = (video.title || "YouTube video").trim();
|
||||
const payload = {
|
||||
title,
|
||||
url: video.url,
|
||||
description: "Saved from YouTube via Trackeep browser extension",
|
||||
tags: ["youtube", "video"],
|
||||
is_public: false,
|
||||
};
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(`${config.apiBaseUrl}/bookmarks`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${config.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Could not connect to Trackeep: ${error && error.message ? error.message : "network error"}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => "");
|
||||
return {
|
||||
ok: false,
|
||||
error: parseResponseError(response.status, body),
|
||||
};
|
||||
}
|
||||
|
||||
await updateYouTubeHistory(video.videoId, {
|
||||
savedAt: Date.now(),
|
||||
title: title,
|
||||
url: video.url,
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async function openPopupWithContext(data) {
|
||||
await storageLocalSet({ contextMenuData: data });
|
||||
try {
|
||||
const maybePromise = browser.action.openPopup();
|
||||
if (maybePromise && typeof maybePromise.then === "function") {
|
||||
await maybePromise;
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (_) {
|
||||
return { ok: false, error: "Cannot open popup automatically in this browser context." };
|
||||
}
|
||||
}
|
||||
|
||||
function setContextAndOpenPopup(tab, selection, isQuickSave, smartData) {
|
||||
const payload = {
|
||||
url: tab?.url || "",
|
||||
title: tab?.title || "",
|
||||
selection: selection || "",
|
||||
timestamp: Date.now(),
|
||||
isQuickSave: !!isQuickSave,
|
||||
smartData: smartData || null,
|
||||
};
|
||||
|
||||
return openPopupWithContext(payload);
|
||||
}
|
||||
|
||||
browser.commands.onCommand.addListener((command) => {
|
||||
if (command !== "quick-save") {
|
||||
return;
|
||||
}
|
||||
|
||||
browser.tabs.query({ active: true, currentWindow: true }, async (tabs) => {
|
||||
const tab = tabs && tabs[0];
|
||||
if (!tab) {
|
||||
return;
|
||||
}
|
||||
|
||||
const info = { linkUrl: tab.url, srcUrl: tab.url };
|
||||
const smartData = await detectContentType(info, tab);
|
||||
await setContextAndOpenPopup(tab, "", true, smartData);
|
||||
});
|
||||
});
|
||||
|
||||
// Smart content detection
|
||||
browser.runtime.onInstalled.addListener((details) => {
|
||||
if (details.reason === "install") {
|
||||
browser.storage.sync.set(
|
||||
{
|
||||
isFirstInstall: true,
|
||||
installDate: new Date().toISOString(),
|
||||
[YOUTUBE_AUTO_PROMPT_KEY]: true,
|
||||
},
|
||||
() => {
|
||||
browser.runtime.openOptionsPage();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
browser.contextMenus.create({
|
||||
id: "save-to-trackeep",
|
||||
title: "Save to Trackeep",
|
||||
contexts: ["page", "link", "selection", "image", "video"],
|
||||
});
|
||||
|
||||
browser.contextMenus.create({
|
||||
id: "quick-save-to-trackeep",
|
||||
title: "Quick Save to Trackeep",
|
||||
contexts: ["page"],
|
||||
});
|
||||
});
|
||||
|
||||
browser.contextMenus.onClicked.addListener(async (info, tab) => {
|
||||
if (info.menuItemId !== "save-to-trackeep" && info.menuItemId !== "quick-save-to-trackeep") {
|
||||
return;
|
||||
}
|
||||
|
||||
const smartData = await detectContentType(info, tab);
|
||||
const url = info.linkUrl || info.srcUrl || tab?.url || "";
|
||||
const title = tab?.title || "";
|
||||
const selection = info.selectionText || "";
|
||||
|
||||
await openPopupWithContext({
|
||||
url,
|
||||
title,
|
||||
selection,
|
||||
timestamp: Date.now(),
|
||||
isQuickSave: info.menuItemId === "quick-save-to-trackeep",
|
||||
smartData,
|
||||
});
|
||||
});
|
||||
|
||||
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (!message || !message.type) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
if (message.type === "trackeep:youtube-video-detected") {
|
||||
return shouldPromptForYouTubeVideo(message.video);
|
||||
}
|
||||
|
||||
if (message.type === "trackeep:youtube-dismissed") {
|
||||
if (message.videoId) {
|
||||
await updateYouTubeHistory(message.videoId, { dismissedAt: Date.now() });
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
if (message.type === "trackeep:youtube-save-request") {
|
||||
return saveYouTubeBookmark(message.video);
|
||||
}
|
||||
|
||||
if (message.type === "trackeep:youtube-open-saver") {
|
||||
const tab = sender && sender.tab ? sender.tab : null;
|
||||
const video = parseYouTubeVideoMeta(message.video && message.video.url, message.video && message.video.title);
|
||||
if (!tab || !video) {
|
||||
return { ok: false, error: "Could not open saver for this video." };
|
||||
}
|
||||
|
||||
const smartData = {
|
||||
type: "video",
|
||||
platform: "youtube",
|
||||
suggestedTags: ["youtube", "video"],
|
||||
};
|
||||
|
||||
return openPopupWithContext({
|
||||
url: video.url,
|
||||
title: video.title,
|
||||
selection: "",
|
||||
timestamp: Date.now(),
|
||||
isQuickSave: false,
|
||||
smartData,
|
||||
});
|
||||
}
|
||||
|
||||
return undefined;
|
||||
})()
|
||||
.then((result) => sendResponse(result))
|
||||
.catch((error) => {
|
||||
sendResponse({
|
||||
ok: false,
|
||||
error: error && error.message ? error.message : "Unexpected extension error.",
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
async function detectContentType(info, tab) {
|
||||
const url = info.linkUrl || info.srcUrl || tab?.url || '';
|
||||
const title = tab?.title || '';
|
||||
|
||||
const url = info.linkUrl || info.srcUrl || tab?.url || "";
|
||||
const title = tab?.title || "";
|
||||
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const domain = urlObj.hostname.toLowerCase();
|
||||
|
||||
// Video detection
|
||||
if (url.includes('youtube.com/watch') || url.includes('youtu.be/')) {
|
||||
return {
|
||||
type: 'video',
|
||||
platform: 'youtube',
|
||||
suggestedTags: ['video', 'youtube', 'educational'],
|
||||
autoTitle: extractYouTubeTitle(url) || title
|
||||
};
|
||||
}
|
||||
|
||||
if (url.includes('vimeo.com') || url.includes('dailymotion.com')) {
|
||||
return {
|
||||
type: 'video',
|
||||
platform: domain.replace('.com', ''),
|
||||
suggestedTags: ['video', domain.replace('.com', '')]
|
||||
};
|
||||
}
|
||||
|
||||
// Social media detection
|
||||
if (domain.includes('twitter.com') || domain.includes('x.com')) {
|
||||
return {
|
||||
type: 'social',
|
||||
platform: 'twitter',
|
||||
suggestedTags: ['social', 'twitter', 'tweet']
|
||||
};
|
||||
}
|
||||
|
||||
if (domain.includes('linkedin.com')) {
|
||||
return {
|
||||
type: 'social',
|
||||
platform: 'linkedin',
|
||||
suggestedTags: ['social', 'linkedin', 'professional']
|
||||
};
|
||||
}
|
||||
|
||||
if (domain.includes('reddit.com')) {
|
||||
return {
|
||||
type: 'social',
|
||||
platform: 'reddit',
|
||||
suggestedTags: ['social', 'reddit', 'discussion']
|
||||
};
|
||||
}
|
||||
|
||||
// Development platforms
|
||||
if (domain.includes('github.com')) {
|
||||
return {
|
||||
type: 'code',
|
||||
platform: 'github',
|
||||
suggestedTags: ['code', 'github', 'development', 'repository']
|
||||
};
|
||||
}
|
||||
|
||||
if (domain.includes('stackoverflow.com')) {
|
||||
return {
|
||||
type: 'code',
|
||||
platform: 'stackoverflow',
|
||||
suggestedTags: ['code', 'stackoverflow', 'programming', 'qa']
|
||||
};
|
||||
}
|
||||
|
||||
if (domain.includes('medium.com')) {
|
||||
return {
|
||||
type: 'article',
|
||||
platform: 'medium',
|
||||
suggestedTags: ['article', 'blog', 'medium']
|
||||
};
|
||||
}
|
||||
|
||||
// Documentation
|
||||
if (domain.includes('docs.') || domain.includes('documentation')) {
|
||||
return {
|
||||
type: 'documentation',
|
||||
suggestedTags: ['documentation', 'docs', 'reference']
|
||||
};
|
||||
}
|
||||
|
||||
// News sites
|
||||
if (domain.includes('news.') || domain.includes('cnn.com') || domain.includes('bbc.com') ||
|
||||
domain.includes('reuters.com') || domain.includes('washingtonpost.com')) {
|
||||
return {
|
||||
type: 'news',
|
||||
suggestedTags: ['news', 'article', 'current-events']
|
||||
};
|
||||
}
|
||||
|
||||
// E-commerce
|
||||
if (domain.includes('amazon.com') || domain.includes('ebay.com') ||
|
||||
domain.includes('shopify.com') || domain.includes('etsy.com')) {
|
||||
return {
|
||||
type: 'shopping',
|
||||
suggestedTags: ['shopping', 'product', 'ecommerce']
|
||||
};
|
||||
}
|
||||
|
||||
// Default detection
|
||||
return {
|
||||
type: 'general',
|
||||
suggestedTags: ['bookmark', 'webpage']
|
||||
};
|
||||
|
||||
} catch (e) {
|
||||
return {
|
||||
type: 'general',
|
||||
suggestedTags: ['bookmark', 'webpage']
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Extract YouTube video title
|
||||
function extractYouTubeTitle(url) {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const videoId = urlObj.searchParams.get('v');
|
||||
if (videoId) {
|
||||
// In a real implementation, you might fetch YouTube API
|
||||
// For now, return null and let the page title be used
|
||||
return null;
|
||||
const ytMeta = parseYouTubeVideoMeta(url, title);
|
||||
if (ytMeta) {
|
||||
return {
|
||||
type: "video",
|
||||
platform: "youtube",
|
||||
suggestedTags: ["video", "youtube"],
|
||||
autoTitle: ytMeta.title,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
return null;
|
||||
|
||||
if (url.includes("vimeo.com") || url.includes("dailymotion.com")) {
|
||||
return {
|
||||
type: "video",
|
||||
platform: domain.replace(".com", ""),
|
||||
suggestedTags: ["video", domain.replace(".com", "")],
|
||||
};
|
||||
}
|
||||
|
||||
if (domain.includes("twitter.com") || domain.includes("x.com")) {
|
||||
return {
|
||||
type: "social",
|
||||
platform: "twitter",
|
||||
suggestedTags: ["social", "twitter", "tweet"],
|
||||
};
|
||||
}
|
||||
|
||||
if (domain.includes("linkedin.com")) {
|
||||
return {
|
||||
type: "social",
|
||||
platform: "linkedin",
|
||||
suggestedTags: ["social", "linkedin", "professional"],
|
||||
};
|
||||
}
|
||||
|
||||
if (domain.includes("reddit.com")) {
|
||||
return {
|
||||
type: "social",
|
||||
platform: "reddit",
|
||||
suggestedTags: ["social", "reddit", "discussion"],
|
||||
};
|
||||
}
|
||||
|
||||
if (domain.includes("github.com")) {
|
||||
return {
|
||||
type: "code",
|
||||
platform: "github",
|
||||
suggestedTags: ["code", "github", "development", "repository"],
|
||||
};
|
||||
}
|
||||
|
||||
if (domain.includes("stackoverflow.com")) {
|
||||
return {
|
||||
type: "code",
|
||||
platform: "stackoverflow",
|
||||
suggestedTags: ["code", "stackoverflow", "programming", "qa"],
|
||||
};
|
||||
}
|
||||
|
||||
if (domain.includes("medium.com")) {
|
||||
return {
|
||||
type: "article",
|
||||
platform: "medium",
|
||||
suggestedTags: ["article", "blog", "medium"],
|
||||
};
|
||||
}
|
||||
|
||||
if (domain.includes("docs.") || domain.includes("documentation")) {
|
||||
return {
|
||||
type: "documentation",
|
||||
suggestedTags: ["documentation", "docs", "reference"],
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
domain.includes("news.") ||
|
||||
domain.includes("cnn.com") ||
|
||||
domain.includes("bbc.com") ||
|
||||
domain.includes("reuters.com") ||
|
||||
domain.includes("washingtonpost.com")
|
||||
) {
|
||||
return {
|
||||
type: "news",
|
||||
suggestedTags: ["news", "article", "current-events"],
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
domain.includes("amazon.com") ||
|
||||
domain.includes("ebay.com") ||
|
||||
domain.includes("shopify.com") ||
|
||||
domain.includes("etsy.com")
|
||||
) {
|
||||
return {
|
||||
type: "shopping",
|
||||
suggestedTags: ["shopping", "product", "ecommerce"],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "general",
|
||||
suggestedTags: ["bookmark", "webpage"],
|
||||
};
|
||||
} catch (_) {
|
||||
return {
|
||||
type: "general",
|
||||
suggestedTags: ["bookmark", "webpage"],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,20 @@
|
||||
"host_permissions": [
|
||||
"<all_urls>"
|
||||
],
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"*://www.youtube.com/*",
|
||||
"*://youtube.com/*",
|
||||
"*://m.youtube.com/*",
|
||||
"*://youtu.be/*"
|
||||
],
|
||||
"js": [
|
||||
"youtube-content.js"
|
||||
],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
],
|
||||
"commands": {
|
||||
"quick-save": {
|
||||
"suggested_key": {
|
||||
|
||||
@@ -847,13 +847,23 @@
|
||||
More secure than JWT tokens, revocable anytime
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="youtubeAutoPrompt">YouTube Auto Prompt</label>
|
||||
<div class="input-help">
|
||||
<label style="display: inline-flex; align-items: center; gap: 8px; cursor: pointer; text-transform: none; letter-spacing: normal; margin: 0;">
|
||||
<input type="checkbox" id="youtubeAutoPrompt" style="width: 16px; height: 16px;" />
|
||||
<span>Ask to save when a YouTube video is detected</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="connectionStatus" class="connection-status" style="display: none;">
|
||||
<div class="status-content">
|
||||
<img src="https://www.svgrepo.com/show/448375/connection-gateway.svg" alt="Status" class="icon" style="width: 16px; height: 16px;" />
|
||||
<div>
|
||||
<strong id="statusTitle">Testing Connection...</strong>
|
||||
<p id="statusMessage">Please wait</p>
|
||||
<p id="connectionStatusMessage">Please wait</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,13 +7,14 @@ if (typeof browser === 'undefined' && typeof chrome !== 'undefined') {
|
||||
|
||||
const apiBaseUrlInput = document.getElementById('trackeepApiUrl');
|
||||
const apiKeyInput = document.getElementById('trackeepApiKey');
|
||||
const youtubeAutoPromptInput = document.getElementById('youtubeAutoPrompt');
|
||||
const testConnectionBtn = document.getElementById('testConnectionBtn');
|
||||
const generateKeyBtn = document.getElementById('generateKeyBtn');
|
||||
const saveBtn = document.getElementById('saveBtn');
|
||||
const statusMessageEl = document.getElementById('statusMessage');
|
||||
const connectionStatusEl = document.getElementById('connectionStatus');
|
||||
const statusTitleEl = document.getElementById('statusTitle');
|
||||
const statusTextEl = document.getElementById('statusMessage');
|
||||
const statusTextEl = document.getElementById('connectionStatusMessage');
|
||||
const installWelcomeEl = document.getElementById('installWelcome');
|
||||
const mainOptionsEl = document.getElementById('mainOptions');
|
||||
|
||||
@@ -99,7 +100,13 @@ function detectAndPrefillApiBaseUrl(callback) {
|
||||
}
|
||||
|
||||
function loadSettings() {
|
||||
browser.storage.sync.get(['trackeepApiBaseUrl', 'trackeepApiKey', 'isFirstInstall'], (items) => {
|
||||
browser.storage.sync.get([
|
||||
'trackeepApiBaseUrl',
|
||||
'trackeepApiKey',
|
||||
'trackeepAuthToken',
|
||||
'youtubeAutoPrompt',
|
||||
'isFirstInstall'
|
||||
], (items) => {
|
||||
// Handle first-time install
|
||||
if (items.isFirstInstall) {
|
||||
installWelcomeEl.style.display = 'flex';
|
||||
@@ -113,8 +120,13 @@ function loadSettings() {
|
||||
if (items.trackeepApiBaseUrl) {
|
||||
apiBaseUrlInput.value = items.trackeepApiBaseUrl;
|
||||
}
|
||||
if (items.trackeepApiKey) {
|
||||
apiKeyInput.value = items.trackeepApiKey;
|
||||
if (items.trackeepApiKey || items.trackeepAuthToken) {
|
||||
apiKeyInput.value = items.trackeepApiKey || items.trackeepAuthToken;
|
||||
}
|
||||
if (youtubeAutoPromptInput) {
|
||||
youtubeAutoPromptInput.checked = typeof items.youtubeAutoPrompt === 'boolean'
|
||||
? items.youtubeAutoPrompt
|
||||
: true;
|
||||
}
|
||||
|
||||
// Auto-detect API URL if empty
|
||||
@@ -126,20 +138,16 @@ function loadSettings() {
|
||||
|
||||
function saveSettings() {
|
||||
const apiBaseUrl = apiBaseUrlInput.value.trim();
|
||||
const apiKey = apiKeyInput.value.trim();
|
||||
const token = apiKeyInput.value.trim();
|
||||
const youtubeAutoPrompt = youtubeAutoPromptInput ? !!youtubeAutoPromptInput.checked : true;
|
||||
|
||||
if (!apiBaseUrl) {
|
||||
showMessage('API base URL is required.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
showMessage('API key is required.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!apiKey.startsWith('tk_')) {
|
||||
showMessage('API key should start with "tk_"', 'error');
|
||||
if (!token) {
|
||||
showMessage('API key or token is required.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -148,7 +156,9 @@ function saveSettings() {
|
||||
|
||||
browser.storage.sync.set({
|
||||
trackeepApiBaseUrl: apiBaseUrl,
|
||||
trackeepApiKey: apiKey,
|
||||
trackeepApiKey: token,
|
||||
trackeepAuthToken: token,
|
||||
youtubeAutoPrompt,
|
||||
isFirstInstall: false
|
||||
}, () => {
|
||||
setButtonLoading(saveBtn, false);
|
||||
@@ -156,38 +166,55 @@ function saveSettings() {
|
||||
});
|
||||
}
|
||||
|
||||
async function validateConnectionToken(apiBaseUrl, token) {
|
||||
const base = apiBaseUrl.replace(/\/$/, '');
|
||||
const isApiKey = token.startsWith('tk_');
|
||||
const endpoint = isApiKey ? `${base}/browser-extension/validate` : `${base}/auth/me`;
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
let payload = {};
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch (_) {
|
||||
// Keep empty payload for non-JSON responses.
|
||||
}
|
||||
|
||||
return { isApiKey, payload };
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
const apiBaseUrl = apiBaseUrlInput.value.trim();
|
||||
const apiKey = apiKeyInput.value.trim();
|
||||
const token = apiKeyInput.value.trim();
|
||||
|
||||
if (!apiBaseUrl || !apiKey) {
|
||||
showConnectionStatus('Connection Failed', 'Please enter both URL and API key', 'error');
|
||||
if (!apiBaseUrl || !token) {
|
||||
showConnectionStatus('Connection Failed', 'Please enter both URL and token', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
showConnectionStatus('Testing Connection', 'Connecting to your Trackeep instance...', 'info');
|
||||
|
||||
try {
|
||||
const base = apiBaseUrl.replace(/\/$/, '');
|
||||
const response = await fetch(`${base}/auth/me`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
showConnectionStatus('Connection Successful', `Connected as ${data.username || 'user'}. API key is valid!`, 'success');
|
||||
|
||||
// Hide success message after 3 seconds
|
||||
setTimeout(() => {
|
||||
hideConnectionStatus();
|
||||
}, 3000);
|
||||
} else {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
const result = await validateConnectionToken(apiBaseUrl, token);
|
||||
const identity = result.payload && result.payload.username ? result.payload.username : 'user';
|
||||
showConnectionStatus(
|
||||
'Connection Successful',
|
||||
`Connected as ${identity}. ${result.isApiKey ? 'API key' : 'Token'} is valid.`,
|
||||
'success'
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
hideConnectionStatus();
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
showConnectionStatus('Connection Failed', `Error: ${error.message}`, 'error');
|
||||
}
|
||||
@@ -290,40 +317,30 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// Test connection from setup form
|
||||
async function testSetupConnection() {
|
||||
const apiBaseUrl = document.getElementById('setupApiUrl').value.trim();
|
||||
const apiKey = document.getElementById('setupApiKey').value.trim();
|
||||
const token = document.getElementById('setupApiKey').value.trim();
|
||||
|
||||
if (!apiBaseUrl || !apiKey) {
|
||||
showSetupConnectionStatus('Connection Failed', 'Please enter both URL and API key', 'error');
|
||||
if (!apiBaseUrl || !token) {
|
||||
showSetupConnectionStatus('Connection Failed', 'Please enter both URL and token', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
showSetupConnectionStatus('Testing Connection', 'Connecting to your Trackeep instance...', 'info');
|
||||
|
||||
try {
|
||||
const base = apiBaseUrl.replace(/\/$/, '');
|
||||
const response = await fetch(`${base}/auth/me`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
showSetupConnectionStatus('Connection Successful', `Connected as ${data.username || 'user'}. API key is valid!`, 'success');
|
||||
|
||||
// Copy values to main form
|
||||
document.getElementById('trackeepApiUrl').value = apiBaseUrl;
|
||||
document.getElementById('trackeepApiKey').value = apiKey;
|
||||
|
||||
// Hide success message after 3 seconds
|
||||
setTimeout(() => {
|
||||
hideSetupConnectionStatus();
|
||||
}, 3000);
|
||||
} else {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
const result = await validateConnectionToken(apiBaseUrl, token);
|
||||
const identity = result.payload && result.payload.username ? result.payload.username : 'user';
|
||||
showSetupConnectionStatus(
|
||||
'Connection Successful',
|
||||
`Connected as ${identity}. ${result.isApiKey ? 'API key' : 'Token'} is valid.`,
|
||||
'success'
|
||||
);
|
||||
|
||||
document.getElementById('trackeepApiUrl').value = apiBaseUrl;
|
||||
document.getElementById('trackeepApiKey').value = token;
|
||||
|
||||
setTimeout(() => {
|
||||
hideSetupConnectionStatus();
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
showSetupConnectionStatus('Connection Failed', `Error: ${error.message}`, 'error');
|
||||
}
|
||||
@@ -332,17 +349,19 @@ async function testSetupConnection() {
|
||||
// Complete setup
|
||||
function completeSetup() {
|
||||
const apiBaseUrl = document.getElementById('setupApiUrl').value.trim();
|
||||
const apiKey = document.getElementById('setupApiKey').value.trim();
|
||||
const token = document.getElementById('setupApiKey').value.trim();
|
||||
|
||||
if (!apiBaseUrl || !apiKey) {
|
||||
showMessage('Please fill in both URL and API key', 'error');
|
||||
if (!apiBaseUrl || !token) {
|
||||
showMessage('Please fill in both URL and token', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Save settings
|
||||
browser.storage.sync.set({
|
||||
trackeepApiBaseUrl: apiBaseUrl,
|
||||
trackeepApiKey: apiKey,
|
||||
trackeepApiKey: token,
|
||||
trackeepAuthToken: token,
|
||||
youtubeAutoPrompt: true,
|
||||
isFirstInstall: false
|
||||
}, () => {
|
||||
showMessage('Setup completed successfully!', 'success');
|
||||
@@ -353,7 +372,7 @@ function completeSetup() {
|
||||
|
||||
// Load settings in main form
|
||||
document.getElementById('trackeepApiUrl').value = apiBaseUrl;
|
||||
document.getElementById('trackeepApiKey').value = apiKey;
|
||||
document.getElementById('trackeepApiKey').value = token;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+255
-811
File diff suppressed because it is too large
Load Diff
+27
-28
@@ -125,9 +125,9 @@ function disableForms(disabled) {
|
||||
}
|
||||
|
||||
function loadConfig(callback) {
|
||||
browser.storage.sync.get(['trackeepApiBaseUrl', 'trackeepAuthToken'], (items) => {
|
||||
browser.storage.sync.get(['trackeepApiBaseUrl', 'trackeepApiKey', 'trackeepAuthToken'], (items) => {
|
||||
const apiBaseUrl = (items.trackeepApiBaseUrl || '').trim();
|
||||
const authToken = (items.trackeepAuthToken || '').trim();
|
||||
const authToken = (items.trackeepApiKey || items.trackeepAuthToken || '').trim();
|
||||
|
||||
trackeepConfig = { apiBaseUrl, authToken };
|
||||
|
||||
@@ -158,7 +158,7 @@ function detectTrackeepDomain(callback) {
|
||||
try {
|
||||
const url = new URL(tab.url);
|
||||
const isTrackeepDomain = url.hostname.includes('trackeep') || url.hostname === 'localhost';
|
||||
if (isTrackeepDomain && url.protocol === 'https:') {
|
||||
if (isTrackeepDomain && (url.protocol === 'https:' || url.protocol === 'http:')) {
|
||||
const candidate = `${url.origin}/api/v1`;
|
||||
browser.storage.sync.get(['trackeepApiBaseUrl'], (items) => {
|
||||
if (!items.trackeepApiBaseUrl) {
|
||||
@@ -305,23 +305,30 @@ function addSuggestedTag(tag) {
|
||||
|
||||
// Handle quick save
|
||||
function handleQuickSave() {
|
||||
if (isQuickSaveMode && smartData) {
|
||||
// Auto-fill with smart data and save immediately
|
||||
if (smartData.suggestedTags && !bookmarkTagsInput.value) {
|
||||
bookmarkTagsInput.value = smartData.suggestedTags.join(', ');
|
||||
}
|
||||
|
||||
// Auto-save after a short delay
|
||||
// Auto-fill with smart data where available.
|
||||
if (smartData && smartData.suggestedTags && !bookmarkTagsInput.value) {
|
||||
bookmarkTagsInput.value = smartData.suggestedTags.join(', ');
|
||||
}
|
||||
|
||||
const hasRequiredFields = bookmarkUrlInput.value.trim() && bookmarkTitleInput.value.trim();
|
||||
if (!hasRequiredFields) {
|
||||
showMessage('URL and title are required before quick save.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isQuickSaveMode) {
|
||||
setTimeout(() => {
|
||||
if (bookmarkUrlInput.value && bookmarkTitleInput.value) {
|
||||
saveBookmark(new Event('submit'));
|
||||
}
|
||||
}, 500);
|
||||
saveBookmark();
|
||||
}, 350);
|
||||
} else {
|
||||
saveBookmark();
|
||||
}
|
||||
}
|
||||
|
||||
async function saveBookmark(event) {
|
||||
event.preventDefault();
|
||||
if (event && typeof event.preventDefault === 'function') {
|
||||
event.preventDefault();
|
||||
}
|
||||
hideMessage();
|
||||
|
||||
const { apiBaseUrl, authToken } = trackeepConfig;
|
||||
@@ -380,12 +387,7 @@ async function saveBookmark(event) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
showMessage(`
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="20,6 9,17 4,12"/>
|
||||
</svg>
|
||||
Bookmark saved successfully!
|
||||
`, 'success');
|
||||
showMessage('Bookmark saved successfully!', 'success');
|
||||
|
||||
// Clear form after successful save
|
||||
setTimeout(() => {
|
||||
@@ -403,7 +405,9 @@ async function saveBookmark(event) {
|
||||
}
|
||||
|
||||
async function uploadFile(event) {
|
||||
event.preventDefault();
|
||||
if (event && typeof event.preventDefault === 'function') {
|
||||
event.preventDefault();
|
||||
}
|
||||
hideMessage();
|
||||
|
||||
const { apiBaseUrl, authToken } = trackeepConfig;
|
||||
@@ -452,12 +456,7 @@ async function uploadFile(event) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
showMessage(`
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="20,6 9,17 4,12"/>
|
||||
</svg>
|
||||
File uploaded successfully!
|
||||
`, 'success');
|
||||
showMessage('File uploaded successfully!', 'success');
|
||||
|
||||
// Clear form after successful upload
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
/* global chrome, browser */
|
||||
|
||||
if (typeof browser === "undefined" && typeof chrome !== "undefined") {
|
||||
browser = chrome;
|
||||
}
|
||||
|
||||
const PROMPT_HOST_ID = "__trackeepYoutubePromptHost";
|
||||
let lastNotifiedVideoId = "";
|
||||
let lastUrl = "";
|
||||
|
||||
function parseYouTubeVideo(url, title) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
const path = parsed.pathname || "";
|
||||
let videoId = "";
|
||||
|
||||
if (host === "youtu.be") {
|
||||
videoId = path.slice(1).split("/")[0];
|
||||
} else if (path.startsWith("/watch")) {
|
||||
videoId = parsed.searchParams.get("v") || "";
|
||||
} else if (path.startsWith("/shorts/")) {
|
||||
videoId = path.split("/")[2] || "";
|
||||
} else if (path.startsWith("/embed/")) {
|
||||
videoId = path.split("/")[2] || "";
|
||||
}
|
||||
|
||||
if (!videoId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cleanedTitle = (title || "").replace(/\s*-\s*YouTube$/i, "").trim();
|
||||
|
||||
return {
|
||||
videoId,
|
||||
url: `https://www.youtube.com/watch?v=${videoId}`,
|
||||
title: cleanedTitle || "YouTube video",
|
||||
};
|
||||
}
|
||||
|
||||
function sendMessage(message, callback) {
|
||||
try {
|
||||
browser.runtime.sendMessage(message, callback);
|
||||
} catch (_) {
|
||||
if (typeof callback === "function") {
|
||||
callback(undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closePrompt() {
|
||||
const existing = document.getElementById(PROMPT_HOST_ID);
|
||||
if (existing) {
|
||||
existing.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function renderPrompt(video) {
|
||||
closePrompt();
|
||||
|
||||
const host = document.createElement("div");
|
||||
host.id = PROMPT_HOST_ID;
|
||||
const shadow = host.attachShadow({ mode: "open" });
|
||||
|
||||
shadow.innerHTML = `
|
||||
<style>
|
||||
:host {
|
||||
all: initial;
|
||||
}
|
||||
.card {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
width: 320px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: linear-gradient(180deg, rgba(17, 20, 31, 0.96) 0%, rgba(9, 11, 18, 0.98) 100%);
|
||||
color: #e9edf8;
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.38);
|
||||
padding: 14px;
|
||||
font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
backdrop-filter: blur(18px);
|
||||
z-index: 2147483647;
|
||||
transform: translateY(-10px);
|
||||
opacity: 0;
|
||||
animation: trackeep-slide-in 180ms ease-out forwards;
|
||||
}
|
||||
@keyframes trackeep-slide-in {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 999px;
|
||||
padding: 4px 9px;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
color: #b5c0db;
|
||||
}
|
||||
h3 {
|
||||
margin: 10px 0 6px;
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
color: #f8faff;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: #b5c0db;
|
||||
}
|
||||
.actions {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.primary {
|
||||
background: linear-gradient(135deg, #4d7dff 0%, #3859f8 100%);
|
||||
color: #ffffff;
|
||||
flex: 1;
|
||||
}
|
||||
.secondary {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #e9edf8;
|
||||
}
|
||||
.ghost {
|
||||
background: transparent;
|
||||
color: #9fa9c8;
|
||||
}
|
||||
.status {
|
||||
margin-top: 10px;
|
||||
min-height: 17px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.status.error {
|
||||
color: #ff8e8e;
|
||||
}
|
||||
.status.ok {
|
||||
color: #73e2b8;
|
||||
}
|
||||
.truncate {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
<div class="card">
|
||||
<span class="tag">Trackeep · YouTube</span>
|
||||
<h3>Save this video to your Trackeep?</h3>
|
||||
<p class="truncate" title="${escapeHtml(video.title)}">${escapeHtml(video.title)}</p>
|
||||
<div class="actions">
|
||||
<button class="primary" id="saveNow">Save now</button>
|
||||
<button class="secondary" id="openSaver">Open saver</button>
|
||||
<button class="ghost" id="later">Later</button>
|
||||
</div>
|
||||
<div class="status" id="status"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.documentElement.appendChild(host);
|
||||
|
||||
const saveBtn = shadow.getElementById("saveNow");
|
||||
const openBtn = shadow.getElementById("openSaver");
|
||||
const laterBtn = shadow.getElementById("later");
|
||||
const statusEl = shadow.getElementById("status");
|
||||
|
||||
const setStatus = (message, type) => {
|
||||
statusEl.textContent = message || "";
|
||||
statusEl.className = `status${type ? ` ${type}` : ""}`;
|
||||
};
|
||||
|
||||
saveBtn.addEventListener("click", () => {
|
||||
saveBtn.disabled = true;
|
||||
openBtn.disabled = true;
|
||||
laterBtn.disabled = true;
|
||||
setStatus("Saving to Trackeep...", "");
|
||||
|
||||
sendMessage({ type: "trackeep:youtube-save-request", video }, (response) => {
|
||||
if (browser.runtime && browser.runtime.lastError) {
|
||||
setStatus("Could not reach extension background worker.", "error");
|
||||
} else if (response && response.ok) {
|
||||
setStatus("Saved successfully.", "ok");
|
||||
setTimeout(closePrompt, 1200);
|
||||
} else {
|
||||
setStatus(
|
||||
response && response.error
|
||||
? response.error
|
||||
: "Save failed. Open saver to review.",
|
||||
"error"
|
||||
);
|
||||
saveBtn.disabled = false;
|
||||
openBtn.disabled = false;
|
||||
laterBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
openBtn.addEventListener("click", () => {
|
||||
sendMessage({ type: "trackeep:youtube-open-saver", video }, () => {
|
||||
closePrompt();
|
||||
});
|
||||
});
|
||||
|
||||
laterBtn.addEventListener("click", () => {
|
||||
sendMessage({ type: "trackeep:youtube-dismissed", videoId: video.videoId }, () => {
|
||||
closePrompt();
|
||||
});
|
||||
});
|
||||
|
||||
window.setTimeout(() => {
|
||||
closePrompt();
|
||||
}, 18000);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function detectAndNotify(force) {
|
||||
const video = parseYouTubeVideo(window.location.href, document.title);
|
||||
if (!video) {
|
||||
closePrompt();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!force && video.videoId === lastNotifiedVideoId && window.location.href === lastUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastNotifiedVideoId = video.videoId;
|
||||
lastUrl = window.location.href;
|
||||
|
||||
sendMessage({ type: "trackeep:youtube-video-detected", video }, (response) => {
|
||||
if (browser.runtime && browser.runtime.lastError) {
|
||||
return;
|
||||
}
|
||||
if (response && response.showPrompt) {
|
||||
renderPrompt(video);
|
||||
} else {
|
||||
closePrompt();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initDetection() {
|
||||
let previousHref = window.location.href;
|
||||
|
||||
const check = () => {
|
||||
if (window.location.href !== previousHref) {
|
||||
previousHref = window.location.href;
|
||||
detectAndNotify(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("yt-navigate-finish", () => detectAndNotify(true), true);
|
||||
window.addEventListener("popstate", () => detectAndNotify(true), true);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden) {
|
||||
detectAndNotify(false);
|
||||
}
|
||||
});
|
||||
|
||||
const observer = new MutationObserver(check);
|
||||
observer.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
window.setInterval(check, 1200);
|
||||
detectAndNotify(true);
|
||||
}
|
||||
|
||||
initDetection();
|
||||
Reference in New Issue
Block a user