├── .eslintrc.json ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── codeql-analysis.yml │ └── scheduled-builds.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode └── settings.json ├── .yarn └── install-state.gz ├── .yarnrc.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING ├── LICENSE ├── README.md ├── archive ├── [legal].tsx └── privacy-policy.md.bkp ├── cache ├── appcache.ts └── plugins-cache.ts ├── changelog.md ├── components.json ├── components ├── AllPluginsMultiView.test.tsx ├── AllPluginsMultiView.tsx ├── AuthorAndDescription.test.tsx ├── AuthorAndDescription.tsx ├── BetaEntryCard.test.tsx ├── BetaEntryCard.tsx ├── CardAnnotations.test.tsx ├── CardAnnotations.tsx ├── Category.test.tsx ├── Category.tsx ├── Comments.tsx ├── Divider.test.tsx ├── Divider.tsx ├── EthicalAd.tsx ├── Faq.test.tsx ├── Faq.tsx ├── FavPluginUpdates.test.tsx ├── FavPluginUpdates.tsx ├── Favorites.test.tsx ├── Favorites.tsx ├── Footer.test.tsx ├── Footer.tsx ├── GivePluginRating.tsx ├── GrowmeScript.tsx ├── Header.test.tsx ├── Header.tsx ├── InfoBar.test.tsx ├── InfoBar.tsx ├── LinkButton.test.tsx ├── LinkButton.tsx ├── Navbar.test.tsx ├── Navbar.tsx ├── PluginCard.test.tsx ├── PluginCard.tsx ├── PluginEcosystemStats.test.tsx ├── PluginEcosystemStats.tsx ├── PluginsBanner.tsx ├── PluginsComparisionTable.test.tsx ├── PluginsComparisionTable.tsx ├── PluginsListView.test.tsx ├── PluginsListView.tsx ├── PluginsMultiView.test.tsx ├── PluginsMultiView.tsx ├── PluginsTableView.test.tsx ├── PluginsTableView.tsx ├── Score.test.tsx ├── Score.tsx ├── Sidebar.tsx ├── Sponsorship.tsx ├── StarRating.tsx ├── StarRatingInput.tsx ├── background │ └── wave-background.tsx ├── home │ ├── Highlights.test.tsx │ ├── Highlights.tsx │ ├── MostDownloaded.test.tsx │ ├── MostDownloaded.tsx │ ├── SubstackNewsletter.test.tsx │ └── SubstackNewsletter.tsx ├── plugins │ ├── PluginSection.tsx │ ├── dataview.tsx │ ├── obsidian-tasks-plugin.tsx │ └── theme.ts ├── post │ ├── LatestPosts.test.tsx │ ├── LatestPosts.tsx │ ├── PostIcon.test.tsx │ └── PostIcon.tsx └── ui │ ├── button.tsx │ ├── card.tsx │ ├── carousel.tsx │ ├── checkbox.tsx │ ├── dialog.tsx │ ├── input.tsx │ ├── label.tsx │ ├── navigation-menu.tsx │ ├── progress.tsx │ ├── spinner.tsx │ └── textarea.tsx ├── constants.ts ├── data ├── indexnow.json ├── siteData.test.ts ├── siteData.ts └── wrapped-2024.json ├── docs-readme.md ├── domain ├── plugins │ └── models │ │ ├── Plugin.ts │ │ └── PluginRatingInfo.ts ├── remark │ ├── ad-hander.ts │ ├── plugin-hander.ts │ └── plugin-image-handler.ts ├── scorer │ ├── ScorerUtils.ts │ └── models │ │ └── PluginMetrics.model.ts └── suggestions │ ├── index.ts │ └── models │ └── index.ts ├── eslint.config.mjs ├── hooks ├── useCustomScore.ts ├── useCustomScoreWithScoreUpdater.ts ├── useIsLessThanLarge.ts ├── useScoreUpdater.ts └── useUser.ts ├── jest.config.ts ├── jsdoc.config.json ├── lib ├── abstractions.ts ├── analytics │ ├── analytics.tsx │ ├── strategies │ │ └── plausible.strategy.tsx │ └── types │ │ └── analytics.ts ├── customThemes.test.ts ├── customThemes.ts ├── environment.ts ├── feature-flag │ ├── feature-flags.tsx │ └── types │ │ └── flags.ts ├── flags.ts ├── jsonLdSchema.test.ts ├── jsonLdSchema.ts ├── legalDocs.ts ├── plugins.ts ├── posts.test.ts ├── posts.ts ├── scorer.ts ├── supabase-server.ts ├── supabase.ts └── utils.ts ├── load-dot-env.ps1 ├── next-env.d.ts ├── next-sitemap.config.js ├── next.config.js ├── package.json ├── pages ├── 404.tsx ├── _app.tsx ├── _document.tsx ├── _responsive-layout.tsx ├── about.tsx ├── auth │ └── callback.tsx ├── bases-support.tsx ├── beta │ ├── index.tsx │ ├── plugins.tsx │ └── themes.tsx ├── categories │ ├── [slug].tsx │ └── index.tsx ├── cookie-policy.tsx ├── favorites.tsx ├── index.tsx ├── info.tsx ├── migrate.tsx ├── most-downloaded.tsx ├── new.tsx ├── plugins │ ├── [slug].tsx │ └── index.tsx ├── posts │ ├── 2024-12-07-wrapped-2024.tsx │ ├── [slug].tsx │ ├── index.tsx │ └── workflows.tsx ├── privacy-policy.tsx ├── profile.tsx ├── scorer │ ├── build.tsx │ └── index.tsx ├── share.tsx ├── tags │ ├── [slug].tsx │ └── index.tsx ├── test.tsx ├── timeline.tsx ├── tools │ └── dataview-query-wizard.tsx ├── trending.tsx └── updates.tsx ├── postcss.config.js ├── posts ├── 2024-11-10-weekly-plugin-updates.md ├── 2024-11-17-weekly-plugin-updates.md ├── 2024-11-24-weekly-plugin-updates.md ├── 2024-12-01-weekly-plugin-updates.md ├── 2024-12-07-wrapped-2024.md ├── 2024-12-08-weekly-plugin-updates.md ├── 2024-12-11-top-7-obsidian-plugins-for-daily-journaling.md ├── 2024-12-15-weekly-plugin-updates.md ├── 2024-12-18-writing-stats-essential-plugins.md ├── 2024-12-22-weekly-plugin-updates.md ├── 2024-12-28-obsidian-plugin-scoring-system.md ├── 2025-01-05-weekly-plugin-updates.md ├── 2025-01-12-weekly-plugin-updates.md ├── 2025-01-18-building-a-custom-score-function.md ├── 2025-01-19-weekly-plugin-updates.md ├── 2025-01-26-weekly-plugin-updates.md ├── 2025-02-03-weekly-plugin-updates.md ├── 2025-02-16-weekly-plugin-updates.md ├── 2025-02-24-weekly-plugin-updates.md ├── 2025-03-09-weekly-plugin-updates.md ├── 2025-03-10-weekly-plugin-updates.md ├── 2025-03-18-weekly-plugin-updates.md ├── 2025-03-23-weekly-plugin-updates.md ├── 2025-03-30-weekly-plugin-updates.md ├── 2025-04-08-weekly-plugin-updates.md ├── 2025-04-10-obsidian-dataview-query-wizard.md ├── 2025-04-13-weekly-plugin-updates.md ├── 2025-04-16-publish-plugins.md ├── 2025-04-20-weekly-plugin-updates.md ├── 2025-04-22-recipes-plugins.md ├── 2025-04-27-weekly-plugin-updates.md ├── 2025-05-01-spaced-repetition-plugins.md ├── 2025-05-04-weekly-plugin-updates.md ├── 2025-05-11-weekly-plugin-updates.md ├── 2025-05-14-music-notation-plugins.md ├── 2025-05-18-weekly-plugin-updates.md ├── 2025-05-25-weekly-plugin-updates.md ├── 2025-05-30-zotero-plugins.md ├── 2025-06-01-weekly-plugin-updates.md ├── 2025-06-08-weekly-updates.md ├── 2025-06-10-custom-callouts-in-obsidian.md ├── 2025-06-13-callout-plugins.md ├── 2025-06-15-weekly-updates.md ├── 2025-06-23-weekly-updates.md ├── 2025-06-29-weekly-updates.md ├── 2025-07-06-weekly-updates.md ├── 2025-07-10-chart-plugins.md ├── 2025-07-13-weekly-updates.md ├── 2025-07-18-pomodoro-plugins.md ├── 2025-07-20-weekly-updates.md ├── 2025-07-27-weekly-updates.md ├── 2025-08-03-weekly-updates.md ├── 2025-08-10-weekly-updates.md ├── 2025-08-24-weekly-updates.md ├── 2025-08-31-weekly-updates.md ├── 2025-09-10-weekly-updates.md ├── 2025-09-14-weekly-updates.md ├── 2025-09-24-weekly-updates.md └── 2025-10-08-weekly-updates.md ├── prisma └── schema.prisma ├── public ├── a944fdca7e16402a80e17aead9645552.txt ├── bcee69848e584efbac6b1dcbadaa8c64.txt ├── data │ └── plugins-history.json ├── docs │ ├── ScorerUtils.html │ ├── ScorerUtils.ts.html │ ├── global.html │ ├── index.html │ ├── models_PluginMetrics.model.ts.html │ ├── scripts │ │ ├── app.min.js │ │ ├── linenumber.js │ │ └── search.js │ └── styles │ │ ├── app.min.css │ │ ├── iframe.css │ │ ├── prettify-jsdoc.css │ │ ├── prettify-tomorrow.css │ │ └── reset.css ├── favicon-64.png ├── favicon.ico ├── images │ ├── 2024-12-22-weekly-plugin-updates-1.webp │ ├── 2025-01-05-weekly-plugin-updates-1.webp │ ├── 2025-01-12-weekly-plugin-updates.webp │ ├── 2025-01-19-weekly-plugin-updates.webp │ ├── 2025-01-25-weekly-plugin-updates.webp │ ├── 2025-02-01-weekly-plugin-updates.webp │ ├── 2025-02-24-weekly-plugin-updates.webp │ ├── 2025-03-10-weekly-plugin-updates-1.webp │ ├── 2025-03-16-weekly-plugin-updates.webp │ ├── 2025-03-18-weekly-plugin-updates-1.webp │ ├── 2025-03-30-weekly-plugin-updates.webp │ ├── 2025-04-08-weekly-plugin-updates.webp │ ├── 2025-04-13-weekly-plugin-updates.webp │ ├── 2025-04-16-publish-workflow-og.png │ ├── 2025-04-16-publish-workflow.webp │ ├── 2025-04-20-weekly-plugin-updates.webp │ ├── 2025-04-22-recipes-workflow-og.webp │ ├── 2025-04-22-recipes-workflow.webp │ ├── 2025-04-27-weekly-plugin-updates.webp │ ├── 2025-05-01-spaced-repetition-workflow-og.png │ ├── 2025-05-01-spaced-repetition-workflow.webp │ ├── 2025-05-04-weekly-plugin-updates.webp │ ├── 2025-05-11-weekly-plugin-updates.webp │ ├── 2025-05-14-sheet-music-workflow-og.webp │ ├── 2025-05-14-sheet-music-workflow.webp │ ├── 2025-05-30-zotero-plugins.webp │ ├── 2025-07-10-charts-plugins.webp │ ├── 2025-07-18-pomodoro-plugins.webp │ ├── calendar-128.png │ ├── calendar.png │ ├── chibi-obsidian-wizard.png │ ├── custom-callouts-in-obsidian-with-css-snippets.webp │ ├── custom-callouts-in-obsidian.png │ ├── custom-scorer-example-1.png │ ├── daily-journaling.webp │ ├── default-callout-in-obsidian.png │ ├── empty-icon.svg │ ├── empty.svg │ ├── favicon.png │ ├── feature-score-intro.gif │ ├── gk-coding-1.webp │ ├── gk-coding.webp │ ├── how-to-favorite-step-1.png │ ├── how-to-favorite-step-2.png │ ├── how-to-favorite-step-3.png │ ├── introducing-categories-and-tags.png │ ├── new-og.webp │ ├── obsidian-stats-ogImage.png │ ├── obsidian-weekly-updates.webp │ ├── plugin-score.png │ ├── plugin-updates-banner.png │ ├── sample-scores.gif │ ├── score-example-1.png │ ├── score-example.png │ ├── scorer │ │ ├── all-plugins-with-score-original.png │ │ ├── all-plugins-with-score.png │ │ ├── empty-builder-original.png │ │ ├── empty-builder.png │ │ ├── normalized-download-count-scorer-original.png │ │ ├── normalized-download-count-scorer.png │ │ ├── save-and-use-original.png │ │ ├── save-and-use.png │ │ ├── scorer-list-original.png │ │ ├── scorer-list-with-scorers-original.png │ │ ├── scorer-list-with-scorers.png │ │ └── scorer-list.png │ ├── scoring-plugins-banner.webp │ ├── trending-plugins.bkp-2.webp │ ├── trending-plugins.bkp.webp │ ├── trending-plugins.webp │ └── undraw │ │ ├── empty.svg │ │ ├── moving_2cfm.png │ │ ├── undraw_awards_fieb.svg │ │ ├── undraw_celebration_re_kc9k.svg │ │ ├── undraw_community_re_cyrm.svg │ │ ├── undraw_lightbulb_moment_re_ulyo.svg │ │ ├── undraw_programmer_re_owql.svg │ │ └── undraw_with-love.svg ├── logo-512-removebg-preview.png ├── logo-512.png ├── logo-64.png ├── logo-apple-touch-icon-180x180.png ├── plugin-scores-2024-12-28.json ├── robots.txt ├── sitemap-1.xml ├── vercel.svg ├── yandex_27ef3a93625b3d8e.html └── yandex_57114eb967a05d89.html ├── store └── scorer-store.ts ├── styles ├── Home.module.css └── globals.css ├── tailwind.config.js ├── tsconfig.json ├── utils ├── datetime.test.ts ├── datetime.ts ├── favorites.test.ts ├── favorites.ts ├── generate-rss.js ├── indexnow.mjs ├── plugins.test.ts └── plugins.ts ├── vercel.json └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals", 3 | "settings": { 4 | "react": { 5 | "version": "detect" 6 | } 7 | }, 8 | "rules": { 9 | "prettier/prettier": "off", 10 | "@typescript-eslint/no-unused-vars": "warn", 11 | "react/react-in-jsx-scope": "off", 12 | "@typescript-eslint/no-explicit-any": "warn", 13 | "@typescript-eslint/no-unused-expressions": "warn", 14 | "react/no-unescaped-entities": "warn", 15 | "react/prop-types": "off", 16 | "react/jsx-key": "warn", 17 | "react/jsx-no-target-blank": "warn", 18 | "@typescript-eslint/no-require-imports": "warn", 19 | "react/display-name": "off", 20 | "@typescript-eslint/no-empty-object-type": "warn", 21 | "prefer-const": "warn", 22 | "no-extra-boolean-cast": "warn", 23 | "no-unexpected-multiline": "warn", 24 | "no-control-regex": "warn", 25 | "no-undef": "off", 26 | "no-redeclare": "warn" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ganesshkumar 2 | buy_me_a_coffee: ganeshkumar 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '[BUG]' 5 | labels: '' 6 | assignees: ganesshkumar 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | 28 | - OS: [e.g. iOS] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | 33 | - Device: [e.g. iPhone6] 34 | - OS: [e.g. iOS8.1] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '[Feature Request]' 5 | labels: '' 6 | assignees: ganesshkumar 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe. ** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: 'CodeQL' 13 | 14 | on: 15 | push: 16 | branches: [master] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [master] 20 | schedule: 21 | - cron: '28 14 * * 3' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: ['javascript'] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v2 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v1 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v1 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v1 71 | -------------------------------------------------------------------------------- /.github/workflows/scheduled-builds.yml: -------------------------------------------------------------------------------- 1 | # .github/workflows/scheduled-builds.yml 2 | name: Production Deployment 3 | 4 | on: 5 | schedule: 6 | # Runs "At minute 0 past every 2nd hour" (see https://crontab.guru) 7 | - cron: '0 */2 * * *' 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | name: Trigger Site Rebuild 13 | runs-on: ubuntu-latest 14 | environment: production 15 | steps: 16 | - name: Deploy master branch 17 | run: curl ${{ secrets.deploy_master_branch }} 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env 29 | .env.local 30 | .env.development.local 31 | .env.test.local 32 | .env.production.local 33 | 34 | # vercel 35 | .vercel 36 | 37 | public/rss.xml 38 | public/sitemap.xml 39 | public/sitemap-0.xml 40 | public/weekly-plugin-updates-rss.xml 41 | 42 | get-assets 43 | *.dot 44 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .next 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "trailingComma": "es5", 5 | "tabWidth": 2 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.insertSpaces": true, 3 | "editor.tabSize": 2 4 | } 5 | -------------------------------------------------------------------------------- /.yarn/install-state.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ganesshkumar/obsidian-plugins-stats-ui/d90af699e1e00aa1c11fca1f505e6b108b031c37/.yarn/install-state.gz -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /CONTRIBUTING: -------------------------------------------------------------------------------- 1 | When contributing to this repository, please first discuss the change you wish to make via issue, email, or any other method with the owners of this repository before making a change. 2 | 3 | Please note we have a [code of conduct](./CODE_OF_CONDUCT.md), please follow it in all your interactions with the project. 4 | 5 | ## Pull Requests 6 | 7 | 1. Ensure any install or build dependencies are removed before the end of the layer when doing a 8 | build. 9 | 2. Update the README.md file with any relevant information. 10 | 3. Increase the version numbers in package.json and manifest.json to the new version that this 11 | Pull Request would represent. You can run `yarn version` or `npm run version` to update to the 12 | next version automatically. 13 | 4. You may merge the Pull Request in once you have the sign-off by any maintainers or if you 14 | do not have permission to do that, you may request a maintainer to merge it for you. 15 | 16 | 17 | ## Bug Report/Feature Requests 18 | 19 | 1. Please use issues to report a bug or request a feature. 20 | 2. Use the templates available to create a [bug report](.github/ISSUE_TEMPLATE/bug_report.md) or [feature request](.github/ISSUE_TEMPLATE/feature_request.md). 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-2025 Ganessh Kumar R P 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Obsidian Stats 2 | 3 | Stay informed about the Obsidian plugin ecosystem with Obsidian Stats! Whether you're tracking trending plugins, new releases, or your personal favorites, we've got you covered. 4 | 5 | ![Obsidian Stats](https://user-images.githubusercontent.com/2135089/154796362-e80a56b4-1f0f-451b-8bf3-3ed435c6b23f.png) 6 | ![Maintainability](https://img.shields.io/codeclimate/maintainability-percentage/ganesshkumar/obsidian-plugins-stats-ui) 7 | ![Code Issues](https://img.shields.io/codeclimate/issues/ganesshkumar/obsidian-plugins-stats-ui?label=code%3Aissues) 8 | ![Tech Debt](https://img.shields.io/codeclimate/tech-debt/ganesshkumar/obsidian-plugins-stats-ui) 9 | 10 | [![Tag 0.1.0](https://img.shields.io/badge/tag-0.1.0-blue)](https://github.com/ganesshkumar/obsidian-plugins-stats-ui) 11 | [![MIT License](https://img.shields.io/github/license/ganesshkumar/obsidian-plugins-stats-ui)](LICENSE) 12 | 13 | ## Features 14 | 15 | Explore [Obsidian](https://obsidian.md) plugins with these powerful features: 16 | 17 | - **[New Plugins](https://www.obsidianstats.com/new)** – Discover plugins released in the past 10 days. 18 | - **[Latest Updates](https://www.obsidianstats.com/updates)** – Stay up to date with the latest releases and changelogs. 19 | - **[Trending Plugins](https://www.obsidianstats.com/trending)** – Check out the top 10 trending plugins based on downloads and engagement. 20 | - **[Most Downloaded](https://obsidianstats.com/most-downloaded)** – View the most downloaded plugins over last week, last month and overall timeline. 21 | - **[Favorites](https://www.obsidianstats.com/favorites)** – Track your favorite plugins and get notified of new updates. 22 | - **[Plugin Timeline](https://www.obsidianstats.com/timeline)** – Track additions, removals, and updates over time. 23 | - **[Plugin Sharing](https://obsidianstats.com/share)** – Compare and share plugin lists with others. 24 | - **[Migration Guide](https://www.obsidianstats.com/migrate)** – Export and import your favorite plugin lists across devices. 25 | - **[Scoring System](https://www.obsidianstats.com/scorer)** – Build custom scoring functions to rank plugins based on your criteria. 26 | - **[Tags](https://www.obsidianstats.com/tags)** – Browse plugins by tags to find exactly what you need. 27 | - **[Timeline](https://www.obsidianstats.com/timeline)** – Visualize the history of plugin updates and releases. 28 | - **[Share](https://www.obsidianstats.com/share)** – Share your favorite plugin lists with the community. 29 | 30 | ## Screenshots 31 | 32 | ## License 33 | 34 | [MIT](LICENSE) 35 | 36 | --- 37 | 38 | If you find this tool useful, consider supporting me with a coffee! ☕ 39 | Buy Me A Coffee 40 | -------------------------------------------------------------------------------- /archive/[legal].tsx: -------------------------------------------------------------------------------- 1 | import { getAllLegalDocIds, getLegalDocData } from '../lib/legalDocs'; 2 | import { GetStaticPaths, GetStaticProps } from 'next'; 3 | import { remark } from 'remark'; 4 | import html from 'remark-html'; 5 | import Header, { IHeaderProps } from '../components/Header'; 6 | import Navbar from '../components/Navbar'; 7 | import { Footer } from '../components/Footer'; 8 | 9 | export const getStaticPaths: GetStaticPaths = async () => { 10 | const paths = getAllLegalDocIds(); 11 | return { 12 | paths, 13 | fallback: false, 14 | }; 15 | }; 16 | 17 | export const getStaticProps: GetStaticProps = async ({ params }) => { 18 | const legalDocData = getLegalDocData(params?.legal as string); 19 | const processedContent = await remark() 20 | .use(html) 21 | .process(legalDocData.content); 22 | const contentHtml = processedContent.toString(); 23 | 24 | const title = 'Privacy Policy'; 25 | const description = 'Obsidian Stats Privacy Policy'; 26 | const canonical = `https://www.obsidianstats.com/privacy-policy`; 27 | const image = `/images/obsidian-stats-ogImage.png`; 28 | const jsonLdSchema = null; 29 | 30 | return { 31 | props: { 32 | title, 33 | description, 34 | canonical, 35 | image, 36 | jsonLdSchema, 37 | legalDocData: { 38 | ...legalDocData, 39 | contentHtml, 40 | }, 41 | }, 42 | }; 43 | }; 44 | 45 | interface ILegalDocData { 46 | id: string; 47 | title: string; 48 | content: string; 49 | contentHtml: string; 50 | } 51 | 52 | interface ILegalDocProps extends IHeaderProps { 53 | legalDocData: ILegalDocData; 54 | } 55 | 56 | const Legal = (props: ILegalDocProps) => { 57 | const { legalDocData } = props; 58 | return ( 59 |
60 |
61 | 62 |
63 |
64 |
65 |

