├── .cursorignore
├── .dependencygraph
└── setting.json
├── .gitignore
├── .npmrc
├── .prettierignore
├── .prettierrc
├── .vscode
├── extensions.json
├── launch.json
└── settings.json
├── README.md
├── adapter.js
├── adapter.ts
├── cors-anywhere.ts
├── e2e
└── demo.test.ts
├── eslint.config.js
├── package.json
├── playwright.config.ts
├── pnpm-lock.yaml
├── postcss.config.js
├── scripts
├── emoji-extractor-filtered.js
├── emoji-extractor.js
├── emoji-test-14.txt
├── extracted
│ ├── filtered
│ │ ├── emoji-compact.json
│ │ └── emoji.json
│ └── unfiltered
│ │ ├── emoji-compact.json
│ │ └── emoji.json
└── pre-migrate
│ ├── .gitignore
│ ├── README.md
│ ├── bun.lockb
│ ├── index.ts
│ ├── package.json
│ └── tsconfig.json
├── src
├── app.html
├── global.d.ts
├── hooks.server.ts
├── lib
│ ├── auth
│ │ ├── AuthForm.svelte
│ │ ├── EyeIcon.svelte
│ │ ├── LoginPopup.svelte
│ │ ├── Spinner.svelte
│ │ └── authStore.ts
│ ├── data
│ │ ├── RuneQuery.svelte.ts
│ │ ├── besticon.ts
│ │ ├── bookmarks
│ │ │ ├── defaults.ts
│ │ │ └── index.ts
│ │ ├── db.svelte.ts
│ │ ├── dbStore.ts
│ │ ├── defaultThemeConfigs.ts
│ │ ├── dexie.svelte.ts
│ │ ├── icons
│ │ │ └── TrashIcon.svelte
│ │ ├── importExport.ts
│ │ ├── liveQuery.svelte.ts
│ │ ├── settings
│ │ │ ├── FluidPanel.svelte
│ │ │ ├── Settings.svelte
│ │ │ ├── SettingsButton.svelte
│ │ │ ├── SettingsPanel.svelte
│ │ │ ├── addMissingSettings.ts
│ │ │ ├── grid
│ │ │ │ ├── Control.svelte
│ │ │ │ ├── GridSettings.svelte
│ │ │ │ └── ShowTitle.svelte
│ │ │ ├── sync
│ │ │ │ └── SyncSettings.svelte
│ │ │ └── theme
│ │ │ │ ├── GradientEditor.svelte
│ │ │ │ ├── Lock.svelte
│ │ │ │ ├── RandomizeBackground.svelte
│ │ │ │ ├── Saturation.svelte
│ │ │ │ ├── ThemeEditor.svelte
│ │ │ │ └── ThemeSettings.svelte
│ │ ├── sync.ts
│ │ ├── transactions.svelte.ts
│ │ ├── types.ts
│ │ └── user.ts
│ ├── feeds
│ │ ├── HN.svelte
│ │ ├── HNComment.svelte
│ │ ├── HNItem.svelte
│ │ ├── HNItemGhost.svelte
│ │ ├── HNSkeletons.svelte
│ │ ├── HNThread.svelte
│ │ ├── News.svelte
│ │ ├── constants.ts
│ │ ├── fetchData.ts
│ │ ├── fetchMeta.ts
│ │ ├── mockData.ts
│ │ ├── rulesets.ts
│ │ ├── skeletonTime.ts
│ │ ├── stores.ts
│ │ └── types.ts
│ ├── graphics
│ │ ├── LoadingDots.svelte
│ │ ├── RandomWave.svelte
│ │ ├── icons
│ │ │ ├── CollapseIcon.svelte
│ │ │ ├── CommentIcon.svelte
│ │ │ ├── Edit.svelte
│ │ │ ├── GithubIcon.svelte
│ │ │ ├── GithubIconWhiteBG.svg
│ │ │ └── HomeIcon.svelte
│ │ └── randomRange.ts
│ ├── inspector
│ │ ├── Group.svelte
│ │ ├── Inspector.svelte
│ │ ├── Menu.svelte
│ │ ├── Row.svelte
│ │ ├── index.svelte
│ │ └── inspectorStore.svelte.ts
│ ├── ruins.svelte.boomer.js
│ ├── ruins.svelte.ts
│ ├── search
│ │ ├── Engines.svelte
│ │ ├── IconList.svelte
│ │ ├── Search.svelte
│ │ ├── engines.ts
│ │ ├── icons
│ │ │ ├── Archive.svelte
│ │ │ ├── DuckDuckGo.svelte
│ │ │ ├── Ecosia.svelte
│ │ │ ├── Google.svelte
│ │ │ ├── HackerNews.svelte
│ │ │ ├── MDN.svelte
│ │ │ └── StackOverflow.svelte
│ │ └── search_state.svelte.ts
│ ├── stores
│ │ ├── activeSection.svelte.ts
│ │ ├── background.svelte.ts
│ │ ├── blurOverlay.svelte.ts
│ │ ├── bookmarkEditor.svelte.ts
│ │ ├── bookmarkEditor.ts
│ │ ├── debug.ts
│ │ ├── derived.svelte.ts
│ │ ├── device.svelte.ts
│ │ ├── folderEditor.svelte.ts
│ │ ├── grid.svelte.ts
│ │ ├── gridStore.ts
│ │ ├── index.ts
│ │ ├── settings-class.svelte.ts
│ │ ├── settings.svelte.ts
│ │ ├── settings.types.ts
│ │ ├── settingsStore.ts
│ │ ├── showGuidelines.ts
│ │ └── state.svelte.ts
│ ├── theme
│ │ ├── ThemeSwitch.svelte
│ │ ├── resolveTheme.ts
│ │ ├── themer.svelte.ts
│ │ ├── themer.types.ts
│ │ └── themes
│ │ │ └── vanilla.ts
│ ├── theme_og
│ │ ├── ThemeToggle.svelte
│ │ ├── createGradient.ts
│ │ ├── graphics
│ │ │ ├── Clouds.svelte
│ │ │ ├── Moon.svelte
│ │ │ ├── Stars.svelte
│ │ │ └── Sun.svelte
│ │ ├── index.ts
│ │ ├── initBackground.ts
│ │ ├── randomizeBackground.ts
│ │ ├── theme.ts
│ │ └── themer.ts
│ ├── ui
│ │ ├── Bookmarks
│ │ │ ├── Bookmark.svelte
│ │ │ ├── BookmarkArt.svelte
│ │ │ ├── BookmarkEditor.svelte
│ │ │ ├── BookmarkEditorIcon.svelte
│ │ │ ├── BookmarkImageEditor.svelte
│ │ │ ├── DeleteBookmark.svelte
│ │ │ ├── Grid.svelte
│ │ │ ├── ImageURL.svelte
│ │ │ └── Tags.svelte
│ │ ├── Button.svelte
│ │ ├── Folders
│ │ │ ├── DeleteFolder.svelte
│ │ │ ├── EmojiPicker.svelte
│ │ │ ├── FolderEditor.svelte
│ │ │ ├── FolderSidebar.svelte
│ │ │ ├── emojis.ts
│ │ │ └── svelte5-events.md
│ │ ├── Header
│ │ │ ├── Header.svelte
│ │ │ ├── Nav.svelte
│ │ │ └── index.ts
│ │ ├── Main.svelte
│ │ ├── Modal.svelte
│ │ ├── Nav.svelte
│ │ ├── NuTab.svelte
│ │ ├── Overlay.svelte
│ │ ├── Range.svelte
│ │ ├── RightClickMenu.svelte
│ │ ├── Scrollbar.svelte
│ │ ├── TabOptions.svelte
│ │ ├── Tooltip.svelte
│ │ └── index.ts
│ └── utils
│ │ ├── Debugger
│ │ ├── Debugger.svelte
│ │ └── FloatingPanel.svelte
│ │ ├── EventManager.svelte.ts
│ │ ├── Inspector.svelte
│ │ ├── autoCleanupEffect.svelte.ts
│ │ ├── boilerplate.svelte.ts
│ │ ├── clickOutside.ts
│ │ ├── clipboardCopy.ts
│ │ ├── color.ts
│ │ ├── css-colors.ts
│ │ ├── daysAgo.ts
│ │ ├── debounce.ts
│ │ ├── defer.ts
│ │ ├── getClipboardUrl.ts
│ │ ├── idFromClassList.ts
│ │ ├── index.ts
│ │ ├── keyboardEvents.ts
│ │ ├── l.ts
│ │ ├── localState.svelte.ts
│ │ ├── localStorageStore.ts
│ │ ├── log.ts
│ │ ├── logger.ts
│ │ ├── mapRange.ts
│ │ ├── nanoid.ts
│ │ ├── onClick.ts
│ │ ├── onClick.types.ts
│ │ ├── persist.svelte.ts
│ │ ├── persist2.svelte.ts
│ │ ├── posEnd.ts
│ │ ├── randomColor.ts
│ │ ├── resizable.ts
│ │ ├── rotateArray.ts
│ │ ├── safeLocalStorage.ts
│ │ ├── selectText.ts
│ │ ├── smoothToggle.ts
│ │ ├── smooth_hover.ts
│ │ ├── stringify.ts
│ │ ├── throttle.ts
│ │ ├── tldr.ts
│ │ ├── visibility.ts
│ │ └── wait.ts
├── routes
│ ├── +error.svelte
│ ├── +layout.svelte
│ ├── +layout.ts
│ ├── +page.svelte
│ ├── Root.svelte
│ ├── api
│ │ └── [url]
│ │ │ └── +server.ts
│ └── garage
│ │ ├── +layout.svelte
│ │ ├── deep-state
│ │ ├── +page.svelte
│ │ └── deep-state.svelte.ts
│ │ ├── global-state
│ │ ├── +page.svelte
│ │ └── globals.svelte.ts
│ │ ├── local-state
│ │ ├── +page.svelte
│ │ ├── decorator
│ │ │ └── +page.svelte
│ │ ├── dot-value
│ │ │ └── +page.svelte
│ │ └── util
│ │ │ └── +page.svelte
│ │ ├── localhost
│ │ └── +page.svelte
│ │ ├── on-click
│ │ └── +page.svelte
│ │ └── simple-themer
│ │ ├── +page.svelte
│ │ ├── simple-themer.svelte.js
│ │ └── simple-themer.svelte.ts
└── styles
│ ├── app.scss
│ ├── btn.scss
│ ├── input.scss
│ ├── prism.scss
│ ├── theme.scss
│ └── utils.sass
├── static
├── android-chrome-192x192.png
├── android-chrome-512x512.png
├── apple-touch-icon.png
├── banner.jpg
├── browserconfig.xml
├── favicon-16x16.png
├── favicon-32x32.png
├── favicon.ico
├── favicon.svg
├── fonts
│ ├── dosis
│ │ ├── OFL.txt
│ │ └── dosis.ttf
│ ├── merriweather
│ │ ├── OFL.txt
│ │ └── merriweather.ttf
│ └── rubik
│ │ ├── OFL.txt
│ │ ├── rubik-italic.ttf
│ │ └── rubik.ttf
├── images
│ └── logos
│ │ └── MDN.jpg
├── logo.svg
├── manifest.json
├── mstile-150x150.png
├── nutab.png
├── robots.txt
└── site.webmanifest
├── svelte.config.js
├── sync-worker
├── .dev.vars.example
├── .editorconfig
├── .gitignore
├── package.json
├── pnpm-lock.yaml
├── src
│ ├── index.ts
│ ├── routes
│ │ └── user.ts
│ ├── types.ts
│ └── url.ts
├── test.ts
├── tsconfig.json
└── wrangler.toml
├── tailwind.config.ts
├── tsconfig.json
└── vite.config.ts
/.cursorignore:
--------------------------------------------------------------------------------
1 | .vscode/
2 | .dependencygraph/
3 |
4 | .git/
5 | node_modules/
6 |
7 | .svelte-kit/
8 | static/
9 | worktop/
10 | scripts/
11 |
12 | pnpm-lock.yaml
13 | adapter.js
14 |
--------------------------------------------------------------------------------
/.dependencygraph/setting.json:
--------------------------------------------------------------------------------
1 | {
2 | "alias": {
3 | "$lib": "src/lib",
4 | "$lib/*": "src/lib/*"
5 | },
6 | "resolveExtensions": [
7 | ".js",
8 | ".jsx",
9 | ".ts",
10 | ".tsx",
11 | ".vue",
12 | ".scss",
13 | ".less"
14 | ]
15 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .git
2 |
3 | # OS
4 | .DS_Store
5 | Thumbs.db
6 |
7 | # Env
8 | .env
9 | .env.*
10 | !.env.example
11 | !.env.test
12 |
13 | # Vite
14 | vite.config.js.timestamp-*
15 | vite.config.ts.timestamp-*
16 |
17 | # NPM
18 | node_modules
19 | package-lock.json
20 | .pnpm.debug.log
21 |
22 | # SvelteKit
23 | .output
24 | /build
25 | /.svelte-kit
26 |
27 | # Vercel
28 | .vercel
29 | /functions
30 | /.vercel_build_output
31 |
32 | # Test
33 | test-results
34 |
35 | # Browser Extensions
36 | build.crx
37 | build.pem
38 |
39 | # Misc
40 | .cursorrules
41 | /context
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | engine-strict=false
2 | strict-peer-dependencies=false
3 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | # Package Managers
2 | package-lock.json
3 | pnpm-lock.yaml
4 | yarn.lock
5 |
6 | # SvelteKit
7 | .svelte-kit/**
8 | static/**
9 | build/**
10 |
11 | # Node
12 | node_modules/**
13 |
14 | # Misc
15 | src/lib/data/transactions.ts
16 | src/lib/utils/keyboardEvents.ts
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": false,
3 | "tabWidth": 4,
4 | "useTabs": true,
5 | "printWidth": 100,
6 | "singleQuote": true,
7 | "trailingComma": "all",
8 | "htmlWhitespaceSensitivity": "css",
9 | "plugins": ["prettier-plugin-svelte"],
10 | "overrides": [
11 | {
12 | "files": "*.svelte",
13 | "options": {
14 | "parser": "svelte"
15 | }
16 | }
17 | ]
18 | }
19 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | /*
4 | * Important extentions
5 | */
6 | "christian-kohler.path-intellisense",
7 | "aaron-bond.better-comments",
8 | "dbaeumer.vscode-eslint",
9 | "svelte.svelte-vscode"
10 |
11 | /*
12 | * Honorable mentions
13 | */
14 | // "coenraads.bracket-pair-colorizer-2",
15 | // "github.vscode-pull-request-github",
16 | // "ardenivanov.svelte-intellisense",
17 | // "vincaslt.highlight-matching-tag",
18 | // "ziyasal.vscode-open-in-github",
19 | // "pflannery.vscode-versionlens",
20 | // "wayou.vscode-todo-highlight",
21 | // "alefragnani.project-manager",
22 | // "ms-vsliveshare.vsliveshare",
23 | // "pkief.material-icon-theme",
24 | // "wallabyjs.quokka-vscode",
25 | // "naumovs.color-highlight",
26 | // "stevencl.adddoccomments",
27 | // "inu1255.easy-snippet",
28 | // "anseki.vscode-color",
29 | // "vsls-contrib.gistfs",
30 | // "mhutchie.git-graph",
31 | // "henoc.svgeditor"
32 | ]
33 | }
34 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to learn about possible attributes.
3 | // Hover to view descriptions of existing attributes.
4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "type": "pwa-chrome",
9 | "request": "launch",
10 | "name": "Launch Chrome against localhost",
11 | "url": "http://localhost:3000",
12 | "webRoot": "${workspaceFolder}/src"
13 | }
14 | ]
15 | }
16 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "[javascript]": {
3 | "editor.defaultFormatter": "esbenp.prettier-vscode",
4 | "editor.formatOnSave": true
5 | },
6 | "[typescript]": {
7 | "editor.defaultFormatter": "esbenp.prettier-vscode",
8 | "editor.formatOnSave": true
9 | },
10 | "[svelte]": {
11 | "editor.defaultFormatter": "svelte.svelte-vscode",
12 | "editor.formatOnSave": true
13 | },
14 | "svelte.plugin.svelte.compilerWarnings": {
15 | "element_invalid_self_closing_tag": "ignore",
16 | //* These are disabled because they are annoying and this is a personal project not intended for a wide audience.
17 | "a11y_aria_attributes": "ignore",
18 | "a11y_incorrect_aria_attribute_type": "ignore",
19 | "a11y_unknown_aria_attribute": "ignore",
20 | "a11y_hidden": "ignore",
21 | "a11y_misplaced_role": "ignore",
22 | "a11y_unknown_role": "ignore",
23 | "a11y_no_abstract_role": "ignore",
24 | "a11y_no_redundant_roles": "ignore",
25 | "a11y_role_has_required_aria_props": "ignore",
26 | "a11y_accesskey": "ignore",
27 | "a11y_autofocus": "ignore",
28 | "a11y_misplaced_scope": "ignore",
29 | "a11y_positive_tabindex": "ignore",
30 | "a11y_invalid_attribute": "ignore",
31 | "a11y_missing_attribute": "ignore",
32 | "a11y_img_redundant_alt": "ignore",
33 | "a11y_label_has_associated_control": "ignore",
34 | "a11y_media_has_caption": "ignore",
35 | "a11y_distracting_elements": "ignore",
36 | "a11y_structure": "ignore",
37 | "a11y_mouse_events_have_key_events": "ignore",
38 | "a11y_missing_content": "ignore",
39 | "a11y_click_events_have_key_events": "ignore",
40 | "a11y_no_static_element_interactions": "ignore",
41 | "a11y_no_noninteractive_element_interactions": "ignore"
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # [Nutab](https://nutab.braebo.dev)
2 |
3 | A sleek new-tab page for managing bookmarks, browsing [hacker news](https://news.ycombinator.com/), and searching the web.
4 |
5 | 
6 |
7 | - Add and organize bookmarks with folders and tags.
8 | - Scroll up to browse [HN](https://news.ycombinator.com/)
9 | - Up and Down arrow keys change search engines when the search bar is focused (or type 'hn', 'ddg', 'so', etc as shortcuts before your query')
10 |
11 | You can use it by setting the url as your new tab page: https://nutab.braebo.dev
12 |
13 | To test the browser extension version _(wip)_, load the "build" folder as an "unpacked" extension.
14 |
--------------------------------------------------------------------------------
/cors-anywhere.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | async fetch(request: Request) {
3 | const url = new URL(request.url).searchParams.get('q')
4 |
5 | if (!url) {
6 | return new Response('No url provided', {
7 | status: 400,
8 | headers: {
9 | 'Access-Control-Allow-Origin': '*',
10 | 'Access-Control-Allow-Headers': '*',
11 | },
12 | })
13 | }
14 |
15 | const response = await fetch(url, {
16 | method: request.method,
17 | headers: request.headers,
18 | })
19 |
20 | const headers = new Headers(response.headers)
21 | headers.set('Access-Control-Allow-Origin', '*')
22 | headers.set('Access-Control-Allow-Headers', '*')
23 |
24 | const responseClone = new Response(response.body, { headers })
25 |
26 | return responseClone
27 | },
28 | }
29 |
--------------------------------------------------------------------------------
/e2e/demo.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, test } from '@playwright/test';
2 |
3 | test('home page has expected h1', async ({ page }) => {
4 | await page.goto('/');
5 | await expect(page.locator('h1')).toBeVisible();
6 | });
7 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import prettier from 'eslint-config-prettier'
2 | import svelte from 'eslint-plugin-svelte'
3 | import ts from 'typescript-eslint'
4 | import globals from 'globals'
5 |
6 | export default ts.config(
7 | ...ts.configs.recommended,
8 | ...svelte.configs['flat/recommended'],
9 | prettier,
10 | ...svelte.configs['flat/prettier'],
11 | {
12 | languageOptions: {
13 | globals: {
14 | ...globals.browser,
15 | ...globals.node,
16 | },
17 | },
18 | },
19 | {
20 | files: ['**/*.svelte'],
21 | languageOptions: {
22 | parserOptions: {
23 | parser: ts.parser,
24 | },
25 | },
26 | },
27 | {
28 | ignores: ['build/', '.svelte-kit/', 'dist/'],
29 | },
30 | {
31 | rules: {
32 | '@typescript-eslint/no-unused-expressions': 'off',
33 | '@typescript-eslint/no-explicit-any': 'off',
34 | },
35 | },
36 | )
37 |
--------------------------------------------------------------------------------
/playwright.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from '@playwright/test'
2 |
3 | export default defineConfig({
4 | webServer: {
5 | command: 'npm run build && npm run preview',
6 | port: 4173,
7 | },
8 |
9 | testDir: 'e2e',
10 | })
11 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | cssNano: {},
6 | },
7 | }
8 |
--------------------------------------------------------------------------------
/scripts/emoji-extractor.js:
--------------------------------------------------------------------------------
1 | // https://github.com/amio/emoji.json/blob/HEAD/scripts/gen.js
2 |
3 | import fs from 'fs'
4 | import path from 'path'
5 | import https from 'https'
6 | import { fileURLToPath } from 'url'
7 | import { dirname } from 'path'
8 |
9 | const __filename = fileURLToPath(import.meta.url)
10 | const __dirname = dirname(__filename)
11 |
12 | const EMOJI_VERSION = '14.0'
13 |
14 | main()
15 |
16 | async function main() {
17 | const text = await getTestFile(EMOJI_VERSION)
18 |
19 | console.log(`Format text to json...`)
20 | const collected = text
21 | .trim()
22 | .split('\n')
23 | .reduce(
24 | (accu, line) => {
25 | if (line.startsWith('# group: ')) {
26 | console.log(` Processing ${line.substr(2)}...`)
27 | accu.group = line.substr(9)
28 | } else if (line.startsWith('# subgroup: ')) {
29 | accu.subgroup = line.substr(12)
30 | } else if (line.startsWith('#')) {
31 | accu.comments = accu.comments + line + '\n'
32 | } else {
33 | const meta = parseLine(line)
34 | if (meta) {
35 | meta.category = `${accu.group} (${accu.subgroup})`
36 | meta.group = accu.group
37 | meta.subgroup = accu.subgroup
38 | accu.full.push(meta)
39 | accu.compact.push(meta.char)
40 | } else {
41 | accu.comments = accu.comments.trim() + '\n\n'
42 | }
43 | }
44 | return accu
45 | },
46 | { comments: '', full: [], compact: [] }
47 | )
48 |
49 | console.log(`Processed emojis: ${collected.full.length}`)
50 |
51 | console.log('Write file: emoji.json, emoji-compact.json \n')
52 | await writeFiles(collected)
53 |
54 | console.log(collected.comments)
55 | }
56 |
57 | async function getTestFile(ver) {
58 | const url = `https://unicode.org/Public/emoji/${ver}/emoji-test.txt`
59 |
60 | process.stdout.write(`Fetch emoji-test.txt (v${EMOJI_VERSION})`)
61 | return new Promise((resolve, reject) => {
62 | https.get(url, (res) => {
63 | let text = ''
64 | res.setEncoding('utf8')
65 | res.on('data', (chunk) => {
66 | process.stdout.write('.')
67 | text += chunk
68 | })
69 | res.on('end', () => {
70 | process.stdout.write('\n')
71 | resolve(text)
72 | })
73 | res.on('error', reject)
74 | })
75 | })
76 | }
77 |
78 | function parseLine(line) {
79 | const data = line.trim().split(/\s+[;#] /)
80 |
81 | if (data.length !== 3) {
82 | return null
83 | }
84 |
85 | const [codes, status, charAndName] = data
86 | const [, char, name] = charAndName.match(/^(\S+) E\d+\.\d+ (.+)$/)
87 |
88 | return { codes, char, name }
89 | }
90 |
91 | const rel = (...args) => path.resolve(__dirname, ...args)
92 |
93 | function writeFiles({ full, compact }) {
94 | fs.writeFileSync(rel('./extracted/unfiltered/emoji.json'), JSON.stringify(full), 'utf8')
95 | fs.writeFileSync(rel('./extracted/unfiltered/emoji-compact.json'), JSON.stringify(compact), 'utf8')
96 | }
97 |
--------------------------------------------------------------------------------
/scripts/pre-migrate/README.md:
--------------------------------------------------------------------------------
1 | # pre-migrate
2 |
3 | To install dependencies:
4 |
5 | ```bash
6 | bun install
7 | ```
8 |
9 | To run:
10 |
11 | ```bash
12 | bun run index.ts
13 | ```
14 |
15 | This project was created using `bun init` in bun v1.1.22. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
16 |
--------------------------------------------------------------------------------
/scripts/pre-migrate/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/braebo/nutab/25baaf74cb0689a484ff41a3678de71c5c588939/scripts/pre-migrate/bun.lockb
--------------------------------------------------------------------------------
/scripts/pre-migrate/index.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @fileoverview Finds all svelte files with a
8 |
9 |
27 |
--------------------------------------------------------------------------------
/src/lib/auth/LoginPopup.svelte:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 | {#if $status != 'invalid'}
15 |
16 | {#if $status == 'success'}
17 |
18 |
{$successMessage}
19 | {:else if $status == 'error'}
20 |
25 | {/if}
26 |
27 | {/if}
28 |
29 |
36 |
37 |
38 |
79 |
--------------------------------------------------------------------------------
/src/lib/auth/Spinner.svelte:
--------------------------------------------------------------------------------
1 |
21 |
22 |
41 |
42 |
61 |
--------------------------------------------------------------------------------
/src/lib/data/RuneQuery.svelte.ts:
--------------------------------------------------------------------------------
1 | import type { Subscriber, Unsubscriber } from 'svelte/store'
2 |
3 | import { untrack } from 'svelte'
4 |
5 | export interface ReadableQuery {
6 | subscribe(
7 | this: void,
8 | run: Subscriber,
9 | invalidate?: () => void,
10 | ): {
11 | unsubscribe: Unsubscriber
12 | }
13 | }
14 |
15 | export type ReadableValue = T extends ReadableQuery ? U : never
16 |
17 | export class RuneQuery>> {
18 | current = $state()
19 |
20 | constructor(public readonly store: ReadableQuery) {
21 | if ($effect.tracking()) {
22 | untrack(() => {
23 | store
24 | .subscribe((v) => {
25 | this.current = v
26 | })
27 | .unsubscribe()
28 | })
29 |
30 | $effect.pre(() => {
31 | return store.subscribe((v) => {
32 | this.current = v
33 | }).unsubscribe
34 | })
35 | } else {
36 | this.dispose = $effect.root(() => {
37 | untrack(() => {
38 | store
39 | .subscribe((v) => {
40 | this.current = v
41 | })
42 | .unsubscribe()
43 | })
44 |
45 | $effect.pre(() => {
46 | return store.subscribe((v) => {
47 | this.current = v
48 | }).unsubscribe
49 | })
50 | })
51 | }
52 | }
53 |
54 | dispose = () => {}
55 | }
56 |
--------------------------------------------------------------------------------
/src/lib/data/besticon.ts:
--------------------------------------------------------------------------------
1 | // import defaultCollection from './collections/defaults'
2 |
3 | // https://besticon-demo.herokuapp.com/
4 | const iconFromUrl = (url: string) => `https://besticon-demo.herokuapp.com/allicons.json?url=${encodeURIComponent(url)}`
5 |
6 | const getIcon = async (url: string) => {
7 | try {
8 | fetch(iconFromUrl(url))
9 | .then((response => response.json()))
10 | .then(data => {
11 | console.log(data)
12 | return data
13 | })
14 | } catch (e) { console.error(e) }
15 | }
16 |
17 | export default getIcon
18 |
--------------------------------------------------------------------------------
/src/lib/data/bookmarks/index.ts:
--------------------------------------------------------------------------------
1 | import { defaultFolder } from './defaults'
2 |
3 | const folders = [defaultFolder] // TODO: Add more default folder templates by category (news / media / notes / art / music / etc)
4 |
5 | export default folders
6 |
--------------------------------------------------------------------------------
/src/lib/data/dbStore.ts:
--------------------------------------------------------------------------------
1 | // import type { Folder, Bookmark, FolderListItem } from './types'
2 | // import type { Writable } from 'svelte/store'
3 |
4 | // import { writable, derived } from 'svelte/store'
5 | // import { localStorageStore } from 'fractils'
6 | // import dexie from '$lib/data/dexie.svelte'
7 | // import { liveQuery } from 'dexie'
8 |
9 | // /**
10 | // ** A list of all folders (without their bookmarks) for the FolderSidebar list.
11 | // */
12 | // export const folders = writable()
13 | // export const activeFolder = writable()
14 |
15 | // // Seperate store that is manually set. i.e. - to a tag-filtered array of bookmarks
16 | // export const activeBookmarks = writable()
17 |
18 | // export const lastActiveFolderId = localStorageStore('lastActiveFolderId', '')
19 |
20 | // export const uniqueTags = liveQuery(
21 | // async () => (await dexie.bookmarks.orderBy('tags').uniqueKeys()) as string[],
22 | // )
23 |
24 | // // Filter bookmarks by tag
25 | // export const tagFilter = writable(null)
26 |
27 | // /** The active folder's bookmarks, filtered by tag if applicable */
28 | // export const activeFolderBookmarks = derived<
29 | // [Writable, Writable, Writable],
30 | // Bookmark[]
31 | // >(
32 | // // @ts-expect-error ...
33 | // [activeBookmarks, activeFolder, tagFilter!],
34 | // ([$activeBookmarks, $activeFolder, $tagFilter], set) => {
35 | // if ($activeBookmarks) {
36 | // // todo- Is this even helping trigger updates?
37 | // }
38 | // if ($tagFilter === null) {
39 | // // // @ts-expect-error ...
40 | // dexie.bookmarks
41 | // .bulkGet(($activeFolder as Folder)?.bookmark_ids || [])
42 | // .then((b) => set(b.filter(Boolean) as Bookmark[]))
43 | // } else {
44 | // dexie.bookmarks
45 | // .where('tags')
46 | // .equals($tagFilter as string)
47 | // .toArray()
48 | // // @ts-expect-error ...
49 | // .then((b) => filterBookmarks(b).then(set))
50 | // }
51 |
52 | // async function filterBookmarks(bookmarks: Bookmark[]) {
53 | // // Some folders have similar bookmarks with unique id's, so we
54 | // // need to filter out the duplicates.
55 | // const uniqueTitles = new Set()
56 | // // @ts-expect-error ...
57 | // const uniqueBookmarks = bookmarks.reduce((acc, curr) => {
58 | // if (!uniqueTitles.has(curr.title)) {
59 | // uniqueTitles.add(curr.title)
60 | // return [...acc, curr]
61 | // }
62 | // return acc
63 | // }, [])
64 | // return uniqueBookmarks
65 | // }
66 |
67 | // return () => {
68 | // set = () => {}
69 | // }
70 | // },
71 | // )
72 |
--------------------------------------------------------------------------------
/src/lib/data/defaultThemeConfigs.ts:
--------------------------------------------------------------------------------
1 | import type { ThemeConfigs } from '$lib/data/types'
2 |
3 | import { randomBackground } from '$lib/utils'
4 |
5 | /**
6 | * Generates a set of default theme configs.
7 | */
8 | export function defaultThemeConfigs(): ThemeConfigs {
9 | const sharedBG = randomBackground()
10 | const lightBG = randomBackground()
11 | const darkBG = randomBackground()
12 |
13 | const shared = {
14 | background: sharedBG.gradient,
15 | lockBackground: false,
16 | customGradient: false,
17 | gradientA: sharedBG.a,
18 | gradientB: sharedBG.b,
19 | gradientOpacity: sharedBG.opacity,
20 | }
21 |
22 | type Theme = typeof shared
23 |
24 | const light: Theme = {
25 | ...shared,
26 | background: lightBG.gradient,
27 | gradientA: lightBG.a,
28 | gradientB: lightBG.b,
29 | gradientOpacity: lightBG.opacity,
30 | }
31 |
32 | const dark: Theme = {
33 | ...shared,
34 | background: darkBG.gradient,
35 | gradientA: darkBG.a,
36 | gradientB: darkBG.b,
37 | gradientOpacity: darkBG.opacity,
38 | }
39 |
40 | return { shared, light, dark }
41 | }
42 |
--------------------------------------------------------------------------------
/src/lib/data/dexie.svelte.ts:
--------------------------------------------------------------------------------
1 | import type { Bookmark, Folder } from './types'
2 | import type { EntityTable } from 'dexie'
3 |
4 | import { defaultBookmarks, defaultFolder } from './bookmarks/defaults'
5 | import { Logger } from '$lib/utils/logger'
6 | // import { db } from './db.svelte'
7 | import Dexie from 'dexie'
8 |
9 | const log = new Logger('Dexie', { fg: 'tomato' }).log
10 |
11 | class DexieBookmarks extends Dexie {
12 | bookmarks!: EntityTable
13 | folders!: EntityTable
14 | settings!: EntityTable<{ key: string; value: string }, 'key'>
15 |
16 | constructor() {
17 | super('BookmarksDB')
18 | this.version(1).stores({
19 | bookmarks: 'bookmark_id, *tags, title, position',
20 | folders: 'folder_id, *bookmarks, position',
21 | settings: 'key',
22 | })
23 | }
24 | }
25 |
26 | const dexie = new DexieBookmarks()
27 |
28 | // Runs once
29 | dexie.on('populate', async () => {
30 | log('🎬 Initializing BookmarksDB:', { defaultFolder })
31 |
32 | // Add default bookmarks
33 | await dexie.bookmarks
34 | .bulkAdd(defaultBookmarks)
35 | .catch((e) => console.warn('Error adding default bookmarks', e))
36 |
37 | // Add default folder
38 | await dexie.folders
39 | .add(defaultFolder)
40 | .catch((e) => console.warn('Error adding default folder', e))
41 |
42 | // Set default folder as active
43 | await dexie.settings.put({ key: 'activeFolderId', value: defaultFolder.folder_id }, 'key')
44 |
45 | // setTimeout(() => {
46 | // log('🏁 BookmarksDB initialized.', {
47 | // activeFolderId: $state.snapshot(db.activeFolderId),
48 | // activeFolder: $state.snapshot(db.activeFolder),
49 | // activeBookmarks: $state.snapshot(db.activeBookmarks),
50 | // defaultFolder,
51 | // })
52 | // }, 100)
53 | })
54 |
55 | export default dexie
56 |
--------------------------------------------------------------------------------
/src/lib/data/icons/TrashIcon.svelte:
--------------------------------------------------------------------------------
1 |
2 |
26 |
27 |
28 |
42 |
--------------------------------------------------------------------------------
/src/lib/data/importExport.ts:
--------------------------------------------------------------------------------
1 | import { addBookmark_db } from './transactions.svelte'
2 | import { exportDB } from 'dexie-export-import'
3 | import { nanoid } from '$lib/utils/nanoid'
4 | import dexie from './dexie.svelte'
5 |
6 | /**
7 | * Saves the current bookmarks to a json file.
8 | */
9 | export const exportBookmarks = async () => {
10 | const blob = await exportDB(dexie, { prettyJson: true })
11 | const a = document.createElement('a')
12 | a.href = URL.createObjectURL(blob)
13 | a.download = 'nutab-bookmarks.json'
14 | a.click()
15 | }
16 |
17 | /**
18 | * Imports bookmarks from a json file.
19 | * @remark Bookmarks are currently added to the active folder.
20 | * It would be nice to add them to their respective folders instead,
21 | * creating said folders if they don't exist.
22 | */
23 | export const importBookmarks = async () => {
24 | const fileEl = document.createElement('input')
25 | fileEl.type = 'file'
26 | fileEl.accept = '.json'
27 | fileEl.click()
28 | fileEl.onchange = async () => {
29 | try {
30 | const file = fileEl.files?.[0]
31 | if (!file) return console.error('No file selected.')
32 | const blob = file.slice(0, file.size, 'application/json')
33 | const json = JSON.parse(await blob.text())
34 | const { data } = json.data
35 |
36 | const bookmarks = data?.filter(
37 | (t: Record) => t.tableName === 'bookmarks',
38 | )?.[0]?.rows
39 |
40 | if (!bookmarks) return console.error('No bookmarks found in file.')
41 |
42 | for (const bookmark of bookmarks) {
43 | bookmark.bookmark_id = nanoid()
44 | addBookmark_db(bookmark)
45 | }
46 | } catch (e) {
47 | console.error(e)
48 | }
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/lib/data/liveQuery.svelte.ts:
--------------------------------------------------------------------------------
1 | //- Doesn't work.
2 |
3 | // import type { Writable } from 'svelte/store'
4 |
5 | // import { liveQuery as dexieLiveQuery } from 'dexie'
6 | // import { fromStore } from 'svelte/store'
7 |
8 | // export const liveQuery = (q: () => T | Promise) =>
9 | // fromStore({
10 | // subscribe: (run, invalidate) => {
11 | // return dexieLiveQuery(q).subscribe(run as () => T, invalidate).unsubscribe
12 | // },
13 | // } as Writable)
14 |
--------------------------------------------------------------------------------
/src/lib/data/settings/Settings.svelte:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/lib/data/settings/SettingsButton.svelte:
--------------------------------------------------------------------------------
1 |
4 |
17 |
18 |
19 |
30 |
31 |
72 |
--------------------------------------------------------------------------------
/src/lib/data/settings/SettingsPanel.svelte:
--------------------------------------------------------------------------------
1 |
17 |
18 |
19 | {@const SvelteComponent = activeSection}
20 |
21 |
33 |
34 | {#key activeSection}
35 |
39 | {#key activeSection}
40 |
41 | {/key}
42 |
43 | {/key}
44 |
45 |
46 |
47 |
103 |
--------------------------------------------------------------------------------
/src/lib/data/settings/addMissingSettings.ts:
--------------------------------------------------------------------------------
1 | import { settings } from '$lib/stores/settings.svelte'
2 | import { log } from '$lib/utils/log'
3 |
4 | export const addMissingSettings = async () => {
5 | const s = settings
6 |
7 | try {
8 | for (const [key, value] of Object.entries(settings)) {
9 | if (!(key in s)) {
10 | log('Missing Setting Found:')
11 | log(`Adding missing setting: ${key}`)
12 | if (typeof key !== 'undefined') {
13 | // @ts-expect-error ...
14 | settings[key] = value
15 | }
16 | }
17 | }
18 | } catch (err) {
19 | log('Error adding missing settings:', 'tomato', 'black')
20 | log(err)
21 | }
22 |
23 | return true
24 | }
25 |
--------------------------------------------------------------------------------
/src/lib/data/settings/grid/Control.svelte:
--------------------------------------------------------------------------------
1 |
12 |
13 |
18 | {#if label}
{/if}
19 |
20 | {@render children?.()}
21 |
22 |
23 |
24 |
68 |
--------------------------------------------------------------------------------
/src/lib/data/settings/grid/GridSettings.svelte:
--------------------------------------------------------------------------------
1 |
47 |
48 |
49 | {#each settingsKeys as setting, i}
50 |
51 | handleInput(e, setting)}
53 | bind:value={settings.ranges[setting]}
54 | range={ranges[setting].range}
55 | name={setting}
56 | />
57 |
58 | {/each}
59 |
60 |
65 |
66 |
67 |
68 |
69 |
89 |
--------------------------------------------------------------------------------
/src/lib/data/settings/grid/ShowTitle.svelte:
--------------------------------------------------------------------------------
1 |
5 |
6 |
22 |
23 |
49 |
--------------------------------------------------------------------------------
/src/lib/data/settings/theme/GradientEditor.svelte:
--------------------------------------------------------------------------------
1 |
16 |
17 |
37 |
38 |
78 |
--------------------------------------------------------------------------------
/src/lib/data/settings/theme/Lock.svelte:
--------------------------------------------------------------------------------
1 |
18 |
19 |
20 |
21 |
39 |
40 |
41 |
54 |
--------------------------------------------------------------------------------
/src/lib/data/settings/theme/RandomizeBackground.svelte:
--------------------------------------------------------------------------------
1 |
43 |
44 |
45 | handleClick()}
48 | style="
49 | filter: hue-rotate({Math.floor($hue)}deg) saturate(10);
50 | color: {themer.mode === 'dark' ? darkText : lightText}
51 | "
52 | role="button"
53 | tabindex={0}
54 | >
55 | Randomize
56 |
57 |
58 |
69 |
--------------------------------------------------------------------------------
/src/lib/data/settings/theme/Saturation.svelte:
--------------------------------------------------------------------------------
1 |
20 |
21 |
22 |
23 |
24 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/src/lib/data/sync.ts:
--------------------------------------------------------------------------------
1 | import { settings } from '$lib/stores/settings.svelte'
2 | import { init_db } from './transactions.svelte'
3 | import { userPhrase } from './user'
4 | import dexie from './dexie.svelte'
5 | import { DEV } from 'esm-env'
6 |
7 | const url = DEV ? 'http://localhost:8787' : 'https://nutab-sync.braebo.dev'
8 |
9 | async function getUserData() {
10 | const bookmarks = await dexie.table('bookmarks').toArray()
11 | const folders = await dexie.table('folders').toArray()
12 | const s = $state.snapshot(settings)
13 |
14 | return {
15 | email: s.email,
16 | data: {
17 | settings: s,
18 | bookmarks,
19 | folders,
20 | },
21 | }
22 | }
23 |
24 | /**
25 | * Saves a user's bookmarks to the database.
26 | * @param phrase The user's phrase.
27 | */
28 | export const postBookmarks = async (phrase: string) => {
29 | const data = await getUserData()
30 |
31 | const res = await fetch(`${url}/${phrase}/bookmarks`, {
32 | method: 'POST',
33 | headers: {
34 | 'content-type': 'application/json',
35 | },
36 | body: JSON.stringify(data),
37 | })
38 |
39 | if (!res.ok) {
40 | console.error('Failed to save bookmarks')
41 | }
42 |
43 | return res.status
44 | }
45 |
46 | /**
47 | * Gets a user's bookmarks from the database.
48 | * @param phrase The user's phrase.
49 | * @returns
50 | */
51 | export const getBookmarks = async (phrase: string) => {
52 | const res = await fetch(`${url}/${phrase}/bookmarks`)
53 |
54 | if (!res.ok) {
55 | console.error('Failed to load bookmarks')
56 | }
57 |
58 | const { bookmarks, folders, settings: newSettings } = await res.json()
59 |
60 | await dexie.table('bookmarks').clear()
61 | await dexie.table('folders').clear()
62 |
63 | await dexie.table('bookmarks').bulkPut(bookmarks)
64 | await dexie.table('folders').bulkPut(folders)
65 | if (newSettings) {
66 | Object.assign(settings, newSettings)
67 | }
68 |
69 | await init_db()
70 |
71 | return res.status
72 | }
73 |
74 | /**
75 | * Creates a new user in the database.
76 | */
77 | export const createUser = async () => {
78 | const data = await getUserData()
79 |
80 | const res = await fetch(`${url}/user`, {
81 | method: 'POST',
82 | body: JSON.stringify(data),
83 | headers: {
84 | 'content-type': 'application/json',
85 | },
86 | })
87 |
88 | if (!res.ok) {
89 | console.error('Failed to create user')
90 | }
91 |
92 | const { phrase } = await res.json()
93 |
94 | userPhrase.set(phrase)
95 |
96 | return res.status
97 | }
98 |
--------------------------------------------------------------------------------
/src/lib/data/user.ts:
--------------------------------------------------------------------------------
1 | import { localStorageStore } from '../utils/localStorageStore'
2 | import type { User } from './types'
3 |
4 | export const userPhrase = localStorageStore('userPhrase', [])
5 |
6 | // TODO not this probably
7 | export const userEmail = localStorageStore('userEmail', '')
8 |
--------------------------------------------------------------------------------
/src/lib/feeds/HNSkeletons.svelte:
--------------------------------------------------------------------------------
1 |
59 |
60 |
61 | {#each Array.from({ length: count }) as _, i}
62 |
63 |
67 |
68 | {/each}
69 |
70 |
71 |
97 |
--------------------------------------------------------------------------------
/src/lib/feeds/News.svelte:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
7 |
8 |
9 |
17 |
--------------------------------------------------------------------------------
/src/lib/feeds/constants.ts:
--------------------------------------------------------------------------------
1 | import type { ICategory, IHNItem } from './types'
2 |
3 | import { dev } from '$app/environment'
4 |
5 | export const DEFAULT_CATEGORY: ICategory = 'topstories'
6 |
7 | export const CORS = dev ? 'http://localhost:8000/?q=' : 'https://cors.fractal.workers.dev/?q='
8 | // export const CORS = 'https://cors.fractal.workers.dev/?'
9 |
10 | export const INITIAL_SIZE = 10
11 |
12 | export const BATCH_SIZE = 5
13 |
14 | export const MOCK_HN_ITEM: IHNItem = {
15 | id: -1,
16 | type: `story`,
17 | by: ' ',
18 | time: Date.now(),
19 | text: ' ',
20 | title: ' ',
21 | days_ago: ' ',
22 | kids: [],
23 | meta: {
24 | url: ' ',
25 | title: ' ',
26 | description: ' ',
27 | icon: ' ',
28 | image: '',
29 | },
30 | }
31 |
--------------------------------------------------------------------------------
/src/lib/feeds/fetchData.ts:
--------------------------------------------------------------------------------
1 | import type { IHNItem, ICategory } from './types'
2 |
3 | import { get } from 'svelte/store'
4 |
5 | import { BATCH_SIZE, DEFAULT_CATEGORY, INITIAL_SIZE } from './constants'
6 | import { currentCategory, items, list } from './stores'
7 | import { daysAgo } from '$lib/utils/daysAgo'
8 |
9 | import fetchMeta from './fetchMeta'
10 |
11 | export const fetchCategory = async (type: ICategory = DEFAULT_CATEGORY): Promise => {
12 | const res: Response | void = await fetch(`https://hacker-news.firebaseio.com/v0/${type}.json`, {
13 | headers: {
14 | Accept: 'application/json',
15 | 'Access-Control-Allow-Origin': '*',
16 | },
17 | }).catch((e) => console.error('Hmm.. problem fetching the stories.', e))
18 |
19 | if (!res) return []
20 |
21 | const category = await res.json()
22 |
23 | return category as number[]
24 | }
25 |
26 | export const fetchItem = async (id: IHNItem['id']): Promise => {
27 | const item = await fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`)
28 | .then((res) => res.json())
29 | .catch((e) => console.error(`Hmm.. problem fetching story ${id}: `, e))
30 | return item as IHNItem
31 | }
32 |
33 | let first = true
34 |
35 | export const fetchStories = async (rangeLower = 0, type = DEFAULT_CATEGORY): Promise => {
36 | const currentList = get(list)
37 | const rangeUpper = first ? INITIAL_SIZE : rangeLower + BATCH_SIZE
38 | if (first) first = false
39 |
40 | // initialize the list or change it's category
41 | if (get(currentCategory) != type || currentList.length < rangeUpper) {
42 | list.set(await fetchCategory(type))
43 | }
44 | // get id's of the new stories
45 | const ids = get(list).slice(rangeLower, rangeUpper)
46 |
47 | // fetch stories
48 | let stories: IHNItem[] = []
49 | const storyPromises = []
50 | for (const id of ids) {
51 | storyPromises.push(fetchItem(id))
52 | }
53 | stories = await Promise.all(storyPromises)
54 |
55 | // fetch opengraph metadata
56 | for (const story of stories) {
57 | if (!story.url) continue
58 |
59 | if (story.time) {
60 | story.days_ago = daysAgo(new Date(story.time * 1000)).string
61 | }
62 |
63 | story.meta = {
64 | url: story.url,
65 | title: '',
66 | description: '',
67 | icon: '',
68 | image: '',
69 | }
70 |
71 | if (story.title) story.meta.title = story.title
72 | if (story.text) story.meta.description = story.text
73 |
74 | lazyLoadMeta(story.id, `${story.url}`)
75 | }
76 |
77 | return stories
78 | }
79 |
80 | async function lazyLoadMeta(id: number, url: string) {
81 | const meta = await fetchMeta(url)
82 |
83 | items.update((items) => {
84 | const item = items?.find((item) => item.id === id)
85 |
86 | if (!item) return items
87 |
88 | if (item.meta) item.meta.image = meta.image
89 | if (item.meta) item.meta.description = meta.description
90 |
91 | return items
92 | })
93 | }
94 |
--------------------------------------------------------------------------------
/src/lib/feeds/fetchMeta.ts:
--------------------------------------------------------------------------------
1 | // modified from https://github.com/luisivan/fetch-meta-tags for browser compat
2 | import type { IMeta } from './types'
3 |
4 | import { parse } from 'node-html-parser'
5 | import { rulesets } from './rulesets'
6 | import { CORS } from './constants'
7 |
8 | const fetchHead = async (url: string) => {
9 | const read = async (body: ReadableStream | null): Promise =>
10 | new Promise(async (resolve) => {
11 | let head = ''
12 | const reader = body?.getReader()
13 | const decoder = new TextDecoder()
14 |
15 | // Stream and decode the response body until the closing tag is found.
16 | reader?.read().then(function next({ done, value }) {
17 | const text = decoder.decode(value)
18 | head += text
19 | const headBody = head.toString().split('')
20 |
21 | if (headBody[1] !== undefined) {
22 | return resolve(`${headBody[0]}