Full Code of dd3v/favbox for AI

master 0e723e1266ae cached
79 files
263.7 KB
70.4k tokens
74 symbols
1 requests
Download .txt
Showing preview only (286K chars total). Download the full file or copy to clipboard to get everything.
Repository: dd3v/favbox
Branch: master
Commit: 0e723e1266ae
Files: 79
Total size: 263.7 KB

Directory structure:
gitextract_xc1kkoq6/

├── .eslintignore
├── .eslintrc.cjs
├── .gitignore
├── LICENSE
├── README.md
├── manifest.chrome.json
├── manifest.firefox.json
├── package.json
├── public/
│   └── site.webmanifest
├── src/
│   ├── assets/
│   │   └── app.css
│   ├── components/
│   │   └── app/
│   │       ├── AppBadge.vue
│   │       ├── AppBullet.vue
│   │       ├── AppButton.vue
│   │       ├── AppConfirmation.vue
│   │       ├── AppDrawer.vue
│   │       ├── AppInfiniteScroll.vue
│   │       ├── AppNotifications.vue
│   │       ├── AppProgress.vue
│   │       ├── AppRadio.vue
│   │       ├── AppSpinner.vue
│   │       └── AppTagInput.vue
│   ├── composables/
│   │   └── useColorExtraction.js
│   ├── constants/
│   │   ├── app.js
│   │   └── httpStatus.js
│   ├── ext/
│   │   ├── browser/
│   │   │   ├── app.js
│   │   │   ├── components/
│   │   │   │   ├── ASide.vue
│   │   │   │   ├── AttributeList.vue
│   │   │   │   ├── BookmarkFavicon.vue
│   │   │   │   ├── BookmarkForm.vue
│   │   │   │   ├── BookmarkLayout.vue
│   │   │   │   ├── BookmarksSync.vue
│   │   │   │   ├── CommandPalette.vue
│   │   │   │   ├── DatePicker.vue
│   │   │   │   ├── FolderTree.vue
│   │   │   │   ├── FolderTreeItem.vue
│   │   │   │   ├── SearchTerm.vue
│   │   │   │   ├── SortDirection.vue
│   │   │   │   ├── TextEditor.vue
│   │   │   │   ├── ThemeMode.vue
│   │   │   │   ├── ViewMode.vue
│   │   │   │   └── card/
│   │   │   │       ├── BookmarkCard.vue
│   │   │   │       ├── DuplicateCard.vue
│   │   │   │       ├── HealthCheckCard.vue
│   │   │   │       ├── PinnedCard.vue
│   │   │   │       └── type/
│   │   │   │           ├── CardView.vue
│   │   │   │           ├── ListView.vue
│   │   │   │           └── MasonryView.vue
│   │   │   ├── index.html
│   │   │   ├── layouts/
│   │   │   │   └── AppLayout.vue
│   │   │   ├── router.js
│   │   │   └── views/
│   │   │       ├── BookmarksView.vue
│   │   │       ├── DuplicatesView.vue
│   │   │       ├── HealthCheckView.vue
│   │   │       └── NotesView.vue
│   │   ├── content/
│   │   │   └── content.js
│   │   ├── popup/
│   │   │   ├── App.vue
│   │   │   ├── PopupView.vue
│   │   │   ├── components/
│   │   │   │   └── BookmarkForm.vue
│   │   │   ├── index.html
│   │   │   └── main.js
│   │   └── sw/
│   │       ├── index.js
│   │       ├── ping.js
│   │       └── sync.js
│   ├── index.html
│   ├── parser/
│   │   └── metadata.js
│   ├── services/
│   │   ├── browserBookmarks.js
│   │   ├── hash.js
│   │   ├── httpClient.js
│   │   └── tags.js
│   └── storage/
│       ├── attribute.js
│       ├── bookmark.js
│       └── idb/
│           └── connection.js
├── tests/
│   ├── integration/
│   │   └── fetch.spec.js
│   └── unit/
│       ├── browserBookmarks.spec.js
│       ├── hash.spec.js
│       ├── metadataParser.spec.js
│       └── tagHelper.spec.js
├── vite.config.firefox.js
└── vite.config.js

================================================
FILE CONTENTS
================================================

================================================
FILE: .eslintignore
================================================
node_modules/
dist/
public/
*.min.js
package-lock.json 

================================================
FILE: .eslintrc.cjs
================================================
module.exports = {
  env: {
    browser: true,
    es2021: true,
    webextensions: true,
    node: true,
  },
  extends: ['plugin:vue/recommended', 'airbnb-base', 'plugin:jsdoc/recommended', 'plugin:vuejs-accessibility/recommended'],
  parserOptions: {
    ecmaVersion: 'latest',
    sourceType: 'module',
  },
  plugins: ['vue', 'import', 'jsdoc', 'vuejs-accessibility'],
  rules: {
    'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
    'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
    'max-len': 'off',
    'vue/no-v-model-argument': 'off',
    'no-plusplus': 'off',
    'no-continue': 'off',
    'class-methods-use-this': 'off',
    'no-unused-expressions': 'off',
    'object-curly-newline': 'off',
    'no-new': 'off',
    'no-bitwise': 'off',
    'no-restricted-syntax': [
      'error',
      'ForInStatement',
      'LabeledStatement',
      'WithStatement',
    ],
    'no-use-before-define': ['error', { functions: false }],
    'no-param-reassign': ['error', { props: false }],
    'import/no-unresolved': ['error', { ignore: ['^~icons/', '^floating-vue', '^floating-vue/dist/style', '^floating-vue/style', '\\.css$', '\\.scss$', '\\.sass$'] }],
    'import/extensions': ['error', { ignore: ['^~icons/', '^floating-vue', '^floating-vue/dist/style', '^floating-vue/style', '\\.css$', '\\.scss$', '\\.sass$'] }],
    // JSDoc rules
    'jsdoc/require-jsdoc': 'off',
    'jsdoc/require-param-description': 'off',
    'jsdoc/require-returns-description': 'off',
    'jsdoc/require-param': 'off',
    'jsdoc/require-returns': 'off',
    'no-await-in-loop': 'off',
    // Accessibility rules
    'vuejs-accessibility/alt-text': 'error',
    'vuejs-accessibility/anchor-has-content': 'error',
    'vuejs-accessibility/aria-props': 'error',
    'vuejs-accessibility/aria-unsupported-elements': 'error',
    'vuejs-accessibility/click-events-have-key-events': 'error',
    'vuejs-accessibility/heading-has-content': 'error',
    'vuejs-accessibility/iframe-has-title': 'error',
    'vuejs-accessibility/interactive-supports-focus': 'error',
    'vuejs-accessibility/label-has-for': 'error',
    'vuejs-accessibility/media-has-caption': 'warn',
    'vuejs-accessibility/mouse-events-have-key-events': 'error',
    'vuejs-accessibility/no-access-key': 'error',
    'vuejs-accessibility/no-autofocus': 'error',
    'vuejs-accessibility/no-distracting-elements': 'error',
    'vuejs-accessibility/no-redundant-roles': 'error',
    'vuejs-accessibility/role-has-required-aria-props': 'error',
    'vuejs-accessibility/tabindex-no-positive': 'error',
    'vuejs-accessibility/no-static-element-interactions': 'error',
    'vuejs-accessibility/form-control-has-label': 'error',
  },
  settings: {
    'import/resolver': {
      alias: {
        map: [['@', './src']],
        extensions: ['.ts', '.js', '.jsx', '.tsx', '.json'],
      },
    },
    'import/ignore': [
    ],
  },
  ignorePatterns: ['vite.config.js', 'vite.config.firefox.js', 'src/ext/browser/app.js'],
};


================================================
FILE: .gitignore
================================================
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
.DS_Store
dist
dist-ssr
coverage
*.local

/cypress/videos/
/cypress/screenshots/

# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?


================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2025 Magalyas Dmitry

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: README.md
================================================
# FavBox

<p align="center">
<a href="https://github.com/dd3v/favbox/issues"><img src="https://img.shields.io/github/issues/dd3v/favbox" alt="issues"></a>
<a href="https://github.com/dd3v/favbox"><img src="https://img.shields.io/github/package-json/v/dd3v/favbox" alt="ver"></a>
<a href="https://github.com/dd3v/favbox"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="license"></a>
<a href="https://github.com/dd3v/favbox"><img src="https://img.shields.io/badge/Made%20With-Love-orange.svg" alt="love"></a>
</p>

<p align="center">
  <a href="app_demo.png"><img src="app_demo.png" alt="FavBox Light Theme" width="48%"></a>
  <a href="app_demo_dark.png"><img src="app_demo_dark.png" alt="FavBox Dark Theme" width="48%"></a>
</p>

<p align="center">
<a href="https://chrome.google.com/webstore/detail/favbox/eangbddipcghohfjefjmfihcjgjnnemj">
<img src="https://img.shields.io/badge/Google%20Chrome-4285F4?style=for-the-badge&logo=GoogleChrome&logoColor=white">
</a>
</p>


FavBox is a local-first **experimental** browser extension that enhances and simplifies bookmark management without cloud storage or third-party services. It extends your browser's native bookmarking features.

Key features:

🔄 Syncs with your browser profile \
🔒 No third‑party data sharing. No ads. No tracking. \
🎨 Minimalist, clean UI\
🏷️ Tag support for easy organization\
🔍 Advanced search, sorting, and filtering by tags, domains, folders, and keywords\
🌁 Multiple display modes\
🌗 Light and dark themes\
🗑️ Detects broken and duplicate bookmarks\
⌨️ Hotkeys for quick search access\
🗒️ Local notes support\
❤️ Free and open source

### Concept

![image](concept.png) 

### Implementation

FavBox scans all bookmarks in the browser, then makes requests to the saved pages and extracts data from them such as title, description, image, and meta tags to improve the search. All the data is stored in local storage IndexedDB. The extension also tracks all browser events related to bookmarks and synchronizes the data. It only extends the standard functionality and does not attempt to replace it. You can work with bookmarks both through the extension and the native browser’s built-in bookmark features.


FavBox is a fully local application. To keep tags synced across devices, it uses a trick. Since bookmarks are synchronized between devices, to keep tags synchronized, the app adds them to the page title.

For example, if you have a bookmark titled `Google Chrome — Wikipedia`, to save tags across devices, extension appends them to the title like this:
`Google Chrome — Wikipedia 🏷 #wiki #browser`

This way, your tags become available on other devices without using any cloud services — only through the standard Google Chrome profile sync.


```
├── public                 # Static assets (icons, etc.)
│   └── icons
├── src                    # Source code
│   ├── assets             # Global styles
│   ├── components         # Shared UI components
│   │   └── app
│   ├── composables        # Vue composables
│   ├── constants          # Application constants
│   ├── ext                # Browser extension
│   │   ├── browser        # FavBox main app
│   │   │   ├── components
│   │   │   ├── layouts
│   │   │   └── views
│   │   ├── content        # Content scripts
│   │   ├── popup          # Extension popup
│   │   └── sw             # Service worker
│   ├── parser             # HTML metadata parser
│   ├── services           # Utility services (HTTP client, bookmarks API, tags, hash)
│   └── storage            # IndexedDB storage
│       └── idb
└── tests
    ├── integration
    └── unit
```

### Permissions

| Permission | Why needed |
|------------|------------|
| `bookmarks` | Read and manage bookmarks|
| `activeTab` | Capture page screenshot for visual previews |
| `tabs` | Get current tab info when saving bookmarks |
| `storage` | Store sync status and extension settings |
| `alarms` | Keep service worker alive for background sync |
| `contextMenus` | Add "Save to FavBox" to right-click menu |
| `<all_urls>` | Fetch page metadata (title, description, favicon) |


### Building
1. `pnpm run build` to build into `dist`
2. Enable dev mode in `chrome://extensions/` and `Load unpacked` extension

### Commands

- **`dev`**  Start development server  
- **`dev:firefox`**  Firefox development build (WIP)
- **`build`**  Production build  
- **`test:unit`** Run unit tests  
- **`test:integration`**   Run integration tests  

### TODO
- Use SQLite Wasm for storage (ideal for future experiments)
- Improve transaction implementation (ensure reliability & better performance)
- The extension already uses a polyfill to maintain compatibility with other browsers. It would be good to test this in Firefox. (WIP)

================================================
FILE: manifest.chrome.json
================================================
{
  "manifest_version": 3,
  "name": "FavBox",
  "description": "A clean, modern bookmark app — local-first by design.",
  "version": "2.1.5",
  "permissions": [
    "bookmarks",
    "activeTab",
    "tabs",
    "storage",
    "alarms",
    "contextMenus"
  ],
  "host_permissions": [
    "<all_urls>"
  ],
  "icons": {
    "16": "icons/icon16.png",
    "32": "icons/icon32.png",
    "48": "icons/icon48.png",
    "128": "icons/icon128.png"
  },
  "action": {
    "default_popup": "ext/popup/index.html"
  },
  "commands": {
    "_execute_action": {
      "suggested_key": {
        "windows": "Ctrl+Shift+Y",
        "mac": "Command+Shift+Y",
        "chromeos": "Ctrl+Shift+U",
        "linux": "Ctrl+Shift+J"
      }
    }
  },
  "background": {
    "service_worker": "ext/sw/index.js",
    "type": "module"
  },
  "content_scripts": [
    {
      "matches": [
        "<all_urls>"
      ],
      "js": [
        "ext/content/content.js"
      ],
      "run_at": "document_end"
    }
  ],
  "web_accessible_resources": [
    {
      "resources": [
        "ext/browser/index.html"
      ],
      "matches": [
        "<all_urls>"
      ]
    }
  ]
}

================================================
FILE: manifest.firefox.json
================================================
{
    "manifest_version": 3,
    "name": "FavBox",
    "description": "A clean, modern bookmark app — local-first by design.",
    "version": "2.1.5",
    "permissions": [
        "bookmarks",
        "activeTab",
        "tabs",
        "storage",
        "alarms",
        "contextMenus"
    ],
    "host_permissions": [
        "<all_urls>"
    ],
    "icons": {
        "16": "icons/icon16.png",
        "32": "icons/icon32.png",
        "48": "icons/icon48.png",
        "128": "icons/icon128.png"
    },
    "action": {
        "default_icon": {
            "16": "icons/icon16.png",
            "32": "icons/icon32.png"
        },
        "default_title": "FavBox",
        "default_popup": "ext/popup/index.html",
        "theme_icons": [
            {
                "light": "icons/icon16.png",
                "dark": "icons/icon16.png",
                "size": 16
            },
            {
                "light": "icons/icon32.png",
                "dark": "icons/icon32.png",
                "size": 32
            }
        ]
    },
    "background": {
        "scripts": [
            "ext/sw/index.js"
        ],
        "persistent": false
    },
    "content_scripts": [
        {
            "matches": [
                "<all_urls>"
            ],
            "js": [
                "ext/content/content.js"
            ],
            "run_at": "document_end"
        }
    ],
    "web_accessible_resources": [
        {
          "resources": [
            "ext/browser/index.html"
          ],
          "matches": [
            "<all_urls>"
          ]
        }
      ]
}

================================================
FILE: package.json
================================================
{
  "name": "favbox",
  "version": "2.1.5",
  "private": false,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "dev:firefox": "vite build --mode development -c vite.config.firefox.js",
    "build": "vite build",
    "test:unit": "vitest --environment jsdom --root tests/unit --disableConsoleIntercept",
    "test:integration": "vitest  --environment jsdom --root tests/integration --disableConsoleIntercept",
    "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix"
  },
  "dependencies": {
    "@fontsource/sn-pro": "^5.2.5",
    "@headlessui/vue": "^1.7.21",
    "@number-flow/vue": "^0.4.3",
    "@tailwindcss/vite": "^4.1.11",
    "@tiptap/extension-bold": "^2.9.1",
    "@tiptap/extension-highlight": "^2.9.1",
    "@tiptap/extension-italic": "^2.9.1",
    "@tiptap/extension-typography": "^2.9.1",
    "@tiptap/extension-underline": "^2.9.1",
    "@tiptap/pm": "^2.9.1",
    "@tiptap/starter-kit": "^2.9.1",
    "@tiptap/vue-3": "^2.9.1",
    "@vuepic/vue-datepicker": "^11.0.2",
    "@vueuse/core": "^13.5.0",
    "@zanmato/vue3-treeselect": "^0.4.1",
    "fast-average-color": "^9.5.0",
    "floating-vue": "^5.2.2",
    "jsstore": "^4.9.0",
    "linkedom": "^0.18.11",
    "node-vibrant": "^4.0.3",
    "notiwind": "^2.0.2",
    "vue": "^3.5.13",
    "vue-next-masonry": "^1.1.3",
    "vue-router": "^4.5.0",
    "webextension-polyfill": "^0.12.0"
  },
  "devDependencies": {
    "@babel/eslint-parser": "^7.28.0",
    "@crxjs/vite-plugin": "^2.0.0-beta.26",
    "@iconify/json": "^2.2.356",
    "@rushstack/eslint-patch": "^1.10.2",
    "@tailwindcss/forms": "^0.5.7",
    "@tailwindcss/typography": "^0.5.13",
    "@types/dompurify": "^3.0.5",
    "@vitejs/plugin-vue": "^6.0.0",
    "@vue/test-utils": "^2.4.5",
    "autoprefixer": "^10.4.21",
    "eslint": "^8.57.1",
    "eslint-config-airbnb-base": "^15.0.0",
    "eslint-import-resolver-alias": "^1.1.2",
    "eslint-plugin-import": "^2.32.0",
    "eslint-plugin-jsdoc": "^48.11.0",
    "eslint-plugin-vue": "^9.33.0",
    "eslint-plugin-vuejs-accessibility": "^2.4.1",
    "file-loader": "^6.2.0",
    "globals": "^16.3.0",
    "jsdom": "^26.1.0",
    "postcss": "^8.5.6",
    "tailwindcss": "^4.1.11",
    "terser": "^5.31.0",
    "unplugin-auto-import": "^19.3.0",
    "unplugin-icons": "^22.1.0",
    "vite": "^7.1.11",
    "vite-plugin-eslint": "^1.8.1",
    "vitest": "^3.0.5",
    "vue-eslint-parser": "^10.2.0"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/dd3v/favbox.git"
  },
  "keywords": [
    "chrome extension",
    "bookmarks"
  ],
  "author": "Magalyas Dmitry",
  "license": "MIT",
  "bugs": {
    "url": "https://github.com/dd3v/favbox/issues"
  },
  "homepage": "https://github.com/dd3v/favbox#readme"
}


================================================
FILE: public/site.webmanifest
================================================
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

================================================
FILE: src/assets/app.css
================================================
@import "tailwindcss";
@plugin "@tailwindcss/forms";
@plugin  "@tailwindcss/typography";

@custom-variant dark (&:where(.dark, .dark *));

@layer base {
  button:not(:disabled),
  [role="button"]:not(:disabled) {
    cursor: pointer;
  }
  *,
  ::after,
  ::before,
  ::backdrop,
  ::file-selector-button {
    border-color: var(--color-gray-200, currentColor);
  }
}

@theme {
  --font-sans: "SN Pro", sans-serif; 
  --color-soft-50: oklch(0.999 0.001 0);
  --color-soft-100: oklch(0.995 0.002 0);
  --color-soft-200: oklch(0.99 0.003 0);
  --color-soft-300: oklch(0.98 0.004 0);
  --color-soft-400: oklch(0.95 0.005 0);
  --color-soft-500: oklch(0.9 0.006 0);
  --color-soft-600: oklch(0.85 0.007 0);
  --color-soft-700: oklch(0.8 0.008 0);
  --color-soft-800: oklch(0.75 0.009 0);
  --color-soft-900: oklch(0.7 0.01 0);

  --color-gray-50: oklch(0.985 0 0);
  --color-gray-100: oklch(0.967 0.001 286.375);
  --color-gray-200: oklch(0.92 0.004 286.32);
  --color-gray-300: oklch(0.871 0.006 286.286);
  --color-gray-400: oklch(0.705 0.015 286.067);
  --color-gray-500: oklch(0.552 0.016 285.938);
  --color-gray-600: oklch(0.442 0.017 285.786);
  --color-gray-700: oklch(0.37 0.013 285.805);
  --color-gray-800: oklch(0.274 0.006 286.033);
  --color-gray-900: oklch(0.21 0.006 285.885);
  --color-gray-950: oklch(0.141 0.005 285.823);
}


@layer utilities {

  :root {
    --scrollbar-thumb: rgba(0, 0, 0, 0.2);
    --scrollbar-thumb-hover: rgba(0, 0, 0, 0.2);
  }

  .dark {
    --scrollbar-thumb: rgba(255, 255, 255, 0.2);
    --scrollbar-thumb-hover: rgba(255, 255, 255, 0.3);
  }

  ::-webkit-scrollbar {
    width: 1px;
    height: 1px;
  }

  ::-webkit-scrollbar-track {
    background: transparent;
  }

  ::-webkit-scrollbar-thumb {
    background: var(--scrollbar-thumb);
    border-radius: 2px;
  }

  ::-webkit-scrollbar-thumb:hover {
    background: var(--scrollbar-thumb-hover);
  }
}
@supports(animation-timeline: view()) {
  @keyframes fade-in-on-enter--fade-out-on-exit {
    entry 0% {
      opacity: 0;
      transform: translateY(100%);
    }

    entry 100% {
      opacity: 1;
      transform: translateY(0);
    }

    exit 0% {
      opacity: 1;
      transform: translateY(0);
    }

    exit 100% {
      opacity: 0;
      transform: translateY(-100%);
    }
  }

  .list-view>ul>li {
    animation: linear fade-in-on-enter--fade-out-on-exit;
    animation-timeline: view();
  }
}

.vue3-treeselect__single-value {
  @apply!text-black dark: !text-white;
}

.vue3-treeselect:not(.vue3-treeselect--disabled):not(.vue3-treeselect--focused) .vue3-treeselect__control:hover {
  @apply !border-gray-200 dark:!border-neutral-700;
}

.vue3-treeselect__control {
  @apply w-full !border-gray-200 !text-xs !text-black !shadow-sm !outline-none focus:!border-gray-300 focus:!ring-0 dark:!border-neutral-800 dark:!bg-neutral-900 dark:!text-white dark:focus:!border-neutral-700;
}

.vue3-treeselect__input {
  @apply !text-xs !text-black dark:!text-white;
}

.vue3-treeselect__input:focus {
  @apply !ring-0 !outline-none !shadow-none !text-xs;
}

.vue3-treeselect__menu {
  @apply !bg-white !shadow-sm !text-xs !border-t-0 !border-solid !border-gray-200 dark:!border-neutral-700 !ring-0 focus:!outline-none dark:!bg-neutral-900 dark:!text-white;
}

.vue3-treeselect .vue3-treeselect__list div {
  @apply !leading-[2.5] !py-0 !my-0;
}

.vue3-treeselect__option--highlight {
  @apply !bg-gray-100 dark:!bg-neutral-800;
}

.vue3-treeselect__option vue3-treeselect__option--highlight {
  @apply !bg-gray-100 dark:!bg-neutral-800;
}

.vue3-treeselect--open .vue3-treeselect__control {
  @apply dark:!border-neutral-700;
}

.vue3-treeselect--single .vue3-treeselect__option--selected {
  @apply !bg-gray-100 dark:!bg-neutral-800;
}

.vue3-treeselect--focused:not(.vue3-treeselect--open) .vue3-treeselect__control {
  @apply !border-gray-200 dark:!border-neutral-700 !shadow-none;
}

.vue3-treeselect__single-value {
  @apply !text-black dark:!text-white;
}

:root .dp__theme_light {
  --dp-background-color: #fff;
  --dp-text-color: var(--color-gray-900);
  --dp-hover-color: var(--color-gray-100);
  --dp-hover-text-color: var(--color-gray-900);
  --dp-primary-color: var(--color-gray-600);
  --dp-primary-text-color: #fff;
  --dp-border-color: var(--color-gray-200);
  --dp-menu-border-color: var(--color-gray-200);
  --dp-font-family: "SN Pro", sans-serif;
  --dp-font-size: 0.75rem;
}

:root .dp__theme_dark {
  --dp-background-color: var(--color-gray-950);
  --dp-text-color: var(--color-gray-100);
  --dp-hover-color: var(--color-gray-800);
  --dp-hover-text-color: var(--color-gray-100);
  --dp-primary-color: var(--color-gray-400);
  --dp-primary-text-color: var(--color-gray-900);
  --dp-border-color: var(--color-gray-700);
  --dp-menu-border-color: var(--color-gray-700);
  --dp-font-family: "SN Pro", sans-serif;
  --dp-font-size: 0.75rem;
}


================================================
FILE: src/components/app/AppBadge.vue
================================================
<template>
  <div
    :class="badgeClass"
    class="inline-flex cursor-pointer gap-x-1 items-center space-x-1 whitespace-nowrap rounded-sm px-2 py-0.5 text-xs ring-1 ring-inset"
  >
    <slot />
    <button
      v-if="closable"
      type="button"
      :class="closeButtonClass"
      class="size-3 flex items-center justify-center"
      aria-label="Close"
      tabindex="0"
      @click="handleClose"
    >
      <svg
        xmlns="http://www.w3.org/2000/svg"
        fill="none"
        viewBox="0 0 24 24"
        stroke-width="1"
        stroke="currentColor"
        class="size-3"
      >
        <path
          stroke-linecap="round"
          stroke-linejoin="round"
          d="M6 18L18 6M6 6l12 12"
        />
      </svg>
    </button>
  </div>
</template>

<script setup>
import { defineEmits, computed } from 'vue';

const props = defineProps({
  color: {
    type: String,
    default: 'gray',
  },
  closable: {
    type: Boolean,
    default: false,
  },
});

const emit = defineEmits(['onClose']);

const badgeClass = computed(() => {
  const colorClasses = {
    red: 'bg-red-50 text-red-700 ring-red-700/10 shadow shadow-red-500/20 dark:bg-red-900 dark:text-red-300 dark:ring-red-400/20',
    yellow: 'bg-yellow-50 text-yellow-800 ring-yellow-700/10 shadow shadow-yellow-500/20 dark:bg-yellow-900 dark:text-yellow-300 dark:ring-yellow-500/20',
    green: 'bg-green-50 text-green-700 ring-green-700/10 shadow shadow-green-500/20 dark:bg-green-900 dark:text-green-300 dark:ring-green-500/20',
    cyan: 'bg-cyan-50 text-cyan-700 ring-cyan-700/10 shadow shadow-cyan-500/20 dark:bg-cyan-900 dark:text-cyan-300 dark:ring-cyan-500/20',
    indigo: 'bg-indigo-50 text-indigo-700 ring-indigo-700/10 shadow shadow-indigo-500/20 dark:bg-indigo-900 dark:text-indigo-300 dark:ring-indigo-500/20',
    purple: 'bg-purple-50 text-purple-700 ring-purple-700/10 shadow shadow-purple-500/20 dark:bg-purple-900 dark:text-purple-300 dark:ring-purple-500/20',
    pink: 'bg-pink-50 text-pink-700 ring-pink-700/10 shadow shadow-pink-500/20 dark:bg-pink-900 dark:text-pink-300 dark:ring-pink-500/20',
    gray: 'bg-gray-50 text-gray-600 ring-gray-700/10 shadow shadow-gray-400/20 dark:bg-neutral-700 dark:text-neutral-300 dark:ring-neutral-500/20',
  };
  return colorClasses[props.color] || colorClasses.gray;
});

const closeButtonClass = computed(() => {
  const closeClasses = {
    red: 'text-red-700 hover:bg-red-300/20 hover:text-red-900 dark:text-red-300 dark:hover:text-red-100',
    yellow: 'text-yellow-700 hover:bg-yellow-300/20 hover:text-yellow-900 dark:text-yellow-300 dark:hover:text-yellow-100',
    green: 'text-green-700 hover:bg-green-300/20 hover:text-green-900 dark:text-green-300 dark:hover:text-green-100',
    cyan: 'text-cyan-700 hover:bg-cyan-300/20 hover:text-cyan-900 dark:text-cyan-300 dark:hover:text-cyan-100',
    indigo: 'text-indigo-700 hover:bg-indigo-300/20 hover:text-indigo-900 dark:text-indigo-300 dark:hover:text-indigo-100',
    purple: 'text-purple-700 hover:bg-purple-300/20 hover:text-purple-900 dark:text-purple-300 dark:hover:text-purple-100',
    pink: 'text-pink-700 hover:bg-pink-300/20 hover:text-pink-900 dark:text-pink-300 dark:hover:text-pink-100',
    gray: 'text-gray-600 hover:bg-gray-300/20 hover:text-gray-800 dark:text-gray-300 dark:hover:text-gray-100',
  };
  return closeClasses[props.color] || closeClasses.gray;
});

