├── .editorconfig ├── .eslintrc.cjs ├── .github └── workflows │ ├── gh-pages.yml │ └── release.yml ├── .gitignore ├── LICENSE ├── README.md ├── assets ├── starred_repos_organizer_01.png ├── starred_repos_organizer_02.png ├── starred_repos_organizer_03.png └── starred_repos_organizer_04.png ├── index.html ├── package-lock.json ├── package.json ├── src ├── App.css ├── App.tsx ├── components │ ├── Footer.css │ ├── Footer.tsx │ ├── Menu.css │ ├── Menu.tsx │ ├── Notification.css │ ├── Notification.tsx │ ├── Pagination.css │ ├── Pagination.tsx │ ├── RepoAdd.css │ ├── RepoAdd.tsx │ ├── RepoCard.css │ ├── RepoCard.tsx │ ├── RepoEdit.css │ ├── RepoEdit.tsx │ ├── RepoGrid.css │ ├── RepoGrid.tsx │ ├── RepoList.tsx │ ├── RepoSelect.css │ ├── RepoSelect.tsx │ ├── SearchFilter.tsx │ ├── Select.css │ ├── Select.tsx │ ├── TopicFilter.css │ ├── TopicFilter.tsx │ ├── TopicSelect.css │ ├── TopicSelect.tsx │ ├── ViewByTopics.css │ ├── ViewByTopics.tsx │ ├── ViewPagination.tsx │ └── index.ts ├── main.tsx ├── repo │ ├── BaseRepo.ts │ ├── GitHubRepo.ts │ ├── GitLabRepo.ts │ ├── GiteaRepo.ts │ └── index.ts ├── server.ts ├── settings │ └── index.ts ├── storage │ ├── index.ts │ ├── localStorageDriver.ts │ ├── mockDriver.ts │ └── restApiDriver.ts ├── types.ts ├── utils.ts └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json ├── user-data-sample.json └── vite.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 4 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | "eslint:recommended", 6 | "plugin:@typescript-eslint/recommended", 7 | "plugin:react-hooks/recommended", 8 | ], 9 | ignorePatterns: ["dist", ".eslintrc.cjs"], 10 | parser: "@typescript-eslint/parser", 11 | plugins: ["react-refresh"], 12 | rules: { 13 | "react-refresh/only-export-components": [ 14 | "warn", 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /.github/workflows/gh-pages.yml: -------------------------------------------------------------------------------- 1 | name: GitHub Pages Sync 2 | 3 | on: 4 | push: 5 | branches: ["master"] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: write 12 | concurrency: 13 | group: ${{ github.workflow }}-${{ github.ref }} 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v3 17 | 18 | - name: Set up Node.js 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: '22' 22 | 23 | - name: Install dependencies 24 | run: npm install 25 | 26 | - name: Build demo 27 | run: npm run build:demo 28 | 29 | - name: Deploy 30 | uses: peaceiris/actions-gh-pages@v4 31 | with: 32 | github_token: ${{ secrets.GITHUB_TOKEN }} 33 | publish_branch: demo 34 | publish_dir: ./dist 35 | commit_message: update Starred Repos Organizer demo 36 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v3 14 | 15 | - name: Set up Node.js 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: '22' 19 | 20 | - name: Install dependencies 21 | run: npm install 22 | 23 | - name: Build demo 24 | run: npm run build:demo 25 | 26 | - name: Rename built files 27 | run: mv dist/index.html dist/app.html 28 | 29 | - name: Release 30 | uses: softprops/action-gh-release@v2 31 | with: 32 | files: dist/app.html 33 | 34 | permissions: 35 | contents: write 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | user-data.json 2 | data/ 3 | 4 | requests.http 5 | 6 | .env 7 | *.bak 8 | 9 | # Logs 10 | logs 11 | *.log 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | pnpm-debug.log* 16 | lerna-debug.log* 17 | 18 | node_modules 19 | dist 20 | dist-ssr 21 | *.local 22 | 23 | # Editor directories and files 24 | .vscode/* 25 | !.vscode/extensions.json 26 | .idea 27 | .DS_Store 28 | *.suo 29 | *.ntvs* 30 | *.njsproj 31 | *.sln 32 | *.sw? 33 | tags 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # STARRED REPOS ORGANIZER 2 | 3 | Organizer your starred repositories from various sources. 4 | 5 | Table Of Contents: 6 | 7 | - [Demo](#demo) 8 | - [Features](#features) 9 | - [Usage](#usage) 10 | - [Development](#development) 11 | - [Roadmap](#roadmap) 12 | - [Credits](#credits) 13 | - [License](#license) 14 | 15 | ## Demo 16 | 17 | A live demo is available at . 18 | 19 | ![Starred Repos Organizer Screenshot 1](./assets/starred_repos_organizer_01.png) 20 | ![Starred Repos Organizer Screenshot 2](./assets/starred_repos_organizer_02.png) 21 | ![Starred Repos Organizer Screenshot 3](./assets/starred_repos_organizer_03.png) 22 | ![Starred Repos Organizer Screenshot 4](./assets/starred_repos_organizer_04.png) 23 | 24 | ## Features 25 | 26 | - Star repositories from GitHub, GitLab, Codeberg, self-hosted Gitlab instance, 27 | self-hosted Gitea instance (more coming soon) 28 | - No account needed, save starred repositories locally 29 | - Import starred repositories from any public user profile on Github or Gitlab 30 | - Import repositories from JSON file 31 | - Export repositories to JSON file 32 | - Export filtered results only to JSON file 33 | - Modify repository topics by adding new ones or deleting existing 34 | - Filter topics by allowed-list and prevent duplicated topics with aliases 35 | - More privacy by not exposing your interests to the internet 36 | - Works offline (except when adding repositories, because it fetches remote data) 37 | - Can be downloaded as single HTML file to run locally 38 | - Sort by name, stars or forks 39 | - Text search and topic filter 40 | - Display items in list 41 | - Display items in grid 42 | - Pagination view 43 | - Group-by-topics view 44 | - Dark & Light themes 45 | - Persistent user preferences 46 | 47 | ## Usage 48 | 49 | There are few options (easiest is the first one): 50 | 51 | 1. Go to the [demo page](https://uwla.github.io/starred_repos_organizer) to use the app. 52 | 2. Download `app.html`from the [latest release](https://github.com/uwla/starred_repos_organizer/releases/tag/v0.0.1-beta). 53 | 3. Download `app.html` from the demo page. 54 | 4. Follow developemnt instructions to launch a local server or build the app locally. 55 | 56 | ## Development 57 | 58 | 1. Clone the repo and `cd` into it: 59 | 60 | ```bash 61 | git clone https://github.com/uwla/starred_repos_organizer && cd starred_repos_organizer 62 | cd starred_repos_organizer 63 | ``` 64 | 65 | 2. Install dependencies: 66 | 67 | ```bash 68 | npm install 69 | ``` 70 | 71 | 3. Copy the local sample file `user-data-sample.json` to `user-data.json`: 72 | 73 | ```bash 74 | cp user-data-sample.json user-data.json 75 | ``` 76 | 77 | This is where the data will be stored. 78 | 79 | 4. Run the scripts: 80 | 81 | To start development server using localStorage for storage: 82 | 83 | ```bash 84 | npm run dev 85 | ``` 86 | 87 | To start development server using an REST server for storage: 88 | 89 | ```bash 90 | npm run dev:rest 91 | ``` 92 | 93 | To build the demo app: 94 | 95 | ```bash 96 | npm run build 97 | ``` 98 | 99 | ## Roadmap 100 | 101 | - [x] Search filter 102 | - [x] Topics filter 103 | - [x] Sort repos by name or stars 104 | - [x] Import all starred repos from public profiles 105 | - [x] Manual selection when importing repos in batch 106 | - [x] Display forks, code language, and other details 107 | - [x] Import data from file 108 | - [x] Export data to file 109 | - [x] Export only filtered entries 110 | - [x] Option to delete all repos 111 | - [x] Option to delete filtered repos 112 | - [x] Show notifications on success 113 | - [x] Manage topics globally 114 | - [x] Display items in list 115 | - [x] Display items in grid 116 | - [x] Group items by topic 117 | - [x] Filter topics by allowed-list 118 | - [x] Prevent duplicated topics using aliases 119 | - [x] Support for GitHub 120 | - [x] Support for GitLab 121 | - [x] Support for CodeBerg 122 | - [x] Support for self-hosted GitLab instance 123 | - [x] Support for self-hosted Gitea instance 124 | - [ ] Support for self-hosted Gogs instance 125 | - [x] Option to specify provider type 126 | - [ ] Option to set auth tokens 127 | 128 | ## Credits 129 | 130 | Thanks [Keziah Moselle](https://github.com/KeziahMoselle) for the 131 | inspiration from his project [export-github-stars](https://github.com/KeziahMoselle/export-github-stars), which export GitHub starred repositories to a JSON file. 132 | 133 | ## License 134 | 135 | MIT. 136 | -------------------------------------------------------------------------------- /assets/starred_repos_organizer_01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uwla/starred_repos_organizer/5e41aa64d620b46fb2593ba919b701fa8da65628/assets/starred_repos_organizer_01.png -------------------------------------------------------------------------------- /assets/starred_repos_organizer_02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uwla/starred_repos_organizer/5e41aa64d620b46fb2593ba919b701fa8da65628/assets/starred_repos_organizer_02.png -------------------------------------------------------------------------------- /assets/starred_repos_organizer_03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uwla/starred_repos_organizer/5e41aa64d620b46fb2593ba919b701fa8da65628/assets/starred_repos_organizer_03.png -------------------------------------------------------------------------------- /assets/starred_repos_organizer_04.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uwla/starred_repos_organizer/5e41aa64d620b46fb2593ba919b701fa8da65628/assets/starred_repos_organizer_04.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Starred Repos Organizer 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "git_stars_organizer", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "dev:rest": "concurrently --kill-others \"npm run serve:*\"", 9 | "build": "tsc && vite build", 10 | "build:demo": "NODE_ENV=demo npm run build", 11 | "serve:app": "VITE_STORAGE_DRIVER=rest vite", 12 | "serve:api": "tsx src/server", 13 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 14 | "preview": "vite preview" 15 | }, 16 | "dependencies": { 17 | "@icons-pack/react-simple-icons": "^9.3.0", 18 | "@mui/icons-material": "^5.15.9", 19 | "@mui/material": "^5.16.8", 20 | "axios": "^1.6.7", 21 | "bootstrap": "^5.3.2", 22 | "export-from-json": "^1.7.4", 23 | "json-server": "^1.0.0-alpha.23", 24 | "react": "^18.2.0", 25 | "react-bootstrap": "^2.10.1", 26 | "react-dom": "^18.2.0", 27 | "react-select": "^5.7.7", 28 | "tsx": "^4.7.1", 29 | "use-file-picker": "^2.1.1" 30 | }, 31 | "devDependencies": { 32 | "@types/bootstrap": "^5.2.10", 33 | "@types/json-server": "^0.14.7", 34 | "@types/react": "^18.2.55", 35 | "@types/react-bootstrap": "^0.32.35", 36 | "@types/react-dom": "^18.2.19", 37 | "@typescript-eslint/eslint-plugin": "^6.21.0", 38 | "@typescript-eslint/parser": "^6.21.0", 39 | "@vitejs/plugin-react": "^4.3.4", 40 | "babel-plugin-react-compiler": "^19.0.0-beta-e1e972c-20250221", 41 | "concurrently": "^8.2.2", 42 | "eslint": "^8.56.0", 43 | "eslint-plugin-react-hooks": "^4.6.0", 44 | "eslint-plugin-react-refresh": "^0.4.5", 45 | "react-compiler-runtime": "^19.0.0-beta-e1e972c-20250221", 46 | "typescript": "^5.2.2", 47 | "vite": "^5.1.0", 48 | "vite-plugin-singlefile": "^2.0.1" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | /* ────────────────────────────────────────────────────────────────────── */ 2 | /* NORMALIZE CSS */ 3 | 4 | * { 5 | box-sizing: border-box; 6 | } 7 | 8 | body, 9 | html { 10 | margin: 0; 11 | padding: 0; 12 | width: 100%; 13 | } 14 | 15 | label, button, [type=button], a[href] { 16 | cursor: pointer; 17 | } 18 | 19 | /* ────────────────────────────────────────────────────────────────────── */ 20 | /* APP */ 21 | 22 | #app { 23 | padding-top: 2rem; 24 | padding-bottom: 3rem; 25 | max-width: 900px; 26 | min-height: calc(100vh - 2.5rem); 27 | /* 2.5em = 1.5em (footer line-height) + 1em (footer padding) */ 28 | position: relative; 29 | } 30 | 31 | #app.full-width { 32 | width: 90%; 33 | max-width: 90%; 34 | margin-left: 5%; 35 | margin-right: 5%; 36 | } 37 | 38 | #app>* { 39 | margin-top: 1em; 40 | } 41 | 42 | #app h1 { 43 | margin-top: 1em; 44 | margin-bottom: 1em; 45 | text-align: center; 46 | } 47 | 48 | /* ────────────────────────────────────────────────────────────────────── */ 49 | /* DEMO MESSAGE */ 50 | 51 | #demo-msg { 52 | text-align: center; 53 | } 54 | 55 | #demo-msg .btn-close { 56 | right: 1em; 57 | } 58 | 59 | /* ────────────────────────────────────────────────────────────────────── */ 60 | /* FIXES */ 61 | 62 | #app .stack-filter { 63 | gap: 0.5em; 64 | flex-direction: row; 65 | } 66 | 67 | #app .stack-filter> :first-child { 68 | flex-grow: 1; 69 | } 70 | 71 | body .MuiChip-label:hover { 72 | text-decoration: underline; 73 | } 74 | 75 | body div[id^="react-select"][id$="listbox"] >div>div { 76 | cursor: pointer; 77 | } 78 | 79 | /* ────────────────────────────────────────────────────────────────────── */ 80 | /* UTILS */ 81 | 82 | .flex-grow { 83 | flex-grow: 2; 84 | } 85 | 86 | /* ────────────────────────────────────────────────────────────────────── */ 87 | /* TOASTS */ 88 | 89 | .toasts { 90 | padding: 1em; 91 | } 92 | 93 | .toasts .toast { 94 | display: flex; 95 | align-items: center; 96 | padding: 0.5em; 97 | font-size: 80%; 98 | --bs-toast-max-width: fit-content; 99 | --bs-toast-spacing: 0.75em; 100 | gap: 0.3em; 101 | } 102 | 103 | .toasts .toast>button { 104 | display: flex; 105 | border-radius: 0; 106 | padding: 0 0.25em; 107 | } 108 | 109 | .toasts .toast>button.close { 110 | padding: 0; 111 | } 112 | 113 | .toasts .toast svg { 114 | font-size: 1em; 115 | } 116 | 117 | /* ────────────────────────────────────────────────────────────────────── */ 118 | /* DARK MODE */ 119 | 120 | body.dark { 121 | --dark-color: #fff; 122 | --dark-bg: #000; 123 | --dark-chip-bg: #0080ff; 124 | --dark-menu-bg: #999; 125 | --dark-menu-border-color: #2b296c; 126 | --dark-menu-chip-bg: #2b296c; 127 | --dark-accordion-btn-bg: #198754; 128 | --dark-accordion-btn-color: #000 129 | --dark-btn-dark-color: #aaa; 130 | --dark-btn-dark-color-hover: #fff; 131 | background: var(--dark-bg); 132 | color: var(--dark-color); 133 | } 134 | 135 | body.dark .card { 136 | --bs-card-border-color: var(--dark-color); 137 | --bs-card-title-color: var(--dark-color); 138 | --bs-card-bg: var(--dark-bg); 139 | --bs-card-color: var(--dark-color); 140 | --bs-card-subtitle-color: var(--dark-color); 141 | } 142 | 143 | body.dark .modal, 144 | body.dark .modal-content { 145 | --bs-modal-border-color: var(--dark-color); 146 | --bs-modal-title-color: var(--dark-color); 147 | --bs-modal-bg: var(--dark-bg); 148 | --bs-modal-color: var(--dark-color); 149 | --bs-modal-subtitle-color: var(--dark-color); 150 | } 151 | 152 | body.dark button.dropdown-toggle { 153 | --bs-btn-color: var(--dark-color); 154 | --bs-btn-border-color: var(--dark-color); 155 | --bs-btn-bg: var(--dark-bg); 156 | } 157 | 158 | body.dark button.btn-outline-dark { 159 | --bs-btn-color: #aaa; 160 | --bs-btn-border-color: #aaa; 161 | --bs-btn-hover-color: var(--dark-color); 162 | --bs-btn-hover-border-color: var(--dark-color); 163 | } 164 | 165 | body.dark .dropdown-menu { 166 | --bs-dropdown-color: var(--dark-color); 167 | --bs-dropdown-link-color: var(--dark-color); 168 | --bs-dropdown-border-color: var(--dark-color); 169 | --bs-dropdown-bg: var(--dark-bg); 170 | } 171 | 172 | body.dark .accordion-button { 173 | --bs-accordion-btn-color: var(--dark-color); 174 | --bs-accordion-btn-border-color: var(--dark-color); 175 | --bs-accordion-btn-bg: var(--dark-accordion-btn-bg); 176 | } 177 | 178 | body.dark input.form-control { 179 | --bs-body-bg: var(--dark-bg); 180 | --bs-body-color: var(--dark-color); 181 | } 182 | 183 | body.dark input.form-control::-webkit-input-placeholder { 184 | color: var(--dark-color); 185 | opacity: 1 !important; 186 | } 187 | 188 | /* MATERIAL UI */ 189 | 190 | body.dark .MuiChip-label { 191 | color: var(--dark-chip-bg); 192 | border: 1px solid var(--dark-chip-bg); 193 | } 194 | 195 | body.dark .MuiCheckbox-root { 196 | color: var(--dark-color); 197 | } 198 | 199 | body.dark .MuiTablePagination-toolbar { 200 | color: var(--dark-color); 201 | background-color: var(--dark-bg); 202 | } 203 | 204 | body.dark .MuiTablePagination-toolbar svg { 205 | color: var(--dark-color); 206 | } 207 | 208 | body.dark .MuiList-root { 209 | color: var(--dark-color); 210 | background-color: var(--dark-bg); 211 | border: 1px solid var(--dark-color); 212 | border-radius: 5px; 213 | } 214 | 215 | body.dark .MuiTablePagination-input { 216 | border: 1px solid var(--dark-color); 217 | border-radius: 5px; 218 | } 219 | 220 | body.dark li.MuiButtonBase-root:hover { 221 | background-color: var(--dark-accordion-btn-bg); 222 | color: var(--dark-accordion-btn-color); 223 | } 224 | 225 | body.dark .MuiTablePagination-toolbar button:disabled svg { 226 | color: grey; 227 | } 228 | 229 | /* REACT SELECT */ 230 | 231 | body.dark div[id^="react-select"][id$="listbox"] { 232 | border: 1px solid var(--dark-menu-border-color); 233 | border-radius: 5px; 234 | background-color: var(--dark-menu-bg); 235 | } 236 | 237 | body.dark div[id^="react-select"][id$="listbox"]>div>div { 238 | border: 1px solid var(--dark-menu-border-color); 239 | background-color: var(--dark-bg); 240 | color: var(--dark-color); 241 | } 242 | 243 | body.dark div[id^="react-select"][id$="listbox"]>div>div:hover { 244 | background-color: var(--dark-menu-chip-bg); 245 | } 246 | 247 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Close as CloseIcon, 3 | Edit as EditIcon, 4 | Undo as UndoIcon, 5 | } from "@mui/icons-material"; 6 | import { Checkbox } from "@mui/material"; 7 | import { useEffect, useState } from "react"; 8 | import { 9 | Alert, 10 | Button, 11 | Container, 12 | Stack, 13 | Toast, 14 | ToastContainer, 15 | } from "react-bootstrap"; 16 | import { MultiValue } from "react-select"; 17 | 18 | import type { 19 | Repo, 20 | SelectOption, 21 | Topic, 22 | TopicAliases, 23 | } from "./types"; 24 | import { 25 | Footer, 26 | Menu, 27 | Notification, 28 | RepoAdd, 29 | RepoEdit, 30 | RepoGrid, 31 | RepoList, 32 | RepoSelect, 33 | SearchFilter, 34 | Select, 35 | TopicFilter, 36 | TopicSelect, 37 | ViewByTopics, 38 | ViewPagination, 39 | } from "./components"; 40 | import RepoProvider from "./repo"; 41 | import SettingsManager from "./settings"; 42 | import StorageDriver from "./storage"; 43 | import { 44 | applyFilters, 45 | extractTopics, 46 | optionsToTopics, 47 | keepOnlyRepoTopics, 48 | uniqueRepos, 49 | } from "./utils"; 50 | 51 | import "./App.css"; 52 | 53 | /* -------------------------------------------------------------------------- */ 54 | // Main 55 | 56 | const shouldShowDemoMsg = 57 | process.env.NODE_ENV == "demo" && localStorage.getItem("repos") == null; 58 | 59 | function App() { 60 | // saved settings 61 | const savedTheme = SettingsManager.get("theme"); 62 | const savedLayout = SettingsManager.get("layout"); 63 | const savedSize = SettingsManager.get("size"); 64 | const savedSortBy = SettingsManager.get("sortBy"); 65 | const savedView = SettingsManager.get("view"); 66 | 67 | // default values for state variables 68 | const defaultAppCssClass = (savedSize === "full") ? "full-width" : ""; 69 | const defaultLayout = (savedLayout === "list") ? RepoList : RepoGrid; 70 | const defaultView = (savedView === "topics") ? ViewByTopics : ViewPagination; 71 | const defaultGroupBy = (savedView === "topics"); 72 | 73 | // state variables 74 | const [allowedTopics, setAllowedTopics] = useState([] as string[]); 75 | const [topicAliases, setTopicAliases] = useState({} as TopicAliases) 76 | const [deletedRepos, setDeletedRepos] = useState([] as Repo[]); 77 | const [Layout, setLayout] = useState(() => defaultLayout); 78 | const [editing, setEditing] = useState(false); 79 | const [errorMsg, setErrorMsg] = useState(""); 80 | const [appCssClasses, setAppCssClasses] = useState(defaultAppCssClass); 81 | const [filteredRepos, setFilteredRepos] = useState([] as Repo[]); 82 | const [editingRepo, setEditingRepo] = useState({} as Repo); 83 | const [pickingTopics, setPickingTopics] = useState(false); 84 | const [repos, setRepos] = useState([] as Repo[]); 85 | const [reposToAdd, setReposToAdd] = useState([] as Repo[]); 86 | const [searchQuery, setSearchQuery] = useState(""); 87 | const [sortBy, setSortBy] = useState(savedSortBy); 88 | const [selectedTopics, setSelectedTopics] = useState([] as SelectOption[]); 89 | const [showDemoMsg, setShowDemoMsg] = useState(shouldShowDemoMsg); 90 | const [successMsg, setSuccessMsg] = useState(""); 91 | const [topics, setTopics] = useState([] as string[]); 92 | const [groupBy, setGroupBy] = useState(defaultGroupBy); 93 | const [View, setView] = useState(() => defaultView); 94 | 95 | // Asynchronous data fetching 96 | useEffect(() => { 97 | toggleDarkMode(savedTheme); 98 | fetchData(); 99 | }, []); 100 | 101 | async function fetchData() { 102 | await StorageDriver.fetchRepos().then((repos: Repo[]) => { 103 | // Assign index to each repo so they can be sorted to the default 104 | // order later on. 105 | repos.forEach((repo: Repo, index: number) => (repo.index = index)); 106 | 107 | // After assigning the indexes, we can safely update the state. 108 | setRepos(repos); 109 | 110 | // Update topics. 111 | setTopics(extractTopics(repos)); 112 | 113 | // reset search filters 114 | setFilteredRepos(repos); 115 | setSelectedTopics([]); 116 | setSearchQuery(""); 117 | }); 118 | await StorageDriver.getAllowedTopics().then((topics: Topic[]) => { 119 | setAllowedTopics(topics); 120 | }) 121 | await StorageDriver.getTopicAliases().then((aliases: TopicAliases) => { 122 | setTopicAliases(aliases) 123 | }) 124 | } 125 | 126 | /* ---------------------------------------------------------------------- */ 127 | // Internal handlers 128 | 129 | function toggleDarkMode(theme = '') { 130 | const body = document.body; 131 | 132 | if (theme === 'light' || body.classList.contains('dark')) { 133 | body.classList.remove('dark'); 134 | body.setAttribute('data-bs-theme', 'light'); 135 | SettingsManager.set("theme", "light"); 136 | return; 137 | } 138 | 139 | if (theme === 'dark' || !body.classList.contains('dark')) { 140 | body.classList.add('dark'); 141 | body.setAttribute('data-bs-theme', 'dark'); 142 | SettingsManager.set("theme", "dark"); 143 | return; 144 | } 145 | } 146 | 147 | function toggleAppWidth() { 148 | if (appCssClasses === "") { 149 | SettingsManager.set("size", "full"); 150 | setAppCssClasses("full-width"); 151 | } else { 152 | SettingsManager.set("size", "half"); 153 | setAppCssClasses(""); 154 | } 155 | } 156 | 157 | function handleSearch(text: string) { 158 | setSearchQuery(text); 159 | const plainTopics = optionsToTopics(selectedTopics); 160 | setFilteredRepos(applyFilters(repos, text, plainTopics)); 161 | } 162 | 163 | function handleSelect(topics: SelectOption[]) { 164 | setSelectedTopics(topics); 165 | const plainTopics = optionsToTopics(topics); 166 | setFilteredRepos(applyFilters(repos, searchQuery, plainTopics)); 167 | } 168 | 169 | function handleSelectLayout(value: string) { 170 | switch (value) { 171 | case "grid": 172 | setLayout(() => RepoGrid); 173 | break; 174 | case "list": 175 | setLayout(() => RepoList); 176 | break; 177 | default: 178 | return; 179 | } 180 | SettingsManager.set('layout', value); 181 | } 182 | 183 | function handleSelectView(value: string) { 184 | switch (value) { 185 | case "pagination": 186 | setView(() => ViewPagination); 187 | break; 188 | case "topics": 189 | setView(() => ViewByTopics); 190 | break; 191 | default: 192 | return; 193 | } 194 | SettingsManager.set('view', value); 195 | } 196 | 197 | function handleTopicClicked(topic: string) { 198 | const plainTopics = optionsToTopics(selectedTopics); 199 | if (plainTopics.includes(topic)) return; 200 | handleSelect([...selectedTopics, { label: topic, value: topic }]); 201 | } 202 | 203 | function handleSort(value: string) { 204 | setSortBy(value); 205 | } 206 | 207 | function getSortFn(sortBy: string) { 208 | let fn : (a: Repo, b: Repo) => number; 209 | switch (sortBy) { 210 | case "": 211 | fn = function (a: Repo, b: Repo) { 212 | return (a.index || 0) - (b.index || 0); 213 | }; 214 | break; 215 | case "stars": 216 | fn = function (a: Repo, b: Repo) { 217 | return (b.stars || 0) - (a.stars || 0); 218 | }; 219 | break; 220 | case "name": 221 | fn = function (a: Repo, b: Repo) { 222 | return a.name.localeCompare(b.name); 223 | }; 224 | break; 225 | case "forks": 226 | fn = function (a: Repo, b: Repo) { 227 | return (b.forks || 0) - (a.forks || 0); 228 | }; 229 | break; 230 | case "random": 231 | fn = function(_a: Repo, _b: Repo) { 232 | return (Math.random() > 0.5) ? -1 : 1; 233 | }; 234 | break; 235 | default: 236 | throw new Error(`Unknown sort option ${sortBy}`); 237 | } 238 | SettingsManager.set('sortBy', sortBy); 239 | return fn; 240 | } 241 | 242 | function updateStateRepos(newRepos: Repo[]) { 243 | setRepos(newRepos); 244 | setFilteredRepos(newRepos); 245 | setTopics(extractTopics(newRepos)); 246 | setSearchQuery(""); 247 | } 248 | 249 | async function handleAddItem(repo: Repo) { 250 | if (repos.find((r: Repo) => r.url === repo.url)) { 251 | setErrorMsg("Repo already added!"); 252 | return false; 253 | } 254 | 255 | return await StorageDriver 256 | .createRepo(repo) 257 | .then((repo) => { 258 | updateStateRepos([repo, ...repos]); 259 | setSuccessMsg("Repository added"); 260 | return true; 261 | }) 262 | .catch(() => { 263 | setErrorMsg("Failed to add repository"); 264 | return false; 265 | }); 266 | } 267 | 268 | async function confirmAddMany(manyRepos: Repo[]) { 269 | setReposToAdd(manyRepos); 270 | return true; 271 | } 272 | 273 | async function importData(data: any) { 274 | const { repos, topics_allowed, topic_aliases } = data; 275 | confirmAddMany(repos).then((confirmed: boolean) => { 276 | if (confirmed) { 277 | StorageDriver.setAllowedTopics(topics_allowed) 278 | .then(() => setAllowedTopics(topics_allowed)); 279 | StorageDriver.setTopicAliases(topic_aliases) 280 | .then(() => setTopicAliases(topic_aliases)) 281 | } 282 | }) 283 | } 284 | 285 | async function handleAddMany(manyRepos: Repo[]) { 286 | if (manyRepos.length === 0) { 287 | setReposToAdd([]); 288 | return; 289 | } 290 | 291 | return await StorageDriver 292 | .createMany(manyRepos) 293 | .then((created) => { 294 | updateStateRepos(uniqueRepos([...created, ...repos])); 295 | setSuccessMsg("Repositories added"); 296 | return true; 297 | }) 298 | .catch(() => { 299 | setErrorMsg("Failed to add repositories"); 300 | return false; 301 | }) 302 | .finally(() => setReposToAdd([])); 303 | } 304 | 305 | async function handleDelete(repo: Repo) { 306 | await StorageDriver.deleteRepo(repo).then((status: boolean) => { 307 | if (status) { 308 | // Update local state. 309 | const filterDeleted = (r: Repo) => r.id != repo.id; 310 | const newRepos = repos.filter(filterDeleted); 311 | setRepos(newRepos); 312 | setFilteredRepos(filteredRepos.filter(filterDeleted)); 313 | setTopics(extractTopics(newRepos)); 314 | 315 | // Cache deleted repos for undo actions. 316 | deletedRepos.push(repo); 317 | setDeletedRepos(deletedRepos); 318 | } else { 319 | setErrorMsg("Failed to delete repository"); 320 | } 321 | }); 322 | } 323 | 324 | async function handleDeleteMany(repos: Repo[]) { 325 | if (repos.length === 0) return; 326 | await StorageDriver 327 | .deleteMany(repos) 328 | .then(fetchData) 329 | .then(() => setSuccessMsg(`${repos.length} repos deleted`)); 330 | } 331 | 332 | function closeUndoDeleteToast(repo: Repo) { 333 | setDeletedRepos(deletedRepos.filter((r: Repo) => r.id !== repo.id)); 334 | } 335 | 336 | async function handleUndoDeleted(repo: Repo) { 337 | closeUndoDeleteToast(repo); 338 | handleAddItem(repo); 339 | } 340 | 341 | function handleEdit(repo: Repo) { 342 | setEditingRepo(repo); 343 | setEditing(true); 344 | } 345 | 346 | async function handleUpdate(repo: Repo, modified = false) { 347 | // this marks the repo to be updated as been locally modified. 348 | repo.modified = modified; 349 | 350 | return StorageDriver 351 | .updateRepo(repo) 352 | .then((updated: Repo) => { 353 | // Update local repos. 354 | let index = repos.findIndex((r: Repo) => r.id === updated.id); 355 | repos.splice(index, 1, updated); 356 | setRepos([...repos]); 357 | 358 | // Updated local filtered repos. 359 | index = filteredRepos.findIndex( 360 | (r: Repo) => r.id === updated.id 361 | ); 362 | filteredRepos.splice(index, 1, updated); 363 | setFilteredRepos([...filteredRepos]); 364 | 365 | // Update topics. 366 | setTopics(extractTopics(repos)); 367 | 368 | // Finish editing 369 | setEditing(false); 370 | 371 | // indicates updated was successful 372 | setSuccessMsg("Repo updated"); 373 | return true; 374 | }) 375 | .catch(() => { 376 | setErrorMsg("Failed to updated repository"); 377 | return false; 378 | }); 379 | } 380 | 381 | async function handleRefresh(repo: Repo) { 382 | // Get the updated version of the repository. 383 | const updated = await RepoProvider.getRepo(repo.url); 384 | 385 | if (repo.modified) { 386 | // Preserve the topics, which may have been overwritten locally. 387 | updated.topics = repo.topics; 388 | } 389 | 390 | // preserve original id 391 | updated.id = repo.id; 392 | 393 | // Then, update it. 394 | handleUpdate(updated); 395 | } 396 | 397 | async function handleTopicsPicking(selectedTopics: Topic[], forceUpdate = false) { 398 | if (selectedTopics.length === topics.length && !forceUpdate) { 399 | setPickingTopics(false); 400 | return; 401 | } 402 | 403 | const updatedRepos = keepOnlyRepoTopics(repos, selectedTopics); 404 | 405 | StorageDriver 406 | .updateMany(updatedRepos) 407 | .then(updateStateRepos) 408 | .then(() => setPickingTopics(false)); 409 | } 410 | 411 | async function handleSetAllowedTopics(topics: Topic[]) { 412 | StorageDriver.setAllowedTopics(topics).then(() => { 413 | setAllowedTopics(topics) 414 | handleTopicsPicking(topics, true); 415 | }); 416 | } 417 | 418 | async function handleSetTopicAliases(aliases: TopicAliases) { 419 | await StorageDriver.setTopicAliases(aliases) 420 | .then((success) => { 421 | if (success) { 422 | setTopicAliases(aliases) 423 | setPickingTopics(false); 424 | } 425 | }) 426 | } 427 | 428 | /* ---------------------------------------------------------------------- */ 429 | // render logic 430 | 431 | return ( 432 | <> 433 | {/* DEMO MESSAGE INFO */} 434 | setShowDemoMsg(false)} 439 | dismissible 440 | > 441 | This app is running on demo mode. Sample data has been loaded. 442 | Data is saved to local storage and can be exported/imported. 443 | 444 | 445 | 446 | {/* */} 447 |

STARRED REPOS

448 | 449 | {/* */} 450 | 461 | 462 | {/* */} 463 | 464 | ) => 468 | handleSelect(value as SelectOption[]) 469 | } 470 | /> 471 | 474 | 475 | 476 | 477 | {/* */} 478 | 479 | 480 | {/* DISPLAY OPTIONS */} 481 | 482 | 483 | {/* SORT BY */} 484 | 498 | 499 | {/* GROUP BY */} 500 | 501 | { 504 | if (checked) handleSelectView('topics'); 505 | else handleSelectView('pagination'); 506 | setGroupBy(checked); 507 | }} 508 | /> 509 | Group by topic 510 | 511 | 512 | {/* SPACER */} 513 |
514 | 515 | {/* ADD BUTTON */} 516 | 517 |
518 | 519 | {/* STATS FILTERED RESULTS */} 520 | {searchQuery &&

Search results for "{searchQuery}"

} 521 | {filteredRepos.length !== repos.length && ( 522 |

523 | Showing {filteredRepos.length} repositories filtered 524 | from {repos.length} 525 |

526 | )} 527 | 528 | {/* MAIN VIEW */} 529 | 539 | 540 | {/* MODAL SELECT REPOSITORIES */} 541 | 546 | 547 | {/* MODAL TO EDIT REPOSITORY */} 548 | {editing && ( 549 | setEditing(false)} 553 | onUpdate={(repo) => handleUpdate(repo, true)} 554 | /> 555 | )} 556 | 557 | {/* MODAL TO EDIT TOPICS */} 558 | setPickingTopics(false)} 564 | onConfirmSelection={handleTopicsPicking} 565 | onUpdateAllowedList={handleSetAllowedTopics} 566 | onUpdateTopicAliases={handleSetTopicAliases} 567 | /> 568 | 569 | {/* NOTIFICATION TOASTS */} 570 | 575 | {/* SUCCESS NOTIFICATION */} 576 | setSuccessMsg("")} 580 | /> 581 | {/* ERROR NOTIFICATION */} 582 | setErrorMsg("")} 586 | /> 587 | {/* DELETED NOTIFICATION W/ UNDO */} 588 | {deletedRepos.map((r: Repo) => ( 589 | closeUndoDeleteToast(r)} 594 | > 595 | Deleted {r.name} 596 | 602 | 609 | 610 | ))} 611 | 612 | 613 | 614 | {/* */} 615 |