script.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631
  1. let bookmarks = [];
  2. let config = {};
  3. let filteredBookmarks = [];
  4. let selectedIndex = 0;
  5. let currentCommandSuggestions = [];
  6. const searchInput = document.getElementById('search-input');
  7. const resultsContainer = document.getElementById('results');
  8. const STORAGE_KEYS = {
  9. CONFIG: 'startpageConfig',
  10. BOOKMARKS: 'startpageBookmarks'
  11. };
  12. const DEFAULT_CONFIG = {
  13. backgroundColor: '#000000',
  14. textColor: '#ffffff',
  15. accentColor: '#4a9eff',
  16. searchEngine: 'https://kagi.com/search?q=',
  17. backgroundImage: '/Users/leonversteeg/Downloads/ChatGPT Image Feb 25, 2026, 02_14_59 PM.png',
  18. backgroundBlur: 0,
  19. maskColor: '#000000',
  20. maskOpacity: 80
  21. };
  22. const STYLE_CONSTANTS = {
  23. HOVER_OPACITY: 0.1,
  24. SELECTED_OPACITY: 0.2
  25. };
  26. /**
  27. * Loads data from localStorage or data.json file.
  28. * Falls back to default values if both sources fail.
  29. */
  30. async function loadData() {
  31. // Try localStorage first
  32. const savedConfig = localStorage.getItem(STORAGE_KEYS.CONFIG);
  33. const savedBookmarks = localStorage.getItem(STORAGE_KEYS.BOOKMARKS);
  34. /**
  35. if (savedConfig && savedBookmarks) {
  36. config = JSON.parse(savedConfig);
  37. bookmarks = JSON.parse(savedBookmarks);
  38. return;
  39. }*/
  40. // If localStorage is incomplete, try loading from data.json
  41. try {
  42. const response = await fetch('data.json');
  43. const data = await response.json();
  44. config = data.config || DEFAULT_CONFIG;
  45. bookmarks = data.bookmarks || [];
  46. } catch (error) {
  47. console.error('Failed to load data.json:', error);
  48. config = DEFAULT_CONFIG;
  49. bookmarks = [];
  50. }
  51. }
  52. function getOrCreateStyleElement(id) {
  53. let element = document.getElementById(id);
  54. if (!element) {
  55. element = document.createElement('style');
  56. element.id = id;
  57. document.head.appendChild(element);
  58. }
  59. return element;
  60. }
  61. function removeElementById(id) {
  62. const element = document.getElementById(id);
  63. if (element) {
  64. element.remove();
  65. }
  66. }
  67. function applyBackgroundImage(backgroundImage, backgroundBlur) {
  68. const blur = backgroundBlur || DEFAULT_CONFIG.backgroundBlur;
  69. const filterValue = blur > 0 ? `blur(${blur}px)` : 'none';
  70. const bgStyle = getOrCreateStyleElement('background-style');
  71. bgStyle.textContent = `
  72. body::before {
  73. content: '';
  74. position: fixed;
  75. top: 0;
  76. left: 0;
  77. width: 100%;
  78. height: 100%;
  79. background-image: url('${backgroundImage}');
  80. background-size: cover;
  81. background-position: center;
  82. background-repeat: no-repeat;
  83. filter: ${filterValue};
  84. z-index: -2;
  85. }
  86. `;
  87. document.body.style.backgroundImage = 'none';
  88. }
  89. function applyMask(maskColor, maskOpacity) {
  90. removeElementById('background-mask');
  91. const opacity = (maskOpacity || DEFAULT_CONFIG.maskOpacity) / 100;
  92. if (opacity > 0) {
  93. const mask = document.createElement('div');
  94. mask.id = 'background-mask';
  95. Object.assign(mask.style, {
  96. position: 'fixed',
  97. top: '0',
  98. left: '0',
  99. width: '100%',
  100. height: '100%',
  101. backgroundColor: maskColor || DEFAULT_CONFIG.maskColor,
  102. opacity: opacity.toString(),
  103. zIndex: '-1',
  104. pointerEvents: 'none'
  105. });
  106. document.body.appendChild(mask);
  107. }
  108. }
  109. function applyConfig() {
  110. const {
  111. backgroundColor,
  112. textColor,
  113. accentColor,
  114. backgroundImage,
  115. backgroundBlur,
  116. maskColor,
  117. maskOpacity
  118. } = config;
  119. document.body.style.backgroundColor = backgroundColor || DEFAULT_CONFIG.backgroundColor;
  120. document.body.style.color = textColor || DEFAULT_CONFIG.textColor;
  121. searchInput.style.color = textColor || DEFAULT_CONFIG.textColor;
  122. if (backgroundImage) {
  123. applyBackgroundImage(backgroundImage, backgroundBlur);
  124. applyMask(maskColor, maskOpacity);
  125. } else {
  126. document.body.style.backgroundImage = 'none';
  127. removeElementById('background-style');
  128. removeElementById('background-mask');
  129. }
  130. const color = accentColor || DEFAULT_CONFIG.accentColor;
  131. const style = getOrCreateStyleElement('dynamic-style');
  132. style.textContent = `
  133. .bookmark-item:hover,
  134. .bookmark-item.selected {
  135. border-left-color: ${color};
  136. }
  137. .bookmark-item:hover {
  138. background-color: ${hexToRgba(color, STYLE_CONSTANTS.HOVER_OPACITY)};
  139. }
  140. .bookmark-item.selected {
  141. background-color: ${hexToRgba(color, STYLE_CONSTANTS.SELECTED_OPACITY)};
  142. }
  143. `;
  144. }
  145. function hexToRgba(hex, alpha) {
  146. const r = parseInt(hex.slice(1, 3), 16);
  147. const g = parseInt(hex.slice(3, 5), 16);
  148. const b = parseInt(hex.slice(5, 7), 16);
  149. return `rgba(${r}, ${g}, ${b}, ${alpha})`;
  150. }
  151. const commands = [
  152. { name: ':list', description: 'Show all bookmarks' },
  153. { name: ':config', description: 'Open settings' },
  154. { name: ':bookmark', description: 'Edit bookmarks' },
  155. { name: ':export', description: 'Export settings and bookmarks' },
  156. { name: ':import', description: 'Import settings and bookmarks' },
  157. { name: ':help', description: 'Show help' },
  158. { name: ':reset', description: 'Reset all settings and bookmarks' }
  159. ];
  160. let showingAllBookmarks = false;
  161. const commandHandlers = {
  162. ':config': () => { window.location.href = 'config.html'; },
  163. ':bookmark': () => { window.location.href = 'bookmarks.html'; },
  164. ':help': () => { toggleHelp(); },
  165. ':list': () => {
  166. // Reload bookmarks from localStorage to get latest changes
  167. const saved = localStorage.getItem(STORAGE_KEYS.BOOKMARKS);
  168. if (saved) {
  169. bookmarks = JSON.parse(saved);
  170. }
  171. showingAllBookmarks = true;
  172. filteredBookmarks = bookmarks;
  173. selectedIndex = 0;
  174. renderResults();
  175. searchInput.value = '';
  176. searchInput.focus();
  177. },
  178. ':reset': () => {
  179. if (confirm('Reset all settings and bookmarks to default? This cannot be undone.')) {
  180. localStorage.removeItem(STORAGE_KEYS.CONFIG);
  181. localStorage.removeItem(STORAGE_KEYS.BOOKMARKS);
  182. window.location.reload();
  183. }
  184. },
  185. ':export': () => { exportData(); },
  186. ':import': () => { importData(); }
  187. };
  188. /**
  189. * Executes a command if the query matches a known command.
  190. * @param {string} query - The command string to execute
  191. * @returns {boolean} - True if command was executed, false otherwise
  192. */
  193. function executeCommand(query) {
  194. const handler = commandHandlers[query];
  195. if (handler) {
  196. handler();
  197. return true;
  198. }
  199. return false;
  200. }
  201. function getFromLocalStorage(key, fallback) {
  202. const saved = localStorage.getItem(key);
  203. return saved ? JSON.parse(saved) : fallback;
  204. }
  205. /**
  206. * Exports settings and bookmarks as a JSON file
  207. */
  208. function exportData() {
  209. const data = {
  210. config: getFromLocalStorage(STORAGE_KEYS.CONFIG, config),
  211. bookmarks: getFromLocalStorage(STORAGE_KEYS.BOOKMARKS, bookmarks),
  212. exportDate: new Date().toISOString()
  213. };
  214. const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
  215. const url = URL.createObjectURL(blob);
  216. const a = document.createElement('a');
  217. const today = new Date().toISOString().split('T')[0];
  218. a.href = url;
  219. a.download = `startpage-export-${today}.json`;
  220. document.body.appendChild(a);
  221. a.click();
  222. document.body.removeChild(a);
  223. URL.revokeObjectURL(url);
  224. }
  225. /**
  226. * Imports settings and bookmarks from a JSON file
  227. */
  228. function importData() {
  229. const input = document.createElement('input');
  230. input.type = 'file';
  231. input.accept = 'application/json';
  232. input.onchange = (e) => {
  233. const file = e.target.files[0];
  234. if (!file) return;
  235. const reader = new FileReader();
  236. reader.onload = (event) => {
  237. try {
  238. const data = JSON.parse(event.target.result);
  239. if (data.config) {
  240. localStorage.setItem(STORAGE_KEYS.CONFIG, JSON.stringify(data.config));
  241. }
  242. if (data.bookmarks) {
  243. localStorage.setItem(STORAGE_KEYS.BOOKMARKS, JSON.stringify(data.bookmarks));
  244. }
  245. alert('Import successful! Page will reload.');
  246. window.location.reload();
  247. } catch (error) {
  248. alert('Failed to import: Invalid file format');
  249. console.error('Import error:', error);
  250. }
  251. };
  252. reader.readAsText(file);
  253. };
  254. input.click();
  255. }
  256. /**
  257. * Checks if a bookmark matches the search query.
  258. * @param {Object} bookmark - The bookmark to check
  259. * @param {string} lowerQuery - The lowercased search query
  260. * @returns {boolean} - True if bookmark matches
  261. */
  262. function bookmarkMatches(bookmark, lowerQuery) {
  263. return bookmark.name.toLowerCase().includes(lowerQuery) ||
  264. bookmark.url.toLowerCase().includes(lowerQuery) ||
  265. bookmark.tags.some(tag => tag.toLowerCase().includes(lowerQuery));
  266. }
  267. /**
  268. * Filters bookmarks or shows command suggestions based on query.
  269. * @param {string} query - The search query
  270. */
  271. function filterBookmarks(query) {
  272. if (!query) {
  273. filteredBookmarks = showingAllBookmarks ? bookmarks : [];
  274. currentCommandSuggestions = [];
  275. selectedIndex = 0;
  276. renderResults();
  277. return;
  278. }
  279. if (query.startsWith(':')) {
  280. showingAllBookmarks = false;
  281. const matchedCommands = commands.filter(cmd =>
  282. cmd.name.startsWith(query.toLowerCase())
  283. );
  284. currentCommandSuggestions = matchedCommands;
  285. selectedIndex = 0;
  286. renderCommandSuggestions(matchedCommands);
  287. return;
  288. }
  289. currentCommandSuggestions = [];
  290. const lowerQuery = query.toLowerCase();
  291. filteredBookmarks = bookmarks.filter(bookmark => bookmarkMatches(bookmark, lowerQuery));
  292. selectedIndex = 0;
  293. renderResults();
  294. }
  295. function createTextElement(className, text) {
  296. const element = document.createElement('div');
  297. element.className = className;
  298. element.textContent = text;
  299. return element;
  300. }
  301. function createResultItem(isSelected, onClickHandler) {
  302. const item = document.createElement('div');
  303. item.className = 'bookmark-item' + (isSelected ? ' selected' : '');
  304. item.addEventListener('click', onClickHandler);
  305. return item;
  306. }
  307. function renderCommandSuggestions(matchedCommands) {
  308. resultsContainer.innerHTML = '';
  309. matchedCommands.forEach((cmd, index) => {
  310. const item = createResultItem(index === selectedIndex, () => {
  311. searchInput.value = cmd.name;
  312. executeCommand(cmd.name);
  313. });
  314. item.appendChild(createTextElement('bookmark-name', cmd.name));
  315. item.appendChild(createTextElement('bookmark-url', cmd.description));
  316. resultsContainer.appendChild(item);
  317. });
  318. updateScrollFade();
  319. }
  320. function createTagsElement(tags) {
  321. const tagsContainer = document.createElement('div');
  322. tagsContainer.className = 'bookmark-tags';
  323. tags.forEach(tag => {
  324. const tagSpan = document.createElement('span');
  325. tagSpan.className = 'tag';
  326. tagSpan.textContent = `#${tag}`;
  327. tagsContainer.appendChild(tagSpan);
  328. });
  329. return tagsContainer;
  330. }
  331. function renderResults() {
  332. resultsContainer.innerHTML = '';
  333. filteredBookmarks.forEach((bookmark, index) => {
  334. const item = createResultItem(index === selectedIndex, () => openBookmark(bookmark));
  335. item.appendChild(createTextElement('bookmark-name', bookmark.name));
  336. item.appendChild(createTextElement('bookmark-url', bookmark.url));
  337. item.appendChild(createTagsElement(bookmark.tags));
  338. resultsContainer.appendChild(item);
  339. });
  340. updateScrollFade();
  341. }
  342. function openBookmark(bookmark) {
  343. window.location.href = bookmark.url;
  344. }
  345. function toggleHelp() {
  346. const existingOverlay = document.getElementById('help-overlay');
  347. if (existingOverlay) {
  348. existingOverlay.remove();
  349. return;
  350. }
  351. const accentColor = config.accentColor || DEFAULT_CONFIG.accentColor;
  352. const textColor = config.textColor || DEFAULT_CONFIG.textColor;
  353. const helpOverlay = document.createElement('div');
  354. helpOverlay.id = 'help-overlay';
  355. Object.assign(helpOverlay.style, {
  356. position: 'fixed',
  357. top: '0',
  358. left: '0',
  359. width: '100%',
  360. height: '100%',
  361. background: 'rgba(0, 0, 0, 0.9)',
  362. display: 'flex',
  363. alignItems: 'center',
  364. justifyContent: 'center',
  365. zIndex: '1000'
  366. });
  367. const helpContent = document.createElement('div');
  368. Object.assign(helpContent.style, {
  369. background: 'rgba(20, 20, 20, 0.95)',
  370. border: `1px solid ${accentColor}`,
  371. borderRadius: '8px',
  372. padding: '40px',
  373. maxWidth: '500px',
  374. color: textColor
  375. });
  376. helpContent.innerHTML = `
  377. <h2 style="margin-bottom: 30px; text-align: center; color: ${accentColor};">Keyboard Shortcuts</h2>
  378. <div style="line-height: 2;">
  379. <div><strong>:</strong> - Show command suggestions</div>
  380. <div><strong>:list</strong> - Show all bookmarks</div>
  381. <div><strong>:config</strong> - Open settings</div>
  382. <div><strong>:bookmark</strong> - Edit bookmarks</div>
  383. <div><strong>:export</strong> - Export settings and bookmarks</div>
  384. <div><strong>:import</strong> - Import settings and bookmarks</div>
  385. <div><strong>:reset</strong> - Reset all settings and bookmarks</div>
  386. <div><strong>:help</strong> - Show/hide this help</div>
  387. <div><strong>↓ / ↑</strong> - Move down / up</div>
  388. <div><strong>Enter</strong> - Open bookmark / Search</div>
  389. <div><strong>Esc</strong> - Clear input / Close help</div>
  390. </div>
  391. <div style="margin-top: 30px; text-align: center; color: #666; font-size: 0.9rem;">
  392. Press Esc to close
  393. </div>
  394. `;
  395. helpOverlay.appendChild(helpContent);
  396. document.body.appendChild(helpOverlay);
  397. helpOverlay.addEventListener('click', (e) => {
  398. if (e.target === helpOverlay) {
  399. helpOverlay.remove();
  400. }
  401. });
  402. }
  403. /**
  404. * Updates the fade effect classes based on scroll position.
  405. */
  406. function updateScrollFade() {
  407. const scrollTop = resultsContainer.scrollTop;
  408. const scrollHeight = resultsContainer.scrollHeight;
  409. const clientHeight = resultsContainer.clientHeight;
  410. // Check if content is scrollable
  411. const isScrollable = scrollHeight > clientHeight;
  412. if (!isScrollable) {
  413. resultsContainer.classList.remove('has-scroll-top', 'has-scroll-bottom');
  414. return;
  415. }
  416. // Check if scrolled from top (with small threshold)
  417. const hasScrollTop = scrollTop > 10;
  418. // Check if not at bottom (with small threshold)
  419. const hasScrollBottom = scrollTop < scrollHeight - clientHeight - 10;
  420. resultsContainer.classList.toggle('has-scroll-top', hasScrollTop);
  421. resultsContainer.classList.toggle('has-scroll-bottom', hasScrollBottom);
  422. }
  423. /**
  424. * Scrolls the selected item into view, keeping it centered when possible.
  425. */
  426. function scrollSelectedIntoView() {
  427. const selectedElement = resultsContainer.querySelector('.bookmark-item.selected');
  428. if (!selectedElement) return;
  429. const containerHeight = resultsContainer.clientHeight;
  430. const elementHeight = selectedElement.offsetHeight;
  431. const centerPosition = containerHeight / 2 - elementHeight / 2;
  432. const desiredScroll = selectedElement.offsetTop - centerPosition;
  433. resultsContainer.scrollTo({
  434. top: desiredScroll,
  435. behavior: 'smooth'
  436. });
  437. setTimeout(updateScrollFade, 100);
  438. }
  439. /**
  440. * Moves the selection cursor up or down in the results list.
  441. * @param {string} direction - Either 'up' or 'down'
  442. */
  443. function moveSelection(direction) {
  444. const items = currentCommandSuggestions.length > 0 ? currentCommandSuggestions : filteredBookmarks;
  445. if (items.length === 0) return;
  446. const delta = direction === 'down' ? 1 : -1;
  447. const newIndex = selectedIndex + delta;
  448. // Don't loop - stop at boundaries
  449. if (newIndex < 0 || newIndex >= items.length) {
  450. return;
  451. }
  452. selectedIndex = newIndex;
  453. if (currentCommandSuggestions.length > 0) {
  454. renderCommandSuggestions(currentCommandSuggestions);
  455. } else {
  456. renderResults();
  457. }
  458. scrollSelectedIntoView();
  459. }
  460. searchInput.addEventListener('input', (e) => {
  461. filterBookmarks(e.target.value);
  462. });
  463. // Add scroll event listener to update fade effects
  464. resultsContainer.addEventListener('scroll', updateScrollFade);
  465. document.addEventListener('keydown', (e) => {
  466. const isInputFocused = document.activeElement === searchInput;
  467. const noModifiers = !e.ctrlKey && !e.metaKey;
  468. // Auto-focus on typing
  469. if (!isInputFocused && noModifiers && !e.altKey && e.key.length === 1 && e.key !== ' ') {
  470. searchInput.focus();
  471. return;
  472. }
  473. // Navigation and actions when input is focused
  474. if (isInputFocused) {
  475. if (e.key === 'ArrowDown' && noModifiers) {
  476. e.preventDefault();
  477. moveSelection('down');
  478. } else if (e.key === 'ArrowUp' && noModifiers) {
  479. e.preventDefault();
  480. moveSelection('up');
  481. } else if (e.key === 'Enter') {
  482. e.preventDefault();
  483. handleEnterKey();
  484. } else if (e.key === 'Escape') {
  485. e.preventDefault();
  486. handleEscapeKey();
  487. }
  488. }
  489. });
  490. function isUrl(query) {
  491. return query.startsWith('http://') ||
  492. query.startsWith('https://') ||
  493. /^[a-zA-Z0-9][-a-zA-Z0-9]*(\.[a-zA-Z0-9][-a-zA-Z0-9]*)+/.test(query);
  494. }
  495. function handleEnterKey() {
  496. const query = searchInput.value.trim();
  497. if (currentCommandSuggestions.length > 0) {
  498. const selectedCommand = currentCommandSuggestions[selectedIndex];
  499. searchInput.value = selectedCommand.name;
  500. currentCommandSuggestions = [];
  501. executeCommand(selectedCommand.name);
  502. return;
  503. }
  504. if (executeCommand(query)) {
  505. return;
  506. }
  507. if (filteredBookmarks.length > 0) {
  508. openBookmark(filteredBookmarks[selectedIndex]);
  509. } else if (query && !query.startsWith(':')) {
  510. if (isUrl(query)) {
  511. // Add https:// if no protocol specified
  512. const url = query.startsWith('http') ? query : `https://${query}`;
  513. window.location.href = url;
  514. } else {
  515. const searchEngine = config.searchEngine || DEFAULT_CONFIG.searchEngine;
  516. window.location.href = searchEngine + encodeURIComponent(query);
  517. }
  518. }
  519. }
  520. function handleEscapeKey() {
  521. const helpOverlay = document.getElementById('help-overlay');
  522. if (helpOverlay) {
  523. helpOverlay.remove();
  524. } else {
  525. showingAllBookmarks = false;
  526. searchInput.value = '';
  527. filterBookmarks('');
  528. searchInput.focus();
  529. }
  530. }
  531. // Reload bookmarks when page becomes visible (e.g., returning from bookmark editor)
  532. document.addEventListener('visibilitychange', () => {
  533. if (!document.hidden) {
  534. const saved = localStorage.getItem(STORAGE_KEYS.BOOKMARKS);
  535. if (saved) {
  536. bookmarks = JSON.parse(saved);
  537. // Update current view if showing all bookmarks
  538. if (showingAllBookmarks) {
  539. filteredBookmarks = bookmarks;
  540. renderResults();
  541. }
  542. }
  543. }
  544. });
  545. async function init() {
  546. await loadData();
  547. applyConfig();
  548. }
  549. init();