diff --git a/.erb/configs/webpack.config.renderer.dev.dll.ts b/.erb/configs/webpack.config.renderer.dev.dll.ts index 614b90f..2e70998 100644 --- a/.erb/configs/webpack.config.renderer.dev.dll.ts +++ b/.erb/configs/webpack.config.renderer.dev.dll.ts @@ -31,7 +31,12 @@ const configuration: webpack.Configuration = { module: require('./webpack.config.renderer.dev').default.module, entry: { - renderer: Object.keys(dependencies || {}), + // webpack has some issue importing a path from @tiptap/pm + // so we're going to exclude it from the entry point bundle + // as we don't need it there anyways. + renderer: Object.keys(dependencies || {}).filter( + (it) => it !== '@tiptap/pm' + ), }, output: { diff --git a/.yarnclean b/.yarnclean new file mode 100644 index 0000000..b591611 --- /dev/null +++ b/.yarnclean @@ -0,0 +1,45 @@ +# test directories +__tests__ +test +tests +powered-test + +# asset directories +docs +doc +website +images +assets + +# examples +example +examples + +# code coverage directories +coverage +.nyc_output + +# build scripts +Makefile +Gulpfile.js +Gruntfile.js + +# configs +appveyor.yml +circle.yml +codeship-services.yml +codeship-steps.yml +wercker.yml +.tern-project +.gitattributes +.editorconfig +.*ignore +.eslintrc +.jshintrc +.flowconfig +.documentup.json +.yarn-metadata.json +.travis.yml + +# misc +*.md diff --git a/assets/garden.jpeg b/assets/garden.jpeg new file mode 100644 index 0000000..fad5932 Binary files /dev/null and b/assets/garden.jpeg differ diff --git a/package.json b/package.json index ccfc5a0..cc41fba 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "build:renderer": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.prod.ts", "postinstall": "ts-node .erb/scripts/check-native-dep.js && electron-builder install-app-deps && cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.dev.dll.ts", "lint": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx", - "package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never", + "package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --mac --win --publish never", "rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app", "start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run start:renderer", "start:main": "cross-env NODE_ENV=development electronmon -r ts-node/register/transpile-only .", @@ -91,12 +91,15 @@ "@radix-ui/react-alert-dialog": "^1.0.4", "@radix-ui/react-dialog": "^1.0.4", "@radix-ui/react-dropdown-menu": "^2.0.5", - "@tiptap/extension-character-count": "^2.0.3", + "@tiptap/extension-character-count": "^2.1.12", + "@tiptap/extension-link": "^2.1.12", "@tiptap/extension-placeholder": "^2.0.3", "@tiptap/extension-typography": "^2.0.4", "@tiptap/pm": "^2.0.3", "@tiptap/react": "^2.0.3", "@tiptap/starter-kit": "^2.0.3", + "axios": "^1.6.0", + "cheerio": "^1.0.0-rc.12", "dotenv": "^16.3.1", "electron-debug": "^3.2.0", "electron-log": "^4.4.8", @@ -105,12 +108,10 @@ "gray-matter": "^4.0.3", "luxon": "^3.3.0", "million": "^2.6.4", - "nanoid": "^4.0.2", "openai": "4.0.0-beta.6", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-router-dom": "^6.11.2", - "use-count-up": "^3.0.1" + "react-router-dom": "^6.11.2" }, "devDependencies": { "@adobe/css-tools": "^4.3.1", @@ -140,7 +141,7 @@ "css-minimizer-webpack-plugin": "^5.0.0", "detect-port": "^1.5.1", "electron": "^25.8.4", - "electron-builder": "^24.2.1", + "electron-builder": "^24.7.0", "electron-devtools-installer": "^3.2.0", "electronmon": "^2.0.2", "eslint": "^8.42.0", @@ -226,8 +227,15 @@ ] }, "win": { + "publisherName": "Pile", "target": [ - "portable" + { + "target": "nsis", + "arch": [ + "x64", + "ia32" + ] + } ] }, "linux": { @@ -262,4 +270,4 @@ ], "logLevel": "quiet" } -} +} \ No newline at end of file diff --git a/release/app/package-lock.json b/release/app/package-lock.json index b272627..a43e5ae 100644 --- a/release/app/package-lock.json +++ b/release/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "pile", - "version": "0.7.50", + "version": "0.8.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "pile", - "version": "0.7.50", + "version": "0.8.1", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/release/app/package.json b/release/app/package.json index 53d5787..4996aa2 100644 --- a/release/app/package.json +++ b/release/app/package.json @@ -1,6 +1,6 @@ { "name": "pile", - "version": "0.7.50", + "version": "0.8.1", "description": "Pile: Everyday journal and thought companion.", "license": "MIT", "author": { diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 3556ddd..f661e5f 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1,13 +1,14 @@ import { ipcMain, app, dialog } from 'electron'; import path from 'path'; import fs from 'fs'; -import pileHelper from './utils/PileHelper'; -import pileIndex from './utils/PileIndex'; -import pileTags from './utils/PileTags'; +import pileHelper from './utils/pileHelper'; +import pileIndex from './utils/pileIndex'; +import pileTags from './utils/pileTags'; +import pileLinks from './utils/pileLinks'; import pileHighlights from './utils/pileHighlights'; import keytar from 'keytar'; +import { getLinkPreview, getLinkContent } from './utils/linkPreview'; const os = require('os'); - const matter = require('gray-matter'); // AI key @@ -19,6 +20,29 @@ ipcMain.handle('set-ai-key', async (event, secretKey) => { return await keytar.setPassword('pile', 'aikey', secretKey); }); +ipcMain.handle('delete-ai-key', async (event) => { + return await keytar.deletePassword('pile', 'aikey'); +}); + +// Link preview +ipcMain.handle('get-link-preview', async (event, url) => { + const preview = await getLinkPreview(url) + .then((data) => { + return data; + }) + .catch(() => null); + return preview; +}); + +ipcMain.handle('get-link-content', async (event, url) => { + const preview = await getLinkContent(url) + .then((data) => { + return data; + }) + .catch(() => null); + return preview; +}); + // Index operations ipcMain.handle('index-load', (event, pilePath) => { const index = pileIndex.load(pilePath); @@ -40,6 +64,17 @@ ipcMain.handle('index-remove', (event, filePath) => { return index; }); +// Links operations +ipcMain.handle('links-get', (event, pilePath, url) => { + const data = pileLinks.get(pilePath, url); + return data; +}); + +ipcMain.handle('links-set', (event, pilePath, url, data) => { + const status = pileLinks.set(pilePath, url, data); + return status; +}); + // Highlight operations ipcMain.handle('highlights-load', (event, pilePath) => { const highlights = pileHighlights.load(pilePath); @@ -92,8 +127,12 @@ ipcMain.on('change-folder', (event, newPath) => { }); ipcMain.handle('matter-parse', async (event, file) => { - const post = matter(file); - return post; + try { + const post = matter(file); + return post; + } catch (error) { + return null; + } }); ipcMain.handle('matter-stringify', async (event, { content, data }) => { @@ -107,7 +146,7 @@ ipcMain.handle('get-files', async (event, dirPath) => { }); ipcMain.handle('get-file', async (event, filePath) => { - const content = await pileHelper.getFile(filePath); + const content = await pileHelper.getFile(filePath).catch(() => null); return content; }); diff --git a/src/main/utils/linkPreview.js b/src/main/utils/linkPreview.js new file mode 100644 index 0000000..4f36007 --- /dev/null +++ b/src/main/utils/linkPreview.js @@ -0,0 +1,164 @@ +const axiosBase = require('axios'); +const cheerio = require('cheerio'); + +const axios = axiosBase.create({ + timeout: 5000, + maxContentLength: 5 * 1024 * 1024, // 5MB + maxBodyLength: 10 * 1024 * 1024, // 10MB +}); + +// Setting the headers directly on the instance +axios.defaults.headers.common['User-Agent'] = 'Mozilla/5.0 Pile/1.0'; +axios.defaults.headers.post['Content-Type'] = 'application/json'; + +export const getLinkPreview = async (url) => { + try { + const response = await axios + .get(url, { + headers: { + Accept: 'text/html', + }, + }) + .then((response) => { + const contentType = response.headers['content-type']; + if (contentType && contentType.includes('text/html')) { + return response; + } + throw new Error('Not an HTML content'); + }); + + const html = response.data; + const $ = cheerio.load(html); + const parsedUrl = new URL(url); + + let meta = { + title: '', + images: [], + host: parsedUrl.host, // Extract the host domain from the URL + favicon: '', // Initialize favicon with an empty string + }; + + // Extract the title + const title = $('title').first().text(); + meta.title = title; + + // Extract the Open Graph images + $('meta').each((index, element) => { + const property = $(element).attr('property'); + const content = $(element).attr('content'); + + if (property === 'og:image') { + meta.images.push(content); + } + }); + + // Extract the favicon + $('link').each((index, element) => { + const rel = $(element).attr('rel')?.toLowerCase(); + const href = $(element).attr('href'); + if (rel && (rel.includes('icon') || rel === 'shortcut icon') && href) { + // Resolve the favicon URL relative to the host URL + meta.favicon = new URL(href, parsedUrl.origin).href; + return false; // Break out of the loop after finding the favicon + } + }); + + // If no favicon is found in the loop, you can set a default path + if (!meta.favicon) { + meta.favicon = parsedUrl.origin + '/favicon.ico'; + } + + return meta; + } catch (error) { + console.error(error); + return null; + } +}; + +const getContentHeuristics = (html) => { + const $ = cheerio.load(html); + let maxDensity = 0; + let mainContent = ''; + + $('*').each(function () { + const text = $(this).clone().children().remove().end().text(); + const wordCount = text.split(/\s+/).length; + const density = wordCount / $(this).text().length; + + // Check if the element has a higher text density and contains more words than the current max + if (density > maxDensity && wordCount > 200) { + // 200 is arbitrary + maxDensity = density; + mainContent = text; + } + }); + + return mainContent.trim().replace(/\s\s+/g, ' '); +}; + +export const getLinkContent = async (url) => { + try { + const response = await axios.get(url); + const $ = cheerio.load(response.data); + + // Some content we want to filter out + $( + 'script, style, iframe, noscript, nav, header, footer, .nav, .menu, .footer' + ).remove(); + + let contentSections = []; + + // Targets likely to hold main text content + const sectionSelectors = 'div, section, article, main, [role="main"]'; + + $(sectionSelectors).each(function () { + const sectionText = $(this).text().trim(); + const textLength = sectionText.replace(/\s+/g, ' ').length; + + // If the section contains a significant amount of text, consider it as content + if (textLength > 200) { + contentSections.push({ + html: $(this).html(), // Keep the HTML to preserve images and links + textLength: textLength, + }); + } + }); + + // Sort sections by text length, descending + contentSections.sort((a, b) => b.textLength - a.textLength); + + // Concatenate the HTML of the top sections to form the main content + let mainContentHtml = contentSections + .slice(0, 3) + .map((section) => section.html) + .join(' '); // Adjust the number of sections as needed + + // Load the concatenated HTML into Cheerio for final cleaning + const mainContent = cheerio.load(mainContentHtml); + + // Extract the clean text content + const textContent = mainContent.text().replace(/\s+/g, ' ').trim(); + + // Extract image sources + const imageSources = mainContent('img') + .map((i, el) => mainContent(el).attr('src')) + .get(); + + // Extract links + const links = mainContent('a') + .map((i, el) => ({ + href: mainContent(el).attr('href'), + text: mainContent(el).text().trim(), + })) + .get(); + + return { + text: textContent, + images: imageSources.slice(0, 10), + links: links.slice(0, 10), + }; + } catch (error) { + console.error(error); + return null; + } +}; diff --git a/src/main/utils/pileHelper.js b/src/main/utils/pileHelper.js index b50e99f..ca6219c 100644 --- a/src/main/utils/pileHelper.js +++ b/src/main/utils/pileHelper.js @@ -70,7 +70,6 @@ class PileHelper { const content = await fs.promises.readFile(filePath, 'utf-8'); return content; } catch (error) { - console.error(`File unavailable`); throw error; } } diff --git a/src/main/utils/pileIndex.js b/src/main/utils/pileIndex.js index ac83b88..99d56d2 100644 --- a/src/main/utils/pileIndex.js +++ b/src/main/utils/pileIndex.js @@ -20,8 +20,18 @@ class PileIndex { return sortedMap; } + resetIndex() { + this.index.clear(); + } + load(pilePath) { if (!pilePath) return; + + // a different pile is being loaded + if (pilePath !== this.pilePath) { + this.resetIndex(); + } + this.pilePath = pilePath; const indexFilePath = path.join(this.pilePath, this.fileName); @@ -34,7 +44,6 @@ class PileIndex { return sortedIndex; } else { - // save to initialize an empty index this.save(); return this.index; } @@ -92,8 +101,8 @@ class PileIndex { const entries = this.index.entries(); if (!entries) return; - let strMap = JSON.stringify(Array.from(entries)); + let strMap = JSON.stringify(Array.from(entries)); fs.writeFileSync(filePath, strMap); } } diff --git a/src/main/utils/pileLinks.js b/src/main/utils/pileLinks.js new file mode 100644 index 0000000..5cb3a32 --- /dev/null +++ b/src/main/utils/pileLinks.js @@ -0,0 +1,77 @@ +const fs = require('fs'); +const path = require('path'); + +class PileLinks { + constructor() { + this.fileName = 'links.json'; + this.pilePath = null; + this.links = new Map(); + } + + resetIndex() { + this.links.clear(); + } + + load(pilePath) { + if (!pilePath) return; + + // skip loading + if (pilePath === this.pilePath) { + return; + } + + // a different pile is being loaded + if (pilePath !== this.pilePath) { + this.resetIndex(); + } + + this.pilePath = pilePath; + const linksFilePath = path.join(this.pilePath, this.fileName); + + if (fs.existsSync(linksFilePath)) { + const data = fs.readFileSync(linksFilePath); + const loadedIndex = new Map(JSON.parse(data)); + this.links = loadedIndex; + + return loadedIndex; + } else { + this.save(); + return this.links; + } + } + + get(pilePath, url) { + if (pilePath !== this.pilePath) { + this.load(pilePath); + } + return this.links.get(url); + } + + set(pilePath, url, data) { + if (pilePath !== this.pilePath) { + this.load(pilePath); + } + + this.links.set(url, data); + this.save(); + + return this.links; + } + + save() { + if (!this.pilePath) return; + if (!fs.existsSync(this.pilePath)) { + fs.mkdirSync(this.pilePath, { recursive: true }); + } + + const filePath = path.join(this.pilePath, this.fileName); + const entries = this.links.entries(); + + if (!entries) return; + + let strMap = JSON.stringify(Array.from(entries)); + fs.writeFileSync(filePath, strMap); + } +} + +module.exports = new PileLinks(); diff --git a/src/main/workers/pileIndexWorker.js b/src/main/workers/pileIndexWorker.js deleted file mode 100644 index 78c1e71..0000000 --- a/src/main/workers/pileIndexWorker.js +++ /dev/null @@ -1,25 +0,0 @@ -const { parentPort } = require('worker_threads'); -const PileIndex = require('../utils/pileIndex'); - -const index = new PileIndex(); - -parentPort.on('message', (msg) => { - switch (msg.command) { - case 'load': - const loadedIndex = index.load(msg.pilePath); - parentPort.postMessage({ key: 'loaded', index: loadedIndex }); - break; - case 'add': - index.add(msg.filePath); - break; - case 'remove': - index.removeFile(msg.filePath); - break; - case 'search': - const result = index.search(msg.key, msg.value); - parentPort.postMessage(result); - break; - default: - console.log('Unknown command:', msg.command); - } -}); diff --git a/src/renderer/App.css b/src/renderer/App.css index aa01b71..fccae0d 100644 --- a/src/renderer/App.css +++ b/src/renderer/App.css @@ -14,6 +14,7 @@ --secondary: #626262; --base: #4d80e6; + --base-text: rgb(246, 247, 255); --base-hover: #2b5bbb; --base-yellow: #e6e04d; --base-green: #4de64d; @@ -42,6 +43,7 @@ --secondary: #c1c1c1; --base: #4d88ff; + --base-text: hsl(213, 100%, 93%); --base-hover: #82acff; --base-yellow: #776b0e; --base-green: #128212; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 2f38af3..f44cbec 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -18,6 +18,8 @@ import { TagsContextProvider } from './context/TagsContext'; import { TimelineContextProvider } from './context/TimelineContext'; import { AIContextProvider } from './context/AIContext'; import { HighlightsContextProvider } from './context/HighlightsContext'; +import { LinksContextProvider } from './context/LinksContext'; +import { ToastsContextProvider } from './context/ToastsContext'; if ('scrollRestoration' in history) { history.scrollRestoration = 'manual'; @@ -48,62 +50,66 @@ export default function App() { return ( - - - - - - - - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - - } - /> - - - - - - - - + + + + + + + + + + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + + } + /> + + + + + + + + + + ); } diff --git a/src/renderer/context/AIContext.js b/src/renderer/context/AIContext.js index 1843e43..d9903aa 100644 --- a/src/renderer/context/AIContext.js +++ b/src/renderer/context/AIContext.js @@ -7,16 +7,6 @@ import { } from 'react'; import OpenAI from 'openai'; -const processKey = (k) => { - if (k.startsWith('unms-')) { - k = k.substring(5); - const reversedStr = k.split('').reverse().join(''); - return 'sk-' + reversedStr; - } - - return k; -}; - export const AIContext = createContext(); export const AIContextProvider = ({ children }) => { @@ -34,10 +24,10 @@ export const AIContextProvider = ({ children }) => { if (!key) return; - const processedKey = processKey(key); const openaiInstance = new OpenAI({ - apiKey: processedKey, + apiKey: key, }); + setAi(openaiInstance); }; @@ -49,7 +39,28 @@ export const AIContextProvider = ({ children }) => { return window.electron.ipc.invoke('set-ai-key', secretKey); }; - const AIContextValue = { ai, prompt, setKey, getKey }; + const deleteKey = () => { + return window.electron.ipc.invoke('delete-ai-key'); + }; + + const getCompletion = async (model = 'gpt-3', context) => { + const response = await ai.chat.completions.create({ + model: 'gpt-4', + max_tokens: 200, + messages: context, + }); + + return response; + }; + + const AIContextValue = { + ai, + prompt, + setKey, + getKey, + deleteKey, + getCompletion, + }; return ( {children} diff --git a/src/renderer/context/LinksContext.js b/src/renderer/context/LinksContext.js new file mode 100644 index 0000000..c1fc82a --- /dev/null +++ b/src/renderer/context/LinksContext.js @@ -0,0 +1,167 @@ +import { + useState, + createContext, + useContext, + useEffect, + useCallback, +} from 'react'; +import { useLocation } from 'react-router-dom'; +import { usePilesContext } from './PilesContext'; +import { useAIContext } from './AIContext'; +import { useToastsContext } from './ToastsContext'; + +export const LinksContext = createContext(); + +export const LinksContextProvider = ({ children }) => { + const { currentPile, getCurrentPilePath } = usePilesContext(); + const { ai, getCompletion } = useAIContext(); + const { addNotification, updateNotification, removeNotification } = + useToastsContext(); + + const getLink = useCallback( + async (url) => { + const pilePath = getCurrentPilePath(); + const preview = await window.electron.ipc.invoke( + 'links-get', + pilePath, + url + ); + + // return cached preview if available + if (preview) { + return preview; + } + + addNotification({ + id: url, + type: 'waiting', + message: 'Creating link preview', + }); + + // otherwise generate a new preview + const _preview = await getPreview(url); + updateNotification(url, 'thinking', 'Generating preview...'); + const aiCard = await generateMeta(url).catch(() => { + console.log( + 'Failed to generate AI link preview, a basic preview will be used.' + ); + return null; + }); + + const linkPreview = { + url: url, + createdAt: new Date().toISOString(), + title: _preview?.title ?? '', + images: _preview?.images ?? [], + favicon: _preview?.favicon ?? '', + host: _preview?.host ?? '', + description: aiCard?.summary ?? '', + summary: '', + aiCard: aiCard ?? null, + }; + + // cache it + setLink(url, linkPreview); + + removeNotification(url); + + return linkPreview; + }, + [currentPile] + ); + + const setLink = useCallback( + async (url, data) => { + const pilePath = getCurrentPilePath(); + window.electron.ipc.invoke('links-set', pilePath, url, data); + }, + [currentPile] + ); + + const getPreview = async (url) => { + const data = await window.electron.ipc.invoke('get-link-preview', url); + return data; + }; + + const getContent = async (url) => { + const data = await window.electron.ipc.invoke('get-link-content', url); + return data; + }; + + const trimContent = (string, numWords = 2000) => { + const wordsArray = string.split(/\s+/); + if (wordsArray.length > numWords) { + return wordsArray.slice(0, numWords).join(' ') + '...'; + } + return string; + }; + + const generateMeta = async (url) => { + const { text, images, links } = await getContent(url); + const trimmedContent = trimContent(text); + + let context = []; + + context.push({ + role: 'system', + content: `Provided below is some extracted plaintext response of a website. Use it to generate the content for a rich preview card for the webpage. + The content is as follows: + ${trimmedContent} + `, + }); + context.push({ + role: 'system', + content: `These are the links on the page: + ${links} + `, + }); + context.push({ + role: 'system', + content: `These are the images on the page: + ${images} + `, + }); + context.push({ + role: 'system', + content: `Provide your response as a JSON object that follows this schema: + { + "url": ${url}, + "category": '', // suggest the best category for this page based on the content. eg: video, book, recipie, app, research paper, news, opinion, blog, social media etc. + "images": [{src: '', alt: ''}], // key images + "summary": string, // tldr summary of this webpage + "highlights": [''], // plaintext sentences of 3-8 key insights, facts or quotes. Like an executive summary. + "buttons": [{title: '', href: ''}], // use the links to generate a primary and secondary buttons appropriate for this preview. ONLY use relevant links from the page. + }`, + }); + + const response = await ai.chat.completions.create({ + model: 'gpt-3.5-turbo-1106', + max_tokens: 500, + messages: context, + response_format: { + type: 'json_object', + }, + }); + + let choice = false; + + try { + choice = JSON.parse(response.choices[0].message.content); + } catch (error) {} + + return choice; + }; + + const linksContextValue = { + getLink, + setLink, + }; + + return ( + + {children} + + ); +}; + +export const useLinksContext = () => useContext(LinksContext); diff --git a/src/renderer/context/ToastsContext.js b/src/renderer/context/ToastsContext.js new file mode 100644 index 0000000..a071811 --- /dev/null +++ b/src/renderer/context/ToastsContext.js @@ -0,0 +1,94 @@ +import { + useState, + createContext, + useContext, + useEffect, + useCallback, + useRef, +} from 'react'; + +export const ToastsContext = createContext(); + +export const ToastsContextProvider = ({ children }) => { + const [notificationsQueue, setNotificationsQueue] = useState([]); + + const notificationTimeoutRef = useRef(); + + const processQueue = () => { + if (notificationsQueue.length > 0) { + // Set a timeout to dismiss the first notification + notificationTimeoutRef.current = setTimeout(() => { + setNotificationsQueue((currentQueue) => currentQueue.slice(1)); + }, notificationsQueue[0].dismissTime || 5000); // Default 5 seconds + } + }; + + useEffect(() => { + // Clear any existing timeouts + if (notificationTimeoutRef.current) { + clearTimeout(notificationTimeoutRef.current); + notificationTimeoutRef.current = null; + } + + processQueue(); + + // Clean up timeout on unmount + return () => { + if (notificationTimeoutRef.current) { + clearTimeout(notificationTimeoutRef.current); + } + }; + }, [notificationsQueue]); + + const addNotification = ({ + id, + type = 'info', + message, + dismissTime = 5000, + }) => { + const newNotification = { id, type, message, dismissTime }; + setNotificationsQueue((currentQueue) => [...currentQueue, newNotification]); + }; + + const updateNotification = (targetId, newType, newMessage) => { + setNotificationsQueue((currentQueue) => + currentQueue.map((notification) => + notification.id === targetId + ? { ...notification, type: newType, message: newMessage } + : notification + ) + ); + }; + + const removeNotification = (targetId) => { + setNotificationsQueue((currentQueue) => + currentQueue.filter((notification) => notification.id !== targetId) + ); + + // If the notification being removed is the first in the queue, we need to clear the timeout + // and process the next notification if available + if ( + notificationsQueue.length > 0 && + notificationsQueue[0].id === targetId + ) { + clearTimeout(notificationTimeoutRef.current); + notificationTimeoutRef.current = null; + processQueue(); + } + }; + + const ToastsContextValue = { + notifications: notificationsQueue, + addNotification, + updateNotification, + removeNotification, + }; + + return ( + + {children} + + ); +}; + +export const useToastsContext = () => useContext(ToastsContext); diff --git a/src/renderer/hooks/usePostHelpers.js b/src/renderer/hooks/usePostHelpers.js index 0c0c88b..97d8c76 100644 --- a/src/renderer/hooks/usePostHelpers.js +++ b/src/renderer/hooks/usePostHelpers.js @@ -18,8 +18,7 @@ export const getPost = async (postPath) => { const post = { content: parsed.content, data: parsed.data }; return post; } catch (error) { - console.error(`Error reading/parsing file: ${postPath}`); - console.error(error); + // TODO: check and cleanup after these files } }; diff --git a/src/renderer/icons/img/ChainIcon.js b/src/renderer/icons/img/ChainIcon.js new file mode 100644 index 0000000..dec97c2 --- /dev/null +++ b/src/renderer/icons/img/ChainIcon.js @@ -0,0 +1,23 @@ +export const ChainIcon = (props) => { + return ( + + + + + + + ); +}; diff --git a/src/renderer/icons/index.js b/src/renderer/icons/index.js index fa8a678..5d971b4 100644 --- a/src/renderer/icons/index.js +++ b/src/renderer/icons/index.js @@ -37,3 +37,4 @@ export * from './img/NeedleIcon'; export * from './img/EditIcon'; export * from './img/AIIcon'; export * from './img/PileIcon'; +export * from './img/ChainIcon'; diff --git a/src/renderer/pages/Pile/Editor/Editor.module.scss b/src/renderer/pages/Pile/Editor/Editor.module.scss index a32d7e2..b2d5d6c 100644 --- a/src/renderer/pages/Pile/Editor/Editor.module.scss +++ b/src/renderer/pages/Pile/Editor/Editor.module.scss @@ -20,8 +20,6 @@ p { line-height: 1.45; - - &:first-child { margin-top: 0; } @@ -31,6 +29,14 @@ } } + a { + color: var(--secondary); + + &:hover { + color: var(--base); + } + } + &.editorBig { p { font-size: 1.2em; @@ -200,8 +206,7 @@ transition: all ease-in-out 120ms; padding: 0 18px; line-height: 28px; - color: var(--base); - color: var(--active-text); + color: var(--base-text); font-size: 0.9em; border-radius: 90px; user-select: none; diff --git a/src/renderer/pages/Pile/Editor/LinkPreviews/LinkPreview/LinkPreview.module.scss b/src/renderer/pages/Pile/Editor/LinkPreviews/LinkPreview/LinkPreview.module.scss new file mode 100644 index 0000000..905ed30 --- /dev/null +++ b/src/renderer/pages/Pile/Editor/LinkPreviews/LinkPreview/LinkPreview.module.scss @@ -0,0 +1,174 @@ +.card { + flex: 1 0 auto; + max-width: 400px; + background: var(--bg-secondary); + border-radius: 17px; + margin: 8px; + transition: all ease-in-out 220ms; + + // &:hover { + // cursor: pointer; + // background: var(--bg-tertiary); + // } + + .image { + padding: 3px 3px 0 3px; + border-radius: 17px 17px 0 0; + + img { + width: 100%; + height: auto; + max-height: 280px; + border-radius: 14px 14px 3px 3px; + margin: 0; + object-fit: cover; + } + } + + .content { + padding: 7px 12px 5px 12px; + + .title { + font-size: 1.05em; + line-height: 1.4; + font-weight: 500; + color: var(--preview); + transition: all ease-in-out 120ms; + + &:hover { + cursor: pointer; + color: var(--active); + } + } + } + + .aiCard { + color: var(--secondary); + padding: 0 12px 12px 12px; + line-height: 1.35; + font-size: 0.9em; + transition: max-height ease-in-out 200ms; + + .highlights { + display: block; + position: relative; + margin: 10px 0 0 0; + padding: 5px 0 0 1em; + height: auto; + max-height: 50px; + overflow: hidden; + border-top: 1px solid var(--border); + transition: max-height ease-in-out 200ms; + + &:hover { + cursor: pointer; + max-height: 100px; + } + + &.show { + max-height: 100%; + } + + .overlay { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; + transition: opacity ease-in-out 200ms; + background: linear-gradient(transparent, var(--bg-secondary)); + + &.hidden { + pointer-events: none; + opacity: 0; + } + } + + li { + padding: 4px 0 4px 0; + list-style: outside; + } + } + + .buttons { + display: flex; + flex-wrap: wrap; + margin: 10px 0 5px 0; + gap: 15px; + + a { + display: flex; + align-items: center; + color: var(--active-text); + background: var(--active); + height: 28px; + line-height: 28px; + padding: 0 12px 0 7px; + border-radius: 90px; + + .icon { + height: 20px; + width: 20px; + margin: -2px 3px 0 0; + } + + &:hover { + background: var(--active-hover); + } + + } + } + } + + .footer { + display: flex; + align-items: center; + padding: 4px 12px 12px 12px; + font-weight: 400; + font-size: 0.9em; + color: var(--secondary); + + .favicon { + height: 18px; + width: 18px; + margin-right: 9px; + border-radius: 2px; + } + + .category { + text-transform: capitalize; + + &::after { + content: '·'; + margin: 0 6px; + opacity: 0.7; + } + } + } +} + +.youtube { + margin: 8px; + padding: 3px 3px 0px 3px; + border-radius: 17px; + background: var(--bg-secondary); + width: 100%; + max-width: 560px; + + &:hover { + background: var(--bg-tertiary); + } + + iframe { + width: 100%; + height: 340px; + border-radius: 14px; + margin: 0; + padding: 0; + border: none; + + &::after { + display: none; + } + } +} \ No newline at end of file diff --git a/src/renderer/pages/Pile/Editor/LinkPreviews/LinkPreview/index.jsx b/src/renderer/pages/Pile/Editor/LinkPreviews/LinkPreview/index.jsx new file mode 100644 index 0000000..07bbb6f --- /dev/null +++ b/src/renderer/pages/Pile/Editor/LinkPreviews/LinkPreview/index.jsx @@ -0,0 +1,173 @@ +import styles from './LinkPreview.module.scss'; +import { useCallback, useState, useEffect } from 'react'; +import { + DiscIcon, + PhotoIcon, + TrashIcon, + TagIcon, + ChainIcon, +} from 'renderer/icons'; +import { motion, AnimatePresence } from 'framer-motion'; +import { useLinksContext } from 'renderer/context/LinksContext'; + +const isUrlYouTubeVideo = (url) => { + // Regular expression to check for various forms of YouTube URLs + const regExp = + /^(https?:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/; + return regExp.test(url); +}; + +export default function LinkPreview({ url }) { + const { getLink } = useLinksContext(); + const [expanded, setExpanded] = useState(false); + const [preview, setPreview] = useState(null); + + const toggleExpand = () => setExpanded(!expanded); + + const getPreview = async (url) => { + const data = await getLink(url); + setPreview(data); + }; + + const updateSummary = (e) => { + const summary = e.target.value; + const _preview = { ...preview, aiCard: { ...preview.aiCard, summary } }; + }; + + useEffect(() => { + getPreview(url); + }, [url]); + + if (!preview) return; + + const createYouTubeEmbed = (url) => { + // Extract the video ID from the YouTube URL + const regExp = /^.*(youtu.be\/|v\/|e\/|u\/\w+\/|embed\/|v=)([^#\&\?]*).*/; + const match = url.match(regExp); + + if (match && match[2].length === 11) { + return ( +
+