const handleClose = () => {
  emit('onClose');
};
</script>


================================================
FILE: src/components/app/AppBullet.vue
================================================
<template>
  <span
    :class="[dotClass, dotSize]"
    class="inline-block rounded-full border"
    aria-hidden="true"
    focusable="false"
  />
</template>

<script setup>
import { computed } from 'vue';

const props = defineProps({
  color: {
    type: String,
    default: 'gray',
  },
  size: {
    type: Number,
    default: 2,
  },
});

const dotSize = computed(() => `size-${props.size}`);

const dotClass = computed(() => {
  const colorClasses = {
    red: 'bg-red-50 border-red-400 dark:bg-red-900 dark:border-red-600',
    yellow: 'bg-yellow-50 border-yellow-400 dark:bg-yellow-900 dark:border-yellow-600',
    green: 'bg-green-50 border-green-400 dark:bg-green-900 dark:border-green-600',
    blue: 'bg-blue-50 border-blue-400 dark:bg-blue-900 dark:border-blue-600',
    indigo: 'bg-indigo-50 border-indigo-400 dark:bg-indigo-900 dark:border-indigo-600',
    purple: 'bg-purple-50 border-purple-400 dark:bg-purple-900 dark:border-purple-600',
    pink: 'bg-pink-50 border-pink-400 dark:bg-pink-900 dark:border-pink-600',
    cyan: 'bg-cyan-50 border-cyan-400 dark:bg-cyan-900 dark:border-cyan-600',
    gray: 'bg-gray-50 border-gray-400 dark:bg-neutral-700 dark:border-neutral-500',
  };
  return colorClasses[props.color] || colorClasses.gray;
});
</script>


================================================
FILE: src/components/app/AppButton.vue
================================================
<template>
  <button
    :class="buttonClasses"
    :aria-label="ariaLabel"
    :title="title"
    :role="role"
    tabindex="0"
  >
    <slot />
  </button>
</template>

<script setup>
import { computed } from 'vue';

const props = defineProps({
  variant: {
    type: String,
    default: 'default',
  },
  ariaLabel: {
    type: String,
    default: 'Button',
  },
  title: {
    type: String,
    default: 'Button',
  },
  role: {
    type: String,
    default: 'button',
  },
});

const buttonClasses = computed(() => {
  const variantClasses = {
    default: 'block rounded-sm bg-black px-3 py-2.5 text-xs font-medium text-white border border-gray-800 shadow-xs transition-all duration-200 ease-out hover:bg-gray-900 hover:border-gray-700 hover:shadow-md active:bg-black active:scale-[0.98] active:shadow-xs focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 dark:bg-white dark:text-black dark:border-gray-200 dark:shadow-xs dark:hover:bg-gray-50 dark:hover:border-gray-300 dark:hover:shadow-md dark:focus:ring-blue-400 dark:focus-visible:ring-blue-400',
    red: 'block rounded-sm bg-red-600 px-3 py-2.5 text-xs font-medium text-white border border-red-500 shadow-xs transition-all duration-200 ease-out hover:bg-red-700 hover:border-red-600 hover:shadow-md active:bg-red-800 active:scale-[0.98] active:shadow-xs focus:outline-hidden focus:ring-2 focus:ring-red-500 focus:ring-offset-2 focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2',
    gray: 'block rounded-sm bg-gray-100 px-3 py-2.5 text-xs font-medium text-gray-700 border border-gray-200 shadow-xs transition-all duration-200 ease-out hover:bg-gray-50 hover:border-gray-300 hover:shadow-md hover:text-gray-900 active:bg-gray-100 active:scale-[0.98] active:shadow-xs focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600 dark:shadow-xs dark:hover:bg-gray-600 dark:hover:border-gray-500 dark:hover:text-white dark:hover:shadow-md dark:focus:ring-blue-400 dark:focus-visible:ring-blue-400',
  };
  return variantClasses[props.variant] || variantClasses.default;
});
</script>


================================================
FILE: src/components/app/AppConfirmation.vue
================================================
<template>
  <TransitionRoot
    as="template"
    :show="isOpen"
  >
    <Dialog
      class="relative z-10"
      @close="cancel"
    >
      <TransitionChild
        as="template"
        enter="ease-out duration-300"
        enter-from="opacity-0"
        enter-to="opacity-100"
        leave="ease-in duration-200"
        leave-from="opacity-100"
        leave-to="opacity-0"
      >
        <div class="fixed inset-0 bg-gray-500/75 transition-opacity" />
      </TransitionChild>
      <div class="fixed inset-0 z-10 w-screen overflow-y-auto">
        <div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
          <TransitionChild
            as="template"
            enter="ease-out duration-300"
            enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
            enter-to="opacity-100 translate-y-0 sm:scale-100"
            leave="ease-in duration-200"
            leave-from="opacity-100 translate-y-0 sm:scale-100"
            leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
          >
            <DialogPanel class="relative overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all dark:bg-black sm:my-8 sm:w-full sm:max-w-lg">
              <div class="px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
                <div class="sm:flex sm:items-start">
                  <div class="mx-auto flex size-12 shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:size-10">
                    <svg
                      class="size-6 text-red-600"
                      aria-hidden="true"
                      xmlns="http://www.w3.org/2000/svg"
                      width="1em"
                      height="1em"
                      viewBox="0 0 26 26"
                    ><path
                      fill="currentColor"
                      d="M13 0C5.925 0 0 5.08 0 11.5c0 3.03 1.359 5.748 3.5 7.781a6.7 6.7 0 0 1-1.094 1.875A16.5 16.5 0 0 1 .375 23.22A1 1 0 0 0 1 25c2.215 0 3.808-.025 5.25-.406c1.29-.342 2.399-1.058 3.531-2.063c1.03.247 2.093.469 3.219.469c7.075 0 13-5.08 13-11.5S20.075 0 13 0m0 2c6.125 0 11 4.32 11 9.5S19.125 21 13 21c-1.089 0-2.22-.188-3.25-.469a1 1 0 0 0-.938.25c-1.125 1.079-1.954 1.582-3.062 1.875c-.51.135-1.494.103-2.188.157c.14-.158.271-.242.407-.407c.786-.96 1.503-1.975 1.719-3.125a1 1 0 0 0-.344-.937C3.249 16.614 2 14.189 2 11.5C2 6.32 6.875 2 13 2m-1.906 3.906a1 1 0 0 0-.469.25l-1.5 1.407l1.344 1.468l1.187-1.125h2.406L15 8.97v1.469l-2.563 1.718A1 1 0 0 0 12 13v2h2v-1.438l2.563-1.718A1 1 0 0 0 17 11V8.594a1 1 0 0 0-.25-.656l-1.5-1.688a1 1 0 0 0-.75-.344h-3.188a1 1 0 0 0-.218 0M12 16v2h2v-2z"
                    /></svg>
                  </div>
                  <div class="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
                    <DialogTitle
                      as="h3"
                      class="text-base font-semibold leading-6 text-black dark:text-white"
                    >
                      <slot name="title" />
                    </DialogTitle>
                    <div class="mt-2">
                      <p class="text-xs text-black dark:text-white">
                        <slot name="description" />
                      </p>
                    </div>
                  </div>
                </div>
              </div>
              <div
                class="flex flex-col gap-2 bg-gray-50 px-4 py-3 dark:bg-neutral-950 sm:flex-row-reverse sm:gap-x-3 sm:gap-y-0 sm:px-6"
              >
                <AppButton
                  variant="red"
                  class="w-full sm:w-auto"
                  @click="confirm"
                >
                  <slot name="confirm" />
                </AppButton>
                <AppButton
                  ref="cancelButtonRef"
                  variant="gray"
                  class="w-full sm:w-auto"
                  @click="cancel"
                >
                  <slot name="cancel" />
                </AppButton>
              </div>
            </DialogPanel>
          </TransitionChild>
        </div>
      </div>
    </Dialog>
  </TransitionRoot>
</template>

<script setup>
import { ref } from 'vue';
import {
  Dialog, DialogPanel, DialogTitle, TransitionChild, TransitionRoot,
} from '@headlessui/vue';
import AppButton from './AppButton.vue';

const isOpen = ref(false);
let resolvePromise = null;

const request = () => new Promise((resolve) => {
  resolvePromise = resolve;
  isOpen.value = true;
});

const close = () => {
  isOpen.value = false;
};

const confirm = () => {
  if (resolvePromise) {
    resolvePromise(true);
  }
  close();
};

const cancel = () => {
  if (resolvePromise) {
    resolvePromise(false);
  }
  close();
};

defineExpose({ request });
</script>


================================================
FILE: src/components/app/AppDrawer.vue
================================================
<template>
  <TransitionRoot
    as="template"
    :show="isOpen"
  >
    <Dialog
      class="relative z-10"
      @close="isOpen = false"
    >
      <TransitionChild
        as="template"
        enter="ease-in-out duration-300"
        enter-from="opacity-0"
        enter-to="opacity-100"
        leave="ease-in-out duration-300"
        leave-from="opacity-100"
        leave-to="opacity-0"
      >
        <div class="fixed inset-0 bg-gray-500/75 transition-opacity" />
      </TransitionChild>

      <div class="fixed inset-0 overflow-hidden">
        <div class="absolute inset-0 overflow-hidden">
          <div class="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10">
            <TransitionChild
              as="template"
              enter="transform transition ease-in-out duration-300 sm:duration-300"
              enter-from="translate-x-full"
              enter-to="translate-x-0"
              leave="transform transition ease-in-out duration-300 sm:duration-300"
              leave-from="translate-x-0"
              leave-to="translate-x-full"
            >
              <DialogPanel class="pointer-events-auto relative w-screen max-w-md">
                <TransitionChild
                  as="template"
                  enter="ease-in-out duration-300"
                  enter-from="opacity-0"
                  enter-to="opacity-100"
                  leave="ease-in-out duration-300"
                  leave-from="opacity-100"
                  leave-to="opacity-0"
                >
                  <div class="absolute left-0 top-0 -ml-8 flex pr-2 pt-4 sm:-ml-10 sm:pr-4">
                    <button
                      type="button"
                      class="relative rounded-md text-gray-300 hover:text-white focus:outline-none focus:ring-2 focus:ring-white"
                      @click="isOpen = false"
                    >
                      <span class="absolute -inset-2.5" />
                      <span class="sr-only">Close panel</span>
                      <svg
                        class="size-6"
                        xmlns="http://www.w3.org/2000/svg"
                        width="1em"
                        height="1em"
                        viewBox="0 0 24 24"
                      ><path
                        fill="currentColor"
                        d="m6.4 18.308l-.708-.708l5.6-5.6l-5.6-5.6l.708-.708l5.6 5.6l5.6-5.6l.708.708l-5.6 5.6l5.6 5.6l-.708.708l-5.6-5.6z"
                      /></svg>
                    </button>
                  </div>
                </TransitionChild>
                <div class="flex h-full flex-col overflow-y-scroll bg-white py-6 shadow-xl dark:bg-black">
                  <div class="px-4 sm:px-6">
                    <DialogTitle class="text-base font-semibold leading-6 text-black dark:text-white">
                      <slot name="title" />
                    </DialogTitle>
                  </div>
                  <div class="relative mt-6 flex flex-1 justify-center  px-4 sm:px-6">
                    <slot name="content" />
                  </div>
                </div>
              </DialogPanel>
            </TransitionChild>
          </div>
        </div>
      </div>
    </Dialog>
  </TransitionRoot>
</template>

<script setup>
import {
  Dialog, DialogPanel, DialogTitle, TransitionChild, TransitionRoot,
} from '@headlessui/vue';
import { ref } from 'vue';

const isOpen = ref(false);

const open = () => {
  isOpen.value = true;
};

const close = () => {
  isOpen.value = false;
};
defineExpose({ open, close });
</script>


================================================
FILE: src/components/app/AppInfiniteScroll.vue
================================================
<template>
  <div ref="scroll">
    <slot />
  </div>
</template>
<script setup>
import { onMounted, onBeforeUnmount, ref, useTemplateRef } from 'vue';

const props = defineProps({ limit: { type: Number, default: 50 } });
const emit = defineEmits(['scroll:end']);
const scrollRef = useTemplateRef('scroll');
const skip = ref(0);
let lastScrollTop = 0;

const scrollUp = () => {
  skip.value = 0;
  scrollRef.value.scrollTo({
    top: 0,
    behavior: 'smooth',
  });
};

const throttle = (func, limit) => {
  let inThrottle = false;
  return (...args) => {
    const context = this;
    if (!inThrottle) {
      func.apply(context, args);
      inThrottle = true;
      setTimeout(() => {
        inThrottle = false;
      }, limit);
    }
  };
};

const onScroll = () => {
  const el = scrollRef.value;
  const currentScrollTop = el.scrollTop;
  const isScrollingDown = currentScrollTop > lastScrollTop;
  lastScrollTop = currentScrollTop;
  if (!isScrollingDown) return;
  if (Math.round(el.offsetHeight + currentScrollTop) >= el.scrollHeight * 0.75) {
    skip.value += parseInt(props.limit, 10);
    emit('scroll:end', skip.value);
  }
};

const throttledScroll = throttle(onScroll, 200);

onMounted(() => scrollRef.value.addEventListener('scroll', throttledScroll));
onBeforeUnmount(() => { scrollRef.value?.removeEventListener('scroll', throttledScroll); });

defineExpose({ scrollRef, scrollUp, skip: skip.value });
</script>


================================================
FILE: src/components/app/AppNotifications.vue
================================================
<template>
  <div class="flex">
    <!-- Error Notifications -->
    <NotificationGroup group="error">
      <div
        class="pointer-events-none fixed inset-0 z-50 flex items-end justify-center p-6"
      >
        <div class="w-full max-w-xs">
          <Notification
            v-slot="{ notifications }"
            enter="transform ease-out duration-300 transition"
            enter-from="translate-y-full opacity-0"
            enter-to="translate-y-0 opacity-100"
            leave="transition ease-in duration-500"
            leave-from="opacity-100"
            leave-to="opacity-0"
            move="transition duration-500"
            move-delay="delay-300"
          >
            <div
              v-for="notification in notifications"
              :key="notification.id"
              role="alert"
              class="mx-auto mt-4 flex w-full max-w-xs items-center gap-3 overflow-hidden rounded-lg border border-neutral-400/30 bg-red-600/60 p-3 text-sm shadow-lg backdrop-blur-md dark:border-white/10 dark:bg-red-800/80 dark:text-white"
            >
              <div class="flex items-center justify-center text-white">
                <svg
                  class="size-5"
                  viewBox="0 0 40 40"
                  xmlns="http://www.w3.org/2000/svg"
                  fill="currentColor"
                >
                  <path
                    d="M20 3.36667C10.8167 3.36667 3.3667 10.8167 3.3667 20C3.3667 29.1833 10.8167 36.6333 20 36.6333C29.1834 36.6333 36.6334 29.1833 36.6334 20C36.6334 10.8167 29.1834 3.36667 20 3.36667ZM19.1334 33.3333V22.9H13.3334L21.6667 6.66667V17.1H27.25L19.1334 33.3333Z"
                  />
                </svg>
              </div>
              <div class="flex-1 text-xs text-white">
                {{ notification.text }}
              </div>
            </div>
          </Notification>
        </div>
      </div>
    </NotificationGroup>

    <!-- Default Notifications -->
    <NotificationGroup group="default">
      <div
        class="pointer-events-none fixed inset-0 z-50 flex items-end justify-center p-6"
      >
        <div class="w-full max-w-xs">
          <Notification
            v-slot="{ notifications }"
            enter="transform ease-out duration-300 transition"
            enter-from="translate-y-full opacity-0"
            enter-to="translate-y-0 opacity-100"
            leave="transition ease-in duration-500"
            leave-from="opacity-100"
            leave-to="opacity-0"
            move="transition duration-500"
            move-delay="delay-300"
          >
            <div
              v-for="notification in notifications"
              :key="notification.id"
              role="alert"
              class="mx-auto mt-4 flex w-full max-w-xs items-center gap-3 overflow-hidden rounded-lg border border-neutral-400/30 bg-green-700/60 p-3 text-sm shadow-lg backdrop-blur-md dark:border-white/10 dark:bg-green-800/80 dark:text-white"
            >
              <div class="flex items-center justify-center text-white">
                <svg
                  class="size-5"
                  viewBox="0 0 40 40"
                  xmlns="http://www.w3.org/2000/svg"
                  fill="currentColor"
                >
                  <path
                    d="M20 3.33331C10.8 3.33331 3.33337 10.8 3.33337 20C3.33337 29.2 10.8 36.6666 20 36.6666C29.2 36.6666 36.6667 29.2 36.6667 20C36.6667 10.8 29.2 3.33331 20 3.33331ZM21.6667 28.3333H18.3334V25H21.6667V28.3333ZM21.6667 21.6666H18.3334V11.6666H21.6667V21.6666Z"
                  />
                </svg>
              </div>
              <div class="flex-1 text-xs text-white">
                {{ notification.text }}
              </div>
            </div>
          </Notification>
        </div>
      </div>
    </NotificationGroup>
  </div>
</template>


================================================
FILE: src/components/app/AppProgress.vue
================================================
<template>
  <div
    class="overflow-hidden rounded-md border border-gray-100 bg-gray-50 p-0.5 shadow-none"
    role="progressbar"
    :aria-valuenow="progress"
    aria-valuemin="0"
    aria-valuemax="100"
    :aria-label="`Progress: ${progress}%`"
  >
    <div class="relative flex items-center justify-center">
      <div
        class="absolute inset-y-0 left-0 rounded-lg bg-black transition-all duration-300 ease-out"
        :style="{ width: progress + '%' }"
      />
      <div class="relative text-xs text-white mix-blend-difference">
        {{ progress }}%
      </div>
    </div>
  </div>
</template>

<script setup>
defineProps({
  progress: {
    type: Number,
    default: 0,
    required: true,
  },
});
</script>


================================================
FILE: src/components/app/AppRadio.vue
================================================
<template>
  <label
    :for="inputId"
    class="group flex cursor-pointer items-center gap-x-2"
  >
    <input
      :id="inputId"
      v-model="model"
      type="radio"
      :name="name"
      :value="value"
      :aria-label="label"
      class="size-4 rounded-full border-gray-300 text-black focus:ring-0 dark:border-neutral-700 dark:bg-neutral-800 dark:checked:bg-black dark:focus:ring-offset-gray-800"
    >
    <span class="text-xs text-black dark:text-white">{{ label }}</span>
  </label>
</template>
<script setup>
import { useId } from 'vue';

const model = defineModel({ type: String, required: true });
defineProps({
  value: {
    type: String,
    required: true,
  },
  label: {
    type: String,
    required: true,
  },
  name: {
    type: String,
    required: true,
  },
});

const inputId = useId();
</script>


================================================
FILE: src/components/app/AppSpinner.vue
================================================
<template>
  <div role="status">
    <svg
      aria-hidden="true"
      class="inline size-8 animate-spin fill-black text-gray-200 dark:fill-gray-300 dark:text-gray-600"
      viewBox="0 0 100 101"
      fill="none"
      xmlns="http://www.w3.org/2000/svg"
    >
      <path
        d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
        fill="currentColor"
      />
      <path
        d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
        fill="currentFill"
      />
    </svg>
    <span class="sr-only">Loading...</span>
  </div>
</template>
<script setup>
</script>


================================================
FILE: src/components/app/AppTagInput.vue
================================================
<template>
  <div
    role="combobox"
    :aria-expanded="showSuggestionContainer"
    aria-haspopup="listbox"
    aria-controls="tag-suggestion-list"
    tabindex="0"
    @click="focus"
    @keydown.enter="focus"
  >
    <div
      class="flex min-h-9 w-full flex-wrap items-center gap-1 whitespace-normal rounded-md border border-gray-200 bg-white px-2 py-1 shadow-sm focus-within:border-gray-300 dark:border-neutral-800 dark:bg-neutral-900 dark:text-white focus-within:dark:border-neutral-700"
      aria-label="Tag input"
    >
      <AppBadge
        v-for="(value, index) in tags"
        :key="index"
        closable
        :data-tag="value"
        @on-close="remove(index)"
      >
        <span class="whitespace-nowrap text-xs">{{ value }}</span>
      </AppBadge>
      <input
        ref="inputRef"
        v-model="tag"
        class="min-w-[20%] flex-1 appearance-none border-0 bg-transparent px-1 py-0 text-xs placeholder:text-xs focus:outline-none focus:ring-0"
        type="text"
        maxlength="25"
        :placeholder="tags.length ? '' : placeholder"
        aria-label="Tag input"
        @keydown.enter.prevent="add"
        @keydown.delete="removeLast"
        @keydown.arrow-up.prevent="arrowUp"
        @keydown.arrow-down.prevent="arrowDown"
        @keydown.escape="hideSuggestions"
      >
    </div>
    <transition
      enter-active-class="transition duration-200 ease-out"
      enter-from-class="translate-y-1 opacity-0"
      enter-to-class="translate-y-0 opacity-100"
      leave-active-class="transition duration-150 ease-in"
      leave-from-class="translate-y-0 opacity-100"
      leave-to-class="translate-y-1 opacity-0"
    >
      <div
        v-if="showSuggestionContainer"
        id="tag-suggestion-list"
        class="z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white text-xs shadow-lg ring-1 ring-black/5 focus:outline-none dark:bg-neutral-900 dark:text-neutral-400"
        role="listbox"
      >
        <ul>
          <li
            v-for="(suggestion, index) in filteredSuggestions"
            :id="`suggestion-${index}`"
            :key="index"
            ref="suggestionRef"
            class="block cursor-pointer px-4 py-2 hover:bg-gray-100 dark:hover:bg-neutral-800"
            :class="{'bg-neutral-100 dark:bg-neutral-800': highlightedSuggestionIndex === index }"
            role="option"
            :aria-selected="highlightedSuggestionIndex === index"
            tabindex="-1"
            @click="add"
            @mouseenter="highlightedSuggestionIndex = index"
            @focus="highlightedSuggestionIndex = index"
            @focusin="highlightedSuggestionIndex = index"
            @keydown.enter="add"
            @keydown.space.prevent="add"
          >
            <div class="inline-flex items-center gap-x-1 dark:text-white">
              <PhHashStraightLight class="size-4" />
              <span>{{ suggestion }}</span>
            </div>
          </li>
        </ul>
      </div>
    </transition>
  </div>
</template>

<script setup>
import {
  ref, computed, watch, onMounted, onUnmounted, nextTick,
} from 'vue';
import AppBadge from '@/components/app/AppBadge.vue';
import PhHashStraightLight from '~icons/ph/hash-straight-light';

const props = defineProps({
  max: { type: Number, default: 5 },
  placeholder: { type: String, default: 'Enter a tag' },
  modelValue: { type: Array, default: () => [] },
  suggestions: { type: Array, default: () => [] },
});

const showSuggestionContainer = ref(false);
const highlightedSuggestionIndex = ref(-1);
const inputRef = ref(null);
const emit = defineEmits(['update:modelValue']);
const tag = ref('');
const suggestionRef = ref([]);

const tags = computed({
  get: () => props.modelValue,
  set: (value) => emit('update:modelValue', value),
});

const focus = () => {
  nextTick(() => {
    if (inputRef.value) {
      inputRef.value.focus();
    }
  });
};

const remove = (index) => {
  tags.value.splice(index, 1);
};

const removeLast = () => {
  if (!tag.value) tags.value.pop();
};

const filteredSuggestions = computed(() => (tag.value === '' ? [] : props.suggestions.filter((suggestion) => suggestion.toLowerCase().includes(tag.value.toLowerCase()))));

const add = () => {
  const value = highlightedSuggestionIndex.value !== -1
    ? filteredSuggestions.value[highlightedSuggestionIndex.value]
    : tag.value;

  if (value && !tags.value.includes(value) && tags.value.length < props.max) {
    tags.value.push(value);
  }
  tag.value = '';
  showSuggestionContainer.value = false;
};

const scrollIntoView = () => {
  const currentElement = suggestionRef.value[highlightedSuggestionIndex.value];
  if (currentElement) {
    currentElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
  }
};

const arrowUp = () => {
  if (highlightedSuggestionIndex.value !== 0) {
    highlightedSuggestionIndex.value -= 1;
    scrollIntoView();
  }
};

const arrowDown = () => {
  if (filteredSuggestions.value.length > highlightedSuggestionIndex.value + 1) {
    highlightedSuggestionIndex.value += 1;
    scrollIntoView();
  }
};

const hideSuggestions = () => {
  showSuggestionContainer.value = false;
};

watch(tag, () => {
  highlightedSuggestionIndex.value = -1;
  showSuggestionContainer.value = tag.value && filteredSuggestions.value.length > 0;
});

onMounted(() => {
  document.addEventListener('click', hideSuggestions);
});

onUnmounted(() => {
  document.removeEventListener('click', hideSuggestions);
});
</script>


================================================
FILE: src/composables/useColorExtraction.js
================================================
import { FastAverageColor } from 'fast-average-color';
import { ref } from 'vue';

export default function useColorExtraction() {
  const placeholder = ref({
    background: 'radial-gradient(ellipse at 50% 40%, rgba(200, 200, 220, 0.5) 0%, rgba(180, 180, 200, 0.3) 50%, rgba(160, 160, 180, 0.15) 100%)',
    transition: 'background 0.8s ease',
  });

  const extract = async (url, cacheKey) => {
    if (!url) return;
    const cached = localStorage.getItem(cacheKey);
    if (cached) {
      const style = JSON.parse(cached);
      placeholder.value = style.placeholder;
      return;
    }
    try {
      const fac = new FastAverageColor();
      const color = await fac.getColorAsync(url);
      const [r, g, b] = color.value;
      const saturate = (v) => Math.min(255, Math.round(v * 1.2));
      const style = {
        placeholder: {
          background: `radial-gradient(ellipse at 50% 40%, rgba(${saturate(r)}, ${saturate(g)}, ${saturate(b)}, 0.5) 0%, rgba(${r}, ${g}, ${b}, 0.3) 50%, rgba(${r}, ${g}, ${b}, 0.15) 100%)`,
          transition: 'background 0.8s ease',
        },
      };
      localStorage.setItem(cacheKey, JSON.stringify(style));
      placeholder.value = style.placeholder;
    } catch (e) {
      console.warn('Color extraction failed, using default', e);
    }
  };

  return { placeholder, extract };
}


================================================
FILE: src/constants/app.js
================================================
export const PAGINATION_LIMIT = 100;
export const NOTIFICATION_DURATION = 3000;


================================================
FILE: src/constants/httpStatus.js
================================================
export const HTTP_STATUS = {
  CONTINUE: 100,
  SWITCHING_PROTOCOLS: 101,
  PROCESSING: 102,
  EARLY_HINTS: 103,

  OK: 200,
  CREATED: 201,
  ACCEPTED: 202,
  NON_AUTHORITATIVE_INFORMATION: 203,
  NO_CONTENT: 204,
  RESET_CONTENT: 205,
  PARTIAL_CONTENT: 206,
  MULTI_STATUS: 207,
  ALREADY_REPORTED: 208,
  IM_USED: 226,

  MULTIPLE_CHOICES: 300,
  MOVED_PERMANENTLY: 301,
  FOUND: 302,
  SEE_OTHER: 303,
  NOT_MODIFIED: 304,
  USE_PROXY: 305,
  TEMPORARY_REDIRECT: 307,
  PERMANENT_REDIRECT: 308,

  BAD_REQUEST: 400,
  UNAUTHORIZED: 401,
  PAYMENT_REQUIRED: 402,
  FORBIDDEN: 403,
  NOT_FOUND: 404,
  METHOD_NOT_ALLOWED: 405,
  NOT_ACCEPTABLE: 406,
  PROXY_AUTHENTICATION_REQUIRED: 407,
  REQUEST_TIMEOUT: 408,
  CONFLICT: 409,
  GONE: 410,
  LENGTH_REQUIRED: 411,
  PRECONDITION_FAILED: 412,
  CONTENT_TOO_LARGE: 413,
  URI_TOO_LONG: 414,
  UNSUPPORTED_MEDIA_TYPE: 415,
  RANGE_NOT_SATISFIABLE: 416,
  EXPECTATION_FAILED: 417,
  IM_A_TEAPOT: 418,
  MISDIRECTED_REQUEST: 421,
  UNPROCESSABLE_ENTITY: 422,
  LOCKED: 423,
  FAILED_DEPENDENCY: 424,
  TOO_EARLY: 425,
  UPGRADE_REQUIRED: 426,
  PRECONDITION_REQUIRED: 428,
  TOO_MANY_REQUESTS: 429,
  REQUEST_HEADER_FIELDS_TOO_LARGE: 431,

  INTERNAL_SERVER_ERROR: 500,
  NOT_IMPLEMENTED: 501,
  BAD_GATEWAY: 502,
  SERVICE_UNAVAILABLE: 503,
  GATEWAY_TIMEOUT: 504,
  HTTP_VERSION_NOT_SUPPORTED: 505,
  VARIANT_ALSO_NEGOTIATES: 506,
  INSUFFICIENT_STORAGE: 507,
  LOOP_DETECTED: 508,
  NOT_EXTENDED: 510,
  NETWORK_AUTHENTICATION_REQUIRED: 511,
  UNKNOWN_ERROR: 520,
  WEB_SERVER_IS_DOWN: 521,
  NETWORK_TIMEOUT_ERROR: 599,
};