66 | {legalDocData.title} 67 |

68 |
71 |
72 |
73 |
74 |
76 | ); 77 | }; 78 | 79 | export default Legal; 80 | -------------------------------------------------------------------------------- /archive/privacy-policy.md.bkp: -------------------------------------------------------------------------------- 1 | --- 2 | title: Privacy Policy 3 | --- 4 | 5 | ## Introduction 6 | Your privacy is important to us. This Privacy Policy explains how we collect, use, and protect your information when you use our website. By accessing or using our website, you agree to the terms of this Privacy Policy. 7 | 8 | ## Information We Collect 9 | 10 | ### 1. **Local Storage Data** 11 | We store certain information in your browser's local storage to enhance your experience, including: 12 | - A list of your favorite plugin IDs. 13 | - Your custom scorer functions. 14 | - The ID of the currently active scorer function. 15 | 16 | This data is stored locally on your browser and is not shared with third parties. It is used solely to provide a personalized experience, such as remembering your preferences between visits. 17 | 18 | ### 2. **Cookies** 19 | We use cookies for the following purposes: 20 | - **Feature Flags and Experiments**: We use Amplitude cookies to manage feature flags and experiments that are essential for the functioning of the website. 21 | - **Analytics**: We use Google Analytics to collect anonymized data about website usage, such as pages visited, session duration, and interactions. This helps us improve website performance and user experience. 22 | 23 | ## How We Use Your Information 24 | The information we collect is used to: 25 | - Improve website functionality and user experience. 26 | - Analyze website performance and usage patterns. 27 | - Personalize your interactions with the site. 28 | 29 | ## Sharing Your Information 30 | We do not share your information with third parties except for: 31 | - **Amplitude**: For managing feature flags and experiments essential to website functionality. 32 | - **Google Analytics**: For analyzing anonymized website usage data. 33 | 34 | Both providers comply with applicable data protection laws and process anonymized data on our behalf. 35 | 36 | ## Your Choices 37 | By using this website, you consent to the collection and use of your information as described in this Privacy Policy. If you do not agree with these terms, you must discontinue use of the website. 38 | 39 | ## Data Security 40 | We take reasonable measures to protect your information from unauthorized access, use, or disclosure. However, no method of transmission or storage is 100% secure, and we cannot guarantee absolute security. 41 | 42 | ## Changes to This Privacy Policy 43 | We may update this Privacy Policy from time to time. Any changes will be posted on this page with an updated "Last Updated" date. 44 | 45 | ## Contact Us 46 | If you have any questions about this Privacy Policy, please open an issue in our [Github page](https://github.com/ganesshkumar/obsidian-plugins-stats-ui/issues). 47 | 48 | _Last Updated: 2025-01-13_ 49 | 50 | -------------------------------------------------------------------------------- /cache/appcache.ts: -------------------------------------------------------------------------------- 1 | const HALF_AN_HOUR = 30 * 60 * 1000; 2 | 3 | class AppCache { 4 | static data = {}; 5 | 6 | static get(key) { 7 | console.log(`cache ${AppCache.data[key] ? 'hit' : 'miss'}: ${key}`); 8 | return AppCache.data[key]; 9 | } 10 | 11 | static set(key, value, timeout = HALF_AN_HOUR) { 12 | if (AppCache.data[key]) { 13 | console.log('no-op: tried to set cache but cache is already present'); 14 | return; 15 | } 16 | 17 | AppCache.data = Object.assign({}, AppCache.data, { [key]: value }); 18 | console.log(`cache set ${key}`); 19 | 20 | setTimeout(() => { 21 | delete AppCache.data[key]; 22 | console.log(`cache invalidated ${key}`); 23 | }, HALF_AN_HOUR); 24 | } 25 | } 26 | 27 | export default AppCache; 28 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | - #### 2025-01-15 4 | - Added `/timeline` page. 5 | - Added develop.obsidianstats.com to publish `develop` branch as beta version of the webapp. 6 | - Added support 7 | - to build curtom scorer via pages/scorer/build and 8 | - to manage scorer functions via pages/scorer/index 9 | - to patch custom scores into plugins in 10 | - components/PluginsMultiView 11 | - pages/plugins/index 12 | - Fixed SEO issue where canonical names for some pages point to invalid URL. 13 | - Fixed issues with tags where redirects to pages/tags/[slug] were inconsistently sanitized. 14 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "styles/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } 22 | -------------------------------------------------------------------------------- /components/AuthorAndDescription.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import '@testing-library/jest-dom'; 4 | import AuthorAndDescription from './AuthorAndDescription'; 5 | 6 | describe('AuthorAndDescription', () => { 7 | test('renders author and description', () => { 8 | const author = 'John Doe'; 9 | const description = 'This is a test description.'; 10 | render(); 11 | 12 | const authorElement = screen.getByText(`${author}`); 13 | const descriptionElement = screen.getByText(description); 14 | 15 | expect(authorElement).toBeInTheDocument(); 16 | expect(descriptionElement).toBeInTheDocument(); 17 | }); 18 | 19 | test('renders author with correct class', () => { 20 | const author = 'Jane Doe'; 21 | render(); 22 | 23 | const authorElement = screen.getByText(`${author}`); 24 | expect(authorElement).toBeInTheDocument(); 25 | expect(authorElement).toHaveClass('group-hover:text-violet-500'); 26 | }); 27 | 28 | test('renders description with correct class', () => { 29 | const description = 'Another test description.'; 30 | render(); 31 | 32 | const descriptionElement = screen.getByText(description); 33 | expect(descriptionElement).toBeInTheDocument(); 34 | expect(descriptionElement).toHaveClass('mr-5'); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /components/AuthorAndDescription.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const AuthorAndDescription = ({ author, description }) => { 4 | return ( 5 | <> 6 |
7 | by {author} 8 |
9 |
{description}
10 | 11 | ); 12 | }; 13 | 14 | export default AuthorAndDescription; 15 | -------------------------------------------------------------------------------- /components/BetaEntryCard.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import '@testing-library/jest-dom'; 4 | import { BetaEntryCard } from './BetaEntryCard'; 5 | import moment from 'moment'; 6 | import { PullRequestEntry } from '@prisma/client'; 7 | 8 | // Minimal mock for PullRequestEntry fields used 9 | const baseEntry: PullRequestEntry = { 10 | id: '1', 11 | prNumber: 1234, 12 | repo: 'user/repo', 13 | name: 'Amazing Beta Plugin', 14 | author: 'GreatDev', 15 | description: 'Provides amazing features for testing highlight logic.', 16 | type: 'plugin', 17 | createdAt: moment().subtract(2, 'days').valueOf() as any, 18 | lastUpdatedAt: moment().valueOf() as any, 19 | prStatus: 'open', 20 | status: 'in-review', 21 | prLabels: null, 22 | operation: 'add', 23 | pluginId: 'amazing-beta-plugin', 24 | version: '0.0.1', 25 | lastClassifiedAt: null, 26 | lastEnrichedAt: null, 27 | needManualReview: false, 28 | manualReviewReason: null, 29 | }; 30 | 31 | describe('BetaEntryCard', () => { 32 | it('highlights matching tokens in name, author, and description', () => { 33 | render(); 34 | 35 | // Name highlight 36 | const nameMarks = screen.getAllByText(/Amazing/i); 37 | expect(nameMarks.length).toBeGreaterThan(0); 38 | 39 | // Author highlight 40 | const authorMarks = screen.getAllByText(/GreatDev/i); 41 | expect(authorMarks.length).toBeGreaterThan(0); 42 | 43 | // Description highlight 44 | const featMarks = screen.getAllByText(/features/i); 45 | expect(featMarks.length).toBeGreaterThan(0); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /components/CardAnnotations.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import '@testing-library/jest-dom'; 4 | import CardAnnotations from './CardAnnotations'; 5 | 6 | describe('CardAnnotations', () => { 7 | test('renders favorite annotation', () => { 8 | render( 9 | 14 | ); 15 | const favoriteAnnotation = screen.getByText('Favorite'); 16 | expect(favoriteAnnotation).toBeInTheDocument(); 17 | expect(favoriteAnnotation).toHaveAttribute('title', 'Favorite plugin'); 18 | }); 19 | 20 | test('renders new annotation', () => { 21 | render( 22 | 28 | ); 29 | const newAnnotation = screen.getByText('New Plugin'); 30 | expect(newAnnotation).toBeInTheDocument(); 31 | expect(newAnnotation).toHaveAttribute('title', 'Less than a day old'); 32 | }); 33 | 34 | test('renders trending annotation', () => { 35 | render( 36 | 41 | ); 42 | const trendingAnnotation = screen.getByText('Trending'); 43 | expect(trendingAnnotation).toBeInTheDocument(); 44 | expect(trendingAnnotation).toHaveAttribute('title', 'Trending plugin'); 45 | }); 46 | 47 | test('renders multiple annotations', () => { 48 | render( 49 | 55 | ); 56 | const favoriteAnnotation = screen.getByText('Favorite'); 57 | const newAnnotation = screen.getByText('New Plugin'); 58 | const trendingAnnotation = screen.getByText('Trending'); 59 | 60 | expect(favoriteAnnotation).toBeInTheDocument(); 61 | expect(newAnnotation).toBeInTheDocument(); 62 | expect(trendingAnnotation).toBeInTheDocument(); 63 | }); 64 | 65 | test('renders no annotations when all props are false', () => { 66 | render( 67 | 72 | ); 73 | expect(screen.queryByText('Favorite')).not.toBeInTheDocument(); 74 | expect(screen.queryByText('New')).not.toBeInTheDocument(); 75 | expect(screen.queryByText('Trending')).not.toBeInTheDocument(); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /components/CardAnnotations.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const CardAnnotations = ({ 4 | isFavorite, 5 | isNotADayOld, 6 | isTrending, 7 | category = '', 8 | }) => { 9 | return ( 10 |
11 | {isFavorite && ( 12 |
16 | Favorite 17 |
18 | )} 19 | {isNotADayOld && ( 20 |
24 | New {category} 25 |
26 | )} 27 | {isTrending && ( 28 |
32 | Trending 33 |
34 | )} 35 |
36 | ); 37 | }; 38 | 39 | export default CardAnnotations; 40 | -------------------------------------------------------------------------------- /components/Category.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import '@testing-library/jest-dom'; 4 | import { CategoryIcon } from './Category'; 5 | 6 | describe('CategoryIcon', () => { 7 | const categories = [ 8 | { name: 'Task Management', testId: 'task-management-icon' }, 9 | { name: 'File Management', testId: 'file-management-icon' }, 10 | { name: 'Note Enhancements', testId: 'note-enhancements-icon' }, 11 | { name: 'Data Visualization', testId: 'data-visualization-icon' }, 12 | { name: '3rd Party Integrations', testId: 'third-party-integrations-icon' }, 13 | { name: 'Productivity Tools', testId: 'productivity-tools-icon' }, 14 | { name: 'Coding & Technical Tools', testId: 'coding-technical-tools-icon' }, 15 | { name: 'Creative & Writing Tools', testId: 'creative-writing-tools-icon' }, 16 | { name: 'Privacy & Security', testId: 'privacy-security-icon' }, 17 | { name: 'Customization & UI', testId: 'customization-ui-icon' }, 18 | { name: 'Collaboration & Sharing', testId: 'collaboration-sharing-icon' }, 19 | { 20 | name: 'Learning & Knowledge Management', 21 | testId: 'learning-knowledge-management-icon', 22 | }, 23 | { name: 'Miscellaneous', testId: 'miscellaneous-icon' }, 24 | { name: 'Uncategorized', testId: 'uncategorized-icon' }, 25 | { name: 'Unknown', testId: 'default-icon' }, 26 | ]; 27 | 28 | categories.forEach(({ name, testId }) => { 29 | test(`renders correct icon for ${name}`, () => { 30 | render(); 31 | const icon = screen.queryByTestId(testId); 32 | expect(icon).toBeInTheDocument(); 33 | expect(icon).toHaveAttribute('width', '48'); 34 | expect(icon).toHaveAttribute('height', '48'); 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /components/Comments.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useRef, useEffect } from 'react'; 3 | 4 | const Comments = () => { 5 | const commentBox = useRef(null); 6 | 7 | useEffect(() => { 8 | const script = document.createElement('script'); 9 | script.src = 'https://utteranc.es/client.js'; 10 | script.async = true; 11 | script.crossOrigin = 'anonymous'; 12 | script.setAttribute('repo', 'ganesshkumar/comments'); 13 | script.setAttribute('issue-term', 'url'); 14 | script.setAttribute('theme', 'github-light'); 15 | script.setAttribute('label', 'obsidian-dataview-query-wizard'); 16 | commentBox.current.appendChild(script); 17 | }, []); 18 | 19 | return
; 20 | }; 21 | 22 | export default Comments; 23 | -------------------------------------------------------------------------------- /components/Divider.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import '@testing-library/jest-dom'; 4 | import Divider from './Divider'; 5 | 6 | describe('Divider Component', () => { 7 | it('renders the Divider component', () => { 8 | render(); 9 | 10 | // Check if the HR.Trimmed component is rendered 11 | expect(screen.getByRole('separator')).toBeInTheDocument(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /components/Divider.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { HR } from 'flowbite-react'; 3 | 4 | const Divider = () => ( 5 |
6 | 7 |
8 | ); 9 | 10 | export default Divider; 11 | -------------------------------------------------------------------------------- /components/Faq.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import '@testing-library/jest-dom'; 4 | import Faqs from './Faq'; 5 | 6 | const mockFaqs = [ 7 | { 8 | question: 'What is your return policy?', 9 | answer: 'You can return any item within 30 days of purchase.', 10 | }, 11 | { 12 | question: 'How do I track my order?', 13 | answer: 14 | 'You can track your order using the tracking number provided in your order confirmation email.', 15 | }, 16 | ]; 17 | 18 | describe('Faqs Component', () => { 19 | it('renders the Faqs component', () => { 20 | render(); 21 | 22 | // Check if the FAQ questions and answers are rendered 23 | mockFaqs.forEach((faq) => { 24 | expect(screen.getByText(faq.question)).toBeInTheDocument(); 25 | expect(screen.getByText(faq.answer)).toBeInTheDocument(); 26 | }); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /components/Faq.tsx: -------------------------------------------------------------------------------- 1 | import { Accordion } from 'flowbite-react'; 2 | import React from 'react'; 3 | import { Faq } from '../lib/abstractions'; 4 | 5 | interface IFaqProps { 6 | faqs: Faq[]; 7 | } 8 | 9 | const Faqs = ({ faqs }: IFaqProps) => { 10 | return ( 11 |
12 | 13 | {faqs && 14 | faqs.map((faq, idx) => ( 15 | 16 | 17 |
18 | {faq.question} 19 |
20 |
21 | 22 |
23 | {faq.answer} 24 |
25 |
26 |
27 | ))} 28 |
29 |
30 | ); 31 | }; 32 | 33 | export default Faqs; 34 | -------------------------------------------------------------------------------- /components/FavPluginUpdates.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import '@testing-library/jest-dom'; 4 | import FavPluginUpdates, { NoFavPlugins } from './FavPluginUpdates'; 5 | import { setupFavorites } from '../utils/favorites'; 6 | 7 | jest.mock('../utils/favorites', () => ({ 8 | setupFavorites: jest.fn(), 9 | })); 10 | 11 | describe('FavPluginUpdates', () => { 12 | it('renders NoFavPlugins component when there are no favorites', () => { 13 | setupFavorites.mockImplementation((setFavorites) => setFavorites([])); 14 | render(); 15 | expect( 16 | screen.getByText('Updates from your favorite plugins') 17 | ).toBeInTheDocument(); 18 | }); 19 | 20 | it('does not render NoFavPlugins component when there are favorites', () => { 21 | setupFavorites.mockImplementation((setFavorites) => 22 | setFavorites(['plugin1']) 23 | ); 24 | render(); 25 | expect( 26 | screen.queryByText('Updates from your favorite plugins') 27 | ).not.toBeInTheDocument(); 28 | }); 29 | }); 30 | 31 | describe('NoFavPlugins', () => { 32 | it('renders step instructions correctly', () => { 33 | render(); 34 | expect( 35 | screen.getByText("Find and open your favorite plugin's page.") 36 | ).toBeInTheDocument(); 37 | expect( 38 | screen.getByText( 39 | 'Click the favorite button to mark the plugin as your favorite.' 40 | ) 41 | ).toBeInTheDocument(); 42 | expect( 43 | screen.getByText( 44 | 'Any plugin updates for your favorite plugins will appear here.' 45 | ) 46 | ).toBeInTheDocument(); 47 | }); 48 | 49 | it('changes step on click', () => { 50 | render(); 51 | const stepElement = screen.getByText( 52 | "Find and open your favorite plugin's page." 53 | ); 54 | stepElement.click(); 55 | expect( 56 | screen.getByText( 57 | 'Click the favorite button to mark the plugin as your favorite.' 58 | ) 59 | ).toBeInTheDocument(); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /components/Favorites.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { useCallback, useEffect, useState } from 'react'; 4 | import { setFavorite, unsetFavorite } from '../utils/favorites'; 5 | 6 | import { Plus, Minus, Share2 } from 'react-feather'; 7 | import { useAnalytics } from '../lib/analytics/analytics'; 8 | 9 | const Favorites = ({ isFavorite, plugin, setFavorites }) => { 10 | const [shareText, setShareText] = useState('share'); 11 | const { trackEvent } = useAnalytics(); 12 | 13 | const hostname = 14 | process.env.hostname || 15 | process.env.VERCEL_URL || 16 | process.env.NODE_ENV === 'production' 17 | ? 'https://www.obsidianstats.com' 18 | : 'http://localhost:5000'; 19 | 20 | const shareClicked = () => { 21 | if (navigator) { 22 | navigator.clipboard.writeText(`${hostname}/plugins/${plugin.pluginId}`); 23 | setShareText('copied link to clipboard'); 24 | } 25 | }; 26 | 27 | useEffect(() => { 28 | if (shareText === 'copied link to clipboard') { 29 | setTimeout(() => setShareText('share'), 5000); 30 | } 31 | }, [shareText]); 32 | 33 | const handleSetFavoriteClicked = useCallback(() => { 34 | trackEvent('Favorite Button Click'); 35 | setFavorite(plugin.pluginId, setFavorites); 36 | }, [plugin.pluginId, setFavorites]); 37 | 38 | const handleUnsetFavoriteClicked = useCallback(() => { 39 | trackEvent('Unfavorite Button Click'); 40 | unsetFavorite(plugin.pluginId, setFavorites); 41 | }, [plugin.pluginId, setFavorites]); 42 | 43 | return ( 44 |
45 | {isFavorite ? ( 46 |
47 | 48 |
53 | unfavorite 54 |
55 |
56 | ) : ( 57 |
58 | 59 |
64 | favorite 65 |
66 |
67 | )} 68 |
69 | 70 |
75 | {shareText} 76 |
77 |
78 |
79 | ); 80 | }; 81 | 82 | export default Favorites; 83 | -------------------------------------------------------------------------------- /components/Footer.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import '@testing-library/jest-dom'; 3 | import { Footer } from './Footer'; 4 | import Constants from '../constants'; 5 | 6 | describe('AppFooter', () => { 7 | test('renders footer brand', () => { 8 | render(