diff --git a/README.md b/README.md index 36fbcaa..641205a 100644 --- a/README.md +++ b/README.md @@ -7,16 +7,54 @@ Hosted for free with GitHub Pages, backed by simple `.md` files. [https://ole-vi.github.io/prompt-sharing/](https://ole-vi.github.io/prompt-sharing/) -## Repo structure +## Local development + +To test the app locally, you must serve it over HTTP (not open the HTML file directly): + +```bash +# From the repo root +python -m http.server 8888 +``` + +Then open **`http://localhost:8888`** in your browser. + +**Important:** Opening `index.html` directly (via `file://` URL) will not work with Firebase authentication. The app must be served over HTTP for GitHub OAuth to function. + +## Architecture + +This is a zero-build, modular single-page application. All code is plain JavaScript modules (no bundler). + +### Folder structure ``` prompt-sharing/ -├── index.html # The app (static single-page site) -└── prompts/ # Markdown prompts live here - ├── stubs.md - └── pr-rubric.md +├── index.html # Main HTML (very lean) +├── firebase-init.js # Firebase initialization +├── src/ +│ ├── app.js # Main app entry point +│ ├── styles.css # All CSS +│ ├── modules/ # Feature modules (ES6) +│ │ ├── auth.js # GitHub OAuth & auth state +│ │ ├── jules.js # Jules integration & encryption +│ │ ├── github-api.js # GitHub API calls +│ │ ├── prompt-list.js # Tree navigation & list rendering +│ │ ├── prompt-renderer.js # Content display & selection +│ │ └── branch-selector.js # Branch management +│ └── utils/ # Shared utilities +│ ├── constants.js # Constants, regex, storage keys +│ ├── slug.js # URL slug generation +│ ├── url-params.js # URL parameter parsing +│ └── dom-helpers.js # Common DOM operations +└── prompts/ # Markdown prompts live here ``` +## How it works + +1. **index.html** loads Firebase SDK + marked.js, then loads `src/app.js` as a module +2. **src/app.js** initializes all modules and wires up event listeners +3. **Modules** are ES6 modules that import utilities and other modules as needed +4. **No build step**: Files are served directly over HTTP + ## Adding a new prompt 1. Create a new file inside the `prompts/` folder. @@ -101,3 +139,39 @@ If a filename doesn’t match any of these keywords, no emoji is added. Emojis a * Repo must remain public for GitHub Pages and the GitHub API to fetch the prompts. * Changes take 1–2 minutes to appear live after pushing to `main`. * No in-browser editing; prompts are managed via git or the GitHub web interface. + +## Development Guide + +### Running locally + +```bash +cd prompt-sharing +python -m http.server 8888 +# Visit http://localhost:8888 +``` + +The dev setup loads modules directly without compilation. Changes are reflected immediately (reload browser). + +### Project organization + +Each module in `src/modules/` handles one major feature: +- **auth.js**: Firebase authentication state and UI updates +- **github-api.js**: All GitHub API calls (repos, prompts, gists) +- **prompt-list.js**: Tree rendering, sidebar list, search +- **prompt-renderer.js**: Content loading and display +- **branch-selector.js**: Branch listing and switching +- **jules.js**: Jules API integration & key encryption + +Utilities in `src/utils/` are shared helpers: +- **constants.js**: Regex patterns, storage keys, emoji mappings, all magic strings +- **slug.js**: URL-safe filename generation +- **url-params.js**: Query string & hash parsing +- **dom-helpers.js**: Reusable DOM operations + +### Code style + +- ES6 modules with explicit imports/exports +- No transpilation or build step +- Plain JavaScript (no frameworks) +- All external libraries loaded from CDN (marked.js, Firebase) + diff --git a/index.html b/index.html index 2e9bd11..75c0b5b 100644 --- a/index.html +++ b/index.html @@ -4,89 +4,12 @@ OLE Prompt Library - + - - @@ -161,1346 +84,6 @@

Save Jules API Key

- + diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..d9b8c89 --- /dev/null +++ b/src/app.js @@ -0,0 +1,156 @@ +// ===== Main App Initialization ===== + +import { OWNER, REPO, BRANCH, STORAGE_KEYS } from './utils/constants.js'; +import { parseParams, getHashParam } from './utils/url-params.js'; +import { initAuthStateListener, updateAuthUI } from './modules/auth.js'; +import { initJulesKeyModalListeners, handleTryInJules } from './modules/jules.js'; +import { initPromptList, loadList, loadExpandedState, renderList, setSelectFileCallback } from './modules/prompt-list.js'; +import { initPromptRenderer, selectBySlug, selectFile, setHandleTryInJulesCallback } from './modules/prompt-renderer.js'; +import { initBranchSelector, loadBranches, setCurrentBranch, setCurrentRepo } from './modules/branch-selector.js'; + +// App state +let currentOwner = OWNER; +let currentRepo = REPO; +let currentBranch = BRANCH; + +function initApp() { + // Parse URL params + const params = parseParams(); + if (params.owner) currentOwner = params.owner; + if (params.repo) currentRepo = params.repo; + if (params.branch) currentBranch = params.branch; + + // Set up callbacks to avoid circular dependencies + setSelectFileCallback(selectFile); + setHandleTryInJulesCallback(handleTryInJules); + + // Initialize modules + initPromptList(); + initPromptRenderer(); + initBranchSelector(currentOwner, currentRepo, currentBranch); + initJulesKeyModalListeners(); + + // Update header + const repoPill = document.getElementById('repoPill'); + if (repoPill) { + repoPill.textContent = `${currentOwner}/${currentRepo}@${currentBranch}`; + } + + // Load prompts + loadPrompts(); + + // Load branches + loadBranches(); + + // Setup event listeners + setupEventListeners(); + + // Wait for Firebase and init auth + waitForFirebase(() => { + initAuthStateListener(); + // Get current user state + if (window.auth && window.auth.currentUser) { + updateAuthUI(window.auth.currentUser); + } + }); +} + +function waitForFirebase(callback, attempts = 0, maxAttempts = 100) { + if (window.firebaseReady) { + callback(); + } else if (attempts < maxAttempts) { + setTimeout(() => waitForFirebase(callback, attempts + 1, maxAttempts), 100); + } else { + console.error('Firebase failed to initialize'); + } +} + +async function loadPrompts() { + const cacheKey = STORAGE_KEYS.promptsCache(currentOwner, currentRepo, currentBranch); + const files = await loadList(currentOwner, currentRepo, currentBranch, cacheKey); + + // Check for hash param to auto-load prompt + const hashSlug = getHashParam('p'); + if (hashSlug) { + await selectBySlug(hashSlug, files, currentOwner, currentRepo, currentBranch); + } +} + +function setupEventListeners() { + // Handle hash changes (prompt selection) + window.addEventListener('hashchange', async () => { + try { + const p = parseParams(); + const prevOwner = currentOwner; + const prevRepo = currentRepo; + const prevBranch = currentBranch; + + if (p.owner) currentOwner = p.owner; + if (p.repo) currentRepo = p.repo; + if (p.branch) currentBranch = p.branch; + + const repoChanged = currentOwner !== prevOwner || currentRepo !== prevRepo; + const branchChanged = currentBranch !== prevBranch; + + if (repoChanged || branchChanged) { + setCurrentRepo(currentOwner, currentRepo); + setCurrentBranch(currentBranch); + const cacheKey = STORAGE_KEYS.promptsCache(currentOwner, currentRepo, currentBranch); + sessionStorage.removeItem(cacheKey); + await loadPrompts(); + await loadBranches(); + } else { + // Just switching prompt + const hashSlug = getHashParam('p'); + if (hashSlug) { + const { getFiles } = await import('./modules/prompt-list.js'); + await selectBySlug(hashSlug, getFiles(), currentOwner, currentRepo, currentBranch); + } + } + } catch (error) { + console.error('Error handling hash change:', error); + } + }); + + // Handle back/forward buttons + window.addEventListener('popstate', async () => { + try { + const p = parseParams(); + const changed = + (p.owner && p.owner !== currentOwner) || + (p.repo && p.repo !== currentRepo) || + (p.branch && p.branch !== currentBranch); + + if (changed) { + currentOwner = p.owner || currentOwner; + currentRepo = p.repo || currentRepo; + currentBranch = p.branch || currentBranch; + setCurrentRepo(currentOwner, currentRepo); + setCurrentBranch(currentBranch); + const cacheKey = STORAGE_KEYS.promptsCache(currentOwner, currentRepo, currentBranch); + sessionStorage.removeItem(cacheKey); + await loadPrompts(); + await loadBranches(); + } + } catch (error) { + console.error('Error handling popstate:', error); + } + }); + + // Handle branch change event + window.addEventListener('branchChanged', async (e) => { + try { + currentBranch = e.detail.branch; + await loadPrompts(); + } catch (error) { + console.error('Error handling branch change:', error); + } + }); +} + +// Initialize app when DOM is ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initApp); +} else { + initApp(); +} diff --git a/src/modules/auth.js b/src/modules/auth.js new file mode 100644 index 0000000..6556d75 --- /dev/null +++ b/src/modules/auth.js @@ -0,0 +1,76 @@ +// ===== Firebase Authentication Module ===== + +let currentUser = null; + +export function getCurrentUser() { + return currentUser; +} + +export function setCurrentUser(user) { + currentUser = user; +} + +export async function signInWithGitHub() { + try { + if (!window.auth) { + alert('Authentication not ready. Please refresh the page.'); + return; + } + const provider = new firebase.auth.GithubAuthProvider(); + await window.auth.signInWithPopup(provider); + } catch (error) { + console.error('Sign-in failed:', error); + alert('Failed to sign in. Please try again.'); + } +} + +export async function signOutUser() { + try { + if (window.auth) { + await window.auth.signOut(); + } + } catch (error) { + console.error('Sign-out failed:', error); + alert('Failed to sign out.'); + } +} + +export function updateAuthUI(user) { + const authStatus = document.getElementById('authStatus'); + const authBtn = document.getElementById('authBtn'); + const signOutBtn = document.getElementById('signOutBtn'); + const userDisplay = document.getElementById('userDisplay'); + const userName = document.getElementById('userName'); + + setCurrentUser(user); + + if (user) { + // User is signed in + authBtn.style.display = 'none'; + signOutBtn.style.display = 'inline-block'; + userDisplay.style.display = 'inline-flex'; + userName.textContent = user.displayName || user.email || 'User'; + signOutBtn.onclick = signOutUser; + } else { + // User is signed out + authBtn.style.display = 'inline-block'; + signOutBtn.style.display = 'none'; + userDisplay.style.display = 'none'; + authBtn.onclick = signInWithGitHub; + } +} + +export function initAuthStateListener() { + try { + if (!window.auth) { + console.error('Auth not initialized yet'); + return; + } + window.auth.onAuthStateChanged((user) => { + updateAuthUI(user); + }); + console.log('Auth state listener initialized'); + } catch (error) { + console.error('Failed to initialize auth listener:', error); + } +} diff --git a/src/modules/branch-selector.js b/src/modules/branch-selector.js new file mode 100644 index 0000000..eb2a4aa --- /dev/null +++ b/src/modules/branch-selector.js @@ -0,0 +1,198 @@ +// ===== Branch Selector Module ===== + +import { USER_BRANCHES, FEATURE_PATTERNS, STORAGE_KEYS } from '../utils/constants.js'; +import { getBranches } from './github-api.js'; + +let branchSelect = null; +let currentBranch = null; +let currentOwner = null; +let currentRepo = null; + +export function initBranchSelector(owner, repo, branch) { + branchSelect = document.getElementById('branchSelect'); + currentOwner = owner; + currentRepo = repo; + currentBranch = branch; + + if (branchSelect) { + branchSelect.addEventListener('change', handleBranchChange); + } +} + +export function setCurrentBranch(branch) { + currentBranch = branch; + if (branchSelect) { + branchSelect.value = branch; + } +} + +export function getCurrentBranch() { + return currentBranch; +} + +export function setCurrentRepo(owner, repo) { + currentOwner = owner; + currentRepo = repo; +} + +function classifyBranch(branchName) { + if (branchName === 'main' || branchName === 'master') { + return 'main'; + } + + if (USER_BRANCHES.includes(branchName)) { + return 'user'; + } + + if ( + branchName.startsWith('codex/') || + /^\d+-/.test(branchName) || + FEATURE_PATTERNS.some(p => branchName.includes(p)) || + (/^[a-zA-Z][a-zA-Z0-9]*$/.test(branchName) && branchName.length >= 15) + ) { + return 'feature'; + } + + if (/^[a-zA-Z][a-zA-Z0-9]*$/.test(branchName) && branchName.length < 15) { + return 'user'; + } + + return 'feature'; +} + +function toggleFeatureBranches() { + // localStorage: branch visibility preference persists across sessions + const showFeatures = localStorage.getItem('showFeatureBranches') === 'true'; + const newShowFeatures = !showFeatures; + localStorage.setItem('showFeatureBranches', newShowFeatures.toString()); + loadBranches(); +} + +function toggleUserBranches() { + // localStorage: branch visibility preference persists across sessions + const showUsers = localStorage.getItem('showUserBranches') !== 'false'; + const newShowUsers = !showUsers; + localStorage.setItem('showUserBranches', newShowUsers.toString()); + loadBranches(); +} + +async function handleBranchChange(e) { + if (!branchSelect) return; + + if (branchSelect.value === '__toggle_features__') { + toggleFeatureBranches(); + return; + } + + if (branchSelect.value === '__toggle_users__') { + toggleUserBranches(); + return; + } + + currentBranch = branchSelect.value; + + const qs = new URLSearchParams(location.search); + qs.set('branch', currentBranch); + const slugMatch = location.hash.match(/[#&?]p=([^&]+)/) || location.hash.match(/^#([^&]+)$/); + const slug = slugMatch ? decodeURIComponent(slugMatch[1]) : null; + + const newUrl = `${location.pathname}?${qs.toString()}${slug ? '#p=' + encodeURIComponent(slug) : ''}`; + history.replaceState(null, '', newUrl); + + // Clear caches and reload + sessionStorage.clear(); + window.dispatchEvent(new CustomEvent('branchChanged', { detail: { branch: currentBranch } })); +} + +export async function loadBranches() { + if (!branchSelect) return; + + branchSelect.disabled = true; + branchSelect.innerHTML = ``; + + try { + const branches = await getBranches(currentOwner, currentRepo); + + const mainBranches = []; + const userBranchesArr = []; + const featureBranches = []; + + for (const b of branches) { + const category = classifyBranch(b.name); + switch (category) { + case 'main': + mainBranches.push(b); + break; + case 'user': + userBranchesArr.push(b); + break; + case 'feature': + featureBranches.push(b); + break; + } + } + + userBranchesArr.sort((a, b) => a.name.localeCompare(b.name)); + featureBranches.sort((a, b) => a.name.localeCompare(b.name)); + + branchSelect.innerHTML = ''; + + // Main branches + for (const b of mainBranches) { + const opt = document.createElement('option'); + opt.value = b.name; + opt.textContent = b.name; + branchSelect.appendChild(opt); + } + + // User branches + if (userBranchesArr.length > 0) { + const showUsers = localStorage.getItem('showUserBranches') !== 'false'; + const userGroup = document.createElement('optgroup'); + userGroup.label = `${showUsers ? '▼' : '▶'} User Branches (${userBranchesArr.length})`; + + if (showUsers) { + for (const b of userBranchesArr) { + const opt = document.createElement('option'); + opt.value = b.name; + opt.textContent = ` ${b.name}`; + userGroup.appendChild(opt); + } + } + branchSelect.appendChild(userGroup); + } + + // Feature branches + if (featureBranches.length > 0) { + const showFeatures = localStorage.getItem('showFeatureBranches') === 'true'; + const featureGroup = document.createElement('optgroup'); + featureGroup.label = `${showFeatures ? '▼' : '▶'} Feature Branches (${featureBranches.length})`; + + if (showFeatures) { + for (const b of featureBranches) { + const opt = document.createElement('option'); + opt.value = b.name; + opt.textContent = ` ${b.name}`; + featureGroup.appendChild(opt); + } + } + branchSelect.appendChild(featureGroup); + } + + // Add current branch if not in list + if (![...branchSelect.options].some(o => o.value === currentBranch)) { + const opt = document.createElement('option'); + opt.value = currentBranch; + opt.textContent = `${currentBranch} (unlisted)`; + branchSelect.appendChild(opt); + } + + branchSelect.value = currentBranch; + branchSelect.title = ''; + } catch (e) { + branchSelect.innerHTML = ``; + branchSelect.title = (e && e.message) ? e.message : 'Failed to load branches'; + } finally { + branchSelect.disabled = false; + } +} diff --git a/src/modules/github-api.js b/src/modules/github-api.js new file mode 100644 index 0000000..f943d99 --- /dev/null +++ b/src/modules/github-api.js @@ -0,0 +1,133 @@ +// ===== GitHub API Module ===== + +let viaProxy = (url) => url; // no proxy, fetch directly from GitHub + +export function setViaProxy(proxyFn) { + viaProxy = proxyFn; +} + +export async function fetchJSON(url) { + const res = await fetch(viaProxy(url), { + cache: 'no-store', + headers: { 'Accept': 'application/vnd.github+json' } + }); + if (!res.ok) { + const txt = await res.text().catch(() => ''); + const err = new Error(`GitHub API ${res.status} ${res.statusText} ${txt.slice(0, 140)}`); + err.status = res.status; + throw err; + } + return res.json(); +} + +export async function listPromptsViaContents(owner, repo, branch, path = 'prompts') { + const url = `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${encodeURIComponent(branch)}&ts=${Date.now()}`; + const entries = await fetchJSON(url); + if (!Array.isArray(entries)) return []; + + const results = []; + for (const entry of entries) { + if (entry.type === 'file' && /\.md$/i.test(entry.name)) { + results.push({ + type: 'file', + name: entry.name, + path: entry.path, + sha: entry.sha, + download_url: entry.download_url + }); + } else if (entry.type === 'dir') { + const children = await listPromptsViaContents(owner, repo, branch, entry.path); + results.push(...children); + } + } + return results; +} + +export async function listPromptsViaTrees(owner, repo, branch) { + const url = `https://api.github.com/repos/${owner}/${repo}/git/trees/${encodeURIComponent(branch)}?recursive=1&ts=${Date.now()}`; + const data = await fetchJSON(url); + const items = (data.tree || []).filter(n => n.type === 'blob' && /^prompts\/.+\.md$/i.test(n.path)); + return items.map(n => ({ + type: 'file', + name: n.path.split('/').pop(), + path: n.path, + sha: n.sha + })); +} + +export async function fetchRawFile(owner, repo, branch, path) { + const url = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}?ts=${Date.now()}`; + const res = await fetch(url, { cache: 'no-store' }); + if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`); + return res.text(); +} + +// ===== Gist Handling ===== + +const GIST_POINTER_REGEX = /^https:\/\/gist\.githubusercontent\.com\/\S+\/raw\/\S+$/i; +const GIST_URL_REGEX = /^https:\/\/gist\.github\.com\/[\w-]+\/[a-f0-9]+\/?(?:#file-[\w.-]+)?(?:\?file=[\w.-]+)?$/i; + +export async function resolveGistRawUrl(gistUrl) { + if (GIST_POINTER_REGEX.test(gistUrl)) { + return gistUrl; + } + + const match = gistUrl.match(/^https:\/\/gist\.github\.com\/([\w-]+)\/([a-f0-9]+)\/?(?:#file-([\w.-]+))?(?:\?file=([\w.-]+))?$/i); + if (!match) { + throw new Error('Invalid gist URL format'); + } + + const [, user, gistId, fragmentFile, queryFile] = match; + const targetFile = fragmentFile || queryFile; + + if (targetFile) { + return `https://gist.githubusercontent.com/${user}/${gistId}/raw/${targetFile}`; + } else { + const apiUrl = `https://api.github.com/gists/${gistId}`; + const res = await fetch(viaProxy(apiUrl)); + if (!res.ok) { + throw new Error(`Failed to fetch gist metadata: ${res.status}`); + } + const gistData = await res.json(); + const files = Object.keys(gistData.files); + + let bestFile = files.find(f => f.endsWith('.md')) || files[0]; + + if (!bestFile) { + throw new Error('No files found in gist'); + } + + return `https://gist.githubusercontent.com/${user}/${gistId}/raw/${bestFile}`; + } +} + +export async function fetchGistContent(gistUrl, cache = new Map()) { + const cacheKey = `gist:${gistUrl}`; + if (cache.has(cacheKey)) { + return cache.get(cacheKey); + } + const url = gistUrl.includes('?') + ? `${gistUrl}&ts=${Date.now()}` + : `${gistUrl}?ts=${Date.now()}`; + const res = await fetch(url, { cache: 'no-store' }); + if (!res.ok) { + throw new Error(`Gist fetch failed: ${res.status} ${res.statusText}`); + } + const text = await res.text(); + cache.set(cacheKey, text); + return text; +} + +export function isGistPointer(text) { + const trimmed = text.trim(); + return GIST_POINTER_REGEX.test(trimmed) || GIST_URL_REGEX.test(trimmed); +} + +export function isGistUrl(url) { + return GIST_POINTER_REGEX.test(url) || GIST_URL_REGEX.test(url); +} + +export async function getBranches(owner, repo) { + const url = `https://api.github.com/repos/${owner}/${repo}/branches?per_page=100&ts=${Date.now()}`; + return fetchJSON(viaProxy(url)); +} diff --git a/src/modules/jules.js b/src/modules/jules.js new file mode 100644 index 0000000..c5dd44c --- /dev/null +++ b/src/modules/jules.js @@ -0,0 +1,247 @@ +// ===== Jules Integration Module ===== + +import { getCurrentUser } from './auth.js'; + +export async function checkJulesKey(uid) { + try { + if (!window.db) { + console.error('Firestore not initialized'); + return false; + } + const doc = await window.db.collection('julesKeys').doc(uid).get(); + return doc.exists; + } catch (error) { + console.error('Error checking Jules key:', error); + return false; + } +} + +export async function deleteStoredJulesKey(uid) { + try { + if (!window.db) return false; + await window.db.collection('julesKeys').doc(uid).delete(); + return true; + } catch (error) { + console.error('Error deleting Jules key:', error); + return false; + } +} + +export async function encryptAndStoreKey(plaintext, uid) { + try { + const paddedUid = (uid + '\0'.repeat(32)).slice(0, 32); + const keyData = new TextEncoder().encode(paddedUid); + const key = await window.crypto.subtle.importKey('raw', keyData, { name: 'AES-GCM' }, false, ['encrypt']); + + const ivString = uid.slice(0, 12).padEnd(12, '0'); + const iv = new TextEncoder().encode(ivString).slice(0, 12); + const plaintextData = new TextEncoder().encode(plaintext); + const ciphertext = await window.crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintextData); + const encrypted = btoa(String.fromCharCode(...new Uint8Array(ciphertext))); + + if (!window.db) throw new Error('Firestore not initialized'); + await window.db.collection('julesKeys').doc(uid).set({ + key: encrypted, + storedAt: firebase.firestore.FieldValue.serverTimestamp() + }); + return true; + } catch (error) { + console.error('Failed to encrypt/store key:', error); + throw error; + } +} + +export async function callRunJulesFunction(promptText) { + const user = window.auth ? window.auth.currentUser : null; + if (!user) { + alert('Not logged in.'); + return null; + } + + try { + const julesBtn = document.getElementById('julesBtn'); + const originalText = julesBtn.textContent; + julesBtn.textContent = 'Running...'; + julesBtn.disabled = true; + + const token = await user.getIdToken(true); + const functionUrl = 'https://us-central1-prompt-sharing-f8eeb.cloudfunctions.net/runJulesHttp'; + + const response = await fetch(functionUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ promptText: promptText || '' }) + }); + + const result = await response.json(); + if (!response.ok) { + throw new Error(result.error || `HTTP ${response.status}`); + } + + julesBtn.textContent = originalText; + julesBtn.disabled = false; + + return result.sessionUrl || null; + } catch (error) { + console.error('Cloud function call failed:', error); + alert('Failed to invoke Jules function: ' + error.message); + const julesBtn = document.getElementById('julesBtn'); + julesBtn.textContent = '⚡ Try in Jules'; + julesBtn.disabled = false; + return null; + } +} + +export async function handleTryInJules(promptText) { + try { + const user = window.auth ? window.auth.currentUser : null; + if (!user) { + try { + const { signInWithGitHub } = await import('./auth.js'); + await signInWithGitHub(); + setTimeout(() => handleTryInJulesAfterAuth(promptText), 500); + } catch (error) { + alert('Login required to use Jules.'); + } + return; + } + await handleTryInJulesAfterAuth(promptText); + } catch (error) { + console.error('Error in Try in Jules:', error); + alert('An error occurred: ' + error.message); + } +} + +export async function handleTryInJulesAfterAuth(promptText) { + const user = window.auth ? window.auth.currentUser : null; + if (!user) { + alert('Not logged in.'); + return; + } + + try { + const hasKey = await checkJulesKey(user.uid); + if (!hasKey) { + showJulesKeyModal(() => handleJulesSaveKey(promptText, user.uid)); + } else { + const sessionUrl = await callRunJulesFunction(promptText); + if (sessionUrl) { + window.open(sessionUrl, '_blank', 'noopener,noreferrer'); + } + } + } catch (error) { + console.error('Error in Jules flow:', error); + alert('An error occurred. Please try again.'); + } +} + +export function showJulesKeyModal(onSave) { + const modal = document.getElementById('julesKeyModal'); + const input = document.getElementById('julesKeyInput'); + modal.classList.add('show'); + input.value = ''; + input.focus(); + + const saveBtn = document.getElementById('julesSaveBtn'); + const cancelBtn = document.getElementById('julesCancelBtn'); + + const handleSave = async () => { + const apiKey = input.value.trim(); + if (!apiKey) { + alert('Please enter your Jules API key.'); + return; + } + + try { + saveBtn.textContent = 'Saving...'; + saveBtn.disabled = true; + + const user = window.auth ? window.auth.currentUser : null; + if (!user) { + alert('Not logged in.'); + saveBtn.textContent = 'Save & Continue'; + saveBtn.disabled = false; + return; + } + + await encryptAndStoreKey(apiKey, user.uid); + + hideJulesKeyModal(); + saveBtn.textContent = 'Save & Continue'; + saveBtn.disabled = false; + + if (onSave) onSave(); + } catch (error) { + console.error('Failed to save Jules key:', error); + alert('Failed to save API key: ' + error.message); + saveBtn.textContent = 'Save & Continue'; + saveBtn.disabled = false; + } + }; + + const handleCancel = () => { + hideJulesKeyModal(); + }; + + saveBtn.onclick = handleSave; + cancelBtn.onclick = handleCancel; +} + +export function hideJulesKeyModal() { + const modal = document.getElementById('julesKeyModal'); + modal.classList.remove('show'); +} + +export function initJulesKeyModalListeners() { + const modal = document.getElementById('julesKeyModal'); + const input = document.getElementById('julesKeyInput'); + + // Close modal on ESC key + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && modal.classList.contains('show')) { + hideJulesKeyModal(); + } + }); + + // Close modal on outside click + modal.addEventListener('click', (e) => { + if (e.target === modal) { + hideJulesKeyModal(); + } + }); + + // Allow Enter to save + input.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + document.getElementById('julesSaveBtn').click(); + } + }); +} + +// Expose for console testing +window.deleteJulesKey = async function() { + const user = window.auth?.currentUser; + if (!user) { + console.log('Not logged in'); + return; + } + const deleted = await deleteStoredJulesKey(user.uid); + if (deleted) { + console.log('✓ Jules key deleted. You can now enter a new one.'); + } else { + console.log('✗ Failed to delete Jules key'); + } +}; + +window.checkJulesKeyStatus = async function() { + const user = window.auth?.currentUser; + if (!user) { + console.log('Not logged in'); + return; + } + const hasKey = await checkJulesKey(user.uid); + console.log('Jules key stored:', hasKey ? '✓ Yes' : '✗ No'); +}; diff --git a/src/modules/prompt-list.js b/src/modules/prompt-list.js new file mode 100644 index 0000000..f8916b7 --- /dev/null +++ b/src/modules/prompt-list.js @@ -0,0 +1,429 @@ +// ===== Prompt List & Tree Module ===== + +import { slugify } from '../utils/slug.js'; +import { STORAGE_KEYS, PRETTY_TITLES, EMOJI_PATTERNS } from '../utils/constants.js'; +import { listPromptsViaContents, listPromptsViaTrees } from './github-api.js'; +import { clearElement, stopPropagation, setElementDisplay, toggleClass } from '../utils/dom-helpers.js'; + +let files = []; +let expandedState = new Set(); +let expandedStateKey = null; +let openSubmenus = new Set(); +let activeSubmenuHeaders = new Set(); +let currentSlug = null; + +// Sidebar elements +let listEl = null; +let searchEl = null; + +// Callback for selectFile - set by app.js to avoid circular dependency +let selectFileCallback = null; + +export function setSelectFileCallback(callback) { + selectFileCallback = callback; +} + +export function initPromptList() { + listEl = document.getElementById('list'); + searchEl = document.getElementById('search'); + if (searchEl) { + searchEl.addEventListener('input', () => renderList(files)); + } +} + +export function getFiles() { + return files; +} + +export function getCurrentSlug() { + return currentSlug; +} + +export function setCurrentSlug(slug) { + currentSlug = slug; +} + +export function ensureAncestorsExpanded(path) { + const ancestors = ancestorPaths(path); + let changed = false; + for (const dir of ancestors) { + if (!expandedState.has(dir)) { + expandedState.add(dir); + changed = true; + } + } + if (changed) persistExpandedState(); + return changed; +} + +function prettyTitle(name) { + const base = name.replace(/\.md$/i, ""); + if (!PRETTY_TITLES) return base; + + for (const [key, { emoji, keywords }] of Object.entries(EMOJI_PATTERNS)) { + if (keywords.some(kw => new RegExp(kw, 'i').test(base))) { + return emoji + " " + base; + } + } + return base; +} + +function getExpandedStateKey(owner, repo, branch) { + return STORAGE_KEYS.expandedState(owner, repo, branch); +} + +export function loadExpandedState(owner, repo, branch) { + const key = getExpandedStateKey(owner, repo, branch); + if (expandedStateKey === key) return; + expandedStateKey = key; + try { + const raw = sessionStorage.getItem(key); + const parsed = raw ? JSON.parse(raw) : []; + expandedState = new Set(Array.isArray(parsed) ? parsed : []); + } catch { + expandedState = new Set(); + } + expandedState.add('prompts'); +} + +export function persistExpandedState() { + // sessionStorage: expanded state is per-session (cleared on refresh) + // This prevents expanded folders from persisting after page reload + const key = expandedStateKey; + if (!key) return; + try { + sessionStorage.setItem(key, JSON.stringify([...expandedState])); + } catch {} +} + +export function toggleDirectory(path, expand) { + const before = expandedState.has(path); + if (expand) { + expandedState.add(path); + } else { + expandedState.delete(path); + } + if (before !== expand) { + persistExpandedState(); + } + renderList(files); +} + +function closeAllSubmenus() { + openSubmenus.forEach(submenu => { + submenu.style.display = 'none'; + }); + activeSubmenuHeaders.forEach(header => { + header.classList.remove('submenu-open'); + }); + openSubmenus.clear(); + activeSubmenuHeaders.clear(); +} + +function ancestorPaths(path) { + const parts = path.split('/'); + const ancestors = []; + for (let i = 0; i < parts.length - 1; i++) { + ancestors.push(parts.slice(0, i + 1).join('/')); + } + return ancestors; +} + +function buildTree(items) { + const root = { type: 'dir', name: 'prompts', path: 'prompts', children: new Map() }; + for (const item of items) { + if (!item.path) continue; + const relative = item.path.replace(/^prompts\/?/, ''); + const segments = relative.split('/'); + let node = root; + let currentPath = 'prompts'; + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + const isFile = i === segments.length - 1; + if (isFile) { + node.children.set(segment, { ...item, type: 'file' }); + } else { + currentPath = `${currentPath}/${segment}`; + if (!node.children.has(segment)) { + node.children.set(segment, { + type: 'dir', + name: segment, + path: currentPath, + children: new Map() + }); + } + node = node.children.get(segment); + } + } + } + return root; +} + +function renderTree(node, container, forcedExpanded, owner, repo, branch) { + const entries = Array.from(node.children.values()); + entries.sort((a, b) => { + if (a.type !== b.type) return a.type === 'dir' ? -1 : 1; + const aName = (a.name || '').toLowerCase(); + const bName = (b.name || '').toLowerCase(); + return aName.localeCompare(bName); + }); + + for (const entry of entries) { + if (entry.type === 'dir') { + const li = document.createElement('li'); + const header = document.createElement('div'); + header.className = 'tree-dir'; + + const toggle = document.createElement('button'); + toggle.type = 'button'; + const isForced = forcedExpanded.has(entry.path); + const isExpanded = isForced || expandedState.has(entry.path); + toggle.textContent = isExpanded ? '▾' : '▸'; + toggle.addEventListener('click', (ev) => { + stopPropagation(ev); + toggleDirectory(entry.path, !isExpanded); + }); + + header.addEventListener('click', (ev) => { + stopPropagation(ev); + toggleDirectory(entry.path, !isExpanded); + }); + + const label = document.createElement('span'); + label.className = 'folder-name'; + label.textContent = entry.name; + + const iconsContainer = document.createElement('div'); + iconsContainer.className = 'folder-icons'; + + const ghIcon = document.createElement('span'); + ghIcon.className = 'github-folder-icon'; + ghIcon.textContent = '🗂️'; + ghIcon.title = 'Open directory on GitHub'; + ghIcon.addEventListener('click', (ev) => { + stopPropagation(ev); + const ghUrl = `https://github.com/${owner}/${repo}/tree/${branch}/${entry.path}`; + window.open(ghUrl, '_blank', 'noopener,noreferrer'); + }); + + const addIcon = document.createElement('span'); + addIcon.className = 'add-file-icon'; + addIcon.textContent = '+'; + addIcon.title = 'Create new file in this directory'; + + const submenu = document.createElement('div'); + submenu.style.position = 'absolute'; + submenu.style.background = 'var(--card)'; + submenu.style.border = '1px solid var(--border)'; + submenu.style.borderRadius = '8px'; + submenu.style.padding = '6px 0'; + submenu.style.boxShadow = '0 4px 10px rgba(0,0,0,0.3)'; + submenu.style.display = 'none'; + submenu.style.zIndex = '10'; + + const makeMenuItem = (label, emoji, onClick) => { + const item = document.createElement('div'); + item.textContent = `${emoji} ${label}`; + item.style.padding = '6px 14px'; + item.style.cursor = 'pointer'; + item.style.fontSize = '13px'; + item.style.color = 'var(--text)'; + item.addEventListener('mouseenter', () => item.style.background = '#1a1f35'); + item.addEventListener('mouseleave', () => item.style.background = 'transparent'); + item.addEventListener('click', (e) => { + e.stopPropagation(); + submenu.style.display = 'none'; + onClick(); + }); + return item; + }; + + submenu.appendChild(makeMenuItem("Prompt (blank)", "📝", () => { + const newFilePath = entry.path ? `${entry.path}/new-prompt.md` : 'new-prompt.md'; + const ghUrl = `https://github.com/${owner}/${repo}/new/${branch}?filename=${encodeURIComponent(newFilePath)}`; + window.open(ghUrl, '_blank', 'noopener,noreferrer'); + })); + + submenu.appendChild(makeMenuItem("Conversation (template)", "💬", () => { + const template = `**Conversation Link (Codex, Jules, etc):** [https://chatgpt.com/s/...]\n\n### Prompt\n[paste your full prompt here]\n\n### Additional Info\n[context, notes, or follow-up thoughts]\n`; + const encoded = encodeURIComponent(template); + const newFilePath = entry.path ? `${entry.path}/new-conversation.md` : 'new-conversation.md'; + const ghUrl = `https://github.com/${owner}/${repo}/new/${branch}?filename=${encodeURIComponent(newFilePath)}&value=${encoded}`; + window.open(ghUrl, '_blank', 'noopener,noreferrer'); + })); + + document.body.appendChild(submenu); + + addIcon.addEventListener('click', (ev) => { + stopPropagation(ev); + + const wasOpen = submenu.style.display === 'block'; + closeAllSubmenus(); + + if (!wasOpen) { + const rect = addIcon.getBoundingClientRect(); + + submenu.style.display = 'block'; + submenu.style.visibility = 'hidden'; + const submenuRect = submenu.getBoundingClientRect(); + + let left = rect.right; + let top = rect.top; + + if (left + submenuRect.width > window.innerWidth - 10) { + left = rect.left - submenuRect.width; + } + + if (top + submenuRect.height > window.innerHeight - 10) { + top = rect.bottom - submenuRect.height; + } + + if (left < 10) { + left = 10; + } + + if (top < 10) { + top = 10; + } + + submenu.style.left = left + 'px'; + submenu.style.top = top + 'px'; + submenu.style.visibility = 'visible'; + openSubmenus.add(submenu); + header.classList.add('submenu-open'); + activeSubmenuHeaders.add(header); + } + }); + + document.addEventListener('click', () => closeAllSubmenus()); + + iconsContainer.appendChild(ghIcon); + iconsContainer.appendChild(addIcon); + + header.appendChild(toggle); + header.appendChild(label); + header.appendChild(iconsContainer); + li.appendChild(header); + + const childList = document.createElement('ul'); + childList.style.display = isExpanded ? 'block' : 'none'; + li.appendChild(childList); + renderTree(entry, childList, forcedExpanded, owner, repo, branch); + if (!childList.children.length) { + continue; + } + container.appendChild(li); + } else { + const file = entry; + const li = document.createElement('li'); + const slug = slugify(file.path); + const a = document.createElement('a'); + a.className = 'item'; + a.href = `#p=${encodeURIComponent(slug)}`; + a.dataset.slug = slug; + a.addEventListener('click', (ev) => { + ev.preventDefault(); + if (selectFileCallback) { + selectFileCallback(file, true, owner, repo, branch).catch(err => { + console.error('Error selecting file:', err); + }); + } + }); + + const left = document.createElement('div'); + left.style.display = 'flex'; + left.style.flexDirection = 'column'; + left.style.gap = '2px'; + const t = document.createElement('div'); + t.className = 'item-title'; + t.textContent = prettyTitle(file.name); + left.appendChild(t); + a.appendChild(left); + li.appendChild(a); + container.appendChild(li); + } + } +} + +export function updateActiveItem() { + const anchors = listEl.querySelectorAll('.item'); + anchors.forEach((a) => { + if (a.dataset.slug === currentSlug) { + a.classList.add('active'); + } else { + a.classList.remove('active'); + } + }); +} + +export function renderList(items, owner, repo, branch) { + loadExpandedState(owner, repo, branch); + const q = searchEl && searchEl.value ? searchEl.value.trim().toLowerCase() : ''; + const searchActive = Boolean(q); + const filtered = !q + ? items.slice() + : items.filter(f => { + const name = f.name?.toLowerCase?.() || ''; + const path = f.path?.toLowerCase?.() || ''; + return name.includes(q) || path.includes(q); + }); + + if (!filtered.length) { + clearElement(listEl); + listEl.innerHTML = '
No prompts found.
'; + return; + } + + const forcedExpanded = new Set(); + if (searchActive) { + for (const file of filtered) { + for (const ancestor of ancestorPaths(file.path)) { + forcedExpanded.add(ancestor); + } + } + } + + clearElement(listEl); + const rootList = document.createElement('ul'); + listEl.appendChild(rootList); + const tree = buildTree(filtered); + renderTree(tree, rootList, forcedExpanded, owner, repo, branch); + updateActiveItem(); +} + +export async function loadList(owner, repo, branch, cacheKey) { + try { + const cached = sessionStorage.getItem(cacheKey); + if (cached) { + files = JSON.parse(cached); + renderList(files, owner, repo, branch); + refreshList(owner, repo, branch, cacheKey).catch(() => {}); + return files; + } + + await refreshList(owner, repo, branch, cacheKey); + return files; + } catch (e) { + clearElement(listEl); + listEl.innerHTML = `
+ Could not load prompts from ${owner}/${repo}@${branch}/prompts.
${e.message} +
`; + return []; + } +} + +export async function refreshList(owner, repo, branch, cacheKey) { + let data; + try { + data = await listPromptsViaContents(owner, repo, branch); + } catch (e) { + if (e.status === 403 || e.status === 404) { + data = await listPromptsViaTrees(owner, repo, branch); + } else { + throw e; + } + } + files = (data || []).filter(x => x && x.type === 'file' && typeof x.path === 'string'); + sessionStorage.setItem(cacheKey, JSON.stringify(files)); + renderList(files, owner, repo, branch); +} diff --git a/src/modules/prompt-renderer.js b/src/modules/prompt-renderer.js new file mode 100644 index 0000000..6ae3543 --- /dev/null +++ b/src/modules/prompt-renderer.js @@ -0,0 +1,289 @@ +// ===== Prompt Renderer Module ===== + +import { slugify } from '../utils/slug.js'; +import { isGistUrl, resolveGistRawUrl, fetchGistContent, fetchRawFile } from './github-api.js'; +import { CODEX_URL_REGEX } from '../utils/constants.js'; +import { setElementDisplay } from '../utils/dom-helpers.js'; +import { ensureAncestorsExpanded, loadExpandedState, persistExpandedState, renderList, updateActiveItem, setCurrentSlug, getCurrentSlug, getFiles } from './prompt-list.js'; + +let cacheRaw = new Map(); +let currentPromptText = null; + +// Callbacks to avoid circular dependencies +let handleTryInJulesCallback = null; + +export function setHandleTryInJulesCallback(callback) { + handleTryInJulesCallback = callback; +} + +// DOM elements +let contentEl = null; +let titleEl = null; +let metaEl = null; +let emptyEl = null; +let actionsEl = null; +let copyBtn = null; +let rawBtn = null; +let ghBtn = null; +let editBtn = null; +let shareBtn = null; +let julesBtn = null; + +export function initPromptRenderer() { + contentEl = document.getElementById('content'); + titleEl = document.getElementById('title'); + metaEl = document.getElementById('meta'); + emptyEl = document.getElementById('empty'); + actionsEl = document.getElementById('actions'); + copyBtn = document.getElementById('copyBtn'); + rawBtn = document.getElementById('rawBtn'); + ghBtn = document.getElementById('ghBtn'); + editBtn = document.getElementById('editBtn'); + shareBtn = document.getElementById('shareBtn'); + julesBtn = document.getElementById('julesBtn'); + + if (copyBtn) copyBtn.addEventListener('click', handleCopyPrompt); + if (shareBtn) shareBtn.addEventListener('click', handleShareLink); + if (julesBtn) { + julesBtn.addEventListener('click', () => { + if (handleTryInJulesCallback) { + handleTryInJulesCallback(currentPromptText); + } + }); + } +} + +export function getCurrentPromptText() { + return currentPromptText; +} + +export function setCurrentPromptText(text) { + currentPromptText = text; +} + +export async function selectBySlug(slug, files, owner, repo, branch) { + try { + const f = files.find(x => slugify(x.path) === slug); + if (f) await selectFile(f, false, owner, repo, branch); + } catch (error) { + console.error('Error selecting file by slug:', error); + } +} + +export async function selectFile(f, pushHash, owner, repo, branch) { + if (!f) { + if (editBtn) { + editBtn.style.display = 'none'; + editBtn.removeAttribute('href'); + } + return; + } + + setElementDisplay(emptyEl, false); + setElementDisplay(titleEl, true); + setElementDisplay(metaEl, true); + setElementDisplay(actionsEl, true); + + titleEl.textContent = f.name.replace(/\.md$/i, ''); + metaEl.textContent = `File: ${f.path}`; + + const slug = slugify(f.path); + if (pushHash) history.pushState(null, '', `#p=${encodeURIComponent(slug)}`); + setCurrentSlug(slug); + + const expanded = ensureAncestorsExpanded(f.path); + if (expanded) { + renderList(getFiles(), owner, repo, branch); + } else { + updateActiveItem(); + } + + let raw; + let isGistContent = false; + let isCodexContent = false; + let gistUrl = null; + let codexUrl = null; + + let cached = cacheRaw.get(slug); + if (cached) { + console.log('Using cached content:', typeof cached); + if (typeof cached === 'string') { + raw = cached; + } else { + if (cached.gistUrl) { + isGistContent = true; + gistUrl = cached.gistUrl; + try { + const finalRawUrl = cached.rawGistUrl || await resolveGistRawUrl(cached.gistUrl); + const gistBody = await fetchGistContent(finalRawUrl, cacheRaw); + raw = gistBody; + cached.body = gistBody; + cached.rawGistUrl = finalRawUrl; + } catch (err) { + console.error('Failed to refetch gist:', err); + raw = cached.body || `Error loading gist: ${err.message}`; + } + } else if (cached.codexUrl) { + isCodexContent = true; + codexUrl = cached.codexUrl; + raw = cached.body; + } else { + raw = cached.body || cached; + } + } + } else { + const text = await fetchRawFile(owner, repo, branch, f.path); + const trimmed = text.trim(); + + if (isGistUrl(trimmed)) { + isGistContent = true; + gistUrl = trimmed; + try { + const rawGistUrl = await resolveGistRawUrl(trimmed); + const gistBody = await fetchGistContent(rawGistUrl, cacheRaw); + raw = gistBody; + cacheRaw.set(slug, { body: gistBody, gistUrl: trimmed, rawGistUrl }); + } catch (err) { + console.error('Failed to fetch gist:', err); + raw = text; + cacheRaw.set(slug, { body: text, gistUrl: trimmed, error: err.message }); + } + } else if (CODEX_URL_REGEX.test(trimmed)) { + isCodexContent = true; + codexUrl = trimmed; + raw = trimmed; + cacheRaw.set(slug, { body: raw, codexUrl: trimmed }); + } else { + raw = text; + cacheRaw.set(slug, raw); + } + } + + // Update button states and links + if (isGistContent && gistUrl) { + editBtn.textContent = '✏️ Edit Link'; + editBtn.title = 'Edit the gist link'; + ghBtn.textContent = '🗂️ View on Gist'; + ghBtn.title = 'Open the gist on GitHub'; + ghBtn.href = gistUrl; + const blob = new Blob([raw], { type: 'text/plain' }); + const dataUrl = URL.createObjectURL(blob); + rawBtn.href = dataUrl; + rawBtn.removeAttribute('download'); + rawBtn.title = 'Open gist content in new tab'; + } else if (isCodexContent && codexUrl) { + editBtn.textContent = '✏️ Edit Link'; + editBtn.title = 'Edit the codex link'; + ghBtn.textContent = '💬 View on Codex'; + ghBtn.title = 'Open the conversation on Codex'; + ghBtn.href = codexUrl; + ghBtn.target = '_blank'; + const blob = new Blob([codexUrl], { type: 'text/plain' }); + const dataUrl = URL.createObjectURL(blob); + rawBtn.href = dataUrl; + rawBtn.target = '_blank'; + rawBtn.removeAttribute('download'); + rawBtn.title = 'Open raw link in new tab'; + } else { + editBtn.textContent = '✏️ Edit on GitHub'; + editBtn.title = 'Edit the file on GitHub'; + ghBtn.textContent = '🗂️ View on GitHub'; + ghBtn.title = 'Open the file on GitHub'; + ghBtn.href = `https://github.com/${owner}/${repo}/blob/${branch}/${f.path}`; + rawBtn.href = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${f.path}`; + rawBtn.title = 'Open raw markdown'; + } + editBtn.style.display = ''; + editBtn.href = `https://github.com/${owner}/${repo}/edit/${branch}/${f.path}`; + + if (isCodexContent) { + copyBtn.style.display = 'none'; + shareBtn.textContent = '🔗 Copy link'; + } else { + copyBtn.style.display = ''; + copyBtn.textContent = '📋 Copy prompt'; + shareBtn.textContent = '🔗 Copy link'; + } + + // Update title and content + const firstLine = raw.split(/\r?\n/)[0]; + if (/^#\s+/.test(firstLine)) { + titleEl.textContent = firstLine.replace(/^#\s+/, ''); + } + + if (isGistContent) { + const looksLikeMarkdown = /^#|^\*|^-|^\d+\.|```/.test(raw.trim()); + if (!looksLikeMarkdown) { + const wrappedContent = '```\n' + raw + '\n```'; + contentEl.innerHTML = marked.parse(wrappedContent, { breaks: true }); + } else { + contentEl.innerHTML = marked.parse(raw, { breaks: true }); + } + } else { + contentEl.innerHTML = marked.parse(raw, { breaks: true }); + } + + setCurrentPromptText(raw); + enhanceCodeBlocks(); +} + +function enhanceCodeBlocks() { + const pres = contentEl.querySelectorAll('pre'); + pres.forEach((pre) => { + if (pre.querySelector('.copy')) return; + const btn = document.createElement('button'); + btn.textContent = 'Copy'; + btn.className = 'btn copy'; + btn.style.position = 'absolute'; + btn.style.margin = '6px'; + btn.style.right = '8px'; + btn.style.transform = 'translateY(-2px)'; + const wrapper = document.createElement('div'); + wrapper.style.position = 'relative'; + pre.parentNode.insertBefore(wrapper, pre); + wrapper.appendChild(pre); + wrapper.appendChild(btn); + btn.addEventListener('click', async () => { + const code = pre.innerText; + try { + await navigator.clipboard.writeText(code); + btn.textContent = 'Copied'; + setTimeout(() => (btn.textContent = 'Copy'), 900); + } catch {} + }); + }); +} + +async function handleCopyPrompt() { + try { + let contentToCopy; + let buttonText; + + const isCodex = getCurrentPromptText() && CODEX_URL_REGEX.test(getCurrentPromptText().trim()); + if (isCodex) { + contentToCopy = getCurrentPromptText(); + buttonText = '📋 Copy link'; + } else { + contentToCopy = getCurrentPromptText(); + buttonText = '📋 Copy prompt'; + } + + await navigator.clipboard.writeText(contentToCopy); + copyBtn.textContent = 'Copied'; + setTimeout(() => (copyBtn.textContent = buttonText), 1000); + } catch { + alert('Clipboard blocked. Select and copy manually.'); + } +} + +async function handleShareLink() { + try { + await navigator.clipboard.writeText(location.href); + shareBtn.textContent = 'Link copied'; + } catch { + alert('Could not copy link.'); + } finally { + const originalText = '🔗 Copy link'; + setTimeout(() => (shareBtn.textContent = originalText), 1000); + } +} diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..5220b17 --- /dev/null +++ b/src/styles.css @@ -0,0 +1,423 @@ +/* ===== Color Variables & Base ===== */ +:root { + --bg: #0f1220; + --card: #171a2b; + --muted: #9aa3b2; + --text: #e8ecf1; + --accent: #5ad1ff; + --border: #262b45; +} + +* { box-sizing: border-box; } + +html, body { + height: 100%; + margin: 0; + font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, Noto Sans, Liberation Sans, Helvetica Neue, sans-serif; + background: var(--bg); + color: var(--text); +} + +/* ===== Header ===== */ +header { + padding: 20px 16px; + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + background: linear-gradient(180deg, rgba(15,18,32,0.96), rgba(15,18,32,0.85)); + backdrop-filter: blur(6px); + z-index: 2; +} + +/* ===== Layout ===== */ +.wrap { + max-width: 1100px; + margin: 0 auto; + display: grid; + grid-template-columns: 300px 1fr; + gap: 16px; + padding: 18px 16px 40px; +} + +@media (max-width: 900px) { + .wrap { + grid-template-columns: 1fr; + } +} + +/* ===== Cards ===== */ +.card { + background: var(--card); + border: 1px solid var(--border); + border-radius: 14px; +} + +/* ===== Sidebar ===== */ +#sidebar { + padding: 10px; + display: flex; + flex-direction: column; + gap: 10px; + height: fit-content; + position: sticky; + top: 76px; +} + +#search { + width: 100%; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--border); + background: #12162a; + color: var(--text); + outline: none; + font-size: 14px; +} + +#search::placeholder { + color: var(--muted); +} + +#list { + max-height: 70vh; + overflow: auto; + padding-right: 4px; +} + +#list ul { + list-style: none; + margin: 0; + padding-left: 16px; +} + +#list > ul { + padding-left: 0; +} + +/* ===== Tree Navigation ===== */ +.tree-dir { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border-radius: 10px; + cursor: pointer; + color: var(--text); +} + +.tree-dir:hover { + background: #141936; +} + +.tree-dir.submenu-open { + background: #151f3c; + border: 1px solid var(--accent); +} + +.tree-dir button { + appearance: none; + border: none; + background: none; + color: var(--muted); + cursor: pointer; + font-size: 12px; + display: flex; + align-items: center; + justify-content: center; + width: 18px; +} + +.tree-dir button:focus { + outline: 1px solid var(--accent); + border-radius: 4px; +} + +.tree-dir .folder-name { + font-size: 13px; + font-weight: 600; + flex: 1; +} + +.tree-dir .folder-icons { + display: flex; + gap: 4px; + margin-left: auto; +} + +.tree-dir .folder-icons span { + cursor: pointer; + opacity: 0.6; + transition: all 0.2s; + padding: 4px; + border-radius: 4px; + min-width: 28px; + min-height: 28px; + display: flex; + align-items: center; + justify-content: center; +} + +.tree-dir .folder-icons span:hover { + opacity: 1; + background: rgba(255, 255, 255, 0.05); +} + +.tree-dir .add-file-icon { + color: #4ade80; + font-weight: bold; +} + +.tree-dir .add-file-icon:hover { + background: rgba(74, 222, 128, 0.1) !important; +} + +/* ===== List Items ===== */ +.item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 10px; + border-radius: 10px; + border: 1px solid transparent; + background: #12162a; + cursor: pointer; +} + +.item:hover { + border-color: var(--border); + background: #141936; +} + +.item-title { + font-size: 14px; + font-weight: 600; +} + +.item-meta { + color: var(--muted); + font-size: 12px; +} + +.item.active { + border-color: var(--accent); + background: #151f3c; +} + +/* ===== Main Content ===== */ +#main { + padding: 14px; +} + +#empty { + color: var(--muted); + font-size: 14px; +} + +#title { + margin: 0 0 6px; + font-size: 22px; +} + +#meta { + color: var(--muted); + font-size: 13px; + margin-bottom: 10px; +} + +#actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 14px; +} + +/* ===== Buttons ===== */ +.btn { + appearance: none; + border: 1px solid var(--border); + background: #11152a; + color: var(--text); + border-radius: 10px; + padding: 8px 10px; + font-size: 13px; + cursor: pointer; + transition: all 0.2s; +} + +.btn:hover { + border-color: #30365a; + background: #151a33; +} + +.btn:active { + transform: translateY(1px); +} + +/* ===== Content ===== */ +#content { + line-height: 1.55; + font-size: 15px; +} + +#content pre { + background: #0b0f21; + padding: 12px; + border: 1px solid var(--border); + border-radius: 10px; + overflow: auto; +} + +#content code { + background: #0b0f21; + padding: 1px 4px; + border-radius: 6px; + font-family: 'Courier New', monospace; +} + +a { + color: var(--accent); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +/* ===== Footer ===== */ +footer { + max-width: 1100px; + margin: 20px auto 40px; + color: var(--muted); + font-size: 13px; + padding: 0 16px; +} + +footer code { + background: #0b0f21; + padding: 1px 4px; + border-radius: 4px; + font-size: 12px; +} + +/* ===== Pills ===== */ +.pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + border-radius: 999px; + border: 1px solid var(--border); + background: #11152a; + font-size: 12px; + color: var(--muted); +} + +/* ===== Branch Selector ===== */ +#branchSelect { + appearance: none; + border: 1px solid var(--border); + background: #11152a; + color: var(--text); + border-radius: 10px; + padding: 8px 10px; + font-size: 13px; + cursor: pointer; + min-width: 140px; +} + +#branchSelect optgroup { + font-weight: 600; + color: var(--muted); + font-size: 12px; +} + +#branchSelect option { + color: var(--text); + font-weight: normal; + background: var(--card); +} + +#branchSelect option[value="__toggle_features__"], +#branchSelect option[value="__toggle_users__"] { + font-weight: 600; + color: var(--accent); + background: var(--card); + cursor: pointer; +} + +#branchSelect option[value="__toggle_features__"]:hover, +#branchSelect option[value="__toggle_users__"]:hover { + background: rgba(90, 209, 255, 0.1); +} + +/* ===== Jules Key Modal ===== */ +#julesKeyModal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + z-index: 1000; + display: none !important; + align-items: center; + justify-content: center; +} + +#julesKeyModal.show { + display: flex !important; +} + +#julesKeyModal > div { + background: var(--card); + border: 1px solid var(--border); + border-radius: 14px; + padding: 20px; + max-width: 400px; + width: 90%; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); +} + +#julesKeyModal h2 { + margin: 0 0 12px; + font-size: 18px; +} + +#julesKeyModal p { + margin: 0 0 16px; + color: var(--muted); + font-size: 13px; +} + +#julesKeyInput { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: #12162a; + color: var(--text); + outline: none; + margin-bottom: 16px; + box-sizing: border-box; + font-size: 14px; +} + +#julesKeyInput::placeholder { + color: var(--muted); +} + +.modal-buttons { + display: flex; + gap: 8px; + justify-content: flex-end; +} + +.modal-buttons .btn { + padding: 8px 12px; +} + +.modal-buttons .btn.primary { + border-color: var(--accent); + color: var(--accent); +} diff --git a/src/utils/constants.js b/src/utils/constants.js new file mode 100644 index 0000000..81a6d1c --- /dev/null +++ b/src/utils/constants.js @@ -0,0 +1,54 @@ +// ===== All Constants, Regex Patterns, and Magic Strings ===== + +export const OWNER = "ole-vi"; +export const REPO = "prompt-sharing"; +export const BRANCH = "main"; +export const PRETTY_TITLES = true; + +// GitHub API +export const GIST_POINTER_REGEX = /^https:\/\/gist\.githubusercontent\.com\/\S+\/raw\/\S+$/i; +export const GIST_URL_REGEX = /^https:\/\/gist\.github\.com\/[\w-]+\/[a-f0-9]+\/?(?:#file-[\w.-]+)?(?:\?file=[\w.-]+)?$/i; +export const CODEX_URL_REGEX = /^https:\/\/chatgpt\.com\/s\/[a-f0-9_]+$/i; + +// Emoji classification keywords +export const EMOJI_PATTERNS = { + review: { emoji: "🔍", keywords: ["review", "pr", "rubric"] }, + bug: { emoji: "🩹", keywords: ["bug", "triage", "fix"] }, + design: { emoji: "🧭", keywords: ["spec", "design", "plan"] }, + refactor: { emoji: "🧹", keywords: ["refactor"] } +}; + +// Branch classification +export const USER_BRANCHES = ["dogi", "jesse", "saksham"]; +export const FEATURE_PATTERNS = ["codex/", "feature/", "fix/", "bugfix/", "hotfix/"]; + +// SessionStorage keys +export const STORAGE_KEYS = { + expandedState: (owner, repo, branch) => `sidebar:expanded:${owner}/${repo}@${branch}`, + promptsCache: (owner, repo, branch) => `prompts:${owner}/${repo}@${branch}`, + showFeatureBranches: "showFeatureBranches", + showUserBranches: "showUserBranches" +}; + +// Error messages +export const ERRORS = { + FIREBASE_NOT_READY: "Firebase not initialized. Please refresh.", + GIST_FETCH_FAILED: "Failed to fetch gist content.", + AUTH_REQUIRED: "Authentication required.", + JULES_KEY_REQUIRED: "No Jules API key stored. Please save your API key first.", + CLIPBOARD_BLOCKED: "Clipboard blocked. Select and copy manually." +}; + +// UI text +export const UI_TEXT = { + LOADING: "Loading...", + SIGN_IN: "Sign in with GitHub", + SIGN_OUT: "Sign Out", + COPY_PROMPT: "📋 Copy prompt", + COPIED: "Copied", + COPY_LINK: "🔗 Copy link", + LINK_COPIED: "Link copied", + TRY_JULES: "⚡ Try in Jules", + RUNNING: "Running...", + SAVE_KEY: "Save & Continue" +}; diff --git a/src/utils/dom-helpers.js b/src/utils/dom-helpers.js new file mode 100644 index 0000000..7918195 --- /dev/null +++ b/src/utils/dom-helpers.js @@ -0,0 +1,34 @@ +// ===== Common DOM Helpers ===== + +export function createElement(tag, className = '', textContent = '') { + const el = document.createElement(tag); + if (className) el.className = className; + if (textContent) el.textContent = textContent; + return el; +} + +export function setElementDisplay(el, show = true) { + el.style.display = show ? '' : 'none'; +} + +export function toggleClass(el, className, force) { + if (force === undefined) { + el.classList.toggle(className); + } else { + el.classList.toggle(className, force); + } +} + +export function clearElement(el) { + while (el.firstChild) { + el.removeChild(el.firstChild); + } +} + +export function onElement(el, event, handler) { + if (el) el.addEventListener(event, handler); +} + +export function stopPropagation(e) { + e.stopPropagation(); +} diff --git a/src/utils/slug.js b/src/utils/slug.js new file mode 100644 index 0000000..58ffb8b --- /dev/null +++ b/src/utils/slug.js @@ -0,0 +1,10 @@ +// ===== Slug Generation ===== + +export function slugify(filePath) { + const base = filePath.replace(/\.md$/i, '').toLowerCase().replace(/\s+/g, '-'); + return encodeURIComponent(base); +} + +export function unslugify(slug) { + return decodeURIComponent(slug); +} diff --git a/src/utils/url-params.js b/src/utils/url-params.js new file mode 100644 index 0000000..6b9b8e5 --- /dev/null +++ b/src/utils/url-params.js @@ -0,0 +1,29 @@ +// ===== URL Parameter Parsing ===== + +export function parseParams() { + const out = {}; + const sources = [ + location.search || "", + location.hash && location.hash.includes("?") + ? location.hash.slice(location.hash.indexOf("?")) + : "" + ]; + for (const src of sources) { + const p = new URLSearchParams(src); + for (const [k, v] of p.entries()) { + out[k.toLowerCase()] = v; + } + } + return out; +} + +export function getHashParam(key) { + const match = location.hash.match(new RegExp(`[#&?]${key}=([^&]+)`)); + return match ? decodeURIComponent(match[1]) : null; +} + +export function setHashParam(key, value) { + const params = new URLSearchParams(location.hash.slice(1)); + params.set(key, value); + location.hash = params.toString(); +}