diff --git a/.coderabbit.yaml b/.coderabbit.yaml index f69abfe..5754fec 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -173,7 +173,7 @@ reviews: - Ensure that "use client" is being used - Ensure that only features that allow pure client-side rendering are used - NextJS best practices (including file structure, API routes, and static generation methods) are used. - + TypeScript: - Avoid 'any', use explicit types - Prefer 'import type' for type imports @@ -217,7 +217,7 @@ reviews: - path: "**/*.{py}" instructions: | Python: - - Check for major PEP 8 violations and Python best practices. + - Check for major PEP 8 violations and Python best practices. # Solidity Smart Contract files - path: "**/*.sol" @@ -244,7 +244,6 @@ reviews: - Integer overflows/underflows (if using unchecked blocks) - Front-running risks where applicable - # Javascript/Typescript test files - path: "**/*.test.{ts,tsx,js,jsx}" instructions: | @@ -254,7 +253,7 @@ reviews: - Async behavior is properly tested - Accessibility testing is included - Test descriptions are sufficiently detailed to clarify the purpose of each test - - The tests are not tautological + - The tests are not tautological # Solidity test files - path: "**/*.test.{sol}" @@ -269,7 +268,6 @@ reviews: - Deterministic behavior (tests should not rely on implicit execution order or shared mutable state). - Clear and descriptive test names that reflect the intended behavior being validated. - # Asset files (images, fonts, etc.) - path: "assets/**/*" instructions: | diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..94f480d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/good_first_issue.yml b/.github/ISSUE_TEMPLATE/good_first_issue.yml index 6f1ae36..c01d8ee 100644 --- a/.github/ISSUE_TEMPLATE/good_first_issue.yml +++ b/.github/ISSUE_TEMPLATE/good_first_issue.yml @@ -45,7 +45,7 @@ body: attributes: value: | ## AI Notice - Important! - + We encourage contributors to use AI tools responsibly when creating Pull Requests. While AI can be a valuable aid, it is essential to ensure that your contributions meet the task requirements, build successfully, include relevant tests, and pass all linters. Submissions that do not meet these standards may be closed without warning to maintain the quality and integrity of the project. Please take the time to understand the changes you are proposing and their impact. - type: checkboxes diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 68c5334..de13795 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,18 +1,21 @@ -### Addressed Issues: +### Addressed Issues: + + Fixes #(issue number) +### Screenshots/Recordings: -### Screenshots/Recordings: +### Additional Notes: -### Additional Notes: - ## Checklist + + - [ ] My code follows the project's code style and conventions - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings or errors @@ -21,4 +24,4 @@ Fixes #(issue number) ## โš ๏ธ AI Notice - Important! - We encourage contributors to use AI tools responsibly when creating Pull Requests. While AI can be a valuable aid, it is essential to ensure that your contributions meet the task requirements, build successfully, include relevant tests, and pass all linters. Submissions that do not meet these standards may be closed without warning to maintain the quality and integrity of the project. Please take the time to understand the changes you are proposing and their impact. +We encourage contributors to use AI tools responsibly when creating Pull Requests. While AI can be a valuable aid, it is essential to ensure that your contributions meet the task requirements, build successfully, include relevant tests, and pass all linters. Submissions that do not meet these standards may be closed without warning to maintain the quality and integrity of the project. Please take the time to understand the changes you are proposing and their impact. diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 5df0eb4..f24b7d3 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -1,62 +1,62 @@ -name-template: 'v$RESOLVED_VERSION' -tag-template: 'v$RESOLVED_VERSION' +name-template: "v$RESOLVED_VERSION" +tag-template: "v$RESOLVED_VERSION" categories: - - title: '๐Ÿš€ Features' + - title: "๐Ÿš€ Features" labels: - - 'feature' - - 'enhancement' - - 'feat' - - title: '๐Ÿ› Bug Fixes' + - "feature" + - "enhancement" + - "feat" + - title: "๐Ÿ› Bug Fixes" labels: - - 'fix' - - 'bugfix' - - 'bug' - - title: '๐Ÿงฐ Maintenance' + - "fix" + - "bugfix" + - "bug" + - title: "๐Ÿงฐ Maintenance" labels: - - 'chore' - - 'maintenance' - - 'refactor' - - title: '๐Ÿ“ Documentation' + - "chore" + - "maintenance" + - "refactor" + - title: "๐Ÿ“ Documentation" labels: - - 'documentation' - - 'docs' - - title: '๐Ÿ”ง Configuration' + - "documentation" + - "docs" + - title: "๐Ÿ”ง Configuration" labels: - - 'configuration' - - 'config' - - title: '๐Ÿงช Tests' + - "configuration" + - "config" + - title: "๐Ÿงช Tests" labels: - - 'tests' - - 'test' - - title: 'โฌ†๏ธ Dependencies' + - "tests" + - "test" + - title: "โฌ†๏ธ Dependencies" labels: - - 'dependencies' - - 'deps' - - title: '๐ŸŽจ Frontend' + - "dependencies" + - "deps" + - title: "๐ŸŽจ Frontend" labels: - - 'frontend' - - 'ui' - - title: 'โš™๏ธ Backend' + - "frontend" + - "ui" + - title: "โš™๏ธ Backend" labels: - - 'backend' - - 'api' - - title: '๐Ÿ” Security' + - "backend" + - "api" + - title: "๐Ÿ” Security" labels: - - 'security' - - title: '๐Ÿณ Docker' + - "security" + - title: "๐Ÿณ Docker" labels: - - 'docker' - - title: '๐Ÿš€ CI/CD' + - "docker" + - title: "๐Ÿš€ CI/CD" labels: - - 'ci-cd' - - 'github-actions' - - title: '๐Ÿ‘ฅ Contributors' + - "ci-cd" + - "github-actions" + - title: "๐Ÿ‘ฅ Contributors" labels: - - 'first-time-contributor' - - 'external-contributor' + - "first-time-contributor" + - "external-contributor" -change-template: '- $TITLE @$AUTHOR (#$NUMBER)' +change-template: "- $TITLE @$AUTHOR (#$NUMBER)" change-title-escapes: '\<*_&' template: | @@ -71,14 +71,14 @@ template: | **Full Changelog**: https://github.com/$OWNER/$REPOSITORY/compare/$PREVIOUS_TAG...v$RESOLVED_VERSION exclude-labels: - - 'skip-changelog' - - 'no-changelog' - - 'duplicate' - - 'invalid' - - 'wontfix' + - "skip-changelog" + - "no-changelog" + - "duplicate" + - "invalid" + - "wontfix" replacers: - search: '/CVE-(\d{4})-(\d+)/g' - replace: '[CVE-$1-$2](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-$1-$2)' + replace: "[CVE-$1-$2](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-$1-$2)" include-pre-releases: false diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b2622c6..27e6122 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,90 +1,60 @@ # Workflow for building and deploying to GitHub Pages -name: Deploy site to Pages +name: Deploy Vite App to GitHub Pages on: - # Runs on pushes targeting the default branch push: - branches: ["main"] + branches: + - main - # Allows you to run this workflow manually from the Actions tab workflow_dispatch: -# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages permissions: contents: read pages: write id-token: write -# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. -# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. concurrency: - group: "pages" - cancel-in-progress: false + group: github-pages + cancel-in-progress: true jobs: - # Build job build: runs-on: ubuntu-latest + steps: - - name: Checkout + - name: Checkout repository uses: actions/checkout@v4 - - name: Detect package manager - id: detect-package-manager - run: | - if [ -f "${{ github.workspace }}/yarn.lock" ]; then - echo "manager=yarn" >> $GITHUB_OUTPUT - echo "command=install" >> $GITHUB_OUTPUT - echo "runner=yarn" >> $GITHUB_OUTPUT - exit 0 - elif [ -f "${{ github.workspace }}/package.json" ]; then - echo "manager=npm" >> $GITHUB_OUTPUT - echo "command=ci" >> $GITHUB_OUTPUT - echo "runner=npx --no-install" >> $GITHUB_OUTPUT - exit 0 - else - echo "Unable to determine package manager" - exit 1 - fi - - name: Setup Node + + - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: "20" - cache: ${{ steps.detect-package-manager.outputs.manager }} - - name: Setup Pages + node-version: 22 + cache: npm + + - name: Setup GitHub Pages uses: actions/configure-pages@v5 - with: - # Automatically inject basePath in your Next.js configuration file and disable - # server side image optimization (https://nextjs.org/docs/api-reference/next/image#unoptimized). - # - # You may remove this line if you want to manage the configuration yourself. - static_site_generator: next - - name: Restore cache - uses: actions/cache@v4 - with: - path: | - .next/cache - # Generate a new cache whenever packages or source files change. - key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }} - # If source files changed but packages didn't, rebuild from a prior cache. - restore-keys: | - ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}- + - name: Install dependencies - run: ${{ steps.detect-package-manager.outputs.manager }} ${{ steps.detect-package-manager.outputs.command }} - - name: Build - run: ${{ steps.detect-package-manager.outputs.runner }} next build - - name: Upload artifact + run: npm ci + + - name: Build application + run: npm run build + + - name: Upload build artifact uses: actions/upload-pages-artifact@v3 with: - path: ./out + path: ./dist - # Deployment job deploy: + needs: build + + runs-on: ubuntu-latest + environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - needs: build + steps: - name: Deploy to GitHub Pages id: deployment diff --git a/.github/workflows/sync-pr-labels.yml b/.github/workflows/sync-pr-labels.yml index 97155c8..97b1184 100644 --- a/.github/workflows/sync-pr-labels.yml +++ b/.github/workflows/sync-pr-labels.yml @@ -93,13 +93,13 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const prBody = context.payload.pull_request.body || ''; - + // Match patterns: Fixes #123, Closes #123, Resolves #123, etc. const issuePatterns = [ /(?:fix|fixes|fixed|close|closes|closed|resolve|resolves|resolved)\s+#(\d+)/gi, /#(\d+)/g ]; - + let issueNumber = null; for (const pattern of issuePatterns) { const match = prBody.match(pattern); @@ -109,7 +109,7 @@ jobs: break; } } - + core.setOutput('issue_number', issueNumber || ''); return issueNumber; @@ -121,7 +121,7 @@ jobs: script: | const issueNumber = '${{ steps.extract-issue.outputs.issue_number }}'; const prNumber = context.payload.pull_request.number; - + try { const issue = await github.rest.issues.get({ owner: context.repo.owner, @@ -185,7 +185,7 @@ jobs: script: | const prNumber = context.payload.pull_request.number; const changedFiles = JSON.parse('${{ steps.changed-files.outputs.files }}'); - + const fileLabels = []; const labelMappings = { 'documentation': ['.md', 'README', 'CONTRIBUTING', 'LICENSE', '.txt'], @@ -200,7 +200,7 @@ jobs: 'docker': ['Dockerfile', 'docker-compose', '.dockerignore'], 'ci-cd': ['.github/', '.gitlab-ci', 'Jenkinsfile', '.circleci'] }; - + for (const file of changedFiles) { for (const [label, patterns] of Object.entries(labelMappings)) { for (const pattern of patterns) { @@ -212,7 +212,7 @@ jobs: } } } - + if (fileLabels.length > 0) { console.log(`Applying file-based labels: ${fileLabels.join(', ')}`); await github.rest.issues.addLabels({ @@ -237,12 +237,12 @@ jobs: repo: context.repo.repo, pull_number: prNumber }); - + const additions = pr.data.additions; const deletions = pr.data.deletions; const totalChanges = additions + deletions; console.log(`PR has ${additions} additions and ${deletions} deletions (${totalChanges} total changes)`); - + let sizeLabel = ''; if (totalChanges <= 10) { sizeLabel = 'size/XS'; @@ -255,19 +255,19 @@ jobs: } else { sizeLabel = 'size/XL'; } - + console.log(`Applying size label: ${sizeLabel}`); - + const currentLabels = await github.rest.issues.listLabelsOnIssue({ owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber }); - + const sizeLabelsToRemove = currentLabels.data .map(label => label.name) .filter(name => name.startsWith('size/')); - + for (const label of sizeLabelsToRemove) { await github.rest.issues.removeLabel({ owner: context.repo.owner, @@ -276,7 +276,7 @@ jobs: name: label }); } - + await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, @@ -294,7 +294,7 @@ jobs: script: | const prNumber = context.payload.pull_request.number; const prAuthor = context.payload.pull_request.user.login; - + try { const commits = await github.rest.repos.listCommits({ owner: context.repo.owner, @@ -362,4 +362,4 @@ jobs: console.log('='.repeat(50)); console.log(`Current labels on PR #${prNumber}:`); console.log(currentLabels.join(', ') || 'No labels'); - console.log('='.repeat(50)); \ No newline at end of file + console.log('='.repeat(50)); diff --git a/.github/workflows/version-release.yml b/.github/workflows/version-release.yml index 981ebab..26f103a 100644 --- a/.github/workflows/version-release.yml +++ b/.github/workflows/version-release.yml @@ -5,7 +5,7 @@ on: branches: - main paths: - - 'VERSION' + - "VERSION" permissions: contents: write @@ -14,7 +14,7 @@ jobs: release: if: ${{ github.repository_owner == 'AOSSIE-Org' }} runs-on: ubuntu-latest - + steps: - name: Checkout code uses: actions/checkout@v4 @@ -62,16 +62,16 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const version = 'v${{ steps.get_version.outputs.version }}'; - + // Get all releases const { data: releases } = await github.rest.repos.listReleases({ owner: context.repo.owner, repo: context.repo.repo, }); - + // Find the draft release const draftRelease = releases.find(release => release.draft === true); - + if (draftRelease) { console.log(`Found draft release: ${draftRelease.name}`); diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2a9b56d..6b830ba 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -23,7 +23,7 @@ repos: - id: check-merge-conflict - id: check-added-large-files - id: mixed-line-ending - args: ['--fix=lf'] + args: ["--fix=lf"] # ---------------------------------- # 2. Config & data files validation @@ -42,11 +42,11 @@ repos: rev: v1.4.0 hooks: - id: detect-secrets - + # args: ['--baseline', '.secrets.baseline'] (Maintainers should add a .secrets.baseline file for secret scanning.) # ============================================================================== -# IMPORTANT: +# IMPORTANT: # Pre-commit runs locally every time you commit; only simple logic should be included here. # Heavy operations should be handled by CI/CD pipelines (GitHub Actions, etc.) # ============================================================================== diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..33c4a87 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +node_modules +dist +build +package-lock.json +yarn.lock \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..cd32a12 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,11 @@ +{ + "semi": true, + "singleQuote": false, + "tabWidth": 2, + "useTabs": false, + "trailingComma": "es5", + "printWidth": 80, + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 286b79e..e2bb064 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,12 +50,12 @@ Before opening an issue, search existing ones to avoid duplicates. Useful bug re OrgExplorer is a **single-package frontend** application: -| Area | Stack | -|------|--------| -| UI | [React](https://react.dev/) 19 | -| Language | [TypeScript](https://www.typescriptlang.org/) | -| Build & dev server | [Vite](https://vite.dev/) (with `@vitejs/plugin-react`) | -| Linting | [ESLint](https://eslint.org/) 9 (flat config in `eslint.config.js`) | +| Area | Stack | +| ------------------ | ------------------------------------------------------------------- | +| UI | [React](https://react.dev/) 19 | +| Language | [TypeScript](https://www.typescriptlang.org/) | +| Build & dev server | [Vite](https://vite.dev/) (with `@vitejs/plugin-react`) | +| Linting | [ESLint](https://eslint.org/) 9 (flat config in `eslint.config.js`) | Approximate layout: @@ -163,14 +163,14 @@ Fix any ESLint or TypeScript errors reported by these commands. Conventional prefixes help scan history: -| Prefix | Use for | -|--------|---------| -| `feat:` | New user-facing behavior | -| `fix:` | Bug fixes | -| `docs:` | Documentation only | -| `style:` | Formatting, no logic change | -| `refactor:` | Internal restructuring | -| `chore:` | Tooling, config, dependencies | +| Prefix | Use for | +| ----------- | ----------------------------- | +| `feat:` | New user-facing behavior | +| `fix:` | Bug fixes | +| `docs:` | Documentation only | +| `style:` | Formatting, no logic change | +| `refactor:` | Internal restructuring | +| `chore:` | Tooling, config, dependencies | Example: @@ -224,17 +224,21 @@ Use rebase or merge according to what maintainers prefer; rebasing keeps history ```markdown ## Description + Brief summary of changes. ## Related issue + Fixes #23 ## Testing + - `npm run lint` - `npm run build` - Manual: โ€ฆ ## Checklist + See PR template. ``` diff --git a/README.md b/README.md index 3c5adf2..a3fcd5c 100644 --- a/README.md +++ b/README.md @@ -71,21 +71,25 @@ TODO: List your main features here: TODO: Update based on your project ### Frontend + - React / Next.js / Flutter / React Native - TypeScript - TailwindCSS ### Backend + - Flask / FastAPI / Node.js / Supabase - Database: PostgreSQL / SQLite / MongoDB ### AI/ML (if applicable) + - LangChain / LangGraph / LlamaIndex - Google Gemini / OpenAI / Anthropic Claude - Vector Database: Weaviate / Pinecone / Chroma - RAG / Prompt Engineering / Agent Frameworks ### Blockchain (if applicable) + - Solidity / solana / cardano / ergo Smart Contracts - Hardhat / Truffle / foundry - Web3.js / Ethers.js / Wagmi @@ -98,21 +102,21 @@ TODO: Update based on your project TODO: Complete applicable items based on your project type - [ ] **The protocol** (if applicable): - - [ ] has been described and formally specified in a paper. - - [ ] has had its main properties mathematically proven. - - [ ] has been formally verified. + - [ ] has been described and formally specified in a paper. + - [ ] has had its main properties mathematically proven. + - [ ] has been formally verified. - [ ] **The smart contracts** (if applicable): - - [ ] were thoroughly reviewed by at least two knights of The Stable Order. - - [ ] were deployed to: [Add deployment details] + - [ ] were thoroughly reviewed by at least two knights of The Stable Order. + - [ ] were deployed to: [Add deployment details] - [ ] **The mobile app** (if applicable): - - [ ] has an _About_ page containing the Stability Nexus's logo and pointing to the social media accounts of the Stability Nexus. - - [ ] is available for download as a release in this repo. - - [ ] is available in the relevant app stores. + - [ ] has an _About_ page containing the Stability Nexus's logo and pointing to the social media accounts of the Stability Nexus. + - [ ] is available for download as a release in this repo. + - [ ] is available in the relevant app stores. - [ ] **The AI/ML components** (if applicable): - - [ ] LLM/model selection and configuration are documented. - - [ ] Prompts and system instructions are version-controlled. - - [ ] Content safety and moderation mechanisms are implemented. - - [ ] API keys and rate limits are properly managed. + - [ ] LLM/model selection and configuration are documented. + - [ ] Prompts and system instructions are version-controlled. + - [ ] Content safety and moderation mechanisms are implemented. + - [ ] API keys and rate limits are properly managed. --- @@ -135,12 +139,14 @@ TODO: Add your system architecture diagram here ``` You can create architecture diagrams using: + - [Draw.io](https://draw.io) - [Excalidraw](https://excalidraw.com) - [Lucidchart](https://lucidchart.com) - [Mermaid](https://mermaid.js.org) (for code-based diagrams) Example structure to include: + - Frontend components - Backend services - Database architecture @@ -241,8 +247,8 @@ For detailed setup instructions, please refer to our [Installation Guide](./docs TODO: Add screenshots showcasing your application -| | | | -|---|---|---| +| | | | +| ------------ | ------------ | ------------ | | Screenshot 1 | Screenshot 2 | Screenshot 3 | --- @@ -277,4 +283,4 @@ Thanks a lot for spending your time helping TODO grow. Keep rocking ๐Ÿฅ‚ [![Contributors](https://contrib.rocks/image?repo=AOSSIE-Org/TODO)](https://github.com/AOSSIE-Org/TODO/graphs/contributors) -ยฉ 2025 AOSSIE +ยฉ 2025 AOSSIE diff --git a/eslint.config.js b/eslint.config.js index 5e6b472..75d3c46 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,14 +1,14 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import tseslint from 'typescript-eslint' -import { defineConfig, globalIgnores } from 'eslint/config' +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; +import { defineConfig, globalIgnores } from "eslint/config"; export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(["dist"]), { - files: ['**/*.{ts,tsx}'], + files: ["**/*.{ts,tsx}"], extends: [ js.configs.recommended, tseslint.configs.recommended, @@ -20,4 +20,4 @@ export default defineConfig([ globals: globals.browser, }, }, -]) +]); diff --git a/package-lock.json b/package-lock.json index 367dd4e..190f145 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,22 +1,34 @@ { "name": "orgexplorer", - "version": "2.0.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "orgexplorer", - "version": "2.0.0", + "version": "1.0.0", "dependencies": { + "@radix-ui/react-slot": "^1.2.4", + "@tailwindcss/vite": "^4.3.0", + "@tanstack/react-query": "^5.100.14", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "d3": "^7.9.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-icons": "^5.3.0", "react-router-dom": "^6.26.2", - "recharts": "^2.12.7" + "recharts": "^2.12.7", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4.3.0" }, "devDependencies": { + "@types/node": "^25.9.1", + "@types/react": "^19.2.15", + "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^4.3.1", + "prettier": "^3.8.3", + "typescript": "^6.0.3", "vite": "^5.4.6" } }, @@ -318,7 +330,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -335,7 +346,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -352,7 +362,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -369,7 +378,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -386,7 +394,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -403,7 +410,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -420,7 +426,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -437,7 +442,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -454,7 +458,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -471,7 +474,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -488,7 +490,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -505,7 +506,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -522,7 +522,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -539,7 +538,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -556,7 +554,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -573,7 +570,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -590,7 +586,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -607,7 +602,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -624,7 +618,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -641,7 +634,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -658,7 +650,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -675,7 +666,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -692,7 +682,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -706,7 +695,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -717,7 +705,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -728,7 +715,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -738,20 +724,51 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@remix-run/router": { "version": "1.23.2", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", @@ -775,7 +792,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -789,7 +805,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -803,7 +818,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -817,7 +831,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -831,7 +844,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -845,7 +857,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -859,7 +870,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -873,7 +883,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -887,7 +896,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -901,7 +909,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -915,7 +922,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -929,7 +935,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -943,7 +948,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -957,7 +961,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -971,7 +974,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -985,7 +987,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -999,7 +1000,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1013,7 +1013,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1027,7 +1026,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1041,7 +1039,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1055,7 +1052,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1069,7 +1065,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1083,7 +1078,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1097,7 +1091,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1111,13 +1104,295 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ] }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", + "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "tailwindcss": "4.3.0" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.100.14", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.14.tgz", + "integrity": "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.100.14", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.14.tgz", + "integrity": "sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.100.14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1230,9 +1505,38 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/react": { + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1322,6 +1626,18 @@ ], "license": "CC-BY-4.0" }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -1787,6 +2103,15 @@ "robust-predicates": "^3.0.2" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -1804,11 +2129,23 @@ "dev": true, "license": "ISC" }, + "node_modules/enhanced-resolve": { + "version": "5.22.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz", + "integrity": "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -1872,7 +2209,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -1893,6 +2229,12 @@ "node": ">=6.9.0" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -1914,6 +2256,15 @@ "node": ">=12" } }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1946,10 +2297,259 @@ "node": ">=6" } }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/loose-envify": { @@ -1974,6 +2574,15 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1982,10 +2591,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "funding": [ { "type": "github", @@ -2020,14 +2628,12 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", - "dev": true, + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "funding": [ { "type": "opencollective", @@ -2044,7 +2650,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2052,6 +2658,22 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -2224,7 +2846,6 @@ "version": "4.60.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -2300,18 +2921,67 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -2369,7 +3039,6 @@ "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.21.3", diff --git a/package.json b/package.json index 73ab888..3e14d9f 100644 --- a/package.json +++ b/package.json @@ -1,23 +1,37 @@ { "name": "orgexplorer", - "version": "2.0.0", + "version": "1.0.0", "private": true, "type": "module", "scripts": { "dev": "vite", - "build": "vite build", - "preview": "vite preview" + "build": "tsc -b && vite build", + "preview": "vite preview", + "format": "prettier . --write", + "format:check": "prettier . --check" }, "dependencies": { + "@radix-ui/react-slot": "^1.2.4", + "@tailwindcss/vite": "^4.3.0", + "@tanstack/react-query": "^5.100.14", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "d3": "^7.9.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-icons": "^5.3.0", "react-router-dom": "^6.26.2", "recharts": "^2.12.7", - "d3": "^7.9.0", - "react-icons": "^5.3.0" + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4.3.0" }, "devDependencies": { + "@types/node": "^25.9.1", + "@types/react": "^19.2.15", + "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^4.3.1", + "prettier": "^3.8.3", + "typescript": "^6.0.3", "vite": "^5.4.6" } } diff --git a/src/App.jsx b/src/App.jsx index 28cce36..c992051 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,56 +1,32 @@ -import React from 'react' -import { Routes, Route, Navigate } from 'react-router-dom' -import { AppProvider } from './context/AppContext' -import Navbar from './components/Navbar' -import RateLimitBanner from './components/RateLimitBanner' -import HomePage from './pages/HomePage' -import OverviewPage from './pages/OverviewPage' -import RepositoriesPage from './pages/RepositoriesPage' -import ContributorsPage from './pages/ContributorsPage' -import NetworkPage from './pages/NetworkPage' -import AnalyticsPage from './pages/AnalyticsPage' -import GovernancePage from './pages/GovernancePage' -import SettingsPage from './pages/SettingsPage' - -function Layout({ children }) { - return ( -
- - -
{children}
- -
- ) -} +import React from "react"; +import { Routes, Route, Navigate } from "react-router-dom"; +import { AppProvider } from "./context/AppContext"; +import HomePage from "./pages/HomePage"; +import OverviewPage from "./pages/OverviewPage"; +import RepositoriesPage from "./pages/RepositoriesPage"; +import ContributorsPage from "./pages/ContributorsPage"; +import NetworkPage from "./pages/NetworkPage"; +import AnalyticsPage from "./pages/AnalyticsPage"; +import GovernancePage from "./pages/GovernancePage"; +import SettingsPage from "./pages/SettingsPage"; +import Layout from "./components/layout/Layout"; export default function App() { return ( - } /> - } /> + } /> + } /> } /> } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> - ) + ); } diff --git a/public/org-explorer-logo.svg b/src/assets/logos/org-explorer-logo.svg similarity index 100% rename from public/org-explorer-logo.svg rename to src/assets/logos/org-explorer-logo.svg diff --git a/src/components/Home/HeroSection.jsx b/src/components/Home/HeroSection.jsx new file mode 100644 index 0000000..7633623 --- /dev/null +++ b/src/components/Home/HeroSection.jsx @@ -0,0 +1,71 @@ +import OrgSearchBox from "./OrgSearchBox"; +import RecentSearches from "./RecentSearches"; +import QuickAccess from "./QuickAccess"; + +import { Spinner } from "@/components/UI"; + +export default function HeroSection(props) { + const { + loading, + loadMsg, + error, + recent, + go, + quickExploreItems, + handleSelectOrg, + } = props; + + return ( +
+
+ +
+

+ Architect Your + Insights +

+ +

+ Unified analytics across one or many GitHub organizations. +

+ + + +

+ Type an org name and press Enter or comma to add. +

+ + {error && ( +

+ {error} +

+ )} + + {loading && ( +
+ +

{loadMsg}

+
+ )} + + {recent.length > 0 && !loading && ( + + )} + + {!loading && ( + + )} +
+
+ ); +} diff --git a/src/components/Home/OrgExplorerFeatures.jsx b/src/components/Home/OrgExplorerFeatures.jsx new file mode 100644 index 0000000..ba32183 --- /dev/null +++ b/src/components/Home/OrgExplorerFeatures.jsx @@ -0,0 +1,228 @@ +export default function OrgExplorerFeatures() { + const features = [ + { + title: "Interactive Org Visualization", + desc: "Explore repositories, teams, and contributors through a dynamic relationship graph designed for instant understanding.", + bullets: [ + "Live node graph", + "Repository mapping", + "Contributor insights", + ], + visual: ( +
+
+ +
+ + {[...Array(8)].map((_, i) => ( +
+ ))} + + + + + + + + +
+ 142 repositories mapped +
+
+ ), + }, + { + title: "Zero Backend Delay", + desc: "Everything runs instantly with direct GitHub API interactions and optimized client-side rendering.", + bullets: [ + "Instant updates", + "No server bottleneck", + "Fast local rendering", + ], + visual: ( +
+
+ +
+
+ GitHub Data Sync + 0.2s +
+ +
+
+
+ +
+ {["Repos", "Teams", "Contributors"].map((item) => ( +
+ {item} +
+ ))} +
+
+
+ ), + }, + { + title: "Secure PAT Authentication", + desc: "Your Personal Access Tokens stay encrypted and stored locally for maximum privacy and security.", + bullets: [ + "Local-only storage", + "Encrypted handling", + "Privacy-first architecture", + ], + visual: ( +
+
+ +
+
+
+ +
+
๐Ÿ”’
+

+ Secure Local Authentication +

+
+
+
+
+ ), + }, + { + title: "Auto Save & Auto Refresh", + desc: "Your workspace updates automatically while preserving every interaction in real time.", + bullets: [ + "Background syncing", + "Real-time refresh", + "Persistent workspace state", + ], + visual: ( +
+
+ +
+
+ โ†ป +
+ +
+
+ โœ“ Workspace Auto-Saved +
+ +
+ โœ“ GitHub Data Refreshed +
+
+
+
+ ), + }, + { + title: "One-Click Exporting", + desc: "Export organization graphs and analytics instantly in multiple formats for sharing and documentation.", + bullets: ["PNG & SVG export", "JSON snapshots", "Instant downloads"], + visual: ( +
+
+ +
+
+
+ Export Workspace +
+ +
+ {["PNG", "SVG", "JSON"].map((item) => ( +
+ .{item.toLowerCase()} + +
+ ))} +
+
+
+
+ ), + }, + ]; + + return ( +
+
+
+

+ Core Features +

+ +

+ Built for exploring GitHub organizations visually. +

+ +

+ A premium developer experience focused on performance, privacy, + automation, and visual clarity. +

+
+ +
+ {features.map((feature, index) => ( +
+
+
+
+ 0{index + 1} +
+ +

+ {feature.title} +

+ +

+ {feature.desc} +

+ +
+ {feature.bullets.map((bullet) => ( +
+
+ {bullet} +
+ ))} +
+
+ +
{feature.visual}
+
+
+ ))} +
+
+
+ ); +} diff --git a/src/components/Home/OrgSearchBox.jsx b/src/components/Home/OrgSearchBox.jsx new file mode 100644 index 0000000..0d520ee --- /dev/null +++ b/src/components/Home/OrgSearchBox.jsx @@ -0,0 +1,92 @@ +import { FiSearch, FiX } from "react-icons/fi"; +import { BsArrowRight } from "react-icons/bs"; + +import { Button } from "@/components/ui/Button"; + +import SearchSuggestions from "./SearchSuggestions"; + +export default function OrgSearchBox({ + input, + setInput, + chips, + addChip, + removeChip, + handleKey, + handleSubmit, + showSuggestions, + setShowSuggestions, + setSelectedIndex, + filteredSuggestions, + selectedIndex, + isLoading, + handleSelectOrg, +}) { + return ( +
+
+
+ + {chips.map((c) => ( + + {c} + + removeChip(c)} + /> + + ))} + + setShowSuggestions(true)} + onBlur={() => { + setTimeout(() => { + setShowSuggestions(false); + }, 200); + + input.trim() && addChip(input); + }} + onChange={(e) => { + setInput(e.target.value); + setSelectedIndex(-1); + }} + onKeyDown={handleKey} + className="w-full bg-transparent text-sm text-white placeholder:text-zinc-500 focus:outline-none md:text-base" + /> +
+ + +
+ + +
+ ); +} diff --git a/src/components/Home/QuickAccess.jsx b/src/components/Home/QuickAccess.jsx new file mode 100644 index 0000000..f6d296e --- /dev/null +++ b/src/components/Home/QuickAccess.jsx @@ -0,0 +1,28 @@ +import { BsArrowRight } from "react-icons/bs"; + +export default function QuickAccess({ items, handleSelectOrg }) { + return ( +
+

+ Quick Explore Access +

+ +
+ {items.map((item) => ( + + ))} +
+
+ ); +} diff --git a/src/components/Home/RecentSearches.jsx b/src/components/Home/RecentSearches.jsx new file mode 100644 index 0000000..f673c2b --- /dev/null +++ b/src/components/Home/RecentSearches.jsx @@ -0,0 +1,38 @@ +import { BsArrowRight } from "react-icons/bs"; +import { cn } from "@/lib/utils"; + +export default function RecentSearches({ recent, go }) { + return ( +
+
+
+ + + Recent Searches + + +
+
+ +
+ {recent.map((r) => ( + + ))} +
+
+ ); +} diff --git a/src/components/Home/SearchSuggestions.jsx b/src/components/Home/SearchSuggestions.jsx new file mode 100644 index 0000000..8d0991e --- /dev/null +++ b/src/components/Home/SearchSuggestions.jsx @@ -0,0 +1,64 @@ +import { BsArrowRight } from "react-icons/bs"; +import { cn } from "@/lib/utils"; + +export default function SearchSuggestions({ + showSuggestions, + input, + isLoading, + filteredSuggestions, + selectedIndex, + handleSelectOrg, + addChip, +}) { + if (!showSuggestions || !input.trim()) return null; + + return ( +
+ {isLoading ? ( +
+ Searching organizations... +
+ ) : filteredSuggestions.length > 0 ? ( + filteredSuggestions.map((org, index) => ( + + )) + ) : ( +
+ No organization found +
+ )} +
+ ); +} diff --git a/src/components/Home/StatsSection.jsx b/src/components/Home/StatsSection.jsx new file mode 100644 index 0000000..e627d90 --- /dev/null +++ b/src/components/Home/StatsSection.jsx @@ -0,0 +1,53 @@ +import React from "react"; + +function StatsSection() { + return ( +
+
+
+ 5,000 + +
+

+ Resource Load +

+ +

+ Girth With PAT +

+
+
+ +
+ 1HR + +
+

+ Data Freshness +

+ +

+ Intelligent Cache +

+
+
+ +
+ ZERO + +
+

+ Engine Efficiency +

+ +

+ Backend Latency +

+
+
+
+
+ ); +} + +export default StatsSection; diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx deleted file mode 100644 index 75ac7b8..0000000 --- a/src/components/Navbar.jsx +++ /dev/null @@ -1,73 +0,0 @@ -import React from 'react' -import { NavLink, useNavigate } from 'react-router-dom' -import { FiSettings, FiZap } from 'react-icons/fi' -import { useApp } from '../context/AppContext' - -const LINKS = [ - { to: '/overview', label: 'Overview' }, - { to: '/repositories', label: 'Repositories' }, - { to: '/contributors', label: 'Contributors' }, - { to: '/network', label: 'Network' }, - { to: '/analytics', label: 'Analytics' }, - { to: '/governance', label: 'Governance' }, -] - -export default function Navbar() { - const { orgs, rateLimit } = useApp() - const navigate = useNavigate() - const hasData = orgs.length > 0 - const lowLimit = rateLimit && rateLimit.remaining < 15 - - return ( - - ) -} diff --git a/src/components/RateLimitBanner.jsx b/src/components/RateLimitBanner.jsx index 8f5edb5..01ce6f4 100644 --- a/src/components/RateLimitBanner.jsx +++ b/src/components/RateLimitBanner.jsx @@ -1,39 +1,54 @@ -import React from 'react' -import { useNavigate } from 'react-router-dom' -import { FiZap, FiAlertTriangle } from 'react-icons/fi' -import { useApp } from '../context/AppContext' +import React from "react"; +import { useNavigate } from "react-router-dom"; +import { FiZap, FiAlertTriangle } from "react-icons/fi"; +import { useApp } from "../context/AppContext"; export default function RateLimitBanner() { - const { rateLimit, pat } = useApp() - const navigate = useNavigate() - if (!rateLimit) return null + const { rateLimit, pat } = useApp(); + const navigate = useNavigate(); + if (!rateLimit) return null; - const pct = rateLimit.remaining / rateLimit.limit - if (pct > 0.2 && rateLimit.limit > 60) return null + const pct = rateLimit.remaining / rateLimit.limit; + if (pct > 0.2 && rateLimit.limit > 60) return null; - const crit = pct < 0.1 - const Icon = crit ? FiAlertTriangle : FiZap + const crit = pct < 0.1; + const Icon = crit ? FiAlertTriangle : FiZap; return ( -
- - - API RATE LIMIT: {rateLimit.remaining} / {rateLimit.limit} REQUESTS REMAINING +
+ + + API RATE LIMIT:{" "} + + {rateLimit.remaining} / {rateLimit.limit} + {" "} + REQUESTS REMAINING {!pat && ( navigate('/settings')} - style={{ color: 'var(--accent)', marginLeft: 10, cursor: 'pointer', fontWeight: 600 }} + onClick={() => navigate("/settings")} + style={{ + color: "var(--accent)", + marginLeft: 10, + cursor: "pointer", + fontWeight: 600, + }} > Add PAT for 5,000 req/hr )} - RESETS HOURLY + RESETS HOURLY
- ) + ); } diff --git a/src/components/UI.jsx b/src/components/UI.jsx index f53b292..a23fcd3 100644 --- a/src/components/UI.jsx +++ b/src/components/UI.jsx @@ -1,178 +1,247 @@ -import React from 'react' -import { - FiChevronUp, FiChevronDown -} from 'react-icons/fi' -import { LuChevronsUpDown as FiChevronsUpDown } from 'react-icons/lu' +import React from "react"; +import { FiChevronUp, FiChevronDown } from "react-icons/fi"; +import { LuChevronsUpDown as FiChevronsUpDown } from "react-icons/lu"; // Design tokens export const C = { card: { - background: 'var(--surface)', - border: '1px solid var(--border)', - borderRadius: 'var(--radius)', - padding: '20px', + background: "var(--surface)", + border: "1px solid var(--border)", + borderRadius: "var(--radius)", + padding: "20px", }, label: { fontSize: 11, - letterSpacing: '.08em', - color: 'var(--text2)', - textTransform: 'uppercase', + letterSpacing: ".08em", + color: "var(--text2)", + textTransform: "uppercase", fontWeight: 500, }, - pill: (color = 'var(--accent)', bg = 'rgba(245,197,24,.12)') => ({ - display: 'inline-flex', - alignItems: 'center', - padding: '2px 8px', + pill: (color = "var(--accent)", bg = "rgba(245,197,24,.12)") => ({ + display: "inline-flex", + alignItems: "center", + padding: "2px 8px", borderRadius: 4, fontSize: 11, fontWeight: 600, - letterSpacing: '.04em', + letterSpacing: ".04em", color, background: bg, }), - btn: (v = 'primary') => ({ - padding: '8px 18px', - borderRadius: 'var(--radius)', + btn: (v = "primary") => ({ + padding: "8px 18px", + borderRadius: "var(--radius)", fontWeight: 600, fontSize: 13, - cursor: 'pointer', - border: 'none', - transition: 'opacity .15s', - ...(v === 'primary' ? { background: 'var(--accent)', color: '#000' } - : v === 'ghost' ? { background: 'transparent', color: 'var(--text)', border: '1px solid var(--border)' } - : { background: 'var(--surface2)', color: 'var(--text)', border: '1px solid var(--border)' }), + cursor: "pointer", + border: "none", + transition: "opacity .15s", + ...(v === "primary" + ? { background: "var(--accent)", color: "#000" } + : v === "ghost" + ? { + background: "transparent", + color: "var(--text)", + border: "1px solid var(--border)", + } + : { + background: "var(--surface2)", + color: "var(--text)", + border: "1px solid var(--border)", + }), }), input: { - background: 'var(--surface2)', - border: '1px solid var(--border)', + background: "var(--surface2)", + border: "1px solid var(--border)", borderRadius: 6, - padding: '8px 12px', - color: 'var(--text)', + padding: "8px 12px", + color: "var(--text)", fontSize: 13, - outline: 'none', + outline: "none", }, select: { - background: 'var(--surface2)', - border: '1px solid var(--border)', + background: "var(--surface2)", + border: "1px solid var(--border)", borderRadius: 6, - padding: '8px 12px', - color: 'var(--text)', + padding: "8px 12px", + color: "var(--text)", fontSize: 13, - outline: 'none', - cursor: 'pointer', + outline: "none", + cursor: "pointer", }, -} +}; // Lifecycle badge color map const LC = { - Thriving: ['#22c55e', 'rgba(34,197,94,.15)'], - Stable: ['#3b82f6', 'rgba(59,130,246,.15)'], - Dormant: ['#f59e0b', 'rgba(245,158,11,.15)'], - Abandoned: ['#ef4444', 'rgba(239,68,68,.15)'], - critical: ['#ef4444', 'rgba(239,68,68,.15)'], - high: ['#f59e0b', 'rgba(245,158,11,.15)'], - healthy: ['#22c55e', 'rgba(34,197,94,.15)'], - unknown: ['#666', 'rgba(102,102,102,.15)'], -} + Thriving: ["#22c55e", "rgba(34,197,94,.15)"], + Stable: ["#3b82f6", "rgba(59,130,246,.15)"], + Dormant: ["#f59e0b", "rgba(245,158,11,.15)"], + Abandoned: ["#ef4444", "rgba(239,68,68,.15)"], + critical: ["#ef4444", "rgba(239,68,68,.15)"], + high: ["#f59e0b", "rgba(245,158,11,.15)"], + healthy: ["#22c55e", "rgba(34,197,94,.15)"], + unknown: ["#666", "rgba(102,102,102,.15)"], +}; export function Badge({ text, variant }) { - const key = variant || text - const [color, bg] = LC[key] || LC.unknown - return {String(text).toUpperCase()} + const key = variant || text; + const [color, bg] = LC[key] || LC.unknown; + return {String(text).toUpperCase()}; } export function HealthBar({ score }) { - const color = score >= 70 ? 'var(--green)' : score >= 40 ? 'var(--amber)' : 'var(--red)' + const color = + score >= 70 ? "var(--green)" : score >= 40 ? "var(--amber)" : "var(--red)"; return ( -
-
-
+
+
+
- {score} + + {score} +
- ) + ); } export function StatCard({ label, value, sub, accent }) { return ( -
+
{label}
-
{value}
- {sub &&
{sub}
} +
+ {value} +
+ {sub &&
{sub}
}
- ) + ); } export function Spinner({ size = 28 }) { return ( -
- ) +
+ ); } export function SortTh({ label, sortKey, sortConfig, onSort }) { - const active = sortConfig.key === sortKey - const Icon = !active ? FiChevronsUpDown : sortConfig.dir === 'desc' ? FiChevronDown : FiChevronUp + const active = sortConfig.key === sortKey; + const Icon = !active + ? FiChevronsUpDown + : sortConfig.dir === "desc" + ? FiChevronDown + : FiChevronUp; return ( onSort(sortKey)} style={{ - padding: '10px 14px', textAlign: 'left', cursor: 'pointer', - userSelect: 'none', whiteSpace: 'nowrap', fontSize: 11, - fontWeight: 600, letterSpacing: '.06em', - background: 'var(--surface2)', borderBottom: '1px solid var(--border)', - color: active ? 'var(--accent)' : 'var(--text2)', + padding: "10px 14px", + textAlign: "left", + cursor: "pointer", + userSelect: "none", + whiteSpace: "nowrap", + fontSize: 11, + fontWeight: 600, + letterSpacing: ".06em", + background: "var(--surface2)", + borderBottom: "1px solid var(--border)", + color: active ? "var(--accent)" : "var(--text2)", }} > - + {label} - ) + ); } export function PageTitle({ title, subtitle, right }) { return ( -
+

{title}

- {subtitle &&

{subtitle}

} + {subtitle && ( +

+ {subtitle} +

+ )}
{right}
- ) + ); } export function LoadMore({ shown, total, onLoad }) { - if (shown >= total) return null + if (shown >= total) return null; return ( -
- -

+

+ +

Showing {shown} of {total}

- ) + ); } export function EmptyOk({ msg, sub }) { return ( -
-
{msg}
- {sub &&
{sub}
} +
+
+ {msg} +
+ {sub &&
{sub}
}
- ) + ); } export function InfoBox({ children }) { return ( -
+
{children}
- ) + ); } diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx new file mode 100644 index 0000000..95e120b --- /dev/null +++ b/src/components/layout/Footer.tsx @@ -0,0 +1,160 @@ +import { Link } from "react-router-dom"; +import { + FaGithub, + FaLinkedin, + FaDiscord, + FaYoutube, + FaXTwitter, +} from "react-icons/fa6"; +import { HiOutlineMail } from "react-icons/hi"; + +const footerLinks = [ + { + label: "Documentation", + href: "/docs", + }, + { + label: "Terms of Service", + href: "/terms", + }, + { + label: "Privacy Policy", + href: "/privacy", + }, + { + label: "API Status", + href: "/status", + }, +]; + +const socialLinks = [ + { + label: "Email", + href: "mailto:aossie.oss@gmail.com", + icon: HiOutlineMail, + }, + { + label: "GitHub", + href: "https://github.com/AOSSIE-Org", + icon: FaGithub, + }, + { + label: "Discord", + href: "https://discord.com/invite/hjUhu33uAn", + icon: FaDiscord, + }, + { + label: "LinkedIn", + href: "https://www.linkedin.com/company/aossie/", + icon: FaLinkedin, + }, + { + label: "X", + href: "https://x.com/aossie_org", + icon: FaXTwitter, + }, + { + label: "YouTube", + href: "https://www.youtube.com/@AOSSIE-Org", + icon: FaYoutube, + }, +]; + +export default function Footer() { + return ( +
+
+ {/* LEFT SECTION */} +
+ {/* NAVIGATION */} + + + {/* SOCIAL LINKS */} +
+ {socialLinks.map((item) => { + const Icon = item.icon; + + return ( + + + + ); + })} +
+
+ + {/* RIGHT SECTION */} +
+

+ ยฉ 2026 OrgExplorer +

+ +

+ Built for open source communities +

+
+
+
+ ); +} diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx new file mode 100644 index 0000000..fc2e87a --- /dev/null +++ b/src/components/layout/Layout.tsx @@ -0,0 +1,22 @@ +import type { ReactNode } from "react"; +import Navbar from "./Navbar"; +import RateLimitBanner from "../RateLimitBanner"; +import Footer from "./Footer"; + +interface LayoutProps { + children: ReactNode; +} + +export default function Layout({ children }: LayoutProps) { + return ( +
+ + + + +
{children}
+ +
+
+ ); +} diff --git a/src/components/layout/Navbar.tsx b/src/components/layout/Navbar.tsx new file mode 100644 index 0000000..b82a0cb --- /dev/null +++ b/src/components/layout/Navbar.tsx @@ -0,0 +1,138 @@ +import { FiMenu, FiSettings } from "react-icons/fi"; +import { Button } from "@/components/ui/Button"; +import { cn } from "@/lib/utils"; +import { useState } from "react"; +import { Link } from "react-router-dom"; +import OrgExplorerLogo from "@/assets/logos/org-explorer-logo.svg"; + +const navItems = [ + { label: "Overview", href: "/overview" }, + { label: "Repositories", href: "/repositories" }, + { label: "Contributors", href: "/contributors" }, + { label: "Network", href: "/network" }, + { label: "Analytics", href: "/analytics" }, + { label: "Governance", href: "/governance" }, +]; + +export default function Navbar() { + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + + return ( +
+
+ {/* LEFT */} +
+ {/* LOGO */} + + OrgExplorer + +
+ + {/* MIDDLE */} +
+ {/* DESKTOP NAV */} + +
+ + {/* RIGHT */} +
+ {/* SETTINGS */} + + + {/* MOBILE MENU */} + +
+
+ + {/* MOBILE NAVIGATION */} + {mobileMenuOpen && ( + + )} +
+ ); +} diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx new file mode 100644 index 0000000..5a8d8f7 --- /dev/null +++ b/src/components/ui/Button.tsx @@ -0,0 +1,110 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; +import { FiLoader } from "react-icons/fi"; + +const buttonVariants = cva( + [ + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium", + "transition-all duration-200", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", + "disabled:pointer-events-none disabled:opacity-50", + "[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + ], + { + variants: { + variant: { + default: + "bg-[#FCD34D] text-black border-2 border-black shadow-[4px_4px_0px_0px_#FCD34D] hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-[2px_2px_0px_0px_#FCD34D] active:translate-x-[4px] active:translate-y-[4px] active:shadow-none transition-all duration-150", + + destructive: + "bg-red-500 text-white border-2 border-black shadow-[4px_4px_0px_0px_#ef4444] hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-[2px_2px_0px_0px_#ef4444] active:translate-x-[4px] active:translate-y-[4px] active:shadow-none transition-all duration-150", + + outline: + "bg-white text-black border-2 border-black shadow-[4px_4px_0px_0px_#ffffff] hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-[2px_2px_0px_0px_#ffffff] active:translate-x-[4px] active:translate-y-[4px] active:shadow-none transition-all duration-150", + + secondary: + "bg-gray-300 text-black border-2 border-black shadow-[4px_4px_0px_0px_#d1d5db] hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-[2px_2px_0px_0px_#d1d5db] active:translate-x-[4px] active:translate-y-[4px] active:shadow-none transition-all duration-150", + + ghost: + "bg-transparent text-white border-2 border-white shadow-[4px_4px_0px_0px_#ffffff] hover:bg-white hover:text-black hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-[2px_2px_0px_0px_#ffffff] active:translate-x-[4px] active:translate-y-[4px] active:shadow-none transition-all duration-150", + + link: "text-blue-500 underline underline-offset-4 font-bold hover:text-blue-400 transition-colors duration-150", + }, + + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + + fullWidth: { + true: "w-full", + }, + + loading: { + true: "cursor-not-allowed", + }, + }, + + defaultVariants: { + variant: "default", + size: "default", + }, + } +); + +export interface ButtonProps + extends + React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; + loading?: boolean; +} + +const Button = React.forwardRef( + ( + { + className, + variant, + size, + fullWidth, + loading, + asChild = false, + disabled, + children, + ...props + }, + ref + ) => { + const Comp = asChild ? Slot : "button"; + + return ( + + {loading && } + + {children} + + ); + } +); + +Button.displayName = "Button"; + +export { Button }; diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index cef8a89..4865981 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -1,99 +1,149 @@ -import { createContext, useContext, useState, useCallback } from 'react' -import { fetchOrg, fetchRepos, fetchContributors, fetchIssues, fetchRateLimit } from '../services/github' -import { buildAnalyticalModel } from '../services/analytics' +import { createContext, useContext, useState, useCallback } from "react"; +import { + fetchOrg, + fetchRepos, + fetchContributors, + fetchIssues, + fetchRateLimit, +} from "../services/github"; +import { buildAnalyticalModel } from "../services/analytics"; -const Ctx = createContext(null) +const Ctx = createContext(null); export function AppProvider({ children }) { - const [pat, setPat] = useState(() => localStorage.getItem('oe_pat') || '') - const [orgs, setOrgs] = useState([]) - const [model, setModel] = useState(null) - const [issuesData, setIssuesData] = useState({}) - const [rateLimit, setRateLimit] = useState(null) - const [loading, setLoading] = useState(false) - const [loadMsg, setLoadMsg] = useState('') - const [govLoading, setGovLoading] = useState(false) - const [error, setError] = useState('') + const [pat, setPat] = useState(() => localStorage.getItem("oe_pat") || ""); + const [orgs, setOrgs] = useState([]); + const [model, setModel] = useState(null); + const [issuesData, setIssuesData] = useState({}); + const [rateLimit, setRateLimit] = useState(null); + const [loading, setLoading] = useState(false); + const [loadMsg, setLoadMsg] = useState(""); + const [govLoading, setGovLoading] = useState(false); + const [error, setError] = useState(""); - const savePat = useCallback(token => { - setPat(token) - token ? localStorage.setItem('oe_pat', token) : localStorage.removeItem('oe_pat') - }, []) + const savePat = useCallback((token) => { + setPat(token); + token + ? localStorage.setItem("oe_pat", token) + : localStorage.removeItem("oe_pat"); + }, []); // Multi-org explore โ€” core of Section 3.2.0 - const explore = useCallback(async orgNames => { - setLoading(true); setError(''); setModel(null); setOrgs([]); setIssuesData({}) - try { - setLoadMsg('Fetching organization metadata...') - const orgRes = await Promise.allSettled(orgNames.map(n => fetchOrg(n, pat))) - const validOrgs = orgRes.filter(r => r.status === 'fulfilled').map(r => r.value) - if (!validOrgs.length) throw new Error('No valid organizations found. Check the names and try again.') - setOrgs(validOrgs) + const explore = useCallback( + async (orgNames) => { + setLoading(true); + setError(""); + setModel(null); + setOrgs([]); + setIssuesData({}); + try { + setLoadMsg("Fetching organization metadata..."); + const orgRes = await Promise.allSettled( + orgNames.map((n) => fetchOrg(n, pat)) + ); + const validOrgs = orgRes + .filter((r) => r.status === "fulfilled") + .map((r) => r.value); + if (!validOrgs.length) + throw new Error( + "No valid organizations found. Check the names and try again." + ); + setOrgs(validOrgs); - setLoadMsg('Fetching repositories...') - const reposPerOrg = {} - await Promise.allSettled(validOrgs.map(async org => { - reposPerOrg[org.login] = await fetchRepos(org.login, pat) - })) + setLoadMsg("Fetching repositories..."); + const reposPerOrg = {}; + await Promise.allSettled( + validOrgs.map(async (org) => { + reposPerOrg[org.login] = await fetchRepos(org.login, pat); + }) + ); - setLoadMsg('Fetching contributor data for top repositories...') - const contribsPerRepo = {} - for (const org of validOrgs) { - const top = (reposPerOrg[org.login] || []) - .sort((a, b) => b.stargazers_count - a.stargazers_count) - .slice(0, 10) - await Promise.allSettled(top.map(async repo => { - contribsPerRepo[`${org.login}/${repo.name}`] = await fetchContributors(org.login, repo.name, pat) - })) - } - - setLoadMsg('Building analytical data model...') - setModel(buildAnalyticalModel(validOrgs, reposPerOrg, contribsPerRepo)) + setLoadMsg("Fetching contributor data for top repositories..."); + const contribsPerRepo = {}; + for (const org of validOrgs) { + const top = (reposPerOrg[org.login] || []) + .sort((a, b) => b.stargazers_count - a.stargazers_count) + .slice(0, 10); + await Promise.allSettled( + top.map(async (repo) => { + contribsPerRepo[`${org.login}/${repo.name}`] = + await fetchContributors(org.login, repo.name, pat); + }) + ); + } - const rl = await fetchRateLimit(pat) - if (rl) setRateLimit(rl) + setLoadMsg("Building analytical data model..."); + setModel(buildAnalyticalModel(validOrgs, reposPerOrg, contribsPerRepo)); - // Save to recent searches - const prev = JSON.parse(localStorage.getItem('oe_recent') || '[]') - const entry = orgNames.join(', ') - localStorage.setItem('oe_recent', JSON.stringify([...new Set([entry, ...prev])].slice(0, 6))) + const rl = await fetchRateLimit(pat); + if (rl) setRateLimit(rl); - } catch (err) { - setError(err.message === 'RATE_LIMIT' - ? 'GitHub API rate limit reached. Add a PAT in Settings for 5,000 req/hr.' - : err.message) - } finally { - setLoading(false); setLoadMsg('') - } - }, [pat]) + // Save to recent searches + const prev = JSON.parse(localStorage.getItem("oe_recent") || "[]"); + const entry = orgNames.join(", "); + localStorage.setItem( + "oe_recent", + JSON.stringify([...new Set([entry, ...prev])].slice(0, 6)) + ); + } catch (err) { + setError( + err.message === "RATE_LIMIT" + ? "GitHub API rate limit reached. Add a PAT in Settings for 5,000 req/hr." + : err.message + ); + } finally { + setLoading(false); + setLoadMsg(""); + } + }, + [pat] + ); // Governance audit โ€” parallel batches of 5 (Section 3.2.5) const runAudit = useCallback(async () => { - if (!model || govLoading) return - setGovLoading(true) - const map = {} - const repos = model.allRepos.slice(0, 15) + if (!model || govLoading) return; + setGovLoading(true); + const map = {}; + const repos = model.allRepos.slice(0, 15); // Batches of 5 using Promise.allSettled for (let i = 0; i < repos.length; i += 5) { - const batch = repos.slice(i, i + 5) - await Promise.allSettled(batch.map(async repo => { - map[`${repo.orgLogin}/${repo.name}`] = await fetchIssues(repo.orgLogin, repo.name, pat) - })) + const batch = repos.slice(i, i + 5); + await Promise.allSettled( + batch.map(async (repo) => { + map[`${repo.orgLogin}/${repo.name}`] = await fetchIssues( + repo.orgLogin, + repo.name, + pat + ); + }) + ); } - setIssuesData(map) - setGovLoading(false) - }, [model, pat, govLoading]) + setIssuesData(map); + setGovLoading(false); + }, [model, pat, govLoading]); return ( - + {children} - ) + ); } -export const useApp = () => useContext(Ctx) +export const useApp = () => useContext(Ctx); diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts new file mode 100644 index 0000000..5b05594 --- /dev/null +++ b/src/hooks/useDebounce.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from "react"; + +export function useDebounce(value: T, delay = 1000) { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/src/hooks/useSortedData.js b/src/hooks/useSortedData.js index 4819f3e..bb2bf78 100644 --- a/src/hooks/useSortedData.js +++ b/src/hooks/useSortedData.js @@ -1,22 +1,31 @@ -import { useState, useMemo } from 'react' +import { useState, useMemo } from "react"; -export function useSortedData(data = [], defaultKey = 'healthScore', defaultDir = 'desc') { - const [cfg, setCfg] = useState({ key: defaultKey, dir: defaultDir }) +export function useSortedData( + data = [], + defaultKey = "healthScore", + defaultDir = "desc" +) { + const [cfg, setCfg] = useState({ key: defaultKey, dir: defaultDir }); - const onSort = key => - setCfg(prev => ({ key, dir: prev.key === key && prev.dir === 'desc' ? 'asc' : 'desc' })) + const onSort = (key) => + setCfg((prev) => ({ + key, + dir: prev.key === key && prev.dir === "desc" ? "asc" : "desc", + })); const sorted = useMemo(() => { - if (!data.length) return [] + if (!data.length) return []; return [...data].sort((a, b) => { - let va = a[cfg.key] ?? '', vb = b[cfg.key] ?? '' + let va = a[cfg.key] ?? "", + vb = b[cfg.key] ?? ""; // Handle arrays (e.g. repos, orgs) - if (Array.isArray(va)) va = va.length - if (Array.isArray(vb)) vb = vb.length - const cmp = typeof va === 'string' ? va.localeCompare(vb) : Number(va) - Number(vb) - return cfg.dir === 'asc' ? cmp : -cmp - }) - }, [data, cfg.key, cfg.dir]) + if (Array.isArray(va)) va = va.length; + if (Array.isArray(vb)) vb = vb.length; + const cmp = + typeof va === "string" ? va.localeCompare(vb) : Number(va) - Number(vb); + return cfg.dir === "asc" ? cmp : -cmp; + }); + }, [data, cfg.key, cfg.dir]); - return { sorted, sortConfig: cfg, onSort } + return { sorted, sortConfig: cfg, onSort }; } diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..a5ef193 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/src/main.jsx b/src/main.jsx index 76aca09..a36e5f1 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,13 +1,17 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import { BrowserRouter } from 'react-router-dom' -import App from './App' -import './styles/global.css' +import React from "react"; +import ReactDOM from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import App from "./App"; +import "./styles/global.css"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -ReactDOM.createRoot(document.getElementById('root')).render( +const queryClient = new QueryClient(); +ReactDOM.createRoot(document.getElementById("root")).render( - + + + -) +); diff --git a/src/pages/AnalyticsPage.jsx b/src/pages/AnalyticsPage.jsx index 981a260..8dd37ec 100644 --- a/src/pages/AnalyticsPage.jsx +++ b/src/pages/AnalyticsPage.jsx @@ -1,55 +1,66 @@ -import React, { useState, useMemo } from 'react' +import React, { useState, useMemo } from "react"; import { - AreaChart, Area, XAxis, YAxis, CartesianGrid, - Tooltip, Legend, ResponsiveContainer, -} from 'recharts' -import { FiDownload, FiRefreshCw } from 'react-icons/fi' -import { useApp } from '../context/AppContext' -import { C, PageTitle, InfoBox } from '../components/UI' -import { buildTimeSeries, exportTrendsCSV } from '../services/analytics' + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, +} from "recharts"; +import { FiDownload, FiRefreshCw } from "react-icons/fi"; +import { useApp } from "../context/AppContext"; +import { C, PageTitle, InfoBox } from "../components/UI"; +import { buildTimeSeries, exportTrendsCSV } from "../services/analytics"; const TOOLTIP_STYLE = { contentStyle: { - background: 'var(--surface)', - border: '1px solid var(--border)', + background: "var(--surface)", + border: "1px solid var(--border)", borderRadius: 6, fontSize: 12, }, - labelStyle: { color: 'var(--text)' }, - itemStyle: { color: 'var(--text2)' }, -} + labelStyle: { color: "var(--text)" }, + itemStyle: { color: "var(--text2)" }, +}; export default function AnalyticsPage() { - const { model, issuesData, runAudit, govLoading } = useApp() - const [granularity, setGranularity] = useState('monthly') - const [selectedRepo, setSelectedRepo] = useState('All') + const { model, issuesData, runAudit, govLoading } = useApp(); + const [granularity, setGranularity] = useState("monthly"); + const [selectedRepo, setSelectedRepo] = useState("All"); - if (!model) return null + if (!model) return null; - const repoNames = ['All', ...model.allRepos.slice(0, 12).map(r => r.name)] - const hasData = Object.keys(issuesData || {}).length > 0 + const repoNames = ["All", ...model.allRepos.slice(0, 12).map((r) => r.name)]; + const hasData = Object.keys(issuesData || {}).length > 0; const allIssues = useMemo(() => { - const arr = [] - Object.values(issuesData || {}).forEach(issues => arr.push(...issues)) - return arr - }, [issuesData]) + const arr = []; + Object.values(issuesData || {}).forEach((issues) => arr.push(...issues)); + return arr; + }, [issuesData]); const filteredIssues = useMemo(() => { - if (selectedRepo === 'All') return allIssues - const key = Object.keys(issuesData || {}).find(k => k.split('/')[1] === selectedRepo) - return key ? (issuesData[key] || []) : [] - }, [allIssues, selectedRepo, issuesData]) + if (selectedRepo === "All") return allIssues; + const key = Object.keys(issuesData || {}).find( + (k) => k.split("/")[1] === selectedRepo + ); + return key ? issuesData[key] || [] : []; + }, [allIssues, selectedRepo, issuesData]); - const series = useMemo(() => - buildTimeSeries(filteredIssues, granularity), + const series = useMemo( + () => buildTimeSeries(filteredIssues, granularity), [filteredIssues, granularity] - ) + ); - const hasSeries = series.length > 0 + const hasSeries = series.length > 0; return ( -
+
exportTrendsCSV(series)} - style={{ ...C.btn('ghost'), fontSize: 12, display: 'flex', alignItems: 'center', gap: 6 }} + style={{ + ...C.btn("ghost"), + fontSize: 12, + display: "flex", + alignItems: "center", + gap: 6, + }} > Export CSV @@ -66,21 +83,35 @@ export default function AnalyticsPage() { /> {/* Controls */} -
+
-
- {['monthly', 'weekly'].map(g => ( +
+ {["monthly", "weekly"].map((g) => ( @@ -91,10 +122,19 @@ export default function AnalyticsPage() { )}
@@ -102,23 +142,33 @@ export default function AnalyticsPage() { {/* Empty state before audit runs */} {!hasData && !govLoading && ( -
+
No trend data loaded yet

- Click "Load Issue and PR History" above to fetch issue and PR timestamps for the top repositories. + Click "Load Issue and PR History" above to fetch issue and PR + timestamps for the top repositories.

- This reuses the governance audit fetch โ€” issues are bucketed by created, closed, and merged timestamps - into weekly or monthly bins with no additional API calls beyond the audit itself. + This reuses the governance audit fetch โ€” issues are bucketed by + created, closed, and merged timestamps into weekly or monthly bins + with no additional API calls beyond the audit itself.

)} {govLoading && ( -
- Fetching issue and PR history for top 15 repositories in batches of 5... +
+ Fetching issue and PR history for top 15 repositories in batches of + 5...
)} @@ -127,35 +177,90 @@ export default function AnalyticsPage() { {hasSeries && ( <>
-
Pull Request Activity
-
Created vs Merged vs Closed
+
+ Pull Request Activity +
+
+ Created vs Merged vs Closed +
- + - - + + - - - + + +
{/* Issue chart */}
-
Issue Activity
-
Created vs Closed
+
+ Issue Activity +
+
+ Created vs Closed +
- + - - + + - - + +
@@ -164,12 +269,14 @@ export default function AnalyticsPage() { {hasData && !hasSeries && ( -
+
No time-series data found for this selection.
-
Try selecting "All" repositories.
+
+ Try selecting "All" repositories. +
)}
- ) + ); } diff --git a/src/pages/ContributorsPage.jsx b/src/pages/ContributorsPage.jsx index 82c347a..adb08c3 100644 --- a/src/pages/ContributorsPage.jsx +++ b/src/pages/ContributorsPage.jsx @@ -1,109 +1,245 @@ -import React, { useState, useMemo } from 'react' -import { FiDownload } from 'react-icons/fi' -import { useApp } from '../context/AppContext' -import { C, SortTh, PageTitle, LoadMore } from '../components/UI' -import { useSortedData } from '../hooks/useSortedData' -import { computeBusFactor, exportContributorsCSV } from '../services/analytics' +import React, { useState, useMemo } from "react"; +import { FiDownload } from "react-icons/fi"; +import { useApp } from "../context/AppContext"; +import { C, SortTh, PageTitle, LoadMore } from "../components/UI"; +import { useSortedData } from "../hooks/useSortedData"; +import { computeBusFactor, exportContributorsCSV } from "../services/analytics"; export default function ContributorsPage() { - const { model } = useApp() - const [search, setSearch] = useState('') - const [shown, setShown] = useState(20) + const { model } = useApp(); + const [search, setSearch] = useState(""); + const [shown, setShown] = useState(20); - if (!model) return null - const { contributors } = model + if (!model) return null; + const { contributors } = model; - const busFactor = useMemo(() => computeBusFactor(contributors), [contributors]) - const topActive = contributors.slice(0, 10).filter(c => c.freshness > 50).length - const freshPct = contributors.length ? Math.round(topActive / Math.min(10, contributors.length) * 100) : 0 - const connectors = contributors.filter(c => c.isConnector) - const crossOrg = contributors.filter(c => c.isCrossOrg) + const busFactor = useMemo( + () => computeBusFactor(contributors), + [contributors] + ); + const topActive = contributors + .slice(0, 10) + .filter((c) => c.freshness > 50).length; + const freshPct = contributors.length + ? Math.round((topActive / Math.min(10, contributors.length)) * 100) + : 0; + const connectors = contributors.filter((c) => c.isConnector); + const crossOrg = contributors.filter((c) => c.isCrossOrg); - const filtered = useMemo(() => - contributors.filter(c => !search || c.login.toLowerCase().includes(search.toLowerCase())), - [contributors, search]) + const filtered = useMemo( + () => + contributors.filter( + (c) => !search || c.login.toLowerCase().includes(search.toLowerCase()) + ), + [contributors, search] + ); - const { sorted, sortConfig, onSort } = useSortedData(filtered, 'totalContribs', 'desc') - const visible = sorted.slice(0, shown) + const { sorted, sortConfig, onSort } = useSortedData( + filtered, + "totalContribs", + "desc" + ); + const visible = sorted.slice(0, shown); - const riskColor = r => r === 'critical' ? 'var(--red)' : r === 'high' ? 'var(--amber)' : 'var(--green)' - const riskBar = r => r === 'critical' ? '90%' : r === 'high' ? '60%' : '25%' + const riskColor = (r) => + r === "critical" + ? "var(--red)" + : r === "high" + ? "var(--amber)" + : "var(--green)"; + const riskBar = (r) => + r === "critical" ? "90%" : r === "high" ? "60%" : "25%"; return ( -
+
exportContributorsCSV(contributors)} style={{ ...C.btn('ghost'), fontSize: 12, display: 'flex', alignItems: 'center', gap: 5 }}> + } /> {/* Signal panels */} -
- +
{/* Bus Factor */} -
+
Bus Factor Risk
-
-
Bus Factor: {busFactor.factor}
- +
+
+ Bus Factor: {busFactor.factor} +
+ {busFactor.risk.toUpperCase()}
-

+

{busFactor.factor <= 2 - ? `${busFactor.factor} contributor${busFactor.factor === 1 ? '' : 's'} own${busFactor.factor === 1 ? 's' : ''} over 50% of total commits. Knowledge distribution is heavily skewed.` - : 'Healthy contributor distribution across the organization.'} + ? `${busFactor.factor} contributor${busFactor.factor === 1 ? "" : "s"} own${busFactor.factor === 1 ? "s" : ""} over 50% of total commits. Knowledge distribution is heavily skewed.` + : "Healthy contributor distribution across the organization."}

-
+
RISK LEVEL - {busFactor.risk.toUpperCase()} + + {busFactor.risk.toUpperCase()} +
-
-
+
+
{/* Freshness Index */}
Freshness Index
-
-
-
+
+
+
{Math.round(freshPct / 10)}/10
-
Core Momentum
-
{topActive} of top 10 contributors active in last 90 days
-
- ACTIVE RECENTLY: {freshPct}% +
+ Core Momentum +
+
+ {topActive} of top 10 contributors active in last 90 days +
+
+ ACTIVE RECENTLY:{" "} + {freshPct}%
-
+
{connectors.length > 0 && ( -
- {connectors.length} cross-repo connectors (3+ repos) +
+ + {connectors.length} + {" "} + cross-repo connectors (3+ repos)
)} {crossOrg.length > 0 && ( -
- {crossOrg.length} cross-org contributors +
+ + {crossOrg.length} + {" "} + cross-org contributors
)}
@@ -111,63 +247,186 @@ export default function ContributorsPage() {
{/* Analytical table */} -
-
+
+
setSearch(e.target.value)} + value={search} + onChange={(e) => setSearch(e.target.value)} placeholder="Search by username..." style={{ ...C.input, width: 220 }} /> - + {filtered.length} contributors โ€” no rank column by design
- +
- - - - - - {visible.map((c, i) => ( - - + - - - - - + + + ))}
+ + + + + + SIGNALS
-
- {c.login} - {c.login} +
+
+ {c.login} + + {c.login} +
-
-
-
+
+
+
+
- {c.totalContribs.toLocaleString()} + + {c.totalContribs.toLocaleString()} +
{c.repos.length}{c.orgs.length}{c.lastActive?.slice(0, 10) || 'โ€”'} -
- {c.isConnector && CONNECTOR} - {c.isCrossOrg && CROSS-ORG} - {c.freshness > 70 && ACTIVE} +
+ {c.repos.length} + + {c.orgs.length} + + {c.lastActive?.slice(0, 10) || "โ€”"} + +
+ {c.isConnector && ( + + CONNECTOR + + )} + {c.isCrossOrg && ( + + CROSS-ORG + + )} + {c.freshness > 70 && ( + + ACTIVE + + )}
- setShown(s => s + 20)} /> + setShown((s) => s + 20)} + />
- ) + ); } diff --git a/src/pages/GovernancePage.jsx b/src/pages/GovernancePage.jsx index 5494ec4..6fc37c1 100644 --- a/src/pages/GovernancePage.jsx +++ b/src/pages/GovernancePage.jsx @@ -1,166 +1,296 @@ -import React, { useState, useMemo } from 'react' -import { FiRefreshCw, FiExternalLink } from 'react-icons/fi' -import { useApp } from '../context/AppContext' -import { C, PageTitle, EmptyOk } from '../components/UI' +import React, { useState, useMemo } from "react"; +import { FiRefreshCw, FiExternalLink } from "react-icons/fi"; +import { useApp } from "../context/AppContext"; +import { C, PageTitle, EmptyOk } from "../components/UI"; const TABS = [ - { key: 'dead', label: 'Dead Issues' }, - { key: 'zombie', label: 'Zombie PRs' }, - { key: 'risky', label: 'Risky Repos' }, - { key: 'license', label: 'No License' }, -] + { key: "dead", label: "Dead Issues" }, + { key: "zombie", label: "Zombie PRs" }, + { key: "risky", label: "Risky Repos" }, + { key: "license", label: "No License" }, +]; export default function GovernancePage() { - const { model, issuesData, runAudit, govLoading } = useApp() - const [tab, setTab] = useState('dead') + const { model, issuesData, runAudit, govLoading } = useApp(); + const [tab, setTab] = useState("dead"); - if (!model) return null + if (!model) return null; - const hasAudit = Object.keys(issuesData || {}).length > 0 - const daysSince = d => Math.floor((Date.now() - new Date(d)) / 86_400_000) + const hasAudit = Object.keys(issuesData || {}).length > 0; + const daysSince = (d) => Math.floor((Date.now() - new Date(d)) / 86_400_000); // Flatten all issues and tag with repo/org const allIssues = useMemo(() => { - const arr = [] + const arr = []; Object.entries(issuesData || {}).forEach(([key, issues]) => { - const [org, repo] = key.split('/') - issues.forEach(i => arr.push({ ...i, repoName: repo, orgName: org })) - }) - return arr - }, [issuesData]) + const [org, repo] = key.split("/"); + issues.forEach((i) => arr.push({ ...i, repoName: repo, orgName: org })); + }); + return arr; + }, [issuesData]); // Health check 1 โ€” Dead Issues (>90 days open, not a PR) const deadIssues = allIssues - .filter(i => !i.pull_request && daysSince(i.created_at) >= 90) - .sort((a, b) => daysSince(b.created_at) - daysSince(a.created_at)) + .filter((i) => !i.pull_request && daysSince(i.created_at) >= 90) + .sort((a, b) => daysSince(b.created_at) - daysSince(a.created_at)); // Health check 2 โ€” Zombie PRs (>90 days open) const zombiePRs = allIssues - .filter(i => i.pull_request && daysSince(i.created_at) >= 90) - .sort((a, b) => daysSince(b.created_at) - daysSince(a.created_at)) + .filter((i) => i.pull_request && daysSince(i.created_at) >= 90) + .sort((a, b) => daysSince(b.created_at) - daysSince(a.created_at)); // Health check 3 โ€” Risky repos (top-2 contributor concentration >80%) - const riskyRepos = model.allRepos.filter(r => { - const c = r.contributors || [] - if (!c.length) return false - const total = c.reduce((s, x) => s + x.contributions, 0) - if (!total) return false - const topTwo = (c[0]?.contributions || 0) + (c[1]?.contributions || 0) - return topTwo / total > 0.8 - }) + const riskyRepos = model.allRepos.filter((r) => { + const c = r.contributors || []; + if (!c.length) return false; + const total = c.reduce((s, x) => s + x.contributions, 0); + if (!total) return false; + const topTwo = (c[0]?.contributions || 0) + (c[1]?.contributions || 0); + return topTwo / total > 0.8; + }); // Health check 4 โ€” No license - const noLicense = model.allRepos.filter(r => !r.license && !r.archived && !r.fork) + const noLicense = model.allRepos.filter( + (r) => !r.license && !r.archived && !r.fork + ); // Issue resolution rate per repo - const topRepos = model.allRepos.slice(0, 8) + const topRepos = model.allRepos.slice(0, 8); - const counts = { dead: deadIssues.length, zombie: zombiePRs.length, risky: riskyRepos.length, license: noLicense.length } + const counts = { + dead: deadIssues.length, + zombie: zombiePRs.length, + risky: riskyRepos.length, + license: noLicense.length, + }; // Stat card const StatBox = ({ label, value, sub, color }) => (
{label}
-
{value}
-
{sub}
+
+ {value} +
+
+ {sub} +
- ) + ); // Issue / PR row const IssueRow = ({ item, i }) => ( - - + +
{item.title}
-
#{item.number} ยท @{item.user?.login}
+
+ #{item.number} ยท @{item.user?.login} +
- - {item.repoName} + + + {item.repoName} + - - {daysSince(item.created_at)} DAYS + + + {daysSince(item.created_at)} DAYS + - + {item.html_url && ( - + GitHub )} - ) + ); const TableHead = () => ( - {['TITLE / ID', 'REPOSITORY', 'AGE', 'ACTION'].map(h => ( - + {["TITLE / ID", "REPOSITORY", "AGE", "ACTION"].map((h) => ( + {h} ))} - ) + ); return ( -
+
- {govLoading && Running audit...} +
+ {govLoading && ( + + Running audit... + + )}
} /> {/* Summary stat cards */} -
- - - - +
+ + + +
{/* Issue Resolution Rate */}
-
Issue Resolution Rate
-
Resolution velocity across key repositories
- {topRepos.map(r => { - const repoIssues = allIssues.filter(i => i.repoName === r.name) - const closed = repoIssues.filter(i => i.state === 'closed').length - const total = repoIssues.length - const rate = total ? Math.round(closed / total * 100) : null - const color = rate === null ? 'var(--text3)' : rate >= 70 ? 'var(--green)' : rate >= 30 ? 'var(--amber)' : 'var(--red)' +
+ Issue Resolution Rate +
+
+ Resolution velocity across key repositories +
+ {topRepos.map((r) => { + const repoIssues = allIssues.filter((i) => i.repoName === r.name); + const closed = repoIssues.filter((i) => i.state === "closed").length; + const total = repoIssues.length; + const rate = total ? Math.round((closed / total) * 100) : null; + const color = + rate === null + ? "var(--text3)" + : rate >= 70 + ? "var(--green)" + : rate >= 30 + ? "var(--amber)" + : "var(--red)"; return (
-
+
- {r.name} - {r.orgLogin} + + {r.name} + + + {r.orgLogin} +
- {rate === null ? 'No data' : `${rate}%`} + {rate === null ? "No data" : `${rate}%`}
-
+
{rate !== null && ( -
+
)}
- ) + ); })} {!hasAudit && ( -
+
Run the audit to populate resolution rates with live issue data.
)} @@ -168,21 +298,42 @@ export default function GovernancePage() { {/* Tabbed detail view */}
-
- {TABS.map(t => ( +
+ {TABS.map((t) => ( @@ -190,70 +341,135 @@ export default function GovernancePage() {
{/* Dead Issues */} - {tab === 'dead' && ( - deadIssues.length ? ( -
- + {tab === "dead" && + (deadIssues.length ? ( +
+
- {deadIssues.slice(0, 25).map((item, i) => )} + + {deadIssues.slice(0, 25).map((item, i) => ( + + ))} +
- ) : - )} + ) : ( + + ))} {/* Zombie PRs */} - {tab === 'zombie' && ( - zombiePRs.length ? ( -
- + {tab === "zombie" && + (zombiePRs.length ? ( +
+
- {zombiePRs.slice(0, 25).map((item, i) => )} + + {zombiePRs.slice(0, 25).map((item, i) => ( + + ))} +
- ) : - )} + ) : ( + + ))} {/* Risky Repos */} - {tab === 'risky' && ( - riskyRepos.length ? ( -
- {riskyRepos.map(r => { - const c = r.contributors || [] - const total = c.reduce((s, x) => s + x.contributions, 0) || 1 - const pct = Math.round(((c[0]?.contributions || 0) + (c[1]?.contributions || 0)) / total * 100) + {tab === "risky" && + (riskyRepos.length ? ( +
+ {riskyRepos.map((r) => { + const c = r.contributors || []; + const total = c.reduce((s, x) => s + x.contributions, 0) || 1; + const pct = Math.round( + (((c[0]?.contributions || 0) + (c[1]?.contributions || 0)) / + total) * + 100 + ); return ( -
+
-
{r.name}
-
- Top 2 contributors own {pct}% of all commits โ€” concentration risk +
+ {r.name} +
+
+ Top 2 contributors own {pct}% of all commits โ€” + concentration risk
- HIGH RISK + + HIGH RISK +
- ) + ); })}
- ) : - )} + ) : ( + + ))} {/* No License */} - {tab === 'license' && ( - noLicense.length ? ( -
- {noLicense.map(r => ( -
+ {tab === "license" && + (noLicense.length ? ( +
+ {noLicense.map((r) => ( +
-
{r.name}
-
{r.orgLogin}
+
+ {r.name} +
+
+ {r.orgLogin} +
- NO LICENSE + + NO LICENSE +
))}
- ) : - )} + ) : ( + + ))}
- ) + ); } diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx index 2d3c6bc..5a8a517 100644 --- a/src/pages/HomePage.jsx +++ b/src/pages/HomePage.jsx @@ -1,142 +1,167 @@ -import React, { useState } from 'react' -import { useNavigate } from 'react-router-dom' -import { FiSearch, FiX } from 'react-icons/fi' -import { useApp } from '../context/AppContext' -import { C, Spinner } from '../components/UI' +import React, { useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; -const QUICK = ['AOSSIE-Org', 'vercel', 'facebook', 'microsoft'] +import { useApp } from "../context/AppContext"; +import { useDebounce } from "@/hooks/useDebounce"; + +import HeroSection from "@/components/home/HeroSection"; +import StatsSection from "@/components/home/StatsSection"; +import OrgExplorerFeatures from "@/components/home/OrgExplorerFeatures"; + +const quickExploreItems = ["AOSSIE-Org", "vercel", "facebook", "microsoft"]; export default function HomePage() { - const { explore, loading, loadMsg, error } = useApp() - const navigate = useNavigate() - const [input, setInput] = useState('') - const [chips, setChips] = useState([]) + const { explore, loading, loadMsg, error } = useApp(); + + const navigate = useNavigate(); + + const [input, setInput] = useState(""); + const [chips, setChips] = useState([]); + const [showSuggestions, setShowSuggestions] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(-1); + + const debouncedQuery = useDebounce(input, 1000); + + const recent = JSON.parse(localStorage.getItem("oe_recent") || "[]"); + + const { data: suggestions = [], isLoading } = useQuery({ + queryKey: ["github-orgs", debouncedQuery], + + queryFn: async () => { + if (!debouncedQuery.trim()) return []; + + const response = await fetch( + `https://api.github.com/search/users?q=${debouncedQuery}+type:org` + ); + + if (!response.ok) { + throw new Error("Failed to fetch organizations"); + } + + const data = await response.json(); + + return data.items; + }, + + enabled: !!debouncedQuery.trim(), + staleTime: 1000 * 60 * 5, + }); + + const filteredSuggestions = useMemo(() => { + return suggestions.filter( + (item) => item.login.toLowerCase() !== input.toLowerCase() + ); + }, [suggestions, input]); + + const addChip = (raw) => { + const parts = raw + .split(/[,+\s]+/) + .map((s) => s.trim()) + .filter(Boolean); + + setChips((prev) => [...new Set([...prev, ...parts])]); + + setInput(""); + }; + + const removeChip = (c) => { + setChips((prev) => prev.filter((x) => x !== c)); + }; + + const go = async (targets) => { + const orgs = + targets || (chips.length ? chips : input.trim() ? [input.trim()] : []); - const recent = JSON.parse(localStorage.getItem('oe_recent') || '[]') + if (!orgs.length) return; - const addChip = raw => { - const parts = raw.split(/[,+\s]+/).map(s => s.trim()).filter(Boolean) - setChips(prev => [...new Set([...prev, ...parts])]) - setInput('') - } + await explore(orgs); - const removeChip = c => setChips(prev => prev.filter(x => x !== c)) + navigate("/overview"); + }; - const handleKey = e => { - if ((e.key === 'Enter' || e.key === ',') && input.trim()) { - e.preventDefault() - addChip(input) + const handleSelectOrg = async (org) => { + const finalValue = Array.isArray(org) ? org : org != null ? [org] : []; + + await go(finalValue); + }; + + const handleKey = (e) => { + if (e.key === "Backspace" && !input && chips.length) { + setChips((prev) => prev.slice(0, -1)); + } + + if (filteredSuggestions.length) { + if (e.key === "ArrowDown") { + e.preventDefault(); + + setSelectedIndex((prev) => + prev < filteredSuggestions.length - 1 ? prev + 1 : prev + ); + } + + if (e.key === "ArrowUp") { + e.preventDefault(); + + setSelectedIndex((prev) => (prev > 0 ? prev - 1 : 0)); + } + + if (e.key === "Enter" && selectedIndex >= 0) { + e.preventDefault(); + + addChip(filteredSuggestions[selectedIndex].login); + + handleSelectOrg(filteredSuggestions[selectedIndex].login); + + return; + } } - if (e.key === 'Backspace' && !input && chips.length) { - setChips(prev => prev.slice(0, -1)) + + if ((e.key === "Enter" || e.key === ",") && input.trim()) { + e.preventDefault(); + + addChip(input); } - } + }; - const go = async (targets) => { - const orgs = targets || (chips.length ? chips : input.trim() ? [input.trim()] : []) - if (!orgs.length) return - await explore(orgs) - navigate('/overview') - } + const handleSubmit = (e) => { + e.preventDefault(); + + go(); + }; return ( -
- {/* Hero */} -
-

- Architect Your{' '} - Insights -

-

- Unified analytics across one or many GitHub organizations. Multi-org portfolio analysis, contributor network graphs, time-series trends, and governance audits โ€” entirely in the browser. -

-
- - {/* Search */} -
-
- - {chips.map(c => ( - - {c} - removeChip(c)} /> - - ))} - setInput(e.target.value)} - onKeyDown={handleKey} - onBlur={() => input.trim() && addChip(input)} - placeholder={chips.length ? 'Add another org...' : 'AOSSIE-Org, StabilityNexus, DjedAlliance...'} - style={{ flex: 1, minWidth: 160, background: 'none', color: 'var(--text)', fontSize: 14, padding: '4px 8px', border: 'none', outline: 'none' }} - /> - -
-

- Type an org name and press Enter or comma to add. Add multiple orgs to analyze as a unified portfolio. -

- {error &&

{error}

} -
- - {/* Loading */} - {loading && ( -
- -

{loadMsg}

-
- )} - - {/* Recent */} - {recent.length > 0 && !loading && ( -
- Recent searches -
- {recent.map(r => ( - - ))} -
-
- )} - - {/* Quick explore */} - {!loading && ( -
- Quick explore -
- {QUICK.map(q => ( - - ))} -
+
+ + + + +
+
+
- )} - - {/* Stats bar */} -
- {[['5,000', 'req/hr with PAT', 'var(--green)'], ['1HR', 'intelligent cache', 'var(--green)'], ['ZERO', 'backend latency', 'var(--accent)']].map(([v, l, color]) => ( -
-
{v}
-
{l}
-
- ))} -
-
- ) + + + ); } diff --git a/src/pages/NetworkPage.jsx b/src/pages/NetworkPage.jsx index 14549eb..634c98f 100644 --- a/src/pages/NetworkPage.jsx +++ b/src/pages/NetworkPage.jsx @@ -1,200 +1,381 @@ -import React, { useEffect, useRef, useState } from 'react' -import * as d3 from 'd3' -import { useApp } from '../context/AppContext' -import { C, PageTitle } from '../components/UI' +import React, { useEffect, useRef, useState } from "react"; +import * as d3 from "d3"; +import { useApp } from "../context/AppContext"; +import { C, PageTitle } from "../components/UI"; export default function NetworkPage() { - const { model } = useApp() - const svgRef = useRef(null) - const simRef = useRef(null) - const [tooltip, setTooltip] = useState(null) - const [showRepos, setShowRepos] = useState(true) - const [showContribs, setShowContribs] = useState(true) + const { model } = useApp(); + const svgRef = useRef(null); + const simRef = useRef(null); + const [tooltip, setTooltip] = useState(null); + const [showRepos, setShowRepos] = useState(true); + const [showContribs, setShowContribs] = useState(true); useEffect(() => { - if (!svgRef.current || !model?.allRepos.length) return + if (!svgRef.current || !model?.allRepos.length) return; - const el = svgRef.current - const W = el.clientWidth || 860 - const H = 580 - const now = Date.now() + const el = svgRef.current; + const W = el.clientWidth || 860; + const H = 580; + const now = Date.now(); - const svg = d3.select(el) - svg.selectAll('*').remove() - svg.attr('viewBox', `0 0 ${W} ${H}`) + const svg = d3.select(el); + svg.selectAll("*").remove(); + svg.attr("viewBox", `0 0 ${W} ${H}`); // Top repos and contributors for performance - const topRepos = model.allRepos.slice(0, 30) - const topContribs = model.contributors.slice(0, 40) - - const nodes = [] - if (showRepos) topRepos.forEach(r => nodes.push({ id: `repo:${r.name}`, type: 'repo', data: r, ts: new Date(r.pushed_at).getTime() })) - if (showContribs) topContribs.forEach(c => nodes.push({ id: `user:${c.login}`, type: 'contributor', data: c, ts: c.lastActive ? new Date(c.lastActive).getTime() : 0 })) - - const nodeSet = new Set(nodes.map(n => n.id)) - const links = [] - topContribs.forEach(c => { - c.repos.slice(0, 5).forEach(repo => { - const s = `user:${c.login}`, t = `repo:${repo.name}` - if (nodeSet.has(s) && nodeSet.has(t)) links.push({ source: s, target: t, weight: repo.count }) - }) - }) + const topRepos = model.allRepos.slice(0, 30); + const topContribs = model.contributors.slice(0, 40); + + const nodes = []; + if (showRepos) + topRepos.forEach((r) => + nodes.push({ + id: `repo:${r.name}`, + type: "repo", + data: r, + ts: new Date(r.pushed_at).getTime(), + }) + ); + if (showContribs) + topContribs.forEach((c) => + nodes.push({ + id: `user:${c.login}`, + type: "contributor", + data: c, + ts: c.lastActive ? new Date(c.lastActive).getTime() : 0, + }) + ); + + const nodeSet = new Set(nodes.map((n) => n.id)); + const links = []; + topContribs.forEach((c) => { + c.repos.slice(0, 5).forEach((repo) => { + const s = `user:${c.login}`, + t = `repo:${repo.name}`; + if (nodeSet.has(s) && nodeSet.has(t)) + links.push({ source: s, target: t, weight: repo.count }); + }); + }); // Scales - const recencyY = d3.scaleLinear().domain([now - 365 * 86_400_000, now]).range([H * 0.83, H * 0.14]).clamp(true) - const repoRadius = d3.scaleSqrt().domain([0, d3.max(topRepos, r => r.stargazers_count) || 1]).range([5, 22]) - const contribR = d3.scaleSqrt().domain([0, d3.max(topContribs, c => c.totalContribs) || 1]).range([4, 14]) - const edgeW = d3.scaleLinear().domain([1, d3.max(links, l => l.weight) || 1]).range([1, 6]) - const healthColor = h => h >= 70 ? '#22c55e' : h >= 40 ? '#f59e0b' : '#ef4444' + const recencyY = d3 + .scaleLinear() + .domain([now - 365 * 86_400_000, now]) + .range([H * 0.83, H * 0.14]) + .clamp(true); + const repoRadius = d3 + .scaleSqrt() + .domain([0, d3.max(topRepos, (r) => r.stargazers_count) || 1]) + .range([5, 22]); + const contribR = d3 + .scaleSqrt() + .domain([0, d3.max(topContribs, (c) => c.totalContribs) || 1]) + .range([4, 14]); + const edgeW = d3 + .scaleLinear() + .domain([1, d3.max(links, (l) => l.weight) || 1]) + .range([1, 6]); + const healthColor = (h) => + h >= 70 ? "#22c55e" : h >= 40 ? "#f59e0b" : "#ef4444"; - const g = svg.append('g') - const zoom = d3.zoom().scaleExtent([0.2, 4]).on('zoom', e => g.attr('transform', e.transform)) - svg.call(zoom) + const g = svg.append("g"); + const zoom = d3 + .zoom() + .scaleExtent([0.2, 4]) + .on("zoom", (e) => g.attr("transform", e.transform)); + svg.call(zoom); // Draw edges - const link = g.append('g') - .selectAll('line').data(links).join('line') - .attr('stroke', '#2a2a2a') - .attr('stroke-opacity', 0.8) - .attr('stroke-width', d => edgeW(d.weight)) + const link = g + .append("g") + .selectAll("line") + .data(links) + .join("line") + .attr("stroke", "#2a2a2a") + .attr("stroke-opacity", 0.8) + .attr("stroke-width", (d) => edgeW(d.weight)); // Draw nodes - const node = g.append('g') - .selectAll('g').data(nodes).join('g') - .attr('cursor', 'pointer') + const node = g + .append("g") + .selectAll("g") + .data(nodes) + .join("g") + .attr("cursor", "pointer") .call( - d3.drag() - .on('start', (e, d) => { if (!e.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y }) - .on('drag', (e, d) => { d.fx = e.x; d.fy = e.y }) - .on('end', (e, d) => { if (!e.active) sim.alphaTarget(0); d.fx = null; d.fy = null }) + d3 + .drag() + .on("start", (e, d) => { + if (!e.active) sim.alphaTarget(0.3).restart(); + d.fx = d.x; + d.fy = d.y; + }) + .on("drag", (e, d) => { + d.fx = e.x; + d.fy = e.y; + }) + .on("end", (e, d) => { + if (!e.active) sim.alphaTarget(0); + d.fx = null; + d.fy = null; + }) ) - .on('mouseover', (event, d) => { + .on("mouseover", (event, d) => { link - .attr('stroke', l => (l.source.id === d.id || l.target.id === d.id) ? '#f5c518' : '#2a2a2a') - .attr('stroke-opacity', l => (l.source.id === d.id || l.target.id === d.id) ? 1 : 0.08) - const rect = el.getBoundingClientRect() - setTooltip({ x: event.clientX - rect.left + 14, y: event.clientY - rect.top - 14, node: d }) - }) - .on('mouseout', () => { - link.attr('stroke', '#2a2a2a').attr('stroke-opacity', 0.8) - setTooltip(null) + .attr("stroke", (l) => + l.source.id === d.id || l.target.id === d.id ? "#f5c518" : "#2a2a2a" + ) + .attr("stroke-opacity", (l) => + l.source.id === d.id || l.target.id === d.id ? 1 : 0.08 + ); + const rect = el.getBoundingClientRect(); + setTooltip({ + x: event.clientX - rect.left + 14, + y: event.clientY - rect.top - 14, + node: d, + }); }) + .on("mouseout", () => { + link.attr("stroke", "#2a2a2a").attr("stroke-opacity", 0.8); + setTooltip(null); + }); - node.each(function(d) { - const el = d3.select(this) - if (d.type === 'repo') { - const r = repoRadius(d.data.stargazers_count || 0) - el.append('rect') - .attr('width', r * 2).attr('height', r * 2) - .attr('x', -r).attr('y', -r).attr('rx', 3) - .attr('fill', healthColor(d.data.healthScore || 0)) - .attr('stroke', '#0d0d0d').attr('stroke-width', 1.5) + node.each(function (d) { + const el = d3.select(this); + if (d.type === "repo") { + const r = repoRadius(d.data.stargazers_count || 0); + el.append("rect") + .attr("width", r * 2) + .attr("height", r * 2) + .attr("x", -r) + .attr("y", -r) + .attr("rx", 3) + .attr("fill", healthColor(d.data.healthScore || 0)) + .attr("stroke", "#0d0d0d") + .attr("stroke-width", 1.5); } else { - el.append('circle') - .attr('r', contribR(d.data.totalContribs || 1)) - .attr('fill', d.data.isConnector ? '#f5c518' : '#555') - .attr('stroke', '#0d0d0d').attr('stroke-width', 1.5) + el.append("circle") + .attr("r", contribR(d.data.totalContribs || 1)) + .attr("fill", d.data.isConnector ? "#f5c518" : "#555") + .attr("stroke", "#0d0d0d") + .attr("stroke-width", 1.5); } - const labelY = d.type === 'repo' - ? repoRadius(d.data.stargazers_count || 0) + 11 - : contribR(d.data.totalContribs || 1) + 11 - const raw = d.type === 'repo' ? d.data.name : d.data.login - el.append('text') - .text(raw.length > 14 ? raw.slice(0, 12) + '..' : raw) - .attr('text-anchor', 'middle').attr('dy', labelY) - .attr('font-size', 9).attr('fill', '#666') - .attr('pointer-events', 'none') - }) + const labelY = + d.type === "repo" + ? repoRadius(d.data.stargazers_count || 0) + 11 + : contribR(d.data.totalContribs || 1) + 11; + const raw = d.type === "repo" ? d.data.name : d.data.login; + el.append("text") + .text(raw.length > 14 ? raw.slice(0, 12) + ".." : raw) + .attr("text-anchor", "middle") + .attr("dy", labelY) + .attr("font-size", 9) + .attr("fill", "#666") + .attr("pointer-events", "none"); + }); // Force simulation with y-force for recency (Section 3.2.7) - const sim = d3.forceSimulation(nodes) - .force('link', d3.forceLink(links).id(d => d.id).distance(75).strength(0.4)) - .force('charge', d3.forceManyBody().strength(-170)) - .force('center', d3.forceCenter(W / 2, H / 2)) - .force('collide', d3.forceCollide(d => d.type === 'repo' ? repoRadius(d.data.stargazers_count || 0) + 8 : 16)) - .force('y', d3.forceY(d => recencyY(d.ts || 0)).strength(0.05)) + const sim = d3 + .forceSimulation(nodes) + .force( + "link", + d3 + .forceLink(links) + .id((d) => d.id) + .distance(75) + .strength(0.4) + ) + .force("charge", d3.forceManyBody().strength(-170)) + .force("center", d3.forceCenter(W / 2, H / 2)) + .force( + "collide", + d3.forceCollide((d) => + d.type === "repo" ? repoRadius(d.data.stargazers_count || 0) + 8 : 16 + ) + ) + .force("y", d3.forceY((d) => recencyY(d.ts || 0)).strength(0.05)); - simRef.current = sim + simRef.current = sim; - sim.on('tick', () => { + sim.on("tick", () => { link - .attr('x1', d => d.source.x).attr('y1', d => d.source.y) - .attr('x2', d => d.target.x).attr('y2', d => d.target.y) - node.attr('transform', d => `translate(${d.x},${d.y})`) - }) + .attr("x1", (d) => d.source.x) + .attr("y1", (d) => d.source.y) + .attr("x2", (d) => d.target.x) + .attr("y2", (d) => d.target.y); + node.attr("transform", (d) => `translate(${d.x},${d.y})`); + }); - return () => sim.stop() - }, [model, showRepos, showContribs]) + return () => sim.stop(); + }, [model, showRepos, showContribs]); - if (!model) return null + if (!model) return null; return ( -
+
{/* Controls */} -
-
- {[['Repositories', showRepos, setShowRepos], ['Contributors', showContribs, setShowContribs]].map(([label, val, set]) => ( - ))}
-
+
Square = Repository node (color = health score) Circle yellow = Cross-repo connector Circle gray = Regular contributor Edge thickness = contribution volume - Recently active nodes float upward + + Recently active nodes float upward +
{/* Canvas */} -
- +
+ {/* Tooltip */} {tooltip && ( -
+
{tooltip.node.data.name || tooltip.node.data.login}
- {tooltip.node.type === 'repo' ? ( + {tooltip.node.type === "repo" ? ( <> -
- Health: = 70 ? 'var(--green)' : tooltip.node.data.healthScore >= 40 ? 'var(--amber)' : 'var(--red)' }}> +
+ Health:{" "} + = 70 + ? "var(--green)" + : tooltip.node.data.healthScore >= 40 + ? "var(--amber)" + : "var(--red)", + }} + > {tooltip.node.data.healthScore}
-
Stars: {tooltip.node.data.stargazers_count?.toLocaleString()}
-
Lifecycle: {tooltip.node.data.lifecycle}
+
+ Stars: {tooltip.node.data.stargazers_count?.toLocaleString()} +
+
+ Lifecycle: {tooltip.node.data.lifecycle} +
) : ( <> -
- Contributions: {tooltip.node.data.totalContribs?.toLocaleString()} +
+ Contributions:{" "} + + {tooltip.node.data.totalContribs?.toLocaleString()} + +
+
+ Repos: {tooltip.node.data.repos?.length}
-
Repos: {tooltip.node.data.repos?.length}
- {tooltip.node.data.isConnector &&
Cross-repo connector
} - {tooltip.node.data.isCrossOrg &&
Cross-org contributor
} + {tooltip.node.data.isConnector && ( +
+ Cross-repo connector +
+ )} + {tooltip.node.data.isCrossOrg && ( +
+ Cross-org contributor +
+ )} )}
)} -
+
Drag nodes to reposition โ€” scroll to zoom
- ) + ); } diff --git a/src/pages/OverviewPage.jsx b/src/pages/OverviewPage.jsx index 5460f95..a483650 100644 --- a/src/pages/OverviewPage.jsx +++ b/src/pages/OverviewPage.jsx @@ -1,82 +1,171 @@ -import React from 'react' -import { useNavigate } from 'react-router-dom' -import { FiExternalLink, FiShare2, FiArrowRight } from 'react-icons/fi' -import { useApp } from '../context/AppContext' -import { C, StatCard, HealthBar } from '../components/UI' +import React from "react"; +import { useNavigate } from "react-router-dom"; +import { FiExternalLink, FiShare2, FiArrowRight } from "react-icons/fi"; +import { useApp } from "../context/AppContext"; +import { C, StatCard, HealthBar } from "../components/UI"; -const LANG_COLORS = ['#22c55e','#f5c518','#3b82f6','#ef4444','#a855f7','#f97316','#06b6d4'] -const fmt = n => n > 999 ? (n / 1000).toFixed(1) + 'k' : String(n) +const LANG_COLORS = [ + "#22c55e", + "#f5c518", + "#3b82f6", + "#ef4444", + "#a855f7", + "#f97316", + "#06b6d4", +]; +const fmt = (n) => (n > 999 ? (n / 1000).toFixed(1) + "k" : String(n)); export default function OverviewPage() { - const { orgs, model } = useApp() - const navigate = useNavigate() - if (!model) return null + const { orgs, model } = useApp(); + const navigate = useNavigate(); + if (!model) return null; - const { allRepos } = model - const isMulti = orgs.length > 1 - const totalStars = allRepos.reduce((s, r) => s + r.stargazers_count, 0) - const totalForks = allRepos.reduce((s, r) => s + r.forks_count, 0) - const activeRepos = allRepos.filter(r => r.lifecycle === 'Thriving' || r.lifecycle === 'Stable').length + const { allRepos } = model; + const isMulti = orgs.length > 1; + const totalStars = allRepos.reduce((s, r) => s + r.stargazers_count, 0); + const totalForks = allRepos.reduce((s, r) => s + r.forks_count, 0); + const activeRepos = allRepos.filter( + (r) => r.lifecycle === "Thriving" || r.lifecycle === "Stable" + ).length; - const langMap = {} - allRepos.forEach(r => { if (r.language) langMap[r.language] = (langMap[r.language] || 0) + 1 }) - const langs = Object.entries(langMap).sort((a, b) => b[1] - a[1]).slice(0, 7) - const langTotal = langs.reduce((s, [, c]) => s + c, 0) + const langMap = {}; + allRepos.forEach((r) => { + if (r.language) langMap[r.language] = (langMap[r.language] || 0) + 1; + }); + const langs = Object.entries(langMap) + .sort((a, b) => b[1] - a[1]) + .slice(0, 7); + const langTotal = langs.reduce((s, [, c]) => s + c, 0); - const topRepos = [...allRepos].sort((a, b) => b.healthScore - a.healthScore).slice(0, 5) + const topRepos = [...allRepos] + .sort((a, b) => b.healthScore - a.healthScore) + .slice(0, 5); const NavCard = ({ to, label, sub }) => (
navigate(to)} - onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--accent)'} - onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border)'} - style={{ ...C.card, cursor: 'pointer', transition: 'border-color .2s' }} + onMouseEnter={(e) => + (e.currentTarget.style.borderColor = "var(--accent)") + } + onMouseLeave={(e) => + (e.currentTarget.style.borderColor = "var(--border)") + } + style={{ ...C.card, cursor: "pointer", transition: "border-color .2s" }} > -
{label}
-
{sub}
- +
+ {label} +
+
+ {sub} +
+ View {label}
- ) + ); return ( -
- +
{/* Org identity bar */} -
+
{isMulti ? ( -
- {orgs.slice(0, 3).map((o, i) => o.avatar_url && ( - {o.login} - ))} +
+ {orgs.slice(0, 3).map( + (o, i) => + o.avatar_url && ( + {o.login} + ) + )}
) : ( orgs[0]?.avatar_url && ( - + ) )}

- {isMulti ? orgs.map(o => o.login).join(' + ') : (orgs[0]?.name || orgs[0]?.login)} + {isMulti + ? orgs.map((o) => o.login).join(" + ") + : orgs[0]?.name || orgs[0]?.login}

-

+

{isMulti ? `${orgs.length} organizations โ€” combined portfolio view` - : (orgs[0]?.description || `@${orgs[0]?.login}`)} + : orgs[0]?.description || `@${orgs[0]?.login}`}

-
+
{!isMulti && orgs[0]?.html_url && ( - + View on GitHub )} @@ -84,29 +173,79 @@ export default function OverviewPage() {
{/* Stats */} -
- - - - +
+ + + +
{/* Language + top repos */} -
+
-
Language Distribution
-
Technology Stack Analysis
-
+
+ Language Distribution +
+
+ Technology Stack Analysis +
+
{langs.map(([lang, count], i) => ( -
+
))}
-
+
{langs.map(([lang, count], i) => ( -
-
- - {lang} {Math.round(count / langTotal * 100)}% +
+
+ + {lang}{" "} + + {Math.round((count / langTotal) * 100)}% +
))} @@ -114,14 +253,34 @@ export default function OverviewPage() {
-
High Impact Repositories
-
By Composite Health Score
-
- {topRepos.map(r => ( +
+ High Impact Repositories +
+
+ By Composite Health Score +
+
+ {topRepos.map((r) => (
-
- {r.name} - {r.healthScore} +
+ + {r.name} + + + {r.healthScore} +
@@ -131,14 +290,44 @@ export default function OverviewPage() {
{/* Nav cards */} -
- - - - - - +
+ + + + + +
- ) + ); } diff --git a/src/pages/RepositoriesPage.jsx b/src/pages/RepositoriesPage.jsx index c124fef..09d1964 100644 --- a/src/pages/RepositoriesPage.jsx +++ b/src/pages/RepositoriesPage.jsx @@ -1,93 +1,187 @@ -import React, { useState, useMemo } from 'react' -import { FiDownload, FiGrid, FiList } from 'react-icons/fi' -import { useApp } from '../context/AppContext' -import { C, Badge, HealthBar, SortTh, PageTitle, LoadMore } from '../components/UI' -import { useSortedData } from '../hooks/useSortedData' -import { exportReposCSV } from '../services/analytics' +import React, { useState, useMemo } from "react"; +import { FiDownload, FiGrid, FiList } from "react-icons/fi"; +import { useApp } from "../context/AppContext"; +import { + C, + Badge, + HealthBar, + SortTh, + PageTitle, + LoadMore, +} from "../components/UI"; +import { useSortedData } from "../hooks/useSortedData"; +import { exportReposCSV } from "../services/analytics"; -const LIFECYCLES = ['All','Thriving','Stable','Dormant','Abandoned'] -const LC_ACTIVE = { Thriving:'var(--green)', Stable:'var(--blue)', Dormant:'var(--amber)', Abandoned:'var(--red)' } +const LIFECYCLES = ["All", "Thriving", "Stable", "Dormant", "Abandoned"]; +const LC_ACTIVE = { + Thriving: "var(--green)", + Stable: "var(--blue)", + Dormant: "var(--amber)", + Abandoned: "var(--red)", +}; export default function RepositoriesPage() { - const { model } = useApp() - const [search, setSearch] = useState('') - const [lifecycle, setLifecycle] = useState('All') - const [lang, setLang] = useState('All') - const [view, setView] = useState('grid') - const [shown, setShown] = useState(20) + const { model } = useApp(); + const [search, setSearch] = useState(""); + const [lifecycle, setLifecycle] = useState("All"); + const [lang, setLang] = useState("All"); + const [view, setView] = useState("grid"); + const [shown, setShown] = useState(20); - if (!model) return null - const { allRepos } = model + if (!model) return null; + const { allRepos } = model; - const langs = useMemo(() => - ['All', ...new Set(allRepos.map(r => r.language).filter(Boolean))].slice(0, 10), - [allRepos]) + const langs = useMemo( + () => + [ + "All", + ...new Set(allRepos.map((r) => r.language).filter(Boolean)), + ].slice(0, 10), + [allRepos] + ); - const filtered = useMemo(() => allRepos.filter(r => - (lifecycle === 'All' || r.lifecycle === lifecycle) && - (lang === 'All' || r.language === lang) && - (!search || r.name.toLowerCase().includes(search.toLowerCase()) || - (r.description || '').toLowerCase().includes(search.toLowerCase())) - ), [allRepos, lifecycle, lang, search]) + const filtered = useMemo( + () => + allRepos.filter( + (r) => + (lifecycle === "All" || r.lifecycle === lifecycle) && + (lang === "All" || r.language === lang) && + (!search || + r.name.toLowerCase().includes(search.toLowerCase()) || + (r.description || "").toLowerCase().includes(search.toLowerCase())) + ), + [allRepos, lifecycle, lang, search] + ); - const { sorted, sortConfig, onSort } = useSortedData(filtered, 'healthScore', 'desc') - const visible = sorted.slice(0, shown) + const { sorted, sortConfig, onSort } = useSortedData( + filtered, + "healthScore", + "desc" + ); + const visible = sorted.slice(0, shown); const TABLE_COLS = [ - ['name', 'Repository'], - ['stargazers_count', 'Stars'], - ['forks_count', 'Forks'], - ['open_issues_count','Open Issues'], - ['healthScore', 'Health'], - ['lifecycle', 'Lifecycle'], - ['pushed_at', 'Last Push'], - ] + ["name", "Repository"], + ["stargazers_count", "Stars"], + ["forks_count", "Forks"], + ["open_issues_count", "Open Issues"], + ["healthScore", "Health"], + ["lifecycle", "Lifecycle"], + ["pushed_at", "Last Push"], + ]; return ( -
+
+ {filtered.length} - / {allRepos.length} repos + + {" "} + / {allRepos.length} repos + } /> {/* Filter bar */}
-
+
setSearch(e.target.value)} + value={search} + onChange={(e) => setSearch(e.target.value)} placeholder="Filter by repository name or description..." style={{ ...C.input, flex: 1, minWidth: 200 }} /> - setLang(e.target.value)} + style={C.select} + > + {langs.map((l) => ( + + ))} -
- -
-
-
- {LIFECYCLES.map(l => ( +
+ {LIFECYCLES.map((l) => (
{/* Table view */} - {view === 'list' && ( -
- + {view === "list" && ( +
+
{TABLE_COLS.map(([k, l]) => ( - + ))} {visible.map((r, i) => ( - - + + + + + + + - - - - - - ))}
-
{r.name}
- {r.orgLogin &&
{r.orgLogin}
} +
+
+ {r.name} +
+ {r.orgLogin && ( +
+ {r.orgLogin} +
+ )} +
+ {r.stargazers_count.toLocaleString()} + + {r.forks_count.toLocaleString()} + 30 + ? "var(--red)" + : "var(--text2)", + }} + > + {r.open_issues_count} + + + + + + {r.pushed_at?.slice(0, 10)} {r.stargazers_count.toLocaleString()}{r.forks_count.toLocaleString()} 30 ? 'var(--red)' : 'var(--text2)' }}>{r.open_issues_count}{r.pushed_at?.slice(0, 10)}
- setShown(s => s + 20)} /> + setShown((s) => s + 20)} + />
)} {/* Grid view */} - {view === 'grid' && ( + {view === "grid" && ( <> -
- {visible.map(r => ( +
+ {visible.map((r) => (
e.currentTarget.style.borderColor = 'var(--accent)'} - onMouseLeave={e => e.currentTarget.style.borderColor = - r.lifecycle === 'Thriving' ? 'rgba(34,197,94,.25)' : - r.lifecycle === 'Abandoned' ? 'rgba(239,68,68,.25)' : 'var(--border)'} + onMouseEnter={(e) => + (e.currentTarget.style.borderColor = "var(--accent)") + } + onMouseLeave={(e) => + (e.currentTarget.style.borderColor = + r.lifecycle === "Thriving" + ? "rgba(34,197,94,.25)" + : r.lifecycle === "Abandoned" + ? "rgba(239,68,68,.25)" + : "var(--border)") + } style={{ ...C.card, - borderColor: r.lifecycle === 'Thriving' ? 'rgba(34,197,94,.25)' : r.lifecycle === 'Abandoned' ? 'rgba(239,68,68,.25)' : 'var(--border)', - transition: 'border-color .2s', display: 'flex', flexDirection: 'column', gap: 10, + borderColor: + r.lifecycle === "Thriving" + ? "rgba(34,197,94,.25)" + : r.lifecycle === "Abandoned" + ? "rgba(239,68,68,.25)" + : "var(--border)", + transition: "border-color .2s", + display: "flex", + flexDirection: "column", + gap: 10, }} > -
+
-
{r.name}
- {r.orgLogin &&
{r.orgLogin}
} +
+ {r.name} +
+ {r.orgLogin && ( +
+ {r.orgLogin} +
+ )}
-

- {r.description || 'No description provided'} +

+ {r.description || "No description provided"}

-
- {[['Stars', r.stargazers_count.toLocaleString()], ['Forks', r.forks_count.toLocaleString()], ['Issues', r.open_issues_count]].map(([l, v]) => ( +
+ {[ + ["Stars", r.stargazers_count.toLocaleString()], + ["Forks", r.forks_count.toLocaleString()], + ["Issues", r.open_issues_count], + ].map(([l, v]) => (
{v}
{l}
@@ -164,25 +373,55 @@ export default function RepositoriesPage() { ))}
{r.language && ( -
- +
+ {r.language}
)}
-
+
HEALTH SCORE {r.pushed_at?.slice(0, 10)}
-
ACTIVITY 40% ยท ISSUES 30% ยท DIVERSITY 30%
+
+ ACTIVITY 40% ยท ISSUES 30% ยท DIVERSITY 30% +
))}
- setShown(s => s + 20)} /> + setShown((s) => s + 20)} + /> )}
- ) + ); } diff --git a/src/pages/SettingsPage.jsx b/src/pages/SettingsPage.jsx index 41c840b..3e61ac3 100644 --- a/src/pages/SettingsPage.jsx +++ b/src/pages/SettingsPage.jsx @@ -1,87 +1,131 @@ -import React, { useState } from 'react' -import { FiEye, FiEyeOff, FiTrash2, FiSave, FiRefreshCw } from 'react-icons/fi' -import { useApp } from '../context/AppContext' -import { C } from '../components/UI' -import { cacheClear } from '../services/github' +import React, { useState } from "react"; +import { FiEye, FiEyeOff, FiTrash2, FiSave, FiRefreshCw } from "react-icons/fi"; +import { useApp } from "../context/AppContext"; +import { C } from "../components/UI"; +import { cacheClear } from "../services/github"; export default function SettingsPage() { - const { pat, savePat, rateLimit } = useApp() - const [draft, setDraft] = useState(pat) - const [show, setShow] = useState(false) - const [saved, setSaved] = useState(false) - const [cleared, setCleared] = useState(false) + const { pat, savePat, rateLimit } = useApp(); + const [draft, setDraft] = useState(pat); + const [show, setShow] = useState(false); + const [saved, setSaved] = useState(false); + const [cleared, setCleared] = useState(false); const handleSave = () => { - savePat(draft.trim()) - setSaved(true) - setTimeout(() => setSaved(false), 2000) - } + savePat(draft.trim()); + setSaved(true); + setTimeout(() => setSaved(false), 2000); + }; const handleDelete = () => { - savePat('') - setDraft('') - } + savePat(""); + setDraft(""); + }; const handleClear = async () => { - await cacheClear() - setCleared(true) - setTimeout(() => setCleared(false), 2000) - } + await cacheClear(); + setCleared(true); + setTimeout(() => setCleared(false), 2000); + }; const rateColor = rateLimit - ? rateLimit.remaining / rateLimit.limit > 0.3 ? 'var(--green)' : 'var(--red)' - : 'var(--text2)' + ? rateLimit.remaining / rateLimit.limit > 0.3 + ? "var(--green)" + : "var(--red)" + : "var(--text2)"; return ( -
-

Settings

- -
+
+

+ Settings +

+
{/* Left column */} -
- +
{/* GitHub Authentication */}
-
+
-
GitHub Authentication
-
Personal Access Token (PAT)
+
+ GitHub Authentication +
+
+ Personal Access Token (PAT) +
{pat && ( - AUTHENTICATED + + AUTHENTICATED + )}
-
+
PRIVATE TOKEN KEY
-
+
setDraft(e.target.value)} + onChange={(e) => setDraft(e.target.value)} placeholder="ghp_xxxxxxxxxxxxxxxxxxxx" style={{ ...C.input, flex: 1 }} - onKeyDown={e => e.key === 'Enter' && handleSave()} + onKeyDown={(e) => e.key === "Enter" && handleSave()} />
-
+
@@ -90,17 +134,52 @@ export default function SettingsPage() { {/* How to create a PAT */}
-
How to create a PAT
-
+
+ How to create a PAT +
+
{[ - ['01', 'Go to GitHub Settings โ†’ Developer settings โ†’ Personal access tokens โ†’ Tokens (classic)'], - ['02', 'Click "Generate new token (classic)"'], - ['03', 'Select scopes: read:org and public_repo'], - ['04', 'Copy the token and paste it above, then click Save'], + [ + "01", + "Go to GitHub Settings โ†’ Developer settings โ†’ Personal access tokens โ†’ Tokens (classic)", + ], + ["02", 'Click "Generate new token (classic)"'], + ["03", "Select scopes: read:org and public_repo"], + ["04", "Copy the token and paste it above, then click Save"], ].map(([n, text]) => ( -
-
{n}
-
{text}
+
+
+ {n} +
+
+ {text} +
))}
@@ -108,59 +187,126 @@ export default function SettingsPage() { {/* Data Cache */}
-
+
Data Cache
-
IndexedDB ยท 1-hour TTL per entry
+
+ IndexedDB ยท 1-hour TTL per entry +
-

- All API responses are cached in IndexedDB with a 1-hour TTL. Cache keys are namespaced per org and per resource type โ€” enabling partial cache hits across paginated fetches. Clearing forces fresh API calls on the next explore. +

+ All API responses are cached in IndexedDB with a 1-hour TTL. Cache + keys are namespaced per org and per resource type โ€” enabling + partial cache hits across paginated fetches. Clearing forces fresh + API calls on the next explore.

{/* Right column */} -
- +
{/* API Quota */}
-
-
API Quota
- +
+
+ API Quota +
+
{rateLimit ? ( <>
- + {rateLimit.remaining.toLocaleString()} - + / {rateLimit.limit.toLocaleString()}
-
Requests Remaining
-
-
+
+ Requests Remaining +
+
+
{!pat && ( -
+
Add a PAT above to unlock 5,000 req/hr instead of 60.
)} ) : ( -
+
Explore an organization to see your live API quota status.
)} @@ -168,27 +314,75 @@ export default function SettingsPage() { {/* Architect Meta */}
-
Architect Meta
-
+
+ Architect Meta +
+
{[ - ['Core Version', 'v2.0.0-stable'], - ['Architecture', 'Client-side only, no backend'], - ['API strategy', '53 req/hr unauthenticated'], - ['Cache', 'IndexedDB + React Context'], + ["Core Version", "v2.0.0-stable"], + ["Architecture", "Client-side only, no backend"], + ["API strategy", "53 req/hr unauthenticated"], + ["Cache", "IndexedDB + React Context"], ].map(([k, v]) => ( -
- {k} - {v} +
+ {k} + + {v} +
))}
Stack Integrity
-
- {['React 18 + Vite', 'React Router v6', 'D3.js v7 (network graph)', 'Recharts 2 (time-series)', 'react-icons', 'IndexedDB cache'].map(s => ( -
-
- {s} +
+ {[ + "React 18 + Vite", + "React Router v6", + "D3.js v7 (network graph)", + "Recharts 2 (time-series)", + "react-icons", + "IndexedDB cache", + ].map((s) => ( +
+
+ {s}
))}
@@ -196,5 +390,5 @@ export default function SettingsPage() {
- ) + ); } diff --git a/src/services/analytics.js b/src/services/analytics.js index acc40d7..72f6f0d 100644 --- a/src/services/analytics.js +++ b/src/services/analytics.js @@ -1,59 +1,69 @@ // Repo Health Indicator (Section 3.2.6) // Activity (40%) + Issue Health (30%) + Diversity (30%) export function computeHealthScore(repo, contributorCount = 0) { - const daysSince = (Date.now() - new Date(repo.pushed_at)) / 86_400_000 - const activity = Math.max(0, 100 - daysSince) - const total = (repo.open_issues_count || 0) + 10 - const issueHealth = Math.max(0, 100 - (repo.open_issues_count / total) * 100) - const diversity = Math.min(100, contributorCount * 10) - return Math.round(activity * 0.4 + issueHealth * 0.3 + diversity * 0.3) + const daysSince = (Date.now() - new Date(repo.pushed_at)) / 86_400_000; + const activity = Math.max(0, 100 - daysSince); + const total = (repo.open_issues_count || 0) + 10; + const issueHealth = Math.max(0, 100 - (repo.open_issues_count / total) * 100); + const diversity = Math.min(100, contributorCount * 10); + return Math.round(activity * 0.4 + issueHealth * 0.3 + diversity * 0.3); } // Repo Lifecycle (Section 3.2.6) โ€” Thriving, Stable, Dormant, Abandoned based on recency of last push export function computeLifecycle(repo) { - const days = (Date.now() - new Date(repo.pushed_at)) / 86_400_000 - if (days <= 30) return 'Thriving' - if (days <= 90) return 'Stable' - if (days <= 180) return 'Dormant' - return 'Abandoned' + const days = (Date.now() - new Date(repo.pushed_at)) / 86_400_000; + if (days <= 30) return "Thriving"; + if (days <= 90) return "Stable"; + if (days <= 180) return "Dormant"; + return "Abandoned"; } // Bus Factor (Section 3.2.6) export function computeBusFactor(contributors = []) { - if (!contributors.length) return { factor: 0, risk: 'unknown' } - const total = contributors.reduce((s, c) => s + c.contributions, 0) - if (!total) return { factor: 0, risk: 'unknown' } - let cum = 0 + if (!contributors.length) return { factor: 0, risk: "unknown" }; + const total = contributors.reduce((s, c) => s + c.contributions, 0); + if (!total) return { factor: 0, risk: "unknown" }; + let cum = 0; for (let i = 0; i < contributors.length; i++) { - cum += contributors[i].contributions + cum += contributors[i].contributions; if (cum / total > 0.5) { - const f = i + 1 - return { factor: f, risk: f <= 1 ? 'critical' : f <= 2 ? 'high' : 'healthy' } + const f = i + 1; + return { + factor: f, + risk: f <= 1 ? "critical" : f <= 2 ? "high" : "healthy", + }; } } - return { factor: contributors.length, risk: 'healthy' } + return { factor: contributors.length, risk: "healthy" }; } // Unified Analytical Data Model (Section 3.2.0) // Merges multiple orgs into one normalized graph: // Organization โ†’ Repositories โ†’ Contributors โ†’ Issues/PRs export function buildAnalyticalModel(orgs, reposPerOrg, contribsPerRepo) { - const allRepos = [] - const contributorMap = {} + const allRepos = []; + const contributorMap = {}; - orgs.forEach(org => { - const repos = reposPerOrg[org.login] || [] + orgs.forEach((org) => { + const repos = reposPerOrg[org.login] || []; - repos.forEach(repo => { - const key = `${org.login}/${repo.name}` - const contribs = contribsPerRepo[key] || [] - const health = computeHealthScore(repo, contribs.length) - const lc = computeLifecycle(repo) - const bf = computeBusFactor(contribs) - allRepos.push({ ...repo, orgLogin: org.login, contributors: contribs, healthScore: health, lifecycle: lc, busFactor: bf }) + repos.forEach((repo) => { + const key = `${org.login}/${repo.name}`; + const contribs = contribsPerRepo[key] || []; + const health = computeHealthScore(repo, contribs.length); + const lc = computeLifecycle(repo); + const bf = computeBusFactor(contribs); + allRepos.push({ + ...repo, + orgLogin: org.login, + contributors: contribs, + healthScore: health, + lifecycle: lc, + busFactor: bf, + }); // Build contributor map โ€” deduplicated by login across orgs - contribs.forEach(c => { + contribs.forEach((c) => { if (!contributorMap[c.login]) { contributorMap[c.login] = { login: c.login, @@ -62,109 +72,187 @@ export function buildAnalyticalModel(orgs, reposPerOrg, contribsPerRepo) { repos: [], orgs: new Set(), lastActive: null, - } + }; } - const entry = contributorMap[c.login] - entry.totalContribs += c.contributions - entry.repos.push({ name: repo.name, org: org.login, count: c.contributions }) - entry.orgs.add(org.login) + const entry = contributorMap[c.login]; + entry.totalContribs += c.contributions; + entry.repos.push({ + name: repo.name, + org: org.login, + count: c.contributions, + }); + entry.orgs.add(org.login); if (!entry.lastActive || repo.pushed_at > entry.lastActive) { - entry.lastActive = repo.pushed_at + entry.lastActive = repo.pushed_at; } - }) - }) - }) + }); + }); + }); // Finalize contributors: compute signals - const contributors = Object.values(contributorMap).map(c => ({ - ...c, - orgs: Array.from(c.orgs), - isConnector: c.repos.length >= 3, - isCrossOrg: c.orgs.size > 1, - freshness: c.lastActive - ? Math.max(0, 100 - (Date.now() - new Date(c.lastActive)) / 86_400_000) - : 0, - })).sort((a, b) => b.totalContribs - a.totalContribs) + const contributors = Object.values(contributorMap) + .map((c) => ({ + ...c, + orgs: Array.from(c.orgs), + isConnector: c.repos.length >= 3, + isCrossOrg: c.orgs.size > 1, + freshness: c.lastActive + ? Math.max(0, 100 - (Date.now() - new Date(c.lastActive)) / 86_400_000) + : 0, + })) + .sort((a, b) => b.totalContribs - a.totalContribs); // Graph is constructed here and persisted through cache layers (Section 3.2.0) - return { allRepos, contributors } + return { allRepos, contributors }; } // Time-Series Bucketing (Section 3.2.9) // Parses created_at, closed_at, merged_at into weekly/monthly bins -export function buildTimeSeries(issues = [], granularity = 'monthly') { - const buckets = {} - - const toKey = dateStr => { - if (!dateStr) return null - const d = new Date(dateStr) - if (granularity === 'weekly') { - const jan1 = new Date(d.getFullYear(), 0, 1) - const week = Math.ceil(((d - jan1) / 86_400_000 + jan1.getDay() + 1) / 7) - return `${d.getFullYear()}-W${String(week).padStart(2, '0')}` +export function buildTimeSeries(issues = [], granularity = "monthly") { + const buckets = {}; + + const toKey = (dateStr) => { + if (!dateStr) return null; + const d = new Date(dateStr); + if (granularity === "weekly") { + const jan1 = new Date(d.getFullYear(), 0, 1); + const week = Math.ceil(((d - jan1) / 86_400_000 + jan1.getDay() + 1) / 7); + return `${d.getFullYear()}-W${String(week).padStart(2, "0")}`; } - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` - } + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; + }; - const ensure = key => { + const ensure = (key) => { if (!buckets[key]) { - buckets[key] = { date: key, prs_created: 0, prs_merged: 0, prs_closed: 0, issues_created: 0, issues_closed: 0 } + buckets[key] = { + date: key, + prs_created: 0, + prs_merged: 0, + prs_closed: 0, + issues_created: 0, + issues_closed: 0, + }; } - } + }; - issues.forEach(item => { - const isPR = Boolean(item.pull_request) + issues.forEach((item) => { + const isPR = Boolean(item.pull_request); - const ck = toKey(item.created_at) + const ck = toKey(item.created_at); if (ck) { - ensure(ck) - if (isPR) buckets[ck].prs_created++ - else buckets[ck].issues_created++ + ensure(ck); + if (isPR) buckets[ck].prs_created++; + else buckets[ck].issues_created++; } if (item.closed_at) { - const xk = toKey(item.closed_at) + const xk = toKey(item.closed_at); if (xk) { - ensure(xk) - if (isPR) buckets[xk].prs_closed++ - else buckets[xk].issues_closed++ + ensure(xk); + if (isPR) buckets[xk].prs_closed++; + else buckets[xk].issues_closed++; } } if (isPR && item.pull_request?.merged_at) { - const mk = toKey(item.pull_request.merged_at) - if (mk) { ensure(mk); buckets[mk].prs_merged++ } + const mk = toKey(item.pull_request.merged_at); + if (mk) { + ensure(mk); + buckets[mk].prs_merged++; + } } - }) + }); return Object.values(buckets) .sort((a, b) => a.date.localeCompare(b.date)) - .slice(-12) + .slice(-12); } // CSV Export (Section 3.2.9) -function download(content, filename, type = 'text/csv') { - const blob = new Blob([content], { type }) - const url = URL.createObjectURL(blob) - const a = Object.assign(document.createElement('a'), { href: url, download: filename }) - a.click() - URL.revokeObjectURL(url) +function download(content, filename, type = "text/csv") { + const blob = new Blob([content], { type }); + const url = URL.createObjectURL(blob); + const a = Object.assign(document.createElement("a"), { + href: url, + download: filename, + }); + a.click(); + URL.revokeObjectURL(url); } export function exportReposCSV(repos) { - const header = ['Repository','Org','Stars','Forks','Open Issues','Health Score','Lifecycle','Language','Last Active'] - const rows = repos.map(r => [r.name, r.orgLogin, r.stargazers_count, r.forks_count, r.open_issues_count, r.healthScore, r.lifecycle, r.language || 'N/A', r.pushed_at?.slice(0, 10)]) - download([header, ...rows].map(r => r.join(',')).join('\n'), 'orgexplorer-repos.csv') + const header = [ + "Repository", + "Org", + "Stars", + "Forks", + "Open Issues", + "Health Score", + "Lifecycle", + "Language", + "Last Active", + ]; + const rows = repos.map((r) => [ + r.name, + r.orgLogin, + r.stargazers_count, + r.forks_count, + r.open_issues_count, + r.healthScore, + r.lifecycle, + r.language || "N/A", + r.pushed_at?.slice(0, 10), + ]); + download( + [header, ...rows].map((r) => r.join(",")).join("\n"), + "orgexplorer-repos.csv" + ); } export function exportContributorsCSV(contributors) { - const header = ['Login','Total Contributions','Repos','Orgs','Last Active','Connector','Cross-Org'] - const rows = contributors.map(c => [c.login, c.totalContribs, c.repos.length, c.orgs.length, c.lastActive?.slice(0, 10) || '', c.isConnector, c.isCrossOrg]) - download([header, ...rows].map(r => r.join(',')).join('\n'), 'orgexplorer-contributors.csv') + const header = [ + "Login", + "Total Contributions", + "Repos", + "Orgs", + "Last Active", + "Connector", + "Cross-Org", + ]; + const rows = contributors.map((c) => [ + c.login, + c.totalContribs, + c.repos.length, + c.orgs.length, + c.lastActive?.slice(0, 10) || "", + c.isConnector, + c.isCrossOrg, + ]); + download( + [header, ...rows].map((r) => r.join(",")).join("\n"), + "orgexplorer-contributors.csv" + ); } export function exportTrendsCSV(series) { - const header = ['Date','PRs Created','PRs Merged','PRs Closed','Issues Created','Issues Closed'] - const rows = series.map(s => [s.date, s.prs_created, s.prs_merged, s.prs_closed, s.issues_created, s.issues_closed]) - download([header, ...rows].map(r => r.join(',')).join('\n'), 'orgexplorer-trends.csv') + const header = [ + "Date", + "PRs Created", + "PRs Merged", + "PRs Closed", + "Issues Created", + "Issues Closed", + ]; + const rows = series.map((s) => [ + s.date, + s.prs_created, + s.prs_merged, + s.prs_closed, + s.issues_created, + s.issues_closed, + ]); + download( + [header, ...rows].map((r) => r.join(",")).join("\n"), + "orgexplorer-trends.csv" + ); } diff --git a/src/services/github.js b/src/services/github.js index a77f4fd..03338ce 100644 --- a/src/services/github.js +++ b/src/services/github.js @@ -1,112 +1,127 @@ -// IndexedDB Cache (L2) -const DB_NAME = 'orgexplorer_v2' -const STORE = 'cache' -const TTL_MS = 3_600_000 // 1 hour +// IndexedDB Cache (L2) +const DB_NAME = "orgexplorer_v2"; +const STORE = "cache"; +const TTL_MS = 3_600_000; // 1 hour function openDB() { return new Promise((resolve, reject) => { - const req = indexedDB.open(DB_NAME, 1) - req.onupgradeneeded = e => e.target.result.createObjectStore(STORE, { keyPath: 'k' }) - req.onsuccess = e => resolve(e.target.result) - req.onerror = () => reject(req.error) - }) + const req = indexedDB.open(DB_NAME, 1); + req.onupgradeneeded = (e) => + e.target.result.createObjectStore(STORE, { keyPath: "k" }); + req.onsuccess = (e) => resolve(e.target.result); + req.onerror = () => reject(req.error); + }); } export async function cacheGet(key) { try { - const db = await openDB() - return new Promise(res => { - const req = db.transaction(STORE, 'readonly').objectStore(STORE).get(key) + const db = await openDB(); + return new Promise((res) => { + const req = db.transaction(STORE, "readonly").objectStore(STORE).get(key); req.onsuccess = () => { - const r = req.result - if (!r || Date.now() - r.ts > TTL_MS) return res(null) - res(r.v) - } - req.onerror = () => res(null) - }) - } catch { return null } + const r = req.result; + if (!r || Date.now() - r.ts > TTL_MS) return res(null); + res(r.v); + }; + req.onerror = () => res(null); + }); + } catch { + return null; + } } export async function cacheSet(key, value) { try { - const db = await openDB() - return new Promise(res => { - const tx = db.transaction(STORE, 'readwrite') - tx.objectStore(STORE).put({ k: key, v: value, ts: Date.now() }) - tx.oncomplete = () => res(true) - tx.onerror = () => res(false) - }) - } catch { return false } + const db = await openDB(); + return new Promise((res) => { + const tx = db.transaction(STORE, "readwrite"); + tx.objectStore(STORE).put({ k: key, v: value, ts: Date.now() }); + tx.oncomplete = () => res(true); + tx.onerror = () => res(false); + }); + } catch { + return false; + } } export async function cacheClear() { try { - const db = await openDB() - return new Promise(res => { - const tx = db.transaction(STORE, 'readwrite') - tx.objectStore(STORE).clear() - tx.oncomplete = () => res(true) - tx.onerror = () => res(false) - }) - } catch { return false } + const db = await openDB(); + return new Promise((res) => { + const tx = db.transaction(STORE, "readwrite"); + tx.objectStore(STORE).clear(); + tx.oncomplete = () => res(true); + tx.onerror = () => res(false); + }); + } catch { + return false; + } } -// Core fetchWithCache +// Core fetchWithCache async function fetchWithCache(url, pat) { // L2 check - const cached = await cacheGet(url) - if (cached) return cached + const cached = await cacheGet(url); + if (cached) return cached; - const headers = { Accept: 'application/vnd.github.v3+json' } - if (pat) headers.Authorization = `token ${pat}` + const headers = { Accept: "application/vnd.github.v3+json" }; + if (pat) headers.Authorization = `token ${pat}`; - const res = await fetch(url, { headers }) - if (res.status === 403) throw new Error('RATE_LIMIT') - if (res.status === 404) throw new Error('NOT_FOUND') - if (!res.ok) throw new Error(`HTTP_${res.status}`) + const res = await fetch(url, { headers }); + if (res.status === 403) throw new Error("RATE_LIMIT"); + if (res.status === 404) throw new Error("NOT_FOUND"); + if (!res.ok) throw new Error(`HTTP_${res.status}`); - const data = await res.json() - cacheSet(url, data) // write-back, non-blocking - return data + const data = await res.json(); + cacheSet(url, data); // write-back, non-blocking + return data; } // Public service functions export const fetchOrg = (org, pat) => - fetchWithCache(`https://api.github.com/orgs/${org}`, pat) + fetchWithCache(`https://api.github.com/orgs/${org}`, pat); export async function fetchRepos(org, pat) { - const all = [] + const all = []; for (let page = 1; page <= 5; page++) { - const url = `https://api.github.com/orgs/${org}/repos?per_page=100&page=${page}&sort=updated` - const data = await fetchWithCache(url, pat) - all.push(...data) - if (data.length < 100) break + const url = `https://api.github.com/orgs/${org}/repos?per_page=100&page=${page}&sort=updated`; + const data = await fetchWithCache(url, pat); + all.push(...data); + if (data.length < 100) break; } - return all + return all; } export async function fetchContributors(org, repo, pat) { try { return await fetchWithCache( - `https://api.github.com/repos/${org}/${repo}/contributors?per_page=30`, pat - ) - } catch { return [] } + `https://api.github.com/repos/${org}/${repo}/contributors?per_page=30`, + pat + ); + } catch { + return []; + } } export async function fetchIssues(org, repo, pat) { try { return await fetchWithCache( - `https://api.github.com/repos/${org}/${repo}/issues?state=all&per_page=100`, pat - ) - } catch { return [] } + `https://api.github.com/repos/${org}/${repo}/issues?state=all&per_page=100`, + pat + ); + } catch { + return []; + } } export async function fetchRateLimit(pat) { try { - const headers = { Accept: 'application/vnd.github.v3+json' } - if (pat) headers.Authorization = `token ${pat}` - const res = await fetch('https://api.github.com/rate_limit', { headers }) - const data = await res.json() - return data.rate - } catch { return null } + const headers = { Accept: "application/vnd.github.v3+json" }; + if (pat) headers.Authorization = `token ${pat}`; + const res = await fetch("https://api.github.com/rate_limit", { headers }); + const data = await res.json(); + return data.rate; + } catch { + return null; + } } diff --git a/src/styles/global.css b/src/styles/global.css index f7a884f..015c0ee 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1,49 +1,81 @@ -*, *::before, *::after { - box-sizing: border-box; - margin: 0; - padding: 0; -} +@import "tailwindcss"; :root { - --bg: #0d0d0d; - --surface: #141414; + --bg: #000000; + --surface: #141414; --surface2: #1a1a1a; - --border: #2a2a2a; - --accent: #f5c518; - --text: #ffffff; - --text2: #888888; - --text3: #444444; - --green: #22c55e; - --red: #ef4444; - --blue: #3b82f6; - --amber: #f59e0b; - --purple: #a855f7; - --radius: 8px; + --border: #2a2a2a; + --accent: #f5c518; + --text: #ffffff; + --text2: #888888; + --text3: #444444; + --green: #22c55e; + --red: #ef4444; + --blue: #3b82f6; + --amber: #f59e0b; + --purple: #a855f7; + --radius: 8px; } -html, body, #root { +html, +body, +#root { min-height: 100%; background: var(--bg); color: var(--text); - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; } -button { cursor: pointer; font-family: inherit; } -input, select { font-family: inherit; } -a { color: var(--accent); text-decoration: none; } +button { + cursor: pointer; + font-family: inherit; +} +input, +select { + font-family: inherit; +} +a { + color: var(--accent); + text-decoration: none; +} -::-webkit-scrollbar { width: 5px; height: 5px; } -::-webkit-scrollbar-track { background: var(--bg); } -::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } +::-webkit-scrollbar { + width: 5px; + height: 5px; +} +::-webkit-scrollbar-track { + background: var(--bg); +} +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 3px; +} +::selection { + background: rgba(252, 211, 77, 0.9); + color: #000000; +} +::-moz-selection { + background: rgba(252, 211, 77, 0.9); + color: #000000; +} @keyframes spin { - to { transform: rotate(360deg); } + to { + transform: rotate(360deg); + } } @keyframes fadeUp { - from { opacity: 0; transform: translateY(10px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } } .fade-up { animation: fadeUp 0.22s ease; diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..be50349 --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + "paths": { + "@/*": ["./src/*"] + }, + "checkJs": false, + "allowJs": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src/**/*"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..8a67f62 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.js b/vite.config.js deleted file mode 100644 index 9ffcc67..0000000 --- a/vite.config.js +++ /dev/null @@ -1,6 +0,0 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' - -export default defineConfig({ - plugins: [react()], -}) diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..d8266bb --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; +import path from "path"; + +export default defineConfig(({ mode }) => ({ + plugins: [react(), tailwindcss()], + + base: mode === "production" ? "/OrgExplorer/" : "/", + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +}));