export const STATUS_MESSAGE = new Map([
  [HTTP_STATUS.CONTINUE, 'Continue'],
  [HTTP_STATUS.SWITCHING_PROTOCOLS, 'Switching Protocols'],
  [HTTP_STATUS.PROCESSING, 'Processing'],
  [HTTP_STATUS.EARLY_HINTS, 'Early Hints'],

  [HTTP_STATUS.OK, 'OK'],
  [HTTP_STATUS.CREATED, 'Created'],
  [HTTP_STATUS.ACCEPTED, 'Accepted'],
  [HTTP_STATUS.NON_AUTHORITATIVE_INFORMATION, 'Non-Authoritative Information'],
  [HTTP_STATUS.NO_CONTENT, 'No Content'],
  [HTTP_STATUS.RESET_CONTENT, 'Reset Content'],
  [HTTP_STATUS.PARTIAL_CONTENT, 'Partial Content'],
  [HTTP_STATUS.MULTI_STATUS, 'Multi-Status'],
  [HTTP_STATUS.ALREADY_REPORTED, 'Already Reported'],
  [HTTP_STATUS.IM_USED, 'IM Used'],

  [HTTP_STATUS.MULTIPLE_CHOICES, 'Multiple Choices'],
  [HTTP_STATUS.MOVED_PERMANENTLY, 'Moved Permanently'],
  [HTTP_STATUS.FOUND, 'Found'],
  [HTTP_STATUS.SEE_OTHER, 'See Other'],
  [HTTP_STATUS.NOT_MODIFIED, 'Not Modified'],
  [HTTP_STATUS.USE_PROXY, 'Use Proxy'],
  [HTTP_STATUS.TEMPORARY_REDIRECT, 'Temporary Redirect'],
  [HTTP_STATUS.PERMANENT_REDIRECT, 'Permanent Redirect'],

  [HTTP_STATUS.BAD_REQUEST, 'Bad Request'],
  [HTTP_STATUS.UNAUTHORIZED, 'Unauthorized'],
  [HTTP_STATUS.PAYMENT_REQUIRED, 'Payment Required'],
  [HTTP_STATUS.FORBIDDEN, 'Forbidden'],
  [HTTP_STATUS.NOT_FOUND, 'Not Found'],
  [HTTP_STATUS.METHOD_NOT_ALLOWED, 'Method Not Allowed'],
  [HTTP_STATUS.NOT_ACCEPTABLE, 'Not Acceptable'],
  [HTTP_STATUS.PROXY_AUTHENTICATION_REQUIRED, 'Proxy Authentication Required'],
  [HTTP_STATUS.REQUEST_TIMEOUT, 'Request Timeout'],
  [HTTP_STATUS.CONFLICT, 'Conflict'],
  [HTTP_STATUS.GONE, 'Gone'],
  [HTTP_STATUS.LENGTH_REQUIRED, 'Length Required'],
  [HTTP_STATUS.PRECONDITION_FAILED, 'Precondition Failed'],
  [HTTP_STATUS.CONTENT_TOO_LARGE, 'Content Too Large'],
  [HTTP_STATUS.URI_TOO_LONG, 'URI Too Long'],
  [HTTP_STATUS.UNSUPPORTED_MEDIA_TYPE, 'Unsupported Media Type'],
  [HTTP_STATUS.RANGE_NOT_SATISFIABLE, 'Range Not Satisfiable'],
  [HTTP_STATUS.EXPECTATION_FAILED, 'Expectation Failed'],
  [HTTP_STATUS.IM_A_TEAPOT, 'I\'m a teapot'],
  [HTTP_STATUS.MISDIRECTED_REQUEST, 'Misdirected Request'],
  [HTTP_STATUS.UNPROCESSABLE_ENTITY, 'Unprocessable Entity'],
  [HTTP_STATUS.LOCKED, 'Locked'],
  [HTTP_STATUS.FAILED_DEPENDENCY, 'Failed Dependency'],
  [HTTP_STATUS.TOO_EARLY, 'Too Early'],
  [HTTP_STATUS.UPGRADE_REQUIRED, 'Upgrade Required'],
  [HTTP_STATUS.PRECONDITION_REQUIRED, 'Precondition Required'],
  [HTTP_STATUS.TOO_MANY_REQUESTS, 'Too Many Requests'],
  [HTTP_STATUS.REQUEST_HEADER_FIELDS_TOO_LARGE, 'Request Header Fields Too Large'],

  [HTTP_STATUS.INTERNAL_SERVER_ERROR, 'Internal Server Error'],
  [HTTP_STATUS.NOT_IMPLEMENTED, 'Not Implemented'],
  [HTTP_STATUS.BAD_GATEWAY, 'Bad Gateway'],
  [HTTP_STATUS.SERVICE_UNAVAILABLE, 'Service Unavailable'],
  [HTTP_STATUS.GATEWAY_TIMEOUT, 'Gateway Timeout'],
  [HTTP_STATUS.HTTP_VERSION_NOT_SUPPORTED, 'HTTP Version Not Supported'],
  [HTTP_STATUS.VARIANT_ALSO_NEGOTIATES, 'Variant Also Negotiates'],
  [HTTP_STATUS.INSUFFICIENT_STORAGE, 'Insufficient Storage'],
  [HTTP_STATUS.LOOP_DETECTED, 'Loop Detected'],
  [HTTP_STATUS.NOT_EXTENDED, 'Not Extended'],
  [HTTP_STATUS.NETWORK_AUTHENTICATION_REQUIRED, 'Network Authentication Required'],
  [HTTP_STATUS.UNKNOWN_ERROR, 'Unknown error. Data retrieval failed. Check bookmarks manually; data may be incomplete.'],
  [HTTP_STATUS.WEB_SERVER_IS_DOWN, ' Web server is down'],
  [HTTP_STATUS.NETWORK_TIMEOUT_ERROR, 'Network Timeout Error'],
]);


================================================
FILE: src/ext/browser/app.js
================================================
import { createApp } from 'vue';
import masonry from 'vue-next-masonry';
import Notifications from 'notiwind';
import FloatingVue from 'floating-vue';
import router from './router';
import AppLayout from './layouts/AppLayout.vue';
import 'floating-vue/dist/style.css';
import '@zanmato/vue3-treeselect/dist/vue3-treeselect.min.css';
import '@fontsource/sn-pro';
import '@vuepic/vue-datepicker/dist/main.css';
import '@/assets/app.css';

const app = createApp(AppLayout)
  .use(router)
  .use(masonry)
  .use(Notifications)
  .use(FloatingVue);

app.mount('#app');


================================================
FILE: src/ext/browser/components/ASide.vue
================================================
<template>
  <aside
    class="flex flex-col h-full min-h-0 w-14 min-w-14 border-soft-900 bg-soft-50 shadow-inner dark:border-r dark:border-neutral-900 dark:bg-neutral-950 relative"
  >
    <div class="flex flex-col items-center pt-3 pb-2 shrink-0">
      <RiBookmarkFill class="size-8 fill-black text-black dark:fill-white dark:text-white" />
    </div>
    <div
      ref="indicatorRef"
      class="absolute left-1/2 -translate-x-1/2 size-10 rounded-md bg-gray-400/20 transition-all duration-500 dark:bg-neutral-800 pointer-events-none z-10"
    />
    <ul class="flex-1 flex flex-col items-center justify-center gap-y-8 overflow-auto py-4 min-h-[60px] max-h-full relative z-10">
      <li
        v-for="(item, key) in items"
        :key="item.key"
        :ref="el => setMenuItemRef(el, item.name)"
      >
        <RouterLink
          :key="key"
          v-tooltip.right="{ content: item.tooltip }"
          :to="{ name: item.name }"
          class="relative"
          tabindex="0"
          @click="handleClick"
          @keydown.enter="handleClick"
        >
          <component
            :is="item.icon"
            class="pointer-events-none size-6 text-black dark:text-white"
          />
        </RouterLink>
      </li>
    </ul>
    <div class="flex flex-col items-center gap-y-5 py-3 mt-auto shrink-0">
      <ThemeMode
        v-tooltip.right="{ content: 'Theme' }"
      />
      <a
        v-tooltip.right="{ content: 'GitHub' }"
        href="https://github.com/dd3v/favbox"
        target="_blank"
        aria-label="GitHub repository"
      >
        <IconoirGithub class="size-4 text-soft-900 hover:text-black dark:text-white dark:hover:text-white" />
      </a>
    </div>
  </aside>
</template>
<script setup>
import {
  defineProps, ref, onMounted, onBeforeUnmount, reactive, watch,
} from 'vue';
import { useRoute } from 'vue-router';

import ThemeMode from '@/ext/browser/components/ThemeMode.vue';
import RiBookmarkFill from '~icons/ri/bookmark-fill';
import IconoirGithub from '~icons/iconoir/github';

defineProps({
  items: {
    type: Array,
    required: true,
  },
});

const route = useRoute();
const indicatorRef = ref(null);
const menuItemsRef = reactive({});

const setMenuItemRef = (el, name) => {
  menuItemsRef[name] = el;
};

const setIndicatorPosition = (element) => {
  const targetRect = element.getBoundingClientRect();
  const center = targetRect.top + targetRect.height / 2;
  indicatorRef.value.style.top = `${center - (indicatorRef.value.offsetHeight / 2)}px`;
};

const moveButton = () => {
  const currentItem = menuItemsRef[route.name];
  if (currentItem) {
    setIndicatorPosition(currentItem);
  }
};

const handleClick = (event) => {
  const targetElement = event.currentTarget;
  setIndicatorPosition(targetElement);
};

onMounted(() => {
  window.addEventListener('resize', moveButton);
});

onBeforeUnmount(() => {
  window.removeEventListener('resize', moveButton);
});

watch(() => route.name, () => {
  moveButton();
}, { immediate: true });
</script>
<style scoped>
aside {
  box-shadow: inset 0 2px 16px 0 rgba(60,60,60,0.14);
}
.dark aside {
  box-shadow: none;
}
</style>


================================================
FILE: src/ext/browser/components/AttributeList.vue
================================================
<template>
  <div class="flex h-full flex-col">
    <div class="flex w-full">
      <div class="relative w-full">
        <div class="absolute top-2 flex items-center pl-2">
          <MaterialSymbolsLightCategorySearchOutline class="size-5 text-gray-400 dark:text-white" />
        </div>
        <input
          v-model="term"
          autocomplete="off"
          type="text"
          aria-label="Search attributes"
          class="mb-2 h-9 w-full rounded-md border-1 border-gray-300/50 px-9 text-xs text-black shadow-sm outline-none placeholder:text-xs focus:border-gray-400/30 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-900 dark:text-white focus:dark:border-neutral-700"
        >
        <Popover class="relative">
          <PopoverButton
            ref="popoverButtonRef"
            class="pointer-events-auto absolute inset-y-1 -top-12 right-0 flex items-center pr-2 focus:outline-none focus:ring-0"
          >
            <div class="flex flex-wrap items-center gap-x-1 text-sm text-gray-400 dark:text-neutral-600">
              <kbd class="inline-flex size-6 items-center justify-center rounded-md border border-gray-200 bg-white font-mono text-lg shadow-sm dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200">
                ⌘
              </kbd>
              <kbd class="inline-flex size-6 items-center justify-center rounded-md border border-gray-200 bg-white font-mono text-xs shadow-sm dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200">
                /
              </kbd>
            </div>
          </PopoverButton>
          <Transition
            enter-active-class="transition duration-200 ease-out"
            enter-from-class="translate-y-1 opacity-0"
            enter-to-class="translate-y-0 opacity-100"
            leave-active-class="transition duration-150 ease-in"
            leave-from-class="translate-y-0 opacity-100"
            leave-to-class="translate-y-1 opacity-0"
          >
            <PopoverPanel
              class="absolute right-1 z-50 mt-2 w-56 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black/5 dark:bg-neutral-900 dark:text-neutral-400"
            >
              <div class="relative">
                <div class="flex flex-col gap-y-3 p-4">
                  <div class="flex items-center justify-between">
                    <div class="flex items-center gap-x-2">
                      <PhArrowsDownUp class="size-5 text-gray-800 dark:text-gray-200" />
                      <h3 class="text-xs text-gray-800 dark:text-gray-200">
                        Sort By
                      </h3>
                    </div>
                    <button
                      class="flex size-6 items-center justify-center rounded-md text-gray-400 hover:bg-gray-100 hover:text-gray-600 focus:outline-none dark:text-neutral-500 dark:hover:bg-neutral-800 dark:hover:text-neutral-300 -mt-1 -mr-1"
                      @click="close"
                    >
                      <PhX class="size-4" />
                    </button>
                  </div>

                  <div class="flex flex-col gap-y-3">
                    <AppRadio
                      v-model="sort"
                      label="Name ↑ (A-Z)"
                      value="value:asc"
                      name="sort"
                    />
                    <AppRadio
                      v-model="sort"
                      label="Name ↓ (Z-A)"
                      value="value:desc"
                      name="sort"
                    />
                    <AppRadio
                      v-model="sort"
                      label="Count ↑ (0-9)"
                      value="count:asc"
                      name="sort"
                    />
                    <AppRadio
                      v-model="sort"
                      label="Count ↓ (9-0)"
                      value="count:desc"
                      name="sort"
                    />
                  </div>

                  <div class="border-t border-gray-200 dark:border-gray-700" />

                  <div class="flex items-center gap-x-2">
                    <PhListChecks class="size-5 text-gray-800 dark:text-white" />
                    <h3 class="text-xs text-black dark:text-white">
                      Includes
                    </h3>
                  </div>

                  <div class="flex flex-col gap-y-3">
                    <SwitchGroup
                      v-for="(value, key) in includes"
                      :key="key"
                    >
                      <div class="flex items-center justify-between">
                        <SwitchLabel class="flex items-center gap-x-1">
                          <AppBullet
                            :size="3"
                            :color="getColor(key)"
                          />
                          <span class="text-xs text-black dark:text-white">{{ key.charAt(0).toUpperCase() + key.slice(1) }}</span>
                        </SwitchLabel>
                        <Switch
                          v-model="includes[key]"
                          :class="value ? 'bg-black dark:bg-neutral-500' : 'bg-neutral-200 dark:bg-neutral-700'"
                          class="relative inline-flex h-4 w-7 items-center rounded-full transition-colors duration-150"
                        >
                          <span class="sr-only">{{ key }}</span>
                          <span
                            :class="value ? 'translate-x-3.5' : 'translate-x-0.5'"
                            class="inline-block size-3 rounded-full bg-white transition-transform duration-150"
                          />
                        </Switch>
                      </div>
                    </SwitchGroup>
                  </div>
                </div>
              </div>
            </PopoverPanel>
          </Transition>
        </Popover>
      </div>
    </div>
    <AppInfiniteScroll
      ref="scrollRef"
      :limit="200"
      class="list-view flex h-full scroll-p-0.5 flex-col overflow-y-auto overflow-x-hidden py-1 text-xs"
      @scroll:end="paginate"
    >
      <ul
        role="listbox"
        aria-label="Filter options"
        class="w-full"
      >
        <li
          v-for="(item, key) in list"
          :key="item.id + key"
          role="option"
          :aria-selected="selected(item.key, item.value)"
          class="w-full"
        >
          <label
            :key="item.id + key"
            :for="item.id + key"
            :class="{'bg-neutral-100 dark:bg-neutral-900': selected(item.key, item.value)}"
            class="my-1 flex cursor-pointer place-items-end items-center px-3 py-2 text-gray-700 hover:bg-neutral-100 dark:text-neutral-400 dark:hover:bg-neutral-900 focus:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-gray-300 dark:focus-visible:ring-gray-600 focus-visible:ring-offset-0 rounded-md"
            tabindex="0"
            role="button"
            @keydown.enter="update(item)"
            @keydown.space.prevent="update(item)"
          >
            <component
              :is="getIcon(item)"
              v-tooltip.top="{ content: getTooltip(item) }"
              class="size-4 select-none focus:outline-none"
              tabindex="-1"
            />
            <input
              :id="item.id + key"
              type="checkbox"
              class="hidden"
              name="item"
              :value="item.value"
              :checked="selected(item.key, item.value)"
              @input="update(item)"
            >
            <span class="truncate px-1"> {{ item.label || item.value }} </span>
            <span
              class="ml-auto"
            >{{ item.count }}</span>
          </label>
        </li>
      </ul>
    </AppInfiniteScroll>
  </div>
</template>
<script setup>
import {
  Popover, PopoverButton, PopoverPanel, SwitchGroup, SwitchLabel,
  Switch,
} from '@headlessui/vue';
import {
  computed, defineModel, onMounted, ref, onBeforeUnmount, watch,
} from 'vue';
import AppRadio from '@/components/app/AppRadio.vue';
import AppBullet from '@/components/app/AppBullet.vue';
import AppInfiniteScroll from '@/components/app/AppInfiniteScroll.vue';
import PhListChecks from '~icons/ph/list-checks';
import PhArrowsDownUp from '~icons/ph/arrows-down-up';
import MaterialSymbolsLightCategorySearchOutline from '~icons/material-symbols-light/category-search-outline';
import PhHashStraightLight from '~icons/ph/hash-straight-light';
import PhGlobeSimpleLight from '~icons/ph/globe-simple-light';
import PhListMagnifyingGlassLight from '~icons/ph/list-magnifying-glass-light';
import PhX from '~icons/ph/x';

const emit = defineEmits(['update:modelValue', 'paginate']);

const sort = defineModel('sort', { type: String, required: true });
const includes = defineModel('includes', { type: Object, required: true });
const term = defineModel('term', { type: String, required: true });

const props = defineProps({
  modelValue: {
    type: Array,
    required: true,
  },
  items: {
    type: Array,
    required: true,
  },
});

const iconMap = {
  keyword: PhListMagnifyingGlassLight,
  domain: PhGlobeSimpleLight,
  tag: PhHashStraightLight,
};

const tooltipMap = {
  keyword: 'Filter by keywords',
  domain: 'Filter by website',
  tag: 'Filter by tag',
};

const popoverButtonRef = ref(null);
const scrollRef = ref(null);

const getIcon = (item) => iconMap[item.key];

const getTooltip = (item) => tooltipMap[item.key];

const selected = (key, value) => props.modelValue.some((item) => item.key === key && item.value === value);

const list = computed(() => props.items);

const paginate = (skip) => {
  emit('paginate', skip);
};

const getColor = (key) => {
  switch (key) {
    case 'domain':
      return 'yellow';
    case 'tag':
      return 'gray';
    case 'keyword':
      return 'green';
    case 'locale':
      return 'cyan';
    case 'type':
      return 'indigo';
    default:
      return 'stone';
  }
};

const update = (item) => {
  const updatedModelValue = [...props.modelValue];
  const index = updatedModelValue.findIndex((f) => f.key === item.key && f.value === item.value);
  if (index !== -1) {
    updatedModelValue.splice(index, 1);
  } else {
    updatedModelValue.push({ key: item.key, value: item.value, label: item.label || item.value });
  }
  emit('update:modelValue', updatedModelValue);
};

const close = () => {
  popoverButtonRef.value.el.click();
};

const popoverKbd = (event) => {
  if ((event.metaKey || event.ctrlKey) && event.key === '/') {
    event.preventDefault();
    close();
  }
};

onMounted(() => {
  document.addEventListener('keydown', popoverKbd);
});

onBeforeUnmount(() => {
  document.removeEventListener('keydown', popoverKbd);
});

watch([sort, includes, term], () => {
  scrollRef.value.scrollUp();
});
</script>


================================================
FILE: src/ext/browser/components/BookmarkFavicon.vue
================================================
<template>
  <div>
    <img
      v-if="faviconUrl"
      v-show="loaded"
      :key="faviconUrl"
      class="size-full"
      :src="faviconUrl"
      alt="favicon"
      @load="loaded = true"
      @error="handleError"
    >

    <PhGlobeSimpleLight
      v-if="!loaded"
      class="size-4 text-black dark:text-white"
    />
  </div>
</template>

<script setup>
import { ref, computed } from 'vue';
import PhGlobeSimpleLight from '~icons/ph/globe-simple-light';

const props = defineProps({
  bookmark: {
    type: Object,
    required: true,
  },
});

const loaded = ref(false);
const originalFailed = ref(false);
const fallbackFailed = ref(false);

const faviconUrl = computed(() => {
  if (props.bookmark.favicon && !originalFailed.value) {
    return props.bookmark.favicon;
  }

  if (!fallbackFailed.value && props.bookmark.domain) {
    return `https://icons.duckduckgo.com/ip3/${encodeURIComponent(props.bookmark.domain)}.ico`;
  }

  return null;
});

const handleError = () => {
  loaded.value = false;

  if (!originalFailed.value) {
    originalFailed.value = true;
  } else {
    fallbackFailed.value = true;
  }
};
</script>


================================================
FILE: src/ext/browser/components/BookmarkForm.vue
================================================
<template>
  <form
    class="flex flex-col gap-y-3"
    @submit.prevent="submit"
  >
    <label
      for="title"
      class="relative"
    >
      <input
        id="title"
        v-model="bookmark.title"
        type="text"
        placeholder="Page title"
        class="h-9 w-full rounded-md border-gray-200 pl-10 text-xs text-black shadow-sm outline-none focus:border-gray-300 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-900 dark:text-white focus:dark:border-neutral-700"
      >
      <div class="pointer-events-none absolute inset-y-0 left-0 grid w-10 place-content-center text-gray-700">
        <BookmarkFavicon
          :bookmark="bookmark"
          class="size-5 fill-black"
        />
      </div>
    </label>
    <Treeselect
      v-model="bookmark.folderId"
      placeholder=""
      :before-clear-all="onBeforeClearAll"
      :always-open="false"
      :default-expand-level="Infinity"
      :options="folders"
    />
    <AppTagInput
      v-model="bookmark.tags"
      class="relative"
      :max="5"
      :suggestions="tags"
      placeholder="Tag it and press enter 🏷️"
    />
    <AppButton class="w-full">
      Save bookmark
    </AppButton>
  </form>
</template>

<script setup>
import { ref, watch } from 'vue';
import Treeselect from '@zanmato/vue3-treeselect';
import AppTagInput from '@/components/app/AppTagInput.vue';
import BookmarkFavicon from '@/ext/browser/components/BookmarkFavicon.vue';
import AppButton from '@/components/app/AppButton.vue';
import { joinTitleAndTags } from '@/services/tags';

const props = defineProps({
  bookmark: {
    type: Object,
    required: true,
  },
  folders: {
    type: Array,
    required: true,
    default: () => [],
  },
  tags: {
    type: Array,
    required: true,
    default: () => [],
  },
});

const bookmark = ref({ ...props.bookmark });
const emit = defineEmits(['onSubmit']);

const findLabelById = (data, id) => {
  for (const item of data) {
    if (item.id === id) {
      return item.label;
    }
    if (item.children) {
      const result = findLabelById(item.children, id);
      if (result) {
        return result;
      }
    }
  }
  return null;
};

const onBeforeClearAll = () => {
  bookmark.value.folderId = 1;
};

const submit = () => {
  const value = JSON.parse(JSON.stringify(bookmark.value));
  emit('onSubmit', {
    browserTitle: joinTitleAndTags(value.title, value.tags),
    title: value.title,
    folderName: value.folderName,
    folderId: value.folderId,
    tags: value.tags,
    id: value.id,
  });
};

watch(() => bookmark.value.folderId, (newId) => {
  bookmark.value.folderName = findLabelById(props.folders, newId);
}, { immediate: true });

</script>


================================================
FILE: src/ext/browser/components/BookmarkLayout.vue
================================================
<template>
  <masonry
    v-if="displayType === 'masonry'"
    :resolve-slot="true"
    :cols="{ 5120: 12, 3840: 10, 2560: 7, 1920: 4, 1280: 4, 992: 3, 768: 2, 576: 1 }"
    :gutter="20"
    class="card-container"
  >
    <slot />
  </masonry>
  <TransitionGroup
    v-else
    tag="div"
    appear
    enter-active-class="transition-opacity duration-200 ease-out"
    enter-from-class="opacity-0"
    enter-to-class="opacity-100"
    :class="layoutClasses"
  >
    <slot />
  </TransitionGroup>
</template>
<script setup>
import { computed } from 'vue';

const props = defineProps({
  displayType: {
    type: String,
    required: true,
    default: 'masonry',
    validator: (value) => ['masonry', 'card', 'list'].includes(value),
  },
});

const LAYOUT_CLASSES = {
  masonry: '',
  card: 'grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5',
  list: 'grid gap-3 grid-cols-1',
};

const layoutClasses = computed(() => LAYOUT_CLASSES[props.displayType] || LAYOUT_CLASSES.masonry);
</script>


================================================
FILE: src/ext/browser/components/BookmarksSync.vue
================================================
<template>
  <div>
    <TransitionRoot
      appear
      :show="isOpen"
      as="template"
    >
      <Dialog
        as="div"
        class="relative z-10"
        @close="close"
      >
        <TransitionChild
          as="template"
          enter="duration-300 ease-out"
          enter-from="opacity-0"
          enter-to="opacity-100"
          leave="duration-200 ease-in"
          leave-from="opacity-100"
          leave-to="opacity-0"
        >
          <div class="fixed inset-0 bg-[rgba(255,_255,_255,_0.19)] backdrop-blur-[13px] backdrop-saturate-[200%]" />
        </TransitionChild>
        <div class="fixed inset-0 overflow-y-auto">
          <div
            class="flex min-h-full items-center justify-center p-4 text-center"
          >
            <TransitionChild
              as="template"
              enter="duration-300 ease-out"
              enter-from="opacity-0 scale-95"
              enter-to="opacity-100 scale-100"
              leave="duration-200 ease-in"
              leave-from="opacity-100 scale-100"
              leave-to="opacity-0 scale-95"
            >
              <DialogPanel
                class="w-full max-w-md overflow-hidden rounded-lg bg-white p-6 text-left align-middle shadow-xl transition-all dark:bg-black"
              >
                <DialogTitle
                  as="h3"
                  class="flex items-center text-lg font-medium leading-6 text-gray-900 dark:text-white"
                >
                  <PixelarticonsHeart class="mr-2" />
                  Scanning your bookmarks..
                </DialogTitle>
                <div class="mt-2">
                  <p class="py-1 text-sm text-black dark:text-white">
                    The app is scanning your bookmarks and gathering information about the pages to make everything run smoother and faster.
                    This process may take a little time depending on how many bookmarks you have, your network and device performance.
                  </p>
                  <p class="py-4 text-sm text-black dark:text-white">
                    Thank you for your patience!
                  </p>
                  <AppProgress
                    :progress="progress"
                  />
                </div>
                <div class="mt-4 flex justify-end">
                  <AppButton @click="close">
                    OK
                  </AppButton>
                </div>
              </DialogPanel>
            </TransitionChild>
          </div>
        </div>
      </Dialog>
    </TransitionRoot>
    <div
      v-if="!isOpen && isSyncing"
      class="fixed bottom-4 right-4 z-50"
    >
      <button
        class="relative size-16 cursor-pointer rounded-full bg-black from-pink-500 via-blue-500 to-green-500 font-mono text-xs text-white shadow-[0_0_0_4px_rgba(180,160,255,0.253)] transition-all duration-300 after:absolute after:inset-0 after:-z-10 after:rounded-full after:bg-gradient-to-r after:opacity-60 after:blur-md hover:shadow-[0_0_20px_rgba(180,160,255,0.5)] hover:after:opacity-100"
        type="button"
        aria-label="Sync progress"
        title="Sync progress"
        tabindex="0"
        @click="open"
      >
        <span class="text-sm text-white">
          <NumberFlow :value="progress" />%
        </span>
      </button>
    </div>
  </div>
</template>
<script setup>
import NumberFlow from '@number-flow/vue';
import { onMounted, ref, defineEmits } from 'vue';
import {
  TransitionRoot,
  TransitionChild,
  Dialog,
  DialogPanel,
  DialogTitle,
} from '@headlessui/vue';
import AppProgress from '@/components/app/AppProgress.vue';
import AppButton from '@/components/app/AppButton.vue';
import PixelarticonsHeart from '~icons/pixelarticons/heart';

