├── .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 | ![img](/static/nutab.png) 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 | 21 | 22 | 23 | 26 | 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 | 29 | 40 | 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 | 9 | 10 | 11 | 16 | 23 | 24 | 25 | 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 |
26 |
27 |
28 |
29 |
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 |
18 |
19 | 26 |
27 |
28 | 35 |
36 |
37 | 38 | 78 | -------------------------------------------------------------------------------- /src/lib/data/settings/theme/Lock.svelte: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 21 |
22 | 30 | 31 | 37 | 38 |
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]}`) 23 | } 24 | 25 | if (done) return resolve('') 26 | 27 | reader.read().then(next) 28 | }) 29 | }) 30 | 31 | try { 32 | const res = await fetch(url, { 33 | mode: 'cors', 34 | headers: { 35 | 'Content-Type': 'text/html', 36 | Accept: 'text/html', 37 | }, 38 | }) 39 | if (!res.ok) return '' 40 | 41 | return read(res.body) 42 | } catch (e) { 43 | console.warn(e) 44 | return '' 45 | } 46 | } 47 | 48 | const makeUrlAbsolute = (url: string, path: string) => new URL(path, new URL(url).origin).toString() 49 | 50 | /** 51 | * Fetches a url and parses the meta tags. 52 | * @param url URL of the page to fetch. 53 | * @returns A promise that resolves to the metadata. 54 | * @example const meta = await fetchMeta('https://news.ycombinator.com/') 55 | */ 56 | export const fetchMeta = async (url: string, imgOnly = false, proxy = true) => { 57 | // const corsUrl = dev ? CORS + url : url 58 | const corsUrl = proxy ? CORS + url : url 59 | 60 | const head = await fetchHead(corsUrl) 61 | const dom = parse(head) 62 | 63 | const metadata: IMeta = { 64 | url, 65 | title: '', 66 | description: '', 67 | icon: '', 68 | image: '', 69 | } 70 | 71 | for (const key in rulesets) { 72 | if (imgOnly && ['image', 'icon'].includes(key)) continue 73 | 74 | for (const rule of rulesets[key].rules) { 75 | const el = dom.querySelector(rule[0]) 76 | 77 | if (el) { 78 | const data = rule[1](el) 79 | 80 | metadata[key] = rulesets[key].absolute ? makeUrlAbsolute(url, data) : data 81 | break 82 | } 83 | } 84 | 85 | if (!metadata[key] && 'defaultValue' in rulesets[key]) { 86 | metadata[key] = makeUrlAbsolute(url, rulesets[key].defaultValue) 87 | } 88 | } 89 | // console.log({ metadata }) 90 | return metadata 91 | } 92 | 93 | export default fetchMeta 94 | -------------------------------------------------------------------------------- /src/lib/feeds/mockData.ts: -------------------------------------------------------------------------------- 1 | import type { IMeta } from './types' 2 | 3 | export const getMockMetaData = async (index: number): Promise => 4 | new Promise((res, rej) => { 5 | setTimeout(() => { 6 | if (Math.random() > 0.01) { 7 | console.log(mockMetaData[index]) 8 | res(mockMetaData[index]) 9 | } else rej(new Error('Mock data failed')) 10 | }, Math.random() * 1000) 11 | }) 12 | 13 | const mockMetaData: IMeta[] = [ 14 | { 15 | title: 'FrameworkComputer', 16 | description: 17 | 'Documentation for the Mainboard in the Framework Laptop - GitHub - FrameworkComputer/Mainboard: Documentation for the Mainboard in the Framework Laptop', 18 | image: 'https://repository-images.githubusercontent.com/462058408/a398a589-990d-40b5-9b0c-5e6635432e39', 19 | url: 'https://github.com/FrameworkComputer/Mainboard', 20 | icon: '', 21 | }, 22 | { 23 | title: 'GitHub', 24 | description: 25 | 'SymPy Documentation repository. Contribute to sympy/sympy_doc development by creating an account on GitHub.', 26 | image: 'https://opengraph.githubassets.com/5ff49c2600625db0e31cab427b2b95e2af62bfabc36152a33163ae175537e7ee/sympy/sympy_doc', 27 | url: 'https://github.com/sympy/sympy_doc', 28 | icon: '', 29 | }, 30 | { 31 | title: 'AMP: Cutting Out Google and Enhancing Privacy', 32 | description: 33 | "Brave is rolling out a new feature called De-AMP, which allows Brave users to bypass Google-hosted AMP pages, and instead visit the content's publisher directly.", 34 | image: 'https://brave.com/privacy-updates/images/privacy-updates-og.png', 35 | url: 'https://brave.com/privacy-updates/18-de-amp/', 36 | icon: '', 37 | }, 38 | { 39 | title: "How Intuit's TurboTax capitalized on taxpayers' fear.", 40 | description: 'ProPublica’s Justin Elliot on how Intuit built its TurboTax empire. ', 41 | image: 'https://compote.slate.com/images/54500d15-7f67-4f32-b631-7b44d1b9be6b.jpeg?width=780&height=520&rect=7942x5295&offset=0x0', 42 | url: 'https://slate.com/technology/2022/04/turbotax-free-file-online-ftc.html', 43 | icon: '', 44 | }, 45 | { 46 | title: 'Canada bans foreign home buyers for two years', 47 | description: 48 | "Canada's housing market is one of the world's hottest, with prices jumping by more than 20% in 2021.", 49 | image: 'https://images.axios.com/XIvj5wkl2ZbWAEAkrIUWwmiLqgs=/0x200:4000x2450/1366x768/2022/04/19/1650329719857.jpg', 50 | url: 'https://www.axios.com/canada-foreign-home-buyers-ban-589fdf10-844f-4160-afe0-e8452df92fa8.html', 51 | icon: '', 52 | }, 53 | ] 54 | -------------------------------------------------------------------------------- /src/lib/feeds/rulesets.ts: -------------------------------------------------------------------------------- 1 | //@ts-nocheck 2 | const attr = (element, attribute) => element.getAttribute(attribute) 3 | 4 | export const rulesets: Record = { 5 | title: { 6 | rules: [ 7 | ['meta[property="og:title"]', (e) => attr(e, 'content')], 8 | ['meta[name="twitter:title"]', (e) => attr(e, 'content')], 9 | ['meta[property="twitter:title"]', (e) => attr(e, 'content')], 10 | ['title', (e) => e.text], 11 | ], 12 | }, 13 | 14 | description: { 15 | rules: [ 16 | ['meta[property="og:description"]', (e) => attr(e, 'content')], 17 | ['meta[name="description" i]', (e) => attr(e, 'content')], 18 | ], 19 | }, 20 | 21 | icon: { 22 | rules: [ 23 | ['link[rel="apple-touch-icon"]', (e) => attr(e, 'href')], 24 | ['link[rel="apple-touch-icon-precomposed"]', (e) => attr(e, 'href')], 25 | ['link[rel="icon" i]', (e) => attr(e, 'href')], 26 | ], 27 | defaultValue: 'favicon.ico', 28 | absolute: true, 29 | }, 30 | 31 | image: { 32 | rules: [ 33 | ['meta[property="og:image:secure_url"]', (e) => attr(e, 'content')], 34 | ['meta[property="og:image:url"]', (e) => attr(e, 'content')], 35 | ['meta[property="og:image"]', (e) => attr(e, 'content')], 36 | ['meta[name="twitter:image"]', (e) => attr(e, 'content')], 37 | ['meta[property="twitter:image"]', (e) => attr(e, 'content')], 38 | ['meta[name="thumbnail"]', (e) => attr(e, 'content')], 39 | ], 40 | absolute: true, 41 | }, 42 | } as const 43 | -------------------------------------------------------------------------------- /src/lib/feeds/skeletonTime.ts: -------------------------------------------------------------------------------- 1 | import { onDestroy, tick } from 'svelte' 2 | import { tweened } from 'svelte/motion' 3 | import { derived } from 'svelte/store' 4 | 5 | const a = tweened(0, { duration: 500 }) 6 | const b = tweened(0) 7 | const c = tweened(100) 8 | 9 | let stageTimer: ReturnType 10 | let t = 1 11 | export const animate = async () => { 12 | await tick() 13 | if (!window) return 14 | requestAnimationFrame(() => { 15 | switch (t) { 16 | case 0: 17 | a.set(0, { duration: 0 }) 18 | b.set(0, { duration: 0 }) 19 | c.set(0.01, { duration: 0 }) 20 | break 21 | case 1: 22 | a.set(-50) 23 | b.set(20) 24 | c.set(150) 25 | break 26 | case 2: 27 | a.set(0) 28 | b.set(100) 29 | c.set(150) 30 | break 31 | case 3: 32 | a.set(150) 33 | b.set(200) 34 | c.set(300) 35 | break 36 | default: 37 | break 38 | } 39 | if (t < 3) t++ 40 | else t = 0 41 | stageTimer = setTimeout(() => { 42 | animate() 43 | clearTimeout(stageTimer) 44 | }, 300) 45 | }) 46 | } 47 | 48 | export const skeletonAnimation = derived( 49 | [a, b, c], 50 | ([$a, $b, $c]) => 51 | `linear-gradient(to right, var(--fg-b) ${$a}%, var(--fg-a) ${$b}%, var(--fg-b) ${$c}%)`, 52 | ) 53 | 54 | // onDestroy(() => (stageTimer = null)) 55 | -------------------------------------------------------------------------------- /src/lib/feeds/stores.ts: -------------------------------------------------------------------------------- 1 | import type { ICategory, IHNItem } from './types' 2 | 3 | import { writable } from 'svelte/store' 4 | 5 | export const currentCategory = writable('topstories') 6 | 7 | export const list = writable([]) 8 | 9 | export const items = writable([]) 10 | -------------------------------------------------------------------------------- /src/lib/graphics/LoadingDots.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 |
.
7 |
.
8 |
.
9 |
10 | 11 | 41 | -------------------------------------------------------------------------------- /src/lib/graphics/RandomWave.svelte: -------------------------------------------------------------------------------- 1 | 41 | 42 |
43 | 44 | 45 | 46 |
47 |
48 | 49 | 50 | 51 |
52 | 53 | 75 | -------------------------------------------------------------------------------- /src/lib/graphics/icons/CollapseIcon.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 17 | 22 | 23 | 24 | 34 | -------------------------------------------------------------------------------- /src/lib/graphics/icons/CommentIcon.svelte: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/graphics/icons/Edit.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 15 | 27 | 39 | 51 | 52 | -------------------------------------------------------------------------------- /src/lib/graphics/icons/GithubIcon.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 13 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/lib/graphics/icons/HomeIcon.svelte: -------------------------------------------------------------------------------- 1 |
2 | 9 | 10 | 11 | 12 | 17 | 18 | 24 | 30 | 36 | 37 | 38 | 39 | 45 | 51 | 56 | 57 | 58 | 59 | 60 | 61 | 62 |
63 | 64 | 94 | -------------------------------------------------------------------------------- /src/lib/graphics/randomRange.ts: -------------------------------------------------------------------------------- 1 | export const randomRange = (min: number, max: number, clamp = false): number => { 2 | const isArray = Array.isArray(min) 3 | 4 | if (isArray) { 5 | const targetArray = min 6 | 7 | return targetArray[randomRange(0, targetArray.length - 1, true)] 8 | } else { 9 | const val = Math.random() * (max - min) + min 10 | 11 | return clamp ? Math.round(val) : val 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/lib/inspector/Group.svelte: -------------------------------------------------------------------------------- 1 | 27 | 28 |

29 | 30 | {label} 31 |

32 | 33 | {#if isOpen} 34 |
35 | {#key state} 36 | {#if typeof state === 'object' && state !== null} 37 | {#each Object.entries(state) as [key, value]} 38 | 39 | {/each} 40 | {:else} 41 | 42 | {/if} 43 | {/key} 44 |
45 | {/if} 46 | 47 | 103 | -------------------------------------------------------------------------------- /src/lib/inspector/Inspector.svelte: -------------------------------------------------------------------------------- 1 | 32 | 33 | saveOpenState(e)} 39 | isOpen={inspectorStore.value['menu'] ?? false} 40 | > 41 |
42 | {#each data as _, i} 43 | { 48 | saveOpenState(e) 49 | }} 50 | /> 51 | {/each} 52 |
53 |
54 | 55 | 62 | -------------------------------------------------------------------------------- /src/lib/inspector/index.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 | {#if mounted} 19 | 43 | {/if} 44 | -------------------------------------------------------------------------------- /src/lib/inspector/inspectorStore.svelte.ts: -------------------------------------------------------------------------------- 1 | // import { localStorageStore } from '$lib/utils/localStorageStore' 2 | 3 | // export const inspectorStore = localStorageStore>('inspector', {}) 4 | 5 | export const inspectorStore = $state<{ value: Record }>({ value: {} }) 6 | -------------------------------------------------------------------------------- /src/lib/ruins.svelte.boomer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @template T 3 | * @param {T} value 4 | */ 5 | export const state = (value) => { 6 | return new (class { 7 | value = $state({ value }) 8 | })() 9 | } 10 | 11 | /** 12 | * @template T 13 | * @param {() => T} fn 14 | */ 15 | export function derived(fn) { 16 | return new (class { 17 | value = $derived.by(fn) 18 | })() 19 | } 20 | -------------------------------------------------------------------------------- /src/lib/ruins.svelte.ts: -------------------------------------------------------------------------------- 1 | import { onDestroy } from 'svelte' 2 | import { DEV } from 'esm-env' 3 | 4 | export function state(value: T) { 5 | return new (class rune { 6 | value = $state({ value }) 7 | })() 8 | } 9 | 10 | /** 11 | * `$derived`, except it's correctly typed and can be initialized anywhere. 12 | */ 13 | export function derived(fn: () => T) { 14 | return new (class rune { 15 | value = $derived.by(fn) 16 | })() 17 | } 18 | 19 | /** 20 | * `$effect`, except it creates a root if needed, and cleans it up automatically in Svelte 21 | * components. 22 | */ 23 | export function effect(fn: () => void, verbose = true) { 24 | if (!$effect.tracking()) { 25 | return effectRoot(() => { 26 | $effect(fn) 27 | }, verbose) 28 | } else { 29 | $effect(fn) 30 | } 31 | return () => {} 32 | } 33 | 34 | /** 35 | * `$effect.root` that automatically cleans up the effect inside Svelte components. 36 | * 37 | * @returns Cleanup function to manually cleanup the effect. 38 | */ 39 | export function effectRoot(fn: () => void, verbose = true) { 40 | let cleanup: VoidFunction | null = $effect.root(fn) 41 | 42 | function destroy() { 43 | if (cleanup === null) { 44 | return 45 | } 46 | 47 | cleanup() 48 | cleanup = null 49 | } 50 | 51 | try { 52 | onDestroy(() => { 53 | destroy() 54 | if (verbose && DEV) { 55 | console.log('%croot: destroyed', 'color:tomato') 56 | } 57 | }) 58 | } catch { 59 | if (verbose && DEV) { 60 | console.warn('Effect created outside Svelte component - manual cleanup required.') 61 | } 62 | } 63 | 64 | return destroy 65 | } 66 | -------------------------------------------------------------------------------- /src/lib/search/Engines.svelte: -------------------------------------------------------------------------------- 1 | 2 | 57 | -------------------------------------------------------------------------------- /src/lib/search/engines.ts: -------------------------------------------------------------------------------- 1 | import type { Engine } from '$lib/data/types' 2 | 3 | import StackOverflow from './icons/StackOverflow.svelte' 4 | import DuckDuckGo from './icons/DuckDuckGo.svelte' 5 | import HackerNews from './icons/HackerNews.svelte' 6 | import Archive from './icons/Archive.svelte' 7 | import Google from './icons/Google.svelte' 8 | import MDN from './icons/MDN.svelte' 9 | 10 | export const defaultEngines: Engine[] = [ 11 | { 12 | position: 0, 13 | name: 'DuckDuckGo', 14 | url: `https://duckduckgo.com/?q=`, 15 | icon: DuckDuckGo, 16 | alias: 'd ', 17 | }, 18 | { 19 | position: 1, 20 | name: 'Google', 21 | url: `https://www.google.com/search?q=`, 22 | icon: Google, 23 | alias: 'g ', 24 | }, 25 | { 26 | position: 2, 27 | name: 'Internet Archive', 28 | url: `https://archive.org/search.php?query=`, 29 | icon: Archive, 30 | alias: 'ar ', 31 | }, 32 | { 33 | position: 3, 34 | name: 'MDN', 35 | url: `https://developer.mozilla.org/en-US/search?q=`, 36 | icon: MDN, 37 | alias: 'mdn ', 38 | }, 39 | { 40 | position: 4, 41 | name: 'StackOverflow', 42 | url: `https://stackoverflow.com/search?q=`, 43 | icon: StackOverflow, 44 | alias: 'so ', 45 | }, 46 | { 47 | position: 5, 48 | name: 'HackerNews', 49 | url: `https://hn.algolia.com/?query=`, 50 | icon: HackerNews, 51 | alias: 'hn ', 52 | }, 53 | ] 54 | -------------------------------------------------------------------------------- /src/lib/search/icons/Archive.svelte: -------------------------------------------------------------------------------- 1 | 16 | 21 | 22 | 23 | 24 | 25 | 31 | 37 | 43 | 49 | 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /src/lib/search/icons/Google.svelte: -------------------------------------------------------------------------------- 1 | 9 | 13 | 17 | 21 | 25 | 26 | -------------------------------------------------------------------------------- /src/lib/search/icons/HackerNews.svelte: -------------------------------------------------------------------------------- 1 | 9 | 17 | 21 | 25 | 29 | 36 | 37 | -------------------------------------------------------------------------------- /src/lib/search/icons/MDN.svelte: -------------------------------------------------------------------------------- 1 | 2 | MDN 7 | -------------------------------------------------------------------------------- /src/lib/search/icons/StackOverflow.svelte: -------------------------------------------------------------------------------- 1 | 9 | 24 | 25 | -------------------------------------------------------------------------------- /src/lib/search/search_state.svelte.ts: -------------------------------------------------------------------------------- 1 | import { safeLocalStorage } from '$lib/utils/safeLocalStorage' 2 | import { untrack } from 'svelte' 3 | 4 | class SearchState { 5 | activeEngine = $state() 6 | searchValue = $state() 7 | 8 | constructor() { 9 | this.dispose = $effect.root(() => { 10 | let first = true 11 | $effect(() => { 12 | this.activeEngine 13 | this.searchValue 14 | 15 | if (first) { 16 | first = false 17 | untrack(() => { 18 | this.activeEngine = safeLocalStorage.get('search-activeEngine', 0) 19 | this.searchValue = safeLocalStorage.get('search-searchValue', '') 20 | }) 21 | } else { 22 | safeLocalStorage.set('search-activeEngine', this.activeEngine) 23 | safeLocalStorage.set('search-searchValue', this.searchValue) 24 | } 25 | }) 26 | }) 27 | } 28 | dispose: () => void 29 | } 30 | 31 | export const search_state = new SearchState() 32 | -------------------------------------------------------------------------------- /src/lib/stores/activeSection.svelte.ts: -------------------------------------------------------------------------------- 1 | // import { writable } from 'svelte/store' 2 | 3 | // export const activeSection = writable<'bookmarks' | 'news'>('bookmarks') 4 | 5 | export const activeSection = $state<{ value: 'bookmarks' | 'news' }>({ value: 'bookmarks' }) 6 | -------------------------------------------------------------------------------- /src/lib/stores/background.svelte.ts: -------------------------------------------------------------------------------- 1 | // import { createActiveGradient } from '$lib/theme_og/createGradient' 2 | // import { activeTheme } from './settings.svelte' 3 | // import { derived } from 'svelte/store' 4 | 5 | // // todo: move createActiveGradient to this file 6 | // export const activeBackground = derived(activeTheme, ($activeTheme) => { 7 | // return createActiveGradient($activeTheme) 8 | // }) 9 | -------------------------------------------------------------------------------- /src/lib/stores/blurOverlay.svelte.ts: -------------------------------------------------------------------------------- 1 | export const blurOverlay = $state({ value: false }) 2 | -------------------------------------------------------------------------------- /src/lib/stores/bookmarkEditor.svelte.ts: -------------------------------------------------------------------------------- 1 | import type { Bookmark, Folder } from '$lib/data/types' 2 | 3 | import { defaultFolder, emptyBookmark, emptyFolder } from '$lib/data/bookmarks/defaults' 4 | import { getBookmark_db, getFolderCount_db } from '$lib/data/transactions.svelte' 5 | import { debounce } from '$lib/utils/debounce' 6 | import { Logger } from '$lib/utils/logger' 7 | import { db } from '$lib/data/db.svelte' 8 | 9 | type EditorContext = 'edit' | 'create' 10 | type EditorType = 'bookmark' | 'folder' 11 | type EditorMode = [EditorContext, EditorType] 12 | 13 | class BookmarkEditorManager { 14 | bookmarkEditorContext = $state(null) 15 | editorType = $state(null) 16 | 17 | editor = $state(null) 18 | folderEditor = $state(null) 19 | 20 | showFolderEditor = $state(false) 21 | showBookmarkEditor = $state(false) 22 | 23 | editorShown = $derived(this.showBookmarkEditor || this.showFolderEditor) 24 | 25 | #log: Logger 26 | 27 | constructor() { 28 | this.#log = new Logger('BookmarkEditor', { fg: 'teal' }) 29 | } 30 | 31 | show = async (mode: EditorMode, i?: number) => { 32 | this.#log.fn('show').debug(`(${mode}, ${i})`) 33 | switch (String(mode)) { 34 | case 'create,bookmark': 35 | this.editor = emptyBookmark(db.activeFolder ?? defaultFolder) 36 | this.showBookmarkEditor = true 37 | break 38 | case 'edit,bookmark': 39 | if (i === undefined) console.error('editor: No index provided for bookmark') 40 | else { 41 | this.editor = 42 | (await getBookmark_db(db.activeBookmarks?.[i]?.bookmark_id)) ?? null 43 | this.showBookmarkEditor = true 44 | } 45 | break 46 | case 'create,folder': 47 | const folderCount = await getFolderCount_db() 48 | this.folderEditor = emptyFolder(folderCount) 49 | this.showFolderEditor = true 50 | break 51 | case 'edit,folder': 52 | this.showFolderEditor = true 53 | break 54 | default: 55 | console.error('editor: Invalid mode provided:', mode) 56 | break 57 | } 58 | this.bookmarkEditorContext = mode[0] 59 | this.editorType = mode[1] 60 | } 61 | 62 | hide = () => { 63 | this.folderEditor = null 64 | this.bookmarkEditorContext = null 65 | this.editorType = null 66 | this.showFolderEditor = false 67 | this.showBookmarkEditor = false 68 | // Avoids flashes during out:transition 69 | debounce(() => { 70 | this.editor = null 71 | }, 250) 72 | } 73 | } 74 | 75 | export const bookmarkEditor = new BookmarkEditorManager() 76 | -------------------------------------------------------------------------------- /src/lib/stores/bookmarkEditor.ts: -------------------------------------------------------------------------------- 1 | import type { Bookmark, Folder } from '$lib/data/types' 2 | 3 | import { getBookmark_db, getFolderCount_db } from '$lib/data/transactions.svelte' 4 | // import { activeFolderBookmarks, activeFolder } from '$lib/data/dbStore' 5 | import { emptyBookmark, emptyFolder } from '$lib/data/bookmarks/defaults' 6 | import { writable, derived } from 'svelte/store' 7 | // import { debounce } from '$lib/utils/debounce' 8 | import { db } from '$lib/data/db.svelte' 9 | import { log } from 'fractils' 10 | 11 | type EditorContext = 'edit' | 'create' 12 | type EditorType = 'bookmark' | 'folder' 13 | type EditorMode = [EditorContext, EditorType] 14 | 15 | export const bookmarkEditorContext = writable(null) 16 | export const editorType = writable(null) 17 | 18 | export const bookmarkEditor = writable(null) 19 | export const folderEditor = writable(null) 20 | 21 | export const showFolderEditor = writable(false) 22 | export const showBookmarkEditor = writable(false) 23 | 24 | export const editorShown = derived( 25 | [showBookmarkEditor, showFolderEditor], 26 | ([$showBookmarkEditor, $showFolderEditor]) => $showBookmarkEditor || $showFolderEditor, 27 | ) 28 | 29 | export const editor = { 30 | show: async (mode: EditorMode, i?: number) => { 31 | log(`editor.show(${mode}, ${i})`, '#111', '#aad', 25) 32 | switch (String(mode)) { 33 | case 'create,bookmark': 34 | if (db.activeFolder) bookmarkEditor.set(emptyBookmark(db.activeFolder)) 35 | else console.error('editor: No active folder') 36 | showBookmarkEditor.set(true) 37 | break 38 | case 'edit,bookmark': 39 | if (i === null) console.error('editor: No index provided for bookmark') 40 | else { 41 | const bookmark = await getBookmark_db(db.activeBookmarks?.[i ?? 0]?.bookmark_id) 42 | if (bookmark) bookmarkEditor.set(bookmark) 43 | else console.error('editor: Bookmark not found') 44 | showBookmarkEditor.set(true) 45 | } 46 | break 47 | case 'create,folder': 48 | const folderCount = await getFolderCount_db() 49 | folderEditor.set(emptyFolder(folderCount)) 50 | showFolderEditor.set(true) 51 | break 52 | case 'edit,folder': 53 | showFolderEditor.set(true) 54 | break 55 | default: 56 | console.error('editor: Invalid mode provided:', mode) 57 | break 58 | } 59 | bookmarkEditorContext.set(mode[0]) 60 | editorType.set(mode[1]) 61 | }, 62 | hide: () => { 63 | folderEditor.set(null) 64 | bookmarkEditorContext.set(null) 65 | editorType.set(null) 66 | showFolderEditor.set(false) 67 | showBookmarkEditor.set(false) 68 | // Avoids flases during out:transition 69 | setTimeout(() => bookmarkEditor.set(null), 250) 70 | }, 71 | } 72 | Object.freeze(editor) 73 | -------------------------------------------------------------------------------- /src/lib/stores/debug.ts: -------------------------------------------------------------------------------- 1 | import { localStorageStore } from 'fractils' 2 | import { writable } from 'svelte/store' 3 | 4 | export const debug = writable(true) 5 | 6 | export const showDebugger = localStorageStore('showDebugger', false) 7 | -------------------------------------------------------------------------------- /src/lib/stores/derived.svelte.ts: -------------------------------------------------------------------------------- 1 | // export function derived(fn: () => T) { 2 | // return new (class { 3 | // value = $derived.by(fn) 4 | // })() as { value: T } 5 | // } 6 | -------------------------------------------------------------------------------- /src/lib/stores/folderEditor.svelte.ts: -------------------------------------------------------------------------------- 1 | import type { Folder } from '$lib/data/types' 2 | 3 | class FolderEditor { 4 | editor = $state() 5 | context = $state<'edit' | 'create'>() 6 | } 7 | 8 | export const folderEditor = new FolderEditor() 9 | -------------------------------------------------------------------------------- /src/lib/stores/index.ts: -------------------------------------------------------------------------------- 1 | // export { settings, cMenu, type Settings } from './settings.svelte' 2 | // export { 3 | // editorType, 4 | // editorShown, 5 | // bookmarkEditor, 6 | // showFolderEditor, 7 | // showBookmarkEditor, 8 | // bookmarkEditorContext, 9 | // } from './bookmarkEditor' 10 | // export { folderEditorContext, folderEditor } from './folderEditor' 11 | // // export { activeBackground } from './background.svelte' 12 | // export { showGuidelines } from './showGuidelines' 13 | // export { activeSection } from './activeSection' 14 | // export { blurOverlay } from './blurOverlay' 15 | // export { debug } from './debug' 16 | -------------------------------------------------------------------------------- /src/lib/stores/settings.svelte.ts: -------------------------------------------------------------------------------- 1 | // import type { ThemeConfig } from '$lib/data/types' 2 | 3 | import { createActiveGradient } from '$lib/theme_og/createGradient' 4 | import { defaultThemeConfigs } from '$lib/data/defaultThemeConfigs' 5 | // import { defaultFolder } from '$lib/data/bookmarks/defaults' 6 | import { persist } from '$lib/utils/persist.svelte' 7 | import { themer } from '$lib/theme/themer.svelte' 8 | import { derived } from '$lib/ruins.svelte' 9 | 10 | export const cMenu = $state({ 11 | visible: false, 12 | x: 0, 13 | y: 0, 14 | el: null as unknown as HTMLElement, 15 | pending: false, 16 | }) 17 | 18 | export type Settings = typeof settings 19 | 20 | export const settings = persist('nutab:settings', { 21 | email: '', 22 | // activeFolderId: defaultFolder.folder_id, 23 | ranges: { 24 | gridWidth: 1300, 25 | gridGap: 50, 26 | iconSize: 45, 27 | }, 28 | transparent: true, 29 | showTitle: false, 30 | sharedTheme: false, 31 | theme: defaultThemeConfigs(), 32 | invertDark: false, 33 | showSettings: false, 34 | }) 35 | 36 | // export const activeTheme = new (class { 37 | // value = $derived.by(() => { 38 | // if (settings.sharedTheme) { 39 | // return settings.theme.shared 40 | // } 41 | // return settings.theme[themer.mode] 42 | // }) 43 | // })() as { value: ThemeConfig } 44 | 45 | export const activeTheme = derived(() => { 46 | if (settings.sharedTheme) { 47 | return settings.theme.shared 48 | } 49 | return settings.theme[themer.mode] 50 | }) 51 | 52 | export const activeBackground = derived(() => createActiveGradient(activeTheme.value)) 53 | -------------------------------------------------------------------------------- /src/lib/stores/settings.types.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/braebo/nutab/25baaf74cb0689a484ff41a3678de71c5c588939/src/lib/stores/settings.types.ts -------------------------------------------------------------------------------- /src/lib/stores/showGuidelines.ts: -------------------------------------------------------------------------------- 1 | import { writable } from 'svelte/store' 2 | 3 | // Used to show grid borders while updating gridWidth 4 | export const showGuidelines = writable(false) 5 | -------------------------------------------------------------------------------- /src/lib/stores/state.svelte.ts: -------------------------------------------------------------------------------- 1 | // export const state = (value: T) => $state({ value }) 2 | -------------------------------------------------------------------------------- /src/lib/theme/themes/vanilla.ts: -------------------------------------------------------------------------------- 1 | import type { ThemeDefinition } from '../themer.types' 2 | 3 | import { resolveTheme } from '../resolveTheme' 4 | 5 | export default resolveTheme({ 6 | title: 'vanilla', 7 | prefix: 'gooey', 8 | vars: { 9 | color: { 10 | base: { 11 | 'theme-a': '#ff4700', 12 | 'theme-b': '#ffcebe', 13 | 'theme-c': '#ffece6', 14 | 'dark-a': '#0b0e11', 15 | 'dark-b': '#1c1e21', 16 | 'dark-c': '#1d252e', 17 | 'dark-d': '#4a4a55', 18 | 'dark-e': '#787b89', 19 | 'light-a': '#ffffff', 20 | 'light-b': '#dfe1e9', 21 | 'light-c': '#c3c4c7', 22 | 'light-d': '#acacb4', 23 | 'light-e': '#5F6377', 24 | }, 25 | dark: {}, 26 | light: {}, 27 | }, 28 | }, 29 | } as const satisfies ThemeDefinition) 30 | -------------------------------------------------------------------------------- /src/lib/theme_og/ThemeToggle.svelte: -------------------------------------------------------------------------------- 1 | 29 | 30 | {#if mounted} 31 | {#key themer.mode} 32 | 33 |
{ 38 | e.preventDefault() 39 | handleToggle() 40 | }} 41 | > 42 | 43 | {#if themer.mode == 'light'} 44 |
53 | 54 |
55 | 56 | {:else if themer.mode == 'dark'} 57 | 58 |
67 | 68 |
69 | {/if} 70 |
71 |
72 | {/key} 73 | {/if} 74 | 75 | 88 | -------------------------------------------------------------------------------- /src/lib/theme_og/createGradient.ts: -------------------------------------------------------------------------------- 1 | import type { ThemeConfig } from '$lib/data/types' 2 | 3 | import { settings } from '$lib/stores/settings.svelte' 4 | 5 | export const createGradient = (theme: keyof typeof settings.theme) => { 6 | const t = settings.theme[theme] 7 | const opacity = t?.gradientOpacity?.toString(16) ?? '30' 8 | return gradient(t, opacity) 9 | } 10 | 11 | export const createActiveGradient = (t: ThemeConfig) => { 12 | const opacity = t?.gradientOpacity?.toString(16) ?? '30' 13 | return gradient(t, opacity) 14 | } 15 | 16 | const gradient = (t: ThemeConfig | undefined, opacity: string) => { 17 | return `background-image: linear-gradient(to bottom, ${t?.gradientA + opacity}, ${t?.gradientB + opacity});` 18 | } 19 | -------------------------------------------------------------------------------- /src/lib/theme_og/graphics/Clouds.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
14 | 15 | 16 | 17 | 27 | 35 | 41 | 42 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 |
65 | 66 | 87 | -------------------------------------------------------------------------------- /src/lib/theme_og/graphics/Moon.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 28 | -------------------------------------------------------------------------------- /src/lib/theme_og/graphics/Stars.svelte: -------------------------------------------------------------------------------- 1 | 18 | 19 | {#if mounted} 20 |
26 | {#each Array(15).fill() as star} 27 | 28 |
29 |
30 |
31 |
32 |
33 |
34 | {/each} 35 |
36 | {/if} 37 | 38 | 97 | -------------------------------------------------------------------------------- /src/lib/theme_og/graphics/Sun.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | 24 | 25 | 26 | 33 | 34 | 35 | 42 | 43 | 44 | 45 | 46 | 53 | 54 | 55 | 62 | 63 | 64 | 65 | 66 |
67 | 68 | 75 | -------------------------------------------------------------------------------- /src/lib/theme_og/index.ts: -------------------------------------------------------------------------------- 1 | export { randomizeBackground } from './randomizeBackground' 2 | export { initBackground } from './initBackground' 3 | export { createGradient } from './createGradient' 4 | -------------------------------------------------------------------------------- /src/lib/theme_og/initBackground.ts: -------------------------------------------------------------------------------- 1 | import { randomizeBackground } from './randomizeBackground' 2 | import { settings } from '$lib/stores/settings.svelte' 3 | import { themer } from '$lib/theme/themer.svelte' 4 | 5 | export const initBackground = () => { 6 | if (settings.sharedTheme) { 7 | if (!settings.theme.shared.lockBackground) { 8 | randomizeBackground('shared') 9 | } 10 | return 11 | } 12 | 13 | if (themer.mode in settings.theme && !settings.theme[themer.mode].lockBackground) { 14 | randomizeBackground(themer.mode) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/lib/theme_og/randomizeBackground.ts: -------------------------------------------------------------------------------- 1 | import { settings } from '$lib/stores/settings.svelte' 2 | import { createGradient } from './createGradient' 3 | import { randomColor } from '$lib/utils' 4 | 5 | export const randomizeBackground = async (theme: keyof typeof settings.theme) => { 6 | const themeSettings = settings.theme[theme] 7 | themeSettings.gradientA = randomColor() 8 | themeSettings.gradientB = randomColor() 9 | themeSettings.background = createGradient(theme) 10 | } 11 | -------------------------------------------------------------------------------- /src/lib/theme_og/theme.ts: -------------------------------------------------------------------------------- 1 | // import localStorageStore from '../utils/localStorageStore' 2 | 3 | // const initialTheme = globalThis.localStorage && 'theme' in localStorage ? localStorage.getItem('theme') : 'dark' 4 | 5 | // export const theme = localStorageStore('theme', initialTheme) 6 | -------------------------------------------------------------------------------- /src/lib/theme_og/themer.ts: -------------------------------------------------------------------------------- 1 | // import { get, writable } from 'svelte/store' 2 | // import { log } from '$lib/utils/log' 3 | // import { theme } from './theme' 4 | 5 | // const detectSystemPreference = (e: MediaQueryListEvent) => applyTheme(e.matches ? 'dark' : 'light') 6 | 7 | // export const initTheme = async (): Promise => { 8 | // log('Init theme()') 9 | // window 10 | // ?.matchMedia('(prefers-color-scheme: dark)') 11 | // .addEventListener('change', detectSystemPreference) 12 | 13 | // if (localStorage) 14 | // if ('theme' in localStorage) { 15 | // try { 16 | // const pref = get(theme) 17 | // if (pref) { 18 | // log('theme found in localStorage: ' + pref, 'white', '#1d1d1d', 20, 'purple') 19 | // applyTheme(pref as string) 20 | // } 21 | // } catch (err) { 22 | // console.log( 23 | // '%c Unable to access theme preference in local storage 😕', 24 | // 'color:coral', 25 | // ) 26 | // console.error(err) 27 | // localStorage.removeItem('theme') 28 | // } 29 | // } else { 30 | // applySystemTheme() 31 | // } 32 | // } 33 | 34 | // export const toggleTheme = (): void => { 35 | // const activeTheme = get(theme) 36 | // log(activeTheme, 'white', '#1d1d1d', 20, 'purple') 37 | 38 | // if (activeTheme == 'light') { 39 | // applyTheme('dark') 40 | // } else { 41 | // applyTheme('light') 42 | // } 43 | // } 44 | 45 | // export const initComplete = writable(false) 46 | 47 | // const applySystemTheme = (): void => { 48 | // if (window?.matchMedia('(prefers-color-scheme: dark)').matches) { 49 | // applyTheme('dark') 50 | // } else { 51 | // applyTheme('light') 52 | // } 53 | // } 54 | 55 | // const applyTheme = (newTheme: string): void => { 56 | // document.documentElement.setAttribute('theme', newTheme) 57 | // try { 58 | // theme.set(newTheme) 59 | // // eslint-disable-next-line @typescript-eslint/no-unused-vars 60 | // } catch (_e: unknown) { 61 | // console.log('%c Unable to save theme preference in local storage 😕', 'color:coral') 62 | // } 63 | // } 64 | -------------------------------------------------------------------------------- /src/lib/ui/Bookmarks/BookmarkArt.svelte: -------------------------------------------------------------------------------- 1 | 26 | 27 |
39 | {#if !bookmark?.useImage} 40 | {@html bookmark?.title} 41 | {/if} 42 |
43 | 44 | 68 | -------------------------------------------------------------------------------- /src/lib/ui/Bookmarks/BookmarkEditorIcon.svelte: -------------------------------------------------------------------------------- 1 | 38 | 39 | {#if bookmarkEditor.editor?.useImage} 40 |
41 | {#if settings?.showTitle} 42 | {@html bookmarkEditor.editor?.title} 43 | {/if} 44 |
45 | {:else} 46 |
47 | {@html bookmarkEditor.editor?.title} 48 |
49 | {/if} 50 | 51 | 75 | -------------------------------------------------------------------------------- /src/lib/ui/Bookmarks/DeleteBookmark.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 | {#if bookmarkEditor.bookmarkEditorContext == 'edit'} 25 | 26 |
27 | 28 | 29 | 30 |
31 | {/if} 32 | 33 | 40 | -------------------------------------------------------------------------------- /src/lib/ui/Bookmarks/ImageURL.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 | {#if open} 21 | 28 | {/if} 29 | 30 | 49 | -------------------------------------------------------------------------------- /src/lib/ui/Button.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 19 | 20 | 23 | 24 | 60 | -------------------------------------------------------------------------------- /src/lib/ui/Folders/DeleteFolder.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 | {#if bookmarkEditor.bookmarkEditorContext == 'edit'} 18 | 19 |
20 | 25 | 26 | 27 |
28 | {/if} 29 | 30 | 42 | -------------------------------------------------------------------------------- /src/lib/ui/Header/Header.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 21 | 22 | 49 | -------------------------------------------------------------------------------- /src/lib/ui/Header/Nav.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 21 | 22 | 71 | -------------------------------------------------------------------------------- /src/lib/ui/Header/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Header } from './Header.svelte' 2 | -------------------------------------------------------------------------------- /src/lib/ui/Main.svelte: -------------------------------------------------------------------------------- 1 | 24 | 25 |
26 |
27 |
32 | {#if newsVisible} 33 | 34 | {/if} 35 |
36 | 37 |
38 | {@render children?.()} 39 | 40 |
41 |
42 |
43 | 44 | 77 | -------------------------------------------------------------------------------- /src/lib/ui/Modal.svelte: -------------------------------------------------------------------------------- 1 | 20 | 21 | {#if showModal} 22 |
27 |
bookmarkEditor.hide()}> 28 | {@render children?.()} 29 |
30 |
31 | {/if} 32 | 33 | 52 | -------------------------------------------------------------------------------- /src/lib/ui/Nav.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 21 | 22 | 39 | -------------------------------------------------------------------------------- /src/lib/ui/NuTab.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 27 | 28 | 60 | -------------------------------------------------------------------------------- /src/lib/ui/Overlay.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
14 | 15 | 40 | -------------------------------------------------------------------------------- /src/lib/ui/TabOptions.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 |
25 | {#each options as option, i} 26 | 27 |
handleClick(option, i)} 31 | role="button" 32 | tabindex="0" 33 | > 34 | {option} 35 |
36 | {/each} 37 |
38 | 39 | 91 | -------------------------------------------------------------------------------- /src/lib/ui/index.ts: -------------------------------------------------------------------------------- 1 | export { Header } from './Header' 2 | -------------------------------------------------------------------------------- /src/lib/utils/Inspector.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | {@render children?.()} 15 | 16 | -------------------------------------------------------------------------------- /src/lib/utils/autoCleanupEffect.svelte.ts: -------------------------------------------------------------------------------- 1 | import { onDestroy } from 'svelte' 2 | import { DEV } from 'esm-env' 3 | 4 | /** 5 | * Behaves the same as `$effect.root`, but automatically 6 | * cleans up the effect inside Svelte components. 7 | * 8 | * @returns Cleanup function to manually cleanup the effect. 9 | */ 10 | export function autoCleanupEffect(fn: () => void | VoidFunction, verbose = true) { 11 | let cleanup: VoidFunction | null = $effect.root(fn) 12 | 13 | function destroy() { 14 | if (cleanup === null) { 15 | return 16 | } 17 | 18 | cleanup() 19 | cleanup = null 20 | } 21 | 22 | try { 23 | onDestroy(() => { 24 | destroy() 25 | if (verbose && DEV) { 26 | console.log('autoCleanupEffect: destroyed') 27 | } 28 | }) 29 | } catch { 30 | if (verbose && DEV) { 31 | console.warn('Effect created outside Svelte component - manual cleanup required.') 32 | } 33 | } 34 | 35 | return destroy 36 | } 37 | -------------------------------------------------------------------------------- /src/lib/utils/boilerplate.svelte.ts: -------------------------------------------------------------------------------- 1 | // // Function 2 | 3 | // function boilerplate() { 4 | // let value = $state('foo') 5 | // return { 6 | // get value() { 7 | // return value 8 | // }, 9 | // set value(v) { 10 | // value = v 11 | // }, 12 | // } 13 | // } 14 | 15 | // export const foo = boilerplate() 16 | 17 | // // Class 18 | 19 | // class Boilerplate { 20 | // value = $state(0) 21 | // } 22 | // export const count = new Boilerplate() 23 | 24 | // // Anonymous Class 25 | 26 | // export const count = new class { 27 | // value = $state(0) 28 | // }() 29 | 30 | // // Poor man's hook 31 | 32 | // const useBoilerplate = (initial: T) => { 33 | // let count = $state(initial) 34 | // return [() => count, (v: T) => (count = v)] 35 | // } 36 | // export const [getCount, setCount] = useBoilerplate(0) 37 | -------------------------------------------------------------------------------- /src/lib/utils/clickOutside.ts: -------------------------------------------------------------------------------- 1 | import type { Action } from 'svelte/action' 2 | 3 | export interface ClickOutsideEventDetail { 4 | target: HTMLElement 5 | } 6 | 7 | /** 8 | * Calls `outclick` when a parent element is clicked. 9 | */ 10 | export type ClickOutsideEvent = CustomEvent 11 | 12 | export interface ClickOutsideOptions { 13 | /** 14 | * Array of classnames. If the click target element has one of these classes, it will not be considered an outclick. 15 | */ 16 | whitelist?: string[] 17 | } 18 | 19 | /** 20 | * Attributes applied to the element using the {@link clickOutside} action. 21 | */ 22 | export interface ClickOutsideAttr { 23 | /** 24 | * Fires when the user clicks outside the element. 25 | */ 26 | onoutclick?: (event: ClickOutsideEvent) => void 27 | } 28 | 29 | /** 30 | * Calls a function when the user clicks outside the element. 31 | * @example 32 | * ```svelte 33 | *
34 | * ``` 35 | */ 36 | export const clickOutside: Action = ( 37 | node, 38 | options?: ClickOutsideOptions, 39 | ) => { 40 | const handleClick = (event: MouseEvent) => { 41 | let disable = false 42 | 43 | for (const className of options?.whitelist || []) { 44 | if (event.target instanceof Element && event.target.classList.contains(className)) { 45 | disable = true 46 | } 47 | } 48 | 49 | if (!disable && node && !node.contains(event.target as Node) && !event.defaultPrevented) { 50 | node.dispatchEvent( 51 | new CustomEvent('outclick', { 52 | detail: { 53 | target: event.target as HTMLElement, 54 | }, 55 | }), 56 | ) 57 | } 58 | } 59 | 60 | document.addEventListener('click', handleClick, true) 61 | 62 | return { 63 | update: (newOptions) => (options = { ...options, ...newOptions }), 64 | destroy() { 65 | document.removeEventListener('click', handleClick, true) 66 | }, 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/lib/utils/clipboardCopy.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copies the given text to the clipboard. 3 | * @param {string} text The text to copy. 4 | */ 5 | export const copy = (string: string) => { 6 | const input = document.createElement('input') 7 | document.body.appendChild(input) 8 | input.setAttribute('id', 'input') 9 | input.value = string 10 | input.select() 11 | document.execCommand('copy') 12 | document.body.removeChild(input) 13 | } 14 | -------------------------------------------------------------------------------- /src/lib/utils/color.ts: -------------------------------------------------------------------------------- 1 | const randomHex = (size: number) => [...Array(size)].map(() => Math.floor(Math.random() * 16).toString(16)).join('') 2 | 3 | export const randomColor = (n = 6) => `#${randomHex(n)}` 4 | 5 | export const randomGradient = () => `background-image: linear-gradient(to bottom, ${randomColor()}, ${randomColor()});` 6 | 7 | export const randomBackground = () => { 8 | const a = randomColor() 9 | const b = randomColor() 10 | const opacity = 48 11 | const gradient = `linear-gradient(to bottom, ${a}, ${b})` 12 | 13 | return { a, b, opacity, gradient } 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/utils/daysAgo.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Calculates the number of days between. 3 | * @param time 4 | * @returns 5 | * int: number of days 6 | * string: sentence describing the number of days (e.g. "today", "yesterday" or "3 days ago") 7 | */ 8 | export const daysAgo = (time: Date) => { 9 | const oneDayMs = 86_400_000 10 | const today = new Date() 11 | const int = Math.round(Math.abs((+today - +time) / oneDayMs)) 12 | const string = int === 0 ? 'today' : int === 1 ? 'yesterday' : `${int} days ago` 13 | const result = { 14 | int, 15 | string 16 | } 17 | Object.defineProperty(result, Symbol.toPrimitive, { 18 | value: () => int 19 | }) 20 | return result 21 | } 22 | -------------------------------------------------------------------------------- /src/lib/utils/debounce.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A callback function wrapped in a debounce timer. 3 | */ 4 | type DebouncedCallback = (...args: any[]) => void 5 | 6 | /** 7 | * A function that cancels the debounce when called. 8 | */ 9 | type CancelDebounce = () => void 10 | 11 | /** 12 | * Debounced event 13 | * @param cb Callback to run (will cancel smoothOut) 14 | * @param delay delay in ms 15 | * @param bypass optionally cancel the debounce 16 | * @returns The debounced callback and a cancel function. 17 | */ 18 | export const debounce = ( 19 | cb: T, 20 | delay = 500, 21 | bypass?: boolean, 22 | ): [T, CancelDebounce] => { 23 | let overTimer: ReturnType 24 | return [ 25 | ((...args) => { 26 | if (bypass) return 27 | clearTimeout(overTimer) 28 | overTimer = setTimeout(() => { 29 | if (bypass) return 30 | try { 31 | cb(...args) 32 | } catch (e) { 33 | console.warn(e) 34 | } 35 | }, delay) 36 | }) as T, 37 | () => clearTimeout(overTimer), 38 | ] 39 | } 40 | 41 | // // Example usage 42 | // const [debounceShowEditIcon] = debounce((i: number) => toggleShowEditIcon(true, i), 500) 43 | // function handleItemMouseOver(i: number) { 44 | // hovering = i 45 | // debounceShowEditIcon(i) 46 | // } 47 | -------------------------------------------------------------------------------- /src/lib/utils/defer.ts: -------------------------------------------------------------------------------- 1 | export const defer = ( 2 | typeof globalThis.requestIdleCallback !== 'undefined' 3 | ? globalThis.requestIdleCallback 4 | : typeof globalThis.requestAnimationFrame !== 'undefined' 5 | ? globalThis.requestAnimationFrame 6 | : (fn: () => void) => setTimeout(fn, 0) 7 | ) as (fn: () => void) => number 8 | 9 | export const cancelDefer = ( 10 | typeof globalThis?.cancelIdleCallback !== 'undefined' 11 | ? globalThis.cancelIdleCallback 12 | : typeof globalThis.cancelAnimationFrame !== 'undefined' 13 | ? globalThis.cancelAnimationFrame 14 | : globalThis.clearTimeout 15 | ) as (id: number) => void 16 | -------------------------------------------------------------------------------- /src/lib/utils/getClipboardUrl.ts: -------------------------------------------------------------------------------- 1 | import { browser } from '$app/environment' 2 | import { log } from 'fractils' 3 | 4 | export const getClipboardUrl = async (): Promise => { 5 | if (!browser) return '' 6 | let url = '' 7 | // @ts-ignore 8 | const perm = await navigator?.permissions?.query({ name: 'clipboard-read' }) 9 | 10 | // If permission to read the clipboard is granted or if the user will 11 | // be prompted to allow it, we proceed. 12 | if (perm.state == 'granted' || perm.state == 'prompt') { 13 | const contents = await navigator.clipboard.readText() 14 | 15 | try { 16 | const toUrl = new URL(contents) 17 | url = String(toUrl) 18 | } catch (e) { 19 | log(e) 20 | } 21 | 22 | // HACK: Can do batter than this 23 | if (contents.length < 100) url = contents 24 | } 25 | return url 26 | } 27 | -------------------------------------------------------------------------------- /src/lib/utils/idFromClassList.ts: -------------------------------------------------------------------------------- 1 | export const idFromClassList = (classes: DOMTokenList, prefix: string): number | null => { 2 | let i: number | null = null 3 | classes.forEach((c) => { 4 | if (i === null && c.includes(prefix) && c.length > 3) { 5 | i = parseInt(c.split(prefix)[1]) 6 | } 7 | }) 8 | return i || i === 0 ? i : null 9 | } 10 | -------------------------------------------------------------------------------- /src/lib/utils/index.ts: -------------------------------------------------------------------------------- 1 | export { randomColor, randomGradient, randomBackground } from './color' 2 | export * from './idFromClassList' 3 | -------------------------------------------------------------------------------- /src/lib/utils/keyboardEvents.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A subset of {@link KeyboardEvent.key} values. 3 | * @see https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values 4 | */ 5 | export type KeyboardEventKey = ModifierKeys | WhitespaceKeys | NavigationKeys | EditingKeys | FunctionKeys | NumericKeypadKeys | UpperAlpha | LowerAlpha 6 | 7 | type NumericKeypadKeys = "Decimal" | "Key11" | "Key12" | "Multiply" | "Add" | "Clear" | "Divide" | "Subtract" | "Separator" | "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" 8 | type UpperAlpha = 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G' | 'H' | 'I' | 'J' | 'K' | 'L' | 'M' | 'N' | 'O' | 'P' | 'Q' | 'R' | 'S' | 'T' | 'U' | 'V' | 'W' | 'X' | 'Y' | 'Z' 9 | type LowerAlpha = 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm' | 'n' | 'o' | 'p' | 'q' | 'r' | 's' | 't' | 'u' | 'v' | 'w' | 'x' | 'y' | 'z' 10 | type ModifierKeys = "Alt" | "AltGraph" | "CapsLock" | "Control" | "Fn" | "FnLock" | "Hyper" | "Meta" | "NumLock" | "ScrollLock" | "Shift" | "Super" | "Symbol" | "SymbolLock" 11 | type WhitespaceKeys = "Enter" | "Tab" | " " 12 | type NavigationKeys = "ArrowDown" | "ArrowLeft" | "ArrowRight" | "ArrowUp" | "End" | "Home" | "PageDown" | "PageUp" 13 | type EditingKeys = "Backspace" | "Delete" 14 | type FunctionKeys = "F1" | "F2" | "F3" | "F4" | "F5" | "F6" | "F7" | "F8" | "F9" | "F10" | "F11" | "F12" | "F13" | "F14" | "F15" | "F16" | "F17" | "F18" | "F19" | "F20" | "Soft1" | "Soft2" | "Soft3" | "Soft4" 15 | -------------------------------------------------------------------------------- /src/lib/utils/l.ts: -------------------------------------------------------------------------------- 1 | export const CONSOLE_COLOR_CODES = { 2 | reset: '\x1b[0m', 3 | // Foreground colors 4 | black: '\x1b[30m', 5 | red: '\x1b[31m', 6 | green: '\x1b[32m', 7 | yellow: '\x1b[33m', 8 | blue: '\x1b[34m', 9 | magenta: '\x1b[35m', 10 | cyan: '\x1b[36m', 11 | white: '\x1b[37m', 12 | gray: '\x1b[90m', 13 | // Background colors 14 | bgBlack: '\x1b[40m', 15 | bgRed: '\x1b[41m', 16 | bgGreen: '\x1b[42m', 17 | bgYellow: '\x1b[43m', 18 | bgBlue: '\x1b[44m', 19 | bgMagenta: '\x1b[45m', 20 | bgCyan: '\x1b[46m', 21 | bgWhite: '\x1b[47m', 22 | // Styles 23 | bold: '\x1b[1m', 24 | dim: '\x1b[2m', 25 | italic: '\x1b[3m', 26 | underline: '\x1b[4m', 27 | } as const 28 | 29 | // Simple hex to RGB conversion 30 | const hexToRgb = (hex: string): [number, number, number] | null => { 31 | const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) 32 | return result 33 | ? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)] 34 | : null 35 | } 36 | 37 | // Function to create hex color 38 | export const hex = (hexColor: string) => (str: string) => { 39 | const rgb = hexToRgb(hexColor) 40 | if (!rgb) return str 41 | return `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m${str}\x1b[0m` 42 | } 43 | 44 | export const color = (colorName: keyof typeof CONSOLE_COLOR_CODES) => (str: string) => 45 | `${CONSOLE_COLOR_CODES[colorName]}${str}${CONSOLE_COLOR_CODES.reset}` 46 | 47 | export const r = color('red') 48 | export const g = color('green') 49 | export const y = color('yellow') 50 | export const b = color('blue') 51 | export const m = color('magenta') 52 | export const c = color('cyan') 53 | export const gr = color('gray') 54 | export const dim = color('dim') 55 | export const o = hex('#ff7f50') 56 | -------------------------------------------------------------------------------- /src/lib/utils/localStorageStore.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/babichjacob/svelte-localstorage 2 | 3 | import { writable } from "svelte/store"; 4 | 5 | /** 6 | * @param key - What key in localStorage to synchronize with. 7 | * @param initial - The initial value of the writable store. 8 | * @param browser - Whether or not this code is running in the browser (where localStorage is available). 9 | */ 10 | export const localStorageStore = (key: string, initial: T, browser = typeof window !== "undefined") => { 11 | let currentValue = initial; 12 | 13 | const { set: setStore, ...readableStore } = writable(initial, () => { 14 | if (browser) { 15 | getAndSetFromLocalStorage(); 16 | 17 | const updateFromStorageEvents = (event: StorageEvent) => { 18 | if (event.key === key) getAndSetFromLocalStorage(); 19 | }; 20 | 21 | window.addEventListener("storage", updateFromStorageEvents); 22 | 23 | return () => window.removeEventListener("storage", updateFromStorageEvents); 24 | } 25 | }); 26 | 27 | // Set both localStorage and this Svelte store 28 | const set = (value: T) => { 29 | currentValue = value; 30 | setStore(value); 31 | 32 | try { 33 | localStorage.setItem(key, JSON.stringify(value)); 34 | } catch (error) { 35 | console.error(`the \`${key}\` store's new value \`${value}\` could not be persisted to localStorage because of ${error}`); 36 | } 37 | }; 38 | 39 | // Synchronize the Svelte store with localStorage 40 | const getAndSetFromLocalStorage = () => { 41 | let localValue = null; 42 | try { 43 | localValue = localStorage.getItem(key); 44 | } catch (error) { 45 | console.error(`the \`${key}\` store's value could not be restored from localStorage because of ${error}`); 46 | } 47 | 48 | if (localValue === null) set(initial); 49 | else { 50 | try { 51 | const parsed = JSON.parse(localValue); 52 | setStore(parsed); 53 | currentValue = parsed; 54 | } catch (error) { 55 | console.error(`localStorage's value for \`${key}\` (\`${localValue}\`) could not be parsed as JSON because of ${error}`); 56 | } 57 | } 58 | }; 59 | 60 | const update = (fn: (T: T) => T) => { 61 | set(fn(currentValue)); 62 | }; 63 | 64 | return { ...readableStore, set, update }; 65 | }; 66 | 67 | export default localStorageStore -------------------------------------------------------------------------------- /src/lib/utils/log.ts: -------------------------------------------------------------------------------- 1 | const isBrowser = 2 | typeof globalThis.window !== 'undefined' && typeof globalThis.window.document !== 'undefined' 3 | 4 | const dev = () => { 5 | if (!isBrowser) return 6 | if (typeof process != 'undefined') { 7 | return process.env?.NODE_ENV === 'development' 8 | } 9 | try { 10 | return import.meta.env.DEV 11 | } catch (e) { 12 | console.error(e) 13 | } 14 | return false 15 | } 16 | 17 | /** 18 | * A simple logger that only runs in dev environments. 19 | * @param msg - A string or object to log 20 | * @param color - Any CSS color value ( named | hex | rgb | hsl ) 21 | * @param bgColor - Same as color ⇧ 22 | * @param fontSize - Any number 23 | * @param css - Optional additional CSS 24 | * @returns {void} 25 | */ 26 | export const log = ( 27 | msg: string | unknown, 28 | color = 'lightblue', 29 | bgColor = 'transparent', 30 | fontSize = 15, 31 | css = '', 32 | ): void => { 33 | if (!dev) return 34 | 35 | if (typeof msg == 'string') 36 | return void console.log( 37 | `%c${msg}`, 38 | `padding:5px;color:${color};background: ${bgColor};border:1px solid ${color};size:${fontSize}px;${css}`, 39 | ) 40 | 41 | console.log(msg) 42 | } 43 | -------------------------------------------------------------------------------- /src/lib/utils/mapRange.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Maps a value from one range to another 3 | * 4 | * @param value - the value to map 5 | * @param x1 - lower bound of the input range 6 | * @param x2 - upper bound of the input range 7 | * @param y1 - lower bound of the output range 8 | * @param y2 - upper bound of the output range 9 | * @returns a number mapped from the input range to the output range 10 | */ 11 | export const mapRange = (value: number, x1: number, x2: number, y1: number, y2: number) => 12 | ((value - x1) * (y2 - y1)) / (x2 - x1) + y1 13 | -------------------------------------------------------------------------------- /src/lib/utils/nanoid.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generate a random ID. 3 | * @param length The length of the ID to generate. Default: `21` 4 | */ 5 | export function nanoid( 6 | /** @default 21 */ 7 | length = 21, 8 | ) { 9 | return crypto 10 | .getRandomValues(new Uint8Array(length)) 11 | .reduce( 12 | (t, e) => 13 | (t += 14 | (e &= 63) < 36 15 | ? e.toString(36) 16 | : e < 62 17 | ? (e - 26).toString(36).toUpperCase() 18 | : e > 62 19 | ? '-' 20 | : '_'), 21 | '', 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /src/lib/utils/persist.svelte.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/PuruVJ/macos-web/blob/main/src/state/persisted.svelte.ts 2 | 3 | // import { autoCleanupEffect } from './autoCleanupEffect.svelte' 4 | import { effect } from '$lib/ruins.svelte' 5 | 6 | type Primitive = string | null | symbol | boolean | number | undefined | bigint 7 | 8 | const isPrimitive = (thing: unknown): thing is Primitive => { 9 | return thing !== Object(thing) || thing === null 10 | } 11 | 12 | /** 13 | * Creates a `$state` rune that's automatically persisted to `localStorage`. 14 | */ 15 | export function persist(key: string, initial: T) { 16 | const existing = localStorage.getItem(key) 17 | 18 | const primitive = isPrimitive(initial) 19 | const parsed_value = existing ? JSON.parse(existing) : initial 20 | 21 | const state = $state( 22 | primitive ? { value: parsed_value } : parsed_value, 23 | ) 24 | 25 | effect(() => { 26 | localStorage.setItem(key, JSON.stringify(primitive ? (state as { value: T }).value : state)) 27 | }) 28 | 29 | return state 30 | } 31 | 32 | export type Persisted = T extends Primitive ? { value: T } : T 33 | -------------------------------------------------------------------------------- /src/lib/utils/persist2.svelte.ts: -------------------------------------------------------------------------------- 1 | import { onDestroy } from 'svelte' 2 | 3 | type Primitive = string | null | symbol | boolean | number | undefined | bigint 4 | 5 | const isPrimitive = (thing: unknown): thing is Primitive => { 6 | return thing !== Object(thing) || thing === null 7 | } 8 | 9 | /** 10 | * State persisted to `localStorage`. 11 | */ 12 | export type Persisted = T extends Primitive ? { value: T } : T 13 | 14 | /** 15 | * Creates a `$state` rune that's automatically persisted to `localStorage`. 16 | */ 17 | export function persist(key: string, initial: T) { 18 | const existing = localStorage.getItem(key) 19 | 20 | const primitive = isPrimitive(initial) 21 | const parsed_value = existing ? JSON.parse(existing) : initial 22 | 23 | const state = $state( 24 | primitive ? { value: parsed_value } : parsed_value, 25 | ) 26 | 27 | effect(() => { 28 | localStorage.setItem(key, JSON.stringify(primitive ? (state as { value: T }).value : state)) 29 | }) 30 | 31 | return state 32 | } 33 | 34 | /** 35 | * Internal effect implementation that creates a root if needed and handles cleanup when 36 | * used within a Svelte component lifecycle. 37 | */ 38 | function effect(fn: () => void) { 39 | if (!$effect.tracking()) { 40 | let cleanup: VoidFunction | null = $effect.root(fn) 41 | 42 | function destroy() { 43 | if (cleanup === null) return 44 | cleanup() 45 | cleanup = null 46 | } 47 | 48 | try { 49 | onDestroy(() => { 50 | destroy() 51 | }) 52 | } catch { 53 | // Outside of Svelte components, the user is responsible for calling destroy. 54 | } 55 | 56 | return destroy 57 | } else { 58 | $effect(fn) 59 | return () => {} 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/lib/utils/posEnd.ts: -------------------------------------------------------------------------------- 1 | // place the cursor at end */ 2 | export default function posEnd(node: HTMLInputElement) { 3 | const length = node.value.length 4 | 5 | // Mostly for Web Browsers 6 | if (node.setSelectionRange) { 7 | node.focus() 8 | node.setSelectionRange(length, length) 9 | } else if ('createTextRange' in node) { 10 | // @ts-expect-error IE Specific 11 | const t = node.createTextRange() 12 | t.collapse(true) 13 | t.moveEnd('character', length) 14 | t.moveStart('character', length) 15 | t.select() 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/lib/utils/randomColor.ts: -------------------------------------------------------------------------------- 1 | const r = (max = 255) => Math.floor(Math.random() * Math.floor(max)) 2 | const rgba = (opacity = 0.1) => [r(), r(), r(), opacity] 3 | const randomColorRgba = () => `rgba(${rgba()})` 4 | const randomHex = (size: number) => [...Array(size)].map(() => Math.floor(Math.random() * 16).toString(16)).join('') 5 | 6 | export const randomColor = () => `#${randomHex(6)}` 7 | -------------------------------------------------------------------------------- /src/lib/utils/rotateArray.ts: -------------------------------------------------------------------------------- 1 | // https://stackoverflow.com/questions/12802383/extending-array-in-typescript 2 | // declare global { 3 | // interface Array { 4 | // rotate(count: number): Array; 5 | // } 6 | // } 7 | 8 | // https://stackoverflow.com/questions/1985260/rotate-the-elements-in-an-array-in-javascript 9 | // Array.prototype.rotate = (function () { 10 | // const push = Array.prototype.push, 11 | // splice = Array.prototype.splice; 12 | 13 | // return function (count: number) { 14 | // const len = this.length >>> 0; 15 | // count = count >> 0; 16 | 17 | // //* convert count to value in range [0, len) 18 | // count = ((count % len) + len) % len; 19 | 20 | // push.apply(this, splice.call(this, 0, count)); 21 | // return this; 22 | // }; 23 | // })(); 24 | 25 | // export {} 26 | -------------------------------------------------------------------------------- /src/lib/utils/safeLocalStorage.ts: -------------------------------------------------------------------------------- 1 | export const safeLocalStorage = { 2 | get: (key: string, fallback: T): T => { 3 | if (!globalThis.localStorage) return fallback 4 | 5 | try { 6 | const raw = localStorage.getItem(key) 7 | if (!raw) return fallback 8 | 9 | if (typeof fallback === 'string') { 10 | return raw as T 11 | } else { 12 | return JSON.parse(raw) as T 13 | } 14 | } catch (e) { 15 | console.error(`Error getting ${key} from localStorage`, { 16 | key, 17 | fallback, 18 | error: e, 19 | }) 20 | return fallback 21 | } 22 | }, 23 | 24 | set: (key: string, value: T): void => { 25 | if (!globalThis.localStorage) return 26 | 27 | try { 28 | const serialized = typeof value === 'string' ? value : JSON.stringify(value) 29 | localStorage.setItem(key, serialized) 30 | } catch (e) { 31 | console.error(`Error setting ${key} in localStorage`, { 32 | key, 33 | value, 34 | error: e, 35 | }) 36 | } 37 | }, 38 | } 39 | -------------------------------------------------------------------------------- /src/lib/utils/selectText.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Selects the text in the given element. 3 | * @param el An element or id. 4 | */ 5 | 6 | export function selectText(el: Element | string): void { 7 | if (typeof window === 'undefined') return 8 | if (typeof el === 'string') { 9 | // @ts-expect-error ... 10 | el = document.getElementById(el) 11 | } 12 | if (!el) { 13 | console.error('select: no element found') 14 | return 15 | } 16 | const selection = window.getSelection() 17 | const range = document.createRange() 18 | range.selectNodeContents(el as Node) 19 | selection?.removeAllRanges() 20 | selection?.addRange(range) 21 | } 22 | -------------------------------------------------------------------------------- /src/lib/utils/smoothToggle.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Type for a callback function that provides a boolean state and optional additional arguments. 3 | */ 4 | type SmoothCallback = (state: boolean, ...args: any[]) => void 5 | 6 | /** 7 | * Returns a function to toggle a boolean with a smooth transition. 8 | * 9 | * @example 10 | * ```html 11 | * 21 | * 22 | * 23 | * 24 | * ``` 25 | */ 26 | export const smoothToggle = ( 27 | /** 28 | * The callback to call when the state changes. 29 | */ 30 | cb: T, 31 | /** 32 | * The delay in `ms` for each state change. 33 | * @default [500,0] 34 | */ 35 | [trueDelay, falseDelay]: [trueDelay?: number, falseDelay?: number] = [500, 0], 36 | ): T => { 37 | trueDelay ??= 500 38 | falseDelay ??= 0 39 | 40 | let onTimer: ReturnType 41 | let offTimer: ReturnType 42 | 43 | const set = ((state: boolean, ...args: any[]) => { 44 | clearTimeout(onTimer) 45 | clearTimeout(offTimer) 46 | 47 | const delay = state ? trueDelay : falseDelay 48 | 49 | if (state) { 50 | onTimer = setTimeout(() => { 51 | try { 52 | cb(state, ...args) 53 | } catch (e) { 54 | console.warn(e) 55 | } 56 | }, delay) 57 | } else { 58 | offTimer = setTimeout(() => { 59 | try { 60 | cb(state, ...args) 61 | } catch (e) { 62 | console.warn(e) 63 | } 64 | }, delay) 65 | } 66 | }) as T 67 | 68 | return set 69 | } 70 | -------------------------------------------------------------------------------- /src/lib/utils/smooth_hover.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * @param node The element consuming the action. 4 | * @param bool Optionally toggle a bool automatically. 5 | */ 6 | 7 | import type { Action } from 'svelte/action' 8 | 9 | export const smooth_hover: Action = (node: HTMLElement, bool?: boolean) => { 10 | let overTimer: ReturnType 11 | function smoothOver(_e: Event, delay = 500, bypass = false) { 12 | if (bypass) return 13 | clearTimers() 14 | overTimer = setTimeout(() => { 15 | // eslint-disable-next-line @typescript-eslint/no-unused-expressions 16 | bool ? (bool = !bool) : null 17 | node.dispatchEvent(new CustomEvent('hoverOn')) 18 | }, delay) 19 | } 20 | 21 | let outTimer: ReturnType 22 | function smoothOut(_e: Event, delay = 300) { 23 | clearTimers() 24 | outTimer = setTimeout(() => { 25 | // eslint-disable-next-line @typescript-eslint/no-unused-expressions 26 | bool ? (bool = !bool) : null 27 | node.dispatchEvent(new CustomEvent('hoverOff')) 28 | }, delay) 29 | } 30 | 31 | node.addEventListener('mouseout', smoothOver, true) 32 | node.addEventListener('mouseover', smoothOver, true) 33 | 34 | function clearTimers() { 35 | ;[outTimer, overTimer].forEach((t) => clearTimeout(t)) 36 | } 37 | 38 | return { 39 | destroy() { 40 | node.removeEventListener('mouseover', smoothOver, true) 41 | node.removeEventListener('mouseout', smoothOut, true) 42 | }, 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/lib/utils/stringify.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A stringify replacer that handles circular references, undefined values, and functions. 3 | * - Circular references are replaced with the string `[Circular ~]` 4 | * where `` is the path to the circular reference relative to the 5 | * root object, i.e. `[Circular ~.b.c]`. 6 | * - Functions are replaced with the string `"[Function]"`. 7 | * - `undefined` values are replaced with the string `"undefined"`. 8 | * 9 | * @param obj - The object to stringify. 10 | * @param indentation - Number of spaces for indentation. Optional. 11 | */ 12 | export const stringify = (input: unknown, indentation = 0) => { 13 | const stack = [] as unknown[] 14 | return JSON.stringify(input, serialize(stack), indentation) 15 | } 16 | 17 | /** 18 | * A replacer function for `JSON.stringify` that handles circular references, 19 | * undefined values, and functions with strings. 20 | * @see {@link stringify} 21 | */ 22 | export function serialize(stack: unknown[]) { 23 | const keys: string[] = [] 24 | 25 | return function (this: unknown, key: string, value: unknown): unknown { 26 | if (typeof value === 'undefined') return 27 | if (typeof value === 'function') return '[Function]' 28 | 29 | let thisPos = stack.indexOf(this) 30 | if (thisPos !== -1) { 31 | stack.length = thisPos + 1 32 | keys.length = thisPos 33 | keys[thisPos] = key 34 | } else { 35 | stack.push(this) 36 | keys.push(key) 37 | } 38 | 39 | let valuePos = stack.indexOf(value) 40 | if (valuePos !== -1) { 41 | return '[Circular ~' + keys.slice(0, valuePos).join('.') + ']' 42 | } 43 | 44 | if (value instanceof Set) { 45 | return Array.from(value) 46 | } 47 | 48 | if (value instanceof Map) { 49 | return Object.fromEntries( 50 | Array.from(value.entries()).map(([k, v]) => { 51 | const newStack = [...stack] 52 | return [k, JSON.parse(JSON.stringify(v, serialize(newStack)))] 53 | }), 54 | ) 55 | } 56 | 57 | if (value instanceof Element) { 58 | return `${value.tagName}.${Array.from(value.classList) 59 | .filter(s => !s.startsWith('s-')) 60 | .join('.')}#${value.id}` 61 | } 62 | 63 | if (stack.length > 0) { 64 | stack.push(value) 65 | } 66 | 67 | return value 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/lib/utils/throttle.ts: -------------------------------------------------------------------------------- 1 | type λ = (...args: TA) => TR 2 | 3 | const { performance } = globalThis 4 | export const cancel = Symbol('throttle.cancel') 5 | 6 | /** 7 | * Create a throttled function that invokes `fun` at most every `ms` milliseconds. 8 | * 9 | * `fun` is invoked with the last arguments passed to the throttled function. 10 | * 11 | * Calling `[throttle.cancel]()` on the throttled function will cancel the currently 12 | * scheduled invocation. 13 | */ 14 | export const throttle = Object.assign( 15 | (fun: λ, ms: number, { leading = true, trailing = true } = {}) => { 16 | let toId: any 17 | let lastInvoke = -Infinity 18 | let lastArgs: any[] | undefined 19 | 20 | const invoke = () => { 21 | lastInvoke = performance.now() 22 | toId = undefined 23 | fun(...lastArgs!) 24 | } 25 | 26 | return Object.assign( 27 | (...args: any[]) => { 28 | if (!leading && !trailing) return 29 | lastArgs = args 30 | const dt = performance.now() - lastInvoke 31 | 32 | if (dt >= ms && toId === undefined && leading) invoke() 33 | else if (toId === undefined && trailing) { 34 | toId = setTimeout(invoke, dt >= ms ? ms : ms - dt) 35 | } 36 | }, 37 | { [cancel]: () => clearTimeout(toId) }, 38 | ) 39 | }, 40 | { cancel }, 41 | ) as (( 42 | fun: T, 43 | ms: number, 44 | opts?: { leading?: boolean; trailing: boolean }, 45 | ) => λ, void> & { [cancel](): void }) & { 46 | cancel: typeof cancel 47 | } 48 | -------------------------------------------------------------------------------- /src/lib/utils/wait.ts: -------------------------------------------------------------------------------- 1 | export const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)) 2 | -------------------------------------------------------------------------------- /src/routes/+error.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 |

{$page.status}

7 | 8 | {#if dev} 9 |
10 |
 {$page.error?.message} 
11 |
 {($page.error as Error)?.stack} 
12 |
13 | {/if} 14 | 15 | 75 | -------------------------------------------------------------------------------- /src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /src/routes/+layout.ts: -------------------------------------------------------------------------------- 1 | export const prerender = true 2 | export const ssr = false 3 | -------------------------------------------------------------------------------- /src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 |
35 | -------------------------------------------------------------------------------- /src/routes/Root.svelte: -------------------------------------------------------------------------------- 1 | 32 | 33 | 34 | Nutab 35 | 36 | 37 | 38 | 39 | {#if dev} 40 | 41 | {/if} 42 | 43 |
44 | 45 | 46 |
54 | 55 | 62 | -------------------------------------------------------------------------------- /src/routes/api/[url]/+server.ts: -------------------------------------------------------------------------------- 1 | export const GET = async ({ params }) => { 2 | const res = await fetch(params.url, { 3 | headers: { 4 | Accept: 'text/html' 5 | } 6 | }) 7 | 8 | console.log({ res }) 9 | 10 | if (res.ok) { 11 | return res.clone(); 12 | } 13 | 14 | return new Response(null, { status: 404 }) 15 | } 16 | -------------------------------------------------------------------------------- /src/routes/garage/+layout.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 |
7 | 8 |
9 | -------------------------------------------------------------------------------- /src/routes/garage/deep-state/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 | 7 |
8 | 9 | 10 | 13 |
14 | 15 |
16 |
{JSON.stringify(deepState, null, 2)}
17 |
18 | 19 | 34 | -------------------------------------------------------------------------------- /src/routes/garage/deep-state/deep-state.svelte.ts: -------------------------------------------------------------------------------- 1 | import { persist } from '$lib/utils/persist.svelte' 2 | 3 | export const deepState = persist('deepState', { 4 | data: { 5 | foo: 'bar', 6 | baz: { 7 | qux: 'quux', 8 | }, 9 | }, 10 | test: 123, 11 | }) 12 | -------------------------------------------------------------------------------- /src/routes/garage/global-state/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 | 7 |
8 | 9 | 10 |
11 | 12 |
13 |

{globals.cart}

14 |
15 | 16 | 35 | -------------------------------------------------------------------------------- /src/routes/garage/global-state/globals.svelte.ts: -------------------------------------------------------------------------------- 1 | export const globals = new (class GlobalState { 2 | cart = $state([]) 3 | 4 | constructor() { 5 | this.cart = JSON.parse(globalThis.localStorage?.getItem('cart') || '[]') 6 | 7 | $effect.root(() => { 8 | $effect(() => { 9 | localStorage.setItem('cart', JSON.stringify(this.cart)) 10 | }) 11 | }) 12 | } 13 | })() 14 | -------------------------------------------------------------------------------- /src/routes/garage/local-state/+page.svelte: -------------------------------------------------------------------------------- 1 | 22 | 23 |
24 | 25 |
26 |

calls {calls}

27 |
28 | 29 |
30 |

count {count.value}

31 | 32 |
33 | 34 |
35 |

localStorage

36 |
{JSON.stringify(localStorage, null, 2)}
37 |
38 | 39 | 57 | -------------------------------------------------------------------------------- /src/routes/garage/local-state/decorator/+page.svelte: -------------------------------------------------------------------------------- 1 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/routes/garage/local-state/dot-value/+page.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 |
23 | 24 |
25 |

foo {settings.value.foo}

26 | {#if settings.value} 27 | 28 | {/if} 29 |
30 | 31 |
32 |

localStorage

33 |
{JSON.stringify(storage, null, 2)}
34 |
35 | 36 | 54 | -------------------------------------------------------------------------------- /src/routes/garage/local-state/util/+page.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | 35 | 36 |
37 | 38 |
39 |

foo {settings?.foo}

40 | {#if settings} 41 | 42 | {/if} 43 |
44 | 45 |
46 |

localStorage

47 |
{JSON.stringify(storage, null, 2)}
48 |
49 | 50 | 68 | -------------------------------------------------------------------------------- /src/routes/garage/localhost/+page.svelte: -------------------------------------------------------------------------------- 1 | 30 | 31 |
32 |
33 | 34 | 35 |
36 | {#if running} 37 |

Running

38 | {:else} 39 |

Not Running

40 | {/if} 41 | 42 |
{data}
43 |
44 | 45 | 62 | -------------------------------------------------------------------------------- /src/routes/garage/on-click/+page.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 |
15 | 16 |
17 | {'meta+ctrl+a'} 18 |
19 | 20 |
{ 23 | count++ 24 | }} 25 | > 26 | count++ 27 |
28 | 29 |
{count}
30 | 31 | {#each keys.slice(0, 10) as key} 32 |
{key}
33 | {/each} 34 | 35 | 67 | -------------------------------------------------------------------------------- /src/routes/garage/simple-themer/+page.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 |
16 |

theme = {themer.theme}

17 |

preference = {themer.preference}

18 | 19 |
20 | 21 | 22 | 23 |
24 | 25 |
localStorage: {JSON.stringify(ls, null, 2)}
26 |
27 | 28 | 56 | -------------------------------------------------------------------------------- /src/routes/garage/simple-themer/simple-themer.svelte.js: -------------------------------------------------------------------------------- 1 | class Themer { 2 | /** @type {'light' | 'dark' | 'system'} */ 3 | // @ts-expect-error - no clue how to non-null assert in jsdoc 4 | preference = $state() 5 | 6 | /** @type {MediaQueryList | undefined} */ 7 | #prefersLight 8 | 9 | theme = $derived.by(() => { 10 | return this.preference === 'system' 11 | ? this.#prefersLight?.matches 12 | ? 'light' 13 | : 'dark' 14 | : this.preference 15 | }) 16 | 17 | constructor() { 18 | // rune hoopla 19 | $effect.root(() => { 20 | $effect(() => { 21 | this.#prefersLight ??= globalThis.window?.matchMedia( 22 | '(prefers-color-scheme: light)', 23 | ) 24 | 25 | if (!this.preference) { 26 | // @ts-expect-error - no clue how to cast in jsdoc 27 | this.preference = localStorage.getItem('theme-preference') ?? 'system' 28 | } else { 29 | localStorage.setItem('theme', this.preference) 30 | } 31 | 32 | localStorage.setItem('theme-preference', this.preference) 33 | localStorage.setItem('theme', this.theme) 34 | 35 | document.documentElement.setAttribute('theme', this.theme) 36 | document.documentElement.setAttribute('color-scheme', this.theme) 37 | 38 | $inspect('theme:', this.theme) 39 | $inspect('preference:', this.preference) 40 | }) 41 | }) 42 | } 43 | } 44 | 45 | export const themer = new Themer() 46 | -------------------------------------------------------------------------------- /src/routes/garage/simple-themer/simple-themer.svelte.ts: -------------------------------------------------------------------------------- 1 | export type ThemePreference = 'light' | 'dark' | 'system' 2 | 3 | class Themer { 4 | preference = $state() 5 | 6 | #prefersLight: MediaQueryList | undefined 7 | 8 | theme = $derived.by(() => { 9 | return this.preference === 'system' 10 | ? this.#prefersLight?.matches 11 | ? 'light' 12 | : 'dark' 13 | : this.preference 14 | })! 15 | 16 | constructor() { 17 | $effect.root(() => { 18 | $effect(() => { 19 | this.#prefersLight ??= globalThis.window?.matchMedia( 20 | '(prefers-color-scheme: light)', 21 | ) 22 | 23 | if (!this.preference) { 24 | this.preference = (localStorage.getItem('theme-preference') ?? 25 | 'system') as ThemePreference 26 | } else { 27 | localStorage.setItem('theme', this.preference) 28 | } 29 | 30 | localStorage.setItem('theme-preference', this.preference) 31 | localStorage.setItem('theme', this.theme) 32 | 33 | document.documentElement.setAttribute('theme', this.theme) 34 | document.documentElement.setAttribute('color-scheme', this.theme) 35 | 36 | $inspect('theme:', this.theme) 37 | $inspect('preference:', this.preference) 38 | }) 39 | }) 40 | } 41 | } 42 | 43 | export const themer = new Themer() 44 | -------------------------------------------------------------------------------- /src/styles/btn.scss: -------------------------------------------------------------------------------- 1 | .btn { 2 | position: relative; 3 | display: flex; 4 | align-items: center; 5 | justify-content: center; 6 | 7 | width: max-content; 8 | height: max-content; 9 | padding: 0.5rem 1rem; 10 | 11 | border-radius: 10px; 12 | color: var(--bg-d); 13 | background: var(--fg-a); 14 | // border: 1px solid var(--fg-a); 15 | box-shadow: var(--shadow-xs); 16 | 17 | font-size: var(--font-md); 18 | font-family: var(--font-a); 19 | word-spacing: 3px; 20 | 21 | user-select: none; 22 | cursor: pointer; 23 | 24 | transition: background 0.15s, color 0.15s, box-shadow 0.15s, transform 0.15s; 25 | 26 | &:hover:not(:active, :focus) { 27 | box-shadow: var(--shadow-sm); 28 | transform: scale(1.05); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/styles/input.scss: -------------------------------------------------------------------------------- 1 | .checkbox { 2 | align-self: flex-start; 3 | 4 | width: 70%; 5 | margin: 0; 6 | 7 | font-weight: 200; 8 | letter-spacing: 1px; 9 | 10 | opacity: 0.5; 11 | 12 | input { 13 | position: absolute; 14 | opacity: 0; 15 | cursor: pointer; 16 | height: 0; 17 | width: 0; 18 | } 19 | } 20 | 21 | $size: 0.75rem; 22 | 23 | .radio { 24 | position: relative; 25 | height: $size; 26 | width: $size; 27 | margin: 0; 28 | 29 | outline: none; 30 | 31 | background: transparent; 32 | border: 1px solid var(--bg-a); 33 | border-radius: 1rem; 34 | 35 | cursor: pointer; 36 | 37 | .circle { 38 | position: absolute; 39 | inset: 0; 40 | 41 | width: 8px; 42 | height: 8px; 43 | margin: auto; 44 | border-radius: 1rem; 45 | 46 | background: var(--bg-a); 47 | transform: scale(0); 48 | transition: 0.1s ease-out; 49 | &.checked { 50 | transform: scale(1); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/styles/prism.scss: -------------------------------------------------------------------------------- 1 | /* 2 | PRISM NORD THEME https://github.com/PrismJS/prism-themes/blob/master/themes/prism-nord.css 3 | 4 | Nord Theme Originally by Arctic Ice Studio 5 | https://nordtheme.com 6 | 7 | Ported for PrismJS by Zane Hitchcoxc (@zwhitchcox) and Gabriel Ramos (@gabrieluizramos) 8 | */ 9 | 10 | 11 | code[class*='language-'], 12 | pre[class*='language-'] { 13 | color: #f8f8f2; 14 | background: none; 15 | 16 | font-family: 'Fira Code', 'Consolas', 'Monaco', 'Andale Mono', 'Ubuntu Mono', monospace; 17 | text-align: left; 18 | white-space: pre; 19 | word-spacing: normal; 20 | word-break: normal; 21 | word-wrap: normal; 22 | line-height: 1.5; 23 | tab-size: 4; 24 | 25 | hyphens: none; 26 | } 27 | 28 | /* Code blocks */ 29 | pre[class*='language-'] { 30 | padding: 1em; 31 | margin: 0.5em 0; 32 | 33 | border-radius: 0.3em; 34 | 35 | overflow: auto; 36 | } 37 | 38 | :not(pre) > code[class*='language-'], 39 | pre[class*='language-'] { 40 | background: #2e3440; 41 | } 42 | 43 | /* Inline code */ 44 | :not(pre) > code[class*='language-'] { 45 | padding: 0.1em; 46 | 47 | border-radius: 0.3em; 48 | 49 | white-space: normal; 50 | } 51 | 52 | .token.comment, 53 | .token.prolog, 54 | .token.doctype, 55 | .token.cdata { 56 | color: #636f88; 57 | } 58 | 59 | .token.punctuation { 60 | color: #81a1c1; 61 | } 62 | 63 | .namespace { 64 | opacity: 0.7; 65 | } 66 | 67 | .token.property, 68 | .token.tag, 69 | .token.constant, 70 | .token.symbol, 71 | .token.deleted { 72 | color: #81a1c1; 73 | } 74 | 75 | .token.number { 76 | color: #b48ead; 77 | } 78 | 79 | .token.boolean { 80 | color: #81a1c1; 81 | } 82 | 83 | .token.selector, 84 | .token.attr-name, 85 | .token.string, 86 | .token.char, 87 | .token.builtin, 88 | .token.inserted { 89 | color: #a3be8c; 90 | } 91 | 92 | .token.operator, 93 | .token.entity, 94 | .token.url, 95 | .language-css .token.string, 96 | .style .token.string, 97 | .token.variable { 98 | color: #81a1c1; 99 | } 100 | 101 | .token.atrule, 102 | .token.attr-value, 103 | .token.function, 104 | .token.class-name { 105 | color: #88c0d0; 106 | } 107 | 108 | .token.keyword { 109 | color: #81a1c1; 110 | } 111 | 112 | .token.regex, 113 | .token.important { 114 | color: #ebcb8b; 115 | } 116 | 117 | .token.important, 118 | .token.bold { 119 | font-weight: bold; 120 | } 121 | 122 | .token.italic { 123 | font-style: italic; 124 | } 125 | 126 | .token.entity { 127 | cursor: help; 128 | } 129 | -------------------------------------------------------------------------------- /src/styles/theme.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --theme-a: #d71f5b; 3 | --theme-b: #ffcc8b; 4 | --theme-c: #ff8ba9; 5 | --always-dark: #0b0e11; 6 | } 7 | :root { 8 | --dark-a: #0b0e11; 9 | --dark-b: #1c1e21; 10 | --dark-c: #1d252e; 11 | --dark-d: #3a3a44; 12 | --light-a: #ffffff; 13 | --light-b: #dfe1e9; 14 | --light-c: #c3c4c7; 15 | --light-d: #96969e; 16 | 17 | --bg-a: light-dark(#0b0e11, #ffffff); 18 | --bg-b: light-dark(#1c1e21, #dfe1e9); 19 | --bg-c: light-dark(#1d252e, #c3c4c7); 20 | --bg-d: light-dark(#545460, #96969e); 21 | --fg-a: light-dark(#ffffff, #0b0e11); 22 | --fg-b: light-dark(#dfe1e9, #1c1e21); 23 | --fg-c: light-dark(#c3c4c7, #1d252e); 24 | --fg-d: light-dark(#acacb4, #3a3a44); 25 | } 26 | 27 | :root[theme='dark'] { 28 | color-scheme: dark; 29 | } 30 | :root[theme='light'] { 31 | color-scheme: light; 32 | } -------------------------------------------------------------------------------- /src/styles/utils.sass: -------------------------------------------------------------------------------- 1 | .br-xs 2 | min-height: 0.5rem 3 | max-height: 0.5rem 4 | 5 | .br-sm 6 | min-height: 1rem 7 | max-height: 1rem 8 | 9 | .br-md 10 | min-height: 4rem 11 | max-height: 4rem 12 | 13 | .br-lg 14 | min-height: 6.5rem 15 | max-height: 6.5rem 16 | 17 | .br-xl 18 | min-height: 10rem 19 | max-height: 10rem 20 | -------------------------------------------------------------------------------- /static/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/braebo/nutab/25baaf74cb0689a484ff41a3678de71c5c588939/static/android-chrome-192x192.png -------------------------------------------------------------------------------- /static/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/braebo/nutab/25baaf74cb0689a484ff41a3678de71c5c588939/static/android-chrome-512x512.png -------------------------------------------------------------------------------- /static/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/braebo/nutab/25baaf74cb0689a484ff41a3678de71c5c588939/static/apple-touch-icon.png -------------------------------------------------------------------------------- /static/banner.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/braebo/nutab/25baaf74cb0689a484ff41a3678de71c5c588939/static/banner.jpg -------------------------------------------------------------------------------- /static/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #00d0ff 7 | 8 | 9 | -------------------------------------------------------------------------------- /static/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/braebo/nutab/25baaf74cb0689a484ff41a3678de71c5c588939/static/favicon-16x16.png -------------------------------------------------------------------------------- /static/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/braebo/nutab/25baaf74cb0689a484ff41a3678de71c5c588939/static/favicon-32x32.png -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/braebo/nutab/25baaf74cb0689a484ff41a3678de71c5c588939/static/favicon.ico -------------------------------------------------------------------------------- /static/favicon.svg: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /static/fonts/dosis/dosis.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/braebo/nutab/25baaf74cb0689a484ff41a3678de71c5c588939/static/fonts/dosis/dosis.ttf -------------------------------------------------------------------------------- /static/fonts/merriweather/merriweather.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/braebo/nutab/25baaf74cb0689a484ff41a3678de71c5c588939/static/fonts/merriweather/merriweather.ttf -------------------------------------------------------------------------------- /static/fonts/rubik/rubik-italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/braebo/nutab/25baaf74cb0689a484ff41a3678de71c5c588939/static/fonts/rubik/rubik-italic.ttf -------------------------------------------------------------------------------- /static/fonts/rubik/rubik.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/braebo/nutab/25baaf74cb0689a484ff41a3678de71c5c588939/static/fonts/rubik/rubik.ttf -------------------------------------------------------------------------------- /static/images/logos/MDN.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/braebo/nutab/25baaf74cb0689a484ff41a3678de71c5c588939/static/images/logos/MDN.jpg -------------------------------------------------------------------------------- /static/logo.svg: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /static/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "browser_action": { 3 | "default_title": "Nutab", 4 | "default_popup": "index.html" 5 | }, 6 | "description": "A better new tab for developers.", 7 | "author": "Fractal ", 8 | "name": "Nutab", 9 | "version": "0.1", 10 | "manifest_version": 3, 11 | "chrome_url_overrides": { 12 | "newtab": "index.html" 13 | }, 14 | "permissions": ["storage"], 15 | "host_permissions": ["https://*/*"], 16 | "icons": { 17 | "16": "/favicon-16x16.png", 18 | "32": "/favicon-32x32.png", 19 | "192": "/android-chrome-192x192.png", 20 | "512": "/android-chrome-512x512.png" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /static/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/braebo/nutab/25baaf74cb0689a484ff41a3678de71c5c588939/static/mstile-150x150.png -------------------------------------------------------------------------------- /static/nutab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/braebo/nutab/25baaf74cb0689a484ff41a3678de71c5c588939/static/nutab.png -------------------------------------------------------------------------------- /static/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /static/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Nutab", 3 | "short_name": "Nutab", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#00d0ff", 17 | "background_color": "#3d3d3d", 18 | "display": "standalone" 19 | } -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' 2 | import adapter_vercel from '@sveltejs/adapter-vercel' 3 | import { sveltePreprocess } from 'svelte-preprocess' 4 | import adapter_auto from '@sveltejs/adapter-auto' 5 | import browser_extension from './adapter.js' 6 | 7 | const vercel = !!process.env.VERCEL 8 | const extension = !!process.env.BROWSER_EXTENSION 9 | 10 | const adapter = vercel 11 | ? adapter_vercel() 12 | : extension 13 | ? browser_extension({ fallback: 'index.html' }) 14 | : adapter_auto() 15 | 16 | const ignoreWarnings = ['a11y-click-events-have-key-events'] 17 | 18 | /** @type {import('@sveltejs/kit').Config} */ 19 | const config = { 20 | preprocess: [ 21 | vitePreprocess(), 22 | sveltePreprocess({ 23 | pug: true, 24 | scss: true, 25 | }), 26 | ], 27 | kit: { 28 | adapter, 29 | appDir: 'ext', //* This is important - chrome extensions can't handle the default _app directory name. 30 | }, 31 | vitePlugin: { 32 | inspector: { 33 | toggleButtonPos: 'bottom-left', 34 | toggleKeyCombo: 'meta-shift', 35 | showToggleButton: 'always', 36 | holdMode: true, 37 | }, 38 | }, 39 | // @ts-expect-error - Why isn't this typed? 40 | onwarn: (warning, handler) => { 41 | // if (warning.code.startsWith('a11y-')) { 42 | if (ignoreWarnings.includes(warning.code)) { 43 | return 44 | } 45 | handler(warning) 46 | }, 47 | warningFilter: (ree) => !ree.code.startsWith('a11y'), 48 | } 49 | 50 | export default config 51 | -------------------------------------------------------------------------------- /sync-worker/.dev.vars.example: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/braebo/nutab/25baaf74cb0689a484ff41a3678de71c5c588939/sync-worker/.dev.vars.example -------------------------------------------------------------------------------- /sync-worker/.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_size = 4 6 | indent_style = tab 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.{json,yml,yaml,md}] 13 | indent_style = space 14 | -------------------------------------------------------------------------------- /sync-worker/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .wrangler 4 | .dev.vars 5 | -------------------------------------------------------------------------------- /sync-worker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "wrangler dev src/index.ts", 5 | "deploy": "wrangler deploy --minify src/index.ts" 6 | }, 7 | "devDependencies": { 8 | "@cloudflare/workers-types": "^4.20241022.0", 9 | "esm-env": "^1.1.4", 10 | "hono": "^4.6.8", 11 | "wrangler": "^3.84.1" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /sync-worker/src/index.ts: -------------------------------------------------------------------------------- 1 | import type { Env } from './types' 2 | 3 | import { new_user, save_user_data, get_user_data } from './routes/user' 4 | import { logger } from 'hono/logger' 5 | import { cors } from 'hono/cors' 6 | import { Hono } from 'hono' 7 | 8 | const app = new Hono() 9 | .use('*', logger()) 10 | .use('*', cors()) 11 | .post('/user', new_user) 12 | .post('/user/:phrase', save_user_data) 13 | .get('/user/:phrase', get_user_data) 14 | 15 | export type SyncWorker = typeof app 16 | 17 | export default app 18 | -------------------------------------------------------------------------------- /sync-worker/src/routes/user.ts: -------------------------------------------------------------------------------- 1 | import type { Env, UserData } from '../types' 2 | 3 | import { Handler } from 'hono' 4 | 5 | export const new_user: Handler = async (c) => { 6 | const body = await c.req.json() 7 | 8 | const res = await fetch('https://mnemonic.willow.sh/new') 9 | if (!res.ok) { 10 | c.status(500) 11 | return c.json({ 12 | error: 'Internal Error', 13 | message: 'Failed to generate phrase 😕', 14 | }) 15 | } 16 | 17 | const phrase: string[] = await res.json() 18 | 19 | await c.env.USER_DATA.put(phrase.join('-'), JSON.stringify(body)) 20 | 21 | return c.json({ phrase }) 22 | } 23 | 24 | export const save_user_data: Handler = async (c) => { 25 | const body = await c.req.json() 26 | 27 | const phrase = c.req.param('phrase') 28 | 29 | await c.env.USER_DATA.put(phrase, JSON.stringify(body)) 30 | 31 | return c.json({ message: 'bookmarks saved 🎉' }, 200) 32 | } 33 | 34 | export const get_user_data: Handler = async (c) => { 35 | const phrase = c.req.param('phrase') 36 | 37 | const bookmarks = await c.env.USER_DATA.get(phrase, 'json') 38 | if (!bookmarks) { 39 | return c.json({ error: 'Not Found', message: 'Not Found' }, 404) 40 | } 41 | 42 | return c.json(bookmarks, 200) 43 | } 44 | -------------------------------------------------------------------------------- /sync-worker/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Bookmark, Folder, Settings } from '../../src/lib/data/types' 2 | import type { Env as HonoEnv } from 'hono' 3 | 4 | //? Hono env config 5 | export interface Env extends HonoEnv { 6 | Bindings: { 7 | USER_DATA: KVNamespace 8 | } 9 | } 10 | 11 | /** 12 | * All data store 13 | */ 14 | export type UserData = { 15 | email?: string 16 | data: { 17 | bookmarks: Bookmark[] 18 | folders: Folder[] 19 | settings?: Settings 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /sync-worker/src/url.ts: -------------------------------------------------------------------------------- 1 | import { DEV } from 'esm-env' 2 | 3 | export const url = DEV 4 | ? ('http://localhost:8787' as const) 5 | : ('https://nutab-sync.braebo.dev' as const) 6 | -------------------------------------------------------------------------------- /sync-worker/test.ts: -------------------------------------------------------------------------------- 1 | import type { UserData } from './src/types' 2 | 3 | import { url } from './src/url' 4 | 5 | export async function createUser(email: string): Promise<{ phrase: string }> { 6 | const res = await fetch(`${url}/user`, { 7 | method: 'POST', 8 | headers: { 'Content-Type': 'application/json' }, 9 | body: JSON.stringify({ 10 | email, 11 | }), 12 | }) 13 | 14 | const data = await res.json() 15 | 16 | return { 17 | phrase: data.phrase, 18 | } 19 | } 20 | 21 | export async function saveBoomarks(phrase: string, bookmarks: UserData) { 22 | await fetch(`${url}/${phrase}/bookmarks`, { 23 | method: 'POST', 24 | headers: { 'Content-Type': 'application/json' }, 25 | body: JSON.stringify(bookmarks), 26 | }) 27 | } 28 | 29 | export async function getBoomarks(phrase: string): Promise { 30 | const res = await fetch(`${url}/${phrase}/bookmarks`, { 31 | method: 'GET', 32 | }) 33 | 34 | const data = (await res.json()) as UserData 35 | 36 | return data 37 | } 38 | -------------------------------------------------------------------------------- /sync-worker/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "strict": true, 5 | "allowJs": true, 6 | "module": "esnext", 7 | "target": "es2021", 8 | "noImplicitAny": true, 9 | // "keyofStringsOnly": true, 🪦 10 | "resolveJsonModule": true, 11 | "strictBindCallApply": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "allowSyntheticDefaultImports": true, 14 | "moduleResolution": "node", 15 | "strictNullChecks": true, 16 | "removeComments": true, 17 | "sourceMap": false, 18 | "checkJs": true, 19 | "noEmit": false, 20 | "types": ["@cloudflare/workers-types"] 21 | }, 22 | "include": ["@types", "src"], 23 | "exclude": ["node_modules"] 24 | } 25 | -------------------------------------------------------------------------------- /sync-worker/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "nutab-sync" 2 | account_id = "ea348805594121ba01589915fdb64d47" 3 | compatibility_date = "2024-10-31" 4 | 5 | kv_namespaces = [ 6 | { binding = "USER_DATA", id = "cf5423949eb840799d39148e7fbdce33" } 7 | ] 8 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import aspectRatio from '@tailwindcss/aspect-ratio'; 2 | import containerQueries from '@tailwindcss/container-queries'; 3 | import typography from '@tailwindcss/typography'; 4 | import type { Config } from 'tailwindcss'; 5 | 6 | export default { 7 | content: ['./src/**/*.{html,js,svelte,ts}'], 8 | 9 | theme: { 10 | extend: {} 11 | }, 12 | 13 | plugins: [typography, containerQueries, aspectRatio] 14 | } as Config; 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "moduleResolution": "Bundler", 5 | "module": "ESNext", 6 | "lib": ["ESNext"], 7 | "target": "ESNext", 8 | "types": ["node", "bun", "vitest", "@sveltejs/kit"], 9 | 10 | "strict": true, 11 | "allowJs": true, 12 | "checkJs": true, 13 | 14 | "esModuleInterop": true, 15 | "verbatimModuleSyntax": true, 16 | "resolveJsonModule": true, 17 | "allowSyntheticDefaultImports": true, 18 | "isolatedModules": true, 19 | "sourceMap": true, 20 | "skipLibCheck": true, 21 | "forceConsistentCasingInFileNames": true, 22 | // "strictNullChecks": false 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { sveltekit } from '@sveltejs/kit/vite' 4 | import { defineConfig } from 'vite' 5 | 6 | export default defineConfig({ 7 | plugins: [sveltekit()], 8 | // server: { port: 3333 }, 9 | // silence warnings - https://sass-lang.com/documentation/breaking-changes/legacy-js-api 10 | css: { preprocessorOptions: { scss: { api: 'modern' } } }, 11 | test: { 12 | include: ['src/**/*.{test,spec}.{js,ts}'], 13 | }, 14 | }) 15 | --------------------------------------------------------------------------------