diff --git a/background.js b/background.js index ec876e3..daae448 100644 --- a/background.js +++ b/background.js @@ -1,14 +1,16 @@ /** - * ReddJSON Background Service Worker v2.0 + * ReddJSON Background Service Worker v2.1 * ═══════════════════════════════════════════════════════════════════ * Handles: * 1. Reddit .json API fetching * 2. AI provider calls (OpenRouter, Groq — OpenAI-compatible) * 3. chrome.storage.local for history, AI posts, settings * 4. Side panel management (open on action click, Reddit-only) + * 5. Keyboard shortcut commands + * 6. JSON filtering (post-only vs full thread) + * 7. Storage size optimization * - * @fileoverview Service worker — the brain of ReddJSON - * @version 2.0.0 + * @version 2.1.0 */ // ============================================================================ @@ -17,10 +19,15 @@ const MAX_HISTORY_ENTRIES = 50; const MAX_AI_POSTS = 50; -const USER_AGENT = 'ReddJSON/2.0.0 (Chrome Extension)'; +const MAX_JSON_STORAGE_BYTES = 500_000; +const USER_AGENT = 'ReddJSON/2.1.0 (Chrome Extension)'; -/** Default system prompt for LinkedIn post generation */ -const DEFAULT_SYSTEM_PROMPT = `You are a world-class LinkedIn content strategist and viral post creator with 10+ years of experience generating posts that regularly hit 100k–1M+ impressions. +/** Default system prompt templates for LinkedIn post generation */ +const PROMPT_TEMPLATES = [ + { + id: 'default', + name: 'LinkedIn Viral Staircase', + prompt: `You are a world-class LinkedIn content strategist and viral post creator with 10+ years of experience generating posts that regularly hit 100k–1M+ impressions. Your mission: Transform the entire Reddit thread (full JSON data) into ONE highly engaging, professional LinkedIn post in "staircase format" (numbered or bulleted ladder style that feels like a story and keeps people scrolling). @@ -33,7 +40,82 @@ Rules (NEVER break these): 6. Length: 300–650 words (perfect LinkedIn sweet spot). 7. If the Reddit post has images, videos, or media, mention at the end: "Attach this visual: [image URL]" so the user knows exactly what to upload. -Here is the full Reddit thread JSON:`; +Here is the full Reddit thread JSON:`, + isDefault: true + }, + { + id: 'storytelling', + name: 'Narrative Storyteller', + prompt: `You are a master business storyteller. Your goal is to transform the provided Reddit thread JSON into a highly engaging, story-driven LinkedIn post. + +Structure your narrative using this format: +1. The Hook: Introduce a struggle, conflict, or high-stakes scenario immediately. +2. The Background: Briefly explain the situation. +3. The Pivot/Insight: Introduce the central learning or realization gained from the Reddit discussion/comments. +4. The Takeaways: Deliver 3 core lessons formatted as short, punchy paragraphs. +5. The Outro: Ask a thought-provoking question to drive comments. + +Rules: +- Keep paragraph length to a maximum of 2 sentences for high readability. +- Write with a natural, conversational, humble yet authoritative voice (first-person "I" or third-person storytelling). +- No emojis, no markdown formatting. +- Avoid business jargon or cliché corporate speak. +- Length: 250-450 words. +- If the Reddit post has images, videos, or media, mention at the end: "Attach this visual: [image URL]" so the user knows exactly what to upload. + +Here is the full Reddit thread JSON:`, + isDefault: true + }, + { + id: 'playbook', + name: 'Actionable Playbook (How-to)', + prompt: `You are a professional educational content creator on LinkedIn. Your mission is to extract the practical knowledge, tips, tools, and steps from this Reddit thread JSON and package it into an actionable "How-To" Playbook or Masterclass. + +Structure of the Playbook: +1. Hook: State the desired outcome and the problem being solved (e.g., "How to achieve X without doing Y"). +2. The Challenge: Why most people fail at this. +3. The Step-by-Step Playbook: 3-5 clear, sequentially numbered steps based on the Reddit post and top comments. Each step should have: + - A bold/clear header. + - 1-2 lines explaining "why" it matters. + - 1 bullet point explaining "how" to do it. +4. The Result: What happens when they follow this. +5. CTA: Encourage users to bookmark/save the post or comment their own tips. + +Rules: +- Extremely concise, high signal-to-noise ratio. +- Use plain text. Use symbols like → or • for bullets. +- Length: 300-500 words. +- If the Reddit post has images, videos, or media, mention at the end: "Attach this visual: [image URL]" so the user knows exactly what to upload. + +Here is the full Reddit thread JSON:`, + isDefault: true + }, + { + id: 'contrarian', + name: 'Contrarian Debate / Discussion', + prompt: `You are a critical thinker and LinkedIn thought leader who loves exploring nuances. Take the Reddit thread JSON and construct a LinkedIn post that highlights a debate, a common misconception, or a contrarian perspective found in the thread. + +Structure: +1. The Hook: Challenge a common belief or highlight a paradox (e.g., "Most people think X. But the reality is Y."). +2. The Debate: Show the tension or contrasting views expressed in the Reddit thread (e.g., "View A argues X, but View B points out Y"). +3. The Nuance: Explain the middle ground or the non-obvious truth that people miss. +4. The Takeaway: How the reader can apply this critical perspective to their work or life. +5. CTA: Ask the audience where they stand on this debate. + +Rules: +- Tone should be respectful, intellectually curious, and engaging. +- Do not take a rigid side; guide the reader to think. +- Use clean formatting with line breaks. +- No markdown, minimal emojis. +- Length: 250-400 words. +- If the Reddit post has images, videos, or media, mention at the end: "Attach this visual: [image URL]" so the user knows exactly what to upload. + +Here is the full Reddit thread JSON:`, + isDefault: true + } +]; + +const DEFAULT_SYSTEM_PROMPT = PROMPT_TEMPLATES[0].prompt; // Provider configurations const PROVIDERS = { @@ -113,6 +195,16 @@ async function fetchRedditJSON(permalink) { } } +function extractPostOnly(fullData) { + try { + if (!Array.isArray(fullData) || fullData.length === 0) return fullData; + const postListing = fullData[0]; + return [postListing]; + } catch { + return fullData; + } +} + // ============================================================================ // AI PROVIDER — FETCH MODELS // ============================================================================ @@ -298,6 +390,7 @@ async function addToHistory(entry) { const jsonString = JSON.stringify(entry.jsonData, null, 2); const jsonPreview = jsonString.substring(0, 300) + (jsonString.length > 300 ? '…' : ''); + const jsonSizeBytes = new Blob([jsonString]).size; const historyEntry = { id: `reddjson_${Date.now()}_${entry.postId}`, @@ -306,7 +399,8 @@ async function addToHistory(entry) { subreddit: entry.subreddit || 'unknown', postId: entry.postId || 'unknown', jsonPreview, - fullJson: entry.jsonData, + fullJson: jsonSizeBytes <= MAX_JSON_STORAGE_BYTES ? entry.jsonData : null, + jsonTruncated: jsonSizeBytes > MAX_JSON_STORAGE_BYTES, timestamp: Date.now(), copiedCount: 1 }; @@ -449,12 +543,24 @@ async function getSettings() { providers: {}, defaultProvider: '', defaultModel: '', - systemPrompts: [ - { id: 'default', name: 'LinkedIn Viral Post', prompt: DEFAULT_SYSTEM_PROMPT, isDefault: true } - ], + systemPrompts: PROMPT_TEMPLATES, activePromptId: 'default' }; - return { success: true, settings: { ...defaults, ...result.reddjson_settings } }; + + const settings = { ...defaults, ...result.reddjson_settings }; + + // Ensure all built-in templates are present in settings.systemPrompts + if (!settings.systemPrompts || settings.systemPrompts.length === 0) { + settings.systemPrompts = JSON.parse(JSON.stringify(PROMPT_TEMPLATES)); + } else { + PROMPT_TEMPLATES.forEach(tpl => { + if (!settings.systemPrompts.some(p => p.id === tpl.id)) { + settings.systemPrompts.push(JSON.parse(JSON.stringify(tpl))); + } + }); + } + + return { success: true, settings }; } catch (error) { return { success: false, error: error.message }; } @@ -479,8 +585,17 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { (async () => { switch (message.action) { // ── Reddit JSON ── - case 'fetchJSON': + case 'fetchJSON': { + const result = await fetchRedditJSON(message.permalink); + if (result.success && message.postOnly) { + result.data = extractPostOnly(result.data); + } + return result; + } + + case 'refetchJSON': { return await fetchRedditJSON(message.permalink); + } // ── History ── case 'addToHistory': @@ -592,7 +707,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { chrome.runtime.onInstalled.addListener((details) => { if (details.reason === 'install') { - console.log('[ReddJSON] 🎉 Extension installed!'); + console.log('[ReddJSON] Extension installed!'); chrome.storage.local.set({ reddjson_history: [], reddjson_ai_posts: [], @@ -600,15 +715,92 @@ chrome.runtime.onInstalled.addListener((details) => { providers: {}, defaultProvider: '', defaultModel: '', - systemPrompts: [ - { id: 'default', name: 'LinkedIn Viral Post', prompt: DEFAULT_SYSTEM_PROMPT, isDefault: true } - ], + systemPrompts: PROMPT_TEMPLATES, activePromptId: 'default' } }); } else if (details.reason === 'update') { - console.log('[ReddJSON] ⬆️ Updated to v' + chrome.runtime.getManifest().version); + console.log('[ReddJSON] Updated to v' + chrome.runtime.getManifest().version); + } +}); + +// ============================================================================ +// KEYBOARD SHORTCUTS +// ============================================================================ + +chrome.commands.onCommand.addListener(async (command) => { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tab?.url?.includes('reddit.com')) return; + + if (command === 'copy-json') { + const urlMatch = new URL(tab.url).pathname.match(/\/r\/[^/]+\/comments\/[^/]+/); + if (!urlMatch) return; + const permalink = urlMatch[0]; + + const result = await fetchRedditJSON(permalink); + if (!result.success) { + chrome.tabs.sendMessage(tab.id, { action: 'showToast', message: result.error, type: 'error' }); + return; + } + + const pretty = JSON.stringify(result.data, null, 2); + chrome.tabs.sendMessage(tab.id, { action: 'copyToClipboard', text: pretty }); + + const title = result.data?.[0]?.data?.children?.[0]?.data?.title || 'Untitled'; + const subreddit = result.data?.[0]?.data?.children?.[0]?.data?.subreddit || 'unknown'; + const postId = result.data?.[0]?.data?.children?.[0]?.data?.id || 'unknown'; + await addToHistory({ permalink, title, subreddit, postId, jsonData: result.data }); + } else if (command === 'generate-post') { + const urlMatch = new URL(tab.url).pathname.match(/\/r\/[^/]+\/comments\/[^/]+/); + if (!urlMatch) return; + const permalink = urlMatch[0]; + + const settings = (await getSettings()).settings; + if (!settings?.defaultProvider || !settings?.defaultModel) { + chrome.tabs.sendMessage(tab.id, { action: 'showToast', message: 'Set up AI provider in Settings first', type: 'error' }); + return; + } + + chrome.tabs.sendMessage(tab.id, { action: 'showToast', message: 'Generating LinkedIn post...', type: 'linkedin' }); + + const providerConfig = settings.providers?.[settings.defaultProvider]; + if (!providerConfig?.apiKey) { + chrome.tabs.sendMessage(tab.id, { action: 'showToast', message: 'No API key configured', type: 'error' }); + return; + } + + const jsonResult = await fetchRedditJSON(permalink); + if (!jsonResult.success) { + chrome.tabs.sendMessage(tab.id, { action: 'showToast', message: jsonResult.error, type: 'error' }); + return; + } + + const promptObj = settings.systemPrompts?.find(p => p.id === settings.activePromptId); + const systemPrompt = promptObj?.prompt || DEFAULT_SYSTEM_PROMPT; + const media = extractMediaFromRedditJson(jsonResult.data); + + const aiResult = await generateAIPost( + settings.defaultProvider, providerConfig.apiKey, settings.defaultModel, systemPrompt, jsonResult.data + ); + + if (!aiResult.success) { + chrome.tabs.sendMessage(tab.id, { action: 'showToast', message: aiResult.error, type: 'error' }); + return; + } + + const title = jsonResult.data?.[0]?.data?.children?.[0]?.data?.title || 'Untitled'; + const subreddit = jsonResult.data?.[0]?.data?.children?.[0]?.data?.subreddit || 'unknown'; + const postId = jsonResult.data?.[0]?.data?.children?.[0]?.data?.id || 'unknown'; + + await addAIPost({ + permalink, redditTitle: title, subreddit, postId, + generatedText: aiResult.text, model: aiResult.model, + provider: settings.defaultProvider, media, usage: aiResult.usage + }); + + chrome.tabs.sendMessage(tab.id, { action: 'showToast', message: 'LinkedIn post ready! Check sidebar', type: 'success' }); + try { await chrome.sidePanel.open({ tabId: tab.id }); } catch {} } }); -console.log('[ReddJSON] Background service worker v2.0 loaded ✓'); +console.log('[ReddJSON] Background service worker v2.1 loaded'); diff --git a/content.js b/content.js index 5f4cf44..1599e42 100644 --- a/content.js +++ b/content.js @@ -1,14 +1,17 @@ /** - * ReddJSON Content Script v2.0 + * ReddJSON Content Script v2.1 * ═══════════════════════════════════════════════════════════════════ * Injects "JSON" + "Post" buttons into Reddit action bars. - * + * * Duplicate prevention: * 1. data-reddjson-added attribute on post elements - * 2. Global Set of processed post IDs + * 2. Global Set of processed post IDs (capped at 500) * 3. Debounced MutationObserver * - * @version 2.0.0 + * v2.1: JSON filter menu (post-only vs full), keyboard shortcut + * message handling, processedPostIds cap. + * + * @version 2.1.0 */ // ============================================================================ @@ -78,7 +81,8 @@ const CONFIG = { observerDebounce: 200, redditOrange: '#FF4500', linkedinBlue: '#0A66C2', - markerAttr: 'data-reddjson-added' + markerAttr: 'data-reddjson-added', + maxProcessedIds: 500 }; // ============================================================================ @@ -88,6 +92,15 @@ const CONFIG = { const processedPostIds = new Set(); let isProcessing = false; +function trackPostId(id) { + if (!id) return; + if (processedPostIds.size >= CONFIG.maxProcessedIds) { + const first = processedPostIds.values().next().value; + processedPostIds.delete(first); + } + processedPostIds.add(id); +} + // ============================================================================ // UTILITIES // ============================================================================ @@ -299,7 +312,7 @@ function extractOldRedditPostData(el) { // CLICK HANDLERS // ============================================================================ -async function handleJsonCopy(e, btn, postData) { +async function handleJsonCopy(e, btn, postData, postOnly = false) { e.preventDefault(); e.stopPropagation(); if (btn.disabled) return; @@ -310,12 +323,16 @@ async function handleJsonCopy(e, btn, postData) { btn.style.cursor = 'wait'; try { - const resp = await chrome.runtime.sendMessage({ action: 'fetchJSON', permalink: postData.permalink }); + const resp = await chrome.runtime.sendMessage({ + action: 'fetchJSON', + permalink: postData.permalink, + postOnly + }); if (!resp?.success) { showToast(resp?.error || 'Fetch failed', 'error', btn); return; } const pretty = JSON.stringify(resp.data, null, 2); await navigator.clipboard.writeText(pretty); - showToast('JSON copied!', 'success', btn); + showToast(postOnly ? 'Post JSON copied (no comments)!' : 'JSON copied!', 'success', btn); chrome.runtime.sendMessage({ action: 'addToHistory', @@ -332,7 +349,7 @@ async function handleJsonCopy(e, btn, postData) { } } -async function handleGenerateLI(e, btn, postData) { +async function handleGenerateLI(e, btn, postData, promptId = null) { e.preventDefault(); e.stopPropagation(); if (btn.disabled) return; @@ -361,7 +378,8 @@ async function handleGenerateLI(e, btn, postData) { permalink: postData.permalink, title: postData.title, subreddit: postData.subreddit, - postId: postData.postId + postId: postData.postId, + promptId: promptId }); if (!resp?.success) { @@ -473,17 +491,29 @@ function injectButtonsToNewRedditPost(postElement) { // Double-check: if buttons already exist in this action bar, skip if (actionBar.container.querySelector('.reddjson-action-btn')) { postElement.setAttribute(CONFIG.markerAttr, 'true'); - if (uniqueKey) processedPostIds.add(uniqueKey); + if (uniqueKey) trackPostId(uniqueKey); return; } - // Create JSON button + // Create JSON button with right-click menu for post-only option const jsonBtn = createActionButton('JSON', REDDJSON_SVG, CONFIG.redditOrange); - jsonBtn.addEventListener('click', (e) => handleJsonCopy(e, jsonBtn, postData)); + jsonBtn.addEventListener('click', (e) => handleJsonCopy(e, jsonBtn, postData, false)); + jsonBtn.addEventListener('contextmenu', (e) => { + e.preventDefault(); + e.stopPropagation(); + showJsonFilterMenu(e, jsonBtn, postData); + }); + jsonBtn.title = 'Copy JSON (right-click for options)'; // Create LinkedIn Post button const liBtn = createActionButton('Post', LINKEDIN_ICON, CONFIG.linkedinBlue); liBtn.addEventListener('click', (e) => handleGenerateLI(e, liBtn, postData)); + liBtn.addEventListener('contextmenu', (e) => { + e.preventDefault(); + e.stopPropagation(); + showPromptTemplateMenu(e, liBtn, postData); + }); + liBtn.title = 'Generate LinkedIn post (right-click to select template)'; // Wrap in a container to keep them together const wrapper = document.createElement('span'); @@ -500,7 +530,7 @@ function injectButtonsToNewRedditPost(postElement) { // Mark as processed postElement.setAttribute(CONFIG.markerAttr, 'true'); - if (uniqueKey) processedPostIds.add(uniqueKey); + if (uniqueKey) trackPostId(uniqueKey); } function injectButtonsToOldRedditPost(postElement) { @@ -520,17 +550,29 @@ function injectButtonsToOldRedditPost(postElement) { if (toolbar.querySelector('.reddjson-action-btn')) { postElement.setAttribute(CONFIG.markerAttr, 'true'); - if (uniqueKey) processedPostIds.add(uniqueKey); + if (uniqueKey) trackPostId(uniqueKey); return; } const jsonBtn = createActionButton('JSON', REDDJSON_SVG, CONFIG.redditOrange); jsonBtn.style.cssText += 'border:1px solid #c6c6c6;padding:4px 8px;border-radius:3px;font-size:11px;height:auto;'; - jsonBtn.addEventListener('click', (e) => handleJsonCopy(e, jsonBtn, postData)); + jsonBtn.addEventListener('click', (e) => handleJsonCopy(e, jsonBtn, postData, false)); + jsonBtn.addEventListener('contextmenu', (e) => { + e.preventDefault(); + e.stopPropagation(); + showJsonFilterMenu(e, jsonBtn, postData); + }); + jsonBtn.title = 'Copy JSON (right-click for options)'; const liBtn = createActionButton('Post', LINKEDIN_ICON, CONFIG.linkedinBlue); liBtn.style.cssText += 'border:1px solid #c6c6c6;padding:4px 8px;border-radius:3px;font-size:11px;height:auto;'; liBtn.addEventListener('click', (e) => handleGenerateLI(e, liBtn, postData)); + liBtn.addEventListener('contextmenu', (e) => { + e.preventDefault(); + e.stopPropagation(); + showPromptTemplateMenu(e, liBtn, postData); + }); + liBtn.title = 'Generate LinkedIn post (right-click to select template)'; const li = document.createElement('li'); li.style.display = 'inline-block'; @@ -539,7 +581,7 @@ function injectButtonsToOldRedditPost(postElement) { toolbar.appendChild(li); postElement.setAttribute(CONFIG.markerAttr, 'true'); - if (uniqueKey) processedPostIds.add(uniqueKey); + if (uniqueKey) trackPostId(uniqueKey); } // ============================================================================ @@ -618,10 +660,22 @@ function injectButtonsToActionRow(actionRow) { // Create buttons const jsonBtn = createActionButton('JSON', REDDJSON_SVG, CONFIG.redditOrange); - jsonBtn.addEventListener('click', (e) => handleJsonCopy(e, jsonBtn, postData)); + jsonBtn.addEventListener('click', (e) => handleJsonCopy(e, jsonBtn, postData, false)); + jsonBtn.addEventListener('contextmenu', (e) => { + e.preventDefault(); + e.stopPropagation(); + showJsonFilterMenu(e, jsonBtn, postData); + }); + jsonBtn.title = 'Copy JSON (right-click for options)'; const liBtn = createActionButton('Post', LINKEDIN_ICON, CONFIG.linkedinBlue); liBtn.addEventListener('click', (e) => handleGenerateLI(e, liBtn, postData)); + liBtn.addEventListener('contextmenu', (e) => { + e.preventDefault(); + e.stopPropagation(); + showPromptTemplateMenu(e, liBtn, postData); + }); + liBtn.title = 'Generate LinkedIn post (right-click to select template)'; const wrapper = document.createElement('span'); wrapper.className = 'reddjson-buttons-wrapper'; @@ -654,6 +708,136 @@ function injectButtonsToActionRow(actionRow) { } } +// ============================================================================ +// JSON FILTER CONTEXT MENU +// ============================================================================ + +function showJsonFilterMenu(e, btn, postData) { + document.querySelectorAll('.reddjson-filter-menu').forEach(m => m.remove()); + + const menu = document.createElement('div'); + menu.className = 'reddjson-filter-menu'; + menu.style.cssText = ` + position:fixed; z-index:2147483647; background:#fff; border:1px solid #e0e0e0; + border-radius:8px; box-shadow:0 4px 12px rgba(0,0,0,.15); padding:4px 0; + min-width:180px; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; + `; + + const options = [ + { label: 'Full Thread JSON', desc: 'Post + all comments', postOnly: false }, + { label: 'Post Only JSON', desc: 'No comments (smaller)', postOnly: true }, + ]; + + options.forEach(opt => { + const item = document.createElement('button'); + item.type = 'button'; + item.style.cssText = ` + display:block; width:100%; padding:8px 14px; border:none; background:none; + text-align:left; cursor:pointer; font-size:12px; color:#1a1a1b; + font-family:inherit; transition:background .15s; + `; + item.innerHTML = `${opt.label}
${opt.desc}`; + item.addEventListener('mouseenter', () => { item.style.background = 'rgba(255,69,0,.06)'; }); + item.addEventListener('mouseleave', () => { item.style.background = 'none'; }); + item.addEventListener('click', (ev) => { + menu.remove(); + handleJsonCopy(ev, btn, postData, opt.postOnly); + }); + menu.appendChild(item); + }); + + menu.style.top = `${e.clientY}px`; + menu.style.left = `${e.clientX}px`; + document.body.appendChild(menu); + + const closeMenu = (ev) => { + if (!menu.contains(ev.target)) { + menu.remove(); + document.removeEventListener('click', closeMenu, true); + } + }; + setTimeout(() => document.addEventListener('click', closeMenu, true), 0); +} + +// ============================================================================ +// PROMPT TEMPLATES CONTEXT MENU +// ============================================================================ + +async function showPromptTemplateMenu(e, btn, postData) { + document.querySelectorAll('.reddjson-filter-menu').forEach(m => m.remove()); + + try { + const settingsResp = await chrome.runtime.sendMessage({ action: 'getSettings' }); + const settings = settingsResp?.settings; + const prompts = settings?.systemPrompts || []; + + if (prompts.length === 0) { + showToast('No templates found', 'error', btn); + return; + } + + const menu = document.createElement('div'); + menu.className = 'reddjson-filter-menu'; + menu.style.cssText = ` + position:fixed; z-index:2147483647; background:#fff; border:1px solid #e0e0e0; + border-radius:8px; box-shadow:0 4px 12px rgba(0,0,0,.15); padding:4px 0; + min-width:220px; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; + `; + + const activePromptId = settings.activePromptId || 'default'; + + prompts.forEach(p => { + const item = document.createElement('button'); + item.type = 'button'; + item.style.cssText = ` + display:block; width:100%; padding:8px 14px; border:none; background:none; + text-align:left; cursor:pointer; font-size:12px; color:#1a1a1b; + font-family:inherit; transition:background .15s; line-height:1.4; + `; + const isActive = p.id === activePromptId; + const previewText = p.prompt ? p.prompt.substring(0, 45).replace(/\n/g, ' ') : ''; + item.innerHTML = `${isActive ? '★ ' : ''}${escapeHtml(p.name)}
${escapeHtml(previewText)}...`; + item.addEventListener('mouseenter', () => { item.style.background = 'rgba(10,102,194,.06)'; }); + item.addEventListener('mouseleave', () => { item.style.background = 'none'; }); + item.addEventListener('click', (ev) => { + menu.remove(); + handleGenerateLI(ev, btn, postData, p.id); + }); + menu.appendChild(item); + }); + + menu.style.top = `${e.clientY}px`; + menu.style.left = `${e.clientX}px`; + document.body.appendChild(menu); + + const closeMenu = (ev) => { + if (!menu.contains(ev.target)) { + menu.remove(); + document.removeEventListener('click', closeMenu, true); + } + }; + setTimeout(() => document.addEventListener('click', closeMenu, true), 0); + } catch (err) { + showToast('Failed to load templates', 'error', btn); + } +} + +// ============================================================================ +// MESSAGE LISTENER — keyboard shortcut support +// ============================================================================ + +chrome.runtime.onMessage.addListener((message) => { + if (message.action === 'showToast') { + showToast(message.message, message.type || 'info'); + } else if (message.action === 'copyToClipboard') { + navigator.clipboard.writeText(message.text).then(() => { + showToast('JSON copied!', 'success'); + }).catch(() => { + showToast('Copy failed — focus this tab', 'error'); + }); + } +}); + // ============================================================================ // MUTATION OBSERVER // ============================================================================ diff --git a/manifest.json b/manifest.json index 7f9063d..d080c47 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "ReddJSON — Reddit to LinkedIn AI", - "version": "2.0.0", + "version": "2.1.0", "description": "Copy Reddit post JSON, generate LinkedIn posts with AI. Sidebar with history, AI posts, and provider settings.", "author": "ReddJSON Team", "icons": { @@ -27,6 +27,22 @@ "https://openrouter.ai/*", "https://api.groq.com/*" ], + "commands": { + "copy-json": { + "suggested_key": { + "default": "Ctrl+Shift+J", + "mac": "Command+Shift+J" + }, + "description": "Copy current Reddit post JSON" + }, + "generate-post": { + "suggested_key": { + "default": "Ctrl+Shift+L", + "mac": "Command+Shift+L" + }, + "description": "Generate LinkedIn post from current page" + } + }, "background": { "service_worker": "background.js" }, diff --git a/sidebar.css b/sidebar.css index 6a45015..da9b557 100644 --- a/sidebar.css +++ b/sidebar.css @@ -1,5 +1,5 @@ /* ═══════════════════════════════════════════════════════════════ - ReddJSON Sidebar v2.0 — Premium Light UI + ReddJSON Sidebar v2.0 — Premium UI with Dark Mode ═══════════════════════════════════════════════════════════════ */ /* ── Reset & Variables ── */ @@ -32,6 +32,29 @@ --transition: .2s ease; } +[data-theme="dark"] { + --bg-primary: #1A1A1B; + --bg-secondary: #272729; + --bg-tertiary: #343536; + --bg-elevated: #2D2D2F; + --text-primary: #D7DADC; + --text-secondary: #9CA3AF; + --text-muted: #6B7280; + --border: #3A3B3C; + --border-light: #4A4B4D; + --accent: #FF5722; + --accent-hover: #FF7043; + --accent-light: rgba(255, 87, 34, 0.12); + --linkedin: #3B82F6; + --linkedin-hover: #60A5FA; + --linkedin-light: rgba(59, 130, 246, 0.12); + --success: #4ADE80; + --danger: #F87171; + --danger-light: rgba(248, 113, 113, 0.12); + --shadow-sm: 0 1px 3px rgba(0, 0, 0, .3); + --shadow-md: 0 4px 12px rgba(0, 0, 0, .4); +} + * { margin: 0; padding: 0; @@ -63,6 +86,10 @@ body { flex-shrink: 0; } +[data-theme="dark"] .header { + background: linear-gradient(135deg, #2D1B14 0%, #1F1410 100%); +} + .header-brand { display: flex; align-items: center; @@ -851,4 +878,124 @@ body { border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; +} + +/* ── Theme Toggle ── */ +.theme-toggle { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border); + border-radius: 50%; + background: var(--bg-tertiary); + color: var(--text-secondary); + cursor: pointer; + transition: all var(--transition); + flex-shrink: 0; + margin-left: auto; +} + +.theme-toggle:hover { + color: var(--accent); + border-color: var(--accent); + background: var(--accent-light); +} + +/* ── Sidebar Toast ── */ +.sidebar-toast { + position: fixed; + bottom: 16px; + left: 50%; + transform: translateX(-50%) translateY(8px); + padding: 8px 16px; + border-radius: var(--radius-pill); + font-size: 12px; + font-weight: 600; + color: #fff; + z-index: 2000; + opacity: 0; + transition: opacity .25s, transform .25s; + pointer-events: none; + white-space: nowrap; + box-shadow: var(--shadow-md); +} + +.sidebar-toast.show { + opacity: 1; + transform: translateX(-50%) translateY(0); +} + +.sidebar-toast.success { background: var(--success); } +.sidebar-toast.error { background: var(--danger); } +.sidebar-toast.info { background: var(--accent); } + +/* ── Export/Import Buttons ── */ +.toolbar-actions { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +.btn-icon-sm { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-tertiary); + color: var(--text-secondary); + cursor: pointer; + transition: all var(--transition); + flex-shrink: 0; +} + +.btn-icon-sm:hover { + color: var(--accent); + border-color: var(--accent); + background: var(--accent-light); +} + +/* ── JSON Filter Dropdown ── */ +.json-filter-menu { + position: absolute; + top: 100%; + left: 0; + margin-top: 4px; + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-md); + z-index: 100; + min-width: 160px; + padding: 4px 0; +} + +.json-filter-item { + display: block; + width: 100%; + padding: 6px 12px; + border: none; + background: none; + color: var(--text-primary); + font-family: var(--font); + font-size: 12px; + text-align: left; + cursor: pointer; + transition: background var(--transition); +} + +.json-filter-item:hover { + background: var(--accent-light); + color: var(--accent); +} + +.json-filter-item small { + display: block; + font-size: 10px; + color: var(--text-muted); + margin-top: 1px; } \ No newline at end of file diff --git a/sidebar.html b/sidebar.html index 8f1066b..542d78b 100644 --- a/sidebar.html +++ b/sidebar.html @@ -19,6 +19,11 @@

ReddJSON

Reddit → LinkedIn AI + @@ -54,12 +59,28 @@

ReddJSON

- +
+ + + +
@@ -75,12 +96,28 @@

ReddJSON

- +
+ + + +
+ + + diff --git a/sidebar.js b/sidebar.js index 8e99226..47082c5 100644 --- a/sidebar.js +++ b/sidebar.js @@ -1,12 +1,13 @@ /** - * ReddJSON Sidebar v2.0 + * ReddJSON Sidebar v2.1 * ═══════════════════════════════════════════════════════════════════ * 3-tab UI: History | AI Posts | Settings * * Communicates with background.js via chrome.runtime.sendMessage. * All data stored in chrome.storage.local only. * - * @version 2.0.0 + * v2.1 additions: dark mode, export/import, debounced search, + * keyboard shortcuts, sidebar toasts, storage optimization. */ // ============================================================================ @@ -38,11 +39,66 @@ function formatCtx(n) { return n.toString(); } +function debounce(fn, wait) { + let t; + return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), wait); }; +} + // Global state let cachedModels = { openrouter: [], groq: [] }; +let cachedHistory = []; +let cachedAIPosts = []; let currentSettings = null; let editingPromptId = null; +// ============================================================================ +// SIDEBAR TOAST +// ============================================================================ + +function showSidebarToast(message, type = 'info') { + const toast = $('#sidebar-toast'); + if (!toast) return; + toast.textContent = message; + toast.className = `sidebar-toast ${type} show`; + clearTimeout(toast._timeout); + toast._timeout = setTimeout(() => { + toast.classList.remove('show'); + }, 2000); +} + +// ============================================================================ +// DARK MODE +// ============================================================================ + +function initTheme() { + const saved = localStorage.getItem('reddjson_theme'); + if (saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches)) { + document.documentElement.setAttribute('data-theme', 'dark'); + } + updateThemeIcon(); + + $('#theme-toggle')?.addEventListener('click', () => { + const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; + if (isDark) { + document.documentElement.removeAttribute('data-theme'); + localStorage.setItem('reddjson_theme', 'light'); + } else { + document.documentElement.setAttribute('data-theme', 'dark'); + localStorage.setItem('reddjson_theme', 'dark'); + } + updateThemeIcon(); + }); +} + +function updateThemeIcon() { + const icon = $('#theme-icon'); + if (!icon) return; + const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; + icon.innerHTML = isDark + ? '' + : ''; +} + // ============================================================================ // TABS // ============================================================================ @@ -58,7 +114,6 @@ function initTabs() { const panel = $(`#${panelId}`); if (panel) panel.classList.add('active'); - // Refresh data when tab becomes visible if (tab.dataset.tab === 'history') loadHistory(); else if (tab.dataset.tab === 'ai-posts') loadAIPosts(); else if (tab.dataset.tab === 'settings') loadSettings(); @@ -74,12 +129,16 @@ async function loadHistory() { const resp = await chrome.runtime.sendMessage({ action: 'getHistory' }); if (!resp?.success) return; - const history = resp.history || []; + cachedHistory = resp.history || []; + renderHistory(); +} + +function renderHistory() { const list = $('#history-list'); const empty = $('#history-empty'); const stats = $('#history-stats'); - if (history.length === 0) { + if (cachedHistory.length === 0) { list.innerHTML = ''; empty.style.display = 'flex'; stats.textContent = ''; @@ -87,13 +146,13 @@ async function loadHistory() { } empty.style.display = 'none'; - const totalCopies = history.reduce((s, e) => s + (e.copiedCount || 1), 0); - stats.textContent = `${history.length} posts · ${totalCopies} total copies`; + const totalCopies = cachedHistory.reduce((s, e) => s + (e.copiedCount || 1), 0); + stats.textContent = `${cachedHistory.length} posts · ${totalCopies} total copies`; const search = ($('#history-search')?.value || '').toLowerCase(); const filtered = search - ? history.filter(e => (e.title + e.subreddit).toLowerCase().includes(search)) - : history; + ? cachedHistory.filter(e => (e.title + e.subreddit).toLowerCase().includes(search)) + : cachedHistory; list.innerHTML = filtered.map(e => `
@@ -106,9 +165,12 @@ async function loadHistory() { ×${e.copiedCount || 1}
${escapeHtml(e.jsonPreview || '')}
-
+
+
@@ -116,7 +178,7 @@ async function loadHistory() { } function initHistoryEvents() { - $('#history-search')?.addEventListener('input', loadHistory); + $('#history-search')?.addEventListener('input', debounce(() => renderHistory(), 150)); $('#history-list').addEventListener('click', async (e) => { const btn = e.target.closest('[data-action]'); @@ -125,15 +187,37 @@ function initHistoryEvents() { if (action === 'recopy') { const resp = await chrome.runtime.sendMessage({ action: 'getHistoryEntry', entryId: id }); - if (resp?.success && resp.entry?.fullJson) { - await navigator.clipboard.writeText(JSON.stringify(resp.entry.fullJson, null, 2)); - btn.textContent = '✓ Copied!'; - setTimeout(() => { btn.textContent = '📋 Re-copy'; }, 1500); + if (resp?.success && resp.entry) { + let json = resp.entry.fullJson; + if (!json && resp.entry.permalink) { + showSidebarToast('Re-fetching from Reddit...', 'info'); + const fetchResp = await chrome.runtime.sendMessage({ action: 'refetchJSON', permalink: resp.entry.permalink }); + if (fetchResp?.success) json = fetchResp.data; + else { showSidebarToast(fetchResp?.error || 'Fetch failed', 'error'); return; } + } + if (json) { + await navigator.clipboard.writeText(JSON.stringify(json, null, 2)); + btn.textContent = '✓ Copied!'; + showSidebarToast('JSON copied to clipboard', 'success'); + setTimeout(() => { btn.textContent = '📋 Re-copy'; }, 1500); + } } } else if (action === 'view') { const resp = await chrome.runtime.sendMessage({ action: 'getHistoryEntry', entryId: id }); if (resp?.success && resp.entry) { - showJsonModal(resp.entry.title, resp.entry.fullJson); + let json = resp.entry.fullJson; + if (!json && resp.entry.permalink) { + showSidebarToast('Re-fetching from Reddit...', 'info'); + const fetchResp = await chrome.runtime.sendMessage({ action: 'refetchJSON', permalink: resp.entry.permalink }); + if (fetchResp?.success) json = fetchResp.data; + else { showSidebarToast(fetchResp?.error || 'Fetch failed', 'error'); return; } + } + if (json) showJsonModal(resp.entry.title, json); + } + } else if (action === 'generate-ai') { + const resp = await chrome.runtime.sendMessage({ action: 'getHistoryEntry', entryId: id }); + if (resp?.success && resp.entry) { + showSidebarPromptMenu(e, btn, resp.entry); } } else if (action === 'delete') { showConfirm('Delete this entry?', async () => { @@ -149,6 +233,137 @@ function initHistoryEvents() { loadHistory(); }); }); + + // Export history + $('#history-export')?.addEventListener('click', () => { + if (cachedHistory.length === 0) { + showSidebarToast('No history to export', 'error'); + return; + } + exportAsJSON(cachedHistory, 'reddjson-history'); + showSidebarToast(`Exported ${cachedHistory.length} entries`, 'success'); + }); + + // Import history + $('#history-import')?.addEventListener('click', () => { + triggerImport('history'); + }); +} + +// ============================================================================ +// SIDEBAR AI GENERATION & TEMPLATE MENU +// ============================================================================ + +function showSidebarPromptMenu(e, btn, entry) { + $$('.sidebar-dropdown-menu').forEach(m => m.remove()); + + const prompts = currentSettings?.systemPrompts || []; + if (prompts.length === 0) { + showSidebarToast('No templates found in settings', 'error'); + return; + } + + const menu = document.createElement('div'); + menu.className = 'sidebar-dropdown-menu'; + menu.style.cssText = ` + position: absolute; + z-index: 1000; + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-md); + padding: 4px 0; + min-width: 180px; + max-width: 240px; + `; + + prompts.forEach(p => { + const item = document.createElement('button'); + item.style.cssText = ` + display: block; + width: 100%; + padding: 8px 12px; + border: none; + background: none; + text-align: left; + font-family: var(--font); + font-size: 11px; + color: var(--text-primary); + cursor: pointer; + transition: background var(--transition); + line-height: 1.3; + `; + const previewText = p.prompt ? p.prompt.substring(0, 35).replace(/\n/g, ' ') : ''; + item.innerHTML = `${escapeHtml(p.name)}
${escapeHtml(previewText)}...`; + + item.addEventListener('mouseenter', () => { item.style.background = 'var(--accent-light)'; }); + item.addEventListener('mouseleave', () => { item.style.background = 'none'; }); + + item.addEventListener('click', async () => { + menu.remove(); + await generateSidebarAIPost(btn, entry, p.id); + }); + menu.appendChild(item); + }); + + const entryEl = btn.closest('.entry'); + if (entryEl) { + entryEl.appendChild(menu); + menu.style.bottom = `${btn.offsetHeight + 10}px`; + menu.style.right = `12px`; + } + + const closeDropdown = (ev) => { + if (!menu.contains(ev.target) && ev.target !== btn) { + menu.remove(); + document.removeEventListener('click', closeDropdown, true); + } + }; + setTimeout(() => document.addEventListener('click', closeDropdown, true), 0); +} + +async function generateSidebarAIPost(btn, entry, promptId) { + if (!currentSettings?.defaultProvider || !currentSettings?.defaultModel) { + showSidebarToast('Set up AI provider in Settings first', 'error'); + return; + } + + const origText = btn.innerHTML; + btn.innerHTML = '⚡ Generating...'; + btn.disabled = true; + btn.style.cursor = 'wait'; + + showSidebarToast('Generating LinkedIn post...', 'info'); + + try { + const resp = await chrome.runtime.sendMessage({ + action: 'generateLinkedInPost', + permalink: entry.permalink, + title: entry.title, + subreddit: entry.subreddit, + postId: entry.postId, + promptId: promptId, + providerId: currentSettings.defaultProvider, + modelId: currentSettings.defaultModel + }); + + if (!resp?.success) { + showSidebarToast(resp?.error || 'Generation failed', 'error'); + return; + } + + showSidebarToast('LinkedIn post ready!', 'success'); + + // Auto switch tab to AI Posts + const aiTab = $('[data-tab="ai-posts"]'); + if (aiTab) aiTab.click(); + } catch (err) { + showSidebarToast(err.message || 'Generation failed', 'error'); + } finally { + btn.innerHTML = origText; + btn.disabled = false; + btn.style.cursor = 'pointer'; + } } // ============================================================================ @@ -159,11 +374,15 @@ async function loadAIPosts() { const resp = await chrome.runtime.sendMessage({ action: 'getAIPosts' }); if (!resp?.success) return; - const posts = resp.posts || []; + cachedAIPosts = resp.posts || []; + renderAIPosts(); +} + +function renderAIPosts() { const list = $('#ai-posts-list'); const empty = $('#ai-posts-empty'); - if (posts.length === 0) { + if (cachedAIPosts.length === 0) { list.innerHTML = ''; empty.style.display = 'flex'; return; @@ -172,8 +391,8 @@ async function loadAIPosts() { empty.style.display = 'none'; const search = ($('#ai-search')?.value || '').toLowerCase(); const filtered = search - ? posts.filter(p => (p.redditTitle + p.generatedText).toLowerCase().includes(search)) - : posts; + ? cachedAIPosts.filter(p => (p.redditTitle + p.generatedText).toLowerCase().includes(search)) + : cachedAIPosts; list.innerHTML = filtered.map(p => { const mediaHtml = (p.media || []).slice(0, 3).map(m => @@ -207,10 +426,9 @@ async function loadAIPosts() { } function initAIPostsEvents() { - $('#ai-search')?.addEventListener('input', loadAIPosts); + $('#ai-search')?.addEventListener('input', debounce(() => renderAIPosts(), 150)); $('#ai-posts-list').addEventListener('click', async (e) => { - // Toggle expand const textEl = e.target.closest('.ai-post-text'); if (textEl) { textEl.classList.toggle('expanded'); return; } @@ -218,19 +436,20 @@ function initAIPostsEvents() { if (!btn) return; const { action, id } = btn.dataset; - const resp = await chrome.runtime.sendMessage({ action: 'getAIPosts' }); - const post = resp?.posts?.find(p => p.id === id); + const post = cachedAIPosts.find(p => p.id === id); if (!post && action !== 'delete-ai') return; if (action === 'copy-text') { await navigator.clipboard.writeText(post.generatedText); btn.textContent = '✓ Copied!'; + showSidebarToast('Post text copied', 'success'); setTimeout(() => { btn.textContent = '📋 Copy Text'; }, 1500); } else if (action === 'copy-media') { const url = post.media?.[0]?.url; if (url) { await navigator.clipboard.writeText(url); btn.textContent = '✓ Copied!'; + showSidebarToast('Image URL copied', 'success'); setTimeout(() => { btn.textContent = '🖼 Copy Image URL'; }, 1500); } } else if (action === 'open-linkedin') { @@ -251,6 +470,87 @@ function initAIPostsEvents() { loadAIPosts(); }); }); + + // Export AI posts + $('#ai-export')?.addEventListener('click', () => { + if (cachedAIPosts.length === 0) { + showSidebarToast('No AI posts to export', 'error'); + return; + } + exportAsJSON(cachedAIPosts, 'reddjson-ai-posts'); + showSidebarToast(`Exported ${cachedAIPosts.length} posts`, 'success'); + }); + + // Import AI posts + $('#ai-import')?.addEventListener('click', () => { + triggerImport('ai-posts'); + }); +} + +// ============================================================================ +// EXPORT / IMPORT +// ============================================================================ + +function exportAsJSON(data, filename) { + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${filename}-${new Date().toISOString().slice(0, 10)}.json`; + a.click(); + URL.revokeObjectURL(url); +} + +let importTarget = null; + +function triggerImport(target) { + importTarget = target; + const input = $('#import-file-input'); + input.value = ''; + input.click(); +} + +function initImport() { + $('#import-file-input')?.addEventListener('change', async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + + try { + const text = await file.text(); + const data = JSON.parse(text); + + if (!Array.isArray(data)) { + showSidebarToast('Invalid file: expected an array', 'error'); + return; + } + + if (importTarget === 'history') { + const resp = await chrome.runtime.sendMessage({ action: 'getHistory' }); + const existing = resp?.history || []; + const existingIds = new Set(existing.map(e => e.postId)); + const newEntries = data.filter(e => e.postId && !existingIds.has(e.postId)); + const merged = [...newEntries, ...existing].slice(0, 50); + await chrome.runtime.sendMessage({ + action: 'saveSettings', + settings: currentSettings + }); + await chrome.storage.local.set({ reddjson_history: merged }); + loadHistory(); + showSidebarToast(`Imported ${newEntries.length} new entries`, 'success'); + } else if (importTarget === 'ai-posts') { + const resp = await chrome.runtime.sendMessage({ action: 'getAIPosts' }); + const existing = resp?.posts || []; + const existingIds = new Set(existing.map(e => e.id)); + const newPosts = data.filter(e => e.id && !existingIds.has(e.id)); + const merged = [...newPosts, ...existing].slice(0, 50); + await chrome.storage.local.set({ reddjson_ai_posts: merged }); + loadAIPosts(); + showSidebarToast(`Imported ${newPosts.length} new posts`, 'success'); + } + } catch (err) { + showSidebarToast('Import failed: invalid JSON', 'error'); + } + }); } // ============================================================================ @@ -262,25 +562,32 @@ async function loadSettings() { if (!resp?.success) return; currentSettings = resp.settings; - // Restore API keys const orKey = currentSettings.providers?.openrouter?.apiKey || ''; const groqKey = currentSettings.providers?.groq?.apiKey || ''; $('#openrouter-key').value = orKey; $('#groq-key').value = groqKey; - // Update status badges updateProviderStatus('openrouter', orKey); updateProviderStatus('groq', groqKey); - // Restore defaults $('#default-provider').value = currentSettings.defaultProvider || ''; - updateDefaultModelDropdown(); - // Load cached models if keys exist - if (orKey) loadModelsForProvider('openrouter', orKey); - if (groqKey) loadModelsForProvider('groq', groqKey); + // Load persisted models from settings cache if they exist, otherwise fetch + ['openrouter', 'groq'].forEach(pid => { + const key = currentSettings.providers?.[pid]?.apiKey || ''; + const savedModels = currentSettings.providers?.[pid]?.models || []; + if (key) { + if (savedModels.length > 0) { + cachedModels[pid] = savedModels; + $(`#${pid}-models-area`).style.display = 'block'; + renderModels(pid); + } else { + loadModelsForProvider(pid, key); + } + } + }); - // Render prompts + updateDefaultModelDropdown(); renderPrompts(); } @@ -309,8 +616,18 @@ async function loadModelsForProvider(providerId, apiKey) { return; } - cachedModels[providerId] = resp.models || []; + const fetchedModels = resp.models || []; + cachedModels[providerId] = fetchedModels; renderModels(providerId); + + // Persist fetched models list into settings storage + if (currentSettings) { + if (!currentSettings.providers) currentSettings.providers = {}; + if (!currentSettings.providers[providerId]) currentSettings.providers[providerId] = {}; + currentSettings.providers[providerId].models = fetchedModels; + await chrome.runtime.sendMessage({ action: 'saveSettings', settings: currentSettings }); + updateDefaultModelDropdown(); + } } function renderModels(providerId) { @@ -325,7 +642,6 @@ function renderModels(providerId) { if (search) models = models.filter(m => m.name.toLowerCase().includes(search) || m.id.toLowerCase().includes(search)); if (freeOnly) models = models.filter(m => m.isFree); - // Sort: free first, then alphabetical models.sort((a, b) => { if (a.isFree && !b.isFree) return -1; if (!a.isFree && b.isFree) return 1; @@ -349,7 +665,6 @@ function renderModels(providerId) { } function initSettingsEvents() { - // Save provider keys ['openrouter', 'groq'].forEach(pid => { $(`#${pid}-save`).addEventListener('click', async () => { const key = $(`#${pid}-key`).value.trim(); @@ -363,18 +678,16 @@ function initSettingsEvents() { await chrome.runtime.sendMessage({ action: 'saveSettings', settings: currentSettings }); updateProviderStatus(pid, key); loadModelsForProvider(pid, key); + showSidebarToast('API key saved', 'success'); }); - // Model search const searchEl = $(`#${pid}-model-search`); - if (searchEl) searchEl.addEventListener('input', () => renderModels(pid)); + if (searchEl) searchEl.addEventListener('input', debounce(() => renderModels(pid), 150)); - // Free filter const freeEl = $(`#${pid}-free-filter`); if (freeEl) freeEl.addEventListener('change', () => renderModels(pid)); }); - // Model selection click document.addEventListener('click', async (e) => { const modelItem = e.target.closest('.model-item'); if (!modelItem) return; @@ -382,11 +695,9 @@ function initSettingsEvents() { const pid = modelItem.dataset.provider; const modelId = modelItem.dataset.modelId; - // Update selected model for this provider if (!currentSettings.providers[pid]) currentSettings.providers[pid] = {}; currentSettings.providers[pid].selectedModel = modelId; - // If this provider is the default, also update global default model if (currentSettings.defaultProvider === pid) { currentSettings.defaultModel = modelId; } @@ -394,9 +705,9 @@ function initSettingsEvents() { await chrome.runtime.sendMessage({ action: 'saveSettings', settings: currentSettings }); renderModels(pid); updateDefaultModelDropdown(); + showSidebarToast(`Model: ${modelId.split('/').pop()}`, 'success'); }); - // Default provider change $('#default-provider').addEventListener('change', async (e) => { currentSettings.defaultProvider = e.target.value; const pid = e.target.value; @@ -409,23 +720,21 @@ function initSettingsEvents() { updateDefaultModelDropdown(); }); - // Default model change $('#default-model').addEventListener('change', async (e) => { currentSettings.defaultModel = e.target.value; await chrome.runtime.sendMessage({ action: 'saveSettings', settings: currentSettings }); }); - // Save defaults button $('#save-defaults').addEventListener('click', async () => { currentSettings.defaultProvider = $('#default-provider').value; currentSettings.defaultModel = $('#default-model').value; await chrome.runtime.sendMessage({ action: 'saveSettings', settings: currentSettings }); const btn = $('#save-defaults'); btn.textContent = 'Saved ✓'; + showSidebarToast('Defaults saved', 'success'); setTimeout(() => { btn.textContent = 'Save Defaults'; }, 1500); }); - // Add new prompt $('#add-prompt').addEventListener('click', () => { editingPromptId = null; $('#prompt-modal-title').textContent = 'New System Prompt'; @@ -434,7 +743,6 @@ function initSettingsEvents() { $('#prompt-modal').style.display = 'flex'; }); - // Prompt modal save $('#prompt-modal-save').addEventListener('click', async () => { const name = $('#prompt-name-input').value.trim(); const prompt = $('#prompt-text-input').value.trim(); @@ -460,9 +768,9 @@ function initSettingsEvents() { await chrome.runtime.sendMessage({ action: 'saveSettings', settings: currentSettings }); $('#prompt-modal').style.display = 'none'; renderPrompts(); + showSidebarToast('Prompt saved', 'success'); }); - // Prompt modal cancel/close $('#prompt-modal-cancel').addEventListener('click', () => { $('#prompt-modal').style.display = 'none'; }); $('#prompt-modal-close').addEventListener('click', () => { $('#prompt-modal').style.display = 'none'; }); } @@ -499,7 +807,6 @@ function renderPrompts() { `).join(''); - // Prompt actions list.querySelectorAll('[data-action]').forEach(btn => { btn.addEventListener('click', async () => { const { action, promptId } = btn.dataset; @@ -508,6 +815,7 @@ function renderPrompts() { currentSettings.activePromptId = promptId; await chrome.runtime.sendMessage({ action: 'saveSettings', settings: currentSettings }); renderPrompts(); + showSidebarToast('Prompt activated', 'success'); } else if (action === 'edit-prompt') { const prompt = currentSettings.systemPrompts.find(p => p.id === promptId); if (!prompt) return; @@ -546,6 +854,7 @@ $('#modal-copy')?.addEventListener('click', async () => { await navigator.clipboard.writeText(text); const btn = $('#modal-copy'); btn.textContent = 'Copied ✓'; + showSidebarToast('JSON copied', 'success'); setTimeout(() => { btn.textContent = 'Copy JSON'; }, 1500); }); @@ -556,6 +865,13 @@ document.addEventListener('click', (e) => { } }); +// Close modals on Escape key +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + $$('.modal-overlay').forEach(m => { m.style.display = 'none'; }); + } +}); + // Confirm dialog let confirmCallback = null; @@ -583,9 +899,7 @@ chrome.storage.onChanged.addListener((changes) => { if (changes.reddjson_ai_posts) { const activeTab = $('.tab.active')?.dataset?.tab; if (activeTab === 'ai-posts') loadAIPosts(); - // Auto-switch to AI Posts tab when a new post arrives if (changes.reddjson_ai_posts.newValue?.length > (changes.reddjson_ai_posts.oldValue?.length || 0)) { - // New AI post was added — switch to AI Posts tab $$('.tab').forEach(t => { t.classList.remove('active'); t.setAttribute('aria-selected', 'false'); }); $$('.tab-content').forEach(p => p.classList.remove('active')); const aiTab = $('[data-tab="ai-posts"]'); @@ -601,12 +915,14 @@ chrome.storage.onChanged.addListener((changes) => { // ============================================================================ async function init() { + initTheme(); initTabs(); initHistoryEvents(); initAIPostsEvents(); initSettingsEvents(); + initImport(); await loadHistory(); - console.log('[ReddJSON] Sidebar v2.0 ready ✓'); + console.log('[ReddJSON] Sidebar v2.1 ready ✓'); } if (document.readyState === 'loading') {