const status = ref(false);
const progress = ref(0);
const isOpen = ref(false);
const isSyncing = ref(false);

const close = () => { isOpen.value = false; };
const open = () => { isOpen.value = true; };
const emit = defineEmits(['onSync']);

onMounted(async () => {
  const storageData = await browser.storage.session.get(['status', 'progress']);
  status.value = storageData.status ?? false;
  progress.value = storageData.progress ?? 0;

  isSyncing.value = !status.value && progress.value > 0 && progress.value < 100;

  isOpen.value = isSyncing.value;
});

browser.runtime.onMessage.addListener(async (message) => {
  if (message.action === 'sync') {
    const wasSyncing = isSyncing.value;
    progress.value = message.data.progress;
    status.value = message.data.progress >= 100;
    isSyncing.value = !status.value && message.data.progress > 0 && message.data.progress < 100;
    if (isSyncing.value && !wasSyncing) {
      isOpen.value = true;
    } else if (message.data.progress >= 100) {
      isOpen.value = false;
    }
    emit('onSync', message.data);
    console.warn('BookmarksSync', message.data);
  }
});
</script>


================================================
FILE: src/ext/browser/components/CommandPalette.vue
================================================
<template>
  <TransitionRoot
    appear
    :show="isOpen"
    as="template"
  >
    <Dialog
      as="div"
      class="relative z-10"
      @close="close"
    >
      <TransitionChild
        as="template"
        enter="duration-300 ease-out"
        enter-from="opacity-0"
        enter-to="opacity-100"
        leave="duration-200 ease-in"
        leave-from="opacity-100"
        leave-to="opacity-0"
      >
        <div class="fixed inset-0 bg-transparent backdrop-blur" />
      </TransitionChild>
      <div class="fixed inset-0 overflow-y-auto">
        <div class="flex min-h-full items-center justify-center p-4 text-center">
          <TransitionChild
            as="template"
            enter="duration-300 ease-out"
            enter-from="opacity-0 scale-95"
            enter-to="opacity-100 scale-100"
            leave="duration-200 ease-in"
            leave-from="opacity-100 scale-100"
            leave-to="opacity-0 scale-95"
          >
            <DialogPanel
              class="flex h-screen max-h-[32rem] w-full max-w-md overflow-hidden rounded-lg border bg-white/60 shadow-lg backdrop-blur-lg transition-all dark:border-neutral-800 dark:bg-black/40 dark:text-white focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0"
            >
              <div
                class="flex w-full flex-col items-start justify-between h-full"
              >
                <div class="flex w-full flex-col flex-1 min-h-0">
                  <div class="flex w-full items-center px-4 py-3 text-gray-700">
                    <span class="mr-2 text-xl text-gray-800 dark:text-gray-200">
                      <PhMagnifyingGlassLight />
                    </span>
                    <input
                      ref="input"
                      v-model="searchTerm"
                      type="text"
                      aria-label="Search command palette"
                      class="flex-1 border-none bg-transparent text-sm outline-none ring-0 placeholder:text-gray-600 focus:border-0 focus:border-none focus:ring-0 dark:text-gray-100 dark:placeholder:text-gray-400"
                      placeholder="Search..."
                      autocomplete="off"
                      autocorrect="off"
                      spellcheck="false"
                      @blur="refocusInput"
                      @keydown.down.prevent="navigateDown"
                      @keydown.up.prevent="navigateUp"
                      @keydown.enter.prevent="selectActiveItem"
                    >
                  </div>

                  <AppInfiniteScroll
                    class="list-view w-full flex-1 overflow-y-auto p-1"
                    :limit="100"
                    @scroll:end="paginate"
                  >
                    <div
                      v-if="isLoading"
                      class="flex min-h-full items-center justify-center p-4"
                    >
                      <AppSpinner class="size-6" />
                    </div>
                    <ul
                      v-else-if="items.length"
                      class="gap-y-2"
                      role="menu"
                    >
                      <li
                        v-for="(item, index) in items"
                        :key="index"
                        ref="item"
                        class="flex cursor-pointer items-center gap-x-2 rounded-md p-3 transition-colors duration-200 hover:bg-black/5 hover:dark:bg-white/10 focus:outline-none focus-visible:ring-1 focus-visible:ring-gray-400 dark:focus-visible:ring-gray-500"
                        :class="{
                          'bg-black/10 dark:bg-white/10': activeIndex === index
                        }"
                        role="menuitem"
                        tabindex="0"
                        @click="handleCommandSelect(item)"
                        @keydown.enter="handleCommandSelect(item)"
                        @keydown.arrow-up.prevent="navigateUp"
                        @keydown.arrow-down.prevent="navigateDown"
                        @focus="activeIndex = index"
                      >
                        <div
                          class="flex size-8 items-center justify-center rounded-lg bg-gradient-to-br from-white to-gray-100 backdrop-blur-sm dark:from-black/80 dark:to-black/80"
                        >
                          <component
                            :is="item.icon"
                            class="size-4 text-gray-700 dark:text-gray-200"
                          />
                        </div>
                        <span class="text-sm text-gray-800 dark:text-gray-100">{{ item.value }}</span>
                      </li>
                    </ul>
                    <div
                      v-else
                      class="flex min-h-full items-center justify-center p-4 text-sm font-thin text-gray-800 dark:text-gray-300"
                    >
                      🔍 No results found.
                    </div>
                  </AppInfiniteScroll>
                </div>
                <div
                  class="w-full border-t border-gray-200 bg-gray-50 px-4 py-3 backdrop-blur-sm dark:border-white/10 dark:bg-white/5"
                >
                  <div class="flex items-center justify-between">
                    <div class="flex items-center gap-2">
                      <kbd
                        class="inline-flex h-6 min-w-[24px] items-center justify-center rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700 shadow-sm dark:border-transparent dark:bg-black dark:text-gray-200"
                      >↑↓</kbd>
                      <span class="text-xs text-gray-600 dark:text-gray-300">navigate</span>
                    </div>
                    <div class="flex items-center gap-4">
                      <div class="flex items-center gap-2">
                        <kbd
                          class="inline-flex h-6 min-w-[24px] items-center justify-center rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700 shadow-sm dark:border-transparent dark:bg-black dark:text-gray-200"
                        >↵</kbd>
                        <span class="text-xs text-gray-600 dark:text-gray-300">select</span>
                      </div>
                      <div class="flex items-center gap-2">
                        <kbd
                          class="inline-flex h-6 min-w-[24px] items-center justify-center rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700 shadow-sm dark:border-transparent dark:bg-black dark:text-gray-200"
                        >esc</kbd>
                      </div>
                    </div>
                  </div>
                </div>
              </div>
            </DialogPanel>
          </TransitionChild>
        </div>
      </div>
    </Dialog>
  </TransitionRoot>
</template>

<script setup>
import { ref, useTemplateRef, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
import {
  Dialog,
  TransitionRoot,
  TransitionChild,
  DialogPanel,
} from '@headlessui/vue';
import AttributeStorage from '@/storage/attribute';
import AppInfiniteScroll from '@/components/app/AppInfiniteScroll.vue';
import AppSpinner from '@/components/app/AppSpinner.vue';
import { useDebounceFn } from '@vueuse/core';

import PhMagnifyingGlassLight from '~icons/ph/magnifying-glass-light';
import PhHashStraightLight from '~icons/ph/hash-straight-light';
import PhGlobeSimpleLight from '~icons/ph/globe-simple-light';
import PhListMagnifyingGlassLight from '~icons/ph/list-magnifying-glass-light';

const attributeStorage = new AttributeStorage();
const emit = defineEmits(['onSelected', 'onVisibilityToggle']);

const isOpen = ref(false);
const searchTerm = ref('');
const activeIndex = ref(-1);
const items = ref([]);
const itemRef = useTemplateRef('item');
const inputRef = useTemplateRef('input');
const isLoading = ref(false);

const iconMap = {
  tag: PhHashStraightLight,
  domain: PhGlobeSimpleLight,
  keyword: PhListMagnifyingGlassLight,
};

const paginate = async (skip) => {
  try {
    const results = await attributeStorage.search(
      { tag: true, domain: true, keyword: true },
      'value',
      'asc',
      searchTerm.value,
      skip,
      100,
    );
    const resultsWithIcons = results.map((item) => ({
      ...item,
      icon: iconMap[item.key],
    }));
    items.value.push(...resultsWithIcons);
  } catch (e) {
    console.error(e);
  }
};

const scrollToActive = (newIndex) => {
  activeIndex.value = newIndex;
  const el = itemRef.value[newIndex];
  if (el) {
    el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
  }
};

const navigateDown = () => {
  const nextIndex = activeIndex.value + 1;
  if (nextIndex < items.value.length) {
    scrollToActive(nextIndex);
  }
};

const navigateUp = () => {
  const prevIndex = activeIndex.value - 1;
  if (prevIndex >= 0) {
    scrollToActive(prevIndex);
  }
};

const performSearch = useDebounceFn(async () => {
  isLoading.value = true;
  try {
    const results = await attributeStorage.search(
      { tag: true, domain: true, keyword: true },
      'value',
      'asc',
      searchTerm.value,
      0,
      100,
    );
    items.value = results.map((item) => ({
      ...item,
      icon: iconMap[item.key],
    }));
    activeIndex.value = 0;
  } catch (e) {
    console.error('Search error:', e);
    items.value = [];
  } finally {
    isLoading.value = false;
  }
}, 100);

const close = () => {
  isOpen.value = false;
  searchTerm.value = '';
  activeIndex.value = -1;
  items.value = [];
  emit('onVisibilityToggle', false);
};

const handleCommandSelect = (selectedItem) => {
  emit('onSelected', [{ key: selectedItem.key, value: selectedItem.value }]);
  close();
};

const selectActiveItem = () => {
  if (activeIndex.value >= 0 && activeIndex.value < items.value.length) {
    handleCommandSelect(items.value[activeIndex.value]);
  }
};

const toggle = () => {
  isOpen.value = !isOpen.value;
  if (isOpen.value) {
    performSearch();
  }
  emit('onVisibilityToggle', isOpen.value);
};

const hotKey = (event) => {
  if (event.altKey && !event.ctrlKey && !event.metaKey && event.code === 'KeyK') {
    event.preventDefault();
    event.stopPropagation();
    toggle();
  }
};

const refocusInput = () => setTimeout(() => { inputRef.value?.focus(); }, 10);

watch(searchTerm, () => {
  if (isOpen.value) {
    performSearch();
  }
});

watch(isOpen, (open) => {
  if (open) {
    performSearch();
    nextTick(() => {
      inputRef.value?.focus();
    });
  }
});

defineExpose({ toggle });

onMounted(() => { document.addEventListener('keydown', hotKey); });
onBeforeUnmount(() => { document.removeEventListener('keydown', hotKey); });
</script>


================================================
FILE: src/ext/browser/components/DatePicker.vue
================================================
<template>
  <div
    ref="rootRef"
    class="relative"
  >
    <button
      v-tooltip.bottom="{ content: 'Date filter' }"
      class="inline-flex size-9 items-center justify-center rounded-md border border-gray-400/30 bg-white text-gray-700 shadow-sm hover:bg-gray-50 dark:border-neutral-800 dark:bg-neutral-900 dark:text-white dark:hover:bg-neutral-800"
      @click="toggleCalendar"
    >
      <IconoirCalendar class="size-5 text-gray-700 dark:text-neutral-400" />
    </button>
    <Transition
      enter-active-class="transition duration-200 ease-out"
      enter-from-class="translate-y-1 opacity-0"
      enter-to-class="translate-y-0 opacity-100"
      leave-active-class="transition duration-150 ease-in"
      leave-from-class="translate-y-0 opacity-100"
      leave-to-class="translate-y-1 opacity-0"
    >
      <div
        v-if="isOpen"
        class="absolute right-0 z-50 mt-2 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black/5 dark:bg-neutral-900"
      >
        <VueDatePicker
          v-model="selectedDate"
          :enable-time-picker="false"
          :dark="isDarkMode"
          :range="true"
          :inline="true"
          :auto-apply="true"
          @update:model-value="onDateSelected"
        />
      </div>
    </Transition>
  </div>
</template>

<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useDark } from '@vueuse/core';
import VueDatePicker from '@vuepic/vue-datepicker';
import IconoirCalendar from '~icons/iconoir/calendar';

const props = defineProps({
  modelValue: {
    type: [Date, String, Array, null],
    default: null,
  },
});

const emit = defineEmits(['update:modelValue']);

const isOpen = ref(false);
const rootRef = ref(null);

const selectedDate = computed({
  get: () => props.modelValue,
  set: (value) => emit('update:modelValue', value),
});

const isDarkMode = useDark();

const toggleCalendar = () => {
  isOpen.value = !isOpen.value;
};

const onDateSelected = (value) => {
  selectedDate.value = value;
  isOpen.value = false;
};

const handleClickOutside = (event) => {
  if (isOpen.value && rootRef.value && !rootRef.value.contains(event.target)) {
    isOpen.value = false;
  }
};

onMounted(() => {
  document.addEventListener('mousedown', handleClickOutside);
});

onBeforeUnmount(() => {
  document.removeEventListener('mousedown', handleClickOutside);
});
</script>


================================================
FILE: src/ext/browser/components/FolderTree.vue
================================================
<template>
  <div class="flex h-full flex-col overflow-hidden">
    <ul
      class="space-y-0.5 overflow-y-auto py-1"
      role="tree"
      aria-label="Bookmark folders"
    >
      <FolderTreeItem
        v-for="folder in folders"
        :key="`${folder.id}-${folder.label}`"
        :folder="folder"
        :selected-id="selectedFolderId"
        :level="0"
        @select="selectFolder"
      />
    </ul>
  </div>
</template>

<script setup>
import { ref, computed, watch } from 'vue';
import FolderTreeItem from '@/ext/browser/components/FolderTreeItem.vue';

const props = defineProps({
  folders: {
    type: Array,
    default: () => [],
    validator: (value) => Array.isArray(value),
  },
  modelValue: {
    type: Array,
    required: true,
    validator: (value) => Array.isArray(value),
  },
});

const emit = defineEmits(['update:modelValue']);

const selectedFolderId = ref(null);

const folderFilter = computed(() => props.modelValue.find((item) => item.key === 'folder'));

const selectFolder = ({ id, label }) => {
  if (selectedFolderId.value === id) {
    selectedFolderId.value = null;
    emit('update:modelValue', props.modelValue.filter((item) => item.key !== 'folder'));
  } else {
    selectedFolderId.value = id;
    const filtered = props.modelValue.filter((item) => item.key !== 'folder');
    filtered.push({ key: 'folder', value: id, label });
    emit('update:modelValue', filtered);
  }
};

watch(
  () => folderFilter.value?.value,
  (newValue) => {
    selectedFolderId.value = newValue || null;
  },
  { immediate: true },
);
</script>


================================================
FILE: src/ext/browser/components/FolderTreeItem.vue
================================================
<template>
  <li
    class="select-none"
    role="treeitem"
    :aria-expanded="hasChildren ? isExpanded : undefined"
    :aria-selected="isSelected"
  >
    <div
      role="button"
      tabindex="0"
      class="flex cursor-pointer items-center gap-1 rounded-md px-2 py-1.5 text-gray-700 hover:bg-neutral-100 dark:text-neutral-400 dark:hover:bg-neutral-900 focus:outline-none focus-visible:ring-1 focus-visible:ring-gray-300 dark:focus-visible:ring-gray-600"
      :class="{ 'bg-neutral-100 dark:bg-neutral-900': isSelected }"
      :aria-label="`${folder.label}${folder.count ? `, ${folder.count} bookmarks` : ''}`"
      @click="select"
      @keydown.enter="select"
      @keydown.space.prevent="select"
    >
      <button
        v-if="hasChildren"
        class="flex size-4 items-center justify-center text-gray-400 hover:text-gray-600 dark:text-neutral-500 dark:hover:text-neutral-300 focus:outline-none"
        :aria-label="isExpanded ? 'Collapse folder' : 'Expand folder'"
        @click.stop="toggle"
        @keydown.enter.stop="toggle"
        @keydown.space.stop.prevent="toggle"
      >
        <PhCaretRight
          class="size-3 transition-transform duration-150"
          :class="{ 'rotate-90': isExpanded }"
        />
      </button>
      <span
        v-else
        class="size-4"
        aria-hidden="true"
      />
      <PhFolderSimpleLight class="size-4 shrink-0" />
      <span class="truncate text-xs">{{ folder.label }}</span>
      <span
        v-if="folder.count"
        class="ml-auto text-xs text-gray-400 dark:text-neutral-600"
        aria-label="bookmark count"
      >{{ folder.count }}</span>
    </div>
    <ul
      v-if="hasChildren && isExpanded"
      class="ml-3 border-l border-gray-200 dark:border-neutral-700"
      role="group"
    >
      <FolderTreeItem
        v-for="child in folder.children"
        :key="`${child.id}-${child.label}`"
        :folder="child"
        :selected-id="selectedId"
        :level="level + 1"
        @select="$emit('select', $event)"
      />
    </ul>
  </li>
</template>

<script setup>
import { ref, computed, watch } from 'vue';
import PhCaretRight from '~icons/ph/caret-right';
import PhFolderSimpleLight from '~icons/ph/folder-simple-light';

const props = defineProps({
  folder: {
    type: Object,
    required: true,
    validator: (value) => value && typeof value === 'object' && value.id && value.label,
  },
  selectedId: {
    type: String,
    default: null,
  },
  level: {
    type: Number,
    default: 0,
    validator: (value) => typeof value === 'number' && value >= 0,
  },
});

const emit = defineEmits(['select']);

const hasChildren = computed(() => Boolean(props.folder.children?.length));

const hasSelectedChild = computed(() => {
  if (!hasChildren.value || !props.selectedId) return false;

  const checkChildren = (children) => {
    for (const child of children) {
      if (child.id === props.selectedId) return true;
      if (child.children && checkChildren(child.children)) return true;
    }
    return false;
  };

  return checkChildren(props.folder.children);
});

const isExpanded = ref(props.level < 2 || hasSelectedChild.value);

const isSelected = computed(() => props.selectedId === props.folder.id);

watch(
  () => hasSelectedChild.value,
  (hasSelected) => {
    if (hasSelected) {
      isExpanded.value = true;
    }
  },
  { immediate: true },
);

const toggle = () => {
  isExpanded.value = !isExpanded.value;
};

const select = () => {
  emit('select', { id: props.folder.id, label: props.folder.label });
};
</script>


================================================
FILE: src/ext/browser/components/SearchTerm.vue
================================================
<template>
  <div
    class="flex h-9 min-h-9 w-full rounded-md border border-gray-200 bg-white px-2 py-1 shadow-sm focus-within:border-gray-300 dark:border-neutral-800 dark:bg-neutral-900 dark:text-white focus-within:dark:border-neutral-700"
  >
    <div class="flex min-w-0 flex-1 items-center gap-x-1 overflow-x-auto">
      <ul class="flex h-full items-center gap-x-1 whitespace-nowrap">
        <li
          v-for="(tag, tagKey) in modelValue"
          :key="tagKey"
          class="flex items-center"
        >
          <AppBadge
            closable
            :color="getColor(tag.key)"
            @on-close="onClose(tag.key, tag.value)"
          >
            <component
              :is="getIcon(tag.key)"
              class="mr-1 size-4"
            />
            {{ tag.label || tag.value }}
          </AppBadge>
        </li>
      </ul>
      <input
        ref="inputRef"
        v-model="term"
        name="term"
        type="text"
        aria-label="Search"
        autocomplete="off"
        autocorrect="off"
        spellcheck="false"
        maxlength="25"
        :placeholder="placeholder"
        class="min-w-max flex-1 appearance-none border-0 bg-transparent px-1 py-0 text-xs placeholder:text-xs focus:outline-none focus:ring-0"
        @keydown.enter="add"
        @keydown.tab.prevent="add"
        @keydown.delete="removeLast"
      >
    </div>
    <div class="flex flex-shrink-0 flex-wrap items-center gap-x-1 text-xs text-gray-400 dark:text-neutral-600">
      <button
        class="m-0 inline-flex appearance-none items-center gap-x-1 border-none bg-transparent p-0"
        @click="handleCommandPallete"
      >
        <kbd class="inline-flex size-6 items-center justify-center rounded-md border border-gray-200 bg-white font-mono text-lg shadow-sm dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200">
          ⌥
        </kbd>
        <kbd class="inline-flex size-6 items-center justify-center rounded-md border border-gray-200 bg-white font-mono text-xs shadow-sm dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200">
          K
        </kbd>
      </button>
    </div>
    <CommandPalette
      ref="cmd"
      @on-visibility-toggle="cmdToggle"
      @on-selected="emit('update:modelValue', [...modelValue, ...$event.filter(n => !modelValue.some(e => e.key === n.key && e.value === n.value))])"
    />
  </div>
</template>

<script setup>
import { ref, computed } from 'vue';
import AppBadge from '@/components/app/AppBadge.vue';
import CommandPalette from '@/ext/browser/components/CommandPalette.vue';

import PhHashStraightLight from '~icons/ph/hash-straight-light';
import PhGlobeSimpleLight from '~icons/ph/globe-simple-light';
import PhListMagnifyingGlassLight from '~icons/ph/list-magnifying-glass-light';
import PhFolderSimpleLight from '~icons/ph/folder-simple-light';
import PhMagnifyingGlassLight from '~icons/ph/magnifying-glass-light';
import MdiIdentifier from '~icons/mdi/identifier';
import PhCalendarBlank from '~icons/ph/calendar-blank';

const props = defineProps({
  placeholder: {
    type: String,
    default: 'Search: tag:important domain:example.com',
  },
  modelValue: {
    type: Array,
    default: () => [],
  },
});

const term = ref('');
const inputRef = ref(null);
const cmd = ref(null);
const emit = defineEmits(['update:modelValue']);

const removeLast = () => {
  if (term.value) return;
  emit('update:modelValue', props.modelValue.slice(0, -1));
};

const add = () => {
  if (!term.value) return;
  const [key, value] = term.value.split(':');
  const validKeys = ['tag', 'keyword', 'domain', 'folder', 'id'];

  if (validKeys.includes(key) && value) {
    emit('update:modelValue', [...props.modelValue, { key, value }]);
  } else {
    const updatedValue = props.modelValue.filter((item) => item.key !== 'term');
    updatedValue.push({ key: 'term', value: term.value });
    emit('update:modelValue', updatedValue);
  }
  term.value = '';
};

const iconMap = computed(() => ({
  folder: PhFolderSimpleLight,
  keyword: PhListMagnifyingGlassLight,
  tag: PhHashStraightLight,
  domain: PhGlobeSimpleLight,
  id: MdiIdentifier,
  dateAdded: PhCalendarBlank,
  default: PhMagnifyingGlassLight,
}));

const getIcon = (key) => iconMap.value[key] || iconMap.value.default;
function getColor(key) {
  switch (key) {
    case 'dateAdded':
      return 'cyan';
    case 'folder':
      return 'purple';
    case 'keyword':
      return 'green';
    case 'tag':
      return 'gray';
    case 'domain':
      return 'yellow';
    case 'id':
      return 'indigo';
    default:
      return 'pink';
  }
}

const focus = () => { inputRef.value.focus(); };
const handleCommandPallete = () => { cmd.value.toggle(); };

const onClose = (key, value) => {
  const data = props.modelValue.filter((item) => !(item.key === key && item.value === value));
  emit('update:modelValue', data);
};

const cmdToggle = (status) => {
  if (status === false) {
    setTimeout(() => focus(), 500);
  }
};

defineExpose({ focus });
</script>


================================================
FILE: src/ext/browser/components/SortDirection.vue
================================================
<template>
  <div class="relative">
    <button
      class="inline-flex size-9 items-center justify-center rounded-md border-1 border-gray-400/30 bg-white text-gray-700 shadow-sm hover:bg-gray-50 dark:border-neutral-800 dark:bg-neutral-900 dark:text-white dark:hover:bg-neutral-800"
      @click="toggleSort"
    >
      <component
        :is="sortIcon"
        class="size-5 cursor-pointer text-gray-700 dark:text-neutral-400"
      />
    </button>
  </div>
</template>

<script setup>
import { computed } from 'vue';
import SolarSortFromBottomToTopLineDuotone from '~icons/solar/sort-from-bottom-to-top-line-duotone';
import SolarSortFromTopToBottomLineDuotone from '~icons/solar/sort-from-top-to-bottom-line-duotone';

const props = defineProps({
  modelValue: {
    type: String,
    default: 'desc',
    required: true,
  },
});

const emit = defineEmits(['update:modelValue']);

const sortIcon = computed(() => (props.modelValue === 'desc'
  ? SolarSortFromTopToBottomLineDuotone
  : SolarSortFromBottomToTopLineDuotone));

const toggleSort = () => {
  emit('update:modelValue', props.modelValue === 'desc' ? 'asc' : 'desc');
};
</script>


================================================
FILE: src/ext/browser/components/TextEditor.vue
================================================
<template>
  <div
    v-if="editor"
    class="flex size-full flex-col"
  >
    <div class="sticky top-0 z-10 flex flex-wrap items-center gap-1 bg-white p-2 text-xs text-black dark:bg-black dark:text-white">
      <button
        type="button"
        class="p-1"
        :class="{ 'rounded bg-black text-white dark:bg-white dark:text-black': editor.isActive('bold') }"
        @click="editor.chain().focus().toggleBold().run()"
      >
        <PhTextB class="size-5" />
      </button>
      <button
        type="button"
        class="p-1"
        :class="{ 'rounded bg-black p-1 text-white dark:bg-white dark:text-black': editor.isActive('italic') }"
        @click="editor.chain().focus().toggleItalic().run()"
      >
        <PhTextItalic class="size-5" />
      </button>
      <button
        type="button"
        class="p-1"
        :class="{ 'rounded bg-black p-1 text-white dark:bg-white dark:text-black': editor.isActive('strike') }"
        @click="editor.chain().focus().toggleStrike().run()"
      >
        <PhTextStrikethrough class="size-5" />
      </button>
      <button
        type="button"
        class="p-1"
        :class="{ 'rounded bg-black p-1 text-white dark:bg-white dark:text-black': editor.isActive('underline') }"
        @click="editor.chain().focus().toggleUnderline().run()"
      >
        <PhTextUnderline class="size-5" />
      </button>
      <button
        type="button"
        class="p-1"
        :class="{ 'rounded bg-black p-1 text-white dark:bg-white dark:text-black': editor.isActive('heading', { level: 1 }) }"
        @click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
      >
        <PhTextHOne class="size-5" />
      </button>
      <button
        type="button"
        class="p-1"
        :class="{ 'rounded bg-black p-1 text-white dark:bg-white dark:text-black': editor.isActive('heading', { level: 2 }) }"
        @click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
      >
        <PhTextHTwo class="size-5" />
      </button>
      <button
        type="button"
        class="p-1"
        :class="{ 'rounded bg-black p-1 text-white dark:bg-white dark:text-black': editor.isActive('heading', { level: 3 }) }"
        @click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
      >
        <PhTextHThree class="size-5" />
      </button>
      <button
        type="button"
        class="p-1"
        :class="{ 'rounded bg-black p-1 text-white dark:bg-white dark:text-black': editor.isActive('heading', { level: 4 }) }"
        @click="editor.chain().focus().toggleHeading({ level: 4 }).run()"
      >
        <PhTextHFour class="size-5" />
      </button>
      <button
        type="button"
        class="p-1"
        :class="{ 'rounded bg-black p-1 text-white dark:bg-white dark:text-black': editor.isActive('bulletList') }"
        @click="editor.chain().focus().toggleBulletList().run()"
      >
        <PhListBullets class="size-5" />
      </button>
      <button
        type="button"
        class="p-1"
        :class="{ 'rounded bg-black p-1 text-white dark:bg-white dark:text-black': editor.isActive('orderedList') }"
        @click="editor.chain().focus().toggleOrderedList().run()"
      >
        <PhListNumbers class="size-5" />
      </button>
      <button
        type="button"
        class="p-1"
        :class="{ 'rounded bg-black p-1 text-white dark:bg-white dark:text-black': editor.isActive('codeBlock') }"
        @click="editor.chain().focus().toggleCodeBlock().run()"
      >
        <PhCode class="size-5" />
      </button>
      <button
        type="button"
        class="p-1"
        :class="{ 'rounded bg-black p-1 text-white dark:bg-white dark:text-black': editor.isActive('blockquote') }"
        @click="editor.chain().focus().toggleBlockquote().run()"
      >
        <PhQuotes class="size-5" />
      </button>
    </div>
    <editor-content
      :editor="editor"
      class="w-full flex-1 overflow-auto p-2 text-black dark:text-white"
    />
  </div>
