├── .github ├── release.yml └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .lintstagedrc ├── .prettierignore ├── LICENSE ├── PRIVACY.md ├── README.md ├── assets ├── icon.png └── tailwind.css ├── commitlint.config.ts ├── components └── ui │ ├── label.tsx │ ├── select.tsx │ └── switch.tsx ├── entrypoints ├── background.ts ├── content │ ├── app.tsx │ ├── components │ │ ├── AssignmentFilter.tsx │ │ ├── animations │ │ │ └── index.ts │ │ ├── styled │ │ │ ├── Assignment.tsx │ │ │ ├── Badges.tsx │ │ │ ├── Button.tsx │ │ │ ├── Class.tsx │ │ │ ├── Dropdown.tsx │ │ │ ├── Modal.tsx │ │ │ └── Typography.tsx │ │ └── themes │ │ │ └── index.ts │ └── index.tsx ├── options │ ├── index.html │ └── main.tsx └── popup │ ├── app.tsx │ ├── index.html │ └── main.tsx ├── eslint.config.mjs ├── package.json ├── pnpm-lock.yaml ├── prettier.config.mjs ├── tsconfig.json ├── types └── index.ts ├── utils ├── cn.ts ├── getAllClassInfo.ts ├── getAssignmentsEachClass.ts └── getUserId.ts └── wxt.config.ts /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - github-actions 5 | - duplicate 6 | - wontfix 7 | - invalid 8 | - question 9 | - release 10 | categories: 11 | - title: 💥 Breaking Changes 12 | labels: 13 | - breaking-change 14 | - breaking 15 | - major 16 | - title: ✨ New Features 17 | labels: 18 | - enhancement 19 | - feature 20 | - feat 21 | - title: 🐛 Bug Fixes 22 | labels: 23 | - bug 24 | - bugfix 25 | - fix 26 | - patch 27 | - title: 🔧 Maintenance 28 | labels: 29 | - chore 30 | - dependencies 31 | - maintenance 32 | - refactor 33 | - perf 34 | - build 35 | - ci 36 | - title: 📚 Documentation 37 | labels: 38 | - documentation 39 | - docs 40 | - title: 🧪 Tests 41 | labels: 42 | - test 43 | - tests 44 | - title: 🎨 Styles 45 | labels: 46 | - style 47 | - ui 48 | - ux 49 | - title: 🔄 Other Changes 50 | labels: 51 | - "*" 52 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | commitlint: 7 | name: Commitlint 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | with: 12 | fetch-depth: 0 13 | 14 | - name: Setup pnpm 15 | uses: pnpm/action-setup@v4 16 | with: 17 | version: 9 18 | 19 | - name: Setup Node.js 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: "20" 23 | cache: "pnpm" 24 | 25 | - name: Install dependencies 26 | run: pnpm install 27 | 28 | - name: Validate current commit (last commit) with commitlint 29 | if: github.event_name == 'push' 30 | run: pnpm commitlint --last --verbose 31 | 32 | - name: Validate PR commits with commitlint 33 | if: github.event_name == 'pull_request' 34 | run: pnpm commitlint --from ${{ github.event.pull_request.head.sha }}~${{ github.event.pull_request.commits }} --to ${{ github.event.pull_request.head.sha }} --verbose 35 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | permissions: 4 | contents: write 5 | pull-requests: write 6 | 7 | on: 8 | workflow_dispatch: 9 | inputs: 10 | version_type: 11 | description: "Version type to bump" 12 | required: true 13 | type: choice 14 | options: 15 | - patch 16 | - minor 17 | - major 18 | default: "patch" 19 | dry_run: 20 | description: "Dry run" 21 | required: false 22 | type: boolean 23 | default: false 24 | 25 | jobs: 26 | release: 27 | name: Build and Release 28 | runs-on: ubuntu-latest 29 | 30 | steps: 31 | - name: Checkout repository 32 | uses: actions/checkout@v4 33 | with: 34 | fetch-depth: 0 35 | 36 | - name: Configure Git 37 | run: | 38 | git config user.name 'github-actions[bot]' 39 | git config user.email 'github-actions[bot]@users.noreply.github.com' 40 | 41 | - name: Setup pnpm 42 | uses: pnpm/action-setup@v4 43 | with: 44 | version: 9 45 | 46 | - name: Setup Node.js 47 | uses: actions/setup-node@v4 48 | with: 49 | node-version: "20" 50 | cache: "pnpm" 51 | 52 | - name: Install dependencies 53 | run: pnpm install 54 | 55 | - name: Bump version 56 | id: bump 57 | run: | 58 | # Get the current version from package.json 59 | current_version=$(node -p "require('./package.json').version") 60 | echo "Current version: $current_version" 61 | 62 | # Split version into components 63 | IFS='.' read -r major minor patch <<< "$current_version" 64 | 65 | # Bump version based on input 66 | case "${{ github.event.inputs.version_type }}" in 67 | "major") new_version="$((major + 1)).0.0" ;; 68 | "minor") new_version="${major}.$((minor + 1)).0" ;; 69 | "patch") new_version="${major}.${minor}.$((patch + 1))" ;; 70 | esac 71 | 72 | # Update package.json 73 | node -e " 74 | const fs = require('fs'); 75 | const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8')); 76 | pkg.version = '$new_version'; 77 | fs.writeFileSync('./package.json', JSON.stringify(pkg, null, 2) + '\n'); 78 | " 79 | 80 | echo "new_version=$new_version" >> $GITHUB_OUTPUT 81 | echo "Version bumped from $current_version to $new_version" 82 | 83 | - name: Create release branch 84 | run: | 85 | git checkout -b release/${{ github.event.inputs.version_type }}-bump 86 | 87 | - name: Commit version bump 88 | run: | 89 | git add package.json 90 | git commit -m "chore(release): v${{ steps.bump.outputs.new_version }}" 91 | git push origin release/${{ github.event.inputs.version_type }}-bump 92 | 93 | - name: Create ZIP files 94 | run: pnpm zip:all 95 | 96 | - name: Create Pull Request 97 | run: | 98 | gh pr create --title "chore(release): v${{ steps.bump.outputs.new_version }}" --body "🚀 Automated release PR for version v${{ steps.bump.outputs.new_version }}" --base main --head release/${{ github.event.inputs.version_type }}-bump --label release 99 | env: 100 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 101 | 102 | - name: Auto-merge Pull Request 103 | run: | 104 | gh pr merge release/${{ github.event.inputs.version_type }}-bump --squash --auto --delete-branch 105 | env: 106 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 107 | 108 | - name: Submit to Chrome Web Store 109 | if: ${{ !inputs.dry_run }} 110 | run: | 111 | pnpm wxt submit \ 112 | --chrome-zip .output/*-chrome.zip 113 | env: 114 | CHROME_EXTENSION_ID: ${{ secrets.CHROME_EXTENSION_ID }} 115 | CHROME_CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }} 116 | CHROME_CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }} 117 | CHROME_REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }} 118 | 119 | # - name: Submit to Firefox Add-ons 120 | # if: ${{ !inputs.dry_run }} 121 | # run: | 122 | # pnpm wxt submit \ 123 | # --firefox-zip .output/*-firefox.zip \ 124 | # --firefox-sources-zip .output/*-sources.zip 125 | # env: 126 | # FIREFOX_EXTENSION_ID: ${{ secrets.FIREFOX_EXTENSION_ID }} 127 | # FIREFOX_JWT_ISSUER: ${{ secrets.FIREFOX_JWT_ISSUER }} 128 | # FIREFOX_JWT_SECRET: ${{ secrets.FIREFOX_JWT_SECRET }} 129 | 130 | - name: Create GitHub Release 131 | uses: softprops/action-gh-release@v2 132 | with: 133 | files: | 134 | .output/*-chrome.zip 135 | .output/*-firefox.zip 136 | generate_release_notes: true 137 | tag_name: v${{ steps.bump.outputs.new_version }} 138 | draft: false 139 | prerelease: false 140 | env: 141 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 142 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .output 12 | stats.html 13 | stats-*.json 14 | .wxt 15 | web-ext.config.ts 16 | 17 | # Editor directories and files 18 | .vscode/* 19 | !.vscode/extensions.json 20 | .idea 21 | .DS_Store 22 | *.suo 23 | *.ntvs* 24 | *.njsproj 25 | *.sln 26 | *.sw? 27 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | pnpm dlx commitlint --edit "$1" -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpm lint-staged -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.{js,jsx,ts,tsx}": ["eslint --fix", "prettier --write"], 3 | "*.{json,css,md,html,yml}": ["prettier --write"] 4 | } 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | pnpm-lock.yaml -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Theeraphat Jaingam 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /PRIVACY.md: -------------------------------------------------------------------------------- 1 | # นโยบายความเป็นส่วนตัว - AssignWatch Extension 2 | 3 | ## ข้อมูลที่เราเก็บรวบรวม 4 | 5 | AssignWatch Extension เก็บรวบรวมข้อมูลต่อไปนี้เท่านั้น: 6 | 7 | 1. **การตั้งค่าผู้ใช้งาน** 8 | - การตั้งค่าธีม (โหมดมืด/สว่าง) 9 | - รูปแบบการแสดงผล (grid/list) 10 | - การตั้งค่าการแจ้งเตือน 11 | - รายการวิชาที่ซ่อน 12 | 13 | ## วิธีการใช้ข้อมูล 14 | 15 | ข้อมูลทั้งหมดถูกใช้เพื่อ: 16 | 17 | - แสดงผลงานที่ได้รับมอบหมายในรูปแบบที่ผู้ใช้ต้องการ 18 | - จัดการการแจ้งเตือนกำหนดส่งงาน 19 | - บันทึกการตั้งค่าส่วนบุคคลของผู้ใช้ 20 | 21 | ## การจัดเก็บข้อมูล 22 | 23 | - ข้อมูลทั้งหมดถูกจัดเก็บในเครื่องของผู้ใช้เท่านั้น ผ่าน browser storage 24 | - ไม่มีการส่งข้อมูลไปยังเซิร์ฟเวอร์ภายนอก 25 | - ข้อมูลจะถูกลบทันทีเมื่อถอนการติดตั้ง extension 26 | 27 | ## การอนุญาตที่จำเป็น 28 | 29 | Extension นี้ขอสิทธิ์การเข้าถึงดังนี้: 30 | 31 | - `storage`: เพื่อบันทึกการตั้งค่าผู้ใช้ 32 | - `notifications`: เพื่อแจ้งเตือนกำหนดส่งงาน (เมื่อเปิดใช้งาน) 33 | - `alarms`: เพื่อจัดการการแจ้งเตือนกำหนดส่งงาน 34 | 35 | ## การแบ่งปันข้อมูล 36 | 37 | - เราไม่แบ่งปัน เก็บ หรือขายข้อมูลของผู้ใช้ให้กับบุคคลที่สาม 38 | - ข้อมูลทั้งหมดอยู่ในเครื่องของผู้ใช้เท่านั้น 39 | 40 | ## การติดต่อ 41 | 42 | หากมีข้อสงสัยเกี่ยวกับนโยบายความเป็นส่วนตัว สามารถติดต่อได้ที่: 43 | 44 | - GitHub Issues: [https://github.com/3raphat/assign-watch/issues](https://github.com/3raphat/assign-watch/issues) 45 | 46 | อัปเดตล่าสุด: 15/12/2024 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Assign Watch - Extension for LEB2 2 | 3 | [![Chrome Web Store](https://img.shields.io/chrome-web-store/v/dedhfmakhbgeopgdipofgooiibkanfad)](https://chromewebstore.google.com/detail/dedhfmakhbgeopgdipofgooiibkanfad) 4 | [![GitHub Release](https://img.shields.io/github/v/release/3raphat/assign-watch)](https://github.com/3raphat/assign-watch/releases/latest) 5 | [![License](https://img.shields.io/github/license/3raphat/assign-watch)](LICENSE) 6 | [![GitHub Stars](https://img.shields.io/github/stars/3raphat/assign-watch)](https://github.com/3raphat/assign-watch/stargazers) 7 | 8 | A browser extension that enhances your LEB2 experience by providing a convenient way to view and manage all your assignments in one place. 9 | 10 | > [!NOTE] 11 | > LEB2 ย่อมาจาก Learning Environment version B2 เป็นแพลตฟอร์มด้านการศึกษาสำหรับการเรียนการสอนออนไลน์ ถูกสร้างและพัฒนาขึ้นโดยหน่วยงานพัฒนาและบูรณาการเทคโนโลยีเพื่อการศึกษา (ETS) ของมหาวิทยาลัยเทคโนโลยีพระจอมเกล้าธนบุรี (มจธ.) หรือ KMUTT 12 | > [Learn More](https://www.leb2.org/what-is-leb2) 13 | 14 | ## Features ✨ 15 | 16 | - 🔍 **Unified View**: Access all assignments across classes in a single modal 17 | - ⚡ **Quick Access**: Instantly open with keyboard shortcut (Alt/Option + A) 18 | - 📱 **Flexible Layout**: Toggle between grid and list views for optimal viewing 19 | - 🌓 **Theme Support**: Seamless dark/light mode integration 20 | - 🔔 **Notifications**: Stay updated with assignment alerts 21 | - 🎯 **Advanced Filtering**: Easy filtering of assignments by class 22 | - 📊 **Status Tracking**: Clear submission status indicators 23 | - 📅 **Due Date Management**: Countdown timers for upcoming deadlines 24 | - 🔒 **Privacy First**: All data stored locally for maximum privacy 25 | 26 | ## Installation 🚀 27 | 28 | ### Chrome Web Store 29 | 30 | 1. Visit the [Chrome Web Store](https://chromewebstore.google.com/detail/dedhfmakhbgeopgdipofgooiibkanfad) 31 | 2. Click "Add to Chrome" 32 | 3. Click "Add extension" in the popup 33 | 34 | ### Manual Installation 35 | 36 | 1. Download the latest release from our [Releases](https://github.com/3raphat/assign-watch/releases) page 37 | 2. Extract the downloaded ZIP file (for chrome) 38 | 39 | #### Chrome 40 | 41 | 1. Navigate to `chrome://extensions` 42 | 2. Enable "Developer mode" (top-right) 43 | 3. Click "Load unpacked" and select the extracted folder 44 | 45 | #### Firefox 46 | 47 | 1. Go to `about:debugging#/runtime/this-firefox` 48 | 2. Click "Load Temporary Add-on..." and select the ZIP file 49 | 50 | > [!WARNING] 51 | > Installing this way will only work until the browser is restarted. For a permanent installation, use the [Firefox Developer Edition](https://www.mozilla.org/en-US/firefox/developer) instead. 52 | 53 | #### Firefox Developer Edition 54 | 55 | 1. Go to `about:config` 56 | 2. Set `xpinstall.signatures.required` to `false` 57 | 3. Go to `about:addons` 58 | 4. Click the gear icon and select "Install Add-on From File..." and select the ZIP file 59 | 60 | _This reportedly works with [Firefox Extended Support Release](https://www.mozilla.org/en-US/firefox/enterprise) and [Nightly](https://www.mozilla.org/en-US/firefox/channel/desktop) as well._ 61 | 62 | ## Development 🛠️ 63 | 64 | 1. Clone the repository 65 | 66 | ```bash 67 | git clone https://github.com/3raphat/assign-watch.git 68 | cd assign-watch 69 | ``` 70 | 71 | 2. Install dependencies 72 | 73 | ```bash 74 | pnpm install 75 | ``` 76 | 77 | 3. Start development server 78 | 79 | ```bash 80 | pnpm dev # For Chrome 81 | # or 82 | pnpm dev:firefox # For Firefox 83 | ``` 84 | 85 | 4. Load the extension 86 | - For Chrome: 87 | 1. Go to `chrome://extensions` 88 | 2. Enable "Developer mode" 89 | 3. Click "Load unpacked" and select the `.output/chrome-mv3` directory 90 | - For Firefox: 91 | 1. Go to `about:debugging#/runtime/this-firefox` 92 | 2. Click "Load Temporary Add-on..." 93 | 3. Select the `manifest.json` file from `.output/firefox-mv2` directory 94 | 95 | ## Contributing 🤝 96 | 97 | Contributions are welcome! Please feel free to submit a Pull Request. 98 | 99 | 1. Fork the repository 100 | 2. Create your feature branch 101 | 102 | ```bash 103 | git checkout -b feat/amazing-feature 104 | ``` 105 | 106 | 3. Commit your changes using [Conventional Commits](https://www.conventionalcommits.org) 107 | 108 | ```bash 109 | git commit -m 'feat: add amazing new feature' 110 | ``` 111 | 112 | 4. Push to your branch 113 | 114 | ```bash 115 | git push origin feat/amazing-feature 116 | ``` 117 | 118 | 5. Open a Pull Request 119 | 120 | ## License 📝 121 | 122 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 123 | 124 | ## Support the Project 💝 125 | 126 | If you find Assign Watch valuable, consider: 127 | 128 | - ⭐ Starring the repository 129 | - 🐛 Reporting bugs or suggesting features 130 | - 💻 Contributing code improvements 131 | - 📢 Sharing with your classmates 132 | - 📝 Writing documentation or tutorials 133 | 134 | Your support helps make Assign Watch better for everyone! 135 | -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3raphat/assign-watch/798c8c9b0ae3a23fc338232d4df24bbf61788482/assets/icon.png -------------------------------------------------------------------------------- /assets/tailwind.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @import "tw-animate-css"; 3 | 4 | @custom-variant dark (&:is(.dark *)); 5 | 6 | :root { 7 | --background: oklch(1 0 0); 8 | --foreground: oklch(0.145 0 0); 9 | --card: oklch(1 0 0); 10 | --card-foreground: oklch(0.145 0 0); 11 | --popover: oklch(1 0 0); 12 | --popover-foreground: oklch(0.145 0 0); 13 | --primary: oklch(0.205 0 0); 14 | --primary-foreground: oklch(0.985 0 0); 15 | --secondary: oklch(0.97 0 0); 16 | --secondary-foreground: oklch(0.205 0 0); 17 | --muted: oklch(0.97 0 0); 18 | --muted-foreground: oklch(0.556 0 0); 19 | --accent: oklch(0.97 0 0); 20 | --accent-foreground: oklch(0.205 0 0); 21 | --destructive: oklch(0.577 0.245 27.325); 22 | --destructive-foreground: oklch(0.577 0.245 27.325); 23 | --border: oklch(0.922 0 0); 24 | --input: oklch(0.922 0 0); 25 | --ring: oklch(0.708 0 0); 26 | --chart-1: oklch(0.646 0.222 41.116); 27 | --chart-2: oklch(0.6 0.118 184.704); 28 | --chart-3: oklch(0.398 0.07 227.392); 29 | --chart-4: oklch(0.828 0.189 84.429); 30 | --chart-5: oklch(0.769 0.188 70.08); 31 | --radius: 0.625rem; 32 | --sidebar: oklch(0.985 0 0); 33 | --sidebar-foreground: oklch(0.145 0 0); 34 | --sidebar-primary: oklch(0.205 0 0); 35 | --sidebar-primary-foreground: oklch(0.985 0 0); 36 | --sidebar-accent: oklch(0.97 0 0); 37 | --sidebar-accent-foreground: oklch(0.205 0 0); 38 | --sidebar-border: oklch(0.922 0 0); 39 | --sidebar-ring: oklch(0.708 0 0); 40 | } 41 | 42 | .dark { 43 | --background: oklch(0.145 0 0); 44 | --foreground: oklch(0.985 0 0); 45 | --card: oklch(0.145 0 0); 46 | --card-foreground: oklch(0.985 0 0); 47 | --popover: oklch(0.145 0 0); 48 | --popover-foreground: oklch(0.985 0 0); 49 | --primary: oklch(0.985 0 0); 50 | --primary-foreground: oklch(0.205 0 0); 51 | --secondary: oklch(0.269 0 0); 52 | --secondary-foreground: oklch(0.985 0 0); 53 | --muted: oklch(0.269 0 0); 54 | --muted-foreground: oklch(0.708 0 0); 55 | --accent: oklch(0.269 0 0); 56 | --accent-foreground: oklch(0.985 0 0); 57 | --destructive: oklch(0.396 0.141 25.723); 58 | --destructive-foreground: oklch(0.637 0.237 25.331); 59 | --border: oklch(0.269 0 0); 60 | --input: oklch(0.269 0 0); 61 | --ring: oklch(0.439 0 0); 62 | --chart-1: oklch(0.488 0.243 264.376); 63 | --chart-2: oklch(0.696 0.17 162.48); 64 | --chart-3: oklch(0.769 0.188 70.08); 65 | --chart-4: oklch(0.627 0.265 303.9); 66 | --chart-5: oklch(0.645 0.246 16.439); 67 | --sidebar: oklch(0.205 0 0); 68 | --sidebar-foreground: oklch(0.985 0 0); 69 | --sidebar-primary: oklch(0.488 0.243 264.376); 70 | --sidebar-primary-foreground: oklch(0.985 0 0); 71 | --sidebar-accent: oklch(0.269 0 0); 72 | --sidebar-accent-foreground: oklch(0.985 0 0); 73 | --sidebar-border: oklch(0.269 0 0); 74 | --sidebar-ring: oklch(0.439 0 0); 75 | } 76 | 77 | @theme inline { 78 | --color-background: var(--background); 79 | --color-foreground: var(--foreground); 80 | --color-card: var(--card); 81 | --color-card-foreground: var(--card-foreground); 82 | --color-popover: var(--popover); 83 | --color-popover-foreground: var(--popover-foreground); 84 | --color-primary: var(--primary); 85 | --color-primary-foreground: var(--primary-foreground); 86 | --color-secondary: var(--secondary); 87 | --color-secondary-foreground: var(--secondary-foreground); 88 | --color-muted: var(--muted); 89 | --color-muted-foreground: var(--muted-foreground); 90 | --color-accent: var(--accent); 91 | --color-accent-foreground: var(--accent-foreground); 92 | --color-destructive: var(--destructive); 93 | --color-destructive-foreground: var(--destructive-foreground); 94 | --color-border: var(--border); 95 | --color-input: var(--input); 96 | --color-ring: var(--ring); 97 | --color-chart-1: var(--chart-1); 98 | --color-chart-2: var(--chart-2); 99 | --color-chart-3: var(--chart-3); 100 | --color-chart-4: var(--chart-4); 101 | --color-chart-5: var(--chart-5); 102 | --radius-sm: calc(var(--radius) - 4px); 103 | --radius-md: calc(var(--radius) - 2px); 104 | --radius-lg: var(--radius); 105 | --radius-xl: calc(var(--radius) + 4px); 106 | --color-sidebar: var(--sidebar); 107 | --color-sidebar-foreground: var(--sidebar-foreground); 108 | --color-sidebar-primary: var(--sidebar-primary); 109 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); 110 | --color-sidebar-accent: var(--sidebar-accent); 111 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); 112 | --color-sidebar-border: var(--sidebar-border); 113 | --color-sidebar-ring: var(--sidebar-ring); 114 | } 115 | 116 | @layer base { 117 | * { 118 | @apply border-border outline-ring/50; 119 | } 120 | body { 121 | @apply bg-background text-foreground font-sans antialiased; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /commitlint.config.ts: -------------------------------------------------------------------------------- 1 | import type { UserConfig } from "@commitlint/types" 2 | 3 | const config: UserConfig = { 4 | extends: ["@commitlint/config-conventional"], 5 | parserPreset: "conventional-changelog-conventionalcommits", 6 | formatter: "@commitlint/format", 7 | rules: { 8 | "type-enum": [ 9 | 2, 10 | "always", 11 | [ 12 | "build", 13 | "chore", 14 | "ci", 15 | "docs", 16 | "feat", 17 | "fix", 18 | "perf", 19 | "refactor", 20 | "revert", 21 | "style", 22 | "test", 23 | ], 24 | ], 25 | }, 26 | } 27 | 28 | export default config 29 | -------------------------------------------------------------------------------- /components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as LabelPrimitive from "@radix-ui/react-label" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/utils/cn" 6 | 7 | const labelVariants = cva( 8 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 9 | ) 10 | 11 | const Label = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef & 14 | VariantProps 15 | >(({ className, ...props }, ref) => ( 16 | 21 | )) 22 | Label.displayName = LabelPrimitive.Root.displayName 23 | 24 | export { Label } 25 | -------------------------------------------------------------------------------- /components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as SelectPrimitive from "@radix-ui/react-select" 3 | import { Check, ChevronDown, ChevronUp } from "lucide-react" 4 | 5 | import { cn } from "@/utils/cn" 6 | 7 | const Select = SelectPrimitive.Root 8 | 9 | const SelectGroup = SelectPrimitive.Group 10 | 11 | const SelectValue = SelectPrimitive.Value 12 | 13 | const SelectTrigger = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef 16 | >(({ className, children, ...props }, ref) => ( 17 | span]:line-clamp-1", 21 | className 22 | )} 23 | {...props} 24 | > 25 | {children} 26 | 27 | 28 | 29 | 30 | )) 31 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName 32 | 33 | const SelectScrollUpButton = React.forwardRef< 34 | React.ElementRef, 35 | React.ComponentPropsWithoutRef 36 | >(({ className, ...props }, ref) => ( 37 | 45 | 46 | 47 | )) 48 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName 49 | 50 | const SelectScrollDownButton = React.forwardRef< 51 | React.ElementRef, 52 | React.ComponentPropsWithoutRef 53 | >(({ className, ...props }, ref) => ( 54 | 62 | 63 | 64 | )) 65 | SelectScrollDownButton.displayName = 66 | SelectPrimitive.ScrollDownButton.displayName 67 | 68 | const SelectContent = React.forwardRef< 69 | React.ElementRef, 70 | React.ComponentPropsWithoutRef 71 | >(({ className, children, position = "popper", ...props }, ref) => ( 72 | 73 | 84 | 85 | 92 | {children} 93 | 94 | 95 | 96 | 97 | )) 98 | SelectContent.displayName = SelectPrimitive.Content.displayName 99 | 100 | const SelectLabel = React.forwardRef< 101 | React.ElementRef, 102 | React.ComponentPropsWithoutRef 103 | >(({ className, ...props }, ref) => ( 104 | 109 | )) 110 | SelectLabel.displayName = SelectPrimitive.Label.displayName 111 | 112 | const SelectItem = React.forwardRef< 113 | React.ElementRef, 114 | React.ComponentPropsWithoutRef 115 | >(({ className, children, ...props }, ref) => ( 116 | 124 | 125 | 126 | 127 | 128 | 129 | {children} 130 | 131 | )) 132 | SelectItem.displayName = SelectPrimitive.Item.displayName 133 | 134 | const SelectSeparator = React.forwardRef< 135 | React.ElementRef, 136 | React.ComponentPropsWithoutRef 137 | >(({ className, ...props }, ref) => ( 138 | 143 | )) 144 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName 145 | 146 | export { 147 | Select, 148 | SelectGroup, 149 | SelectValue, 150 | SelectTrigger, 151 | SelectContent, 152 | SelectLabel, 153 | SelectItem, 154 | SelectSeparator, 155 | SelectScrollUpButton, 156 | SelectScrollDownButton, 157 | } 158 | -------------------------------------------------------------------------------- /components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as SwitchPrimitives from "@radix-ui/react-switch" 3 | 4 | import { cn } from "@/utils/cn" 5 | 6 | const Switch = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | 23 | 24 | )) 25 | Switch.displayName = SwitchPrimitives.Root.displayName 26 | 27 | export { Switch } 28 | -------------------------------------------------------------------------------- /entrypoints/background.ts: -------------------------------------------------------------------------------- 1 | import { storage } from "#imports" 2 | import dayjs from "dayjs" 3 | 4 | import type { AssignmentResponse } from "@/types" 5 | 6 | export default defineBackground(() => { 7 | async function checkUpcomingAssignments() { 8 | try { 9 | const [enabled, reminderTime] = await Promise.all([ 10 | storage.getItem("sync:notifications:enabled"), 11 | storage.getItem("sync:notifications:reminderTime"), 12 | ]) 13 | 14 | if (!enabled) return 15 | 16 | const now = dayjs() 17 | const reminderHours = parseInt(reminderTime || "72") 18 | const reminderThreshold = now.add(reminderHours, "hour") 19 | 20 | const classWithAssignments = await storage.getItem< 21 | { 22 | assignments: AssignmentResponse 23 | id: string 24 | title: string | null 25 | description: string | null 26 | }[] 27 | >("local:classWithAssignments") 28 | 29 | if (!classWithAssignments?.length) return 30 | 31 | for (const classInfo of classWithAssignments) { 32 | const assignments = classInfo.assignments?.activities || [] 33 | if (!assignments.length) continue 34 | 35 | for (const assignment of assignments) { 36 | if (!assignment.due_date) continue 37 | 38 | const dueDate = dayjs(assignment.due_date) 39 | const notificationKey = `notified:${assignment.id}` 40 | const lastNotifyKey = `lastNotify:${assignment.id}` 41 | 42 | if ( 43 | dueDate.isBefore(now) || 44 | assignment.quiz_submission_is_submitted === 1 45 | ) { 46 | await storage.removeItems([ 47 | `local:${notificationKey}`, 48 | `local:${lastNotifyKey}`, 49 | ]) 50 | continue 51 | } 52 | 53 | if (dueDate.isAfter(reminderThreshold)) { 54 | continue 55 | } 56 | 57 | const lastNotifyTime = await storage.getItem( 58 | `local:${lastNotifyKey}` 59 | ) 60 | const timeSinceLastNotify = lastNotifyTime 61 | ? now.diff(lastNotifyTime, "hours") 62 | : reminderHours 63 | 64 | if (!lastNotifyTime || timeSinceLastNotify >= 24) { 65 | const readableDueDate = dueDate.format("D MMM YYYY [at] HH:mm") 66 | const dayUntilDue = dueDate.diff(now, "day") 67 | const dayUntilDueString = 68 | dayUntilDue === 0 69 | ? "today" 70 | : dayUntilDue === 1 71 | ? "tomorrow" 72 | : `in ${dayUntilDue} days` 73 | 74 | const notificationId = `assignment-${assignment.class_id}-${assignment.id}` 75 | 76 | const message = `${ 77 | assignment.type === "ASM" ? "Assignment" : "Quiz" 78 | } "${assignment.title}" from ${ 79 | classInfo.title || "Class" 80 | }\n\nDue ${dayUntilDueString} (${readableDueDate})` 81 | 82 | browser.notifications.create(notificationId, { 83 | type: "basic", 84 | iconUrl: "icons/128.png", 85 | title: `Due ${dayUntilDueString}: "${assignment.title}"`, 86 | message, 87 | buttons: [ 88 | { 89 | title: "View details", 90 | }, 91 | ], 92 | priority: 2, 93 | }) 94 | 95 | await storage.setItem(`local:${lastNotifyKey}`, now.valueOf()) 96 | } 97 | } 98 | } 99 | } catch (error) { 100 | console.error("Error checking assignments:", error) 101 | } 102 | } 103 | 104 | browser.runtime.onInstalled.addListener(async (details) => { 105 | if (details.reason === browser.runtime.OnInstalledReason.INSTALL) { 106 | await storage.setItems([ 107 | { 108 | key: "sync:notifications:enabled", 109 | value: true, 110 | }, 111 | { 112 | key: "sync:notifications:reminderTime", 113 | value: "72", 114 | }, 115 | ]) 116 | browser.runtime.openOptionsPage() 117 | } 118 | }) 119 | 120 | browser.alarms.create("checkAssignments", { periodInMinutes: 1 }) 121 | 122 | browser.alarms.onAlarm.addListener(async (alarm) => { 123 | if (alarm.name === "checkAssignments") { 124 | await checkUpcomingAssignments() 125 | } 126 | }) 127 | 128 | browser.notifications.onButtonClicked.addListener((notificationId) => { 129 | if (notificationId.startsWith("assignment-")) { 130 | const [, classId, assignmentId] = notificationId.split("-") 131 | browser.tabs.create({ 132 | url: `https://app.leb2.org/class/${classId}/activity/${assignmentId}`, 133 | }) 134 | } 135 | }) 136 | }) 137 | -------------------------------------------------------------------------------- /entrypoints/content/app.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useMemo, useState, type FC } from "react" 2 | import isPropValid from "@emotion/is-prop-valid" 3 | import { useQueries } from "@tanstack/react-query" 4 | import { storage } from "#imports" 5 | import dayjs from "dayjs" 6 | import relativeTime from "dayjs/plugin/relativeTime" 7 | import { EyeOff, LayoutGrid, LayoutList } from "lucide-react" 8 | import { StyleSheetManager, ThemeProvider } from "styled-components" 9 | 10 | import { 11 | AssignmentContainer, 12 | AssignmentInfo, 13 | AssignmentItem, 14 | } from "@/entrypoints/content/components/styled/Assignment" 15 | import { StatusBadge } from "@/entrypoints/content/components/styled/Badges" 16 | import { 17 | ThemeToggleButton, 18 | ViewToggleButton, 19 | } from "@/entrypoints/content/components/styled/Button" 20 | import { 21 | ClassCard, 22 | ClassContainer, 23 | } from "@/entrypoints/content/components/styled/Class" 24 | import { 25 | DropdownButton, 26 | DropdownContent, 27 | DropdownItem, 28 | DropdownLabel, 29 | InlineContainer, 30 | LinkText, 31 | TabButton, 32 | TabContainer, 33 | } from "@/entrypoints/content/components/styled/Dropdown" 34 | import { 35 | ModalContent, 36 | ModalHeader, 37 | ModalOverlay, 38 | } from "@/entrypoints/content/components/styled/Modal" 39 | import { 40 | DueDate, 41 | NoAssignments, 42 | } from "@/entrypoints/content/components/styled/Typography" 43 | import { darkTheme, lightTheme } from "@/entrypoints/content/components/themes" 44 | import type { AssignmentResponse, TActivity, TAssignmentFilter } from "@/types" 45 | 46 | import "dayjs/locale/th" 47 | 48 | import AssignmentFilter from "./components/AssignmentFilter" 49 | 50 | dayjs.extend(relativeTime) 51 | dayjs.locale("th") 52 | 53 | const App: FC = () => { 54 | const [isOpen, setIsOpen] = useState(false) 55 | const [isCompactMode, setIsCompactMode] = useState(false) 56 | const [isGridView, setIsGridView] = useState(false) 57 | const [isClosing, setIsClosing] = useState(false) 58 | const [isDarkMode, setIsDarkMode] = useState(false) 59 | const [hiddenClasses, setHiddenClasses] = useState([]) 60 | const [hiddenAssignments, setHiddenAssignments] = useState([]) 61 | const [isDropdownOpen, setIsDropdownOpen] = useState(false) 62 | const [isAssignmentFilterOpen, setIsAssignmentFilterOpen] = 63 | useState(false) 64 | const [currentTime, setCurrentTime] = useState(dayjs()) 65 | const [activeTab, setActiveTab] = useState<"classes" | "assignments">( 66 | "classes" 67 | ) 68 | 69 | const [filterAssignment, setFilterAssignment] = useState({ 70 | submit: { 71 | isSubmit: false, 72 | isNotSubmit: false, 73 | }, 74 | type: { 75 | isIND: false, 76 | isGRP: false, 77 | }, 78 | assessmentType: { 79 | isAssignment: false, 80 | isQuiz: false, 81 | }, 82 | }) 83 | 84 | const allClassInfo = useMemo(() => getAllClassInfo(), []) 85 | 86 | useEffect(() => { 87 | const loadPreferences = async () => { 88 | const [ 89 | savedView, 90 | savedDarkMode, 91 | savedHiddenClasses, 92 | savedHiddenAssignments, 93 | savedFilterSettings, 94 | savedCompactMode, 95 | ] = await Promise.all([ 96 | storage.getItem("local:assignmentsGridView"), 97 | storage.getItem("local:darkMode"), 98 | storage.getItem("local:hiddenClasses"), 99 | storage.getItem("local:hiddenAssignments"), 100 | storage.getItem("local:filterSettings"), 101 | storage.getItem("sync:compactMode"), 102 | ]) 103 | setIsCompactMode(savedCompactMode ?? false) 104 | setIsGridView(savedView ?? false) 105 | setIsDarkMode(savedDarkMode ?? false) 106 | setHiddenClasses(savedHiddenClasses ?? []) 107 | setHiddenAssignments(savedHiddenAssignments ?? []) 108 | setFilterAssignment( 109 | savedFilterSettings ?? { 110 | submit: { 111 | isSubmit: false, 112 | isNotSubmit: false, 113 | }, 114 | type: { 115 | isIND: false, 116 | isGRP: false, 117 | }, 118 | assessmentType: { 119 | isAssignment: false, 120 | isQuiz: false, 121 | }, 122 | } 123 | ) 124 | } 125 | loadPreferences() 126 | }, []) 127 | 128 | useEffect(() => { 129 | const timer = setInterval(() => { 130 | setCurrentTime(dayjs()) 131 | }, 1000) 132 | 133 | return () => clearInterval(timer) 134 | }, []) 135 | 136 | useEffect(() => { 137 | const handleClickOutside = (event: MouseEvent) => { 138 | const dropdownContainers = document.querySelectorAll( 139 | ".dropdown-container" 140 | ) 141 | let clickedInside = false 142 | 143 | dropdownContainers.forEach((container) => { 144 | if (container && container.contains(event.target as Node)) { 145 | clickedInside = true 146 | } 147 | }) 148 | 149 | if (!clickedInside) { 150 | setIsDropdownOpen(false) 151 | setIsAssignmentFilterOpen(false) 152 | } 153 | } 154 | 155 | document.addEventListener("mousedown", handleClickOutside) 156 | return () => document.removeEventListener("mousedown", handleClickOutside) 157 | }, []) 158 | 159 | useEffect(() => { 160 | if (isOpen) { 161 | document.body.style.overflow = "hidden" 162 | } else { 163 | document.body.style.overflow = "unset" 164 | } 165 | 166 | return () => { 167 | document.body.style.overflow = "unset" 168 | } 169 | }, [isOpen]) 170 | 171 | const handleViewChange = useCallback(async (checked: boolean) => { 172 | setIsGridView(checked) 173 | await storage.setItem("local:assignmentsGridView", checked) 174 | }, []) 175 | 176 | const toggleDarkMode = useCallback(async () => { 177 | setIsDarkMode((prev) => { 178 | const newValue = !prev 179 | storage.setItem("local:darkMode", newValue) 180 | return newValue 181 | }) 182 | }, []) 183 | 184 | const handleClassVisibilityChange = useCallback( 185 | async (classId: string, isChecked: boolean) => { 186 | setHiddenClasses((prev) => { 187 | const newHiddenClasses = isChecked 188 | ? prev.filter((id) => id !== classId) 189 | : [...prev, classId] 190 | storage.setItem("local:hiddenClasses", newHiddenClasses) 191 | return newHiddenClasses 192 | }) 193 | }, 194 | [] 195 | ) 196 | 197 | const handleAssignmentVisibilityChange = useCallback( 198 | async (assignmentId: string, isChecked: boolean) => { 199 | setHiddenAssignments((prev) => { 200 | const newHiddenAssignments = isChecked 201 | ? prev.filter((id) => id !== assignmentId) 202 | : [...prev, assignmentId] 203 | storage.setItem( 204 | "local:hiddenAssignments", 205 | newHiddenAssignments 206 | ) 207 | return newHiddenAssignments 208 | }) 209 | }, 210 | [] 211 | ) 212 | 213 | const handleClose = useCallback(() => { 214 | setIsClosing(true) 215 | setTimeout(() => { 216 | setIsOpen(false) 217 | setIsClosing(false) 218 | setIsDropdownOpen(false) 219 | }, 200) 220 | }, []) 221 | 222 | const assignments = useQueries({ 223 | queries: allClassInfo.map((classInfo) => ({ 224 | queryKey: ["classInfo", classInfo.id], 225 | queryFn: () => getAssignmentsEachClass(classInfo.id), 226 | staleTime: 30000, // Cache results for 30 seconds 227 | cacheTime: 5 * 60 * 1000, // Keep in cache for 5 minutes 228 | })), 229 | }) 230 | 231 | const classWithAssignments = useMemo( 232 | () => 233 | allClassInfo.map((classInfo, index) => ({ 234 | ...classInfo, 235 | assignments: assignments[index]?.data as AssignmentResponse, 236 | })), 237 | [allClassInfo, assignments] 238 | ) 239 | 240 | useEffect(() => { 241 | const saveClassWithAssignments = async () => { 242 | await storage.setItem( 243 | "local:classWithAssignments", 244 | classWithAssignments.map((classInfo) => ({ 245 | ...classInfo, 246 | assignments: { 247 | ...classInfo.assignments, 248 | activities: classInfo.assignments?.activities.filter( 249 | (activity) => 250 | activity.due_date && new Date(activity.due_date) > new Date() 251 | ), 252 | }, 253 | })) 254 | ) 255 | } 256 | saveClassWithAssignments() 257 | }, [classWithAssignments]) 258 | 259 | useEffect(() => { 260 | const saveFilterSettings = async () => { 261 | await storage.setItem("local:filterSettings", filterAssignment) 262 | } 263 | 264 | saveFilterSettings() 265 | }, [filterAssignment]) 266 | 267 | const handleOpenModal = useCallback(() => { 268 | const currentUrl = window.location.href 269 | if ( 270 | currentUrl !== "https://app.leb2.org/class" && 271 | currentUrl.match(/^https:\/\/app\.leb2\.org\/class\/.+/) 272 | ) { 273 | window.location.href = "https://app.leb2.org/class?openModal=true" 274 | } else { 275 | setIsOpen(true) 276 | } 277 | }, []) 278 | 279 | useEffect(() => { 280 | const urlParams = new URLSearchParams(window.location.search) 281 | if (urlParams.get("openModal") === "true") { 282 | setIsOpen(true) 283 | const newUrl = window.location.origin + window.location.pathname 284 | window.history.replaceState({}, "", newUrl) 285 | } 286 | }, []) 287 | 288 | const formatDueDate = useCallback( 289 | (dueDate: string | null) => { 290 | if (!dueDate) return "" 291 | 292 | const due = dayjs(dueDate) 293 | const diffDays = due.diff(currentTime, "day") 294 | const diffHours = due.diff(currentTime, "hour") 295 | const diffMinutes = due.diff(currentTime, "minute") 296 | const diffSeconds = due.diff(currentTime, "second") 297 | 298 | const formatWithColor = (text: string, color: string) => ( 299 | {text} 300 | ) 301 | 302 | if (diffSeconds < 0) { 303 | return formatWithColor("เลยกำหนดส่ง", "#ef4444") 304 | } 305 | 306 | if (diffHours < 1) { 307 | return formatWithColor(`~${diffMinutes} นาที`, "#f97316") 308 | } 309 | 310 | if (diffHours < 24) { 311 | const remainingHours = diffHours 312 | const remainingMinutes = diffMinutes % 60 313 | const text = 314 | remainingMinutes > 0 315 | ? `~${remainingHours} ชั่วโมง ${remainingMinutes} นาที` 316 | : `~${remainingHours} ชั่วโมง` 317 | return formatWithColor(text, "#eab308") 318 | } 319 | 320 | const remainingDays = diffDays 321 | const remainingHours = diffHours % 24 322 | const text = 323 | remainingHours > 0 324 | ? `~${remainingDays} วัน ${remainingHours} ชั่วโมง` 325 | : `~${remainingDays} วัน` 326 | return formatWithColor(text, "#22c55e") 327 | }, 328 | [currentTime] 329 | ) 330 | 331 | const theme = useMemo( 332 | () => (isDarkMode ? darkTheme : lightTheme), 333 | [isDarkMode] 334 | ) 335 | 336 | useEffect(() => { 337 | const handleKeyboardShortcut = () => { 338 | if (isOpen) { 339 | handleClose() 340 | } else { 341 | handleOpenModal() 342 | } 343 | } 344 | 345 | document.addEventListener("openAssignmentModal", handleKeyboardShortcut) 346 | 347 | return () => { 348 | document.removeEventListener( 349 | "openAssignmentModal", 350 | handleKeyboardShortcut 351 | ) 352 | } 353 | }, [handleOpenModal, handleClose, isOpen]) 354 | 355 | return ( 356 | 357 | 358 |
359 | 371 | 372 | {isOpen && ( 373 | <> 374 | 378 | 382 | 383 |
387 | 388 | { 390 | setIsDropdownOpen(!isDropdownOpen) 391 | setIsAssignmentFilterOpen(false) 392 | }} 393 | > 394 | Show / Hide 395 | 396 | {isDropdownOpen && ( 397 | 398 | 399 | setActiveTab("classes")} 402 | > 403 | Classes 404 | 405 | setActiveTab("assignments")} 408 | > 409 | Assignments 410 | 411 | 412 | 413 | {activeTab === "classes" ? ( 414 | allClassInfo.map((classInfo) => ( 415 | 416 | 422 | handleClassVisibilityChange( 423 | classInfo.id, 424 | e.target.checked 425 | ) 426 | } 427 | style={{ marginRight: "8px" }} 428 | /> 429 | {classInfo.title} 430 | 431 | )) 432 | ) : ( 433 | <> 434 | {classWithAssignments.map((classInfo) => { 435 | const hiddenClassAssignments = 436 | classInfo.assignments?.activities.filter( 437 | (assignment) => 438 | hiddenAssignments.includes( 439 | assignment.id.toString() 440 | ) 441 | ) 442 | 443 | if (!hiddenClassAssignments?.length) return null 444 | 445 | return ( 446 |
447 | 448 | {classInfo.title} 449 | 450 | {hiddenClassAssignments.map( 451 | (assignment) => ( 452 | 453 | 457 | handleAssignmentVisibilityChange( 458 | assignment.id.toString(), 459 | true 460 | ) 461 | } 462 | style={{ marginRight: "8px" }} 463 | /> 464 | {assignment.title} 465 | 466 | ) 467 | )} 468 |
469 | ) 470 | })} 471 | {classWithAssignments.every( 472 | (classInfo) => 473 | !classInfo.assignments?.activities.some( 474 | (assignment) => 475 | hiddenAssignments.includes( 476 | assignment.id.toString() 477 | ) 478 | ) 479 | ) && ( 480 |

485 | No hidden assignments 486 |

487 | )} 488 | 489 | )} 490 |
491 | )} 492 | 493 | { 495 | setIsAssignmentFilterOpen(!isAssignmentFilterOpen) 496 | setIsDropdownOpen(false) 497 | }} 498 | > 499 | Filter 500 | 501 | 502 | {isAssignmentFilterOpen && ( 503 | 504 | 505 | 508 |

Submit / Not Submit

509 | { 512 | setFilterAssignment({ 513 | ...filterAssignment, 514 | submit: { 515 | isNotSubmit: false, 516 | isSubmit: false, 517 | }, 518 | }) 519 | }} 520 | > 521 | Clear 522 | 523 |
524 |
525 | 532 | 539 | 540 | 543 |

Individual / Group

544 | { 546 | setFilterAssignment({ 547 | ...filterAssignment, 548 | type: { 549 | isGRP: false, 550 | isIND: false, 551 | }, 552 | }) 553 | }} 554 | > 555 | Clear 556 | 557 |
558 |
559 | 566 | 573 | 574 | 577 |

