UNPKG

jattac.react.recents

Version:

A React component and utility for tracking and displaying recent browser locations and URLs.

347 lines (327 loc) 19.3 kB
'use strict'; var React = require('react'); /****************************************************************************** Copyright (c) Microsoft Corporation. Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ***************************************************************************** */ /* global Reflect, Promise, SuppressedError, Symbol, Iterator */ function __awaiter(thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); } typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) { var e = new Error(message); return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e; }; const defaultNamespace = 'global'; class OnBrowserLocationTracker { constructor(config) { this.storageKey = 'recents'; this.titleObserver = null; this.config = config; if (!OnBrowserLocationTracker._namespace) { if (this.config.namespace) { OnBrowserLocationTracker._namespace = this.config.namespace; } else { OnBrowserLocationTracker._namespace = defaultNamespace; } } } static get namespace() { if (this._namespace) { return this._namespace; } else { return defaultNamespace; } } get fullStorageKey() { return `${OnBrowserLocationTracker.namespace}_${this.storageKey}`; } getStoredRecents() { return __awaiter(this, void 0, void 0, function* () { const data = localStorage.getItem(this.fullStorageKey); const result = (data ? JSON.parse(data) : []); const validRecents = result .filter((a) => this.isValid(a)) .sort((a, b) => new Date(b.lastVisited).getTime() - new Date(a.lastVisited).getTime()); const uniqueByUrl = [...new Map(validRecents.map((item) => [item.url, item])).values()]; return uniqueByUrl; }); } isValid(location) { return location.url && location.windowTitle ? true : false; } saveRecents(recents) { return __awaiter(this, void 0, void 0, function* () { const validRecents = recents.filter((a) => this.isValid(a)); localStorage.setItem(this.fullStorageKey, JSON.stringify(validRecents)); }); } shouldTrack(location) { var _a; if (!this.config.dontTrack) return true; // If no dontTrack rules, always track const { withPrefix, withSuffix, contains } = this.config.dontTrack; // Helper function to check a condition const matchesCondition = (value, patterns) => { if (!value || !patterns) return false; return patterns.some((pattern) => { const { urlOrTitle: title, url } = pattern; return (title && value.startsWith(title)) || (url && value.startsWith(url)); }); }; const matchables = [withPrefix, withSuffix, contains]; for (let i = 0; i < matchables.length; i++) { const specificMatchable = (_a = matchables[i]) !== null && _a !== void 0 ? _a : []; const foundMatch = matchesCondition(location.url, specificMatchable) || matchesCondition(location.windowTitle, specificMatchable); if (foundMatch) { return false; } } return true; } trackVisit(location) { return __awaiter(this, void 0, void 0, function* () { location.url = this.processUrlForTracking(location.url); if (!this.shouldTrack(location)) return; // Skip tracking if location matches dontTrack rules const recents = yield this.getStoredRecents(); // Remove any existing entry for the same URL const updatedRecents = recents.filter((r) => r.url !== location.url); // Add the new location to the top updatedRecents.unshift(location); // Enforce the limit if (updatedRecents.length > this.config.maxRecents) { updatedRecents.pop(); } yield this.saveRecents(updatedRecents); }); } getRecentsAsync() { return __awaiter(this, void 0, void 0, function* () { return yield this.getStoredRecents(); }); } removeRecent(url) { return __awaiter(this, void 0, void 0, function* () { const recents = yield this.getStoredRecents(); const updatedRecents = recents.filter((recent) => recent.url !== url); yield this.saveRecents(updatedRecents); }); } clearRecents() { return __awaiter(this, void 0, void 0, function* () { localStorage.removeItem(this.fullStorageKey); }); } getCurrentLocation() { // Ensure document is ready before accessing title if (!window.location.href) return null; // Check if the document is fully loaded before accessing <title> if (document.readyState === 'complete') { this.observeTitleChange(); } else { // If the document is not fully loaded, listen for the load event window.addEventListener('load', () => this.observeTitleChange()); } return null; // MutationObserver will handle updates } /** * Processes the URL for tracking, taking into account the disregardQueryStrings configuration. * @param url The URL to process. * @returns The processed URL. */ processUrlForTracking(url) { if (!this.config.disregardQueryStrings) return url; const urlObj = new URL(url); const path = urlObj.pathname; this.config.disregardQueryStrings.forEach(({ path: disregardPath, queryStrings }) => { if (path.startsWith(disregardPath)) { queryStrings.forEach((queryString) => { urlObj.searchParams.delete(queryString); }); } }); return urlObj.toString(); } observeTitleChange() { const titleElement = document.querySelector('title'); // Fallback if the title is not available if (!titleElement) { // If no title tag found, observe the <head> element instead const headElement = document.querySelector('head'); if (!headElement) return; if (!this.titleObserver) { this.titleObserver = new MutationObserver(() => { const title = document.title; this.updateLocation(title); }); // Observe changes in the <head> element to detect changes in the title this.titleObserver.observe(headElement, { childList: true, // Watch for changes in child elements like <title> subtree: true, // Watch all descendants, including the title tag }); } } else { // Directly update if the <title> is available const title = document.title; this.updateLocation(title); } } updateLocation(title) { return __awaiter(this, void 0, void 0, function* () { const maxTitleLength = 45; if (!title) return; // Truncate title if it exceeds max length and add ellipses if (title.length > maxTitleLength) { title = title.substring(0, maxTitleLength) + '...'; } const location = { windowTitle: title, url: window.location.href, lastVisited: new Date().toISOString(), }; // Track the visit and persist the location yield this.trackVisit(location); }); } } OnBrowserLocationTracker._namespace = ''; function styleInject(css, ref) { if ( ref === void 0 ) ref = {}; var insertAt = ref.insertAt; if (typeof document === 'undefined') { return; } var head = document.head || document.getElementsByTagName('head')[0]; var style = document.createElement('style'); style.type = 'text/css'; if (insertAt === 'top') { if (head.firstChild) { head.insertBefore(style, head.firstChild); } else { head.appendChild(style); } } else { head.appendChild(style); } if (style.styleSheet) { style.styleSheet.cssText = css; } else { style.appendChild(document.createTextNode(css)); } } var css_248z = "@keyframes Recents-module_fadeIn__fxi5P {\n from {\n opacity: 0;\n transform: translateY(-10px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n}\n\n/* Mobile-first base styles */\n.Recents-module_recentsContainer__87baP {\n padding: 12px;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica,\n Arial, sans-serif;\n animation: Recents-module_fadeIn__fxi5P 0.5s ease-in-out;\n width: 100%;\n box-sizing: border-box;\n}\n\n.Recents-module_recentsTitle__t8LqW {\n font-size: 1.2em;\n font-weight: 600;\n margin-bottom: 16px;\n display: flex;\n justify-content: space-between;\n align-items: center;\n}\n\n.Recents-module_removeAllButton__30r2o {\n background-color: #f44336;\n color: white;\n border: none;\n padding: 6px 10px;\n border-radius: 4px;\n cursor: pointer;\n font-size: 0.8rem;\n transition: background-color 0.3s ease;\n}\n\n.Recents-module_recentsList__9h8x4 {\n list-style: none;\n padding: 0;\n margin: 0;\n}\n\n.Recents-module_recentItem__AIFny {\n display: flex;\n flex-wrap: wrap; /* Allow items to wrap */\n align-items: center;\n padding: 10px 4px;\n margin-bottom: 8px;\n border-radius: 6px;\n transition: background-color 0.3s ease;\n}\n\n.Recents-module_recentItem__AIFny:hover {\n background-color: #f5f5f5;\n}\n\n.Recents-module_favicon__nMqe3 {\n width: 20px;\n height: 20px;\n margin-right: 12px;\n flex-shrink: 0;\n}\n\n.Recents-module_recentLink__LnBqE {\n color: #333;\n text-decoration: none;\n font-size: 1rem;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n /* Take up most of the space, but leave room for the button */\n flex-basis: calc(100% - 80px);\n flex-grow: 1;\n}\n\n.Recents-module_recentLink__LnBqE:hover {\n text-decoration: underline;\n}\n\n.Recents-module_timeLabel__Paq1c {\n font-size: 0.8rem;\n color: #666;\n flex-basis: 100%; /* Make time take full width on new line */\n margin-top: 4px;\n padding-left: 32px; /* Align with text, not favicon */\n}\n\n.Recents-module_removeButton__Ugt7l {\n background: transparent;\n border: none;\n cursor: pointer;\n color: #9e9e9e;\n padding: 4px;\n display: flex;\n align-items: center;\n justify-content: center;\n border-radius: 50%;\n transition: background-color 0.3s ease, color 0.3s ease;\n margin-left: auto; /* Pushes button to the far right */\n}\n\n.Recents-module_removeButton__Ugt7l:hover {\n background-color: #e0e0e0;\n color: #d32f2f;\n}\n\n.Recents-module_noRecents__OySGm {\n text-align: center;\n padding: 32px;\n border: 2px dashed #e0e0e0;\n border-radius: 8px;\n}\n\n.Recents-module_noRecentsText__kT8tq {\n font-size: 1rem;\n color: #666;\n}\n\n/* Desktop-aware styles */\n@media (min-width: 768px) {\n .Recents-module_recentsContainer__87baP {\n max-width: 600px;\n margin: 0 auto;\n padding: 16px;\n }\n\n .Recents-module_recentsTitle__t8LqW {\n font-size: 1.25em;\n }\n\n .Recents-module_removeAllButton__30r2o {\n padding: 6px 12px;\n font-size: 0.875rem;\n }\n\n .Recents-module_recentItem__AIFny {\n flex-wrap: nowrap; /* Prevent wrapping on desktop */\n }\n\n .Recents-module_recentLink__LnBqE {\n flex-basis: auto; /* Reset basis */\n }\n\n .Recents-module_timeLabel__Paq1c {\n flex-basis: auto; /* Reset basis */\n margin-top: 0;\n padding-left: 0;\n margin-left: 16px;\n font-size: 0.875rem;\n }\n\n .Recents-module_removeButton__Ugt7l {\n margin-left: 16px;\n }\n}\n"; var styles = {"recentsContainer":"Recents-module_recentsContainer__87baP","fadeIn":"Recents-module_fadeIn__fxi5P","recentsTitle":"Recents-module_recentsTitle__t8LqW","removeAllButton":"Recents-module_removeAllButton__30r2o","recentsList":"Recents-module_recentsList__9h8x4","recentItem":"Recents-module_recentItem__AIFny","favicon":"Recents-module_favicon__nMqe3","recentLink":"Recents-module_recentLink__LnBqE","timeLabel":"Recents-module_timeLabel__Paq1c","removeButton":"Recents-module_removeButton__Ugt7l","noRecents":"Recents-module_noRecents__OySGm","noRecentsText":"Recents-module_noRecentsText__kT8tq"}; styleInject(css_248z); const formatFriendlyTime = (isoDate) => { const date = new Date(isoDate); const now = new Date(); const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); const diffInMinutes = Math.floor(diffInSeconds / 60); const diffInHours = Math.floor(diffInSeconds / 3600); const diffInDays = Math.floor(diffInSeconds / 86400); const diffInWeeks = Math.floor(diffInSeconds / 604800); const diffInMonths = Math.floor(diffInSeconds / 2592000); if (diffInSeconds < 60) return `${diffInSeconds} seconds ago`; if (diffInMinutes < 60) return `${diffInMinutes} minutes ago`; if (diffInHours < 24) return `${diffInHours} hours ago`; if (diffInDays === 1) return "Yesterday"; if (diffInDays < 7) return `${diffInDays} days ago`; if (diffInWeeks < 4) return `${diffInWeeks} weeks ago`; if (diffInMonths < 12) return `${diffInMonths} months ago`; return date.toLocaleDateString(); // Return a more standard date format if it's more than a year ago }; // NoRecents.svg - a cleaner, modern icon const NoRecents = () => (React.createElement("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 64 64", width: "64", height: "64", fill: "none" }, React.createElement("g", null, React.createElement("path", { fill: "#888", d: "M32 8C18.746 8 8 18.746 8 32s10.746 24 24 24 24-10.746 24-24-10.746-24-24-24zm0 44c-11.046 0-20-8.954-20-20s8.954-20 20-20 20 8.954 20 20-8.954 20-20 20z" }), React.createElement("path", { fill: "#888", d: "M32 12c1.657 0 3 1.343 3 3s-1.343 3-3 3-3-1.343-3-3 1.343-3 3-3zm-9 10c1.657 0 3 1.343 3 3s-1.343 3-3 3-3-1.343-3-3 1.343-3 3-3zm18 0c1.657 0 3 1.343 3 3s-1.343 3-3 3-3-1.343-3-3 1.343-3 3-3z" })))); const getFaviconUrl = (url) => { try { const urlObj = new URL(url); const domain = urlObj.hostname; return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`; } catch (error) { console.error("Invalid URL:", url); return ""; // Return a path to a default/placeholder icon if you have one } }; const TrashIcon = () => (React.createElement("svg", { xmlns: "http://www.w3.org/2000/svg", width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" }, React.createElement("polyline", { points: "3 6 5 6 21 6" }), React.createElement("path", { d: "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" }), React.createElement("line", { x1: "10", y1: "11", x2: "10", y2: "17" }), React.createElement("line", { x1: "14", y1: "11", x2: "14", y2: "17" }))); const Recents = ({ onBeforeRemove, namespace, maxRecents = 10, }) => { const [recents, setRecents] = React.useState([]); const tracker = React.useMemo(() => { return new OnBrowserLocationTracker({ maxRecents: maxRecents, namespace: namespace, }); }, [maxRecents, namespace]); React.useEffect(() => { const loadRecents = () => __awaiter(void 0, void 0, void 0, function* () { const storedRecents = yield tracker.getRecentsAsync(); setRecents(storedRecents); }); loadRecents(); }, [tracker]); const handleRemoveRecent = (recent) => { const shouldRemove = onBeforeRemove ? onBeforeRemove([recent]) : true; if (!shouldRemove) return; setRecents((prevRecents) => prevRecents.filter((item) => item.url !== recent.url)); tracker.removeRecent(recent.url); }; const handleRemoveAll = () => { const shouldRemoveAll = onBeforeRemove ? onBeforeRemove(recents) : true; if (!shouldRemoveAll) return; setRecents([]); tracker.clearRecents(); }; return (React.createElement("div", { className: styles.recentsContainer }, React.createElement("h2", { className: styles.recentsTitle }, "Recently Visited", recents.length > 0 && (React.createElement("button", { onClick: handleRemoveAll, className: styles.removeAllButton, title: "Remove all recents" }, "Clear All"))), recents.length === 0 ? (React.createElement("div", { className: styles.noRecents }, React.createElement(NoRecents, null), React.createElement("p", { className: styles.noRecentsText }, "No recent activity yet. Start browsing to see your recent history here!"))) : (React.createElement("ul", { className: styles.recentsList }, recents.map((item, index) => (React.createElement("li", { key: index, className: styles.recentItem }, React.createElement("img", { src: getFaviconUrl(item.url), alt: "favicon", className: styles.favicon }), React.createElement("a", { href: item.url, className: styles.recentLink }, item.windowTitle), React.createElement("span", { className: styles.timeLabel }, formatFriendlyTime(item.lastVisited)), React.createElement("button", { onClick: () => handleRemoveRecent(item), className: styles.removeButton, title: "Remove this recent" }, React.createElement(TrashIcon, null))))))))); }; exports.OnBrowserLocationTracker = OnBrowserLocationTracker; exports.Recents = Recents; //# sourceMappingURL=index.js.map