</template>
<script setup>
import { ref, watch, onMounted, onBeforeUnmount } from 'vue';
import { Editor, EditorContent } from '@tiptap/vue-3';
import StarterKit from '@tiptap/starter-kit';
import Underline from '@tiptap/extension-underline';
import Highlight from '@tiptap/extension-highlight';
import Typography from '@tiptap/extension-typography';
import PhTextB from '~icons/ph/text-b';
import PhTextItalic from '~icons/ph/text-italic';
import PhTextStrikethrough from '~icons/ph/text-strikethrough';
import PhTextUnderline from '~icons/ph/text-underline';
import PhTextHFour from '~icons/ph/text-h-four';
import PhTextHOne from '~icons/ph/text-h-one';
import PhTextHTwo from '~icons/ph/text-h-two';
import PhTextHThree from '~icons/ph/text-h-three';
import PhQuotes from '~icons/ph/quotes';
import PhListNumbers from '~icons/ph/list-numbers';
import PhListBullets from '~icons/ph/list-bullets';
import PhCode from '~icons/ph/code';

const props = defineProps({
  modelValue: {
    type: String,
    default: '',
  },
});
const emit = defineEmits(['update:modelValue']);
const editor = ref(null);

watch(
  () => props.modelValue,
  (value) => {
    if (editor.value) {
      const isSame = editor.value.getHTML() === value;
      if (isSame) return;

      editor.value.commands.setContent(value, false);
    }
  },
);
onMounted(() => {
  editor.value = new Editor({
    extensions: [StarterKit, Underline, Highlight, Typography],
    editorProps: {
      attributes: {
        class: 'prose prose-neutral dark:prose-invert prose-sm max-w-none text-sm min-h-full w-full p-2 focus:outline-none',
      },
    },
    content: props.modelValue,
    onUpdate: () => {
      emit('update:modelValue', editor.value.getHTML());
    },
  });
});
onBeforeUnmount(() => {
  editor.value.destroy();
});
</script>


================================================
FILE: src/ext/browser/components/ThemeMode.vue
================================================
<template>
  <div class="relative">
    <button
      class=" text-gray-700  dark:border-neutral-800  dark:text-white"
      @click="toggleTheme()"
    >
      <component
        :is="isDark ? IconoirSunLight : IconoirHalfMoon"
        class="size-4 text-soft-900 hover:text-black dark:text-white dark:hover:text-white"
      />
    </button>
  </div>
</template>
<script setup>
import { useDark, useToggle } from '@vueuse/core';
import IconoirHalfMoon from '~icons/iconoir/half-moon?width=24px&height=24px';
import IconoirSunLight from '~icons/iconoir/sun-light?width=24px&height=24px';

const isDark = useDark();
const toggleTheme = useToggle(isDark);
</script>


================================================
FILE: src/ext/browser/components/ViewMode.vue
================================================
<template>
  <Menu
    as="div"
    class="relative inline-block text-left"
  >
    <MenuButton
      class="inline-flex size-9 items-center justify-center rounded-md border-1 border-gray-400/30 bg-white text-gray-700 shadow-sm hover:bg-gray-50 dark:border-neutral-800 dark:bg-neutral-900 dark:text-white dark:hover:bg-neutral-800"
    >
      <component
        :is="icon"
        class="m-auto size-5 cursor-pointer text-gray-700 dark:text-neutral-400"
        aria-hidden="true"
      />
    </MenuButton>

    <Transition
      enter-active-class="transition duration-200 ease-out"
      enter-from-class="translate-y-1 opacity-0"
      enter-to-class="translate-y-0 opacity-100"
      leave-active-class="transition duration-150 ease-in"
      leave-from-class="translate-y-0 opacity-100"
      leave-to-class="translate-y-1 opacity-0"
    >
      <MenuItems
        class="absolute right-0 mt-2 w-40 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black/5 dark:bg-neutral-900 dark:text-neutral-400"
      >
        <MenuItem v-slot="{ active }">
          <button
            :class="[
              active
                ? 'bg-neutral-50 text-gray-700 dark:bg-neutral-800  dark:text-neutral-400'
                : 'text-gray-700 dark:text-neutral-400',
              'group flex w-full items-center rounded-md p-2 text-xs',
            ]"
            @click="view = 'masonry'"
          >
            <CircumGrid42
              :active="active"
              class="mr-2 size-5"
              aria-hidden="true"
            />
            Gallery
          </button>
        </MenuItem>
        <MenuItem v-slot="{ active }">
          <button
            :class="[
              active
                ? 'bg-neutral-50 text-gray-700 dark:bg-neutral-800 dark:text-neutral-400'
                : 'text-gray-700 dark:text-neutral-400',
              'group flex w-full items-center rounded-md p-2 text-xs',
            ]"
            @click="view = 'card'"
          >
            <CircumGrid41
              :active="active"
              class="mr-2 size-5"
              aria-hidden="true"
            />
            Cards
          </button>
        </MenuItem>
        <MenuItem v-slot="{ active }">
          <button
            :class="[
              active
                ? 'bg-neutral-50 text-gray-700 dark:bg-neutral-800 dark:text-neutral-400'
                : 'text-gray-700 dark:text-neutral-400',
              'group flex w-full items-center rounded-md p-2 text-xs',
            ]"
            @click="view = 'list'"
          >
            <CircumGrid2H
              :active="active"
              class="mr-2 size-5"
              aria-hidden="true"
            />
            List
          </button>
        </MenuItem>
      </MenuItems>
    </Transition>
  </Menu>
</template>
<script setup>
import {
  Menu, MenuButton, MenuItems, MenuItem,
} from '@headlessui/vue';

import { computed } from 'vue';

import CircumGrid42 from '~icons/circum/grid-4-2';
import CircumGrid41 from '~icons/circum/grid-4-1';
import CircumGrid2H from '~icons/circum/grid-2-h';

const props = defineProps({
  modelValue: {
    type: String,
    requred: true,
    default: 'masonry',
  },
});
const emit = defineEmits(['update:modelValue']);
const view = computed({
  get: () => props.modelValue,
  set: (value) => emit('update:modelValue', value),
});
const icon = computed({
  get: () => {
    switch (view.value) {
      case 'card':
        return CircumGrid41;
      case 'list':
        return CircumGrid2H;
      case 'masonry':
        return CircumGrid42;
      default:
        return CircumGrid42;
    }
  },
});
</script>


================================================
FILE: src/ext/browser/components/card/BookmarkCard.vue
================================================
<template>
  <Transition
    appear
    enter-active-class="transition-opacity duration-200 ease-out"
    enter-from-class="opacity-0"
    enter-to-class="opacity-100"
  >
    <component
      :is="displayComponent"
      :key="bookmark.id"
      :bookmark="bookmark"
    >
      <template #actions>
        <div class="absolute right-2 top-0 transition-opacity duration-300 ease-out group-hover:opacity-100">
          <div class="flex gap-x-2">
            <button
              v-tooltip.bottom-start="{ content: 'Delete'}"
              class="-translate-y-8 rounded-md bg-red-500 p-1.5 text-white opacity-100 shadow-md transition-transform delay-100 duration-150 ease-out group-hover:translate-y-2 group-hover:opacity-100"
              @click="$emit('onRemove', bookmark)"
            >
              <CarbonTrashCan class="size-4" />
            </button>
            <button
              v-tooltip.bottom-start="{ content: 'Add to Notes'}"
              class="-translate-y-8 rounded-md p-1.5  text-white opacity-100 shadow-md transition-transform delay-100 duration-500 ease-out group-hover:translate-y-2 group-hover:opacity-100"
              :class="[
                bookmark.pinned === 0 ? 'bg-black' : 'bg-purple-500 '
              ]"
              @click="$emit('onPin', bookmark)"
            >
              <ClarityClipboardOutlineBadged class="size-4" />
            </button>
            <button
              v-tooltip.bottom-start="{ content: 'Update bookmark'}"
              class="-translate-y-8  rounded-md bg-black p-1.5 text-white opacity-100 shadow-md transition-transform delay-100 duration-1000 ease-out group-hover:translate-y-2 group-hover:opacity-100"
              @click="$emit('onEdit', bookmark)"
            >
              <CarbonEdit class="size-4" />
            </button>
          </div>
        </div>
      </template>
    </component>
  </Transition>
</template>
<script setup>
import { computed } from 'vue';
import CardView from '@/ext/browser/components/card/type/CardView.vue';
import ListView from '@/ext/browser/components/card/type/ListView.vue';
import MasonryView from '@/ext/browser/components/card/type/MasonryView.vue';

import CarbonTrashCan from '~icons/carbon/trash-can';
import ClarityClipboardOutlineBadged from '~icons/clarity/clipboard-outline-badged';
import CarbonEdit from '~icons/carbon/edit';

const props = defineProps({
  bookmark: {
    type: Object,
    required: true,
    default: () => {},
  },
  displayType: {
    type: String,
    required: true,
    default: 'masonry',
  },
});
defineEmits(['onRemove', 'onEdit', 'onPin', 'onScreenshot']);

const displayComponent = computed({
  get: () => {
    switch (props.displayType) {
      case 'card':
        return CardView;
      case 'list':
        return ListView;
      case 'masonry':
        return MasonryView;
      default:
        return MasonryView;
    }
  },
});
</script>


================================================
FILE: src/ext/browser/components/card/DuplicateCard.vue
================================================
<template>
  <div class="group relative w-full rounded-md border border-solid bg-white shadow-xs transition-all duration-200 ease-in-out hover:bg-gray-50 hover:shadow-sm dark:border-neutral-900 dark:bg-neutral-950 dark:hover:bg-neutral-900">
    <div class="flex items-center justify-between w-full p-3 text-gray-900 dark:text-neutral-100">
      <div class="flex items-center gap-x-3 min-w-0 flex-1">
        <BookmarkFavicon
          :bookmark="bookmark"
          class="size-4 fill-gray-700 dark:fill-gray-100"
        />
        <div class="min-w-0">
          <div class="flex items-center gap-x-2 min-w-0 overflow-hidden">
            <a
              :href="bookmark.url"
              target="_blank"
              class="focus-visible:ring-2 focus-visible:ring-blue-500 rounded text-sm text-black dark:text-white no-underline hover:no-underline truncate"
            >
              {{ bookmark.title }}
            </a>
            <span class="text-xs text-gray-400 dark:text-gray-500 flex-shrink-0 truncate">ID:{{ bookmark.id }}</span>
          </div>
          <p class="text-xs dark:text-neutral-500">
            {{ bookmark.domain }}
          </p>
          <div class="flex flex-wrap gap-x-1 gap-y-1 mt-1">
            <AppBadge
              v-for="(value, key) in bookmark.tags"
              :key="key"
              class="text-xs px-1.5 py-0.5 truncate max-w-[60px]"
            >
              {{ value }}
            </AppBadge>
          </div>
        </div>
      </div>
      <button
        v-tooltip.bottom-start="{ content: 'Delete' }"
        class="ml-2 p-1 rounded transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500 text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/30"
        aria-label="Delete duplicate"
        @click="$emit('onDelete', bookmark)"
      >
        <CarbonTrashCan class="size-4" />
      </button>
    </div>
  </div>
</template>

<script setup>
import BookmarkFavicon from '@/ext/browser/components/BookmarkFavicon.vue';
import AppBadge from '@/components/app/AppBadge.vue';
import CarbonTrashCan from '~icons/carbon/trash-can';

defineProps({
  bookmark: {
    type: Object,
    required: true,
  },
});
</script>


================================================
FILE: src/ext/browser/components/card/HealthCheckCard.vue
================================================
<template>
  <div class="group relative w-full rounded-md border border-solid bg-white shadow-xs transition-all duration-200 ease-in-out hover:bg-gray-50 hover:shadow-sm dark:border-neutral-900 dark:bg-neutral-950 dark:hover:bg-neutral-900">
    <div class="flex items-center justify-between w-full p-3 text-gray-900 dark:text-neutral-100">
      <div class="flex items-center gap-x-3 min-w-0 flex-1">
        <AppBadge
          v-tooltip.bottom-start="{ content: STATUS_MESSAGE.get(bookmark.httpStatus) ?? 'Unknown Status' }"
          :color="bookmark.httpStatus === HTTP_STATUS.UNKNOWN_ERROR ? 'yellow' : 'red'"
        >
          {{ bookmark.httpStatus }}
        </AppBadge>
        <BookmarkFavicon
          :bookmark="bookmark"
          class="size-4 fill-gray-700 dark:fill-gray-100"
        />
        <div class="min-w-0">
          <a
            :href="bookmark.url"
            target="_blank"
            class="focus-visible:ring-2 focus-visible:ring-blue-500 rounded text-sm text-black dark:text-white no-underline hover:no-underline"
          >
            {{ bookmark.title }}
          </a>
          <p class="text-xs dark:text-neutral-500">
            {{ bookmark.domain }}
          </p>
        </div>
      </div>
      <button
        v-tooltip.bottom-start="{ content: 'Delete'}"
        class="ml-2 p-1 rounded transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500 text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/30"
        aria-label="Delete bookmark"
        @click="$emit('onDelete', bookmark)"
      >
        <CarbonTrashCan class="size-4" />
      </button>
    </div>
  </div>
</template>

<script setup>
import BookmarkFavicon from '@/ext/browser/components/BookmarkFavicon.vue';
import AppBadge from '@/components/app/AppBadge.vue';
import { STATUS_MESSAGE, HTTP_STATUS } from '@/constants/httpStatus';
import CarbonTrashCan from '~icons/carbon/trash-can';

defineProps({
  bookmark: {
    type: Object,
    required: true,
  },
});

</script>


================================================
FILE: src/ext/browser/components/card/PinnedCard.vue
================================================
<template>
  <div
    class="group relative min-h-min w-full overflow-hidden rounded-md border border-solid p-3 shadow-xs dark:border-neutral-800 dark:bg-neutral-950"
    :class="activeClass"
  >
    <div class="mb-1 flex items-center gap-x-1.5 text-sm text-black dark:text-white">
      <AppBullet
        v-if="bookmark.notes"
        v-tooltip.top="'Has notes'"
        color="purple"
        :size="2"
        class="flex-shrink-0"
      />
      <span class="truncate">{{ bookmark.title }}</span>
    </div>
    <div class="flex items-center justify-between text-xs text-black dark:text-white mt-2">
      <div class="flex items-center gap-x-2 min-w-0">
        <BookmarkFavicon
          :bookmark="bookmark"
          class="size-3"
        />
        <span class="truncate">{{ bookmark.domain }}</span>
      </div>
      <div class="flex items-center text-xs text-gray-400 flex-shrink-0 ml-2">
        <CarbonTime class="mr-1" />
        <span class="mr-1">Last viewed:</span>
        <span>{{ new Date(bookmark.updatedAt).toLocaleString() }}</span>
      </div>
    </div>

    <div class="absolute right-2 top-0 transition-opacity duration-300 ease-out group-hover:opacity-100">
      <div class="flex gap-x-2">
        <button
          v-tooltip.bottom-start="{ content: 'Unpin bookmark'}"
          aria-label="Unpin bookmark"
          class="-translate-y-8 rounded-md p-1.5 text-white opacity-100 shadow-md transition-transform delay-100 duration-300 ease-out group-hover:translate-y-2 group-hover:opacity-100"
          :class="[
            bookmark.pinned === 0 ? 'bg-black' : 'bg-purple-500 '
          ]"
          @click="$emit('pin', bookmark)"
        >
          <CarbonPin class="size-4" />
        </button>
        <button
          v-tooltip.bottom-start="{ content: 'Open'}"
          aria-label="Open bookmark"
          class="-translate-y-8 rounded-md bg-black p-1.5 text-white opacity-100 shadow-md transition-transform delay-100 duration-500 ease-out group-hover:translate-y-2 group-hover:opacity-100"
          @click="$emit('open', bookmark)"
        >
          <CarbonNewTab class="size-4" />
        </button>
      </div>
    </div>
  </div>
</template>

<script setup>
import { computed } from 'vue';
import AppBullet from '@/components/app/AppBullet.vue';
import BookmarkFavicon from '@/ext/browser/components/BookmarkFavicon.vue';
import CarbonPin from '~icons/carbon/pin';
import CarbonNewTab from '~icons/carbon/new-tab';
import CarbonTime from '~icons/carbon/time';

const props = defineProps({
  bookmark: {
    type: Object,
    required: true,
  },
  active: {
    type: Boolean,
    default: false,
  },
});

defineEmits(['remove', 'pin', 'edit']);

const activeClass = computed(() => (props.active ? 'bg-gray-100 dark:bg-neutral-600' : 'bg-white dark:bg-neutral-950'));

</script>


================================================
FILE: src/ext/browser/components/card/type/CardView.vue
================================================
<template>
  <div
    class="group relative w-full max-w-md mx-auto overflow-hidden rounded-xl border border-solid border-gray-200 bg-white shadow-sm p-0 flex flex-col dark:border-neutral-800 dark:bg-neutral-950"
  >
    <div class="px-4 pt-4 flex-1 flex flex-col">
      <a
        :href="bookmark.url"
        target="_blank"
        rel="noopener noreferrer"
        class="break-words text-sm text-black dark:text-white line-clamp-3"
      >
        {{ bookmark.title }}
      </a>
      <div
        v-if="bookmark.tags && bookmark.tags.length"
        class="flex flex-wrap items-center gap-2 my-2"
      >
        <AppBadge
          v-for="(value, key) in bookmark.tags"
          :key="key"
        >
          {{ value }}
        </AppBadge>
      </div>
    </div>
    <!-- preview image -->
    <a
      :href="bookmark.url"
      target="_blank"
      rel="noopener noreferrer"
      :aria-label="`Open link: ${bookmark.title}`"
      class="block px-4 py-1"
    >
      <div class="w-full aspect-video rounded-md bg-gray-100 dark:bg-neutral-900 flex items-center justify-center overflow-hidden">
        <img
          v-if="bookmark.image && !imageError"
          :src="String(bookmark.image)"
          :alt="bookmark.title"
          class="w-full h-full object-cover rounded-md"
          @error="imageError = true"
        >
        <div
          v-else
          class="flex items-center justify-center w-full h-full"
        >
          <BookmarkFavicon
            :bookmark="bookmark"
            class="max-w-8 max-h-8"
          />
        </div>
      </div>
    </a>
    <!-- footer -->
    <div class="flex flex-col gap-2 px-4 py-3 border-t border-gray-200 mt-2 sm:flex-row sm:items-center sm:justify-between dark:border-neutral-800">
      <span class="flex items-center gap-1 text-xs text-gray-400 dark:text-neutral-500 min-w-0 truncate">
        <BookmarkFavicon
          :bookmark="bookmark"
          class="w-3 h-3 shrink-0"
        />
        <span class="truncate">{{ bookmark.domain }}</span>
      </span>
      <div class="flex items-center gap-2 sm:order-last">
        <slot name="actions" />
        <span class="flex items-center text-xs text-gray-400 dark:text-neutral-500 whitespace-nowrap">
          <PhCalendarBlank class="mr-1 align-text-bottom shrink-0" />
          {{ new Date(bookmark.dateAdded).toISOString().slice(0, 10) }}
        </span>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import AppBadge from '@/components/app/AppBadge.vue';
import BookmarkFavicon from '@/ext/browser/components/BookmarkFavicon.vue';
import PhCalendarBlank from '~icons/ph/calendar-blank';

defineProps({
  bookmark: {
    type: Object,
    required: true,
  },
});

const imageError = ref(false);
</script>


================================================
FILE: src/ext/browser/components/card/type/ListView.vue
================================================
<template>
  <div
    class="group relative min-h-min w-full overflow-hidden rounded-md border border-solid border-gray-200 bg-white p-3 shadow-xs dark:border-neutral-900 dark:bg-neutral-950"
  >
    <a
      :href="bookmark.url"
      rel="noopener noreferrer"
      target="_blank"
      class="w-full block"
    >
      <div class="mb-3 flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2">
        <span class="text-sm text-black dark:text-white leading-snug">{{ bookmark.title }}</span>
        <span class="flex items-center text-xs text-gray-400 dark:text-neutral-500 shrink-0">
          <PhCalendarBlank class="text-xs mr-1" />
          {{ new Date(bookmark.dateAdded).toISOString().slice(0, 10) }}
        </span>
      </div>
      <div class="flex flex-col gap-2.5">
        <span class="text-xs text-gray-700 dark:text-neutral-100 flex items-center gap-1.5">
          <BookmarkFavicon
            :bookmark="bookmark"
            class="w-3 h-3 shrink-0"
          />
          {{ bookmark.domain }}
        </span>
        <p
          v-if="bookmark.description"
          class="break-words text-xs text-gray-700 dark:text-neutral-500 leading-relaxed"
        >
          {{ bookmark.description }}
        </p>
        <div
          v-if="bookmark.tags && bookmark.tags.length"
          class="flex flex-wrap gap-2"
        >§
          <AppBadge
            v-for="(value, key) in bookmark.tags"
            :key="key"
          >
            {{ value }}
          </AppBadge>
        </div>
      </div>
    </a>
    <slot name="actions" />
  </div>
</template>
<script setup>
import { computed } from 'vue';
import AppBadge from '@/components/app/AppBadge.vue';
import BookmarkFavicon from '@/ext/browser/components/BookmarkFavicon.vue';
import PhCalendarBlank from '~icons/ph/calendar-blank';

const props = defineProps({
  bookmark: {
    type: Object,
    required: true,
  },
});

const bookmark = computed({
  get: () => props.bookmark,
});
</script>


================================================
FILE: src/ext/browser/components/card/type/MasonryView.vue
================================================
<template>
  <div class="group relative">
    <!-- Animated border background -->
    <div
      :class="['animated-gradient absolute -inset-0.5 rounded-lg bg-gradient-to-r opacity-0 transition-all duration-500 group-hover:opacity-60', randomGradient]"
    />
    <!-- Glow effect -->
    <div
      :class="['glow-effect absolute -inset-2 opacity-0 blur-lg transition-all duration-300 group-hover:opacity-50 dark:group-hover:opacity-100', glowClass]"
    />
    <div
      class="group relative mb-1 min-h-[60px] sm:min-h-[80px] w-full overflow-hidden rounded-md border border-solid border-gray-100 bg-white shadow-sm hover:[box-shadow:0px_0px_0px_1px_rgba(233,_226,_238,_0.253)] dark:border-neutral-900 dark:bg-neutral-950 dark:shadow-sm dark:hover:[box-shadow:0px_0px_0px_1px_rgba(233,_226,_238,_0.253)] sm:mb-2 md:mb-3"
    >
      <a
        :href="bookmark.url"
        rel="noopener noreferrer"
        target="_blank"
      >
        <!-- image -->
        <div
          class="flex w-full items-center justify-center relative"
          :class="{
            'min-h-[80px] sm:min-h-[120px]': !bookmark.image || imageError,
            'min-h-[60px] sm:min-h-[80px]': bookmark.image && !imageError
          }"
          :style="(!bookmark.image || imageError) ? placeholder : null"
        >
          <img
            v-if="bookmark.image && !imageError"
            :key="bookmark.id"
            :src="String(bookmark.image)"
            :alt="bookmark.title"
            class="max-h-full max-w-full object-cover transition-all duration-700 ease-out relative"
            :class="{ 'opacity-0': imageLoading }"
            @loadstart="imageLoading = true; imageError = false"
            @load="imageLoading = false"
            @error="onImageError"
          >
          <div
            v-if="imageLoading && bookmark.image && !imageError"
            class="absolute inset-0 flex items-center justify-center z-10"
          >
            <AppSpinner />
          </div>
          <div
            v-if="!bookmark.image || imageError"
            class="relative flex size-full items-center justify-center overflow-hidden min-w-0"
          >
            <div class="relative z-0 flex flex-col items-center p-6 w-full min-w-0">

              <div
                class="group relative flex aspect-square size-20 shrink-0 items-center justify-center overflow-hidden rounded-2xl border border-white/50 dark:border-white/10 bg-white/20 dark:bg-white/5 shadow-lg backdrop-blur-xl mb-4"
              >
                <BookmarkFavicon
                  :bookmark="bookmark"
                  class="relative z-10 max-h-8 max-w-8 rounded-sm object-contain"
                />
              </div>

              <div class="text-center w-full min-w-0">
                <p class="text-sm font-medium text-gray-700 dark:text-gray-200 truncate px-1">
                  {{ bookmark.domain || bookmark.title }}
                </p>
                <p class="text-xs text-gray-500 dark:text-gray-400">
                  No preview available
                </p>
              </div>

            </div>
          </div>
        </div>
        <!-- end image -->

        <div class="p-2 sm:p-3">
          <h1 class="break-words text-sm text-black dark:text-white line-clamp-3">{{ bookmark.title }}</h1>
          <p class="break-words py-2 text-xs text-gray-700 dark:text-neutral-500">
            {{ bookmark.description }}
          </p>
          <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
            <span class="flex items-center gap-1 text-xs text-gray-400 dark:text-neutral-500 min-w-0 truncate">
              <BookmarkFavicon
                :bookmark="bookmark"
                class="size-4 shrink-0"
              />
              <span class="truncate">{{ bookmark.domain }}</span>
            </span>
            <span
              v-if="bookmark.dateAdded"
              class="flex items-center text-xs text-gray-400 dark:text-neutral-500 whitespace-nowrap"
            >
              <PhCalendarBlank class="text-xs mr-1 align-text-bottom text-gray-400 dark:text-neutral-500 shrink-0" />
              {{ new Date(bookmark.dateAdded).toISOString().slice(0, 10) }}
            </span>
          </div>
          <div
            v-if="bookmark.tags && bookmark.tags.length"
            class="mt-5 flex flex-wrap gap-1"
          >
            <AppBadge
              v-for="(value, key) in bookmark.tags"
              :key="key"
            >
              {{ value }}
            </AppBadge>
          </div>
        </div>
      </a>
      <slot name="actions" />
    </div>
  </div>
</template>

<script setup>
import { computed, onMounted, ref } from 'vue';
import AppBadge from '@/components/app/AppBadge.vue';
import AppSpinner from '@/components/app/AppSpinner.vue';
import BookmarkFavicon from '@/ext/browser/components/BookmarkFavicon.vue';
import useColorExtraction from '@/composables/useColorExtraction';
import PhCalendarBlank from '~icons/ph/calendar-blank';

const props = defineProps({
  bookmark: {
    type: Object,
    required: true,
  },
});

const imageError = ref(false);
const imageLoading = ref(true);
const { placeholder, extract } = useColorExtraction();
const faviconUrl = `https://icons.duckduckgo.com/ip3/${props.bookmark.domain}.ico`;
const cacheKey = `fav_${props.bookmark.domain}`;
const onImageError = async () => {
  imageError.value = true;
  imageLoading.value = false;
  extract(faviconUrl, cacheKey);
};

onMounted(async () => {
  if (!props.bookmark.image) {
    imageLoading.value = false;
    extract(faviconUrl, cacheKey);
  }
});
const gradientClasses = [
  'gradient-cyan-blue',
  'gradient-indigo-violet',
  'gradient-sky-cyan',
  'gradient-amber-orange',
  'gradient-rose-pink',
  'gradient-emerald-teal',
  'gradient-purple-fuchsia',
  'gradient-violet-purple',
  'gradient-blue-cyan',
  'gradient-pink-rose',
];

const randomGradient = computed(() => gradientClasses[Math.floor(Math.random() * gradientClasses.length)]);

const glowClass = computed(() => `glow-${randomGradient.value.replace('gradient-', '')}`);
</script>

<style scoped>
@keyframes gradient-shift {
  0%, 100% {
    background-position: 0% 50%;
  }
  50% {
    background-position: 100% 50%;
  }
}

.group:hover .animated-gradient {
  background-size: 200% 200%;
  animation: gradient-shift 4s ease-in-out infinite;
}