Assignment / Quiz

578 | { 580 | setFilterAssignment({ 581 | ...filterAssignment, 582 | assessmentType: { 583 | isQuiz: false, 584 | isAssignment: false, 585 | }, 586 | }) 587 | }} 588 | > 589 | Clear 590 | 591 |
592 |
593 | 600 | 607 |
608 | )} 609 |
610 |
611 |

Assignments 🥰

612 |
613 | 614 | {isDarkMode ? "🌞" : "🌚"} 615 | 616 |
617 | handleViewChange(false)} 620 | > 621 | 622 | 623 | handleViewChange(true)} 626 | > 627 | 628 | 629 |
630 |
631 |
632 | 633 | {classWithAssignments.map((classInfo) => { 634 | const isHidden = hiddenClasses.includes(classInfo.id) 635 | if (isHidden) return null 636 | 637 | const { activities } = classInfo.assignments || { 638 | activities: [], 639 | } 640 | 641 | const assignmentsToSubmit = activities.filter( 642 | (assignment) => assignment.due_date !== null 643 | ) 644 | const submittedAssignments = activities.filter( 645 | (assignment) => 646 | assignment.quiz_submission_is_submitted === 1 647 | ) 648 | const lateAssignments = assignmentsToSubmit.filter( 649 | (assignment) => assignment.due_date_exceed 650 | ) 651 | const assignments = assignmentsToSubmit.filter( 652 | (assignment) => 653 | (!lateAssignments.includes(assignment) || 654 | !submittedAssignments.includes(assignment)) && 655 | !hiddenAssignments.includes(assignment.id.toString()) 656 | ) 657 | 658 | let filteredTasks = assignments 659 | 660 | const filters = [ 661 | { 662 | condition: filterAssignment.assessmentType.isAssignment, 663 | predicate: (task: TActivity) => task.type === "ASM", 664 | }, 665 | { 666 | condition: filterAssignment.assessmentType.isQuiz, 667 | predicate: (task: TActivity) => task.type === "QUZ", 668 | }, 669 | { 670 | condition: filterAssignment.type.isGRP, 671 | predicate: (task: TActivity) => 672 | task.group_type === "STU", 673 | }, 674 | { 675 | condition: filterAssignment.type.isIND, 676 | predicate: (task: TActivity) => 677 | task.group_type === "IND", 678 | }, 679 | { 680 | condition: filterAssignment.submit.isNotSubmit, 681 | predicate: (task: TActivity) => 682 | !task.quiz_submission_is_submitted, 683 | }, 684 | { 685 | condition: filterAssignment.submit.isSubmit, 686 | predicate: (task: TActivity) => 687 | task.quiz_submission_is_submitted, 688 | }, 689 | ] 690 | 691 | if (filteredTasks.length === 0 && isCompactMode) return null 692 | 693 | filters.forEach(({ condition, predicate }) => { 694 | if (condition) { 695 | filteredTasks = filteredTasks.filter(predicate) 696 | } 697 | }) 698 | 699 | const hiddenAssignmentsCount = assignmentsToSubmit.filter( 700 | (assignment) => 701 | hiddenAssignments.includes(assignment.id.toString()) 702 | ).length 703 | 704 | return ( 705 | 706 |
713 |
714 |

715 | 720 | {classInfo.title} 721 | 722 |

723 |

{classInfo.description}

724 |
725 | {hiddenAssignmentsCount > 0 && ( 726 |

732 | ซ่อน {hiddenAssignmentsCount} งาน 733 |

734 | )} 735 |
736 | 737 | {filteredTasks.length > 0 ? ( 738 | filteredTasks.map((assignment) => ( 739 | { 742 | const hideButton = document.querySelector( 743 | `#hide-button-${assignment.id}` 744 | ) as HTMLButtonElement 745 | hideButton.style.opacity = "1" 746 | }} 747 | onMouseOut={() => { 748 | const hideButton = document.querySelector( 749 | `#hide-button-${assignment.id}` 750 | ) as HTMLButtonElement 751 | hideButton.style.opacity = "0" 752 | }} 753 | > 754 |
761 | 762 | 774 | {assignment.title} 775 | 784 | 789 | 790 | 791 | 792 | กำหนดส่ง:{" "} 793 | {dayjs(assignment.due_date).format( 794 | "D MMM YYYY HH:mm" 795 | )} 796 | {" | "} 797 | {formatDueDate(assignment.due_date)} 798 | 799 | 800 | 826 |
827 |
828 | 836 | {assignment.quiz_submission_is_submitted === 837 | 1 838 | ? "ส่งแล้ว" 839 | : "ยังไม่ส่ง"} 840 | 841 | 842 | {assignment.type === "ASM" 843 | ? "Assignment" 844 | : "Quiz"} 845 | 846 | 847 | {assignment.group_type === "IND" 848 | ? "งานเดี่ยว" 849 | : "งานกลุ่ม"} 850 | 851 |
852 |
853 | )) 854 | ) : ( 855 | ไม่มีงานที่รอส่ง 856 | )} 857 |
858 |
859 | ) 860 | })} 861 |
862 |
863 | 864 | )} 865 |
866 |
867 |
868 | ) 869 | } 870 | 871 | export default App 872 | -------------------------------------------------------------------------------- /entrypoints/content/components/AssignmentFilter.tsx: -------------------------------------------------------------------------------- 1 | import React from "React" 2 | 3 | import { TAssignmentFilter } from "@/types" 4 | 5 | import { DropdownItem } from "./styled/Dropdown" 6 | 7 | type Props = { 8 | obj: TAssignmentFilter 9 | setObj: React.Dispatch> 10 | inputLabel: string 11 | itemKey: 12 | | keyof TAssignmentFilter["type"] 13 | | keyof TAssignmentFilter["submit"] 14 | | keyof TAssignmentFilter["assessmentType"] 15 | section: keyof TAssignmentFilter 16 | } 17 | 18 | const AssignmentFilter: React.FC = (props) => { 19 | return ( 20 | 21 | { 30 | const updatedSection = Object.keys(props.obj[props.section]).reduce( 31 | (acc, key) => ({ 32 | ...acc, 33 | [key]: key === props.itemKey ? event.target.checked : false, 34 | }), 35 | {} 36 | ) 37 | const updateObj = { 38 | ...props.obj, 39 | [props.section]: updatedSection, 40 | } 41 | props.setObj(updateObj) 42 | }} 43 | /> 44 | {props.inputLabel} 45 | 46 | ) 47 | } 48 | 49 | export default AssignmentFilter 50 | -------------------------------------------------------------------------------- /entrypoints/content/components/animations/index.ts: -------------------------------------------------------------------------------- 1 | import { keyframes } from "styled-components" 2 | 3 | export const scaleUpAndFadeIn = keyframes` 4 | from { 5 | transform: scale(0.95); 6 | opacity: 0; 7 | } 8 | to { 9 | transform: scale(1); 10 | opacity: 1; 11 | } 12 | ` 13 | 14 | export const scaleDownAndFadeOut = keyframes` 15 | from { 16 | transform: scale(1); 17 | opacity: 1; 18 | } 19 | to { 20 | transform: scale(0.95); 21 | opacity: 0; 22 | } 23 | ` 24 | 25 | export const fadeIn = keyframes` 26 | from { 27 | opacity: 0; 28 | } 29 | to { 30 | opacity: 1; 31 | } 32 | ` 33 | 34 | export const fadeOut = keyframes` 35 | from { 36 | opacity: 1; 37 | } 38 | to { 39 | opacity: 0; 40 | } 41 | ` 42 | 43 | export const slideIn = keyframes` 44 | from { 45 | transform: translateY(10px); 46 | opacity: 0; 47 | } 48 | to { 49 | transform: translateY(0); 50 | opacity: 1; 51 | } 52 | ` 53 | -------------------------------------------------------------------------------- /entrypoints/content/components/styled/Assignment.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components" 2 | 3 | import { slideIn } from "@/entrypoints/content/components/animations" 4 | 5 | export const AssignmentContainer = styled.div` 6 | display: flex; 7 | flex-direction: column; 8 | gap: 0.75rem; 9 | ` 10 | 11 | export const AssignmentInfo = styled.div` 12 | display: flex; 13 | flex-direction: column; 14 | gap: 0.5rem; 15 | ` 16 | 17 | export const AssignmentItem = styled.div` 18 | display: flex; 19 | flex-direction: column; 20 | gap: 0.75rem; 21 | padding: 1rem; 22 | background-color: ${({ theme }) => theme.background}; 23 | border: 1px solid ${({ theme }) => theme.border}; 24 | border-radius: 0.75rem; 25 | transition: box-shadow 0.2s ease; 26 | box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); 27 | animation: ${slideIn} 0.3s ease-out; 28 | 29 | .assignment-link { 30 | display: flex; 31 | align-items: center; 32 | gap: 0.5rem; 33 | color: ${({ theme }) => theme.text}; 34 | text-decoration: none; 35 | font-weight: 500; 36 | transition: color 0.2s ease; 37 | 38 | span { 39 | font-size: 1.25rem; 40 | } 41 | 42 | svg { 43 | opacity: 0.7; 44 | transition: opacity 0.2s ease; 45 | } 46 | 47 | &:hover { 48 | text-decoration: underline; 49 | svg { 50 | opacity: 1; 51 | } 52 | } 53 | } 54 | 55 | .status-badges { 56 | display: flex; 57 | flex-wrap: wrap; 58 | gap: 0.5rem; 59 | } 60 | ` 61 | -------------------------------------------------------------------------------- /entrypoints/content/components/styled/Badges.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components" 2 | 3 | const getStatusColor = (type: string) => { 4 | const colors = { 5 | submitted: "#22c55e", 6 | notSubmitted: "#ef4444", 7 | assignment: "#f59e0b", 8 | quiz: "#06b6d4", 9 | individual: "#8b5cf6", 10 | group: "#ec4899", 11 | } 12 | 13 | switch (type) { 14 | case "submitted": 15 | return colors.submitted 16 | case "notSubmitted": 17 | return colors.notSubmitted 18 | case "ASM": 19 | return colors.assignment 20 | case "QUZ": 21 | return colors.quiz 22 | case "IND": 23 | return colors.individual 24 | case "STU": 25 | return colors.group 26 | default: 27 | return colors.individual 28 | } 29 | } 30 | 31 | export const StatusBadge = styled.span<{ $type: string }>` 32 | padding: 0.25rem 0.75rem; 33 | border-radius: 1rem; 34 | font-size: 0.875rem; 35 | color: white; 36 | letter-spacing: 0.025em; 37 | text-transform: uppercase; 38 | opacity: 0.9; 39 | transition: opacity 0.2s ease; 40 | background-color: ${(props) => getStatusColor(props.$type)}; 41 | ` 42 | -------------------------------------------------------------------------------- /entrypoints/content/components/styled/Button.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components" 2 | 3 | export const ViewToggleButton = styled.button<{ active: boolean }>` 4 | position: relative; 5 | display: flex; 6 | align-items: center; 7 | justify-content: center; 8 | padding: 0.25rem; 9 | border: 1px solid ${({ theme }) => theme.border}; 10 | background: ${({ active, theme }) => 11 | active ? theme.primary : theme.background}; 12 | color: ${({ active, theme }) => 13 | active ? theme.background : theme.textMuted}; 14 | cursor: pointer; 15 | transition: all 0.2s ease; 16 | margin-left: -1px; 17 | 18 | &:first-of-type { 19 | border-top-left-radius: 0.375rem; 20 | border-bottom-left-radius: 0.375rem; 21 | margin-left: 0; 22 | } 23 | 24 | &:last-of-type { 25 | border-top-right-radius: 0.375rem; 26 | border-bottom-right-radius: 0.375rem; 27 | } 28 | 29 | &:hover { 30 | background: ${({ active, theme }) => 31 | active ? theme.primary : theme.hover}; 32 | } 33 | ` 34 | 35 | export const ThemeToggleButton = styled.button` 36 | background: none; 37 | border: none; 38 | cursor: pointer; 39 | font-size: 1.25rem; 40 | padding: 0.25rem; 41 | ` 42 | -------------------------------------------------------------------------------- /entrypoints/content/components/styled/Class.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components" 2 | 3 | import { slideIn } from "@/entrypoints/content/components/animations" 4 | 5 | export const ClassContainer = styled.div<{ $isGrid: boolean }>` 6 | display: ${(props) => (props.$isGrid ? "grid" : "flex")}; 7 | grid-template-columns: repeat(2, 1fr); 8 | flex-direction: column; 9 | gap: 1rem; 10 | ` 11 | 12 | export const ClassCard = styled.div` 13 | position: relative; 14 | padding: 1.25rem; 15 | background-color: ${({ theme }) => theme.cardBg}; 16 | border: 1px solid ${({ theme }) => theme.border}; 17 | border-radius: 1rem; 18 | transition: all 0.2s ease; 19 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); 20 | animation: ${slideIn} 0.3s ease-out; 21 | 22 | .class-card-header { 23 | display: flex; 24 | justify-content: baseline; 25 | align-items: first baseline; 26 | gap: 0.5rem; 27 | margin-bottom: 0.75rem; 28 | 29 | h2 { 30 | font-size: 1.75rem; 31 | font-weight: 600; 32 | color: ${({ theme }) => theme.text}; 33 | line-height: 1.2; 34 | white-space: nowrap; 35 | 36 | a:hover { 37 | text-decoration: underline; 38 | } 39 | } 40 | 41 | p { 42 | color: ${({ theme }) => theme.textMuted}; 43 | line-height: 1.5; 44 | font-size: 1rem; 45 | margin: 0; 46 | overflow: hidden; 47 | display: -webkit-box; 48 | -webkit-box-orient: vertical; 49 | -webkit-line-clamp: 1; 50 | } 51 | } 52 | ` 53 | -------------------------------------------------------------------------------- /entrypoints/content/components/styled/Dropdown.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components" 2 | 3 | export const InlineContainer = styled.div` 4 | display: flex; 5 | gap: 10px; 6 | ` 7 | 8 | export const LinkText = styled.p` 9 | text-decoration: underline; 10 | cursor: pointer; 11 | 12 | &:hover { 13 | cursor: pointer; 14 | } 15 | ` 16 | 17 | export const DropdownButton = styled.button` 18 | display: flex; 19 | align-items: center; 20 | padding: 0.25rem 0.5rem; 21 | border: 1px solid ${({ theme }) => theme.border}; 22 | border-radius: 0.375rem; 23 | background: ${({ theme }) => theme.background}; 24 | color: ${({ theme }) => theme.textMuted}; 25 | cursor: pointer; 26 | font-size: 1.125rem; 27 | transition: all 0.2s ease; 28 | 29 | &:hover { 30 | background: ${({ theme }) => theme.hover}; 31 | } 32 | ` 33 | 34 | export const DropdownContent = styled.div` 35 | position: absolute; 36 | top: 100%; 37 | left: 0; 38 | background: ${({ theme }) => theme.background}; 39 | border: 1px solid ${({ theme }) => theme.border}; 40 | margin-top: 0.5rem; 41 | border-radius: 0.375rem; 42 | width: 250px; 43 | max-height: 400px; 44 | overflow-y: auto; 45 | z-index: 1000; 46 | box-shadow: 47 | 0 10px 15px -3px rgba(0, 0, 0, 0.05), 48 | 0 4px 6px -4px rgba(0, 0, 0, 0.05); 49 | ` 50 | 51 | export const DropdownItem = styled.label` 52 | margin: 0; 53 | display: block; 54 | align-items: inline; 55 | padding: 0.5rem 0.75rem; 56 | cursor: pointer; 57 | border-bottom: 1px solid ${({ theme }) => theme.border}; 58 | 59 | &:last-child { 60 | border-bottom: none; 61 | } 62 | 63 | input { 64 | margin-right: 0.5rem; 65 | } 66 | ` 67 | 68 | export const DropdownLabel = styled.div` 69 | padding: 0.5rem 0.75rem; 70 | font-weight: 500; 71 | color: ${({ theme }) => theme.textMuted}; 72 | font-size: 1rem; 73 | background-color: ${({ theme }) => theme.cardBg}; 74 | border-bottom: 1px solid ${({ theme }) => theme.border}; 75 | ` 76 | 77 | export const TabContainer = styled.div` 78 | display: flex; 79 | border-bottom: 1px solid ${({ theme }) => theme.border}; 80 | margin-bottom: 0.5rem; 81 | ` 82 | 83 | export const TabButton = styled.button<{ active: boolean }>` 84 | flex: 1; 85 | padding: 0.5rem; 86 | background: ${({ active, theme }) => (active ? theme.cardBg : "transparent")}; 87 | color: ${({ active, theme }) => (active ? theme.text : theme.textMuted)}; 88 | border: none; 89 | cursor: pointer; 90 | font-size: 1rem; 91 | font-weight: 500; 92 | transition: all 0.2s ease; 93 | 94 | &:hover { 95 | background: ${({ theme }) => theme.hover}; 96 | color: ${({ theme }) => theme.text}; 97 | } 98 | 99 | &:first-child { 100 | border-top-left-radius: 0.375rem; 101 | } 102 | 103 | &:last-child { 104 | border-top-right-radius: 0.375rem; 105 | } 106 | ` 107 | -------------------------------------------------------------------------------- /entrypoints/content/components/styled/Modal.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components" 2 | 3 | import { 4 | fadeIn, 5 | fadeOut, 6 | scaleDownAndFadeOut, 7 | scaleUpAndFadeIn, 8 | } from "@/entrypoints/content/components/animations" 9 | 10 | export const ModalHeader = styled.div` 11 | position: relative; 12 | display: flex; 13 | align-items: center; 14 | justify-content: space-between; 15 | width: 100%; 16 | margin-bottom: 1rem; 17 | 18 | h1 { 19 | position: absolute; 20 | left: 50%; 21 | transform: translateX(-50%); 22 | font-size: 1.875rem; 23 | font-weight: 600; 24 | color: ${({ theme }) => theme.text}; 25 | margin: 0; 26 | white-space: nowrap; 27 | } 28 | 29 | p { 30 | color: ${({ theme }) => theme.textMuted}; 31 | font-size: 1rem; 32 | margin: 0; 33 | } 34 | 35 | .settings-bar { 36 | display: flex; 37 | justify-content: center; 38 | align-items: center; 39 | gap: 0.75rem; 40 | } 41 | 42 | .view-toggle { 43 | display: flex; 44 | align-items: center; 45 | justify-content: center; 46 | color: ${({ theme }) => theme.textMuted}; 47 | font-size: 0.875rem; 48 | } 49 | ` 50 | 51 | export const ModalOverlay = styled.div` 52 | position: fixed; 53 | inset: 0; 54 | background-color: rgba(0, 0, 0, 0.5); 55 | z-index: 7483640; 56 | animation: ${fadeIn} 0.2s ease-out; 57 | 58 | &.closing { 59 | animation: ${fadeOut} 0.2s ease-out forwards; 60 | } 61 | ` 62 | 63 | export const ModalContent = styled.div<{ $isGrid: boolean }>` 64 | position: fixed; 65 | inset: 0; 66 | display: flex; 67 | flex-direction: column; 68 | width: 90%; 69 | max-width: ${(props) => (props.$isGrid ? "64rem" : "38rem")}; 70 | height: fit-content; 71 | max-height: 85vh; 72 | min-height: 50vh; 73 | margin: auto; 74 | background-color: ${({ theme }) => theme.background}; 75 | padding: 1.5rem; 76 | box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3); 77 | border-radius: 1rem; 78 | z-index: 7483641; 79 | color: ${({ theme }) => theme.text}; 80 | overflow-y: auto; 81 | transition: 82 | max-width 0.3s ease, 83 | height 0.3s ease; 84 | animation: ${scaleUpAndFadeIn} 0.3s ease-out; 85 | 86 | &.closing { 87 | animation: ${scaleDownAndFadeOut} 0.2s ease-out forwards; 88 | } 89 | ` 90 | -------------------------------------------------------------------------------- /entrypoints/content/components/styled/Typography.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components" 2 | 3 | export const DueDate = styled.p` 4 | color: ${({ theme }) => theme.textMuted}; 5 | font-size: 1.125rem; 6 | ` 7 | 8 | export const NoAssignments = styled.p` 9 | color: ${({ theme }) => theme.textMuted}; 10 | font-size: 1.125rem; 11 | margin: 0 auto; 12 | font-style: italic; 13 | ` 14 | -------------------------------------------------------------------------------- /entrypoints/content/components/themes/index.ts: -------------------------------------------------------------------------------- 1 | export const lightTheme = { 2 | primary: "#2563eb", 3 | text: "#333333", 4 | textMuted: "#909190", 5 | background: "#ffffff", 6 | border: "#e5e7eb", 7 | hover: "#f3f4f6", 8 | cardBg: "#f8fafc", 9 | } 10 | 11 | export const darkTheme = { 12 | primary: "#3b82f6", 13 | text: "#e5e7eb", 14 | textMuted: "#9ca3af", 15 | background: "#1f2937", 16 | border: "#374151", 17 | hover: "#374151", 18 | cardBg: "#111827", 19 | } 20 | -------------------------------------------------------------------------------- /entrypoints/content/index.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query" 2 | import ReactDOM from "react-dom/client" 3 | 4 | import App from "@/entrypoints/content/app" 5 | 6 | const queryClient = new QueryClient() 7 | 8 | export default defineContentScript({ 9 | matches: ["https://app.leb2.org/*"], 10 | cssInjectionMode: "ui", 11 | 12 | async main(ctx) { 13 | if (ctx.isInvalid) return 14 | 15 | document.addEventListener("keydown", (e) => { 16 | if (e.altKey && e.key.toLowerCase() === "a") { 17 | e.preventDefault() 18 | document.dispatchEvent(new CustomEvent("openAssignmentModal")) 19 | } 20 | }) 21 | 22 | const ui = createIntegratedUi(ctx, { 23 | position: "inline", 24 | append: "last", 25 | anchor: ".nav.navbar-nav.page-menu.flex-container.fxf-rnw", 26 | onMount: (container) => { 27 | const root = ReactDOM.createRoot(container) 28 | root.render( 29 | 30 | 31 | 32 | ) 33 | return { root } 34 | }, 35 | onRemove: (elements) => { 36 | elements?.root.unmount() 37 | }, 38 | }) 39 | 40 | ui.mount() 41 | }, 42 | }) 43 | -------------------------------------------------------------------------------- /entrypoints/options/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Assign Watch | Settings 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /entrypoints/options/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import ReactDOM from "react-dom/client" 3 | 4 | import "@/assets/tailwind.css" 5 | 6 | import App from "@/entrypoints/popup/app" 7 | 8 | import "@fontsource-variable/anuphan" 9 | 10 | ReactDOM.createRoot(document.getElementById("root")!).render( 11 | 12 | 13 | 14 | ) 15 | -------------------------------------------------------------------------------- /entrypoints/popup/app.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react" 2 | import { storage } from "#imports" 3 | import { 4 | BellRing, 5 | CalendarClock, 6 | Coffee, 7 | Link, 8 | ListCollapse, 9 | } from "lucide-react" 10 | import { z } from "zod" 11 | 12 | import { Label } from "@/components/ui/label" 13 | import { 14 | Select, 15 | SelectContent, 16 | SelectItem, 17 | SelectTrigger, 18 | SelectValue, 19 | } from "@/components/ui/select" 20 | import { Switch } from "@/components/ui/switch" 21 | 22 | const settingSchema = z.object({ 23 | notificationsEnabled: z.boolean().default(true), 24 | reminderTime: z.enum(["24", "48", "72", "120", "168"]).default("72"), 25 | compactMode: z.boolean().default(false), 26 | }) 27 | 28 | type Setting = z.infer 29 | 30 | function App() { 31 | const [settings, setSettings] = useState({ 32 | notificationsEnabled: true, 33 | reminderTime: "72", 34 | compactMode: false, 35 | }) 36 | 37 | useEffect(() => { 38 | const loadSettings = async () => { 39 | const notificationsEnabled = await storage.getItem( 40 | "sync:notifications:enabled" 41 | ) 42 | const reminderTime = await storage.getItem( 43 | "sync:notifications:reminderTime" 44 | ) 45 | const compactMode = await storage.getItem("sync:compactMode") 46 | 47 | const parsedSettings = await settingSchema.parseAsync({ 48 | notificationsEnabled: notificationsEnabled ?? true, 49 | reminderTime: reminderTime ?? "72", 50 | compactMode: compactMode ?? false, 51 | }) 52 | setSettings(parsedSettings) 53 | } 54 | 55 | loadSettings() 56 | }, []) 57 | 58 | const updateSetting = async (key: string, value: string | boolean) => { 59 | setSettings((prev) => ({ ...prev, [key]: value })) 60 | const storageKey = 61 | key === "notificationsEnabled" 62 | ? "sync:notifications:enabled" 63 | : key === "reminderTime" 64 | ? "sync:notifications:reminderTime" 65 | : "sync:compactMode" 66 | await storage.setItem(storageKey, value) 67 | } 68 | 69 | const handleNotificationToggle = async () => { 70 | const newState = !settings.notificationsEnabled 71 | 72 | if (newState) { 73 | const permission = await browser.permissions.request({ 74 | permissions: ["notifications"], 75 | }) 76 | if (!permission) { 77 | return updateSetting("notificationsEnabled", false) 78 | } 79 | } 80 | 81 | updateSetting("notificationsEnabled", newState) 82 | } 83 | 84 | const handleCompactModeToggle = async () => { 85 | const newState = !settings.compactMode 86 | updateSetting("compactMode", newState) 87 | 88 | const tabs = await browser.tabs.query({ 89 | url: ["https://app.leb2.org/", "https://app.leb2.org/class"], 90 | }) 91 | for (const tab of tabs) { 92 | await browser.tabs.reload(tab.id!) 93 | } 94 | } 95 | 96 | return ( 97 |
98 |

