├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
├── pull_request_template.md
└── workflows
│ ├── docker.yml
│ ├── lint.yml
│ └── prettier.yml
├── .gitignore
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── backend
├── .dockerignore
├── .gitignore
├── Dockerfile
├── README.md
├── controllers
│ ├── add_task.go
│ ├── app_handlers.go
│ ├── complete_task.go
│ ├── controllers_test.go
│ ├── delete_task.go
│ ├── edit_task.go
│ ├── get_tasks.go
│ ├── job_queue.go
│ ├── modify_task.go
│ └── websocket.go
├── go.mod
├── go.sum
├── main.go
├── middleware
│ └── ratelimit.go
├── models
│ ├── request_body.go
│ └── task.go
└── utils
│ ├── exec_command.go
│ ├── generate_encryption_secret.go
│ ├── generate_uuid.go
│ ├── tw
│ ├── add_task.go
│ ├── complete_task.go
│ ├── delete_task.go
│ ├── edit_task.go
│ ├── export_tasks.go
│ ├── fetch_tasks.go
│ ├── modify_task.go
│ ├── set_config.go
│ ├── sync_tasks.go
│ └── taskwarrior_test.go
│ └── utils_test.go
├── ccsync.postman_collection.json
├── docker-compose.yml
└── frontend
├── .eslintrc.cjs
├── .gitignore
├── .prettierrc
├── Dockerfile
├── README.md
├── babel.config.js
├── badge.py
├── components.json
├── coverage-report.json
├── index.html
├── jest.config.cjs
├── jest.setup.ts
├── nginx.conf
├── package-lock.json
├── package.json
├── postcss.config.js
├── src
├── App.css
├── App.tsx
├── assets
│ ├── logo.jpg
│ ├── logo.png
│ ├── logo_header.png
│ └── logo_light.png
├── components
│ ├── HomeComponents
│ │ ├── BottomBar
│ │ │ ├── BottomBar.tsx
│ │ │ ├── __tests__
│ │ │ │ ├── BottomBar.test.tsx
│ │ │ │ └── bottom-bar-utils.test.ts
│ │ │ └── bottom-bar-utils.ts
│ │ ├── FAQ
│ │ │ ├── FAQ.tsx
│ │ │ ├── FAQItem.tsx
│ │ │ ├── __tests__
│ │ │ │ ├── FAQ.test.tsx
│ │ │ │ ├── FAQItem.test.tsx
│ │ │ │ └── faq-utils.test.ts
│ │ │ └── faq-utils.ts
│ │ ├── Footer
│ │ │ ├── Footer.tsx
│ │ │ └── __tests__
│ │ │ │ └── Footer.test.tsx
│ │ ├── Hero
│ │ │ ├── CopyButton.tsx
│ │ │ ├── Hero.tsx
│ │ │ ├── ToastNotification.tsx
│ │ │ └── __tests__
│ │ │ │ ├── CopyButton.test.tsx
│ │ │ │ ├── Hero.test.tsx
│ │ │ │ └── ToastNotification.test.tsx
│ │ ├── Navbar
│ │ │ ├── Navbar.tsx
│ │ │ ├── NavbarDesktop.tsx
│ │ │ ├── NavbarMobile.tsx
│ │ │ ├── __tests__
│ │ │ │ ├── Navbar.test.tsx
│ │ │ │ ├── NavbarDesktop.test.tsx
│ │ │ │ ├── NavbarMobile.test.tsx
│ │ │ │ └── navbar-utils.test.ts
│ │ │ └── navbar-utils.ts
│ │ ├── SetupGuide
│ │ │ ├── CopyableCode.tsx
│ │ │ ├── SetupGuide.tsx
│ │ │ └── __tests__
│ │ │ │ ├── CopyableCode.test.tsx
│ │ │ │ └── SetupGuide.test.tsx
│ │ └── Tasks
│ │ │ ├── Pagination.tsx
│ │ │ ├── Tasks.tsx
│ │ │ ├── __tests__
│ │ │ ├── Pagination.test.tsx
│ │ │ ├── Tasks.test.tsx
│ │ │ └── tasks-utils.test.ts
│ │ │ ├── hooks.ts
│ │ │ └── tasks-utils.ts
│ ├── HomePage.tsx
│ ├── LandingComponents
│ │ ├── About
│ │ │ ├── About.tsx
│ │ │ └── __tests__
│ │ │ │ └── About.test.tsx
│ │ ├── Contact
│ │ │ ├── Contact.tsx
│ │ │ └── __tests__
│ │ │ │ └── Contact.test.tsx
│ │ ├── FAQ
│ │ │ ├── FAQ.tsx
│ │ │ ├── FAQItem.tsx
│ │ │ ├── __tests__
│ │ │ │ ├── FAQ.test.tsx
│ │ │ │ ├── FAQItem.test.tsx
│ │ │ │ └── faq-utils.test.ts
│ │ │ └── faq-utils.ts
│ │ ├── Footer
│ │ │ ├── Footer.tsx
│ │ │ └── __tests__
│ │ │ │ └── Footer.test.tsx
│ │ ├── Hero
│ │ │ ├── Hero.tsx
│ │ │ ├── HeroCards.tsx
│ │ │ └── __tests__
│ │ │ │ ├── Hero.test.tsx
│ │ │ │ └── HeroCards.test.tsx
│ │ ├── HowItWorks
│ │ │ ├── HowItWorks.tsx
│ │ │ └── __tests__
│ │ │ │ └── HowItWorks.test.tsx
│ │ └── Navbar
│ │ │ ├── Navbar.tsx
│ │ │ ├── NavbarDesktop.tsx
│ │ │ ├── NavbarMobile.tsx
│ │ │ ├── __tests__
│ │ │ ├── Navbar.test.tsx
│ │ │ ├── NavbarDesktop.test.tsx
│ │ │ └── navbar-utils.test.ts
│ │ │ └── navbar-utils.ts
│ ├── LandingPage.tsx
│ ├── __tests__
│ │ ├── HomePage.test.tsx
│ │ └── LandingPage.test.tsx
│ ├── ui
│ │ ├── accordion.tsx
│ │ ├── avatar.tsx
│ │ ├── badge.tsx
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── dialog.tsx
│ │ ├── dropdown-menu.tsx
│ │ ├── input.tsx
│ │ ├── label.tsx
│ │ ├── navigation-menu.tsx
│ │ ├── select.tsx
│ │ ├── sheet.tsx
│ │ └── table.tsx
│ └── utils
│ │ ├── Icons.tsx
│ │ ├── ScrollToTop.tsx
│ │ ├── URLs.ts
│ │ ├── __tests__
│ │ ├── ScrollToTop.test.tsx
│ │ ├── theme-mode-toggle.test.tsx
│ │ ├── theme-provider.test.tsx
│ │ └── types.test.ts
│ │ ├── theme-mode-toggle.tsx
│ │ ├── theme-provider.tsx
│ │ ├── types.ts
│ │ └── utils.ts
├── index.css
├── lib
│ └── utils.tsx
├── main.tsx
└── vite-env.d.ts
├── tailwind.config.js
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ""
5 | labels: ""
6 | assignees: ""
7 | ---
8 |
9 | **Describe the bug**
10 | A clear and concise description of what the bug is.
11 |
12 | **To Reproduce**
13 | Steps to reproduce the behavior:
14 |
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Desktop (please complete the following information):**
27 |
28 | - OS: [e.g. iOS]
29 | - Browser [e.g. chrome, safari]
30 | - Version [e.g. 22]
31 |
32 | **Smartphone (please complete the following information):**
33 |
34 | - Device: [e.g. iPhone6]
35 | - OS: [e.g. iOS8.1]
36 | - Browser [e.g. stock browser, safari]
37 | - Version [e.g. 22]
38 |
39 | **Additional context**
40 | Add any other context about the problem here.
41 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ""
5 | labels: ""
6 | assignees: ""
7 | ---
8 |
9 | **Is your feature request related to a problem? Please describe.**
10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
11 |
12 | **Describe the solution you'd like**
13 | A clear and concise description of what you want to happen.
14 |
15 | **Describe alternatives you've considered**
16 | A clear and concise description of any alternative solutions or features you've considered.
17 |
18 | **Additional context**
19 | Add any other context or screenshots about the feature request here.
20 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 | ### Description
2 |
3 |
4 |
5 | - Fixes: #(replace_with_the_issue_fixed)
6 |
7 | ### Checklist
8 |
9 | - [ ] Ran `npx prettier --write .` (for formatting)
10 | - [ ] Ran `gofmt -w .` (for Go backend)
11 | - [ ] Ran `npm test` (for JS/TS testing)
12 | - [ ] Added unit tests, if applicable
13 | - [ ] Verified all tests pass
14 | - [ ] Updated documentation, if needed
15 |
16 | ### Additional Notes
17 |
18 |
19 |
--------------------------------------------------------------------------------
/.github/workflows/docker.yml:
--------------------------------------------------------------------------------
1 | name: Build and Push Docker Images to GHCR
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | jobs:
9 | build-and-push-frontend:
10 | runs-on: ubuntu-latest
11 | permissions:
12 | contents: read
13 | packages: write
14 |
15 | steps:
16 | - name: Checkout repository code
17 | uses: actions/checkout@v4
18 |
19 | - name: Set up Docker Buildx
20 | uses: docker/setup-buildx-action@v3
21 |
22 | - name: Log in to GitHub Container Registry (GHCR)
23 | uses: docker/login-action@v3
24 | with:
25 | registry: ghcr.io
26 | username: ${{ github.actor }}
27 | password: ${{ secrets.GITHUB_TOKEN }}
28 |
29 | - name: Build and push frontend image to GHCR
30 | uses: docker/build-push-action@v5
31 | with:
32 | context: ./frontend
33 | push: true
34 | tags: |
35 | ghcr.io/ccsync/frontend:latest
36 | ghcr.io/ccsync/frontend:${{ github.sha }}
37 | cache-from: type=gha
38 | cache-to: type=gha,mode=max
39 |
40 | build-and-push-backend:
41 | runs-on: ubuntu-latest
42 | permissions:
43 | contents: read
44 | packages: write
45 |
46 | steps:
47 | - name: Checkout repository code
48 | uses: actions/checkout@v4
49 |
50 | - name: Set up Docker Buildx
51 | uses: docker/setup-buildx-action@v3
52 |
53 | - name: Log in to GitHub Container Registry (GHCR)
54 | uses: docker/login-action@v3
55 | with:
56 | registry: ghcr.io
57 | username: ${{ github.actor }}
58 | password: ${{ secrets.GITHUB_TOKEN }}
59 |
60 | - name: Build and push backend image to GHCR
61 | uses: docker/build-push-action@v5
62 | with:
63 | context: ./backend
64 | push: true
65 | tags: |
66 | ghcr.io/ccsync/backend:latest
67 | ghcr.io/ccsync/backend:${{ github.sha }}
68 | cache-from: type=gha
69 | cache-to: type=gha,mode=max
70 |
--------------------------------------------------------------------------------
/.github/workflows/lint.yml:
--------------------------------------------------------------------------------
1 | name: Go Format
2 |
3 | on:
4 | push:
5 | branches:
6 | - "main"
7 | pull_request:
8 | branches:
9 | - "*"
10 |
11 | jobs:
12 | gofmt:
13 | runs-on: ubuntu-latest
14 | steps:
15 | - uses: actions/checkout@v3
16 |
17 | - name: Set up Go
18 | uses: actions/setup-go@v4
19 | with:
20 | go-version: "1.21"
21 |
22 | - name: Check Go formatting
23 | run: |
24 | files=$(gofmt -l ./backend)
25 | if [[ -n "$files" ]]; then
26 | echo "The following files are not formatted correctly:"
27 | echo "$files"
28 | echo "Please run 'gofmt -w .' inside the backend directory before committing."
29 | exit 1
30 | else
31 | echo "All Go files are properly formatted."
32 | fi
33 |
--------------------------------------------------------------------------------
/.github/workflows/prettier.yml:
--------------------------------------------------------------------------------
1 | name: Prettier
2 | on:
3 | push:
4 | branches:
5 | - "main"
6 | pull_request:
7 | branches:
8 | - "*"
9 | jobs:
10 | prettier:
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@v3
14 | - name: Use Node.js
15 | uses: actions/setup-node@v3
16 | with:
17 | node-version: "20.x"
18 | - run: npm ci
19 | working-directory: frontend
20 | - run: npx prettier --check .
21 | working-directory: frontend
22 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .backend.env
2 | .frontend.env
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Welcome to CCSync contributing guide
2 |
3 | Thank you for investing your time in contributing to our project! :sparkles:.
4 |
5 | Read our [Code of Conduct](./CODE_OF_CONDUCT.md) to keep our community approachable and respectable.
6 |
7 | In this guide you will get an overview of a contribution workflow from opening an issue, creating a PR, reviewing, and merging the PR.
8 |
9 | ## New contributor guide
10 |
11 | To get an overview of the project, read the [documentation of CCSync](https://its-me-abhishek.github.io/ccsync-docs/).
12 |
13 | ### Issues
14 |
15 | #### Create a new issue
16 |
17 | If you spot a problem with the docs, [search if an issue already exists](https://github.com/its-me-abhishek/ccsync/issues). If a related issue doesn't exist, you can open a new issue using a relevant issue form.
18 |
19 | #### Solve an issue
20 |
21 | Scan through our [existing issues](https://github.com/its-me-abhishek/ccsync/issues) to find one that interests you. You can narrow down the search using `labels` as filters. If you find an issue to work on, and after discussion, it comes out to be a valid issue, you are welcome to open a PR with a fix.
22 |
23 | ### Make Changes
24 |
25 | 1. Fork the repository.
26 |
27 | - Using GitHub Desktop:
28 |
29 | - [Getting started with GitHub Desktop](https://docs.github.com/en/desktop/installing-and-configuring-github-desktop/getting-started-with-github-desktop) will guide you through setting up Desktop.
30 | - Once Desktop is set up, you can use it to [fork the repo](https://docs.github.com/en/desktop/contributing-and-collaborating-using-github-desktop/cloning-and-forking-repositories-from-github-desktop)!
31 |
32 | - Using the command line:
33 | - [Fork the repo](https://docs.github.com/en/github/getting-started-with-github/fork-a-repo#fork-an-example-repository) so that you can make your changes without affecting the original project until you're ready to merge them.
34 |
35 | 2. For more information, see the [development guide](https://its-me-abhishek.github.io/ccsync-docs/).
36 |
37 | 3. Create a new working branch and start with your changes!
38 |
39 | ### Commit your updates
40 |
41 | Commit the changes once you are happy with them.
42 |
43 | Please follow these rules or conventions while committing any new changes:
44 |
45 | - `feat`: new feature for the user, not a new feature for build script
46 | - `fix`: bug fix for the user
47 | - `docs`: changes to the documentation
48 | - `style`: formatting, missing semi colons, etc
49 | - `refactor`: refactoring production code, eg. renaming a variable
50 | - `test`: adding missing tests, refactoring tests
51 | - `chore`: updating grunt tasks, etc., no production code change
52 | - Run `npx prettier --write .` before commiting so as to adhere to the linting scheme of the project's frontend
53 | - Run `gofmt -w .` before commiting so as to adhere to the linting scheme of the project's backend
54 |
55 | ### Pull Request
56 |
57 | When you're finished with the changes, create a pull request, also known as a PR.
58 |
59 | - Don't forget to link PR to issue if you are solving one.
60 | - Enable the checkbox to [allow maintainer edits](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/allowing-changes-to-a-pull-request-branch-created-from-a-fork) so the branch can be updated for a merge.
61 | - If you run into any merge issues, checkout this [git tutorial](https://github.com/skills/resolve-merge-conflicts) to help you resolve merge conflicts and other issues.
62 |
63 | ### Your PR is merged!
64 |
65 | Congratulations :tada::tada:.
66 |
67 | Once your PR is merged, your contributions will be publicly visible in [closed PRs](https://github.com/its-me-abhishek/ccsync/pulls?q=is%3Apr+is%3Aclosed).
68 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Abhishek
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 |
--------------------------------------------------------------------------------
/backend/.dockerignore:
--------------------------------------------------------------------------------
1 | task-3.0.2
2 |
--------------------------------------------------------------------------------
/backend/.gitignore:
--------------------------------------------------------------------------------
1 | .env
2 | task-3.0.2
3 | task-3.0.2.tar.gz
--------------------------------------------------------------------------------
/backend/Dockerfile:
--------------------------------------------------------------------------------
1 | # Use the official Golang image for building the backend
2 | FROM golang:1.23.1-alpine as build
3 |
4 | # Set working directory
5 | WORKDIR /app
6 |
7 | # Copy go.mod and go.sum files
8 | COPY go.mod go.sum ./
9 | RUN go mod tidy
10 |
11 | # Copy the rest of the application code
12 | COPY . .
13 |
14 | # Copy the .env file
15 | # COPY .env .env
16 |
17 | # Install dependencies for Taskwarrior and libuuid
18 | RUN apk add --no-cache \
19 | cmake \
20 | g++ \
21 | make \
22 | tar \
23 | util-linux-dev \
24 | rust \
25 | cargo \
26 | libuuid \
27 | libstdc++ \
28 | libgcc \
29 | # Ensure all required packages are installed
30 | && apk update \
31 | && apk add --no-cache \
32 | libuuid \
33 | libstdc++ \
34 | libgcc \
35 | util-linux
36 |
37 | # Download and build Taskwarrior
38 | RUN wget https://github.com/GothenburgBitFactory/taskwarrior/releases/download/v3.1.0/task-3.1.0.tar.gz -O /tmp/task-3.1.0.tar.gz && \
39 | tar xzf /tmp/task-3.1.0.tar.gz -C /tmp && \
40 | cd /tmp/task-3.1.0 && \
41 | cmake -B build -DCMAKE_BUILD_TYPE=None . && \
42 | cmake --build build && \
43 | cmake --install build
44 |
45 | # Build the Go application
46 | RUN go build -o main .
47 |
48 | # Use a minimal image for running the backend
49 | FROM alpine:3.20
50 | WORKDIR /root/
51 |
52 | # Install runtime dependencies
53 | RUN apk add --no-cache \
54 | libuuid \
55 | libstdc++ \
56 | libgcc \
57 | gnutls
58 |
59 | # Copy the binary and .env file from the build stage
60 | COPY --from=build /app/main .
61 | # COPY --from=build /app/.env .
62 | COPY --from=build /usr/local/bin/task /usr/local/bin/task
63 |
64 | # Expose port 8000
65 | EXPOSE 8000
66 |
67 | # Command to run the executable
68 | CMD ["./main"]
69 |
--------------------------------------------------------------------------------
/backend/README.md:
--------------------------------------------------------------------------------
1 | ## Guide to setup the backend for development purposes:
2 |
3 | - Download the requirements
4 |
5 | ```bash
6 | go mod download
7 | go mod tidy
8 | ```
9 |
10 | - Go to [Google cloud credential page](https://console.cloud.google.com/apis/credentials) for generating client id and secret.
11 |
12 | - Add the Client ID and secret as an environment variable
13 | - Sample .env format:
14 |
15 | ```bash
16 | CLIENT_ID="client_ID"
17 | CLIENT_SEC="client_SECRET"
18 | REDIRECT_URL_DEV="http://localhost:8000/auth/callback"
19 | SESSION_KEY=""
20 | # If using Docker
21 | FRONTEND_ORIGIN_DEV="http://localhost"
22 | CONTAINER_ORIGIN="http://YOUR_CONTAINER_NAME:8080/"
23 | # Else if using npm
24 | FRONTEND_ORIGIN_DEV="http://localhost:5173"
25 | CONTAINER_ORIGIN="http://localhost:8080/"
26 | ```
27 |
28 | Common pitfall: use the value
29 |
30 | ```
31 | FRONTEND_ORIGIN_DEV="http://localhost"
32 | CONTAINER_ORIGIN="http://YOUR_CONTAINER_NAME:8080/"
33 | ```
34 |
35 | only while using Docker Container
36 |
37 | use
38 |
39 | ```
40 | FRONTEND_ORIGIN_DEV="http://localhost:5173"
41 | CONTAINER_ORIGIN="http://localhost:8080/"
42 | ```
43 |
44 | if you want to run by `npm run dev`
45 |
46 | - Run the application:
47 |
48 | ```bash
49 | go mod download
50 | go mod tidy
51 | ```
52 |
53 | - Run the backend container only:
54 | ```bash
55 | docker-compose build backend
56 | docker-compose up
57 | ```
58 |
--------------------------------------------------------------------------------
/backend/controllers/add_task.go:
--------------------------------------------------------------------------------
1 | package controllers
2 |
3 | import (
4 | "ccsync_backend/models"
5 | "ccsync_backend/utils/tw"
6 | "encoding/json"
7 | "fmt"
8 | "io"
9 | "net/http"
10 | )
11 |
12 | var GlobalJobQueue *JobQueue
13 |
14 | func AddTaskHandler(w http.ResponseWriter, r *http.Request) {
15 | if r.Method == http.MethodPost {
16 | body, err := io.ReadAll(r.Body)
17 | if err != nil {
18 | http.Error(w, fmt.Sprintf("error reading request body: %v", err), http.StatusBadRequest)
19 | return
20 | }
21 | defer r.Body.Close()
22 | // fmt.Printf("Raw request body: %s\n", string(body))
23 |
24 | var requestBody models.AddTaskRequestBody
25 |
26 | err = json.Unmarshal(body, &requestBody)
27 | if err != nil {
28 | http.Error(w, fmt.Sprintf("error decoding request body: %v", err), http.StatusBadRequest)
29 | return
30 | }
31 | email := requestBody.Email
32 | encryptionSecret := requestBody.EncryptionSecret
33 | uuid := requestBody.UUID
34 | description := requestBody.Description
35 | project := requestBody.Project
36 | priority := requestBody.Priority
37 | dueDate := requestBody.DueDate
38 | tags := requestBody.Tags
39 |
40 | if description == "" {
41 | http.Error(w, "Description is required, and cannot be empty!", http.StatusBadRequest)
42 | return
43 | }
44 | if dueDate == "" {
45 | http.Error(w, "Due Date is required, and cannot be empty!", http.StatusBadRequest)
46 | return
47 | }
48 | job := Job{
49 | Name: "Add Task",
50 | Execute: func() error {
51 | return tw.AddTaskToTaskwarrior(email, encryptionSecret, uuid, description, project, priority, dueDate, tags)
52 | },
53 | }
54 | GlobalJobQueue.AddJob(job)
55 | w.WriteHeader(http.StatusAccepted)
56 | return
57 | }
58 | http.Error(w, "Invalid request method", http.StatusMethodNotAllowed)
59 | }
60 |
--------------------------------------------------------------------------------
/backend/controllers/app_handlers.go:
--------------------------------------------------------------------------------
1 | package controllers
2 |
3 | import (
4 | "ccsync_backend/utils"
5 | "context"
6 | "encoding/json"
7 | "log"
8 | "net/http"
9 | "os"
10 |
11 | "github.com/gorilla/sessions"
12 | "golang.org/x/oauth2"
13 | )
14 |
15 | type App struct {
16 | Config *oauth2.Config
17 | SessionStore *sessions.CookieStore
18 | UserEmail string
19 | EncryptionSecret string
20 | UUID string
21 | }
22 |
23 | func (a *App) OAuthHandler(w http.ResponseWriter, r *http.Request) {
24 | url := a.Config.AuthCodeURL("state", oauth2.AccessTypeOffline)
25 | http.Redirect(w, r, url, http.StatusTemporaryRedirect)
26 | }
27 |
28 | // fetching the info
29 | func (a *App) OAuthCallbackHandler(w http.ResponseWriter, r *http.Request) {
30 | log.Println("Fetching user info...")
31 |
32 | code := r.URL.Query().Get("code")
33 |
34 | t, err := a.Config.Exchange(context.Background(), code)
35 | if err != nil {
36 | http.Error(w, err.Error(), http.StatusBadRequest)
37 | return
38 | }
39 |
40 | client := a.Config.Client(context.Background(), t)
41 | resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
42 | if err != nil {
43 | http.Error(w, err.Error(), http.StatusBadRequest)
44 | return
45 | }
46 | defer resp.Body.Close()
47 |
48 | var userInfo map[string]interface{}
49 | if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil {
50 | http.Error(w, err.Error(), http.StatusInternalServerError)
51 | return
52 | }
53 |
54 | email, okEmail := userInfo["email"].(string)
55 | id, okId := userInfo["id"].(string)
56 | if !okEmail || !okId {
57 | http.Error(w, "Unable to retrieve user info", http.StatusInternalServerError)
58 | return
59 | }
60 | uuidStr := utils.GenerateUUID(email, id)
61 | encryptionSecret := utils.GenerateEncryptionSecret(uuidStr, email, id)
62 |
63 | userInfo["uuid"] = uuidStr
64 | userInfo["encryption_secret"] = encryptionSecret
65 | session, _ := a.SessionStore.Get(r, "session-name")
66 | session.Values["user"] = userInfo
67 | if err := session.Save(r, w); err != nil {
68 | http.Error(w, err.Error(), http.StatusInternalServerError)
69 | return
70 | }
71 |
72 | log.Printf("User Info: %v", userInfo)
73 |
74 | frontendOriginDev := os.Getenv("FRONTEND_ORIGIN_DEV")
75 | http.Redirect(w, r, frontendOriginDev+"/home", http.StatusSeeOther)
76 | }
77 |
78 | func (a *App) UserInfoHandler(w http.ResponseWriter, r *http.Request) {
79 | session, _ := a.SessionStore.Get(r, "session-name")
80 | userInfo, ok := session.Values["user"].(map[string]interface{})
81 | if !ok || userInfo == nil {
82 | http.Error(w, "No user info available", http.StatusUnauthorized)
83 | return
84 | }
85 |
86 | log.Printf("Sending User Info: %v", userInfo)
87 | w.Header().Set("Content-Type", "application/json")
88 | json.NewEncoder(w).Encode(userInfo)
89 | }
90 |
91 | func (a *App) EnableCORS(handler http.Handler) http.Handler {
92 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
93 | allowedOrigin := os.Getenv("FRONTEND_ORIGIN_DEV") // frontend origin
94 | w.Header().Set("Access-Control-Allow-Origin", allowedOrigin)
95 | w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
96 | w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
97 | w.Header().Set("Access-Control-Allow-Credentials", "true") // to allow credentials
98 | if r.Method == "OPTIONS" {
99 | w.WriteHeader(http.StatusOK)
100 | return
101 | }
102 | handler.ServeHTTP(w, r)
103 | })
104 | }
105 |
106 | // logout and delete session
107 | func (a *App) LogoutHandler(w http.ResponseWriter, r *http.Request) {
108 | session, _ := a.SessionStore.Get(r, "session-name")
109 | session.Options.MaxAge = -1
110 | if err := session.Save(r, w); err != nil {
111 | http.Error(w, err.Error(), http.StatusInternalServerError)
112 | return
113 | }
114 |
115 | w.WriteHeader(http.StatusOK)
116 | log.Print("User has logged out")
117 | }
118 |
--------------------------------------------------------------------------------
/backend/controllers/complete_task.go:
--------------------------------------------------------------------------------
1 | package controllers
2 |
3 | import (
4 | "ccsync_backend/models"
5 | "ccsync_backend/utils/tw"
6 | "encoding/json"
7 | "fmt"
8 | "io"
9 | "net/http"
10 | )
11 |
12 | func CompleteTaskHandler(w http.ResponseWriter, r *http.Request) {
13 | if r.Method == http.MethodPost {
14 | body, err := io.ReadAll(r.Body)
15 | if err != nil {
16 | http.Error(w, fmt.Sprintf("error reading request body: %v", err), http.StatusBadRequest)
17 | return
18 | }
19 | defer r.Body.Close()
20 |
21 | // fmt.Printf("Raw request body: %s\n", string(body))
22 |
23 | var requestBody models.CompleteTaskRequestBody
24 |
25 | err = json.Unmarshal(body, &requestBody)
26 | if err != nil {
27 | http.Error(w, fmt.Sprintf("error decoding request body: %v", err), http.StatusBadRequest)
28 | return
29 | }
30 |
31 | email := requestBody.Email
32 | encryptionSecret := requestBody.EncryptionSecret
33 | uuid := requestBody.UUID
34 | taskuuid := requestBody.TaskUUID
35 |
36 | if taskuuid == "" {
37 | http.Error(w, "taskuuid is required", http.StatusBadRequest)
38 | return
39 | }
40 |
41 | // if err := tw.CompleteTaskInTaskwarrior(email, encryptionSecret, uuid, taskuuid); err != nil {
42 | // http.Error(w, err.Error(), http.StatusInternalServerError)
43 | // return
44 | // }
45 | job := Job{
46 | Name: "Complete Task",
47 | Execute: func() error {
48 | return tw.CompleteTaskInTaskwarrior(email, encryptionSecret, uuid, taskuuid)
49 | },
50 | }
51 | GlobalJobQueue.AddJob(job)
52 | w.WriteHeader(http.StatusAccepted)
53 | return
54 | }
55 | http.Error(w, "Invalid request method", http.StatusMethodNotAllowed)
56 | }
57 |
--------------------------------------------------------------------------------
/backend/controllers/delete_task.go:
--------------------------------------------------------------------------------
1 | package controllers
2 |
3 | import (
4 | "ccsync_backend/models"
5 | "ccsync_backend/utils/tw"
6 | "encoding/json"
7 | "fmt"
8 | "io"
9 | "net/http"
10 | )
11 |
12 | func DeleteTaskHandler(w http.ResponseWriter, r *http.Request) {
13 | if r.Method == http.MethodPost {
14 | body, err := io.ReadAll(r.Body)
15 | if err != nil {
16 | http.Error(w, fmt.Sprintf("error reading request body: %v", err), http.StatusBadRequest)
17 | return
18 | }
19 | defer r.Body.Close()
20 |
21 | var requestBody models.DeleteTaskRequestBody
22 |
23 | err = json.Unmarshal(body, &requestBody)
24 | if err != nil {
25 | http.Error(w, fmt.Sprintf("error decoding request body: %v", err), http.StatusBadRequest)
26 | return
27 | }
28 |
29 | email := requestBody.Email
30 | encryptionSecret := requestBody.EncryptionSecret
31 | uuid := requestBody.UUID
32 | taskuuid := requestBody.TaskUUID
33 |
34 | if taskuuid == "" {
35 | http.Error(w, "taskuuid is required", http.StatusBadRequest)
36 | return
37 | }
38 |
39 | // if err := tw.DeleteTaskInTaskwarrior(email, encryptionSecret, uuid, taskuuid); err != nil {
40 | // http.Error(w, err.Error(), http.StatusInternalServerError)
41 | // return
42 | // }
43 | job := Job{
44 | Name: "Delete Task",
45 | Execute: func() error {
46 | return tw.DeleteTaskInTaskwarrior(email, encryptionSecret, uuid, taskuuid)
47 | },
48 | }
49 | GlobalJobQueue.AddJob(job)
50 | w.WriteHeader(http.StatusAccepted)
51 | return
52 | }
53 | http.Error(w, "Invalid request method", http.StatusMethodNotAllowed)
54 | }
55 |
--------------------------------------------------------------------------------
/backend/controllers/edit_task.go:
--------------------------------------------------------------------------------
1 | package controllers
2 |
3 | import (
4 | "ccsync_backend/models"
5 | "ccsync_backend/utils/tw"
6 | "encoding/json"
7 | "fmt"
8 | "io"
9 | "net/http"
10 | )
11 |
12 | func EditTaskHandler(w http.ResponseWriter, r *http.Request) {
13 | if r.Method == http.MethodPost {
14 | body, err := io.ReadAll(r.Body)
15 | if err != nil {
16 | http.Error(w, fmt.Sprintf("error reading request body: %v", err), http.StatusBadRequest)
17 | return
18 | }
19 | defer r.Body.Close()
20 |
21 | // fmt.Printf("Raw request body: %s\n", string(body))
22 |
23 | var requestBody models.EditTaskRequestBody
24 |
25 | err = json.Unmarshal(body, &requestBody)
26 | if err != nil {
27 | http.Error(w, fmt.Sprintf("error decoding request body: %v", err), http.StatusBadRequest)
28 | return
29 | }
30 |
31 | email := requestBody.Email
32 | encryptionSecret := requestBody.EncryptionSecret
33 | uuid := requestBody.UUID
34 | taskID := requestBody.TaskID
35 | description := requestBody.Description
36 | tags := requestBody.Tags
37 |
38 | if taskID == "" {
39 | http.Error(w, "taskID is required", http.StatusBadRequest)
40 | return
41 | }
42 |
43 | // if err := tw.EditTaskInTaskwarrior(uuid, description, email, encryptionSecret, taskID); err != nil {
44 | // http.Error(w, err.Error(), http.StatusInternalServerError)
45 | // return
46 | // }
47 |
48 | job := Job{
49 | Name: "Edit Task",
50 | Execute: func() error {
51 | return tw.EditTaskInTaskwarrior(uuid, description, email, encryptionSecret, taskID, tags)
52 | },
53 | }
54 | GlobalJobQueue.AddJob(job)
55 | w.WriteHeader(http.StatusAccepted)
56 |
57 | return
58 | }
59 | http.Error(w, "Invalid request method", http.StatusMethodNotAllowed)
60 | }
61 |
--------------------------------------------------------------------------------
/backend/controllers/get_tasks.go:
--------------------------------------------------------------------------------
1 | package controllers
2 |
3 | import (
4 | "ccsync_backend/utils/tw"
5 | "encoding/json"
6 | "net/http"
7 | "os"
8 | )
9 |
10 | // helps to fetch tasks using '/tasks' route
11 | func TasksHandler(w http.ResponseWriter, r *http.Request) {
12 | email := r.URL.Query().Get("email")
13 | encryptionSecret := r.URL.Query().Get("encryptionSecret")
14 | UUID := r.URL.Query().Get("UUID")
15 | origin := os.Getenv("CONTAINER_ORIGIN")
16 | if email == "" || encryptionSecret == "" || UUID == "" {
17 | http.Error(w, "Missing required parameters", http.StatusBadRequest)
18 | return
19 | }
20 |
21 | if r.Method == http.MethodGet {
22 | tasks, _ := tw.FetchTasksFromTaskwarrior(email, encryptionSecret, origin, UUID)
23 | if tasks == nil {
24 | http.Error(w, "Failed to fetch tasks at backend", http.StatusInternalServerError)
25 | return
26 | }
27 | w.Header().Set("Content-Type", "application/json")
28 | json.NewEncoder(w).Encode(tasks)
29 | return
30 | }
31 |
32 | http.Error(w, "Invalid request method", http.StatusMethodNotAllowed)
33 | }
34 |
--------------------------------------------------------------------------------
/backend/controllers/job_queue.go:
--------------------------------------------------------------------------------
1 | package controllers
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "sync"
7 | )
8 |
9 | type Job struct {
10 | Name string
11 | Execute func() error
12 | }
13 |
14 | type JobQueue struct {
15 | jobChannel chan Job
16 | wg sync.WaitGroup
17 | }
18 |
19 | func NewJobQueue() *JobQueue {
20 | queue := &JobQueue{
21 | jobChannel: make(chan Job, 100),
22 | }
23 | go queue.processJobs()
24 | return queue
25 | }
26 |
27 | func (q *JobQueue) AddJob(job Job) {
28 | q.wg.Add(1)
29 | q.jobChannel <- job
30 |
31 | // notify job queued
32 | go BroadcastJobStatus(JobStatus{
33 | Job: job.Name,
34 | Status: "queued",
35 | })
36 | }
37 |
38 | func (q *JobQueue) processJobs() {
39 | for job := range q.jobChannel {
40 | fmt.Printf("Executing job: %s\n", job.Name)
41 |
42 | go BroadcastJobStatus(JobStatus{
43 | Job: job.Name,
44 | Status: "in-progress",
45 | })
46 |
47 | if err := job.Execute(); err != nil {
48 | log.Printf("Error executing job %s: %v\n", job.Name, err)
49 |
50 | go BroadcastJobStatus(JobStatus{
51 | Job: job.Name,
52 | Status: "failure",
53 | })
54 | } else {
55 | log.Printf("Success in executing job %s\n", job.Name)
56 |
57 | go BroadcastJobStatus(JobStatus{
58 | Job: job.Name,
59 | Status: "success",
60 | })
61 | }
62 |
63 | q.wg.Done()
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/backend/controllers/modify_task.go:
--------------------------------------------------------------------------------
1 | package controllers
2 |
3 | import (
4 | "ccsync_backend/models"
5 | "ccsync_backend/utils/tw"
6 | "encoding/json"
7 | "fmt"
8 | "io"
9 | "net/http"
10 | )
11 |
12 | func ModifyTaskHandler(w http.ResponseWriter, r *http.Request) {
13 | if r.Method == http.MethodPost {
14 | body, err := io.ReadAll(r.Body)
15 | if err != nil {
16 | http.Error(w, fmt.Sprintf("error reading request body: %v", err), http.StatusBadRequest)
17 | return
18 | }
19 | defer r.Body.Close()
20 |
21 | // fmt.Printf("Raw request body: %s\n", string(body))
22 |
23 | var requestBody models.ModifyTaskRequestBody
24 |
25 | err = json.Unmarshal(body, &requestBody)
26 | if err != nil {
27 | http.Error(w, fmt.Sprintf("error decoding request body: %v", err), http.StatusBadRequest)
28 | return
29 | }
30 | email := requestBody.Email
31 | encryptionSecret := requestBody.EncryptionSecret
32 | uuid := requestBody.UUID
33 | taskID := requestBody.TaskID
34 | description := requestBody.Description
35 | project := requestBody.Project
36 | priority := requestBody.Priority
37 | status := requestBody.Status
38 | due := requestBody.Due
39 | tags := requestBody.Tags
40 |
41 | if description == "" {
42 | http.Error(w, "Description is required, and cannot be empty!", http.StatusBadRequest)
43 | return
44 | }
45 | if taskID == "" {
46 | http.Error(w, "taskID is required", http.StatusBadRequest)
47 | return
48 | }
49 |
50 | // if err := tw.ModifyTaskInTaskwarrior(uuid, description, project, priority, status, due, email, encryptionSecret, taskID); err != nil {
51 | // http.Error(w, err.Error(), http.StatusInternalServerError)
52 | // return
53 | // }
54 |
55 | job := Job{
56 | Name: "Modify Task",
57 | Execute: func() error {
58 | return tw.ModifyTaskInTaskwarrior(uuid, description, project, priority, status, due, email, encryptionSecret, taskID, tags)
59 | },
60 | }
61 | GlobalJobQueue.AddJob(job)
62 | w.WriteHeader(http.StatusAccepted)
63 | return
64 | }
65 | http.Error(w, "Invalid request method", http.StatusMethodNotAllowed)
66 | }
67 |
--------------------------------------------------------------------------------
/backend/controllers/websocket.go:
--------------------------------------------------------------------------------
1 | package controllers
2 |
3 | import (
4 | "log"
5 | "net/http"
6 |
7 | "github.com/gorilla/websocket"
8 | )
9 |
10 | type JobStatus struct {
11 | Job string `json:"job"`
12 | Status string `json:"status"`
13 | }
14 |
15 | var upgrader = websocket.Upgrader{
16 | CheckOrigin: func(r *http.Request) bool {
17 | return true
18 | },
19 | }
20 |
21 | var clients = make(map[*websocket.Conn]bool)
22 | var broadcast = make(chan JobStatus)
23 |
24 | func WebSocketHandler(w http.ResponseWriter, r *http.Request) {
25 | ws, err := upgrader.Upgrade(w, r, nil)
26 | if err != nil {
27 | log.Println("WebSocket Upgrade Error:", err)
28 | return
29 | }
30 | defer ws.Close()
31 |
32 | clients[ws] = true
33 | log.Println("New WebSocket connection established!")
34 |
35 | for {
36 | _, _, err := ws.ReadMessage()
37 | if err != nil {
38 | delete(clients, ws)
39 | log.Println("WebSocket connection closed:", err)
40 | break
41 | }
42 | }
43 | }
44 |
45 | func BroadcastJobStatus(jobStatus JobStatus) {
46 | log.Printf("Broadcasting: %+v\n", jobStatus)
47 | broadcast <- jobStatus
48 | }
49 |
50 | func JobStatusManager() {
51 | for {
52 | jobStatus := <-broadcast
53 | log.Printf("Sending to clients: %+v\n", jobStatus)
54 | for client := range clients {
55 | err := client.WriteJSON(jobStatus)
56 | if err != nil {
57 | log.Printf("WebSocket Write Error: %v\n", err)
58 | client.Close()
59 | delete(clients, client)
60 | }
61 | }
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/backend/go.mod:
--------------------------------------------------------------------------------
1 | module ccsync_backend
2 |
3 | go 1.19
4 |
5 | require (
6 | github.com/gorilla/sessions v1.2.2
7 | golang.org/x/oauth2 v0.20.0
8 | )
9 |
10 | require (
11 | github.com/davecgh/go-spew v1.1.1 // indirect
12 | github.com/gorilla/securecookie v1.1.2 // indirect
13 | github.com/pmezard/go-difflib v1.0.0 // indirect
14 | gopkg.in/yaml.v3 v3.0.1 // indirect
15 | )
16 |
17 | require (
18 | cloud.google.com/go/compute/metadata v0.3.0 // indirect
19 | github.com/google/uuid v1.6.0
20 | github.com/gorilla/websocket v1.5.3
21 | github.com/joho/godotenv v1.5.1
22 | github.com/stretchr/testify v1.9.0
23 | )
24 |
--------------------------------------------------------------------------------
/backend/go.sum:
--------------------------------------------------------------------------------
1 | cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
2 | cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
5 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
6 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
7 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
8 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
9 | github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
10 | github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
11 | github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY=
12 | github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
13 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
14 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
15 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
16 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
17 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
18 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
19 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
20 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
21 | golang.org/x/oauth2 v0.20.0 h1:4mQdhULixXKP1rwYBW0vAijoXnkTG0BLCDRzfe1idMo=
22 | golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
23 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
24 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
25 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
26 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
27 |
--------------------------------------------------------------------------------
/backend/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/gob"
5 | "log"
6 | "net/http"
7 | "os"
8 | "time"
9 |
10 | "github.com/gorilla/sessions"
11 | "github.com/joho/godotenv"
12 | "golang.org/x/oauth2"
13 | "golang.org/x/oauth2/google"
14 |
15 | "ccsync_backend/controllers"
16 | "ccsync_backend/middleware"
17 | )
18 |
19 | func main() {
20 | if os.Getenv("ENV") != "production" {
21 | _ = godotenv.Load()
22 | log.Println("Loaded")
23 | } else {
24 | log.Println("Continue")
25 | }
26 |
27 | controllers.GlobalJobQueue = controllers.NewJobQueue()
28 | // OAuth2 client credentials
29 | clientID := os.Getenv("CLIENT_ID")
30 | clientSecret := os.Getenv("CLIENT_SEC")
31 | redirectURL := os.Getenv("REDIRECT_URL_DEV")
32 |
33 | // OAuth2 configuration
34 | conf := &oauth2.Config{
35 | ClientID: clientID,
36 | ClientSecret: clientSecret,
37 | RedirectURL: redirectURL,
38 | Scopes: []string{"email", "profile"},
39 | Endpoint: google.Endpoint,
40 | }
41 |
42 | // Create a session store
43 | sessionKey := []byte(os.Getenv("SESSION_KEY"))
44 | if len(sessionKey) == 0 {
45 | log.Fatal("SESSION_KEY environment variable is not set or empty")
46 | }
47 | store := sessions.NewCookieStore(sessionKey)
48 | gob.Register(map[string]interface{}{})
49 |
50 | app := controllers.App{Config: conf, SessionStore: store}
51 | mux := http.NewServeMux()
52 |
53 | //Rate limiter middleware that allows 50 requests per 30 seconds per IP
54 | limiter := middleware.NewRateLimiter(30*time.Second, 50)
55 | rateLimitedHandler := middleware.RateLimitMiddleware(limiter)
56 |
57 | mux.Handle("/auth/oauth", rateLimitedHandler(http.HandlerFunc(app.OAuthHandler)))
58 | mux.Handle("/auth/callback", rateLimitedHandler(http.HandlerFunc(app.OAuthCallbackHandler)))
59 | mux.Handle("/api/user", rateLimitedHandler(http.HandlerFunc(app.UserInfoHandler)))
60 | mux.Handle("/auth/logout", rateLimitedHandler(http.HandlerFunc(app.LogoutHandler)))
61 | mux.Handle("/tasks", rateLimitedHandler(http.HandlerFunc(controllers.TasksHandler)))
62 | mux.Handle("/add-task", rateLimitedHandler(http.HandlerFunc(controllers.AddTaskHandler)))
63 | mux.Handle("/edit-task", rateLimitedHandler(http.HandlerFunc(controllers.EditTaskHandler)))
64 | mux.Handle("/modify-task", rateLimitedHandler(http.HandlerFunc(controllers.ModifyTaskHandler)))
65 | mux.Handle("/complete-task", rateLimitedHandler(http.HandlerFunc(controllers.CompleteTaskHandler)))
66 | mux.Handle("/delete-task", rateLimitedHandler(http.HandlerFunc(controllers.DeleteTaskHandler)))
67 |
68 | mux.HandleFunc("/ws", controllers.WebSocketHandler)
69 |
70 | go controllers.JobStatusManager()
71 | log.Println("Server started at :8000")
72 | if err := http.ListenAndServe(":8000", app.EnableCORS(mux)); err != nil {
73 | log.Fatal(err)
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/backend/middleware/ratelimit.go:
--------------------------------------------------------------------------------
1 | package middleware
2 |
3 | import (
4 | "fmt"
5 | "net/http"
6 | "strings"
7 | "sync"
8 | "time"
9 | )
10 |
11 | type RateLimiter struct {
12 | sync.RWMutex
13 | requests map[string]*FixedWindow
14 | windowSize time.Duration
15 | maxRequests int
16 | cleanupTick time.Duration
17 | }
18 |
19 | type FixedWindow struct {
20 | count int
21 | windowStart time.Time
22 | }
23 |
24 | func NewRateLimiter(windowSize time.Duration, maxRequests int) *RateLimiter {
25 | limiter := &RateLimiter{
26 | requests: make(map[string]*FixedWindow),
27 | windowSize: windowSize,
28 | maxRequests: maxRequests,
29 | cleanupTick: time.Minute,
30 | }
31 |
32 | go limiter.startCleanup()
33 | return limiter
34 | }
35 |
36 | func (rl *RateLimiter) startCleanup() {
37 | ticker := time.NewTicker(rl.cleanupTick)
38 | defer ticker.Stop()
39 |
40 | for range ticker.C {
41 | rl.cleanup()
42 | }
43 | }
44 |
45 | func (rl *RateLimiter) cleanup() {
46 | rl.Lock()
47 | defer rl.Unlock()
48 |
49 | now := time.Now()
50 | for ip, window := range rl.requests {
51 | if now.Sub(window.windowStart) > rl.windowSize*2 {
52 | delete(rl.requests, ip)
53 | }
54 | }
55 | }
56 |
57 | func (rl *RateLimiter) IsAllowed(ip string) (bool, time.Time) {
58 | rl.Lock()
59 | defer rl.Unlock()
60 |
61 | now := time.Now()
62 | window, exists := rl.requests[ip]
63 |
64 | if !exists {
65 | rl.requests[ip] = &FixedWindow{
66 | count: 1,
67 | windowStart: now,
68 | }
69 | return true, now.Add(rl.windowSize)
70 | }
71 |
72 | windowEnd := window.windowStart.Add(rl.windowSize)
73 | if now.After(windowEnd) {
74 | window.count = 1
75 | window.windowStart = now
76 | return true, now.Add(rl.windowSize)
77 | }
78 |
79 | if window.count < rl.maxRequests {
80 | window.count++
81 | return true, windowEnd
82 | }
83 |
84 | return false, windowEnd
85 | }
86 |
87 | func RateLimitMiddleware(limiter *RateLimiter) func(http.Handler) http.Handler {
88 | return func(next http.Handler) http.Handler {
89 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
90 | ip := getRealIP(r)
91 |
92 | allowed, resetTime := limiter.IsAllowed(ip)
93 | if !allowed {
94 | w.Header().Set("X-RateLimit-Limit", fmt.Sprintf("%d", limiter.maxRequests))
95 | w.Header().Set("X-RateLimit-Reset", resetTime.Format(time.RFC1123))
96 | w.Header().Set("Retry-After", fmt.Sprintf("%.0f", time.Until(resetTime).Seconds()))
97 | http.Error(w, "Rate limit exceeded. Please try again later.", http.StatusTooManyRequests)
98 | return
99 | }
100 |
101 | next.ServeHTTP(w, r)
102 | })
103 | }
104 | }
105 |
106 | func getRealIP(r *http.Request) string {
107 | ip := r.Header.Get("X-Real-IP")
108 | if ip != "" {
109 | return ip
110 | }
111 |
112 | ip = r.Header.Get("X-Forwarded-For")
113 | if ip != "" {
114 | ips := strings.Split(ip, ",")
115 | if len(ips) > 0 {
116 | return strings.TrimSpace(ips[0])
117 | }
118 | }
119 |
120 | ip = r.RemoteAddr
121 | if idx := strings.Index(ip, ":"); idx != -1 {
122 | ip = ip[:idx]
123 | }
124 |
125 | return ip
126 | }
127 |
--------------------------------------------------------------------------------
/backend/models/request_body.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | // Request body for task related request handlers
4 | type AddTaskRequestBody struct {
5 | Email string `json:"email"`
6 | EncryptionSecret string `json:"encryptionSecret"`
7 | UUID string `json:"UUID"`
8 | Description string `json:"description"`
9 | Project string `json:"project"`
10 | Priority string `json:"priority"`
11 | DueDate string `json:"due"`
12 | Tags []string `json:"tags"`
13 | }
14 | type ModifyTaskRequestBody struct {
15 | Email string `json:"email"`
16 | EncryptionSecret string `json:"encryptionSecret"`
17 | UUID string `json:"UUID"`
18 | TaskID string `json:"taskid"`
19 | Description string `json:"description"`
20 | Project string `json:"project"`
21 | Priority string `json:"priority"`
22 | Status string `json:"status"`
23 | Due string `json:"due"`
24 | Tags []string `json:"tags"`
25 | }
26 | type EditTaskRequestBody struct {
27 | Email string `json:"email"`
28 | EncryptionSecret string `json:"encryptionSecret"`
29 | UUID string `json:"UUID"`
30 | TaskID string `json:"taskid"`
31 | Description string `json:"description"`
32 | Tags []string `json:"tags"`
33 | }
34 | type CompleteTaskRequestBody struct {
35 | Email string `json:"email"`
36 | EncryptionSecret string `json:"encryptionSecret"`
37 | UUID string `json:"UUID"`
38 | TaskUUID string `json:"taskuuid"`
39 | }
40 | type DeleteTaskRequestBody struct {
41 | Email string `json:"email"`
42 | EncryptionSecret string `json:"encryptionSecret"`
43 | UUID string `json:"UUID"`
44 | TaskUUID string `json:"taskuuid"`
45 | }
46 |
--------------------------------------------------------------------------------
/backend/models/task.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | // Annotation represents a single annotation entry on a Taskwarrior task
4 | type Annotation struct {
5 | Entry string `json:"entry"`
6 | Description string `json:"description"`
7 | }
8 |
9 | // Task represents a Taskwarrior task
10 | type Task struct {
11 | ID int32 `json:"id"`
12 | Description string `json:"description"`
13 | Project string `json:"project"`
14 | Tags []string `json:"tags"`
15 | Status string `json:"status"`
16 | UUID string `json:"uuid"`
17 | Urgency float32 `json:"urgency"`
18 | Priority string `json:"priority"`
19 | Due string `json:"due"`
20 | Start string `json:"start"`
21 | End string `json:"end"`
22 | Entry string `json:"entry"`
23 | Wait string `json:"wait"`
24 | Modified string `json:"modified"`
25 | Depends []string `json:"depends"`
26 | RType string `json:"rtype"`
27 | Recur string `json:"recur"`
28 | Annotations []Annotation `json:"annotations"`
29 | }
30 |
--------------------------------------------------------------------------------
/backend/utils/exec_command.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import "os/exec"
4 |
5 | func ExecCommandInDir(dir, command string, args ...string) error {
6 | cmd := exec.Command(command, args...)
7 | cmd.Dir = dir
8 | return cmd.Run()
9 | }
10 |
11 | func ExecCommand(command string, args ...string) error {
12 | cmd := exec.Command(command, args...)
13 | return cmd.Run()
14 | }
15 |
16 | func ExecCommandForOutputInDir(dir, command string, args ...string) ([]byte, error) {
17 | cmd := exec.Command(command, args...)
18 | cmd.Dir = dir
19 | return cmd.Output()
20 | }
21 |
--------------------------------------------------------------------------------
/backend/utils/generate_encryption_secret.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "crypto/sha256"
5 | "encoding/hex"
6 | )
7 |
8 | // logic to generate encryption secret for tw config
9 | func GenerateEncryptionSecret(uuidStr, email, id string) string {
10 | hash := sha256.New()
11 | hash.Write([]byte(uuidStr + email + id))
12 | return hex.EncodeToString(hash.Sum(nil))
13 | }
14 |
--------------------------------------------------------------------------------
/backend/utils/generate_uuid.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import "github.com/google/uuid"
4 |
5 | // logic to generate client ID for tw config
6 | func GenerateUUID(email, id string) string {
7 | namespace := uuid.NewMD5(uuid.NameSpaceOID, []byte(email+id))
8 | return namespace.String()
9 | }
10 |
--------------------------------------------------------------------------------
/backend/utils/tw/add_task.go:
--------------------------------------------------------------------------------
1 | package tw
2 |
3 | import (
4 | "ccsync_backend/utils"
5 | "fmt"
6 | "os"
7 | "strings"
8 | )
9 |
10 | // add task to the user's tw client
11 | func AddTaskToTaskwarrior(email, encryptionSecret, uuid, description, project, priority, dueDate string, tags []string) error {
12 | if err := utils.ExecCommand("rm", "-rf", "/root/.task"); err != nil {
13 | return fmt.Errorf("error deleting Taskwarrior data: %v", err)
14 | }
15 |
16 | tempDir, err := os.MkdirTemp("", "taskwarrior-"+email)
17 | if err != nil {
18 | return fmt.Errorf("failed to create temporary directory: %v", err)
19 | }
20 | defer os.RemoveAll(tempDir)
21 |
22 | origin := os.Getenv("CONTAINER_ORIGIN")
23 | if err := SetTaskwarriorConfig(tempDir, encryptionSecret, origin, uuid); err != nil {
24 | return err
25 | }
26 |
27 | if err := SyncTaskwarrior(tempDir); err != nil {
28 | return err
29 | }
30 |
31 | cmdArgs := []string{"add", description}
32 | if project != "" {
33 | cmdArgs = append(cmdArgs, "project:"+project)
34 | }
35 | if priority != "" {
36 | cmdArgs = append(cmdArgs, "priority:"+priority)
37 | }
38 | if dueDate != "" {
39 | cmdArgs = append(cmdArgs, "due:"+dueDate)
40 | }
41 | // Add tags to the task
42 | if len(tags) > 0 {
43 | for _, tag := range tags {
44 | if tag != "" {
45 | // Ensure tag doesn't contain spaces
46 | cleanTag := strings.ReplaceAll(tag, " ", "_")
47 | cmdArgs = append(cmdArgs, "+"+cleanTag)
48 | }
49 | }
50 | }
51 |
52 | if err := utils.ExecCommandInDir(tempDir, "task", cmdArgs...); err != nil {
53 | return fmt.Errorf("failed to add task: %v\n %v", err, cmdArgs)
54 | }
55 |
56 | // Sync Taskwarrior again
57 | if err := SyncTaskwarrior(tempDir); err != nil {
58 | return err
59 | }
60 |
61 | return nil
62 | }
63 |
--------------------------------------------------------------------------------
/backend/utils/tw/complete_task.go:
--------------------------------------------------------------------------------
1 | package tw
2 |
3 | import (
4 | "ccsync_backend/utils"
5 | "fmt"
6 | "os"
7 | )
8 |
9 | func CompleteTaskInTaskwarrior(email, encryptionSecret, uuid, taskuuid string) error {
10 | if err := utils.ExecCommand("rm", "-rf", "/root/.task"); err != nil {
11 | return fmt.Errorf("error deleting Taskwarrior data: %v", err)
12 | }
13 | tempDir, err := os.MkdirTemp("", "taskwarrior-"+email)
14 | if err != nil {
15 | return fmt.Errorf("failed to create temporary directory: %v", err)
16 | }
17 | defer os.RemoveAll(tempDir)
18 |
19 | origin := os.Getenv("CONTAINER_ORIGIN")
20 | if err := SetTaskwarriorConfig(tempDir, encryptionSecret, origin, uuid); err != nil {
21 | return err
22 | }
23 |
24 | if err := SyncTaskwarrior(tempDir); err != nil {
25 | return err
26 | }
27 |
28 | if err := utils.ExecCommandInDir(tempDir, "task", taskuuid, "done", "rc.confirmation=off"); err != nil {
29 | return fmt.Errorf("failed to mark task as done: %v", err)
30 | }
31 |
32 | // Sync Taskwarrior again
33 | if err := SyncTaskwarrior(tempDir); err != nil {
34 | return err
35 | }
36 |
37 | return nil
38 | }
39 |
--------------------------------------------------------------------------------
/backend/utils/tw/delete_task.go:
--------------------------------------------------------------------------------
1 | package tw
2 |
3 | import (
4 | "ccsync_backend/utils"
5 | "fmt"
6 | "os"
7 | )
8 |
9 | func DeleteTaskInTaskwarrior(email, encryptionSecret, uuid, taskuuid string) error {
10 | if err := utils.ExecCommand("rm", "-rf", "/root/.task"); err != nil {
11 | return fmt.Errorf("error deleting Taskwarrior data: %v", err)
12 | }
13 | tempDir, err := os.MkdirTemp("", "taskwarrior-"+email)
14 | if err != nil {
15 | return fmt.Errorf("failed to create temporary directory: %v", err)
16 | }
17 | defer os.RemoveAll(tempDir)
18 |
19 | origin := os.Getenv("CONTAINER_ORIGIN")
20 | if err := SetTaskwarriorConfig(tempDir, encryptionSecret, origin, uuid); err != nil {
21 | return err
22 | }
23 |
24 | if err := SyncTaskwarrior(tempDir); err != nil {
25 | return err
26 | }
27 |
28 | if err := utils.ExecCommandInDir(tempDir, "task", taskuuid, "delete", "rc.confirmation=off"); err != nil {
29 | return fmt.Errorf("failed to mark task as deleted: %v", err)
30 | }
31 |
32 | // Sync Taskwarrior again
33 | if err := SyncTaskwarrior(tempDir); err != nil {
34 | return err
35 | }
36 |
37 | return nil
38 | }
39 |
--------------------------------------------------------------------------------
/backend/utils/tw/edit_task.go:
--------------------------------------------------------------------------------
1 | package tw
2 |
3 | import (
4 | "ccsync_backend/utils"
5 | "fmt"
6 | "os"
7 | "strings"
8 | )
9 |
10 | func EditTaskInTaskwarrior(uuid, description, email, encryptionSecret, taskID string, tags []string) error {
11 | if err := utils.ExecCommand("rm", "-rf", "/root/.task"); err != nil {
12 | return fmt.Errorf("error deleting Taskwarrior data: %v", err)
13 | }
14 | tempDir, err := os.MkdirTemp("", "taskwarrior-"+email)
15 | if err != nil {
16 | return fmt.Errorf("failed to create temporary directory: %v", err)
17 | }
18 | defer os.RemoveAll(tempDir)
19 |
20 | origin := os.Getenv("CONTAINER_ORIGIN")
21 | if err := SetTaskwarriorConfig(tempDir, encryptionSecret, origin, uuid); err != nil {
22 | return err
23 | }
24 |
25 | if err := SyncTaskwarrior(tempDir); err != nil {
26 | return err
27 | }
28 |
29 | // Escape the double quotes in the description and format it
30 | if err := utils.ExecCommand("task", taskID, "modify", description); err != nil {
31 | fmt.Println("task " + taskID + " modify " + description)
32 | return fmt.Errorf("failed to edit task: %v", err)
33 | }
34 |
35 | // Handle tags
36 | if len(tags) > 0 {
37 | for _, tag := range tags {
38 | if strings.HasPrefix(tag, "+") {
39 | // Add tag
40 | tagValue := strings.TrimPrefix(tag, "+")
41 | if err := utils.ExecCommand("task", taskID, "modify", "+"+tagValue); err != nil {
42 | return fmt.Errorf("failed to add tag %s: %v", tagValue, err)
43 | }
44 | } else if strings.HasPrefix(tag, "-") {
45 | // Remove tag
46 | tagValue := strings.TrimPrefix(tag, "-")
47 | if err := utils.ExecCommand("task", taskID, "modify", "-"+tagValue); err != nil {
48 | return fmt.Errorf("failed to remove tag %s: %v", tagValue, err)
49 | }
50 | } else {
51 | // Add tag without prefix
52 | if err := utils.ExecCommand("task", taskID, "modify", "+"+tag); err != nil {
53 | return fmt.Errorf("failed to add tag %s: %v", tag, err)
54 | }
55 | }
56 | }
57 | }
58 |
59 | // Sync Taskwarrior again
60 | if err := SyncTaskwarrior(tempDir); err != nil {
61 | return err
62 | }
63 | return nil
64 | }
65 |
--------------------------------------------------------------------------------
/backend/utils/tw/export_tasks.go:
--------------------------------------------------------------------------------
1 | package tw
2 |
3 | import (
4 | "ccsync_backend/models"
5 | "ccsync_backend/utils"
6 | "encoding/json"
7 | "fmt"
8 | )
9 |
10 | // export the tasks so as to add them to DB
11 | func ExportTasks(tempDir string) ([]models.Task, error) {
12 | output, err := utils.ExecCommandForOutputInDir(tempDir, "task", "export")
13 | if err != nil {
14 | return nil, fmt.Errorf("error executing Taskwarrior export command: %v", err)
15 | }
16 |
17 | // Parse the exported tasks
18 | var tasks []models.Task
19 | if err := json.Unmarshal(output, &tasks); err != nil {
20 | return nil, fmt.Errorf("error parsing tasks: %v", err)
21 | }
22 |
23 | return tasks, nil
24 | }
25 |
--------------------------------------------------------------------------------
/backend/utils/tw/fetch_tasks.go:
--------------------------------------------------------------------------------
1 | package tw
2 |
3 | import (
4 | "ccsync_backend/models"
5 | "ccsync_backend/utils"
6 | "fmt"
7 | "os"
8 | )
9 |
10 | // complete logic (delete config if any->setup config->sync->get tasks->export)
11 | func FetchTasksFromTaskwarrior(email, encryptionSecret, origin, UUID string) ([]models.Task, error) {
12 | // temporary directory for each user
13 | if err := utils.ExecCommand("rm", "-rf", "/root/.task"); err != nil {
14 | return nil, fmt.Errorf("error deleting Taskwarrior data: %v", err)
15 | }
16 |
17 | tempDir, err := os.MkdirTemp("", "taskwarrior-"+email)
18 | if err != nil {
19 | return nil, fmt.Errorf("failed to create temporary directory: %v", err)
20 | }
21 | defer os.RemoveAll(tempDir)
22 |
23 | if err := SetTaskwarriorConfig(tempDir, encryptionSecret, origin, UUID); err != nil {
24 | return nil, err
25 | }
26 |
27 | if err := SyncTaskwarrior(tempDir); err != nil {
28 | return nil, err
29 | }
30 |
31 | tasks, err := ExportTasks(tempDir)
32 | if err != nil {
33 | return nil, err
34 | }
35 | return tasks, nil
36 | }
37 |
--------------------------------------------------------------------------------
/backend/utils/tw/modify_task.go:
--------------------------------------------------------------------------------
1 | package tw
2 |
3 | import (
4 | "ccsync_backend/utils"
5 | "fmt"
6 | "os"
7 | "strings"
8 | )
9 |
10 | func ModifyTaskInTaskwarrior(uuid, description, project, priority, status, due, email, encryptionSecret, taskID string, tags []string) error {
11 | if err := utils.ExecCommand("rm", "-rf", "/root/.task"); err != nil {
12 | fmt.Println("1")
13 | return fmt.Errorf("error deleting Taskwarrior data: %v", err)
14 | }
15 | tempDir, err := os.MkdirTemp("", "taskwarrior-"+email)
16 | if err != nil {
17 | fmt.Println("2")
18 | return fmt.Errorf("failed to create temporary directory: %v", err)
19 | }
20 | defer os.RemoveAll(tempDir)
21 |
22 | origin := os.Getenv("CONTAINER_ORIGIN")
23 | if err := SetTaskwarriorConfig(tempDir, encryptionSecret, origin, uuid); err != nil {
24 | fmt.Println("4")
25 | return err
26 | }
27 |
28 | if err := SyncTaskwarrior(tempDir); err != nil {
29 | fmt.Println("5")
30 | return err
31 | }
32 |
33 | escapedDescription := fmt.Sprintf(`description:"%s"`, strings.ReplaceAll(description, `"`, `\"`))
34 |
35 | if err := utils.ExecCommand("task", taskID, "modify", escapedDescription); err != nil {
36 | fmt.Println("6")
37 | return fmt.Errorf("failed to edit task: %v", err)
38 | }
39 |
40 | escapedProject := fmt.Sprintf(`project:%s`, strings.ReplaceAll(project, `"`, `\"`))
41 | if err := utils.ExecCommand("task", taskID, "modify", escapedProject); err != nil {
42 | fmt.Println("7")
43 | return fmt.Errorf("failed to edit task project: %v", err)
44 | }
45 |
46 | escapedPriority := fmt.Sprintf(`priority:%s`, strings.ReplaceAll(priority, `"`, `\"`))
47 | if err := utils.ExecCommand("task", taskID, "modify", escapedPriority); err != nil {
48 | fmt.Println("8")
49 | return fmt.Errorf("failed to edit task priority: %v", err)
50 | }
51 |
52 | escapedDue := fmt.Sprintf(`due:%s`, strings.ReplaceAll(due, `"`, `\"`))
53 | if err := utils.ExecCommand("task", taskID, "modify", escapedDue); err != nil {
54 | fmt.Println("8")
55 | return fmt.Errorf("failed to edit task due: %v", err)
56 | }
57 |
58 | // escapedStatus := fmt.Sprintf(`status:%s`, strings.ReplaceAll(status, `"`, `\"`))
59 | if status == "completed" {
60 | utils.ExecCommand("task", taskID, "done", "rc.confirmation=off")
61 | } else if status == "deleted" {
62 | utils.ExecCommand("task", taskID, "delete", "rc.confirmation=off")
63 | }
64 |
65 | // Handle tags
66 | if len(tags) > 0 {
67 | for _, tag := range tags {
68 | if strings.HasPrefix(tag, "+") {
69 | // Add tag
70 | tagValue := strings.TrimPrefix(tag, "+")
71 | if err := utils.ExecCommand("task", taskID, "modify", "+"+tagValue); err != nil {
72 | return fmt.Errorf("failed to add tag %s: %v", tagValue, err)
73 | }
74 | } else if strings.HasPrefix(tag, "-") {
75 | // Remove tag
76 | tagValue := strings.TrimPrefix(tag, "-")
77 | if err := utils.ExecCommand("task", taskID, "modify", "-"+tagValue); err != nil {
78 | return fmt.Errorf("failed to remove tag %s: %v", tagValue, err)
79 | }
80 | } else {
81 | // Add tag without prefix
82 | if err := utils.ExecCommand("task", taskID, "modify", "+"+tag); err != nil {
83 | return fmt.Errorf("failed to add tag %s: %v", tag, err)
84 | }
85 | }
86 | }
87 | }
88 |
89 | if err := SyncTaskwarrior(tempDir); err != nil {
90 | fmt.Println("11")
91 | return err
92 | }
93 |
94 | return nil
95 | }
96 |
--------------------------------------------------------------------------------
/backend/utils/tw/set_config.go:
--------------------------------------------------------------------------------
1 | package tw
2 |
3 | import (
4 | "ccsync_backend/utils"
5 | "fmt"
6 | )
7 |
8 | // logic to set tw config on backend
9 | func SetTaskwarriorConfig(tempDir, encryptionSecret, origin, UUID string) error {
10 | configCmds := [][]string{
11 | {"task", "config", "sync.encryption_secret", encryptionSecret, "rc.confirmation=off"},
12 | {"task", "config", "sync.server.origin", origin, "rc.confirmation=off"},
13 | {"task", "config", "sync.server.client_id", UUID, "rc.confirmation=off"},
14 | }
15 |
16 | for _, args := range configCmds {
17 | if err := utils.ExecCommandInDir(tempDir, args[0], args[1:]...); err != nil {
18 | return fmt.Errorf("error setting Taskwarrior config (%v): %v", args, err)
19 | }
20 | }
21 | return nil
22 | }
23 |
--------------------------------------------------------------------------------
/backend/utils/tw/sync_tasks.go:
--------------------------------------------------------------------------------
1 | package tw
2 |
3 | import (
4 | "ccsync_backend/utils"
5 | "fmt"
6 | )
7 |
8 | // sync the user's tasks to all of their TW clients
9 | func SyncTaskwarrior(tempDir string) error {
10 | if err := utils.ExecCommandInDir(tempDir, "task", "sync"); err != nil {
11 | return fmt.Errorf("error syncing Taskwarrior: %v", err)
12 | }
13 | return nil
14 | }
15 |
--------------------------------------------------------------------------------
/backend/utils/tw/taskwarrior_test.go:
--------------------------------------------------------------------------------
1 | package tw
2 |
3 | import (
4 | "fmt"
5 | "testing"
6 | )
7 |
8 | func TestSetTaskwarriorConfig(t *testing.T) {
9 | err := SetTaskwarriorConfig("./", "encryption_secret", "container_origin", "client_id")
10 | if err != nil {
11 | t.Errorf("SetTaskwarriorConfig() failed: %v", err)
12 | } else {
13 | fmt.Println("SetTaskwarriorConfig test passed")
14 | }
15 | }
16 | func TestSyncTaskwarrior(t *testing.T) {
17 | err := SyncTaskwarrior("./")
18 | if err != nil {
19 | t.Errorf("SyncTaskwarrior failed: %v", err)
20 | } else {
21 | fmt.Println("Sync Dir test passed")
22 | }
23 | }
24 |
25 | func TestEditTaskInATaskwarrior(t *testing.T) {
26 | err := EditTaskInTaskwarrior("uuid", "description", "email", "encryptionSecret", "taskuuid", nil)
27 | if err != nil {
28 | t.Errorf("EditTaskInTaskwarrior() failed: %v", err)
29 | } else {
30 | fmt.Println("Edit test passed")
31 | }
32 | }
33 |
34 | func TestExportTasks(t *testing.T) {
35 | task, err := ExportTasks("./")
36 | if task != nil && err == nil {
37 | fmt.Println("Task export test passed")
38 | } else {
39 | t.Errorf("ExportTasks() failed: %v", err)
40 | }
41 | }
42 |
43 | func TestAddTaskToTaskwarrior(t *testing.T) {
44 | err := AddTaskToTaskwarrior("email", "encryption_secret", "clientId", "description", "", "H", "2025-03-03", nil)
45 | if err != nil {
46 | t.Errorf("AddTaskToTaskwarrior failed: %v", err)
47 | } else {
48 | fmt.Println("Add task passed")
49 | }
50 | }
51 |
52 | func TestCompleteTaskInTaskwarrior(t *testing.T) {
53 | err := CompleteTaskInTaskwarrior("email", "encryptionSecret", "client_id", "taskuuid")
54 | if err != nil {
55 | t.Errorf("CompleteTaskInTaskwarrior failed: %v", err)
56 | } else {
57 | fmt.Println("Complete task passed")
58 | }
59 | }
60 |
61 | func TestAddTaskWithTags(t *testing.T) {
62 | err := AddTaskToTaskwarrior("email", "encryption_secret", "clientId", "description", "", "H", "2025-03-03", []string{"work", "important"})
63 | if err != nil {
64 | t.Errorf("AddTaskToTaskwarrior with tags failed: %v", err)
65 | } else {
66 | fmt.Println("Add task with tags passed")
67 | }
68 | }
69 |
70 | func TestEditTaskWithTagAddition(t *testing.T) {
71 | err := EditTaskInTaskwarrior("uuid", "description", "email", "encryptionSecret", "taskuuid", []string{"+urgent", "+important"})
72 | if err != nil {
73 | t.Errorf("EditTaskInTaskwarrior with tag addition failed: %v", err)
74 | } else {
75 | fmt.Println("Edit task with tag addition passed")
76 | }
77 | }
78 |
79 | func TestEditTaskWithTagRemoval(t *testing.T) {
80 | err := EditTaskInTaskwarrior("uuid", "description", "email", "encryptionSecret", "taskuuid", []string{"-work", "-lowpriority"})
81 | if err != nil {
82 | t.Errorf("EditTaskInTaskwarrior with tag removal failed: %v", err)
83 | } else {
84 | fmt.Println("Edit task with tag removal passed")
85 | }
86 | }
87 |
88 | func TestEditTaskWithMixedTagOperations(t *testing.T) {
89 | err := EditTaskInTaskwarrior("uuid", "description", "email", "encryptionSecret", "taskuuid", []string{"+urgent", "-work", "normal"})
90 | if err != nil {
91 | t.Errorf("EditTaskInTaskwarrior with mixed tag operations failed: %v", err)
92 | } else {
93 | fmt.Println("Edit task with mixed tag operations passed")
94 | }
95 | }
96 |
97 | func TestModifyTaskWithTags(t *testing.T) {
98 | err := ModifyTaskInTaskwarrior("uuid", "description", "project", "H", "pending", "2025-03-03", "email", "encryptionSecret", "taskuuid", []string{"+urgent", "-work", "normal"})
99 | if err != nil {
100 | t.Errorf("ModifyTaskInTaskwarrior with tags failed: %v", err)
101 | } else {
102 | fmt.Println("Modify task with tags passed")
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/backend/utils/utils_test.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "crypto/sha256"
5 | "encoding/hex"
6 | "os"
7 | "testing"
8 |
9 | "github.com/google/uuid"
10 | "github.com/stretchr/testify/assert"
11 | )
12 |
13 | func Test_GenerateUUID(t *testing.T) {
14 | email := "test@example.com"
15 | id := "12345"
16 | expectedUUID := uuid.NewMD5(uuid.NameSpaceOID, []byte(email+id)).String()
17 |
18 | uuidStr := GenerateUUID(email, id)
19 | assert.Equal(t, expectedUUID, uuidStr)
20 | }
21 |
22 | func Test_GenerateEncryptionSecret(t *testing.T) {
23 | uuidStr := "uuid-test"
24 | email := "test@example.com"
25 | id := "12345"
26 | hash := sha256.New()
27 | hash.Write([]byte(uuidStr + email + id))
28 | expectedSecret := hex.EncodeToString(hash.Sum(nil))
29 |
30 | encryptionSecret := GenerateEncryptionSecret(uuidStr, email, id)
31 | assert.Equal(t, expectedSecret, encryptionSecret)
32 | }
33 |
34 | func Test_ExecCommandInDir(t *testing.T) {
35 | tempDir, err := os.MkdirTemp("", "testdir")
36 | if err != nil {
37 | t.Fatalf("Failed to create temp directory: %v", err)
38 | }
39 | defer os.RemoveAll(tempDir)
40 |
41 | tempFile := tempDir + "/testfile"
42 | if err := os.WriteFile(tempFile, []byte("hello"), 0644); err != nil {
43 | t.Fatalf("Failed to create temp file: %v", err)
44 | }
45 |
46 | command := "ls"
47 | args := []string{"-l"}
48 |
49 | if err := ExecCommandInDir(tempDir, command, args...); err != nil {
50 | t.Errorf("ExecCommandInDir failed: %v", err)
51 | }
52 | }
53 |
54 | func Test_ExecCommand(t *testing.T) {
55 | command := "echo"
56 | args := []string{"hello world"}
57 |
58 | if err := ExecCommand(command, args...); err != nil {
59 | t.Errorf("ExecCommand failed: %v", err)
60 | }
61 | }
62 |
63 | func Test_ExecCommandForOutputInDir(t *testing.T) {
64 | tempDir, err := os.MkdirTemp("", "testdir")
65 | if err != nil {
66 | t.Fatalf("Failed to create temp directory: %v", err)
67 | }
68 | defer os.RemoveAll(tempDir)
69 |
70 | tempFile := tempDir + "/testfile"
71 | if err := os.WriteFile(tempFile, []byte("hello"), 0644); err != nil {
72 | t.Fatalf("Failed to create temp file: %v", err)
73 | }
74 |
75 | command := "ls"
76 | args := []string{"-1"}
77 |
78 | output, err := ExecCommandForOutputInDir(tempDir, command, args...)
79 | if err != nil {
80 | t.Errorf("ExecCommandForOutputInDir failed: %v", err)
81 | }
82 |
83 | if string(output) == "" {
84 | t.Errorf("Expected output but got empty result")
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | frontend:
3 | build:
4 | context: ./frontend
5 | dockerfile: Dockerfile
6 | ports:
7 | - "80:80"
8 | networks:
9 | - tasknetwork
10 | depends_on:
11 | - backend
12 | env_file:
13 | - ./.frontend.env
14 | healthcheck:
15 | test: ["CMD", "curl", "-f", "http://localhost:80"]
16 | interval: 30s
17 | timeout: 10s
18 | retries: 3
19 |
20 | backend:
21 | build:
22 | context: ./backend
23 | dockerfile: Dockerfile
24 | ports:
25 | - "8000:8000"
26 | networks:
27 | - tasknetwork
28 | depends_on:
29 | - syncserver
30 | env_file:
31 | - ./.backend.env
32 | healthcheck:
33 | test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
34 | interval: 30s
35 | timeout: 10s
36 | retries: 3
37 | volumes:
38 | - ./backend/data:/app/data
39 |
40 | syncserver:
41 | image: ghcr.io/gothenburgbitfactory/taskchampion-sync-server:latest
42 | ports:
43 | - "8080:8080"
44 | networks:
45 | - tasknetwork
46 | healthcheck:
47 | test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
48 | interval: 30s
49 | timeout: 10s
50 | retries: 3
51 |
52 | networks:
53 | tasknetwork:
54 | driver: bridge
55 |
--------------------------------------------------------------------------------
/frontend/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: { browser: true, es2020: true },
4 | node: true,
5 | extends: [
6 | 'eslint:recommended',
7 | 'plugin:@typescript-eslint/recommended',
8 | 'plugin:react-hooks/recommended',
9 | ],
10 | ignorePatterns: ['dist', '.eslintrc.cjs'],
11 | parser: '@typescript-eslint/parser',
12 | plugins: ['react-refresh'],
13 | rules: {
14 | 'react-refresh/only-export-components': [
15 | 'warn',
16 | { allowConstantExport: true },
17 | ],
18 | },
19 | };
20 |
--------------------------------------------------------------------------------
/frontend/.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 | dist
12 | dist-ssr
13 | *.local
14 | coverage
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 | .env
26 |
--------------------------------------------------------------------------------
/frontend/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": true,
3 | "singleQuote": true,
4 | "trailingComma": "es5",
5 | "printWidth": 80,
6 | "tabWidth": 2,
7 | "arrowParens": "always"
8 | }
9 |
--------------------------------------------------------------------------------
/frontend/Dockerfile:
--------------------------------------------------------------------------------
1 | # Use node image for building the frontend
2 | FROM node:16-alpine as build
3 |
4 | # Set working directory
5 | WORKDIR /app
6 |
7 | # Copy package.json and install dependencies
8 | COPY package.json package-lock.json ./
9 | RUN npm install
10 |
11 | # Copy the rest of the application code and build
12 | COPY . .
13 | RUN npm run build
14 |
15 | # Serve the frontend using nginx
16 | FROM nginx:alpine
17 | COPY --from=build /app/dist /usr/share/nginx/html
18 |
19 | # Copy nginx configuration file
20 | COPY nginx.conf /etc/nginx/conf.d/default.conf
21 |
22 | # Expose port 80
23 | EXPOSE 80
24 |
25 | # Start nginx
26 | CMD ["nginx", "-g", "daemon off;"]
27 |
--------------------------------------------------------------------------------
/frontend/README.md:
--------------------------------------------------------------------------------
1 | ## Guide to setup the frontend for development purposes
2 |
3 | - ```bash
4 | npm i
5 | ```
6 |
7 | - ```bash
8 | npm run dev
9 | ```
10 |
11 | - Set environment variables in .env as:
12 |
13 | For docker usage:
14 |
15 | ```bash
16 | VITE_BACKEND_URL="http://localhost:8000/"
17 | VITE_FRONTEND_URL="http://localhost:80"
18 | VITE_CONTAINER_ORIGIN="http://localhost:8080/"
19 | ```
20 |
21 | For normal npm usage:
22 |
23 | ```bash
24 | VITE_BACKEND_URL="http://localhost:8000/"
25 | VITE_FRONTEND_URL="http://localhost:5173"
26 | VITE_CONTAINER_ORIGIN="http://localhost:8080/"
27 | ```
28 |
29 | - Note: The ports can be changed on demand, and if you want to do so, be sure to change ports of the Dockerfiles as well as the ports in docker-compose.yml
30 |
31 | - Run the frontend container only:
32 | ```bash
33 | docker-compose build frontend
34 | docker-compose up
35 | ```
36 |
--------------------------------------------------------------------------------
/frontend/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | ['@babel/preset-env', { targets: { node: 'current' } }],
4 | '@babel/preset-typescript',
5 | ],
6 | };
7 |
--------------------------------------------------------------------------------
/frontend/badge.py:
--------------------------------------------------------------------------------
1 | # script for extracting code coverage value for readme
2 |
3 | from bs4 import BeautifulSoup
4 | import json
5 |
6 | def extract_coverage_data(file_path):
7 | with open(file_path, "r", encoding="utf-8") as file:
8 | html_content = file.read()
9 | soup = BeautifulSoup(html_content, "lxml")
10 | div = soup.find("div", class_="fl pad1y space-right2")
11 | percentage = div.find("span", class_="strong").get_text(strip=True)
12 | return {"frontend": percentage}
13 |
14 | def save_to_json(data, output_path):
15 | with open(output_path, "w", encoding="utf-8") as json_file:
16 | json.dump(data, json_file, indent=4)
17 |
18 |
19 | html_file_path = "./coverage/lcov-report/index.html"
20 | json_file_path = "./coverage-report.json"
21 |
22 | coverage_data = extract_coverage_data(html_file_path)
23 |
24 | save_to_json(coverage_data, json_file_path)
25 |
26 | print(f"Coverage data has been saved to {json_file_path}")
27 |
--------------------------------------------------------------------------------
/frontend/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "src/app.css",
9 | "baseColor": "slate",
10 | "cssVariables": true
11 | },
12 | "aliases": {
13 | "components": "@/components",
14 | "utils": "@/lib/utils"
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/frontend/coverage-report.json:
--------------------------------------------------------------------------------
1 | {
2 | "frontend": "80.71%"
3 | }
4 |
--------------------------------------------------------------------------------
/frontend/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | CCSync
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/frontend/jest.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | preset: 'ts-jest',
3 | testEnvironment: 'jsdom',
4 | moduleNameMapper: {
5 | '^@/(.*)$': ['/src/$1', '/lib/$1'],
6 | '\\.(scss|sass|css)$': 'identity-obj-proxy',
7 | },
8 | transformIgnorePatterns: ['/node_modules/(?!react-toastify)'],
9 | setupFilesAfterEnv: ['/jest.setup.ts'],
10 | collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts'],
11 | testMatch: ['**/__tests__/**/*.{ts,tsx}', '**/?(*.)+(spec|test).{ts,tsx}'],
12 | transform: {
13 | '^.+\\.tsx?$': [
14 | 'ts-jest',
15 | {
16 | diagnostics: {
17 | ignoreCodes: [1343],
18 | },
19 | astTransformers: {
20 | before: [
21 | {
22 | path: 'node_modules/ts-jest-mock-import-meta',
23 | options: {
24 | metaObjectReplacement: { url: 'https://www.url.com' },
25 | },
26 | },
27 | ],
28 | },
29 | },
30 | ],
31 | },
32 | };
33 |
--------------------------------------------------------------------------------
/frontend/jest.setup.ts:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom';
2 |
--------------------------------------------------------------------------------
/frontend/nginx.conf:
--------------------------------------------------------------------------------
1 | server {
2 | listen 80;
3 |
4 | location / {
5 | root /usr/share/nginx/html;
6 | try_files $uri /index.html;
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/frontend/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/frontend/src/App.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | /* *=========== Green theme =========== */
6 | @layer base {
7 | :root {
8 | --background: 0 0% 100%;
9 | --foreground: 240 10% 3.9%;
10 | --card: 0 0% 100%;
11 | --card-foreground: 240 10% 3.9%;
12 | --popover: 0 0% 100%;
13 | --popover-foreground: 240 10% 3.9%;
14 | --primary: 142.1 76.2% 36.3%;
15 | --primary-foreground: 355.7 100% 97.3%;
16 | --secondary: 240 4.8% 95.9%;
17 | --secondary-foreground: 240 5.9% 10%;
18 | --muted: 240 4.8% 95.9%;
19 | --muted-foreground: 240 3.8% 46.1%;
20 | --accent: 240 4.8% 95.9%;
21 | --accent-foreground: 240 5.9% 10%;
22 | --destructive: 0 84.2% 60.2%;
23 | --destructive-foreground: 0 0% 98%;
24 | --border: 240 5.9% 90%;
25 | --input: 240 5.9% 90%;
26 | --ring: 142.1 76.2% 36.3%;
27 | --radius: 0.5rem;
28 | }
29 |
30 | .dark {
31 | --background: 20 14.3% 4.1%;
32 | --foreground: 0 0% 95%;
33 | --card: 24 9.8% 10%;
34 | --card-foreground: 0 0% 95%;
35 | --popover: 0 0% 9%;
36 | --popover-foreground: 0 0% 95%;
37 | --primary: 142.1 70.6% 45.3%;
38 | --primary-foreground: 144.9 80.4% 10%;
39 | --secondary: 240 3.7% 15.9%;
40 | --secondary-foreground: 0 0% 98%;
41 | --muted: 0 0% 15%;
42 | --muted-foreground: 240 5% 64.9%;
43 | --accent: 12 6.5% 15.1%;
44 | --accent-foreground: 0 0% 98%;
45 | --destructive: 0 62.8% 30.6%;
46 | --destructive-foreground: 0 85.7% 97.3%;
47 | --border: 240 3.7% 15.9%;
48 | --input: 240 3.7% 15.9%;
49 | --ring: 142.4 71.8% 29.2%;
50 | }
51 | }
52 |
53 | @layer base {
54 | * {
55 | @apply border-border;
56 | }
57 | body {
58 | @apply bg-background text-foreground;
59 | }
60 | }
61 |
62 | /* width */
63 | ::-webkit-scrollbar {
64 | height: 2px;
65 | width: 10px;
66 | }
67 |
68 | /* Track */
69 | ::-webkit-scrollbar-track {
70 | background: #000000;
71 | }
72 |
73 | /* Handle */
74 | ::-webkit-scrollbar-thumb {
75 | background: #3bbcf0;
76 | border-radius: 0px;
77 | }
78 |
79 | /* Handle on hover */
80 | ::-webkit-scrollbar-thumb:hover {
81 | background: #3bbcf0;
82 | }
83 |
--------------------------------------------------------------------------------
/frontend/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { HomePage } from './components/HomePage';
2 | import './App.css';
3 | import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
4 | import { LandingPage } from './components/LandingPage';
5 |
6 | function App() {
7 | return (
8 |
9 |
10 | } />
11 | } />
12 |
13 |
14 | );
15 | }
16 |
17 | export default App;
18 |
--------------------------------------------------------------------------------
/frontend/src/assets/logo.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CCExtractor/ccsync/0dea33210306edd07a6b7a4720e934936fd2b3d7/frontend/src/assets/logo.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CCExtractor/ccsync/0dea33210306edd07a6b7a4720e934936fd2b3d7/frontend/src/assets/logo.png
--------------------------------------------------------------------------------
/frontend/src/assets/logo_header.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CCExtractor/ccsync/0dea33210306edd07a6b7a4720e934936fd2b3d7/frontend/src/assets/logo_header.png
--------------------------------------------------------------------------------
/frontend/src/assets/logo_light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CCExtractor/ccsync/0dea33210306edd07a6b7a4720e934936fd2b3d7/frontend/src/assets/logo_light.png
--------------------------------------------------------------------------------
/frontend/src/components/HomeComponents/BottomBar/BottomBar.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | Select,
4 | SelectContent,
5 | SelectGroup,
6 | SelectItem,
7 | SelectLabel,
8 | SelectTrigger,
9 | SelectValue,
10 | } from '@/components/ui/select';
11 | import { NavigationMenu } from '@/components/ui/navigation-menu';
12 | import { buttonVariants } from '@/components/ui/button';
13 | import { BottomBarProps } from './bottom-bar-utils';
14 |
15 | const BottomBar: React.FC = ({
16 | projects,
17 | selectedProject,
18 | setSelectedProject,
19 | status,
20 | selectedStatus,
21 | setSelectedStatus,
22 | }) => {
23 | return (
24 |
25 |
26 |
48 |
52 |
53 |
54 |
55 |
56 |
57 | Projects
58 | All Projects
59 | {projects.map((project) => (
60 |
61 | {project}
62 |
63 | ))}
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 | Status
74 | All
75 | {status.map((status) => (
76 |
77 | {status}
78 |
79 | ))}
80 |
81 |
82 |
83 |
84 |
85 | );
86 | };
87 |
88 | export default BottomBar;
89 |
--------------------------------------------------------------------------------
/frontend/src/components/HomeComponents/BottomBar/__tests__/BottomBar.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import BottomBar from '../BottomBar';
3 |
4 | describe('BottomBar Component', () => {
5 | const mockSetSelectedProject = jest.fn();
6 | const mockSetSelectedStatus = jest.fn();
7 | const projects = ['Project A', 'Project B'];
8 | const status = ['Status A', 'Status B'];
9 |
10 | test('renders BottomBar component', () => {
11 | render(
12 |
20 | );
21 |
22 | expect(screen.getByText('Home')).toBeInTheDocument();
23 | expect(screen.getByText('Tasks')).toBeInTheDocument();
24 | });
25 | });
26 |
--------------------------------------------------------------------------------
/frontend/src/components/HomeComponents/BottomBar/__tests__/bottom-bar-utils.test.ts:
--------------------------------------------------------------------------------
1 | import { BottomBarProps, RouteProps, routeList } from '../bottom-bar-utils';
2 |
3 | describe('RouteProps interface', () => {
4 | it('should have href and label properties', () => {
5 | const exampleRoute: RouteProps = { href: '#', label: 'Example' };
6 | expect(exampleRoute).toHaveProperty('href');
7 | expect(exampleRoute).toHaveProperty('label');
8 | });
9 | });
10 |
11 | describe('BottomBarProps interface', () => {
12 | it('should have project and status properties', () => {
13 | const example: BottomBarProps = {
14 | projects: [''],
15 | selectedProject: '',
16 | setSelectedProject: jest.fn(),
17 | status: [''],
18 | selectedStatus: '',
19 | setSelectedStatus: jest.fn(),
20 | };
21 | expect(example).toHaveProperty('projects');
22 | expect(example).toHaveProperty('selectedProject');
23 | expect(example).toHaveProperty('setSelectedProject');
24 | expect(example).toHaveProperty('status');
25 | expect(example).toHaveProperty('selectedStatus');
26 | expect(example).toHaveProperty('setSelectedStatus');
27 | });
28 | });
29 |
30 | describe('routeList array', () => {
31 | it('should be an array', () => {
32 | expect(Array.isArray(routeList)).toBe(true);
33 | });
34 |
35 | it('should contain objects with href and label properties', () => {
36 | routeList.forEach((route) => {
37 | expect(route).toHaveProperty('href');
38 | expect(route).toHaveProperty('label');
39 | });
40 | });
41 |
42 | it('should contain correct number of routes', () => {
43 | expect(routeList.length).toBe(2);
44 | });
45 |
46 | it('should match the predefined routes', () => {
47 | expect(routeList).toEqual([
48 | { href: '#', label: 'Home' },
49 | { href: '#tasks', label: 'Tasks' },
50 | ]);
51 | });
52 | });
53 |
--------------------------------------------------------------------------------
/frontend/src/components/HomeComponents/BottomBar/bottom-bar-utils.ts:
--------------------------------------------------------------------------------
1 | import { Dispatch, SetStateAction } from 'react';
2 |
3 | export interface RouteProps {
4 | href: string;
5 | label: string;
6 | }
7 |
8 | export interface BottomBarProps {
9 | projects: string[];
10 | selectedProject: string | null;
11 | setSelectedProject: Dispatch>;
12 |
13 | status: string[];
14 | selectedStatus: string | null;
15 | setSelectedStatus: Dispatch>;
16 | }
17 |
18 | export const routeList: RouteProps[] = [
19 | { href: '#', label: 'Home' },
20 | { href: '#tasks', label: 'Tasks' },
21 | ];
22 |
--------------------------------------------------------------------------------
/frontend/src/components/HomeComponents/FAQ/FAQ.tsx:
--------------------------------------------------------------------------------
1 | import { Accordion } from '@/components/ui/accordion';
2 | import { FAQItem } from './FAQItem';
3 | import { FAQList } from './faq-utils';
4 | import { BlueHeading } from '@/lib/utils';
5 |
6 | export const FAQ = () => {
7 | return (
8 |
9 |
13 |
14 |
15 | {FAQList.map(({ question, answer, value }) => (
16 |
22 | ))}
23 |
24 |
25 |
26 | Still have questions?{' '}
27 |
32 | Contact us
33 |
34 |
35 |
36 | );
37 | };
38 |
--------------------------------------------------------------------------------
/frontend/src/components/HomeComponents/FAQ/FAQItem.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | AccordionItem,
3 | AccordionTrigger,
4 | AccordionContent,
5 | } from '@/components/ui/accordion';
6 |
7 | interface FAQProps {
8 | question: string;
9 | answer: string;
10 | value: string;
11 | }
12 |
13 | export const FAQItem = ({ question, answer, value }: FAQProps) => (
14 |
15 | {question}
16 | {answer}
17 |
18 | );
19 |
--------------------------------------------------------------------------------
/frontend/src/components/HomeComponents/FAQ/__tests__/FAQ.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, screen } from '@testing-library/react';
3 | import { FAQ } from '../FAQ';
4 | import { FAQList } from '../faq-utils';
5 |
6 | jest.mock('../faq-utils', () => ({
7 | FAQList: [
8 | {
9 | question: 'What is React?',
10 | answer: 'A JavaScript library for building user interfaces.',
11 | value: 'q1',
12 | },
13 | {
14 | question: 'What is TypeScript?',
15 | answer:
16 | 'A typed superset of JavaScript that compiles to plain JavaScript.',
17 | value: 'q2',
18 | },
19 | ],
20 | }));
21 |
22 | jest.mock('@/components/ui/accordion', () => ({
23 | Accordion: ({ children }: { children: React.ReactNode }) => (
24 | {children}
25 | ),
26 | }));
27 |
28 | jest.mock('../FAQItem', () => ({
29 | FAQItem: ({ question, answer }: { question: string; answer: string }) => (
30 |
31 |
{question}
32 |
{answer}
33 |
34 | ),
35 | }));
36 |
37 | describe('FAQ component', () => {
38 | test('renders without crashing', () => {
39 | render( );
40 | });
41 |
42 | test('renders the section title correctly', () => {
43 | render( );
44 | const titleElement = screen.getByText(/Frequently Asked/i);
45 | expect(titleElement).toBeInTheDocument();
46 | });
47 |
48 | test('renders the FAQ items correctly', () => {
49 | render( );
50 | FAQList.forEach(({ question }) => {
51 | const questionElement = screen.getByText(question);
52 | expect(questionElement).toBeInTheDocument();
53 | });
54 | });
55 |
56 | test('renders the contact link correctly', () => {
57 | render( );
58 | const contactLink = screen.getByText(/Contact us/i);
59 | expect(contactLink).toBeInTheDocument();
60 | expect(contactLink).toHaveAttribute('href', '#contact');
61 | });
62 | });
63 |
--------------------------------------------------------------------------------
/frontend/src/components/HomeComponents/FAQ/__tests__/FAQItem.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen, fireEvent } from '@testing-library/react';
2 | import { FAQItem } from '../FAQItem';
3 | import { Accordion } from '@/components/ui/accordion';
4 |
5 | describe('FAQItem', () => {
6 | const question = 'What is your return policy?';
7 | const answer = 'You can return any item within 30 days of purchase.';
8 | const value = 'faq-1';
9 |
10 | test('renders question and answer correctly', () => {
11 | render(
12 |
13 |
19 |
20 | );
21 |
22 | // check if the question is rendered
23 | expect(screen.getByText(question)).toBeInTheDocument();
24 |
25 | // clicking the trigger to expand the accordion
26 | fireEvent.click(screen.getByText(question));
27 |
28 | // check if the answer is visible after clicking
29 | expect(screen.getByText(answer)).toBeVisible();
30 | });
31 | });
32 |
--------------------------------------------------------------------------------
/frontend/src/components/HomeComponents/FAQ/__tests__/faq-utils.test.ts:
--------------------------------------------------------------------------------
1 | import { FAQList } from '../faq-utils';
2 |
3 | describe('FAQList', () => {
4 | test('should contain the correct number of FAQ items', () => {
5 | expect(FAQList).toHaveLength(4);
6 | });
7 |
8 | test('should contain the correct FAQ items', () => {
9 | expect(FAQList).toEqual([
10 | {
11 | question: 'What is CCSync?',
12 | answer:
13 | 'CCSync is a service that allows you to synchronize your tasks between your devices using Taskwarrior. It provides a hosted solution for taskchampion-sync-server, eliminating the need to set up and manage your own server.',
14 | value: 'item-1',
15 | },
16 | {
17 | question: 'What devices can I use with CCSync?',
18 | answer:
19 | 'CCSync works with any device that can run Taskwarrior, including desktops, laptops, smartphones, and tablets.',
20 | value: 'item-2',
21 | },
22 | {
23 | question: 'How do I initialize sync between my clients?',
24 | answer:
25 | 'The connection process is straightforward. Refer to the setup guide above for step-by-step instructions on configuring Taskwarrior to connect to our server.',
26 | value: 'item-3',
27 | },
28 | {
29 | question: 'Do you have access to my task content?',
30 | answer:
31 | 'The tasks are stored securely in your Browser based local database. It helps in making the tasks available on interfaces other than the PC, directly on the web!',
32 | value: 'item-4',
33 | },
34 | ]);
35 | });
36 |
37 | test('should have valid properties for each FAQ item', () => {
38 | FAQList.forEach((item) => {
39 | expect(item).toHaveProperty('question');
40 | expect(item).toHaveProperty('answer');
41 | expect(item).toHaveProperty('value');
42 | expect(typeof item.question).toBe('string');
43 | expect(typeof item.answer).toBe('string');
44 | expect(typeof item.value).toBe('string');
45 | });
46 | });
47 | });
48 |
--------------------------------------------------------------------------------
/frontend/src/components/HomeComponents/FAQ/faq-utils.ts:
--------------------------------------------------------------------------------
1 | export interface FAQProps {
2 | question: string;
3 | answer: string;
4 | value: string;
5 | }
6 |
7 | export const FAQList: FAQProps[] = [
8 | {
9 | question: 'What is CCSync?',
10 | answer:
11 | 'CCSync is a service that allows you to synchronize your tasks between your devices using Taskwarrior. It provides a hosted solution for taskchampion-sync-server, eliminating the need to set up and manage your own server.',
12 | value: 'item-1',
13 | },
14 | {
15 | question: 'What devices can I use with CCSync?',
16 | answer:
17 | 'CCSync works with any device that can run Taskwarrior, including desktops, laptops, smartphones, and tablets.',
18 | value: 'item-2',
19 | },
20 | {
21 | question: 'How do I initialize sync between my clients?',
22 | answer:
23 | 'The connection process is straightforward. Refer to the setup guide above for step-by-step instructions on configuring Taskwarrior to connect to our server.',
24 | value: 'item-3',
25 | },
26 | {
27 | question: 'Do you have access to my task content?',
28 | answer:
29 | 'The tasks are stored securely in your Browser based local database. It helps in making the tasks available on interfaces other than the PC, directly on the web!',
30 | value: 'item-4',
31 | },
32 | ];
33 |
--------------------------------------------------------------------------------
/frontend/src/components/HomeComponents/Footer/__tests__/Footer.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import '@testing-library/jest-dom';
3 | import { Footer } from '../Footer';
4 |
5 | jest.mock('../../../../assets/logo.png', () => 'logo-path');
6 | jest.mock('../../../../assets/logo_light.png', () => 'logo-light-path');
7 |
8 | describe('Footer component', () => {
9 | test('renders without crashing', () => {
10 | render();
11 | });
12 |
13 | test('renders the logo with correct alt text', () => {
14 | render();
15 | const logoElement = screen.getByAltText('Logo');
16 | expect(logoElement).toBeInTheDocument();
17 | });
18 |
19 | test('renders the light logo with correct alt text', () => {
20 | render();
21 | const logoElement = screen.getByAltText('Logo-light');
22 | expect(logoElement).toBeInTheDocument();
23 | });
24 | });
25 |
--------------------------------------------------------------------------------
/frontend/src/components/HomeComponents/Hero/CopyButton.tsx:
--------------------------------------------------------------------------------
1 | import { CopyToClipboard } from 'react-copy-to-clipboard';
2 | import { CopyIcon } from 'lucide-react';
3 | import { showToast } from './ToastNotification';
4 | import { CopyButtonProps } from '@/components/utils/types';
5 |
6 | export const CopyButton = ({ text, label }: CopyButtonProps) => (
7 | showToast(label)}>
8 |
9 |
10 |
11 |
12 | );
13 |
--------------------------------------------------------------------------------
/frontend/src/components/HomeComponents/Hero/Hero.tsx:
--------------------------------------------------------------------------------
1 | import { Props } from '../../utils/types';
2 | import { CopyButton } from './CopyButton';
3 | import { ToastNotification } from './ToastNotification';
4 |
5 | export const Hero = (props: Props) => {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 | Welcome,
13 | {' '}
14 |
15 |
16 |
17 | {props.name}!
18 |
19 |
20 |
21 |
22 |
23 | Follow the guide below to setup sync for your Taskwarrior clients
24 |
25 |
26 |
27 |
28 | Here are your credentials
29 |
30 |
31 | You may use your own as well, but make sure to use the same
32 | credentials on each client.
33 | Also, the tasks won't be stored on our database if you do
34 | so.
35 |
36 |
37 |
38 |
UUID
39 |
40 |
41 | {props.uuid}
42 |
43 |
44 |
45 |
46 |
47 |
48 | Encryption Secret
49 |
50 |
51 |
52 |
53 | {props.encryption_secret}
54 |
55 |
56 |
60 |
61 |
62 |
63 |
64 | {/* Shadow effect */}
65 |
66 |
67 |
68 |
69 | );
70 | };
71 |
--------------------------------------------------------------------------------
/frontend/src/components/HomeComponents/Hero/ToastNotification.tsx:
--------------------------------------------------------------------------------
1 | import { toast, ToastContainer } from 'react-toastify';
2 | import 'react-toastify/dist/ReactToastify.css';
3 |
4 | export const showToast = (text: string) => {
5 | toast.success(`${text} copied to clipboard!`, {
6 | position: 'bottom-left',
7 | autoClose: 3000,
8 | hideProgressBar: false,
9 | closeOnClick: true,
10 | pauseOnHover: true,
11 | draggable: true,
12 | progress: undefined,
13 | });
14 | };
15 |
16 | export const ToastNotification = () => (
17 |
28 | );
29 |
--------------------------------------------------------------------------------
/frontend/src/components/HomeComponents/Hero/__tests__/CopyButton.test.tsx:
--------------------------------------------------------------------------------
1 | // CopyButton.test.tsx
2 | import { render, fireEvent } from '@testing-library/react';
3 | import { CopyButton } from '../CopyButton';
4 | import { showToast } from '../ToastNotification';
5 |
6 | jest.mock('../ToastNotification', () => ({
7 | showToast: jest.fn(),
8 | }));
9 |
10 | describe('CopyButton Component', () => {
11 | test('copies text to clipboard and shows toast on copy', () => {
12 | const textToCopy = 'Example text';
13 | const label = 'Copied to clipboard';
14 | const { getByRole } = render(
15 |
16 | );
17 | const button = getByRole('button');
18 |
19 | fireEvent.click(button);
20 | expect(showToast).toHaveBeenCalledWith(label);
21 | });
22 | });
23 |
--------------------------------------------------------------------------------
/frontend/src/components/HomeComponents/Hero/__tests__/Hero.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import '@testing-library/jest-dom';
3 | import { Hero } from '../Hero';
4 | import { Props } from '../../../utils/types';
5 |
6 | jest.mock('../CopyButton', () => ({
7 | CopyButton: ({ text, label }: { text: string; label: string }) => (
8 |
9 | {label}
10 |
11 | ),
12 | }));
13 |
14 | jest.mock('../ToastNotification', () => ({
15 | ToastNotification: () =>
,
16 | showToast: jest.fn(),
17 | }));
18 |
19 | describe('Hero component', () => {
20 | const mockProps: Props = {
21 | name: 'Test User',
22 | uuid: '1234-5678-9012-3456',
23 | encryption_secret: 's3cr3t',
24 | };
25 |
26 | test('renders without crashing', () => {
27 | render( );
28 | });
29 |
30 | test('renders the welcome message with the user name', () => {
31 | render( );
32 | const welcomeMessage = screen.getByText(/Welcome,/i);
33 | const nameMessage = screen.getByText(/Test User!/i);
34 | expect(welcomeMessage).toBeInTheDocument();
35 | expect(nameMessage).toBeInTheDocument();
36 | });
37 |
38 | test('renders the guide message', () => {
39 | render( );
40 | const guideMessage = screen.getByText(
41 | /Follow the guide below to setup sync for your Taskwarrior clients/i
42 | );
43 | expect(guideMessage).toBeInTheDocument();
44 | });
45 |
46 | test('renders the ToastNotification component', () => {
47 | render( );
48 | const toastNotification = screen.getByTestId('toast-notification');
49 | expect(toastNotification).toBeInTheDocument();
50 | });
51 | });
52 |
--------------------------------------------------------------------------------
/frontend/src/components/HomeComponents/Hero/__tests__/ToastNotification.test.tsx:
--------------------------------------------------------------------------------
1 | import { toast } from 'react-toastify';
2 | import '@testing-library/jest-dom';
3 | import { showToast, ToastNotification } from '../ToastNotification';
4 | import { render } from '@testing-library/react';
5 |
6 | jest.mock('react-toastify/dist/ReactToastify.css', () => ({}));
7 |
8 | jest.mock('react-toastify', () => ({
9 | toast: {
10 | success: jest.fn(),
11 | },
12 | ToastContainer: jest.fn(() => null),
13 | }));
14 |
15 | describe('Toast Notification Tests', () => {
16 | beforeEach(() => {
17 | jest.clearAllMocks();
18 | });
19 |
20 | test('showToast function should call toast.success with correct message', () => {
21 | const text = 'Test Text';
22 | showToast(text);
23 | expect(toast.success).toHaveBeenCalledWith(`${text} copied to clipboard!`, {
24 | position: 'bottom-left',
25 | autoClose: 3000,
26 | hideProgressBar: false,
27 | closeOnClick: true,
28 | pauseOnHover: true,
29 | draggable: true,
30 | progress: undefined,
31 | });
32 | });
33 | });
34 |
35 | describe('ToastNotification', () => {
36 | test('renders ToastContainer with correct properties', () => {
37 | render( );
38 |
39 | expect(
40 | jest.requireMock('react-toastify').ToastContainer
41 | ).toHaveBeenCalledWith(
42 | expect.objectContaining({
43 | position: 'bottom-center',
44 | autoClose: 3000,
45 | hideProgressBar: false,
46 | newestOnTop: false,
47 | closeOnClick: true,
48 | rtl: false,
49 | pauseOnFocusLoss: true,
50 | draggable: true,
51 | pauseOnHover: true,
52 | }),
53 | {}
54 | );
55 | });
56 | });
57 |
--------------------------------------------------------------------------------
/frontend/src/components/HomeComponents/Navbar/Navbar.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import {
3 | NavigationMenu,
4 | NavigationMenuItem,
5 | NavigationMenuList,
6 | } from '@/components/ui/navigation-menu';
7 | import logo from '../../../assets/logo.png';
8 | import logoLight from '../../../assets/logo_light.png';
9 | import { NavbarMobile } from './NavbarMobile';
10 | import { NavbarDesktop } from './NavbarDesktop';
11 | import { Props } from './navbar-utils';
12 |
13 | export const Navbar = (
14 | props: Props & {
15 | isLoading: boolean;
16 | setIsLoading: (val: boolean) => void;
17 | }
18 | ) => {
19 | const [isOpen, setIsOpen] = useState(false);
20 |
21 | return (
22 |
54 | );
55 | };
56 |
--------------------------------------------------------------------------------
/frontend/src/components/HomeComponents/Navbar/NavbarDesktop.tsx:
--------------------------------------------------------------------------------
1 | import { Github, LogOut, Trash2 } from 'lucide-react';
2 | import {
3 | DropdownMenu,
4 | DropdownMenuContent,
5 | DropdownMenuItem,
6 | DropdownMenuLabel,
7 | DropdownMenuTrigger,
8 | } from '@/components/ui/dropdown-menu';
9 | import { Avatar, AvatarFallback, AvatarImage } from '../../ui/avatar';
10 | import { ModeToggle } from '../../utils/theme-mode-toggle';
11 | import { buttonVariants } from '@/components/ui/button';
12 | import {
13 | routeList,
14 | deleteAllTasks,
15 | handleLogout,
16 | RouteProps,
17 | Props,
18 | } from './navbar-utils';
19 | import { url } from '@/components/utils/URLs';
20 |
21 | export const NavbarDesktop = (
22 | props: Props & {
23 | isLoading: boolean;
24 | setIsLoading: (val: boolean) => void;
25 | }
26 | ) => (
27 |
28 |
29 | {routeList.map((route: RouteProps, i) => (
30 |
38 | {route.label}
39 |
40 | ))}
41 |
42 |
43 |
44 |
45 |
46 |
47 | CN
48 |
49 |
50 |
51 | {props.email}
52 | deleteAllTasks(props)}
55 | >
56 |
57 | Delete all tasks
58 |
59 | window.open(url.githubRepoURL, '_blank')}
61 | >
62 |
63 | GitHub
64 |
65 |
66 |
67 | Log out
68 |
69 |
70 |
71 |
72 |
73 |
74 | );
75 |
--------------------------------------------------------------------------------
/frontend/src/components/HomeComponents/Navbar/NavbarMobile.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Sheet,
3 | SheetContent,
4 | SheetHeader,
5 | SheetTitle,
6 | SheetTrigger,
7 | } from '@/components/ui/sheet';
8 | import { url } from '@/components/utils/URLs';
9 | import { GitHubLogoIcon } from '@radix-ui/react-icons';
10 | import { Menu } from 'lucide-react';
11 | import { ModeToggle } from '../../utils/theme-mode-toggle';
12 | import { buttonVariants } from '@/components/ui/button';
13 | import {
14 | routeList,
15 | deleteAllTasks,
16 | handleLogout,
17 | RouteProps,
18 | Props,
19 | } from './navbar-utils';
20 |
21 | export const NavbarMobile = (
22 | props: Props & { setIsOpen: (isOpen: boolean) => void; isOpen: boolean } & {
23 | isLoading: boolean;
24 | setIsLoading: (val: boolean) => void;
25 | }
26 | ) => (
27 |
28 |
29 |
30 |
31 |
32 | props.setIsOpen(true)}
35 | >
36 | Menu Icon
37 |
38 |
39 |
40 |
41 |
42 | CCSync
43 |
44 |
45 |
46 | {routeList.map(({ href, label }: RouteProps) => (
47 | props.setIsOpen(false)}
52 | className={buttonVariants({ variant: 'ghost' })}
53 | >
54 | {label}
55 |
56 | ))}
57 |
65 |
66 | Github
67 |
68 | deleteAllTasks(props)}
70 | className={`w-[110px] border ${buttonVariants({
71 | variant: 'destructive',
72 | })}`}
73 | >
74 | Delete All Tasks
75 |
76 |
82 | Log out
83 |
84 |
85 |
86 |
87 |
88 | );
89 |
--------------------------------------------------------------------------------
/frontend/src/components/HomeComponents/Navbar/__tests__/Navbar.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import { Navbar } from '../Navbar';
3 | import { Props } from '../navbar-utils';
4 |
5 | // Mocking the NavbarMobile and NavbarDesktop components
6 | jest.mock('../NavbarMobile', () => ({
7 | NavbarMobile: ({
8 | isOpen,
9 | }: {
10 | isOpen: boolean;
11 | setIsOpen: (isOpen: boolean) => void;
12 | }) =>
,
13 | }));
14 |
15 | jest.mock('../NavbarDesktop', () => ({
16 | NavbarDesktop: (_props: Props) =>
,
17 | }));
18 |
19 | // Mocking the logo images
20 | jest.mock('../../../../assets/logo.png', () => 'logo.png');
21 | jest.mock('../../../../assets/logo_light.png', () => 'logo_light.png');
22 |
23 | describe('Navbar Component', () => {
24 | const mockSetIsLoading = jest.fn();
25 |
26 | const props: Props & {
27 | isLoading: boolean;
28 | setIsLoading: (val: boolean) => void;
29 | } = {
30 | imgurl: '',
31 | email: '',
32 | encryptionSecret: '',
33 | origin: '',
34 | UUID: '',
35 | isLoading: false,
36 | setIsLoading: mockSetIsLoading,
37 | };
38 |
39 | test('renders Navbar component with correct elements', () => {
40 | render( );
41 |
42 | const logoLightImage = screen.getByAltText('Light-Logo');
43 | const logoImage = screen.getByAltText('Logo');
44 |
45 | expect(logoLightImage).toBeInTheDocument();
46 | expect(logoImage).toBeInTheDocument();
47 |
48 | // Check for NavbarDesktop and NavbarMobile components
49 | expect(screen.getByTestId('navbar-desktop')).toBeInTheDocument();
50 | expect(screen.getByTestId('navbar-mobile')).toBeInTheDocument();
51 | });
52 |
53 | test('NavbarMobile component receives correct props', () => {
54 | render( );
55 |
56 | const navbarMobile = screen.getByTestId('navbar-mobile');
57 | expect(navbarMobile).toHaveAttribute('data-isopen', 'false');
58 | });
59 | });
60 |
--------------------------------------------------------------------------------
/frontend/src/components/HomeComponents/Navbar/__tests__/NavbarDesktop.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import { NavbarDesktop } from '../NavbarDesktop';
3 | import { Props, routeList } from '../navbar-utils';
4 |
5 | // Mock external dependencies
6 | jest.mock('../navbar-utils', () => ({
7 | deleteAllTasks: jest.fn(),
8 | handleLogout: jest.fn(),
9 | routeList: [
10 | { href: '#', label: 'Home' },
11 | { href: '#tasks', label: 'Tasks' },
12 | { href: '#setup-guide', label: 'Setup Guide' },
13 | { href: '#faq', label: 'FAQ' },
14 | ],
15 | }));
16 |
17 | describe('NavbarDesktop', () => {
18 | const mockSetIsLoading = jest.fn();
19 | const mockProps: Props = {
20 | imgurl: 'http://example.com/image.png',
21 | email: 'test@example.com',
22 | encryptionSecret: 'secret',
23 | origin: 'http://localhost:3000',
24 | UUID: '1234-5678',
25 | };
26 |
27 | const extendedProps = {
28 | ...mockProps,
29 | isLoading: false,
30 | setIsLoading: mockSetIsLoading,
31 | };
32 |
33 | afterEach(() => {
34 | jest.clearAllMocks();
35 | });
36 |
37 | it('renders the navigation links correctly', () => {
38 | render( );
39 |
40 | routeList.forEach((route) => {
41 | expect(screen.getByText(route.label)).toBeInTheDocument();
42 | });
43 | });
44 |
45 | it('displays user email and handles dropdown menu actions', () => {
46 | render( );
47 | });
48 | });
49 |
--------------------------------------------------------------------------------
/frontend/src/components/HomeComponents/Navbar/__tests__/NavbarMobile.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen, fireEvent } from '@testing-library/react';
2 | import { NavbarMobile } from '../NavbarMobile';
3 | import {
4 | deleteAllTasks,
5 | handleLogout,
6 | Props,
7 | routeList,
8 | } from '../navbar-utils';
9 |
10 | jest.mock('../navbar-utils', () => ({
11 | deleteAllTasks: jest.fn(),
12 | handleLogout: jest.fn(),
13 | routeList: [
14 | { href: '#', label: 'Home' },
15 | { href: '#tasks', label: 'Tasks' },
16 | { href: '#setup-guide', label: 'Setup Guide' },
17 | { href: '#faq', label: 'FAQ' },
18 | ],
19 | }));
20 |
21 | describe('NavbarMobile', () => {
22 | const mockSetIsOpen = jest.fn();
23 | const mockSetIsLoading = jest.fn();
24 | const mockProps: Props & {
25 | setIsOpen: (isOpen: boolean) => void;
26 | isOpen: boolean;
27 | isLoading: boolean;
28 | setIsLoading: (val: boolean) => void;
29 | } = {
30 | imgurl: 'http://example.com/image.png',
31 | email: 'test@example.com',
32 | encryptionSecret: 'secret',
33 | origin: 'http://localhost:3000',
34 | UUID: '1234-5678',
35 | setIsOpen: mockSetIsOpen,
36 | isOpen: false,
37 | isLoading: false,
38 | setIsLoading: mockSetIsLoading,
39 | };
40 |
41 | afterEach(() => {
42 | jest.clearAllMocks();
43 | });
44 |
45 | it('renders the ModeToggle and Menu button', () => {
46 | render( );
47 | });
48 |
49 | it('opens the menu when Menu button is clicked', () => {
50 | render( );
51 | const menuButton = screen.getByRole('button', { name: /menu icon/i });
52 |
53 | fireEvent.click(menuButton);
54 | expect(mockProps.setIsOpen).toHaveBeenCalledWith(true);
55 | });
56 |
57 | it('displays the navigation links and buttons correctly when menu is open', () => {
58 | const openProps = { ...mockProps, isOpen: true };
59 | render( );
60 |
61 | routeList.forEach((route) => {
62 | expect(screen.getByText(route.label)).toBeInTheDocument();
63 | });
64 | expect(screen.getByText('Github')).toBeInTheDocument();
65 | expect(screen.getByText('Delete All Tasks')).toBeInTheDocument();
66 | expect(screen.getByText('Log out')).toBeInTheDocument();
67 | });
68 |
69 | it('closes the menu when a navigation link is clicked', () => {
70 | const openProps = { ...mockProps, isOpen: true };
71 | render( );
72 |
73 | const homeLink = screen.getByText('Home');
74 | fireEvent.click(homeLink);
75 | expect(mockProps.setIsOpen).toHaveBeenCalledWith(false);
76 | });
77 |
78 | it("calls deleteAllTasks when 'Delete All Tasks' is clicked", () => {
79 | const openProps = { ...mockProps, isOpen: true };
80 | render( );
81 | const deleteButton = screen.getByText('Delete All Tasks');
82 |
83 | fireEvent.click(deleteButton);
84 | expect(deleteAllTasks).toHaveBeenCalledWith(openProps);
85 | });
86 |
87 | it("calls handleLogout when 'Log out' is clicked", () => {
88 | const openProps = { ...mockProps, isOpen: true };
89 | render( );
90 | const logoutButton = screen.getByText('Log out');
91 |
92 | fireEvent.click(logoutButton);
93 | expect(handleLogout).toHaveBeenCalled();
94 | });
95 | });
96 |
--------------------------------------------------------------------------------
/frontend/src/components/HomeComponents/Navbar/__tests__/navbar-utils.test.ts:
--------------------------------------------------------------------------------
1 | // import { tasksCollection } from '@/lib/controller';
2 | import { handleLogout, deleteAllTasks } from '../navbar-utils';
3 |
4 | // Mock external dependencies
5 | jest.mock('react-toastify', () => ({
6 | toast: {
7 | success: jest.fn(),
8 | error: jest.fn(),
9 | info: jest.fn(),
10 | update: jest.fn(),
11 | },
12 | }));
13 |
14 | jest.mock('dexie', () => {
15 | return jest.fn().mockImplementation(() => ({
16 | version: jest.fn().mockReturnThis(),
17 | stores: jest.fn().mockReturnThis(),
18 | table: jest.fn().mockReturnValue({
19 | where: jest.fn().mockReturnThis(),
20 | equals: jest.fn().mockReturnThis(),
21 | delete: jest.fn().mockResolvedValue(undefined), // simulates delete success
22 | }),
23 | }));
24 | });
25 |
26 | jest.mock('@/components/utils/URLs.ts', () => ({
27 | url: {
28 | backendURL: 'http://localhost:3000/',
29 | },
30 | }));
31 |
32 | global.fetch = jest.fn();
33 |
34 | describe('navbar-utils', () => {
35 | afterEach(() => {
36 | jest.clearAllMocks();
37 | });
38 |
39 | describe('handleLogout', () => {
40 | it('should call fetch with correct URL and redirect on success', async () => {
41 | (fetch as jest.Mock).mockResolvedValue({ ok: true });
42 |
43 | await handleLogout();
44 |
45 | expect(fetch).toHaveBeenCalledWith('http://localhost:3000/auth/logout', {
46 | method: 'POST',
47 | credentials: 'include',
48 | });
49 | expect(window.location.href).toBe('http://localhost/');
50 | });
51 |
52 | it('should log an error if fetch fails', async () => {
53 | const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
54 |
55 | (fetch as jest.Mock).mockResolvedValue({ ok: false });
56 |
57 | await handleLogout();
58 |
59 | expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to logout');
60 | consoleErrorSpy.mockRestore();
61 | });
62 |
63 | it('should log an error if fetch throws an exception', async () => {
64 | const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
65 |
66 | (fetch as jest.Mock).mockRejectedValue(new Error('Network error'));
67 |
68 | await handleLogout();
69 |
70 | expect(consoleErrorSpy).toHaveBeenCalledWith(
71 | 'Error logging out:',
72 | expect.any(Error)
73 | );
74 | consoleErrorSpy.mockRestore();
75 | });
76 | });
77 |
78 | describe('deleteAllTasks', () => {
79 | it('should delete tasks without error', async () => {
80 | const props = {
81 | imgurl: '',
82 | email: 'test@example.com',
83 | encryptionSecret: '',
84 | origin: '',
85 | UUID: '',
86 | };
87 |
88 | await expect(deleteAllTasks(props)).resolves.toBeUndefined();
89 | });
90 | });
91 | });
92 |
--------------------------------------------------------------------------------
/frontend/src/components/HomeComponents/Navbar/navbar-utils.ts:
--------------------------------------------------------------------------------
1 | import { toast } from 'react-toastify';
2 | import { url } from '@/components/utils/URLs';
3 | import Dexie from 'dexie';
4 | import { Task } from '@/components/utils/types';
5 |
6 | class TasksDatabase extends Dexie {
7 | tasks: Dexie.Table; // string = type of primary key (uuid)
8 |
9 | constructor() {
10 | super('tasksDB');
11 | this.version(1).stores({
12 | tasks: 'uuid, email, status, project', // Primary key and indexed props
13 | });
14 | this.tasks = this.table('tasks');
15 | }
16 | }
17 | const db = new TasksDatabase();
18 |
19 | export interface RouteProps {
20 | href: string;
21 | label: string;
22 | }
23 |
24 | export type Props = {
25 | imgurl: string;
26 | email: string;
27 | encryptionSecret: string;
28 | origin: string;
29 | UUID: string;
30 | };
31 |
32 | export const routeList: RouteProps[] = [
33 | { href: '#', label: 'Home' },
34 | { href: '#tasks', label: 'Tasks' },
35 | { href: '#setup-guide', label: 'Setup Guide' },
36 | { href: '#faq', label: 'FAQ' },
37 | ];
38 |
39 | export const handleLogout = async () => {
40 | try {
41 | const response = await fetch(url.backendURL + 'auth/logout', {
42 | method: 'POST',
43 | credentials: 'include',
44 | });
45 | if (response.ok) {
46 | window.location.href = '/';
47 | } else {
48 | console.error('Failed to logout');
49 | }
50 | } catch (error) {
51 | console.error('Error logging out:', error);
52 | }
53 | };
54 |
55 | export const deleteAllTasks = async (props: Props) => {
56 | const loadingToastId = toast.info(
57 | `Deleting all tasks for ${props.email}...`,
58 | {
59 | position: 'bottom-left',
60 | autoClose: false,
61 | hideProgressBar: true,
62 | closeOnClick: false,
63 | pauseOnHover: true,
64 | draggable: true,
65 | }
66 | );
67 |
68 | try {
69 | // Delete tasks where email matches props.email
70 | await db.tasks.where('email').equals(props.email).delete();
71 |
72 | toast.update(loadingToastId, {
73 | render: `All tasks for ${props.email} deleted successfully!`,
74 | type: 'success',
75 | autoClose: 3000,
76 | hideProgressBar: false,
77 | closeOnClick: true,
78 | pauseOnHover: true,
79 | draggable: true,
80 | });
81 |
82 | console.log(`Deleted tasks for email: ${props.email}`);
83 | } catch (error) {
84 | toast.update(loadingToastId, {
85 | render: `Error deleting tasks for ${props.email}: ${error}`,
86 | type: 'error',
87 | autoClose: 3000,
88 | hideProgressBar: false,
89 | closeOnClick: true,
90 | pauseOnHover: true,
91 | draggable: true,
92 | });
93 | console.error(`Error deleting tasks for ${props.email}:`, error);
94 | }
95 | };
96 |
--------------------------------------------------------------------------------
/frontend/src/components/HomeComponents/SetupGuide/CopyableCode.tsx:
--------------------------------------------------------------------------------
1 | import { CopyIcon } from 'lucide-react';
2 | import CopyToClipboard from 'react-copy-to-clipboard';
3 | import { toast } from 'react-toastify';
4 |
5 | interface CopyableCodeProps {
6 | text: string;
7 | copyText: string;
8 | }
9 |
10 | export const CopyableCode = ({ text, copyText }: CopyableCodeProps) => {
11 | const handleCopy = (text: string) => {
12 | toast.success(`${text} copied to clipboard!`, {
13 | position: 'bottom-left',
14 | autoClose: 3000,
15 | hideProgressBar: false,
16 | closeOnClick: true,
17 | pauseOnHover: true,
18 | draggable: true,
19 | progress: undefined,
20 | });
21 | };
22 |
23 | return (
24 |
25 |
26 |
27 | {text}
28 |
29 |
30 |
handleCopy(copyText)}>
31 |
32 |
33 |
34 |
35 |
36 | );
37 | };
38 |
--------------------------------------------------------------------------------
/frontend/src/components/HomeComponents/SetupGuide/__tests__/CopyableCode.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen, fireEvent, waitFor } from '@testing-library/react';
2 | import { CopyableCode } from '../CopyableCode';
3 | import { toast } from 'react-toastify';
4 |
5 | // Mock the toast function
6 | jest.mock('react-toastify', () => ({
7 | toast: {
8 | success: jest.fn(),
9 | },
10 | }));
11 |
12 | // Mock CopyIcon
13 | jest.mock('lucide-react', () => ({
14 | CopyIcon: () => ,
15 | }));
16 |
17 | describe('CopyableCode', () => {
18 | const sampleText = 'Sample code';
19 | const sampleCopyText = 'Copy this text';
20 |
21 | it('renders correctly with given text', () => {
22 | render( );
23 |
24 | expect(screen.getByText(sampleText)).toBeInTheDocument();
25 | expect(screen.getByTestId('copy-icon')).toBeInTheDocument();
26 | });
27 |
28 | it('copies text to clipboard and shows toast message', async () => {
29 | render( );
30 |
31 | fireEvent.click(screen.getByTestId('copy-icon'));
32 |
33 | await waitFor(() => {
34 | expect(toast.success).toHaveBeenCalledWith(
35 | `${sampleCopyText} copied to clipboard!`,
36 | {
37 | position: 'bottom-left',
38 | autoClose: 3000,
39 | hideProgressBar: false,
40 | closeOnClick: true,
41 | pauseOnHover: true,
42 | draggable: true,
43 | progress: undefined,
44 | }
45 | );
46 | });
47 | });
48 | });
49 |
--------------------------------------------------------------------------------
/frontend/src/components/HomeComponents/SetupGuide/__tests__/SetupGuide.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import { SetupGuide } from '../SetupGuide';
3 | import { Props } from '../../../utils/types';
4 | import { url } from '@/components/utils/URLs';
5 |
6 | // Mocking the CopyableCode component
7 | jest.mock('../CopyableCode', () => ({
8 | CopyableCode: ({ text, copyText }: { text: string; copyText: string }) => (
9 |
10 | {text}
11 |
12 | ),
13 | }));
14 |
15 | describe('SetupGuide Component', () => {
16 | const props: Props = {
17 | name: 'test-name',
18 | encryption_secret: 'test-encryption-secret',
19 | uuid: 'test-uuid',
20 | };
21 |
22 | test('renders SetupGuide component with correct text', () => {
23 | render( );
24 | });
25 |
26 | test('renders CopyableCode components with correct props', () => {
27 | render( );
28 |
29 | // Check for CopyableCode components
30 | const copyableCodeElements = screen.getAllByTestId('copyable-code');
31 | expect(copyableCodeElements.length).toBe(5);
32 |
33 | // Validate the text and copyText props of each CopyableCode component
34 | expect(copyableCodeElements[0]).toHaveAttribute(
35 | 'data-text',
36 | 'task --version'
37 | );
38 | expect(copyableCodeElements[0]).toHaveAttribute(
39 | 'data-copytext',
40 | 'task --version'
41 | );
42 | expect(copyableCodeElements[1]).toHaveAttribute(
43 | 'data-text',
44 | `task config sync.encryption_secret ${props.encryption_secret}`
45 | );
46 | expect(copyableCodeElements[1]).toHaveAttribute(
47 | 'data-copytext',
48 | `task config sync.encryption_secret ${props.encryption_secret}`
49 | );
50 | expect(copyableCodeElements[2]).toHaveAttribute(
51 | 'data-text',
52 | `task config sync.server.origin ${url.containerOrigin}`
53 | );
54 | expect(copyableCodeElements[2]).toHaveAttribute(
55 | 'data-copytext',
56 | `task config sync.server.origin ${url.containerOrigin}`
57 | );
58 | expect(copyableCodeElements[3]).toHaveAttribute(
59 | 'data-text',
60 | `task config sync.server.client_id ${props.uuid}`
61 | );
62 | expect(copyableCodeElements[3]).toHaveAttribute(
63 | 'data-copytext',
64 | `task config sync.server.client_id ${props.uuid}`
65 | );
66 | });
67 | });
68 |
--------------------------------------------------------------------------------
/frontend/src/components/HomeComponents/Tasks/Pagination.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@/components/ui/button';
2 | import React from 'react';
3 |
4 | interface PaginationProps {
5 | currentPage: number;
6 | totalPages: number;
7 | paginate: (page: number) => void;
8 | getDisplayedPages: (totalPages: number, currentPage: number) => number[];
9 | }
10 |
11 | const Pagination: React.FC = ({
12 | currentPage,
13 | totalPages,
14 | paginate,
15 | getDisplayedPages,
16 | }) => {
17 | return (
18 |
19 |
paginate(currentPage - 1)}
23 | disabled={currentPage === 1}
24 | >
25 | Previous
26 |
27 |
28 |
29 | {getDisplayedPages(totalPages, currentPage).map((page) => (
30 |
31 | paginate(page)}
35 | >
36 | {page}
37 |
38 |
39 | ))}
40 |
41 |
42 |
paginate(currentPage + 1)}
46 | disabled={currentPage === totalPages}
47 | >
48 | Next
49 |
50 |
51 | );
52 | };
53 |
54 | export default Pagination;
55 |
--------------------------------------------------------------------------------
/frontend/src/components/HomeComponents/Tasks/__tests__/Pagination.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen, fireEvent } from '@testing-library/react';
2 | import Pagination from '../Pagination';
3 |
4 | describe('Pagination', () => {
5 | const mockPaginate = jest.fn();
6 | const mockGetDisplayedPages = jest.fn((totalPages, _currentPage) => {
7 | const pages = [];
8 | for (let i = 1; i <= totalPages; i++) {
9 | pages.push(i);
10 | }
11 | return pages;
12 | });
13 |
14 | beforeEach(() => {
15 | mockPaginate.mockClear();
16 | });
17 |
18 | const renderComponent = (currentPage: number, totalPages: number) => {
19 | return render(
20 |
26 | );
27 | };
28 |
29 | it('renders correctly with given props', () => {
30 | renderComponent(1, 5);
31 |
32 | expect(screen.getByText('Previous')).toBeInTheDocument();
33 | expect(screen.getByText('Next')).toBeInTheDocument();
34 | expect(screen.getByText('1')).toBeInTheDocument();
35 | expect(screen.getByText('2')).toBeInTheDocument();
36 | expect(screen.getByText('3')).toBeInTheDocument();
37 | expect(screen.getByText('4')).toBeInTheDocument();
38 | expect(screen.getByText('5')).toBeInTheDocument();
39 | });
40 |
41 | it('disables the "Previous" button on the first page', () => {
42 | renderComponent(1, 5);
43 |
44 | expect(screen.getByText('Previous')).toBeDisabled();
45 | expect(screen.getByText('Next')).toBeEnabled();
46 | });
47 |
48 | it('disables the "Next" button on the last page', () => {
49 | renderComponent(5, 5);
50 |
51 | expect(screen.getByText('Previous')).toBeEnabled();
52 | expect(screen.getByText('Next')).toBeDisabled();
53 | });
54 |
55 | it('calls paginate with correct arguments when a page button is clicked', () => {
56 | renderComponent(3, 5);
57 |
58 | fireEvent.click(screen.getByText('1'));
59 | fireEvent.click(screen.getByText('4'));
60 |
61 | expect(mockPaginate).toHaveBeenCalledWith(1);
62 | expect(mockPaginate).toHaveBeenCalledWith(4);
63 | });
64 |
65 | it('calls paginate with correct arguments when "Previous" and "Next" buttons are clicked', () => {
66 | renderComponent(3, 5);
67 |
68 | fireEvent.click(screen.getByText('Previous'));
69 | expect(mockPaginate).toHaveBeenCalledWith(2);
70 |
71 | fireEvent.click(screen.getByText('Next'));
72 | expect(mockPaginate).toHaveBeenCalledWith(4);
73 | });
74 | });
75 |
--------------------------------------------------------------------------------
/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import { Tasks } from '../Tasks'; // Ensure correct path to Tasks component
3 |
4 | // Mock props for the Tasks component
5 | const mockProps = {
6 | email: 'test@example.com',
7 | encryptionSecret: 'mockEncryptionSecret',
8 | UUID: 'mockUUID',
9 | isLoading: false, // mock the loading state
10 | setIsLoading: jest.fn(), // mock the setter function
11 | };
12 |
13 | // Mock functions and modules
14 | jest.mock('react-toastify', () => ({
15 | toast: {
16 | success: jest.fn(),
17 | error: jest.fn(),
18 | },
19 | }));
20 |
21 | jest.mock('../tasks-utils', () => ({
22 | markTaskAsCompleted: jest.fn(),
23 | markTaskAsDeleted: jest.fn(),
24 | }));
25 |
26 | global.fetch = jest.fn().mockResolvedValue({ ok: true });
27 |
28 | describe('Tasks Component', () => {
29 | test('renders tasks component', async () => {
30 | render( );
31 | expect(screen.getByTestId('tasks')).toBeInTheDocument();
32 | });
33 | });
34 |
--------------------------------------------------------------------------------
/frontend/src/components/HomeComponents/Tasks/hooks.ts:
--------------------------------------------------------------------------------
1 | import { Task } from "@/components/utils/types";
2 | import Dexie from "dexie";
3 |
4 | export const fetchTaskwarriorTasks = async ({
5 | email,
6 | encryptionSecret,
7 | UUID,
8 | backendURL,
9 | }: {
10 | email: string;
11 | encryptionSecret: string;
12 | UUID: string;
13 | backendURL: string;
14 | }) => {
15 | const fullURL =
16 | backendURL +
17 | `/tasks?email=${encodeURIComponent(
18 | email
19 | )}&encryptionSecret=${encodeURIComponent(
20 | encryptionSecret
21 | )}&UUID=${encodeURIComponent(UUID)}`;
22 |
23 | const response = await fetch(fullURL, {
24 | method: 'GET',
25 | headers: {
26 | 'Content-Type': 'application/json',
27 | },
28 | });
29 |
30 | if (!response.ok) {
31 | throw new Error('Failed to fetch tasks from backend');
32 | }
33 |
34 | return response.json();
35 | };
36 |
37 | export const addTaskToBackend = async ({
38 | email,
39 | encryptionSecret,
40 | UUID,
41 | description,
42 | project,
43 | priority,
44 | due,
45 | tags,
46 | backendURL,
47 | }: {
48 | email: string;
49 | encryptionSecret: string;
50 | UUID: string;
51 | description: string;
52 | project: string;
53 | priority: string;
54 | due: string;
55 | tags: string[];
56 | backendURL: string;
57 | }) => {
58 | const response = await fetch(`${backendURL}add-task`, {
59 | method: 'POST',
60 | body: JSON.stringify({
61 | email,
62 | encryptionSecret,
63 | UUID,
64 | description,
65 | project,
66 | priority,
67 | due,
68 | tags,
69 | }),
70 | headers: {
71 | 'Content-Type': 'application/json',
72 | },
73 | });
74 |
75 | if (!response.ok) {
76 | const errorText = await response.text();
77 | throw new Error(errorText || 'Failed to add task');
78 | }
79 |
80 | return response;
81 | };
82 |
83 | export const editTaskOnBackend = async ({
84 | email,
85 | encryptionSecret,
86 | UUID,
87 | description,
88 | tags,
89 | taskID,
90 | backendURL,
91 | }: {
92 | email: string;
93 | encryptionSecret: string;
94 | UUID: string;
95 | description: string;
96 | tags: string[];
97 | taskID: string;
98 | backendURL: string;
99 | }) => {
100 | const response = await fetch(`${backendURL}edit-task`, {
101 | method: 'POST',
102 | body: JSON.stringify({
103 | email,
104 | encryptionSecret,
105 | UUID,
106 | taskID,
107 | description,
108 | tags,
109 | }),
110 | headers: {
111 | 'Content-Type': 'application/json',
112 | },
113 | });
114 |
115 | if (!response.ok) {
116 | const errorText = await response.text();
117 | throw new Error(errorText || 'Failed to edit task');
118 | }
119 |
120 | return response;
121 | };
122 |
123 | export class TasksDatabase extends Dexie {
124 | tasks: Dexie.Table;
125 |
126 | constructor() {
127 | super('tasksDB');
128 | this.version(1).stores({
129 | tasks: 'uuid, email, status, project',
130 | });
131 | this.tasks = this.table('tasks');
132 | }
133 | }
--------------------------------------------------------------------------------
/frontend/src/components/LandingComponents/About/About.tsx:
--------------------------------------------------------------------------------
1 | import { motion, useAnimation } from 'framer-motion';
2 | import { useInView } from 'react-intersection-observer';
3 | import { useEffect } from 'react';
4 |
5 | const animateUp = {
6 | hidden: { opacity: 0, y: 20 },
7 | visible: { opacity: 1, y: 0, transition: { duration: 0.5 } },
8 | };
9 |
10 | export const About = () => {
11 | const controls = useAnimation();
12 | const { ref, inView } = useInView();
13 |
14 | useEffect(() => {
15 | if (inView) {
16 | controls.start('visible');
17 | } else {
18 | controls.start('hidden');
19 | }
20 | }, [controls, inView]);
21 |
22 | return (
23 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | About{' '}
39 |
40 | CCSync
41 |
42 |
43 | CCSync uses a hosted Taskchampion Sync Server instance that
44 | helps users to sync tasks across all your Taskwarrior 3.0
45 | clients and higher.
46 |
47 | Users can sign in using Google and generate their secret keys to
48 | setup synchronisation on their Taskwarrior clients.
49 |
50 |
51 |
52 |
53 |
54 |
55 | );
56 | };
57 |
--------------------------------------------------------------------------------
/frontend/src/components/LandingComponents/About/__tests__/About.test.tsx:
--------------------------------------------------------------------------------
1 | // About.test.tsx
2 | import { render, screen } from '@testing-library/react';
3 | import '@testing-library/jest-dom';
4 | import { About } from '../About';
5 | import { useInView } from 'react-intersection-observer';
6 |
7 | jest.mock('react-intersection-observer', () => ({
8 | useInView: jest.fn(),
9 | }));
10 |
11 | describe('About Component', () => {
12 | beforeEach(() => {
13 | (useInView as jest.Mock).mockReturnValue({
14 | ref: jest.fn(),
15 | inView: true,
16 | });
17 | });
18 |
19 | it('renders the About section', () => {
20 | render( );
21 | const aboutSection = screen.getByTestId('about-section');
22 | expect(aboutSection).toBeInTheDocument();
23 | });
24 |
25 | it('renders the heading correctly', () => {
26 | render( );
27 | const heading = screen.getByRole('heading', { name: /About CCSync/i });
28 | expect(heading).toBeInTheDocument();
29 | expect(heading).toHaveTextContent('About CCSync');
30 | });
31 |
32 | it('renders the paragraph text correctly', () => {
33 | render( );
34 | const paragraph = screen.getByText(
35 | /CCSync uses a hosted Taskchampion Sync Server instance/i
36 | );
37 | expect(paragraph).toBeInTheDocument();
38 | expect(paragraph).toHaveTextContent(
39 | 'CCSync uses a hosted Taskchampion Sync Server instance that helps users to sync tasks across all your Taskwarrior 3.0 clients and higher.'
40 | );
41 | });
42 |
43 | it('renders additional paragraph content correctly', () => {
44 | render( );
45 | const paragraph = screen.getByText(
46 | /Users can sign in using Google and generate their secret keys to setup synchronisation on their Taskwarrior clients/i
47 | );
48 | expect(paragraph).toBeInTheDocument();
49 | expect(paragraph).toHaveTextContent(
50 | 'Users can sign in using Google and generate their secret keys to setup synchronisation on their Taskwarrior clients.'
51 | );
52 | });
53 | });
54 |
--------------------------------------------------------------------------------
/frontend/src/components/LandingComponents/Contact/Contact.tsx:
--------------------------------------------------------------------------------
1 | import { motion } from 'framer-motion';
2 | import { useInView } from 'react-intersection-observer';
3 | import {
4 | Card,
5 | CardDescription,
6 | CardFooter,
7 | CardHeader,
8 | CardTitle,
9 | } from '@/components/ui/card';
10 | import { AiOutlineDiscord } from 'react-icons/ai';
11 | import { GithubIcon, MailIcon } from 'lucide-react';
12 | import { TbBrandZulip } from 'react-icons/tb';
13 | import { url } from '@/components/utils/URLs';
14 |
15 | export interface ContactProps {
16 | icon: JSX.Element;
17 | name: string;
18 | position: string;
19 | url: string;
20 | }
21 |
22 | const contactList: ContactProps[] = [
23 | {
24 | icon: ,
25 | name: 'Zulip',
26 | position: 'Join our Zulip channel',
27 | url: url.zulipURL,
28 | },
29 | {
30 | icon: ,
31 | name: 'Github',
32 | position: 'Check out our Github repository',
33 | url: url.githubRepoURL,
34 | },
35 | {
36 | icon: ,
37 | name: 'Discord',
38 | position: 'Join us at Discord for discussions',
39 | url: '',
40 | },
41 | {
42 | icon: ,
43 | name: 'Email',
44 | position: 'Email us for any queries',
45 | url: '',
46 | },
47 | ];
48 |
49 | const cardVariants = {
50 | hidden: { opacity: 0, x: 50 },
51 | visible: { opacity: 1, x: 0, transition: { duration: 0.6 } },
52 | hover: { scale: 1.05, transition: { duration: 0.3 } },
53 | };
54 |
55 | export const Contact = () => {
56 | const { ref, inView } = useInView({
57 | triggerOnce: true,
58 | threshold: 0.1,
59 | });
60 |
61 | return (
62 |
105 | );
106 | };
107 |
--------------------------------------------------------------------------------
/frontend/src/components/LandingComponents/Contact/__tests__/Contact.test.tsx:
--------------------------------------------------------------------------------
1 | // Contact.test.tsx
2 | import { render, screen } from '@testing-library/react';
3 | import '@testing-library/jest-dom';
4 | import { Contact } from '../Contact';
5 | import { useInView } from 'react-intersection-observer';
6 |
7 | // Mock useInView hook
8 | jest.mock('react-intersection-observer', () => ({
9 | useInView: jest.fn(),
10 | }));
11 |
12 | describe('Contact Component', () => {
13 | beforeEach(() => {
14 | (useInView as jest.Mock).mockReturnValue({
15 | ref: jest.fn(),
16 | inView: true, // Set inView to true to simulate the element being in view
17 | });
18 | });
19 |
20 | test('renders the Contact section', () => {
21 | render( );
22 | const sectionElement = screen.getByTestId('#contact');
23 | expect(sectionElement).toBeInTheDocument();
24 | });
25 |
26 | test('renders all contact cards', () => {
27 | render( );
28 |
29 | const contactNames = ['Zulip', 'Github', 'Discord', 'Email'];
30 |
31 | contactNames.forEach((name) => {
32 | const contactCard = screen.getByText(name);
33 | expect(contactCard).toBeInTheDocument();
34 | });
35 | });
36 |
37 | test('renders the correct positions for contact cards', () => {
38 | render( );
39 |
40 | const contactPositions = [
41 | 'Join our Zulip channel',
42 | 'Check out our Github repository',
43 | 'Join us at Discord for discussions',
44 | 'Email us for any queries',
45 | ];
46 |
47 | contactPositions.forEach((position) => {
48 | const contactPosition = screen.getByText(position);
49 | expect(contactPosition).toBeInTheDocument();
50 | });
51 | });
52 | });
53 |
--------------------------------------------------------------------------------
/frontend/src/components/LandingComponents/FAQ/FAQ.tsx:
--------------------------------------------------------------------------------
1 | import { Accordion } from '@/components/ui/accordion';
2 | import { FAQItem } from './FAQItem';
3 | import { FAQList } from './faq-utils';
4 | import { BlueHeading } from '@/lib/utils';
5 | import { url } from '@/components/utils/URLs';
6 |
7 | export const FAQ = () => {
8 | return (
9 |
10 |
11 |
12 |
13 | {FAQList.map(({ question, answer, value }) => (
14 |
20 | ))}
21 |
22 |
23 |
24 | Still have questions?{' '}
25 |
30 | Contact us
31 |
32 |
33 |
34 | );
35 | };
36 |
--------------------------------------------------------------------------------
/frontend/src/components/LandingComponents/FAQ/FAQItem.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | AccordionItem,
3 | AccordionTrigger,
4 | AccordionContent,
5 | } from '@/components/ui/accordion';
6 |
7 | interface FAQProps {
8 | question: string;
9 | answer: string;
10 | value: string;
11 | }
12 |
13 | export const FAQItem = ({ question, answer, value }: FAQProps) => (
14 |
15 | {question}
16 | {answer}
17 |
18 | );
19 |
--------------------------------------------------------------------------------
/frontend/src/components/LandingComponents/FAQ/__tests__/FAQ.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, screen } from '@testing-library/react';
3 | import { FAQ } from '../FAQ';
4 | import { FAQList } from '../faq-utils';
5 |
6 | jest.mock('../faq-utils', () => ({
7 | FAQList: [
8 | {
9 | question: 'What is React?',
10 | answer: 'A JavaScript library for building user interfaces.',
11 | value: 'q1',
12 | },
13 | {
14 | question: 'What is TypeScript?',
15 | answer:
16 | 'A typed superset of JavaScript that compiles to plain JavaScript.',
17 | value: 'q2',
18 | },
19 | ],
20 | }));
21 |
22 | jest.mock('@/components/ui/accordion', () => ({
23 | Accordion: ({ children }: { children: React.ReactNode }) => (
24 | {children}
25 | ),
26 | }));
27 |
28 | jest.mock('../FAQItem', () => ({
29 | FAQItem: ({ question, answer }: { question: string; answer: string }) => (
30 |
31 |
{question}
32 |
{answer}
33 |
34 | ),
35 | }));
36 |
37 | describe('FAQ component', () => {
38 | test('renders without crashing', () => {
39 | render( );
40 | });
41 |
42 | test('renders the section title correctly', () => {
43 | render( );
44 | const titleElement = screen.getByText(/Frequently Asked/i);
45 | expect(titleElement).toBeInTheDocument();
46 | });
47 |
48 | test('renders the FAQ items correctly', () => {
49 | render( );
50 | FAQList.forEach(({ question }) => {
51 | const questionElement = screen.getByText(question);
52 | expect(questionElement).toBeInTheDocument();
53 | });
54 | });
55 |
56 | test('renders the contact link correctly', () => {
57 | render( );
58 | const contactLink = screen.getByText(/Contact us/i);
59 | expect(contactLink).toBeInTheDocument();
60 | });
61 | });
62 |
--------------------------------------------------------------------------------
/frontend/src/components/LandingComponents/FAQ/__tests__/FAQItem.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen, fireEvent } from '@testing-library/react';
2 | import { FAQItem } from '../FAQItem';
3 | import { Accordion } from '@/components/ui/accordion';
4 |
5 | describe('FAQItem', () => {
6 | const question = 'What is your return policy?';
7 | const answer = 'You can return any item within 30 days of purchase.';
8 | const value = 'faq-1';
9 |
10 | test('renders question and answer correctly', () => {
11 | render(
12 |
13 |
19 |
20 | );
21 |
22 | // check if the question is rendered
23 | expect(screen.getByText(question)).toBeInTheDocument();
24 |
25 | // clicking the trigger to expand the accordion
26 | fireEvent.click(screen.getByText(question));
27 |
28 | // check if the answer is visible after clicking
29 | expect(screen.getByText(answer)).toBeVisible();
30 | });
31 | });
32 |
--------------------------------------------------------------------------------
/frontend/src/components/LandingComponents/FAQ/__tests__/faq-utils.test.ts:
--------------------------------------------------------------------------------
1 | import { FAQList } from '../faq-utils';
2 |
3 | describe('FAQList', () => {
4 | test('should contain the correct number of FAQ items', () => {
5 | expect(FAQList).toHaveLength(4);
6 | });
7 |
8 | test('should contain the correct FAQ items', () => {
9 | expect(FAQList).toEqual([
10 | {
11 | question: 'What is CCSync?',
12 | answer:
13 | 'CCSync is a service that allows you to synchronize your tasks between your devices using Taskwarrior. It provides a hosted solution for taskchampion-sync-server, eliminating the need to set up and manage your own server.',
14 | value: 'item-1',
15 | },
16 | {
17 | question: 'What devices can I use with CCSync?',
18 | answer:
19 | 'CCSync works with any device that can run Taskwarrior, including desktops, laptops, smartphones, and tablets.',
20 | value: 'item-2',
21 | },
22 | {
23 | question: 'How do I initialize sync between my clients?',
24 | answer:
25 | 'The connection process is straightforward. Refer to the setup guide above for step-by-step instructions on configuring Taskwarrior to connect to our server.',
26 | value: 'item-3',
27 | },
28 | {
29 | question: 'Do you have access to my task content?',
30 | answer:
31 | 'The tasks are stored securely in your Browser based local database. It helps in making the tasks available on interfaces other than the PC, directly on the web!',
32 | value: 'item-4',
33 | },
34 | ]);
35 | });
36 |
37 | test('should have valid properties for each FAQ item', () => {
38 | FAQList.forEach((item) => {
39 | expect(item).toHaveProperty('question');
40 | expect(item).toHaveProperty('answer');
41 | expect(item).toHaveProperty('value');
42 | expect(typeof item.question).toBe('string');
43 | expect(typeof item.answer).toBe('string');
44 | expect(typeof item.value).toBe('string');
45 | });
46 | });
47 | });
48 |
--------------------------------------------------------------------------------
/frontend/src/components/LandingComponents/FAQ/faq-utils.ts:
--------------------------------------------------------------------------------
1 | export interface FAQProps {
2 | question: string;
3 | answer: string;
4 | value: string;
5 | }
6 |
7 | export const FAQList: FAQProps[] = [
8 | {
9 | question: 'What is CCSync?',
10 | answer:
11 | 'CCSync is a service that allows you to synchronize your tasks between your devices using Taskwarrior. It provides a hosted solution for taskchampion-sync-server, eliminating the need to set up and manage your own server.',
12 | value: 'item-1',
13 | },
14 | {
15 | question: 'What devices can I use with CCSync?',
16 | answer:
17 | 'CCSync works with any device that can run Taskwarrior, including desktops, laptops, smartphones, and tablets.',
18 | value: 'item-2',
19 | },
20 | {
21 | question: 'How do I initialize sync between my clients?',
22 | answer:
23 | 'The connection process is straightforward. Refer to the setup guide above for step-by-step instructions on configuring Taskwarrior to connect to our server.',
24 | value: 'item-3',
25 | },
26 | {
27 | question: 'Do you have access to my task content?',
28 | answer:
29 | 'The tasks are stored securely in your Browser based local database. It helps in making the tasks available on interfaces other than the PC, directly on the web!',
30 | value: 'item-4',
31 | },
32 | ];
33 |
--------------------------------------------------------------------------------
/frontend/src/components/LandingComponents/Footer/__tests__/Footer.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import '@testing-library/jest-dom';
3 | import { Footer } from '../Footer';
4 |
5 | // mock the imports
6 | jest.mock('../../../../assets/logo.png', () => 'logo-path');
7 | jest.mock('../../../../assets/logo_light.png', () => 'logo-light-path');
8 |
9 | describe('Footer component', () => {
10 | test('renders without crashing', () => {
11 | render();
12 | });
13 |
14 | test('renders the logo with correct alt text', () => {
15 | render();
16 | const logoElement = screen.getByAltText('Logo');
17 | expect(logoElement).toBeInTheDocument();
18 | });
19 |
20 | test('renders the light logo with correct alt text', () => {
21 | render();
22 | const logoElement = screen.getByAltText('Logo-light');
23 | expect(logoElement).toBeInTheDocument();
24 | });
25 | });
26 |
--------------------------------------------------------------------------------
/frontend/src/components/LandingComponents/Hero/Hero.tsx:
--------------------------------------------------------------------------------
1 | import { url } from '@/components/utils/URLs';
2 | import { Button } from '../../ui/button';
3 | import { buttonVariants } from '../../ui/button';
4 | import { HeroCards } from './HeroCards';
5 | import { GitHubLogoIcon } from '@radix-ui/react-icons';
6 |
7 | export const Hero = () => {
8 | return (
9 |
10 |
11 |
12 |
13 |
14 | CCSync
15 | {' '}
16 | - the hosted solution
17 | {' '}
18 | for syncing with all your{' '}
19 |
20 |
21 | Taskwarrior
22 | {' '}
23 | clients
24 |
25 |
26 |
27 |
28 | Effortlessly sync your tasks across all your TaskWarrior clients
29 |
30 |
31 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 | );
57 | };
58 |
--------------------------------------------------------------------------------
/frontend/src/components/LandingComponents/Hero/HeroCards.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent } from '@/components/ui/card';
2 |
3 | import { motion } from 'framer-motion';
4 |
5 | const popIn = {
6 | hidden: { scale: 0.9, opacity: 0 },
7 | visible: { scale: 1, opacity: 1, transition: { duration: 0.5 } },
8 | hover: { scale: 1.05, transition: { duration: 0.3 } },
9 | };
10 |
11 | export const HeroCards = () => {
12 | return (
13 |
14 |
21 |
22 |
23 | Keep your data safe with top-notch security features.
24 |
25 |
26 |
27 |
34 |
35 |
36 |
37 | Sign in to generate your keys in order to sync across all your
38 | Taskwarrior clients
39 |
40 |
41 |
42 |
43 |
50 |
51 |
52 | Hassle-free sync across all devices
53 |
54 |
55 |
56 |
63 |
64 |
65 | Have any issues or queries?
66 |
67 |
68 | Contact us
69 |
70 |
71 |
72 |
73 |
74 |
75 | );
76 | };
77 |
--------------------------------------------------------------------------------
/frontend/src/components/LandingComponents/Hero/__tests__/Hero.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, screen } from '@testing-library/react';
3 | import { Hero } from '../Hero';
4 | import { url } from '@/components/utils/URLs';
5 |
6 | // mocking the HeroCards component
7 | jest.mock('../HeroCards', () => ({
8 | HeroCards: () => HeroCards Component
,
9 | }));
10 |
11 | // mocking the buttonVariants function
12 | jest.mock('../../../ui/button', () => ({
13 | Button: ({
14 | children,
15 | className,
16 | }: {
17 | children: React.ReactNode;
18 | className?: string;
19 | }) => {children} ,
20 | buttonVariants: ({ variant }: { variant: string }) => `btn-${variant}`,
21 | }));
22 |
23 | describe('Hero Component', () => {
24 | test('renders Hero component with correct text and elements', () => {
25 | render( );
26 | // check for buttons and links
27 | const signInButton = screen.getByText(/Sign in to get started/i);
28 | expect(signInButton.closest('a')).toHaveAttribute(
29 | 'href',
30 | url.backendURL + 'auth/oauth'
31 | );
32 | expect(signInButton).toHaveClass(
33 | 'w-full md:w-1/3 bg-blue-400 hover:bg-blue-500'
34 | );
35 |
36 | const githubButton = screen.getByText(/Github Repository/i);
37 | expect(githubButton.closest('a')).toHaveAttribute(
38 | 'href',
39 | url.githubRepoURL
40 | );
41 | expect(githubButton.closest('a')).toHaveAttribute('target', '_blank');
42 | expect(githubButton).toHaveClass('w-full md:w-1/3 btn-outline');
43 | expect(screen.getByTestId('hero-cards')).toBeInTheDocument();
44 | });
45 |
46 | test('renders HeroCards component', () => {
47 | render( );
48 | expect(screen.getByTestId('hero-cards')).toBeInTheDocument();
49 | });
50 | });
51 |
--------------------------------------------------------------------------------
/frontend/src/components/LandingComponents/Hero/__tests__/HeroCards.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import { HeroCards } from '../HeroCards';
3 |
4 | describe('HeroCards Component', () => {
5 | it('renders all cards with the correct content', () => {
6 | render( );
7 |
8 | // test for cards
9 | expect(
10 | screen.getByText(/Keep your data safe with top-notch security features./i)
11 | ).toBeInTheDocument();
12 | expect(
13 | screen.getByText(
14 | /Sign in to generate your keys in order to sync across all your Taskwarrior clients/i
15 | )
16 | ).toBeInTheDocument();
17 | expect(
18 | screen.getByText(/Hassle-free sync across all devices/i)
19 | ).toBeInTheDocument();
20 | expect(
21 | screen.getByText(/Have any issues or queries?/i)
22 | ).toBeInTheDocument();
23 | expect(screen.getByRole('link', { name: /Contact us/i })).toHaveAttribute(
24 | 'href',
25 | '#contact'
26 | );
27 | });
28 | });
29 |
--------------------------------------------------------------------------------
/frontend/src/components/LandingComponents/HowItWorks/HowItWorks.tsx:
--------------------------------------------------------------------------------
1 | import { motion, useAnimation } from 'framer-motion';
2 | import { useInView } from 'react-intersection-observer';
3 | import { useEffect } from 'react';
4 | import { Card, CardContent, CardHeader, CardTitle } from '../../ui/card';
5 | import { MedalIcon, MapIcon, PlaneIcon, GiftIcon } from '../../utils/Icons';
6 |
7 | interface FeatureProps {
8 | icon: JSX.Element;
9 | title: string;
10 | description: string;
11 | }
12 |
13 | const features: FeatureProps[] = [
14 | {
15 | icon: ,
16 | title: 'Sign in',
17 | description:
18 | 'Sign in with Google to generate secret UUIDs, or generate your own using a random key generator',
19 | },
20 | {
21 | icon: ,
22 | title: 'Setup',
23 | description:
24 | 'Setup the taskserver for your Taskwarrior clients by following the documentation',
25 | },
26 | {
27 | icon: ,
28 | title: 'Share',
29 | description:
30 | 'Sign in on multiple devices and use the same UUIDs to sync tasks across all the clients or your team',
31 | },
32 | {
33 | icon: ,
34 | title: 'Deploy your own',
35 | description:
36 | 'You can also deploy your own server instance by following this documentation',
37 | },
38 | ];
39 |
40 | const cardVariants = {
41 | hidden: { opacity: 0, x: -50 },
42 | visible: { opacity: 1, x: 0, transition: { duration: 0.6 } },
43 | hover: { scale: 1.05, transition: { duration: 0.3 } },
44 | };
45 |
46 | export const HowItWorks = () => {
47 | const controls = useAnimation();
48 | const { ref, inView } = useInView();
49 |
50 | useEffect(() => {
51 | if (inView) {
52 | controls.start('visible');
53 | }
54 | }, [controls, inView]);
55 |
56 | return (
57 |
62 |
63 | How It{' '}
64 |
65 | Works{' '}
66 |
67 |
68 |
69 |
70 |
74 | {features.map(({ icon, title, description }: FeatureProps) => (
75 |
83 |
84 |
85 |
86 | {icon}
87 | {title}
88 |
89 |
90 | {description}
91 |
92 |
93 | ))}
94 |
95 |
96 | );
97 | };
98 |
--------------------------------------------------------------------------------
/frontend/src/components/LandingComponents/HowItWorks/__tests__/HowItWorks.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import '@testing-library/jest-dom';
3 | import { HowItWorks } from '../HowItWorks';
4 | import { useInView } from 'react-intersection-observer';
5 |
6 | // Mock useInView hook
7 | jest.mock('react-intersection-observer', () => ({
8 | useInView: jest.fn(),
9 | }));
10 |
11 | describe('HowItWorks Component', () => {
12 | beforeEach(() => {
13 | (useInView as jest.Mock).mockReturnValue({
14 | ref: jest.fn(),
15 | inView: true, // Set inView to true to simulate the element being in view
16 | });
17 | });
18 |
19 | test('renders the How It Works section', () => {
20 | render( );
21 |
22 | const sectionElement = screen.getByTestId('#howItWorks');
23 | expect(sectionElement).toBeInTheDocument();
24 | });
25 |
26 | test('renders all feature cards', () => {
27 | render( );
28 |
29 | const featureTitles = ['Sign in', 'Setup', 'Share', 'Deploy your own'];
30 |
31 | featureTitles.forEach((title) => {
32 | const featureCard = screen.getByText(title);
33 | expect(featureCard).toBeInTheDocument();
34 | });
35 | });
36 |
37 | test('renders the correct descriptions for feature cards', () => {
38 | render( );
39 |
40 | const featureDescriptions = [
41 | 'Sign in with Google to generate secret UUIDs, or generate your own using a random key generator',
42 | 'Setup the taskserver for your Taskwarrior clients by following the documentation',
43 | 'Sign in on multiple devices and use the same UUIDs to sync tasks across all the clients or your team',
44 | 'You can also deploy your own server instance by following this documentation',
45 | ];
46 |
47 | featureDescriptions.forEach((description) => {
48 | const featureDescription = screen.getByText(description);
49 | expect(featureDescription).toBeInTheDocument();
50 | });
51 | });
52 | });
53 |
--------------------------------------------------------------------------------
/frontend/src/components/LandingComponents/Navbar/Navbar.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | NavigationMenu,
3 | NavigationMenuItem,
4 | NavigationMenuList,
5 | } from '@/components/ui/navigation-menu';
6 | import logo from '../../../assets/logo.png';
7 | import logoLight from '../../../assets/logo_light.png';
8 | import { NavbarMobile } from './NavbarMobile';
9 | import { NavbarDesktop } from './NavbarDesktop';
10 |
11 | export const Navbar = () => {
12 | return (
13 |
45 | );
46 | };
47 |
--------------------------------------------------------------------------------
/frontend/src/components/LandingComponents/Navbar/NavbarDesktop.tsx:
--------------------------------------------------------------------------------
1 | import { Button, buttonVariants } from '../../ui/button';
2 | import { ModeToggle } from '../../utils/theme-mode-toggle';
3 | import { routeList } from './navbar-utils';
4 | import { url } from '@/components/utils/URLs';
5 |
6 | export const NavbarDesktop = () => {
7 | return (
8 | <>
9 |
10 | {routeList.map(({ href, label }, i) => (
11 |
17 | {label}
18 |
19 | ))}
20 |
21 |
22 |
23 |
24 |
25 |
26 | Docs
27 |
28 |
29 | >
30 | );
31 | };
32 |
--------------------------------------------------------------------------------
/frontend/src/components/LandingComponents/Navbar/NavbarMobile.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import {
3 | Sheet,
4 | SheetContent,
5 | SheetHeader,
6 | SheetTitle,
7 | SheetTrigger,
8 | } from '@/components/ui/sheet';
9 | import { Button, buttonVariants } from '../../ui/button';
10 | import { Menu } from 'lucide-react';
11 | import { ModeToggle } from '../../utils/theme-mode-toggle';
12 | import { routeList } from './navbar-utils';
13 | import { url } from '@/components/utils/URLs';
14 |
15 | export const NavbarMobile = () => {
16 | const [isOpen, setIsOpen] = useState(false);
17 |
18 | return (
19 |
20 |
21 |
22 |
23 |
24 | setIsOpen(true)}
27 | >
28 | Menu Icon
29 |
30 |
31 |
32 |
33 |
34 | CCSync
35 |
36 |
37 | {routeList.map(({ href, label }) => (
38 | setIsOpen(false)}
43 | className={buttonVariants({ variant: 'ghost' })}
44 | >
45 | {label}
46 |
47 | ))}
48 |
49 | Docs
50 |
51 |
52 |
53 |
54 |
55 | );
56 | };
57 |
--------------------------------------------------------------------------------
/frontend/src/components/LandingComponents/Navbar/__tests__/Navbar.test.tsx:
--------------------------------------------------------------------------------
1 | import { render } from '@testing-library/react';
2 | import { Navbar } from '../Navbar';
3 |
4 | jest.mock('../../../../assets/logo.png', () => 'mocked-logo.png');
5 | jest.mock('../../../../assets/logo_light.png', () => 'mocked-logo-light.png');
6 |
7 | describe('Navbar component', () => {
8 | it('renders navbar with desktop and mobile navigation', () => {
9 | const { getByAltText, getByText } = render( );
10 |
11 | const logoLight = getByAltText('Logo-Light');
12 | expect(logoLight).toBeInTheDocument();
13 |
14 | const logoRegular = getByAltText('Logo');
15 | expect(logoRegular).toBeInTheDocument();
16 |
17 | const homeLink = getByText('Home');
18 | expect(homeLink).toBeInTheDocument();
19 |
20 | const aboutLink = getByText('About');
21 | expect(aboutLink).toBeInTheDocument();
22 |
23 | const howItWorksLink = getByText('How it works');
24 | expect(howItWorksLink).toBeInTheDocument();
25 |
26 | const contactUsLink = getByText('Contact Us');
27 | expect(contactUsLink).toBeInTheDocument();
28 |
29 | const faqLink = getByText('FAQ');
30 | expect(faqLink).toBeInTheDocument();
31 | });
32 | });
33 |
--------------------------------------------------------------------------------
/frontend/src/components/LandingComponents/Navbar/__tests__/NavbarDesktop.test.tsx:
--------------------------------------------------------------------------------
1 | import { render } from '@testing-library/react';
2 | import { NavbarDesktop } from '../NavbarDesktop';
3 |
4 | // Mock external dependencies
5 | jest.mock('../navbar-utils', () => ({
6 | deleteAllTasks: jest.fn(),
7 | handleLogout: jest.fn(),
8 | routeList: [{ href: '#', label: 'Home' }],
9 | }));
10 |
11 | describe('NavbarDesktop', () => {
12 | afterEach(() => {
13 | jest.clearAllMocks();
14 | });
15 |
16 | it('renders correctly', () => {
17 | render( );
18 | });
19 | });
20 |
--------------------------------------------------------------------------------
/frontend/src/components/LandingComponents/Navbar/__tests__/navbar-utils.test.ts:
--------------------------------------------------------------------------------
1 | import { RouteProps, routeList } from '../navbar-utils';
2 |
3 | describe('routeList', () => {
4 | it('should be an array of RouteProps', () => {
5 | routeList.forEach((route) => {
6 | expect(route).toHaveProperty('href');
7 | expect(route).toHaveProperty('label');
8 |
9 | expect(typeof route.href).toBe('string');
10 | expect(typeof route.label).toBe('string');
11 | });
12 | });
13 |
14 | it('should contain the correct routes', () => {
15 | const expectedRoutes: RouteProps[] = [
16 | { href: '#', label: 'Home' },
17 | { href: '#about', label: 'About' },
18 | { href: '#howItWorks', label: 'How it works' },
19 | { href: '#contact', label: 'Contact Us' },
20 | { href: '#faq', label: 'FAQ' },
21 | ];
22 |
23 | expect(routeList).toEqual(expectedRoutes);
24 | });
25 | });
26 |
--------------------------------------------------------------------------------
/frontend/src/components/LandingComponents/Navbar/navbar-utils.ts:
--------------------------------------------------------------------------------
1 | export interface RouteProps {
2 | href: string;
3 | label: string;
4 | }
5 |
6 | export const routeList: RouteProps[] = [
7 | {
8 | href: '#',
9 | label: 'Home',
10 | },
11 | {
12 | href: '#about',
13 | label: 'About',
14 | },
15 | {
16 | href: '#howItWorks',
17 | label: 'How it works',
18 | },
19 | {
20 | href: '#contact',
21 | label: 'Contact Us',
22 | },
23 | {
24 | href: '#faq',
25 | label: 'FAQ',
26 | },
27 | ];
28 |
--------------------------------------------------------------------------------
/frontend/src/components/LandingPage.tsx:
--------------------------------------------------------------------------------
1 | import { About } from './LandingComponents/About/About';
2 | import { FAQ } from './LandingComponents/FAQ/FAQ';
3 | import { Footer } from './LandingComponents/Footer/Footer';
4 | import { Hero } from './LandingComponents/Hero/Hero';
5 | import { HowItWorks } from './LandingComponents/HowItWorks/HowItWorks';
6 | import { Navbar } from './LandingComponents/Navbar/Navbar';
7 | import { ScrollToTop } from '../components/utils/ScrollToTop';
8 | import { Contact } from './LandingComponents/Contact/Contact';
9 | import '../App.css';
10 |
11 | export const LandingPage = () => {
12 | return (
13 | <>
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | >
23 | );
24 | };
25 |
--------------------------------------------------------------------------------
/frontend/src/components/__tests__/HomePage.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen, waitFor } from '@testing-library/react';
2 | import { HomePage } from '../HomePage';
3 |
4 | // Mock dependencies
5 | jest.mock('../HomeComponents/Navbar/Navbar', () => ({
6 | Navbar: () => Mocked Navbar
,
7 | }));
8 | jest.mock('../HomeComponents/Hero/Hero', () => ({
9 | Hero: () => Mocked Hero
,
10 | }));
11 | jest.mock('../HomeComponents/Footer/Footer', () => ({
12 | Footer: () => Mocked Footer
,
13 | }));
14 | jest.mock('../HomeComponents/SetupGuide/SetupGuide', () => ({
15 | SetupGuide: () => Mocked SetupGuide
,
16 | }));
17 | jest.mock('../HomeComponents/FAQ/FAQ', () => ({
18 | FAQ: () => Mocked FAQ
,
19 | }));
20 | jest.mock('../HomeComponents/Tasks/Tasks', () => ({
21 | Tasks: () => Mocked Tasks
,
22 | }));
23 |
24 | const mockedNavigate = jest.fn();
25 | jest.mock('react-router', () => ({
26 | useNavigate: () => mockedNavigate,
27 | }));
28 | jest.mock('@/components/utils/URLs', () => ({
29 | url: {
30 | backendURL: 'http://mocked-backend-url/',
31 | containerOrigin: 'http://mocked-origin/',
32 | frontendURL: 'http://mocked-frontend-url/',
33 | },
34 | }));
35 |
36 | // Mock fetch
37 | global.fetch = jest.fn(() =>
38 | Promise.resolve({
39 | ok: true,
40 | json: () =>
41 | Promise.resolve({
42 | picture: 'mocked-picture-url',
43 | email: 'mocked-email',
44 | encryption_secret: 'mocked-encryption-secret',
45 | uuid: 'mocked-uuid',
46 | name: 'mocked-name',
47 | }),
48 | })
49 | ) as jest.Mock;
50 |
51 | describe('HomePage', () => {
52 | afterEach(() => {
53 | jest.clearAllMocks();
54 | });
55 |
56 | it('renders correctly when user info is fetched successfully', async () => {
57 | render( );
58 |
59 | await waitFor(() => {
60 | expect(screen.getByText('Mocked Navbar')).toBeInTheDocument();
61 | expect(screen.getByText('Mocked Hero')).toBeInTheDocument();
62 | expect(screen.getByText('Mocked Tasks')).toBeInTheDocument();
63 | expect(screen.getByText('Mocked SetupGuide')).toBeInTheDocument();
64 | expect(screen.getByText('Mocked FAQ')).toBeInTheDocument();
65 | expect(screen.getByText('Mocked Footer')).toBeInTheDocument();
66 | });
67 | });
68 |
69 | it('renders session expired message when user info fetch fails', async () => {
70 | (fetch as jest.Mock).mockImplementationOnce(() =>
71 | Promise.resolve({
72 | ok: false,
73 | })
74 | );
75 |
76 | render( );
77 |
78 | await waitFor(() => {
79 | expect(screen.getByText('Session has been expired.')).toBeInTheDocument();
80 | });
81 | });
82 |
83 | it('navigates to home page on fetch error', async () => {
84 | (fetch as jest.Mock).mockImplementationOnce(() =>
85 | Promise.reject('Fetch error')
86 | );
87 |
88 | render( );
89 |
90 | await waitFor(() => {
91 | expect(mockedNavigate).toHaveBeenCalledWith('/');
92 | });
93 | });
94 | });
95 |
--------------------------------------------------------------------------------
/frontend/src/components/__tests__/LandingPage.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import { LandingPage } from '../LandingPage';
3 |
4 | // Mock dependencies
5 | jest.mock('../LandingComponents/Navbar/Navbar', () => ({
6 | Navbar: () => Mocked Navbar
,
7 | }));
8 | jest.mock('../LandingComponents/Hero/Hero', () => ({
9 | Hero: () => Mocked Hero
,
10 | }));
11 | jest.mock('../LandingComponents/About/About', () => ({
12 | About: () => Mocked About
,
13 | }));
14 | jest.mock('../LandingComponents/HowItWorks/HowItWorks', () => ({
15 | HowItWorks: () => Mocked HowItWorks
,
16 | }));
17 | jest.mock('../LandingComponents/Contact/Contact', () => ({
18 | Contact: () => Mocked Contact
,
19 | }));
20 | jest.mock('../LandingComponents/FAQ/FAQ', () => ({
21 | FAQ: () => Mocked FAQ
,
22 | }));
23 | jest.mock('../LandingComponents/Footer/Footer', () => ({
24 | Footer: () => Mocked Footer
,
25 | }));
26 | jest.mock('../../components/utils/ScrollToTop', () => ({
27 | ScrollToTop: () => Mocked ScrollToTop
,
28 | }));
29 |
30 | describe('LandingPage', () => {
31 | it('renders all components correctly', () => {
32 | render( );
33 |
34 | expect(screen.getByText('Mocked Navbar')).toBeInTheDocument();
35 | expect(screen.getByText('Mocked Hero')).toBeInTheDocument();
36 | expect(screen.getByText('Mocked About')).toBeInTheDocument();
37 | expect(screen.getByText('Mocked HowItWorks')).toBeInTheDocument();
38 | expect(screen.getByText('Mocked Contact')).toBeInTheDocument();
39 | expect(screen.getByText('Mocked FAQ')).toBeInTheDocument();
40 | expect(screen.getByText('Mocked Footer')).toBeInTheDocument();
41 | expect(screen.getByText('Mocked ScrollToTop')).toBeInTheDocument();
42 | });
43 | });
44 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/accordion.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as AccordionPrimitive from '@radix-ui/react-accordion';
3 | import { ChevronDown } from 'lucide-react';
4 |
5 | import { cn } from '@/components/utils/utils';
6 |
7 | const Accordion = AccordionPrimitive.Root;
8 |
9 | const AccordionItem = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => (
13 |
18 | ));
19 | AccordionItem.displayName = 'AccordionItem';
20 |
21 | const AccordionTrigger = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef
24 | >(({ className, children, ...props }, ref) => (
25 |
26 | svg]:rotate-180',
30 | className
31 | )}
32 | {...props}
33 | >
34 | {children}
35 |
36 |
37 |
38 | ));
39 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
40 |
41 | const AccordionContent = React.forwardRef<
42 | React.ElementRef,
43 | React.ComponentPropsWithoutRef
44 | >(({ className, children, ...props }, ref) => (
45 |
50 |
53 | {children}
54 |
55 |
56 | ));
57 |
58 | AccordionContent.displayName = AccordionPrimitive.Content.displayName;
59 |
60 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
61 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as AvatarPrimitive from '@radix-ui/react-avatar';
3 |
4 | import { cn } from '@/components/utils/utils';
5 |
6 | const Avatar = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
18 | ));
19 | Avatar.displayName = AvatarPrimitive.Root.displayName;
20 |
21 | const AvatarImage = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef
24 | >(({ className, ...props }, ref) => (
25 |
30 | ));
31 | AvatarImage.displayName = AvatarPrimitive.Image.displayName;
32 |
33 | const AvatarFallback = React.forwardRef<
34 | React.ElementRef,
35 | React.ComponentPropsWithoutRef
36 | >(({ className, ...props }, ref) => (
37 |
45 | ));
46 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
47 |
48 | export { Avatar, AvatarImage, AvatarFallback };
49 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { cva, type VariantProps } from 'class-variance-authority';
3 |
4 | import { cn } from '@/components/utils/utils';
5 |
6 | const badgeVariants = cva(
7 | 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
13 | secondary:
14 | 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
15 | destructive:
16 | 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
17 | outline: 'text-foreground',
18 | },
19 | },
20 | defaultVariants: {
21 | variant: 'default',
22 | },
23 | }
24 | );
25 |
26 | export interface BadgeProps
27 | extends React.HTMLAttributes,
28 | VariantProps {}
29 |
30 | function Badge({ className, variant, ...props }: BadgeProps) {
31 | return (
32 |
33 | );
34 | }
35 |
36 | export { Badge, badgeVariants };
37 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Slot } from '@radix-ui/react-slot';
3 | import { cva, type VariantProps } from 'class-variance-authority';
4 |
5 | import { cn } from '@/components/utils/utils';
6 |
7 | const buttonVariants = cva(
8 | 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
9 | {
10 | variants: {
11 | variant: {
12 | default: 'bg-primary text-primary-foreground hover:bg-primary/90',
13 | destructive:
14 | 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
15 | outline:
16 | 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
17 | secondary:
18 | 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
19 | ghost: 'hover:bg-accent hover:text-accent-foreground',
20 | link: 'text-primary underline-offset-4 hover:underline',
21 | },
22 | size: {
23 | default: 'h-10 px-4 py-2',
24 | sm: 'h-9 rounded-md px-3',
25 | lg: 'h-11 rounded-md px-8',
26 | icon: 'h-10 w-10',
27 | },
28 | },
29 | defaultVariants: {
30 | variant: 'default',
31 | size: 'default',
32 | },
33 | }
34 | );
35 |
36 | export interface ButtonProps
37 | extends React.ButtonHTMLAttributes,
38 | VariantProps {
39 | asChild?: boolean;
40 | }
41 |
42 | const Button = React.forwardRef(
43 | ({ className, variant, size, asChild = false, ...props }, ref) => {
44 | const Comp = asChild ? Slot : 'button';
45 | return (
46 |
51 | );
52 | }
53 | );
54 | Button.displayName = 'Button';
55 |
56 | export { Button, buttonVariants };
57 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { cn } from '@/components/utils/utils';
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ));
18 | Card.displayName = 'Card';
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ));
30 | CardHeader.displayName = 'CardHeader';
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLParagraphElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
44 | ));
45 | CardTitle.displayName = 'CardTitle';
46 |
47 | const CardDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ));
57 | CardDescription.displayName = 'CardDescription';
58 |
59 | const CardContent = React.forwardRef<
60 | HTMLDivElement,
61 | React.HTMLAttributes
62 | >(({ className, ...props }, ref) => (
63 |
64 | ));
65 | CardContent.displayName = 'CardContent';
66 |
67 | const CardFooter = React.forwardRef<
68 | HTMLDivElement,
69 | React.HTMLAttributes
70 | >(({ className, ...props }, ref) => (
71 |
76 | ));
77 | CardFooter.displayName = 'CardFooter';
78 |
79 | export {
80 | Card,
81 | CardHeader,
82 | CardFooter,
83 | CardTitle,
84 | CardDescription,
85 | CardContent,
86 | };
87 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { cn } from '@/components/utils/utils';
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | );
21 | }
22 | );
23 | Input.displayName = 'Input';
24 |
25 | export { Input };
26 |
--------------------------------------------------------------------------------
/frontend/src/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 '@/components/utils/utils';
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 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/table.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { cn } from '@/components/utils/utils';
4 |
5 | const Table = React.forwardRef<
6 | HTMLTableElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
16 | ));
17 | Table.displayName = 'Table';
18 |
19 | const TableHeader = React.forwardRef<
20 | HTMLTableSectionElement,
21 | React.HTMLAttributes
22 | >(({ className, ...props }, ref) => (
23 |
24 | ));
25 | TableHeader.displayName = 'TableHeader';
26 |
27 | const TableBody = React.forwardRef<
28 | HTMLTableSectionElement,
29 | React.HTMLAttributes
30 | >(({ className, ...props }, ref) => (
31 |
36 | ));
37 | TableBody.displayName = 'TableBody';
38 |
39 | const TableFooter = React.forwardRef<
40 | HTMLTableSectionElement,
41 | React.HTMLAttributes
42 | >(({ className, ...props }, ref) => (
43 | tr]:last:border-b-0',
47 | className
48 | )}
49 | {...props}
50 | />
51 | ));
52 | TableFooter.displayName = 'TableFooter';
53 |
54 | const TableRow = React.forwardRef<
55 | HTMLTableRowElement,
56 | React.HTMLAttributes
57 | >(({ className, ...props }, ref) => (
58 |
66 | ));
67 | TableRow.displayName = 'TableRow';
68 |
69 | const TableHead = React.forwardRef<
70 | HTMLTableCellElement,
71 | React.ThHTMLAttributes
72 | >(({ className, ...props }, ref) => (
73 |
81 | ));
82 | TableHead.displayName = 'TableHead';
83 |
84 | const TableCell = React.forwardRef<
85 | HTMLTableCellElement,
86 | React.TdHTMLAttributes
87 | >(({ className, ...props }, ref) => (
88 |
93 | ));
94 | TableCell.displayName = 'TableCell';
95 |
96 | const TableCaption = React.forwardRef<
97 | HTMLTableCaptionElement,
98 | React.HTMLAttributes
99 | >(({ className, ...props }, ref) => (
100 |
105 | ));
106 | TableCaption.displayName = 'TableCaption';
107 |
108 | export {
109 | Table,
110 | TableHeader,
111 | TableBody,
112 | TableFooter,
113 | TableHead,
114 | TableRow,
115 | TableCell,
116 | TableCaption,
117 | };
118 |
--------------------------------------------------------------------------------
/frontend/src/components/utils/ScrollToTop.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { Button } from '../ui/button';
3 | import { ArrowUpToLine } from 'lucide-react';
4 |
5 | export const ScrollToTop = () => {
6 | const [showTopBtn, setShowTopBtn] = useState(false);
7 |
8 | useEffect(() => {
9 | window.addEventListener('scroll', () => {
10 | if (window.scrollY > 400) {
11 | setShowTopBtn(true);
12 | } else {
13 | setShowTopBtn(false);
14 | }
15 | });
16 | }, []);
17 |
18 | const goToTop = () => {
19 | window.scroll({
20 | top: 0,
21 | left: 0,
22 | });
23 | };
24 |
25 | return (
26 | <>
27 | {showTopBtn && (
28 |
33 |
34 |
35 | )}
36 | >
37 | );
38 | };
39 |
--------------------------------------------------------------------------------
/frontend/src/components/utils/URLs.ts:
--------------------------------------------------------------------------------
1 | const isTesting = false;
2 |
3 | export const url = isTesting
4 | ? {
5 | backendURL: '',
6 | frontendURL: '',
7 | containerOrigin: '',
8 | githubRepoURL: '',
9 | githubDocsURL: '',
10 | zulipURL: '',
11 | taskwarriorURL: '',
12 | taskchampionSyncServerURL: '',
13 | }
14 | : {
15 | backendURL: import.meta.env.VITE_BACKEND_URL,
16 | frontendURL: import.meta.env.VITE_FRONTEND_URL,
17 | containerOrigin: import.meta.env.VITE_CONTAINER_ORIGIN,
18 | githubRepoURL: 'https://github.com/CCExtractor/ccsync',
19 | githubDocsURL: 'https://its-me-abhishek.github.io/ccsync-docs/',
20 | zulipURL: 'https://ccextractor.org/public/general/support/',
21 | taskwarriorURL: 'https://taskwarrior.org/docs/',
22 | taskchampionSyncServerURL:
23 | 'https://github.com/GothenburgBitFactory/taskchampion-sync-server/tree/main',
24 | };
25 |
--------------------------------------------------------------------------------
/frontend/src/components/utils/__tests__/ScrollToTop.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, fireEvent, screen } from '@testing-library/react';
2 | import { ScrollToTop } from '../ScrollToTop';
3 |
4 | // Mock the window.scroll function
5 | global.scroll = jest.fn();
6 |
7 | describe('ScrollToTop Component', () => {
8 | it('does not show the button initially', () => {
9 | render( );
10 | expect(screen.queryByRole('button')).not.toBeInTheDocument();
11 | });
12 |
13 | it('shows the button after scrolling down more than 400 pixels', () => {
14 | render( );
15 |
16 | // Simulate scrolling down
17 | fireEvent.scroll(window, { target: { scrollY: 500 } });
18 |
19 | expect(screen.getByRole('button')).toBeInTheDocument();
20 | });
21 |
22 | it('hides the button when scrolled back up above 400 pixels', () => {
23 | render( );
24 |
25 | // Simulate scrolling down
26 | fireEvent.scroll(window, { target: { scrollY: 500 } });
27 | expect(screen.getByRole('button')).toBeInTheDocument();
28 |
29 | // Simulate scrolling back up
30 | fireEvent.scroll(window, { target: { scrollY: 300 } });
31 | expect(screen.queryByRole('button')).not.toBeInTheDocument();
32 | });
33 |
34 | it('scrolls to the top when button is clicked', () => {
35 | render( );
36 |
37 | // Simulate scrolling down
38 | fireEvent.scroll(window, { target: { scrollY: 500 } });
39 | expect(screen.getByRole('button')).toBeInTheDocument();
40 |
41 | // Click the button
42 | fireEvent.click(screen.getByRole('button'));
43 |
44 | // Check if the scroll function was called with the correct parameters
45 | expect(global.scroll).toHaveBeenCalledWith({
46 | top: 0,
47 | left: 0,
48 | });
49 | });
50 | });
51 |
--------------------------------------------------------------------------------
/frontend/src/components/utils/__tests__/theme-mode-toggle.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import { ModeToggle } from '../theme-mode-toggle';
3 | import { useTheme } from '@/components/utils/theme-provider';
4 |
5 | // Mocking the useTheme hook
6 | jest.mock('@/components/utils/theme-provider');
7 |
8 | describe('ModeToggle', () => {
9 | const setThemeMock = jest.fn();
10 |
11 | beforeEach(() => {
12 | setThemeMock.mockClear();
13 | (useTheme as jest.Mock).mockReturnValue({ setTheme: setThemeMock });
14 | });
15 |
16 | test('renders without crashing', () => {
17 | render( );
18 | expect(
19 | screen.getByRole('button', { name: /toggle theme/i })
20 | ).toBeInTheDocument();
21 | });
22 | });
23 |
--------------------------------------------------------------------------------
/frontend/src/components/utils/__tests__/theme-provider.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import { act } from 'react-dom/test-utils';
3 | import { ThemeProvider, useTheme } from '../theme-provider';
4 |
5 | describe('ThemeProvider', () => {
6 | let originalMatchMedia: ((query: string) => MediaQueryList) &
7 | ((query: string) => MediaQueryList);
8 |
9 | beforeAll(() => {
10 | originalMatchMedia = window.matchMedia;
11 | window.matchMedia = jest.fn().mockImplementation((query) => {
12 | return {
13 | matches: query.includes('dark'),
14 | media: query,
15 | onchange: null,
16 | addListener: jest.fn(),
17 | removeListener: jest.fn(),
18 | addEventListener: jest.fn(),
19 | removeEventListener: jest.fn(),
20 | dispatchEvent: jest.fn(),
21 | };
22 | });
23 | });
24 |
25 | afterAll(() => {
26 | window.matchMedia = originalMatchMedia;
27 | });
28 |
29 | beforeEach(() => {
30 | localStorage.clear();
31 | });
32 |
33 | const TestComponent = () => {
34 | const { theme, setTheme } = useTheme();
35 | return (
36 |
37 | {theme}
38 | setTheme('light')}>Set Light Theme
39 |
40 | );
41 | };
42 |
43 | test('should use default theme if no theme is stored in localStorage', () => {
44 | render(
45 |
46 |
47 |
48 | );
49 |
50 | expect(screen.getByTestId('theme')).toHaveTextContent('dark');
51 | });
52 |
53 | test('should use stored theme from localStorage', () => {
54 | localStorage.setItem('vite-ui-theme', 'light');
55 |
56 | render(
57 |
58 |
59 |
60 | );
61 |
62 | expect(screen.getByTestId('theme')).toHaveTextContent('light');
63 | });
64 |
65 | test('should update theme and localStorage when setTheme is called', () => {
66 | render(
67 |
68 |
69 |
70 | );
71 |
72 | act(() => {
73 | screen.getByText('Set Light Theme').click();
74 | });
75 |
76 | expect(screen.getByTestId('theme')).toHaveTextContent('light');
77 | expect(localStorage.getItem('vite-ui-theme')).toBe('light');
78 | });
79 |
80 | test('useTheme hook should throw error when used outside ThemeProvider', () => {
81 | const consoleError = jest
82 | .spyOn(console, 'error')
83 | .mockImplementation(() => {});
84 | const TestErrorComponent = () => {
85 | try {
86 | useTheme();
87 | } catch (error) {
88 | return Error ;
89 | }
90 | return null;
91 | };
92 |
93 | render( );
94 | consoleError.mockRestore();
95 | });
96 |
97 | test('should apply system theme if theme is set to system', () => {
98 | localStorage.setItem('vite-ui-theme', 'system');
99 |
100 | render(
101 |
102 |
103 |
104 | );
105 |
106 | const expectedTheme = window.matchMedia('(prefers-color-scheme: dark)')
107 | .matches
108 | ? 'dark'
109 | : 'light';
110 |
111 | expect(document.documentElement.classList.contains(expectedTheme)).toBe(
112 | true
113 | );
114 | });
115 | });
116 |
--------------------------------------------------------------------------------
/frontend/src/components/utils/__tests__/types.test.ts:
--------------------------------------------------------------------------------
1 | import { User, Props, Task } from '../types';
2 |
3 | describe('User interface', () => {
4 | it('should accept valid User object', () => {
5 | const user: User = {
6 | name: 'John Doe',
7 | email: 'john.doe@example.com',
8 | picture: 'https://example.com/avatar.jpg',
9 | };
10 |
11 | expect(user.name).toBe('John Doe');
12 | expect(user.email).toBe('john.doe@example.com');
13 | expect(user.picture).toBe('https://example.com/avatar.jpg');
14 | });
15 |
16 | it('should throw error if required fields are missing', () => {
17 | // Uncommenting the following line should result in a compilation error:
18 | // this is because other fields would be missing
19 | // const user: User = { name: 'John Doe' };
20 | });
21 | });
22 |
23 | describe('Props interface', () => {
24 | it('should accept valid Props object', () => {
25 | const props: Props = {
26 | name: 'ComponentName',
27 | uuid: '123e4567-e89b-12d3-a456-426614174000',
28 | encryption_secret: 'random-secret-key',
29 | };
30 |
31 | expect(props.name).toBe('ComponentName');
32 | expect(props.uuid).toBe('123e4567-e89b-12d3-a456-426614174000');
33 | expect(props.encryption_secret).toBe('random-secret-key');
34 | });
35 | });
36 |
37 | describe('Task interface', () => {
38 | it('should accept valid Task object', () => {
39 | const task: Task = {
40 | id: 1,
41 | description: 'Example task description',
42 | project: 'Project ABC',
43 | tags: ['tag1', 'tag2'],
44 | status: 'in progress',
45 | uuid: '123e4567-e89b-12d3-a456-426614174000',
46 | urgency: 1,
47 | priority: 'high',
48 | due: '2024-06-20',
49 | start: '2024-05-20',
50 | end: '2024-06-25',
51 | entry: '2024-06-18',
52 | wait: '2025-07-18',
53 | modified: '2024-06-19',
54 | depends: ['123e4567', '123e4567'],
55 | rtype: 'any',
56 | recur: 'none',
57 | email: 'test@example.com',
58 | };
59 |
60 | expect(task.id).toBe(1);
61 | expect(task.description).toBe('Example task description');
62 | expect(task.project).toBe('Project ABC');
63 | expect(task.tags).toEqual(['tag1', 'tag2']);
64 | expect(task.status).toBe('in progress');
65 | expect(task.uuid).toBe('123e4567-e89b-12d3-a456-426614174000');
66 | expect(task.urgency).toBe(1);
67 | expect(task.priority).toBe('high');
68 | expect(task.due).toBe('2024-06-20');
69 | expect(task.end).toBe('2024-06-25');
70 | expect(task.entry).toBe('2024-06-18');
71 | expect(task.modified).toBe('2024-06-19');
72 | expect(task.email).toBe('test@example.com');
73 | });
74 | });
75 |
--------------------------------------------------------------------------------
/frontend/src/components/utils/theme-mode-toggle.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@/components/ui/button';
2 | import {
3 | DropdownMenu,
4 | DropdownMenuContent,
5 | DropdownMenuItem,
6 | DropdownMenuTrigger,
7 | } from '@/components/ui/dropdown-menu';
8 | import { useTheme } from '@/components/utils/theme-provider';
9 | import { Moon, Sun } from 'lucide-react';
10 |
11 | export function ModeToggle() {
12 | const { setTheme } = useTheme();
13 |
14 | return (
15 |
16 |
17 |
18 |
22 |
26 | Toggle theme
27 |
28 |
29 |
30 | setTheme('light')}>
31 | Light
32 |
33 | setTheme('dark')}>
34 | Dark
35 |
36 | setTheme('system')}>
37 | System
38 |
39 |
40 |
41 | );
42 | }
43 |
--------------------------------------------------------------------------------
/frontend/src/components/utils/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, useContext, useEffect, useState } from 'react';
2 |
3 | type Theme = 'dark' | 'light' | 'system';
4 |
5 | type ThemeProviderProps = {
6 | children: React.ReactNode;
7 | defaultTheme?: Theme;
8 | storageKey?: string;
9 | };
10 |
11 | type ThemeProviderState = {
12 | theme: Theme;
13 | setTheme: (theme: Theme) => void;
14 | };
15 |
16 | const initialState: ThemeProviderState = {
17 | theme: 'system',
18 | setTheme: () => null,
19 | };
20 |
21 | const ThemeProviderContext = createContext(initialState);
22 |
23 | export function ThemeProvider({
24 | children,
25 | defaultTheme = 'dark',
26 | storageKey = 'vite-ui-theme',
27 | ...props
28 | }: ThemeProviderProps) {
29 | const [theme, setTheme] = useState(
30 | () => (localStorage.getItem(storageKey) as Theme) || defaultTheme
31 | );
32 |
33 | useEffect(() => {
34 | const root = window.document.documentElement;
35 |
36 | root.classList.remove('light', 'dark');
37 |
38 | if (theme === 'system') {
39 | const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
40 | .matches
41 | ? 'dark'
42 | : 'light';
43 |
44 | root.classList.add(systemTheme);
45 | return;
46 | }
47 |
48 | root.classList.add(theme);
49 | }, [theme]);
50 |
51 | const value = {
52 | theme,
53 | setTheme: (theme: Theme) => {
54 | localStorage.setItem(storageKey, theme);
55 | setTheme(theme);
56 | },
57 | };
58 |
59 | return (
60 |
61 | {children}
62 |
63 | );
64 | }
65 |
66 | export const useTheme = () => {
67 | const context = useContext(ThemeProviderContext);
68 |
69 | if (context === undefined)
70 | throw new Error('useTheme must be used within a ThemeProvider');
71 |
72 | return context;
73 | };
74 |
--------------------------------------------------------------------------------
/frontend/src/components/utils/types.ts:
--------------------------------------------------------------------------------
1 | export interface User {
2 | name: string;
3 | email: string;
4 | picture: string;
5 | }
6 |
7 | export interface Props {
8 | name: string;
9 | uuid: string;
10 | encryption_secret: string;
11 | }
12 |
13 | export interface CopyButtonProps {
14 | text: string;
15 | label: string;
16 | }
17 |
18 | export interface Task {
19 | id: number;
20 | description: string;
21 | project: string;
22 | tags: string[];
23 | status: string;
24 | uuid: string;
25 | urgency: number;
26 | priority: string;
27 | due: string;
28 | start: string;
29 | end: string;
30 | entry: string;
31 | wait: string;
32 | modified: string;
33 | depends: string[];
34 | rtype: string;
35 | recur: string;
36 | email: string;
37 | }
38 |
--------------------------------------------------------------------------------
/frontend/src/components/utils/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from 'clsx';
2 | import { twMerge } from 'tailwind-merge';
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/frontend/src/index.css:
--------------------------------------------------------------------------------
1 | html {
2 | scroll-behavior: smooth;
3 | }
4 |
5 | /* HeroCards background shadow */
6 | .shadow {
7 | position: absolute;
8 | background: hsla(330, 100%, 50%, 0%);
9 | border-radius: 24px;
10 | rotate: 35deg;
11 |
12 | width: 260px;
13 | top: 200px;
14 | height: 400px;
15 | filter: blur(150px);
16 | animation: shadow-slide infinite 4s linear alternate;
17 | }
18 |
19 | @keyframes shadow-slide {
20 | from {
21 | background: hsla(330, 100%, 50%, 20%); /* Pink shadow color */
22 | right: 460px;
23 | }
24 | to {
25 | background: hsla(240, 100%, 50%, 80%); /* Blue shadow color */
26 | right: 160px;
27 | }
28 | }
29 |
30 | @media (max-width: 1024px) {
31 | .shadow {
32 | top: 70px;
33 | }
34 |
35 | @keyframes shadow-slide {
36 | from {
37 | background: hsla(330, 100%, 50%, 20%); /* Pink shadow color */
38 | right: 460px;
39 | }
40 | to {
41 | background: hsla(240, 100%, 50%, 50%); /* Blue shadow color */
42 | right: 160px;
43 | }
44 | }
45 | }
46 |
47 | @media (max-width: 768px) {
48 | .shadow {
49 | top: 70px;
50 | width: 100px;
51 | height: 350px;
52 | filter: blur(60px);
53 | }
54 |
55 | @keyframes shadow-slide {
56 | from {
57 | background: hsla(330, 100%, 50%, 20%); /* Pink shadow color */
58 | right: 280px;
59 | }
60 | to {
61 | background: hsla(240, 100%, 50%, 30%); /* Blue shadow color */
62 | right: 100px;
63 | }
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/frontend/src/lib/utils.tsx:
--------------------------------------------------------------------------------
1 | export const BlueHeading = ({
2 | prefix,
3 | suffix,
4 | }: {
5 | prefix: any;
6 | suffix: any;
7 | }) => {
8 | return (
9 |
10 | {prefix}{' '}
11 |
12 | {suffix}
13 |
14 |
15 | );
16 | };
17 |
--------------------------------------------------------------------------------
/frontend/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom/client';
3 | import App from './App.tsx';
4 | import { ThemeProvider } from '@/components/utils/theme-provider.tsx';
5 | import './index.css';
6 |
7 | ReactDOM.createRoot(document.getElementById('root')!).render(
8 |
9 |
10 |
11 |
12 |
13 | );
14 |
--------------------------------------------------------------------------------
/frontend/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/frontend/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | darkMode: ['class'],
4 | content: [
5 | './pages/**/*.{ts,tsx}',
6 | './components/**/*.{ts,tsx}',
7 | './app/**/*.{ts,tsx}',
8 | './src/**/*.{ts,tsx}',
9 | ],
10 | theme: {
11 | container: {
12 | center: true,
13 | padding: '1.5rem',
14 | screens: {
15 | '2xl': '1400px',
16 | },
17 | },
18 | extend: {
19 | colors: {
20 | border: 'hsl(var(--border))',
21 | input: 'hsl(var(--input))',
22 | ring: 'hsl(var(--ring))',
23 | background: 'hsl(var(--background))',
24 | foreground: 'hsl(var(--foreground))',
25 | primary: {
26 | DEFAULT: 'hsl(var(--primary))',
27 | foreground: 'hsl(var(--primary-foreground))',
28 | },
29 | secondary: {
30 | DEFAULT: 'hsl(var(--secondary))',
31 | foreground: 'hsl(var(--secondary-foreground))',
32 | },
33 | destructive: {
34 | DEFAULT: 'hsl(var(--destructive))',
35 | foreground: 'hsl(var(--destructive-foreground))',
36 | },
37 | muted: {
38 | DEFAULT: 'hsl(var(--muted))',
39 | foreground: 'hsl(var(--muted-foreground))',
40 | },
41 | accent: {
42 | DEFAULT: 'hsl(var(--accent))',
43 | foreground: 'hsl(var(--accent-foreground))',
44 | },
45 | popover: {
46 | DEFAULT: 'hsl(var(--popover))',
47 | foreground: 'hsl(var(--popover-foreground))',
48 | },
49 | card: {
50 | DEFAULT: 'hsl(var(--card))',
51 | foreground: 'hsl(var(--card-foreground))',
52 | },
53 | },
54 | borderRadius: {
55 | lg: 'var(--radius)',
56 | md: 'calc(var(--radius) - 2px)',
57 | sm: 'calc(var(--radius) - 4px)',
58 | },
59 | keyframes: {
60 | 'accordion-down': {
61 | from: { height: 0 },
62 | to: { height: 'var(--radix-accordion-content-height)' },
63 | },
64 | 'accordion-up': {
65 | from: { height: 'var(--radix-accordion-content-height)' },
66 | to: { height: 0 },
67 | },
68 | },
69 | animation: {
70 | 'accordion-down': 'accordion-down 0.2s ease-out',
71 | 'accordion-up': 'accordion-up 0.2s ease-out',
72 | },
73 | },
74 | },
75 | plugins: [require('tailwindcss-animate')],
76 | };
77 |
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 | "esModuleInterop": true,
9 | "types": ["node", "jest", "@testing-library/jest-dom"],
10 |
11 | /* Bundler mode */
12 | "moduleResolution": "bundler",
13 | "allowImportingTsExtensions": true,
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react-jsx",
18 |
19 | /* Linting */
20 | "strict": true,
21 | "noUnusedLocals": true,
22 | "noUnusedParameters": true,
23 | "noFallthroughCasesInSwitch": true,
24 |
25 | "baseUrl": ".",
26 | "paths": {
27 | "@/*": ["./src/*"]
28 | }
29 | },
30 | "include": ["src"],
31 | "references": [{ "path": "./tsconfig.node.json" }]
32 | }
33 |
--------------------------------------------------------------------------------
/frontend/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true
8 | },
9 | "include": ["vite.config.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/frontend/vite.config.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import react from '@vitejs/plugin-react';
3 | import { defineConfig } from 'vite';
4 |
5 | export default defineConfig({
6 | plugins: [react()],
7 | resolve: {
8 | alias: {
9 | '@': path.resolve(__dirname, './src'),
10 | },
11 | },
12 | });
13 |
--------------------------------------------------------------------------------