/* Gradient classes with full definitions */
.gradient-cyan-blue {
  background: linear-gradient(to right, #22d3ee, #93c5fd, #22d3ee);
}

.gradient-indigo-violet {
  background: linear-gradient(to right, #818cf8, #c4b5fd, #818cf8);
}

.gradient-sky-cyan {
  background: linear-gradient(to right, #0ea5e9, #22d3ee, #0ea5e9);
}

.gradient-amber-orange {
  background: linear-gradient(to right, #fbbf24, #fdba74, #fbbf24);
}

.gradient-rose-pink {
  background: linear-gradient(to right, #f43f5e, #f9a8d4, #f43f5e);
}

.gradient-emerald-teal {
  background: linear-gradient(to right, #10b981, #5eead4, #10b981);
}

.gradient-purple-fuchsia {
  background: linear-gradient(to right, #a855f7, #e879f9, #a855f7);
}

.gradient-violet-purple {
  background: linear-gradient(to right, #8b5cf6, #a855f7, #8b5cf6);
}

.gradient-blue-cyan {
  background: linear-gradient(to right, #3b82f6, #22d3ee, #3b82f6);
}

.gradient-pink-rose {
  background: linear-gradient(to right, #ec4899, #f43f5e, #ec4899);
}

/* Glow effect classes */
.glow-cyan-blue {
  background: radial-gradient(circle, rgba(34, 211, 238, 0.5) 0%, rgba(59, 130, 246, 0.5) 40%, rgba(34, 211, 238, 0.5) 70%, transparent 100%);
}

.glow-indigo-violet {
  background: radial-gradient(circle, rgba(99, 102, 241, 0.5) 0%, rgba(139, 92, 246, 0.5) 40%, rgba(99, 102, 241, 0.5) 70%, transparent 100%);
}

.glow-sky-cyan {
  background: radial-gradient(circle, rgba(14, 165, 233, 0.5) 0%, rgba(34, 211, 238, 0.5) 40%, rgba(14, 165, 233, 0.5) 70%, transparent 100%);
}

.glow-amber-orange {
  background: radial-gradient(circle, rgba(251, 191, 36, 0.5) 0%, rgba(251, 146, 60, 0.5) 40%, rgba(251, 191, 36, 0.5) 70%, transparent 100%);
}

.glow-rose-pink {
  background: radial-gradient(circle, rgba(244, 63, 94, 0.5) 0%, rgba(236, 72, 153, 0.5) 40%, rgba(244, 63, 94, 0.5) 70%, transparent 100%);
}

.glow-emerald-teal {
  background: radial-gradient(circle, rgba(16, 185, 129, 0.5) 0%, rgba(20, 184, 166, 0.5) 40%, rgba(16, 185, 129, 0.5) 70%, transparent 100%);
}

.glow-purple-fuchsia {
  background: radial-gradient(circle, rgba(147, 51, 234, 0.5) 0%, rgba(217, 70, 239, 0.5) 40%, rgba(147, 51, 234, 0.5) 70%, transparent 100%);
}

.glow-violet-purple {
  background: radial-gradient(circle, rgba(139, 92, 246, 0.5) 0%, rgba(147, 51, 234, 0.5) 40%, rgba(139, 92, 246, 0.5) 70%, transparent 100%);
}

.glow-blue-cyan {
  background: radial-gradient(circle, rgba(59, 130, 246, 0.5) 0%, rgba(34, 211, 238, 0.5) 40%, rgba(59, 130, 246, 0.5) 70%, transparent 100%);
}

.glow-pink-rose {
  background: radial-gradient(circle, rgba(236, 72, 153, 0.5) 0%, rgba(244, 63, 94, 0.5) 40%, rgba(236, 72, 153, 0.5) 70%, transparent 100%);
}
</style>


================================================
FILE: src/ext/browser/index.html
================================================
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <link rel="icon" href="/favicon.ico">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>FavBox</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="app.js"></script>
  </body>
</html>


================================================
FILE: src/ext/browser/layouts/AppLayout.vue
================================================
<template>
  <div class="flex h-screen w-full scroll-smooth font-sans">
    <ASide :items="menu" />
    <main class="size-full overflow-hidden">
      <Suspense>
        <RouterView v-slot="{ Component, route }">
          <KeepAlive include="HealthCheckView">
            <component
              :is="Component"
              :key="route.name"
            />
          </KeepAlive>
        </RouterView>
      </Suspense>
    </main>
    <AppNotifications />
  </div>
</template>

<script setup>
import { onErrorCaptured } from 'vue';
import { notify } from 'notiwind';
import AppNotifications from '@/components/app/AppNotifications.vue';
import ASide from '@/ext/browser/components/ASide.vue';
import ClarityBookmarkLine from '~icons/clarity/bookmark-line';
import ClarityCopyLine from '~icons/clarity/copy-line';
import PhLinkBreak from '~icons/ph/link-break';
import ClarityClipboardOutlineBadged from '~icons/clarity/clipboard-outline-badged';

const menu = [
  { name: 'BookmarksView', label: 'Bookmarks', icon: ClarityBookmarkLine, tooltip: 'View all bookmarks' },
  { name: 'NotesView', label: 'Notes', icon: ClarityClipboardOutlineBadged, tooltip: 'Bookmarks with notes' },
  { name: 'HealthCheckView', label: 'Health Check', icon: PhLinkBreak, tooltip: 'Check broken links' },
  { name: 'DuplicatesView', label: 'Duplicates', icon: ClarityCopyLine, tooltip: 'Find duplicate bookmarks' },
];

onErrorCaptured((e) => {
  notify({ group: 'error', text: e.message }, 8500);
});
</script>

<style>
html, body {
  overflow: hidden;
}
</style>


================================================
FILE: src/ext/browser/router.js
================================================
import { createWebHashHistory, createRouter } from 'vue-router';

const routes = [
  {
    path: '/bookmarks',
    alias: '/ext/browser/index.html',
    name: 'BookmarksView',
    component: () => import('./views/BookmarksView.vue'),
    meta: {
      page: 1,
    },
  },
  {
    path: '/bookmarks/:id',
    name: 'BookmarkDetailView',
    component: () => import('./views/BookmarksView.vue'),
    meta: {
      page: 1,
    },
  },
  {
    path: '/notes',
    name: 'NotesView',
    component: () => import('./views/NotesView.vue'),
    meta: { page: 2 },
  },
  {
    path: '/health-check',
    name: 'HealthCheckView',
    component: () => import('./views/HealthCheckView.vue'),
    meta: { page: 3 },
  },
  {
    path: '/duplicates',
    name: 'DuplicatesView',
    component: () => import('./views/DuplicatesView.vue'),
    meta: { page: 4 },
  },
  {
    path: '/',
    redirect: '/bookmarks',
  },
  {
    path: '/:catchAll(.*)',
    redirect: '/bookmarks',
  },
];

const router = createRouter({
  history: createWebHashHistory(),
  routes,
});

export default router;


================================================
FILE: src/ext/browser/views/BookmarksView.vue
================================================
<template>
  <div class="flex w-full overflow-y-hidden dark:bg-black">
    <!-- Sidebar with Tailwind responsive classes -->
    <div class="hidden md:block w-68 max-w-68 flex-shrink-0 transition-all duration-300 ease-in-out">
      <div class="flex h-screen w-full max-w-64 flex-col border-r border-soft-400 bg-white p-2 dark:border-neutral-800 dark:bg-black transition-all duration-300 ease-in-out">
        <TabGroup>
          <TabList class="mb-2 flex gap-1 rounded-md bg-gray-100 p-1 dark:bg-neutral-900">
            <Tab
              v-slot="{ selected }"
              class="w-full focus:outline-none"
            >
              <div
                :class="selected ? 'bg-white shadow-sm dark:bg-neutral-800' : 'hover:bg-gray-200 dark:hover:bg-neutral-700'"
                class="rounded-md px-3 py-1.5 text-xs font-medium text-gray-700 transition-colors dark:text-neutral-300"
              >
                Search
              </div>
            </Tab>
            <Tab
              v-slot="{ selected }"
              class="w-full focus:outline-none"
            >
              <div
                :class="selected ? 'bg-white shadow-sm dark:bg-neutral-800' : 'hover:bg-gray-200 dark:hover:bg-neutral-700'"
                class="rounded-md px-3 py-1.5 text-xs font-medium text-gray-700 transition-colors dark:text-neutral-300"
              >
                Folders
              </div>
            </Tab>
          </TabList>

          <TabPanels class="flex-1 overflow-hidden">
            <TabPanel class="flex h-full flex-col">
              <AttributeList
                v-model="bookmarksQuery"
                v-model:sort="attributesSort"
                v-model:includes="attributesIncludes"
                v-model:term="attributesTerm"
                :items="attributesList"
                @paginate="skip => loadAttributes({ skip, append: true })"
              />
            </TabPanel>

            <TabPanel class="h-full overflow-hidden">
              <FolderTree
                v-model="bookmarksQuery"
                :folders="folderTree"
              />
            </TabPanel>
          </TabPanels>
        </TabGroup>
      </div>
    </div>

    <AppInfiniteScroll
      ref="scroll"
      class="flex h-screen w-full flex-col overflow-y-auto"
      :limit="PAGINATION_LIMIT"
      @scroll:end="loadMore"
    >
      <div class="sticky top-0 z-10 flex w-full flex-row flex-wrap items-center gap-2 bg-white/70 pb-3 pt-2 px-2 backdrop-blur-sm sm:gap-x-3 dark:bg-black/70">
        <SearchTerm
          ref="search"
          v-model="bookmarksQuery"
          :placeholder="bookmarksTotalPlaceholder"
          class="flex-1 min-w-0"
        />
        <div class="flex shrink-0 gap-x-2 sm:gap-x-3">
          <ViewMode
            v-model="viewMode"
            v-tooltip.bottom="{ content: 'Display mode' }"
          />
          <SortDirection
            v-model="bookmarksSort"
            v-tooltip.bottom="{ content: 'Sort direction' }"
          />
          <DatePicker v-model="selectedDate" />
        </div>
      </div>
      <div
        v-if="loading"
        class="flex flex-1 flex-col items-center justify-center p-5"
      >
        <AppSpinner class="size-12" />
      </div>
      <div
        v-else-if="bookmarksIsEmpty && !loading"
        class="flex flex-1 flex-col items-center justify-center p-5"
      >
        <span class="px-4 text-center text-lg font-thin text-black sm:text-2xl dark:text-white">
          🔍 No bookmarks match your search. Try changing the filters or keywords.
        </span>
      </div>
      <BookmarkLayout
        v-if="!loading"
        class="p-2"
        :display-type="viewMode"
      >
        <BookmarkCard
          v-for="bookmark in bookmarksList"
          :key="bookmark.id"
          :display-type="viewMode"
          :bookmark="bookmark"
          @on-remove="handleRemove"
          @on-edit="handleEdit"
          @on-pin="handlePin"
        />
      </BookmarkLayout>
    </AppInfiniteScroll>

    <AppDrawer ref="drawer">
      <template #title>
        Edit Bookmark
      </template>
      <template #content>
        <BookmarkForm
          :bookmark="bookmarksEditState.bookmark"
          :folders="bookmarksEditState.folders"
          :tags="bookmarksEditState.tags"
          class="w-full"
          @on-submit="handleSubmit"
        />
      </template>
    </AppDrawer>
    <BookmarksSync @on-sync="sync" />
    <AppConfirmation
      key="delete"
      ref="deleteConfirmation"
    >
      <template #title>
        Delete bookmark
      </template>
      <template #description>
        Are you sure you want to delete this bookmark? This action cannot be undone. Removing the bookmark from FavBox
        will also delete it from your browser.
      </template>
      <template #cancel>
        Cancel
      </template>
      <template #confirm>
        Delete
      </template>
    </AppConfirmation>
    <AppConfirmation
      key="screenshot"
      ref="screenshotRef"
    >
      <template #title>
        Take a screenshot
      </template>
      <template #description>
        The browser extension will open a new tab, wait for the page to load, then capture a screenshot and save the
        current preview.
      </template>
      <template #cancel>
        Cancel
      </template>
      <template #confirm>
        Ok
      </template>
    </AppConfirmation>
  </div>
</template>

<script setup>
import {
  reactive, ref, onMounted, computed, useTemplateRef, watch,
} from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { notify } from 'notiwind';
import { useStorage, useDebounceFn } from '@vueuse/core';
import { PAGINATION_LIMIT, NOTIFICATION_DURATION } from '@/constants/app';
import { TabGroup, TabList, Tab, TabPanels, TabPanel } from '@headlessui/vue';
import AppDrawer from '@/components/app/AppDrawer.vue';
import AppSpinner from '@/components/app/AppSpinner.vue';
import AttributeList from '@/ext/browser/components/AttributeList.vue';
import FolderTree from '@/ext/browser/components/FolderTree.vue';
import BookmarkStorage from '@/storage/bookmark';
import AttributeStorage from '@/storage/attribute';

import { getFolderTree } from '@/services/browserBookmarks';
import SearchTerm from '@/ext/browser/components/SearchTerm.vue';
import ViewMode from '@/ext/browser/components/ViewMode.vue';
import BookmarkLayout from '@/ext/browser/components/BookmarkLayout.vue';
import AppInfiniteScroll from '@/components/app/AppInfiniteScroll.vue';
import BookmarksSync from '@/ext/browser/components/BookmarksSync.vue';
import BookmarkCard from '@/ext/browser/components/card/BookmarkCard.vue';
import AppConfirmation from '@/components/app/AppConfirmation.vue';
import BookmarkForm from '@/ext/browser/components/BookmarkForm.vue';
import SortDirection from '@/ext/browser/components/SortDirection.vue';
import DatePicker from '@/ext/browser/components/DatePicker.vue';

const bookmarkStorage = new BookmarkStorage();
const attributeStorage = new AttributeStorage();

const route = useRoute();
const router = useRouter();

const drawerRef = useTemplateRef('drawer');
const scrollRef = useTemplateRef('scroll');
const deleteConfirmationRef = useTemplateRef('deleteConfirmation');
const screenshotRef = useTemplateRef('screenshotRef');
const searchRef = useTemplateRef('search');

const viewMode = useStorage('viewMode', 'masonry');
const loading = ref(false);

// Reactive state for bookmarks
const bookmarksList = ref([]);
const bookmarksTotal = ref(0);
const bookmarksQuery = ref(route.params.id ? [{ key: 'id', value: route.params.id }] : []);
const bookmarksSort = ref('desc');
const selectedDate = ref(null);

// Reactive state for attributes
const attributesSort = ref('count:desc');
const attributesTerm = ref('');
const attributesIncludes = reactive({ domain: true, tag: true, keyword: true });
const attributesList = ref([]);
const folderTree = ref([]);

const bookmarksEditState = reactive({
  bookmark: null,
  folders: [],
  tags: [],
});

const bookmarksIsEmpty = computed(() => bookmarksList.value.length === 0 && !loading.value);
const bookmarksTotalPlaceholder = computed(() => (bookmarksQuery.value.length ? '' : `🚀 Total: ${bookmarksTotal.value}. Search: tag:important domain:example.com`));

const load = async () => {
  try {
    loading.value = true;
    bookmarksList.value = await bookmarkStorage.search(bookmarksQuery.value, 0, PAGINATION_LIMIT, bookmarksSort.value);
  } catch (e) {
    console.error(e);
    notify({ group: 'error', text: 'Error loading bookmarks.' }, NOTIFICATION_DURATION);
  } finally {
    loading.value = false;
  }
};

const loadMore = async (offset) => {
  try {
    const more = await bookmarkStorage.search(bookmarksQuery.value, offset, PAGINATION_LIMIT, bookmarksSort.value);
    bookmarksList.value.push(...more);
  } catch (e) {
    console.error(e);
    notify({ group: 'error', text: 'Error loading bookmarks.' }, NOTIFICATION_DURATION);
  }
};

const sync = async () => {
  try {
    bookmarksList.value = [];
    loading.value = true;
    bookmarksTotal.value = await bookmarkStorage.total();
    bookmarksList.value = await bookmarkStorage.search(bookmarksQuery.value, 0, PAGINATION_LIMIT, bookmarksSort.value);
    attributesList.value = await attributeStorage.search(
      attributesIncludes,
      ...attributesSort.value.split(':'),
      attributesTerm.value,
      0,
      PAGINATION_LIMIT,
    );
  } catch (error) {
    console.error('Error refreshing bookmarks:', error);
    notify({ group: 'error', text: 'Failed to refresh bookmarks.' }, NOTIFICATION_DURATION);
  } finally {
    loading.value = false;
  }
};

const loadAttributes = useDebounceFn(async ({ skip = 0, limit = PAGINATION_LIMIT, append = false, includes = attributesIncludes, sort = attributesSort.value, term = attributesTerm.value } = {}) => {
  try {
    const [sortColumn, sortDirection] = sort.split(':');
    const newAttributes = await attributeStorage.search(
      includes,
      sortColumn,
      sortDirection,
      term,
      skip,
      limit,
    );
    if (append) {
      attributesList.value.push(...newAttributes);
    } else {
      attributesList.value = newAttributes;
    }
  } catch (e) {
    console.error('Error loading attributes:', e);
    notify({ group: 'error', text: 'Error loading attributes.' }, NOTIFICATION_DURATION);
  }
}, 100);

const handleRemove = async (bookmark) => {
  if (await deleteConfirmationRef.value.request() === false) {
    return;
  }
  try {
    const id = bookmark.id.toString();
    await browser.bookmarks.remove(id);
    bookmarksList.value = bookmarksList.value.filter((item) => item.id.toString() !== id);
    notify({ group: 'default', text: 'Bookmark successfully removed!' }, NOTIFICATION_DURATION);
    console.log(`Bookmark ${id} successfully removed`);
    const [sortColumn, sortDirection] = attributesSort.value.split(':');
    attributesList.value = await attributeStorage.search(attributesIncludes, sortColumn, sortDirection, attributesTerm.value, 0, PAGINATION_LIMIT);
  } catch (error) {
    console.error('Error removing bookmark:', error);
    notify({ group: 'error', text: 'Failed to remove bookmark. Please try again.' }, NOTIFICATION_DURATION);
  }

  // Silently load more bookmarks if needed, without showing spinner
  try {
    if (bookmarksList.value.length < PAGINATION_LIMIT) {
      const more = await bookmarkStorage.search(bookmarksQuery.value, bookmarksList.value.length, 1, bookmarksSort.value);
      if (more.length) bookmarksList.value.push(...more);
    }
  } catch (e) {
    console.error('Error loading additional bookmarks after removal:', e);
  }
};

const handleEdit = async (bookmark) => {
  try {
    bookmarksEditState.bookmark = JSON.parse(JSON.stringify(bookmark));
    drawerRef.value.open();
    const [tags, folders] = await Promise.all([bookmarkStorage.getTags(), getFolderTree()]);
    bookmarksEditState.tags = tags;
    bookmarksEditState.folders = folders;
  } catch (error) {
    console.error('Failed to load data:', error);
    notify({ group: 'error', text: 'Error loading data.' }, NOTIFICATION_DURATION);
  }
};

const handlePin = (bookmark) => {
  try {
    const status = bookmark.pinned ? 0 : 1;
    bookmark.pinned = status;
    bookmarkStorage.updatePinStatusById(bookmark.id, status);
    const message = status ? 'Added to notes!' : 'Removed from notes!';
    notify({ group: 'default', text: message }, NOTIFICATION_DURATION);
  } catch (e) {
    console.error(e);
    notify({ group: 'error', text: 'Failed to update pin status.' }, NOTIFICATION_DURATION);
  }
};

const handleSubmit = async (data) => {
  try {
    const id = String(data.id);
    await browser.bookmarks.update(id, { title: data.browserTitle });
    await browser.bookmarks.move(id, { parentId: String(data.folderId) });
    const bookmark = bookmarksList.value.find((item) => item.id === id);
    if (bookmark) {
      bookmark.title = data.title;
      bookmark.tags = data.tags;
      bookmark.folderId = data.folderId;
      bookmark.folderName = data.folderName;
    }
    notify({ group: 'default', text: 'Bookmark successfully saved!' }, NOTIFICATION_DURATION);
  } catch (error) {
    console.error('Failed to save bookmark:', error);
    notify({ group: 'error', text: 'Bookmark not saved.' }, NOTIFICATION_DURATION);
  } finally {
    drawerRef.value.close();
  }
};

browser.runtime.onMessage.addListener(async (message) => {
  if (message.action === 'refresh') {
    console.log('Refreshing bookmarks view...');
    const [attrs, folders, total, bookmarks] = await Promise.all([
      attributeStorage.search(attributesIncludes, ...attributesSort.value.split(':'), attributesTerm.value, 0, PAGINATION_LIMIT),
      getFolderTree(),
      bookmarkStorage.total(),
      bookmarkStorage.search(bookmarksQuery.value, 0, PAGINATION_LIMIT, bookmarksSort.value),
    ]);
    attributesList.value = attrs;
    folderTree.value = folders;
    bookmarksTotal.value = total;
    bookmarksList.value = bookmarks;
  }
});

watch(
  [bookmarksQuery, bookmarksSort],
  async ([query]) => {
    if (!query.some((f) => f.key === 'dateAdded')) {
      selectedDate.value = null;
    }
    await load();
    scrollRef.value?.scrollUp();
    if (bookmarksQuery.value.length === 0 && route.params.id) {
      router.replace({ path: '/bookmarks' });
    }
  },
  { immediate: false },
);

watch(selectedDate, (date) => {
  if (date) {
    const formatted = date.map((d) => d.toISOString().slice(0, 10));
    bookmarksQuery.value = [
      ...bookmarksQuery.value.filter((f) => f.key !== 'dateAdded'),
      { key: 'dateAdded', value: formatted.join('~') },
    ];
  }
});

watch(
  [attributesSort, attributesTerm, attributesIncludes],
  () => {
    loadAttributes({ skip: 0, append: false });
  },
  { immediate: false },
);

onMounted(async () => {
  try {
    loading.value = true;
    const [sortColumn, sortDirection] = attributesSort.value.split(':');
    const [result, totalResult, folders] = await Promise.all([
      attributeStorage.search(attributesIncludes, sortColumn, sortDirection, attributesTerm.value, 0, PAGINATION_LIMIT),
      bookmarkStorage.total(),
      getFolderTree(),
    ]);
    attributesList.value = result;
    bookmarksTotal.value = totalResult;
    folderTree.value = folders;
    await load();
    searchRef.value.focus();
  } catch (error) {
    console.error('Error during component mount:', error);
    notify({ group: 'error', text: 'Error initializing bookmarks view.' }, NOTIFICATION_DURATION);
  } finally {
    loading.value = false;
  }
});
</script>


================================================
FILE: src/ext/browser/views/DuplicatesView.vue
================================================
<template>
  <div class="h-screen w-full flex flex-col">
    <AppInfiniteScroll
      class="flex flex-col flex-1 overflow-y-auto bg-white dark:bg-black"
      :limit="PAGINATION_LIMIT"
      @scroll:end="loadMore"
    >
      <div
        class="sticky top-0 z-10 flex w-full flex-col  bg-white/70 p-4 backdrop-blur-sm dark:bg-black/50"
      >
        <div class="flex w-full items-center justify-between">
          <span class="text-xl font-extralight text-black dark:text-white">
            Total duplicate groups: <NumberFlow :value="total" />
          </span>
        </div>
      </div>
      <div
        v-if="loading || groups.length === 0"
        class="flex flex-1 flex-col items-center justify-center p-5"
      >
        <AppSpinner v-if="loading" />
        <div
          v-else
          class="text-2xl text-black dark:text-white"
        >
          🚀 No duplicate bookmarks found.
        </div>
      </div>
      <TransitionGroup
        v-show="groups.length > 0"
        enter-active-class="transition-opacity duration-200 ease-out"
        enter-from-class="opacity-0"
        enter-to-class="opacity-100"
        leave-active-class="transition-opacity duration-200 ease-in"
        leave-from-class="opacity-100"
        leave-to-class="opacity-0"
        move-class="transition-transform duration-200 ease-out"
        tag="div"
        class="flex flex-col gap-y-4 p-4"
      >
        <div
          v-for="group in groups"
          :key="group.url"
          class="rounded-md border border-solid bg-white shadow-xs dark:border-neutral-900 dark:bg-neutral-950 mb-3"
        >
          <div class="flex items-center justify-between w-full p-3 pb-0">
            <div class="flex items-center gap-x-2">
              <AppBadge color="gray">
                {{ group.count }}
              </AppBadge>
              <span class="text-xs text-gray-900 dark:text-white truncate w-full">
                <a
                  :href="group.url"
                  class="block max-w-xs md:max-w-md lg:max-w-2xl truncate hover:underline focus:underline"
                  target="_blank"
                  rel="noopener noreferrer"
                  :title="group.url"
                >
                  {{ group.url }}
                </a>
              </span>
            </div>
          </div>
          <div class="flex flex-col gap-y-3 p-3 pt-2">
            <DuplicateCard
              v-for="bookmark in group.bookmarks"
              :key="bookmark.id"
              :bookmark="bookmark"
              @onDelete="onDelete"
            />
          </div>
        </div>
      </TransitionGroup>
    </AppInfiniteScroll>
    <AppConfirmation ref="confirmation">
      <template #title>
        Delete bookmark
      </template>
      <template #description>
        Are you sure you want to delete this bookmark? This action cannot be undone. Removing the bookmark from FavBox will also delete it from your browser.
      </template>
      <template #cancel>
        Cancel
      </template>
      <template #confirm>
        Delete
      </template>
    </AppConfirmation>
  </div>
</template>

<script setup>
import { ref, onMounted, useTemplateRef } from 'vue';
import { notify } from 'notiwind';
import { PAGINATION_LIMIT, NOTIFICATION_DURATION } from '@/constants/app';
import BookmarkStorage from '@/storage/bookmark';
import AppInfiniteScroll from '@/components/app/AppInfiniteScroll.vue';
import AppSpinner from '@/components/app/AppSpinner.vue';
import AppBadge from '@/components/app/AppBadge.vue';
import DuplicateCard from '@/ext/browser/components/card/DuplicateCard.vue';
import AppConfirmation from '@/components/app/AppConfirmation.vue';
import NumberFlow from '@number-flow/vue';

const bookmarkStorage = new BookmarkStorage();

const loading = ref(true);
const groups = ref([]);
const total = ref(0);
const confirmationRef = useTemplateRef('confirmation');
const deletedGroupsCount = ref(0);

const load = async () => {
  try {
    loading.value = true;
    const result = await bookmarkStorage.getDuplicatesGrouped(0, PAGINATION_LIMIT);
    groups.value = result.groups;
    total.value = result.total;
  } catch (error) {
    console.error('Error loading duplicates:', error);
    notify({ group: 'error', text: 'Error loading duplicates' }, NOTIFICATION_DURATION);
  } finally {
    loading.value = false;
  }
};

const loadMore = async (offset) => {
  try {
    const result = await bookmarkStorage.getDuplicatesGrouped(offset, PAGINATION_LIMIT);
    groups.value.push(...result.groups);
  } catch (error) {
    console.error('Error loading more duplicates:', error);
  }
};

const findBookmarkInGroups = (bookmarkId) => {
  for (let i = 0; i < groups.value.length; i++) {
    const group = groups.value[i];
    const bookmarkIndex = group.bookmarks.findIndex((b) => b.id === bookmarkId);
    if (bookmarkIndex !== -1) {
      return { groupIndex: i, bookmarkIndex };
    }
  }
  return null;
};

const removeBookmarkFromGroup = (groupIndex, bookmarkIndex) => {
  const group = groups.value[groupIndex];
  group.bookmarks.splice(bookmarkIndex, 1);

  const isGroupEmpty = group.bookmarks.length <= 1;
  if (isGroupEmpty) {
    groups.value.splice(groupIndex, 1);
    total.value -= 1;
    deletedGroupsCount.value += 1;
  }
  return isGroupEmpty;
};

const loadOneMoreGroup = async () => {
  const offset = groups.value.length + deletedGroupsCount.value;
  if (offset >= total.value) return;

  const result = await bookmarkStorage.getDuplicatesGrouped(offset, 1);
  if (result.groups.length > 0) {
    groups.value.push(result.groups[0]);
  }
};

const onDelete = async (bookmark) => {
  if (await confirmationRef.value.request() === false) return;

  try {
    await browser.bookmarks.remove(String(bookmark.id));

    const location = findBookmarkInGroups(bookmark.id);
    if (!location) return;

    const groupRemoved = removeBookmarkFromGroup(location.groupIndex, location.bookmarkIndex);
    if (groupRemoved) {
      await loadOneMoreGroup();
    }

    notify({ group: 'default', text: 'Bookmark deleted.' }, NOTIFICATION_DURATION);
  } catch (error) {
    console.error('Error deleting bookmark:', error);
    notify({ group: 'error', text: 'Error deleting bookmark' }, NOTIFICATION_DURATION);
  }
};

onMounted(load);
</script>


================================================
FILE: src/ext/browser/views/HealthCheckView.vue
================================================
<template>
  <AppInfiniteScroll
    class="flex h-screen w-full flex-col overflow-y-auto bg-white dark:bg-black"
    :limit="PAGINATION_LIMIT"
    @scroll:end="loadMore"
  >
    <div
      class="sticky top-0 z-10 flex w-full flex-col border-solid bg-white/70 p-4 backdrop-blur-sm dark:bg-black/50"
    >
      <div class="flex w-full items-center justify-between">
        <span
          class="text-xl font-extralight text-black dark:text-white"
        >
          Total: <NumberFlow :value="total" />
        </span>
        <div class="flex gap-x-3">
          <AppButton
            v-if="scanning"
            variant="gray"
            @click="stop"
          >
            Stop
          </AppButton>
          <AppButton
            v-else
            @click="scan"
          >
            Scan bookmarks
          </AppButton>
        </div>
      </div>
      <AppProgress
        v-if="scanning"
        :progress="progress"
        class="mt-3 w-full"
      />
    </div>
    <div
      v-if="loading || bookmarks.length === 0"
      class="flex flex-1 flex-col items-center justify-center p-5"
    >
      <AppSpinner v-if="loading" />
      <div
        v-else
        class="text-2xl font-thin text-black dark:text-white"
      >
        ✅ Looks like there are no broken bookmarks in your browser.
      </div>
    </div>
    <TransitionGroup
      v-show="bookmarks.length > 0"
      enter-active-class="transition-opacity duration-200 ease-out"
      enter-from-class="opacity-0"
      enter-to-class="opacity-100"
      leave-active-class="transition-opacity duration-200 ease-in"
      leave-from-class="opacity-100"
      leave-to-class="opacity-0"
      move-class="transition-transform duration-200 ease-out"
      tag="div"
      class="flex flex-col gap-y-3 p-4"
    >
      <HealthCheckCard
        v-for="bookmark in bookmarks"
        :key="bookmark.id"
        :bookmark="bookmark"
        @on-delete="onDelete"
      />
    </TransitionGroup>
    <AppConfirmation ref="confirmation">
      <template #title>
        Delete bookmark
      </template>
      <template #description>
        Are you sure you want to delete this bookmark? This action cannot be undone. Removing the bookmark from FavBox
        will also delete it from your browser.
      </template>
      <template #cancel>
        Cancel
      </template>
      <template #confirm>
        Delete
      </template>
    </AppConfirmation>
  </AppInfiniteScroll>
</template>

<script setup>
import { ref, onMounted, useTemplateRef, onActivated } from 'vue';
import NumberFlow from '@number-flow/vue';
import { notify } from 'notiwind';
import BookmarkStorage from '@/storage/bookmark';
import AppConfirmation from '@/components/app/AppConfirmation.vue';
import AppInfiniteScroll from '@/components/app/AppInfiniteScroll.vue';
import HealthCheckCard from '@/ext/browser/components/card/HealthCheckCard.vue';
import { HTTP_STATUS } from '@/constants/httpStatus';
import { PAGINATION_LIMIT, NOTIFICATION_DURATION } from '@/constants/app';
import { fetchUrl, fetchHead } from '@/services/httpClient';
import AppButton from '@/components/app/AppButton.vue';
import AppProgress from '@/components/app/AppProgress.vue';
import AppSpinner from '@/components/app/AppSpinner.vue';

const bookmarkStorage = new BookmarkStorage();
const bookmarks = ref([]);
const confirmationRef = useTemplateRef('confirmation');
const total = ref(0);
const loading = ref(true);
const scanning = ref(false);
const progress = ref(0);

const httpStatuses = [
  HTTP_STATUS.NOT_FOUND,
  HTTP_STATUS.SERVICE_UNAVAILABLE,
  HTTP_STATUS.INTERNAL_SERVER_ERROR,
  HTTP_STATUS.GATEWAY_TIMEOUT,
  HTTP_STATUS.BAD_GATEWAY,
  HTTP_STATUS.WEB_SERVER_IS_DOWN,
  HTTP_STATUS.GONE,
  HTTP_STATUS.REQUEST_TIMEOUT,
];

const scan = async () => {
  scanning.value = true;
  progress.value = 0;

  const totalBookmarks = await bookmarkStorage.total();
  if (totalBookmarks === 0) {
    scanning.value = false;
    return;
  }

  let processed = 0;
  let id = null;

  while (scanning.value) {
    const batch = await bookmarkStorage.findAfterId(id, PAGINATION_LIMIT);
    if (batch.length === 0) break;

    processed += batch.length;
    progress.value = Math.ceil((processed / totalBookmarks) * 100);

    const results = await Promise.all(
      batch.map(async (bookmark) => {
        let httpStatus = await fetchHead(bookmark.url, 15000);
        if (httpStatus === HTTP_STATUS.NOT_FOUND) {
          httpStatus = (await fetchUrl(bookmark.url, 15000)).httpStatus;
        }
        return httpStatus >= HTTP_STATUS.BAD_REQUEST
          ? { httpStatus, id: bookmark.id }
          : null;
      }),
    );

    const broken = results.filter(Boolean);
    if (broken.length) {
      await Promise.all(
        broken.map((r) => bookmarkStorage.updateHttpStatusById(r.id, r.httpStatus)),
      );
      total.value = await bookmarkStorage.getTotalByHttpStatus(httpStatuses);
      bookmarks.value = await bookmarkStorage.findByHttpStatus(httpStatuses, 0, PAGINATION_LIMIT);
    }

    id = batch[batch.length - 1].id;
  }

  if (scanning.value) {
    progress.value = 100;
  }
  scanning.value = false;
};

const stop = () => {
  scanning.value = false;
  progress.value = 0;
};

const load = async () => {
  try {
    loading.value = true;
    bookmarks.value = await bookmarkStorage.findByHttpStatus(httpStatuses, 0, PAGINATION_LIMIT);
    total.value = await bookmarkStorage.getTotalByHttpStatus(httpStatuses);
  } catch (e) {
    console.error(e);
  } finally {
    loading.value = false;
  }
};

const loadMore = async (offset) => {
  try {
    const more = await bookmarkStorage.findByHttpStatus(httpStatuses, offset, PAGINATION_LIMIT);
    bookmarks.value.push(...more);
  } catch (e) {
    console.error(e);
  }
};

const onDelete = async (bookmark) => {
  if (await confirmationRef.value.request() === false) {
    return;
  }
  try {
    await browser.bookmarks.remove(String(bookmark.id));
    bookmarks.value = bookmarks.value.filter((b) => b.id !== bookmark.id);
    total.value = await bookmarkStorage.getTotalByHttpStatus(httpStatuses);
    notify({ group: 'default', text: 'Bookmark successfully removed!' }, NOTIFICATION_DURATION);
  } catch (e) {
    console.error(e);
    notify({ group: 'error', text: 'Failed to remove bookmark. Please try again.' }, NOTIFICATION_DURATION);
  }

  try {
    if (bookmarks.value.length < PAGINATION_LIMIT) {
      const more = await bookmarkStorage.findByHttpStatus(httpStatuses, bookmarks.value.length, 1);
      if (more.length) bookmarks.value.push(...more);
    }
  } catch (e) {
    console.error(e);
  }
};

onMounted(load);
onActivated(async () => {
  total.value = 0;
  total.value = await bookmarkStorage.getTotalByHttpStatus(httpStatuses);
});
</script>


================================================
FILE: src/ext/browser/views/NotesView.vue
================================================
<template>
  <div class="flex flex-col md:flex-row h-screen md:h-screen">
    <div class="w-full md:w-1/3 gap-y-3 border-b md:border-b-0 md:border-r border-gray-300 bg-white dark:border-neutral-900 dark:bg-black">
      <div class="relative w-full p-2">
        <input
          id="title"
          v-model="searchTerm"
          type="text"
          placeholder="Search something.."
          class="h-9 w-full rounded-md border-gray-200 pl-7 text-xs text-gray-700 shadow-sm outline-none focus:border-gray-300 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-900 dark:text-white focus:dark:border-neutral-700"
        >
        <span class="pointer-events-none absolute inset-y-0 left-1 grid w-10 place-content-center text-black dark:text-white">
          <PhMagnifyingGlassLight />
        </span>
      </div>
      <AppInfiniteScroll
        ref="scroll"
        class="h-72 md:h-full overflow-y-auto"
        :limit="PAGINATION_LIMIT"
        @scroll:end="loadMoreBookmarks"
      >
        <TransitionGroup
          enter-active-class="transition-opacity duration-200 ease-out"
          enter-from-class="opacity-0"
          enter-to-class="opacity-100"
          leave-active-class="transition-opacity duration-200 ease-in"
          leave-from-class="opacity-100"
          leave-to-class="opacity-0"
          move-class="transition-transform duration-200 ease-out"
          tag="ul"
          class="w-full flex flex-col cursor-pointer gap-y-2 px-2 pb-20 mt-1"
          role="listbox"
        >
          <li
            v-for="bookmark in bookmarks"
            :key="`${bookmark.id}-${bookmark.updatedAt}`"
            role="option"
            :aria-selected="bookmark.id === currentBookmarkId"
            tabindex="0"
            class="focus:outline-none focus-visible:ring-1 focus-visible:ring-gray-400 dark:focus-visible:ring-gray-500 rounded-md"
            @click="openEditor(bookmark)"
            @keydown.enter="openEditor(bookmark)"
          >
            <PinnedCard
              :bookmark="bookmark"
              :active="bookmark.id === currentBookmarkId"
              @open="open"
              @pin="unpin"
            />
          </li>
        </TransitionGroup>
      </AppInfiniteScroll>
    </div>
    <div class="flex flex-1 flex-col overflow-y-auto bg-white px-2 dark:bg-black h-full md:h-screen">
      <TextEditor
        v-if="currentBookmark"
        v-model="editorNotes"
        class="w-full"
      />
      <div
        v-else
        class="flex flex-1 flex-col items-center justify-center p-5"
      >
        <AppSpinner v-if="loading" />
        <span
          v-else-if="isEmpty"
          class="text-2xl font-thin text-black dark:text-white"
        >
          🗂️ Your <u>local storage</u> is currently empty. Please pin bookmarks to get started.
        </span>
        <span
          v-else
          class="text-2xl font-thin text-black dark:text-white"
        >
          📌 Select a bookmark to start editing.
        </span>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, watch, useTemplateRef, computed } from 'vue';
import { useDebounceFn } from '@vueuse/core';
import { notify } from 'notiwind';
import { PAGINATION_LIMIT, NOTIFICATION_DURATION } from '@/constants/app';
import BookmarkStorage from '@/storage/bookmark';
import AppInfiniteScroll from '@/components/app/AppInfiniteScroll.vue';
import PinnedCard from '@/ext/browser/components/card/PinnedCard.vue';
import TextEditor from '@/ext/browser/components/TextEditor.vue';
import AppSpinner from '@/components/app/AppSpinner.vue';
import PhMagnifyingGlassLight from '~icons/ph/magnifying-glass-light';

const bookmarkStorage = new BookmarkStorage();
const scrollRef = useTemplateRef('scroll');

const bookmarks = ref([]);
const searchTerm = ref('');
const currentBookmarkId = ref(null);
const editorNotes = ref('');
const loading = ref(true);

const currentBookmark = computed(() => {
  if (!currentBookmarkId.value) return null;
  return bookmarks.value.find((b) => b.id === currentBookmarkId.value) || null;
});

const isEmpty = computed(() => !loading.value && bookmarks.value.length === 0);

const saveNotes = async (bookmarkId, notes) => {
  try {
    await bookmarkStorage.updateNotesById(bookmarkId, notes);

    const bookmarkIndex = bookmarks.value.findIndex((b) => b.id === bookmarkId);
    if (bookmarkIndex !== -1) {
      bookmarks.value[bookmarkIndex].notes = notes;
    }
  } catch (error) {
    console.error('Error saving notes:', error);
    notify(
      { group: 'error', text: 'Error saving notes.' },
      NOTIFICATION_DURATION,
    );
  }
};

const closeEditor = async () => {
  if (currentBookmarkId.value && editorNotes.value !== undefined) {
    await saveNotes(currentBookmarkId.value, editorNotes.value);
  }
  currentBookmarkId.value = null;
  editorNotes.value = '';
};

const loadBookmarks = async () => {
  try {
    loading.value = true;
    const newBookmarks = await bookmarkStorage.findPinned(
      0,
      PAGINATION_LIMIT,
      searchTerm.value,
    );
    bookmarks.value = newBookmarks;

    if (currentBookmarkId.value && !newBookmarks.some((b) => b.id === currentBookmarkId.value)) {
      await closeEditor();
    }
  } catch (error) {
    console.error('Error loading bookmarks:', error);
    notify(
      { group: 'error', text: 'Error loading data.' },
      NOTIFICATION_DURATION,
    );
  } finally {
    loading.value = false;
  }
};

const loadMoreBookmarks = async (offset) => {
  try {
    const newBookmarks = await bookmarkStorage.findPinned(
      offset,
      PAGINATION_LIMIT,
      searchTerm.value,
    );
    bookmarks.value.push(...newBookmarks);
  } catch (error) {
    console.error('Error loading more bookmarks:', error);
    notify(
      { group: 'error', text: 'Error loading data.' },
      NOTIFICATION_DURATION,
    );
  }
};

const performSearch = async () => {
  await loadBookmarks();
  scrollRef.value?.scrollUp();
};

const debouncedSearch = useDebounceFn(performSearch, 300);

const open = (bookmark) => {
  window.open(bookmark.url, '_blank');
};

const unpin = async (bookmark) => {
  try {
    await bookmarkStorage.updatePinStatusById(bookmark.id, 0);

    if (currentBookmarkId.value === bookmark.id) {
      await closeEditor();
    }

    await loadBookmarks();
  } catch (error) {
    console.error('Error updating pin status:', error);
    notify(
      { group: 'error', text: 'Error updating bookmark.' },
      NOTIFICATION_DURATION,
    );
  }
};

const openEditor = async (bookmark) => {
  const previousBookmarkId = currentBookmarkId.value;

  if (previousBookmarkId && previousBookmarkId !== bookmark.id && editorNotes.value !== undefined) {
    await saveNotes(previousBookmarkId, editorNotes.value);
  }

  currentBookmarkId.value = bookmark.id;
  editorNotes.value = bookmark.notes || '';
};

watch(searchTerm, debouncedSearch);
watch(editorNotes, (newNotes) => {
  if (!currentBookmarkId.value) return;
  if (newNotes === currentBookmark.value?.notes) return;
  saveNotes(currentBookmarkId.value, newNotes);
});

onMounted(loadBookmarks);
</script>


================================================
FILE: src/ext/content/content.js
================================================
let port;
/**
 *
 */
function connect() {
  console.warn('Keep alive connection..');
  port = browser.runtime.connect({ name: 'favbox' });
  port.onDisconnect.addListener(connect);
  port.onMessage.addListener((msg) => {
    console.log('received', msg, 'from bg');
  });
  port.postMessage({ action: 'ping' });
}
try {
  connect();
} catch (e) {
  console.error('Content script error', e);
}

browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.action === 'getHTML') {
    const headElement = document.head;
    const headHTML = headElement ? headElement.outerHTML : '';
    const completeHTML = `<!DOCTYPE html><html>${headHTML}<body></body></html>`;
    sendResponse({ html: completeHTML });
  }
});

console.log('</> Content script loaded');


================================================
FILE: src/ext/popup/App.vue
================================================
<template>
  <Suspense>
    <PopupView />
  </Suspense>
</template>
<script setup>
import PopupView from '@/ext/popup/PopupView.vue';
</script>


================================================
FILE: src/ext/popup/PopupView.vue
================================================
<template>
  <div class="relative inset-0 flex h-full min-h-64 min-w-96 flex-col">
    <div class="flex items-center justify-between bg-white p-3 dark:bg-black">
      <div class="flex items-center gap-3">
        <div class="flex size-12 items-center justify-center rounded-lg bg-gradient-to-r from-gray-800 to-gray-900 text-lg font-bold text-white shadow-md dark:bg-gradient-to-r dark:from-gray-100 dark:to-gray-300 dark:text-black">
          <RiBookmarkFill class="size-6" />
        </div>
        <h4 class="font-sans text-xl font-semibold tracking-tight dark:text-white">
          FavBox
        </h4>
      </div>
      <div class="group relative inline-flex items-center justify-center">
        <div class="absolute inset-0 animate-pulse rounded-md bg-gradient-to-r from-red-500 via-blue-500 to-green-500 opacity-0 blur-lg transition-all duration-500 group-hover:opacity-100 group-hover:blur-md group-hover:duration-200" />
        <button
          class="group relative inline-flex items-center justify-center rounded-md border border-black bg-white px-4 py-2 text-xs font-medium text-black transition-all duration-200 hover:-translate-y-0.5 hover:bg-black hover:text-white hover:shadow-lg dark:border-white dark:bg-black dark:text-white dark:hover:bg-white dark:hover:text-black"
          @click="openApp"
        >
          Open App
          <RiArrowRightSLine class="-mr-1 ml-2 mt-0.5 size-2.5 text-black transition-colors duration-200 group-hover:text-white dark:text-white dark:group-hover:text-black" />
        </button>
      </div>
    </div>
    <div
      v-if="exists"
      class="flex grow flex-col items-center justify-center bg-white p-6 dark:bg-neutral-950"
    >
      <div class="mb-8 flex flex-col items-center justify-center text-center">
        <div class="mb-4 flex size-16 items-center justify-center rounded-full bg-gray-100 dark:bg-gray-800">
          <LineMdConfirm class="size-8 text-gray-900 dark:text-white" />
        </div>
        <h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">
          Bookmark Exists!
        </h3>
        <p class="text-sm text-gray-600 dark:text-gray-400">
          This page is already saved in your bookmarks
        </p>
      </div>

      <button
        class="group relative w-auto cursor-pointer overflow-hidden rounded-md border border-black bg-black p-2 px-6 text-center font-semibold text-white transition-all duration-300 hover:border-gray-800 dark:border-white dark:bg-white dark:text-black dark:hover:border-gray-200"
        @click="openAppWithBookmark"
      >
        <div class="flex items-center gap-2">
          <div
            class="size-2 scale-100 rounded-lg bg-white transition-all duration-300 group-hover:scale-[100.8] dark:bg-black"
          />
          <span
            class="inline-block whitespace-nowrap transition-all duration-300 group-hover:translate-x-12 group-hover:opacity-0"
          >
            View in FavBox
          </span>
        </div>

        <div
          class="absolute top-0 z-10 flex size-full translate-x-12 items-center justify-center gap-2 text-black opacity-0 transition-all duration-300 group-hover:-translate-x-5 group-hover:opacity-100 dark:text-white"
        >
          <span class="whitespace-nowrap">View in FavBox</span>
          <RiArrowRightLine class="size-4" />
        </div>
      </button>
    </div>
    <div
      v-else
      class="flex grow flex-col gap-3 bg-white p-3 dark:bg-black"
    >
      <div
        v-if="errorMessage"
        class="flex items-center rounded bg-red-100 px-3 py-2 text-xs text-red-500 shadow-xs dark:bg-red-900 dark:text-red-200"
      >
        <span>{{ errorMessage }}</span>
      </div>
      <BookmarkForm
        class="w-full"
        :title="tab.title"
        :favicon="tab.favIconUrl"
        :url="tab.url"
        :folders="folders"
        :tags="tags"
        @submit="handleSubmit"
      />
    </div>
  </div>
</template>

<script setup>
import { onMounted, ref } from 'vue';
import BookmarkForm from '@/ext/popup/components/BookmarkForm.vue';
import { getFolderTree } from '@/services/browserBookmarks';
import BookmarkStorage from '@/storage/bookmark';
import LineMdConfirm from '~icons/line-md/confirm?width=24px&height=24px';
import RiBookmarkFill from '~icons/ri/bookmark-fill';
import RiArrowRightLine from '~icons/ri/arrow-right-line';
import RiArrowRightSLine from '~icons/ri/arrow-right-s-line';

const tags = await (new BookmarkStorage()).getTags();
const folders = await getFolderTree();
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
const exists = ref(false);
const bookmarkId = ref(null);
const errorMessage = ref('');

try {
  const bookmarks = await browser.bookmarks.search({ url: tab.url });
  exists.value = bookmarks.length > 0;
  if (bookmarks.length > 0) {
    const [firstBookmark] = bookmarks;
    bookmarkId.value = firstBookmark.id;
  }
} catch (e) {
  exists.value = false;
  console.error(e);
}

console.debug('folders', folders);
console.debug('tab', tab);
console.debug('bookmarkId', bookmarkId.value);

const handleSubmit = async (data) => {
  try {
    errorMessage.value = '';
    await browser.bookmarks.create({
      title: data.title,
      parentId: data.parentId,
      url: data.url,
    });
    window.close();
  } catch (e) {
    errorMessage.value = "😔 Oops, something went wrong. Please try again, or use your browser's built-in bookmark tool.";
    console.error(e);
  }
};

const openApp = () => {
  browser.tabs.create({ url: '/ext/browser/index.html', index: tab.index + 1 });
  window.close();
};

const openAppWithBookmark = () => {
  const url = `/ext/browser/index.html#/bookmarks/${bookmarkId.value}`;
  browser.tabs.create({ url, index: tab.index + 1 });
  window.close();
};

onMounted(async () => {
  if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
    document.documentElement.classList.add('dark');
  } else {
    document.documentElement.classList.remove('dark');
  }
});
</script>


================================================
FILE: src/ext/popup/components/BookmarkForm.vue
================================================
<template>
  <form
    @submit.prevent="submit"
  >
    <div class="flex flex-col gap-y-3">
      <label
        for="title"
        class="relative"
      >
        <input
          id="title"
          v-model="bookmarkTitle"
          required
          type="text"
          placeholder="Page title"
          class="h-9 w-full rounded-md border-gray-200 pl-10 text-xs text-black shadow-sm outline-none focus:border-gray-300 focus:ring-0  dark:border-neutral-800 dark:bg-neutral-900 dark:text-white focus:dark:border-neutral-700"
        >
        <div class="pointer-events-none absolute inset-y-0 left-0 grid w-10 place-content-center text-gray-700">
          <img
            v-if="favicon"
            class="size-5"
            :src="favicon"
            alt="favicon"
          >
          <PhGlobeSimpleLight
            v-else
            class="size-5"
          />
        </div>
      </label>
      <Treeselect
        v-model="selectedFolder"
        placeholder=""
        :before-clear-all="onBeforeClearAll"
        :always-open="false"
        :options="folders"
      />
      <AppTagInput
        v-model="selectedTags"
        :max="5"
        :suggestions="tags"
        placeholder="Tag it and press enter 🏷️"
      />
      <div class="my-0 flex w-full justify-between">
        <AppButton
          class="w-full"
        >
          Save bookmark
        </AppButton>
      </div>
    </div>
  </form>
</template>
<script setup>
import { ref, watch } from 'vue';

import Treeselect from '@zanmato/vue3-treeselect';
import AppTagInput from '@/components/app/AppTagInput.vue';
import AppButton from '@/components/app/AppButton.vue';
import { joinTitleAndTags } from '@/services/tags';
import PhGlobeSimpleLight from '~icons/ph/globe-simple-light';

const props = defineProps({
  folders: {
    type: Array,
    required: true,
    default: () => [],
  },
  tags: {
    type: Array,
    required: true,
    default: () => [],
  },
  favicon: {
    type: String,
    required: true,
  },
  title: {
    type: String,
    required: true,
  },
  url: {
    type: String,
    required: true,
  },
});

const bookmarkTitle = ref(props.title);

watch(() => props.title, (newTitle) => {
  bookmarkTitle.value = newTitle;
});
const selectedFolder = ref(1);
const selectedTags = ref([]);

const onBeforeClearAll = () => {
  selectedFolder.value = 1;
};

const emit = defineEmits(['submit']);

const submit = () => {
  emit('submit', { title: joinTitleAndTags(bookmarkTitle.value, selectedTags.value), url: props.url, parentId: String(selectedFolder.value) });
};
</script>


================================================
FILE: src/ext/popup/index.html
================================================
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8" />
  <link rel="icon" href="/favicon.ico" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Vite App</title>
</head>

<body>
  <div id="app"></div>
  <script type="module" src="./main.js"></script>
</body>

</html>

================================================
FILE: src/ext/popup/main.js
================================================
import { createApp } from 'vue';
import App from './App.vue';
import '@/assets/app.css';
import '@zanmato/vue3-treeselect/dist/vue3-treeselect.min.css';
import '@fontsource/sn-pro';

createApp(App).mount('#app');


================================================
FILE: src/ext/sw/index.js
================================================
import BookmarkStorage from '@/storage/bookmark';
import AttributeStorage from '@/storage/attribute';
import MetadataParser from '@/parser/metadata';
import { fetchUrl } from '@/services/httpClient';
import { extractTitle, extractTags } from '@/services/tags';
import { getFoldersMap, getBookmarksFromNode } from '@/services/browserBookmarks';
import sync from './sync';
import ping from './ping';

const bookmarkStorage = new BookmarkStorage();
const attributeStorage = new AttributeStorage();

// https://developer.chrome.com/docs/extensions/develop/migrate/to-service-workers
const waitUntil = async (promise) => {
  const keepAlive = setInterval(browser.runtime.getPlatformInfo, 25 * 1000);
  try {
    await promise;
  } finally {
    clearInterval(keepAlive);
  }
};

browser.runtime.onInstalled.addListener(async () => {
  browser.contextMenus.create({ id: 'openPopup', title: 'Bookmark this page', contexts: ['all'] });
  await browser.alarms.create('healthcheck', { periodInMinutes: 0.5 });
  await browser.storage.session.set({ nativeImport: false });
  waitUntil(sync());
});

browser.runtime.onStartup.addListener(async () => {
  console.warn('Wake up..');
  await browser.storage.session.set({ nativeImport: false });
  const alarm = await browser.alarms.get('healthcheck');
  if (!alarm) {
    await browser.alarms.create('healthcheck', { periodInMinutes: 0.5 });
  }
  waitUntil(sync());
});

browser.alarms.onAlarm.addListener(async (alarm) => {
  if (alarm.name === 'healthcheck') {
    console.log('health check');
    await browser.storage.local.set({ lastHealthCheck: Date.now() });
  }
});

browser.contextMenus.onClicked.addListener((info) => {
  if (info.menuItemId === 'openPopup') {
    browser.action.openPopup();
  }
});

// https:// developer.browser.com/docs/extensions/reference/bookmarks/#event-onCreated
browser.bookmarks.onCreated.addListener(async (id, bookmark) => {
  const { nativeImport } = await browser.storage.session.get('nativeImport');
  if (nativeImport === true) {
    return;
  }
  console.time(`bookmark-created-${id}`);
  console.warn('🎉 Handle bookmark create..', id, bookmark);
  if (bookmark.url === undefined) {
    console.warn('bad bookmark data', bookmark);
    return;
  }
  let response = null;
  let activeTab = null;

  // fetch HTML from active tab (content script)
  [activeTab] = await browser.tabs.query({ active: true });
  try {
    console.warn('activeTab', activeTab);
    console.warn('requesting html from tab', activeTab);
    const content = await browser.tabs.sendMessage(activeTab.id, { action: 'getHTML' });
    response = { html: content?.html, error: 0 };
    console.warn('response from tab', response);
  } catch (e) {
    console.error('No tabs. It is weird. Fetching data from internet.. 🌎', e);
    response = await fetchUrl(bookmark.url, 15000);
  }

  try {
    if (response === null) {
      throw new Error('No page data: response is null');
    }
    const foldersMap = await getFoldersMap();
    const entity = await (new MetadataParser(bookmark, response, foldersMap)).getFavboxBookmark();
    if (entity.image === null && activeTab) {
      try {
        console.warn('📸 No image, take a screenshot', activeTab);
        const screenshot = await browser.tabs.captureVisibleTab(activeTab.windowId, { format: 'jpeg', quality: 10 });
        entity.image = screenshot;
      } catch (e) {
        console.error('📸', e);
      }
    }
    console.log('🔖 Entity', entity);
    await bookmarkStorage.create(entity);
    await attributeStorage.create(entity);
    refreshUserInterface();
    console.log('🎉 Bookmark has been created..');
  } catch (e) {
    console.error('🎉', e, id, bookmark);
  } finally {
    response = null;
    activeTab = null;
  }
  console.timeEnd(`bookmark-created-${id}`);
});

// https://developer.chrome.com/docs/extensions/reference/bookmarks/#event-onChanged
browser.bookmarks.onChanged.addListener(async (id, changeInfo) => {
  console.time(`bookmark-changed-${id}`);
  try {
    const [bookmark] = await browser.bookmarks.get(id);
    // folder
    if (!bookmark.url) {
      // Only update if title actually changed
      if (changeInfo.title !== undefined) {
        await bookmarkStorage.updateBookmarksFolderName(bookmark.id, changeInfo.title);
        console.log('🔄 Folder has been updated..', id, changeInfo
Download .txt
gitextract_xc1kkoq6/

├── .eslintignore
├── .eslintrc.cjs
├── .gitignore
├── LICENSE
├── README.md
├── manifest.chrome.json
├── manifest.firefox.json
├── package.json
├── public/
│   └── site.webmanifest
├── src/
│   ├── assets/
│   │   └── app.css
│   ├── components/
│   │   └── app/
│   │       ├── AppBadge.vue
│   │       ├── AppBullet.vue
│   │       ├── AppButton.vue
│   │       ├── AppConfirmation.vue
│   │       ├── AppDrawer.vue
│   │       ├── AppInfiniteScroll.vue
│   │       ├── AppNotifications.vue
│   │       ├── AppProgress.vue
│   │       ├── AppRadio.vue
│   │       ├── AppSpinner.vue
│   │       └── AppTagInput.vue
│   ├── composables/
│   │   └── useColorExtraction.js
│   ├── constants/
│   │   ├── app.js
│   │   └── httpStatus.js
│   ├── ext/
│   │   ├── browser/
│   │   │   ├── app.js
│   │   │   ├── components/
│   │   │   │   ├── ASide.vue
│   │   │   │   ├── AttributeList.vue
│   │   │   │   ├── BookmarkFavicon.vue
│   │   │   │   ├── BookmarkForm.vue
│   │   │   │   ├── BookmarkLayout.vue
│   │   │   │   ├── BookmarksSync.vue
│   │   │   │   ├── CommandPalette.vue
│   │   │   │   ├── DatePicker.vue
│   │   │   │   ├── FolderTree.vue
│   │   │   │   ├── FolderTreeItem.vue
│   │   │   │   ├── SearchTerm.vue
│   │   │   │   ├── SortDirection.vue
│   │   │   │   ├── TextEditor.vue
│   │   │   │   ├── ThemeMode.vue
│   │   │   │   ├── ViewMode.vue
│   │   │   │   └── card/
│   │   │   │       ├── BookmarkCard.vue
│   │   │   │       ├── DuplicateCard.vue
│   │   │   │       ├── HealthCheckCard.vue
│   │   │   │       ├── PinnedCard.vue
│   │   │   │       └── type/
│   │   │   │           ├── CardView.vue
│   │   │   │           ├── ListView.vue
│   │   │   │           └── MasonryView.vue
│   │   │   ├── index.html
│   │   │   ├── layouts/
│   │   │   │   └── AppLayout.vue
│   │   │   ├── router.js
│   │   │   └── views/
│   │   │       ├── BookmarksView.vue
│   │   │       ├── DuplicatesView.vue
│   │   │       ├── HealthCheckView.vue
│   │   │       └── NotesView.vue
│   │   ├── content/
│   │   │   └── content.js
│   │   ├── popup/
│   │   │   ├── App.vue
│   │   │   ├── PopupView.vue
│   │   │   ├── components/
│   │   │   │   └── BookmarkForm.vue
│   │   │   ├── index.html
│   │   │   └── main.js
│   │   └── sw/
│   │       ├── index.js
│   │       ├── ping.js
│   │       └── sync.js
│   ├── index.html
│   ├── parser/
│   │   └── metadata.js
│   ├── services/
│   │   ├── browserBookmarks.js
│   │   ├── hash.js
│   │   ├── httpClient.js
│   │   └── tags.js
│   └── storage/
│       ├── attribute.js
│       ├── bookmark.js
│       └── idb/
│           └── connection.js
├── tests/
│   ├── integration/
│   │   └── fetch.spec.js
│   └── unit/
│       ├── browserBookmarks.spec.js
│       ├── hash.spec.js
│       ├── metadataParser.spec.js
│       └── tagHelper.spec.js
├── vite.config.firefox.js
└── vite.config.js
Download .txt
SYMBOL INDEX (74 symbols across 13 files)

FILE: src/composables/useColorExtraction.js
  function useColorExtraction (line 4) | function useColorExtraction() {

FILE: src/constants/app.js
  constant PAGINATION_LIMIT (line 1) | const PAGINATION_LIMIT = 100;
  constant NOTIFICATION_DURATION (line 2) | const NOTIFICATION_DURATION = 3000;

FILE: src/constants/httpStatus.js
  constant HTTP_STATUS (line 1) | const HTTP_STATUS = {
  constant STATUS_MESSAGE (line 72) | const STATUS_MESSAGE = new Map([

FILE: src/ext/content/content.js
  function connect (line 5) | function connect() {

FILE: src/ext/sw/index.js
  function refreshUserInterface (line 238) | function refreshUserInterface() {

FILE: src/ext/sw/sync.js
  constant MAX_CONCURRENT (line 8) | const MAX_CONCURRENT = 50;
  constant BATCH_SIZE (line 9) | const BATCH_SIZE = 100;
  constant PROGRESS_UPDATE_INTERVAL (line 10) | const PROGRESS_UPDATE_INTERVAL = 3000;

FILE: src/parser/metadata.js
  class MetadataParser (line 7) | class MetadataParser {
    method constructor (line 22) | constructor(bookmark, httpResponse, folders = new Map()) {
    method getTitle (line 34) | getTitle() {
    method getDescription (line 67) | getDescription() {
    method #searchPagePreview (line 89) | #searchPagePreview() {
    method #getImageFromMeta (line 110) | #getImageFromMeta() {
    method #getYouTubeVideoId (line 142) | #getYouTubeVideoId() {
    method getImage (line 165) | getImage() {
    method getDomain (line 183) | getDomain() {
    method getFavicon (line 191) | getFavicon() {
    method getUrl (line 203) | getUrl() {
    method getKeywords (line 211) | getKeywords() {
    method #getFolderName (line 222) | #getFolderName() {
    method getFavboxBookmark (line 230) | async getFavboxBookmark() {

FILE: src/services/browserBookmarks.js
  function getBookmarksCount (line 5) | async function getBookmarksCount() {
  function getBookmarksFromNode (line 27) | function getBookmarksFromNode(node) {
  function getFolderTree (line 47) | async function getFolderTree() {
  function getFoldersMap (line 89) | async function getFoldersMap() {

FILE: src/services/hash.js
  function hashCode (line 1) | function hashCode(...str) {

FILE: src/services/httpClient.js
  function fetchUrl (line 9) | async function fetchUrl(url, timeout = 20000) {
  function fetchHead (line 41) | async function fetchHead(url, timeout = 20000) {

FILE: src/services/tags.js
  function joinTitleAndTags (line 7) | function joinTitleAndTags(title, tags = []) {
  function extractTitle (line 20) | function extractTitle(string) {
  function extractTags (line 30) | function extractTags(string) {

FILE: src/storage/attribute.js
  class AttributeStorage (line 4) | class AttributeStorage {
    method search (line 5) | async search(includes, sortColumn = 'count', sortDirection = 'desc', t...
    method filterByKeyAndValue (line 34) | async filterByKeyAndValue(key, value, skip, limit = 50) {
    method getAttributesFromBookmark (line 55) | getAttributesFromBookmark(bookmark) {
    method create (line 65) | async create(bookmark) {
    method remove (line 86) | async remove(bookmark) {
    method update (line 138) | async update(newBookmark, oldBookmark) {
    method refreshFromAggregated (line 152) | async refreshFromAggregated(domains = [], tags = [], keywords = [], tr...
    method clear (line 184) | async clear() {
    method saveMany (line 189) | async saveMany(attributes) {

FILE: src/storage/bookmark.js
  class BookmarkStorage (line 3) | class BookmarkStorage {
    method createMany (line 4) | async createMany(data) {
    method findAfterId (line 16) | async findAfterId(id, limit) {
    method search (line 27) | async search(query, skip = 0, limit = 50, sortDirection = 'desc') {
    method total (line 89) | async total() {
    method create (line 96) | async create(entity) {
    method updateHttpStatusById (line 104) | async updateHttpStatusById(id, status) {
    method setOK (line 118) | async setOK() {
    method findPinned (line 126) | async findPinned(skip = 0, limit = 50, term = '') {
    method updatePinStatusById (line 157) | async updatePinStatusById(id, status) {
    method update (line 171) | async update(id, data) {
    method removeByIds (line 182) | async removeByIds(ids) {
    method removeById (line 195) | async removeById(id) {
    method getIds (line 203) | async getIds(ids) {
    method getByFolderId (line 216) | async getByFolderId(folderId) {
    method updateBookmarksFolderName (line 229) | async updateBookmarksFolderName(folderId, folderName) {
    method getById (line 243) | async getById(id) {
    method getByUrl (line 256) | async getByUrl(url) {
    method getTags (line 269) | async getTags() {
    method updateStatusByIds (line 283) | async updateStatusByIds(status, ids) {
    method updateNotesById (line 298) | async updateNotesById(id, notes) {
    method updateImageById (line 312) | async updateImageById(id, image) {
    method findByHttpStatus (line 325) | async findByHttpStatus(statuses, skip = 0, limit = 50) {
    method getTotalByHttpStatus (line 343) | async getTotalByHttpStatus(statuses) {
    method getAllIds (line 355) | async getAllIds() {
    method getDuplicatesGrouped (line 364) | async getDuplicatesGrouped(skip = 0, limit = 50) {
    method aggregateByField (line 418) | async aggregateByField(field, flatten = false) {
    method aggregateDomains (line 433) | async aggregateDomains() {
    method aggregateTags (line 437) | async aggregateTags() {
    method aggregateKeywords (line 441) | async aggregateKeywords() {
Condensed preview — 79 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (287K chars).
[
  {
    "path": ".eslintignore",
    "chars": 55,
    "preview": "node_modules/\ndist/\npublic/\n*.min.js\npackage-lock.json "
  },
  {
    "path": ".eslintrc.cjs",
    "chars": 3019,
    "preview": "module.exports = {\n  env: {\n    browser: true,\n    es2021: true,\n    webextensions: true,\n    node: true,\n  },\n  extends"
  },
  {
    "path": ".gitignore",
    "chars": 302,
    "preview": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\n.DS_Stor"
  },
  {
    "path": "LICENSE",
    "chars": 1072,
    "preview": "MIT License\n\nCopyright (c) 2025 Magalyas Dmitry\n\nPermission is hereby granted, free of charge, to any person obtaining a"
  },
  {
    "path": "README.md",
    "chars": 4741,
    "preview": "# FavBox\n\n<p align=\"center\">\n<a href=\"https://github.com/dd3v/favbox/issues\"><img src=\"https://img.shields.io/github/iss"
  },
  {
    "path": "manifest.chrome.json",
    "chars": 1152,
    "preview": "{\n  \"manifest_version\": 3,\n  \"name\": \"FavBox\",\n  \"description\": \"A clean, modern bookmark app — local-first by design.\","
  },
  {
    "path": "manifest.firefox.json",
    "chars": 1602,
    "preview": "{\n    \"manifest_version\": 3,\n    \"name\": \"FavBox\",\n    \"description\": \"A clean, modern bookmark app — local-first by des"
  },
  {
    "path": "package.json",
    "chars": 2733,
    "preview": "{\n  \"name\": \"favbox\",\n  \"version\": \"2.1.5\",\n  \"private\": false,\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n  "
  },
  {
    "path": "public/site.webmanifest",
    "chars": 263,
    "preview": "{\"name\":\"\",\"short_name\":\"\",\"icons\":[{\"src\":\"/android-chrome-192x192.png\",\"sizes\":\"192x192\",\"type\":\"image/png\"},{\"src\":\"/"
  },
  {
    "path": "src/assets/app.css",
    "chars": 4862,
    "preview": "@import \"tailwindcss\";\n@plugin \"@tailwindcss/forms\";\n@plugin  \"@tailwindcss/typography\";\n\n@custom-variant dark (&:where("
  },
  {
    "path": "src/components/app/AppBadge.vue",
    "chars": 3447,
    "preview": "<template>\n  <div\n    :class=\"badgeClass\"\n    class=\"inline-flex cursor-pointer gap-x-1 items-center space-x-1 whitespac"
  },
  {
    "path": "src/components/app/AppBullet.vue",
    "chars": 1273,
    "preview": "<template>\n  <span\n    :class=\"[dotClass, dotSize]\"\n    class=\"inline-block rounded-full border\"\n    aria-hidden=\"true\"\n"
  },
  {
    "path": "src/components/app/AppButton.vue",
    "chars": 2319,
    "preview": "<template>\n  <button\n    :class=\"buttonClasses\"\n    :aria-label=\"ariaLabel\"\n    :title=\"title\"\n    :role=\"role\"\n    tabi"
  },
  {
    "path": "src/components/app/AppConfirmation.vue",
    "chars": 4753,
    "preview": "<template>\n  <TransitionRoot\n    as=\"template\"\n    :show=\"isOpen\"\n  >\n    <Dialog\n      class=\"relative z-10\"\n      @clo"
  },
  {
    "path": "src/components/app/AppDrawer.vue",
    "chars": 3591,
    "preview": "<template>\n  <TransitionRoot\n    as=\"template\"\n    :show=\"isOpen\"\n  >\n    <Dialog\n      class=\"relative z-10\"\n      @clo"
  },
  {
    "path": "src/components/app/AppInfiniteScroll.vue",
    "chars": 1433,
    "preview": "<template>\n  <div ref=\"scroll\">\n    <slot />\n  </div>\n</template>\n<script setup>\nimport { onMounted, onBeforeUnmount, re"
  },
  {
    "path": "src/components/app/AppNotifications.vue",
    "chars": 3855,
    "preview": "<template>\n  <div class=\"flex\">\n    <!-- Error Notifications -->\n    <NotificationGroup group=\"error\">\n      <div\n      "
  },
  {
    "path": "src/components/app/AppProgress.vue",
    "chars": 732,
    "preview": "<template>\n  <div\n    class=\"overflow-hidden rounded-md border border-gray-100 bg-gray-50 p-0.5 shadow-none\"\n    role=\"p"
  },
  {
    "path": "src/components/app/AppRadio.vue",
    "chars": 834,
    "preview": "<template>\n  <label\n    :for=\"inputId\"\n    class=\"group flex cursor-pointer items-center gap-x-2\"\n  >\n    <input\n      :"
  },
  {
    "path": "src/components/app/AppSpinner.vue",
    "chars": 1441,
    "preview": "<template>\n  <div role=\"status\">\n    <svg\n      aria-hidden=\"true\"\n      class=\"inline size-8 animate-spin fill-black te"
  },
  {
    "path": "src/components/app/AppTagInput.vue",
    "chars": 5487,
    "preview": "<template>\n  <div\n    role=\"combobox\"\n    :aria-expanded=\"showSuggestionContainer\"\n    aria-haspopup=\"listbox\"\n    aria-"
  },
  {
    "path": "src/composables/useColorExtraction.js",
    "chars": 1337,
    "preview": "import { FastAverageColor } from 'fast-average-color';\nimport { ref } from 'vue';\n\nexport default function useColorExtra"
  },
  {
    "path": "src/constants/app.js",
    "chars": 80,
    "preview": "export const PAGINATION_LIMIT = 100;\nexport const NOTIFICATION_DURATION = 3000;\n"
  },
  {
    "path": "src/constants/httpStatus.js",
    "chars": 5041,
    "preview": "export const HTTP_STATUS = {\n  CONTINUE: 100,\n  SWITCHING_PROTOCOLS: 101,\n  PROCESSING: 102,\n  EARLY_HINTS: 103,\n\n  OK: "
  },
  {
    "path": "src/ext/browser/app.js",
    "chars": 564,
    "preview": "import { createApp } from 'vue';\nimport masonry from 'vue-next-masonry';\nimport Notifications from 'notiwind';\nimport Fl"
  },
  {
    "path": "src/ext/browser/components/ASide.vue",
    "chars": 3151,
    "preview": "<template>\n  <aside\n    class=\"flex flex-col h-full min-h-0 w-14 min-w-14 border-soft-900 bg-soft-50 shadow-inner dark:b"
  },
  {
    "path": "src/ext/browser/components/AttributeList.vue",
    "chars": 10887,
    "preview": "<template>\n  <div class=\"flex h-full flex-col\">\n    <div class=\"flex w-full\">\n      <div class=\"relative w-full\">\n      "
  },
  {
    "path": "src/ext/browser/components/BookmarkFavicon.vue",
    "chars": 1142,
    "preview": "<template>\n  <div>\n    <img\n      v-if=\"faviconUrl\"\n      v-show=\"loaded\"\n      :key=\"faviconUrl\"\n      class=\"size-full"
  },
  {
    "path": "src/ext/browser/components/BookmarkForm.vue",
    "chars": 2686,
    "preview": "<template>\n  <form\n    class=\"flex flex-col gap-y-3\"\n    @submit.prevent=\"submit\"\n  >\n    <label\n      for=\"title\"\n     "
  },
  {
    "path": "src/ext/browser/components/BookmarkLayout.vue",
    "chars": 1026,
    "preview": "<template>\n  <masonry\n    v-if=\"displayType === 'masonry'\"\n    :resolve-slot=\"true\"\n    :cols=\"{ 5120: 12, 3840: 10, 256"
  },
  {
    "path": "src/ext/browser/components/BookmarksSync.vue",
    "chars": 4886,
    "preview": "<template>\n  <div>\n    <TransitionRoot\n      appear\n      :show=\"isOpen\"\n      as=\"template\"\n    >\n      <Dialog\n       "
  },
  {
    "path": "src/ext/browser/components/CommandPalette.vue",
    "chars": 10790,
    "preview": "<template>\n  <TransitionRoot\n    appear\n    :show=\"isOpen\"\n    as=\"template\"\n  >\n    <Dialog\n      as=\"div\"\n      class="
  },
  {
    "path": "src/ext/browser/components/DatePicker.vue",
    "chars": 2405,
    "preview": "<template>\n  <div\n    ref=\"rootRef\"\n    class=\"relative\"\n  >\n    <button\n      v-tooltip.bottom=\"{ content: 'Date filter"
  },
  {
    "path": "src/ext/browser/components/FolderTree.vue",
    "chars": 1578,
    "preview": "<template>\n  <div class=\"flex h-full flex-col overflow-hidden\">\n    <ul\n      class=\"space-y-0.5 overflow-y-auto py-1\"\n "
  },
  {
    "path": "src/ext/browser/components/FolderTreeItem.vue",
    "chars": 3565,
    "preview": "<template>\n  <li\n    class=\"select-none\"\n    role=\"treeitem\"\n    :aria-expanded=\"hasChildren ? isExpanded : undefined\"\n "
  },
  {
    "path": "src/ext/browser/components/SearchTerm.vue",
    "chars": 5035,
    "preview": "<template>\n  <div\n    class=\"flex h-9 min-h-9 w-full rounded-md border border-gray-200 bg-white px-2 py-1 shadow-sm focu"
  },
  {
    "path": "src/ext/browser/components/SortDirection.vue",
    "chars": 1148,
    "preview": "<template>\n  <div class=\"relative\">\n    <button\n      class=\"inline-flex size-9 items-center justify-center rounded-md b"
  },
  {
    "path": "src/ext/browser/components/TextEditor.vue",
    "chars": 5808,
    "preview": "<template>\n  <div\n    v-if=\"editor\"\n    class=\"flex size-full flex-col\"\n  >\n    <div class=\"sticky top-0 z-10 flex flex-"
  },
  {
    "path": "src/ext/browser/components/ThemeMode.vue",
    "chars": 664,
    "preview": "<template>\n  <div class=\"relative\">\n    <button\n      class=\" text-gray-700  dark:border-neutral-800  dark:text-white\"\n "
  },
  {
    "path": "src/ext/browser/components/ViewMode.vue",
    "chars": 3653,
    "preview": "<template>\n  <Menu\n    as=\"div\"\n    class=\"relative inline-block text-left\"\n  >\n    <MenuButton\n      class=\"inline-flex"
  },
  {
    "path": "src/ext/browser/components/card/BookmarkCard.vue",
    "chars": 2916,
    "preview": "<template>\n  <Transition\n    appear\n    enter-active-class=\"transition-opacity duration-200 ease-out\"\n    enter-from-cla"
  },
  {
    "path": "src/ext/browser/components/card/DuplicateCard.vue",
    "chars": 2230,
    "preview": "<template>\n  <div class=\"group relative w-full rounded-md border border-solid bg-white shadow-xs transition-all duration"
  },
  {
    "path": "src/ext/browser/components/card/HealthCheckCard.vue",
    "chars": 2041,
    "preview": "<template>\n  <div class=\"group relative w-full rounded-md border border-solid bg-white shadow-xs transition-all duration"
  },
  {
    "path": "src/ext/browser/components/card/PinnedCard.vue",
    "chars": 2833,
    "preview": "<template>\n  <div\n    class=\"group relative min-h-min w-full overflow-hidden rounded-md border border-solid p-3 shadow-x"
  },
  {
    "path": "src/ext/browser/components/card/type/CardView.vue",
    "chars": 2790,
    "preview": "<template>\n  <div\n    class=\"group relative w-full max-w-md mx-auto overflow-hidden rounded-xl border border-solid borde"
  },
  {
    "path": "src/ext/browser/components/card/type/ListView.vue",
    "chars": 1994,
    "preview": "<template>\n  <div\n    class=\"group relative min-h-min w-full overflow-hidden rounded-md border border-solid border-gray-"
  },
  {
    "path": "src/ext/browser/components/card/type/MasonryView.vue",
    "chars": 9085,
    "preview": "<template>\n  <div class=\"group relative\">\n    <!-- Animated border background -->\n    <div\n      :class=\"['animated-grad"
  },
  {
    "path": "src/ext/browser/index.html",
    "chars": 323,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\">\n    <link rel=\"icon\" href=\"/favicon.ico\">\n    <meta"
  },
  {
    "path": "src/ext/browser/layouts/AppLayout.vue",
    "chars": 1549,
    "preview": "<template>\n  <div class=\"flex h-screen w-full scroll-smooth font-sans\">\n    <ASide :items=\"menu\" />\n    <main class=\"siz"
  },
  {
    "path": "src/ext/browser/router.js",
    "chars": 1079,
    "preview": "import { createWebHashHistory, createRouter } from 'vue-router';\n\nconst routes = [\n  {\n    path: '/bookmarks',\n    alias"
  },
  {
    "path": "src/ext/browser/views/BookmarksView.vue",
    "chars": 15600,
    "preview": "<template>\n  <div class=\"flex w-full overflow-y-hidden dark:bg-black\">\n    <!-- Sidebar with Tailwind responsive classes"
  },
  {
    "path": "src/ext/browser/views/DuplicatesView.vue",
    "chars": 6299,
    "preview": "<template>\n  <div class=\"h-screen w-full flex flex-col\">\n    <AppInfiniteScroll\n      class=\"flex flex-col flex-1 overfl"
  },
  {
    "path": "src/ext/browser/views/HealthCheckView.vue",
    "chars": 6769,
    "preview": "<template>\n  <AppInfiniteScroll\n    class=\"flex h-screen w-full flex-col overflow-y-auto bg-white dark:bg-black\"\n    :li"
  },
  {
    "path": "src/ext/browser/views/NotesView.vue",
    "chars": 7127,
    "preview": "<template>\n  <div class=\"flex flex-col md:flex-row h-screen md:h-screen\">\n    <div class=\"w-full md:w-1/3 gap-y-3 border"
  },
  {
    "path": "src/ext/content/content.js",
    "chars": 783,
    "preview": "let port;\n/**\n *\n */\nfunction connect() {\n  console.warn('Keep alive connection..');\n  port = browser.runtime.connect({ "
  },
  {
    "path": "src/ext/popup/App.vue",
    "chars": 144,
    "preview": "<template>\n  <Suspense>\n    <PopupView />\n  </Suspense>\n</template>\n<script setup>\nimport PopupView from '@/ext/popup/Po"
  },
  {
    "path": "src/ext/popup/PopupView.vue",
    "chars": 6021,
    "preview": "<template>\n  <div class=\"relative inset-0 flex h-full min-h-64 min-w-96 flex-col\">\n    <div class=\"flex items-center jus"
  },
  {
    "path": "src/ext/popup/components/BookmarkForm.vue",
    "chars": 2591,
    "preview": "<template>\n  <form\n    @submit.prevent=\"submit\"\n  >\n    <div class=\"flex flex-col gap-y-3\">\n      <label\n        for=\"ti"
  },
  {
    "path": "src/ext/popup/index.html",
    "chars": 316,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n\n<head>\n  <meta charset=\"UTF-8\" />\n  <link rel=\"icon\" href=\"/favicon.ico\" />\n  <meta na"
  },
  {
    "path": "src/ext/popup/main.js",
    "chars": 213,
    "preview": "import { createApp } from 'vue';\nimport App from './App.vue';\nimport '@/assets/app.css';\nimport '@zanmato/vue3-treeselec"
  },
  {
    "path": "src/ext/sw/index.js",
    "chars": 8945,
    "preview": "import BookmarkStorage from '@/storage/bookmark';\nimport AttributeStorage from '@/storage/attribute';\nimport MetadataPar"
  },
  {
    "path": "src/ext/sw/ping.js",
    "chars": 771,
    "preview": "const ping = () => {\n  const onMessage = (msg, port) => {\n    console.log('received', msg, 'from', port.sender);\n  };\n  "
  },
  {
    "path": "src/ext/sw/sync.js",
    "chars": 4656,
    "preview": "import { fetchUrl } from '@/services/httpClient';\nimport BookmarkStorage from '@/storage/bookmark';\nimport AttributeStor"
  },
  {
    "path": "src/index.html",
    "chars": 837,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n\n<head>\n  <meta charset=\"utf-8\">\n  <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\""
  },
  {
    "path": "src/parser/metadata.js",
    "chars": 7503,
    "preview": "import { parseHTML } from 'linkedom';\nimport { extractTitle, extractTags } from '@/services/tags';\n\n/**\n * Class for par"
  },
  {
    "path": "src/services/browserBookmarks.js",
    "chars": 2569,
    "preview": "/**\n * Counts the total number of bookmarks.\n * @returns {Promise<number>}\n */\nexport async function getBookmarksCount()"
  },
  {
    "path": "src/services/hash.js",
    "chars": 363,
    "preview": "export default function hashCode(...str) {\n  if (!str.length || !str.every((s) => typeof s === 'string')) {\n    return '"
  },
  {
    "path": "src/services/httpClient.js",
    "chars": 1780,
    "preview": "import { HTTP_STATUS } from '@/constants/httpStatus';\n\n/**\n * Makes an HTTP GET request with a timeout.\n * @param {strin"
  },
  {
    "path": "src/services/tags.js",
    "chars": 1192,
    "preview": "/**\n * Joins a title and tags into a single string.\n * @param {string} title\n * @param {Array<string>} tags\n * @returns "
  },
  {
    "path": "src/storage/attribute.js",
    "chars": 5678,
    "preview": "import hashCode from '@/services/hash';\nimport useConnection from './idb/connection';\n\nexport default class AttributeSto"
  },
  {
    "path": "src/storage/bookmark.js",
    "chars": 10501,
    "preview": "import useConnection from './idb/connection';\n\nexport default class BookmarkStorage {\n  async createMany(data) {\n    con"
  },
  {
    "path": "src/storage/idb/connection.js",
    "chars": 3612,
    "preview": "/* eslint-disable import/extensions */\n/* eslint-disable import/no-unresolved */\n/* eslint-disable new-cap */\nimport { C"
  },
  {
    "path": "tests/integration/fetch.spec.js",
    "chars": 1162,
    "preview": "import { describe, expect, it } from 'vitest';\nimport { fetchUrl, fetchHead } from '@/services/httpClient';\nimport { HTT"
  },
  {
    "path": "tests/unit/browserBookmarks.spec.js",
    "chars": 11019,
    "preview": "import {\n  getBookmarksCount,\n  getBookmarksFromNode,\n  getFolderTree,\n  getFoldersMap,\n  getBookmarksIterator,\n} from '"
  },
  {
    "path": "tests/unit/hash.spec.js",
    "chars": 2797,
    "preview": "import { describe, expect, it } from 'vitest';\nimport hashCode from '@/services/hash';\n\ndescribe('hashCode', () => {\n  i"
  },
  {
    "path": "tests/unit/metadataParser.spec.js",
    "chars": 13661,
    "preview": "import { describe, expect, it, beforeEach } from 'vitest';\nimport MetadataParser from '@/parser/metadata';\n\ndescribe('Me"
  },
  {
    "path": "tests/unit/tagHelper.spec.js",
    "chars": 3131,
    "preview": "import { describe, expect, it } from 'vitest';\nimport { joinTitleAndTags, extractTags, extractTitle } from '@/services/t"
  },
  {
    "path": "vite.config.firefox.js",
    "chars": 1259,
    "preview": "import { defineConfig } from 'vite';\nimport vue from '@vitejs/plugin-vue';\nimport { resolve } from 'path';\nimport { crx "
  },
  {
    "path": "vite.config.js",
    "chars": 1461,
    "preview": "import { defineConfig } from 'vite';\nimport vue from '@vitejs/plugin-vue';\nimport { resolve } from 'path';\nimport { crx "
  }
]

About this extraction

This page contains the full source code of the dd3v/favbox GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 79 files (263.7 KB), approximately 70.4k tokens, and a symbol index with 74 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!