99 | Settings 100 | / การตั้งค่า 101 |

102 | 103 |
104 |
105 | 114 | 119 |
120 |
121 | 130 | 131 | 150 |
151 |
152 | 161 | 166 |
167 |
168 | 169 | 216 |
217 | ) 218 | } 219 | 220 | export default App 221 | -------------------------------------------------------------------------------- /entrypoints/popup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Assign Watch | Settings 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /entrypoints/popup/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import ReactDOM from "react-dom/client" 3 | 4 | import "@/assets/tailwind.css" 5 | 6 | import App from "@/entrypoints/popup/app" 7 | 8 | import "@fontsource-variable/anuphan" 9 | 10 | ReactDOM.createRoot(document.getElementById("root")!).render( 11 | 12 | 13 | 14 | ) 15 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import path from "node:path" 2 | import { fileURLToPath } from "node:url" 3 | import { includeIgnoreFile } from "@eslint/compat" 4 | import pluginJs from "@eslint/js" 5 | import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended" 6 | import pluginReact from "eslint-plugin-react" 7 | import globals from "globals" 8 | import tseslint from "typescript-eslint" 9 | 10 | import autoImports from "./.wxt/eslint-auto-imports.mjs" 11 | 12 | const __filename = fileURLToPath(import.meta.url) 13 | const __dirname = path.dirname(__filename) 14 | const gitignorePath = path.resolve(__dirname, ".gitignore") 15 | 16 | /** @type {import('eslint').Linter.Config[]} */ 17 | export default [ 18 | includeIgnoreFile(gitignorePath), 19 | { 20 | files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"], 21 | languageOptions: { 22 | globals: globals.browser, 23 | }, 24 | settings: { 25 | react: { 26 | version: "detect", 27 | }, 28 | }, 29 | }, 30 | autoImports, 31 | pluginJs.configs.recommended, 32 | ...tseslint.configs.recommended, 33 | pluginReact.configs.flat.recommended, 34 | eslintPluginPrettierRecommended, 35 | { 36 | rules: { 37 | "react/react-in-jsx-scope": "off", 38 | "@typescript-eslint/no-explicit-any": "off", 39 | "react/prop-types": "off", 40 | }, 41 | }, 42 | ] 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "assign-watch", 3 | "description": "View all your LEB2 assignments in one place", 4 | "private": true, 5 | "version": "0.2.8", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "wxt", 9 | "dev:firefox": "wxt -b firefox", 10 | "build": "wxt build", 11 | "build:firefox": "wxt build -b firefox", 12 | "build:all": "wxt build && wxt build -b firefox", 13 | "zip": "wxt zip", 14 | "zip:firefox": "wxt zip -b firefox", 15 | "zip:all": "wxt zip && wxt zip -b firefox", 16 | "compile": "tsc --noEmit", 17 | "postinstall": "wxt prepare", 18 | "lint": "eslint .", 19 | "lint:fix": "eslint --fix .", 20 | "format": "prettier --write .", 21 | "prepare": "husky", 22 | "lint-staged": "lint-staged" 23 | }, 24 | "dependencies": { 25 | "@emotion/is-prop-valid": "^1.3.1", 26 | "@fontsource-variable/anuphan": "^5.2.5", 27 | "@radix-ui/react-label": "^2.1.7", 28 | "@radix-ui/react-select": "^2.2.5", 29 | "@radix-ui/react-switch": "^1.2.5", 30 | "@tailwindcss/vite": "^4.1.7", 31 | "@tanstack/react-query": "^5.76.2", 32 | "class-variance-authority": "^0.7.1", 33 | "clsx": "^2.1.1", 34 | "dayjs": "^1.11.13", 35 | "lucide-react": "^0.511.0", 36 | "react": "^19.1.0", 37 | "react-dom": "^19.1.0", 38 | "styled-components": "^6.1.18", 39 | "tailwind-merge": "^3.3.0", 40 | "tailwindcss": "^4.1.7", 41 | "tw-animate-css": "^1.3.0", 42 | "zod": "^3.25.23" 43 | }, 44 | "devDependencies": { 45 | "@commitlint/cli": "^19.8.1", 46 | "@commitlint/config-conventional": "^19.8.1", 47 | "@commitlint/types": "^19.8.1", 48 | "@eslint/compat": "^1.2.9", 49 | "@eslint/js": "^9.27.0", 50 | "@ianvs/prettier-plugin-sort-imports": "^4.4.1", 51 | "@types/chrome": "^0.0.323", 52 | "@types/react": "^19.1.5", 53 | "@types/react-dom": "^19.1.5", 54 | "@wxt-dev/auto-icons": "^1.0.2", 55 | "@wxt-dev/module-react": "^1.1.3", 56 | "conventional-changelog-conventionalcommits": "^9.0.0", 57 | "eslint": "^9.27.0", 58 | "eslint-config-prettier": "^10.1.5", 59 | "eslint-plugin-prettier": "^5.4.0", 60 | "eslint-plugin-react": "^7.37.5", 61 | "globals": "^16.1.0", 62 | "husky": "^9.1.7", 63 | "lint-staged": "^16.0.0", 64 | "prettier": "^3.5.3", 65 | "prettier-plugin-tailwindcss": "^0.6.11", 66 | "typescript": "^5.8.3", 67 | "typescript-eslint": "^8.32.1", 68 | "wxt": "^0.20.6" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /prettier.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import("prettier").Config} */ 2 | const config = { 3 | endOfLine: "lf", 4 | trailingComma: "es5", 5 | tabWidth: 2, 6 | semi: false, 7 | singleQuote: false, 8 | plugins: [ 9 | "@ianvs/prettier-plugin-sort-imports", 10 | "prettier-plugin-tailwindcss", 11 | ], 12 | tailwindAttributes: ["cn"], 13 | importOrder: [ 14 | "^(react/(.*)$)|^(react$)", 15 | "", 16 | "", 17 | "^types$", 18 | "^@/types/(.*)$", 19 | "^@/assets/(.*)$", 20 | "^@/entrypoints/(.*)$", 21 | "^@/(.*)$", 22 | "", 23 | "^[./]", 24 | ], 25 | importOrderParserPlugins: ["typescript", "jsx", "decorators-legacy"], 26 | importOrderTypeScriptVersion: "5.0.0", 27 | } 28 | 29 | export default config 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.wxt/tsconfig.json", 3 | "compilerOptions": { 4 | "allowImportingTsExtensions": true, 5 | "jsx": "react-jsx" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /types/index.ts: -------------------------------------------------------------------------------- 1 | export type AssignmentResponse = { 2 | user: Array<{ 3 | id: number 4 | firstname_en: string 5 | lastname_en: string 6 | firstname_th: string 7 | lastname_th: string 8 | }> 9 | activities: TActivity[] 10 | } 11 | 12 | export type TActivity = { 13 | id: number 14 | user_id: number 15 | class_id: number 16 | adv_starred: number 17 | group_type: string 18 | type: string 19 | peer_assessment: number 20 | is_allow_repeat: number 21 | title: string 22 | description: string 23 | start_date: string 24 | due_date: string | null 25 | edit_group_mode: string 26 | created_at: string 27 | user: number 28 | activity_submission_id: number | null 29 | class_user_id: number 30 | activity_group_id: number | null 31 | activity_group_name: string | null 32 | activity_submission_submitted_at: string | null 33 | due_date_exceed: boolean 34 | quiz_submission_is_submitted: number 35 | count_group_member: number 36 | activity_submission_is_late: boolean 37 | fileactivities: any[] 38 | questions: any[] 39 | submissions: any[] 40 | } 41 | 42 | export type TAssignmentFilter = { 43 | submit: { 44 | isSubmit: boolean 45 | isNotSubmit: boolean 46 | } 47 | type: { 48 | isIND: boolean 49 | isGRP: boolean 50 | } 51 | assessmentType: { 52 | isAssignment: boolean 53 | isQuiz: boolean 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /utils/cn.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /utils/getAllClassInfo.ts: -------------------------------------------------------------------------------- 1 | export function getAllClassInfo() { 2 | const classCards = document.querySelectorAll(".class-card") 3 | const classTextElements = document.querySelectorAll("#font-color-main-card p") 4 | const classesInfo = [] 5 | 6 | for (let i = 0; i < classCards.length; i++) { 7 | const cardName = classCards[i].getAttribute("name") 8 | const classId = cardName?.split("-")[1] 9 | if (!classId) continue 10 | const classTitle = classTextElements[i * 2].textContent 11 | const classDescription = classTextElements[i * 2 + 1].textContent 12 | 13 | classesInfo.push({ 14 | id: classId, 15 | title: classTitle, 16 | description: classDescription, 17 | }) 18 | } 19 | return classesInfo 20 | } 21 | -------------------------------------------------------------------------------- /utils/getAssignmentsEachClass.ts: -------------------------------------------------------------------------------- 1 | export async function getAssignmentsEachClass(classId: string) { 2 | const userId = getUserId() 3 | const url = `https://app.leb2.org/api/get/assessment-activities/student?class_id=${classId}&student_id=${userId}&filter_groups[0][filters][0][key]=class_id&filter_groups[0][filters][0][value]=${classId}&sort[]=sequence&sort[]=id&select[]=activities:id,user_id,class_id,adv_starred,group_type,type,peer_assessment,is_allow_repeat,title,description,start_date,due_date,edit_group_mode,created_at&select[]=user:id,firstname_en,lastname_en,firstname_th,lastname_th&includes[]=user:sideload&includes[]=fileactivities:ids&includes[]=questions:ids` 4 | 5 | const res = await fetch(url) 6 | return await res.json() 7 | } 8 | -------------------------------------------------------------------------------- /utils/getUserId.ts: -------------------------------------------------------------------------------- 1 | export function getUserId() { 2 | const userId = document 3 | .querySelector("[data-userid]") 4 | ?.getAttribute("data-userid") 5 | return userId 6 | } 7 | -------------------------------------------------------------------------------- /wxt.config.ts: -------------------------------------------------------------------------------- 1 | import tailwindcss from "@tailwindcss/vite" 2 | import { defineConfig } from "wxt" 3 | 4 | // See https://wxt.dev/api/config.html 5 | export default defineConfig({ 6 | vite: () => ({ 7 | plugins: [tailwindcss()], 8 | }), 9 | imports: { 10 | eslintrc: { 11 | enabled: 9, 12 | }, 13 | }, 14 | modules: ["@wxt-dev/module-react", "@wxt-dev/auto-icons"], 15 | webExt: { 16 | disabled: true, 17 | }, 18 | manifest: { 19 | name: "Assign Watch - Extension for LEB2", 20 | permissions: ["storage", "notifications", "alarms"], 21 | }, 22 | hooks: { 23 | "build:manifestGenerated": (wxt, manifest) => { 24 | if (wxt.config.mode === "development") { 25 | manifest.name += " (DEV)" 26 | } 27 | 28 | if (wxt.config.browser === "firefox") { 29 | manifest.browser_specific_settings = { 30 | gecko: { 31 | id: "assignwatch@leb2", 32 | }, 33 | } 34 | } 35 | }, 36 | }, 37 | }) 38 | --------------------------------------------------------------------------------