Compare commits

..
Author SHA1 Message Date
rmcampos 1381e08273 chore: fix frontend setup buildx step and drop unused files 2026-08-07 16:02:30 +02:00
rmcampos a96a113f49 chore: fix ngrok setup to run locally
Frontend CD / build (push) Failing after 2m31s
2026-08-07 15:55:04 +02:00
rmcampos 923617bdff chore: bump all client deps to latest in minor target 2026-08-07 15:29:29 +02:00
rmcampos 5ef96002cc chore: bump all client deps to latest in patch target 2026-08-07 14:54:03 +02:00
rmcampos d08f611174 feat: add link to changelog from sidebar version 2026-08-07 14:52:23 +02:00
rmcampos 09462e9e84 docs: update changelog with docker images 2026-07-28 12:54:05 -03:00
rmcampos 4993b1e806 feat: add notes archival option before deleting (#20)
Frontend CD / build (push) Successful in 4m59s
Backend CD / build (push) Successful in 22m37s
# What

- Add archiving notes option
- Notes should be archived before deleting.

# Why

- Makes the flow more clear and follow the same pattern as other app.Reviewed-on: #20
2026-07-28 13:52:19 +00:00
rmcampos 7b45e2ccbd feat: add completed tasks and delete confirm (#19)
Backend CD / build (push) Successful in 4m32s
Frontend CD / build (push) Successful in 4m41s
# What

- Add confirm modal before deleting tasks and notes
- Add section with completed tasks

# Why

- That's because why.Reviewed-on: #19
2026-07-23 20:33:23 +00:00
rmcampos 1ebf89668d chore: small styling improvements
Frontend CD / build (push) Successful in 5m50s
2026-07-23 19:52:47 +02:00
rmcampos d7cd922b94 chore: add script to update local user when running in dev 2026-07-23 18:48:42 +02:00
rmcampos c9916a8475 fix: wrong styling in account page buttons 2026-07-23 18:48:21 +02:00
rmcampos 6803ae6ebc chore: bump minor deps to latest version
Frontend CD / build (push) Successful in 8m56s
2026-07-22 22:47:33 +02:00
renovate-botandrmcampos 231e091f5d chore(deps): update caddy docker tag to v2.11.4 (#9)
Frontend CD / build (push) Successful in 3m29s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| caddy | final | minor | `2.10.2-alpine` → `2.11.4-alpine` |

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yNTIuMSIsInVwZGF0ZWRJblZlciI6IjQzLjI1Mi4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

---------

Co-authored-by: Ricardo Campos <ricardompcampos@gmail.com>
Reviewed-on: #9
Co-authored-by: Renovate Bot <renovate-bot@lightroasted.vps-kinghost.net>
2026-07-22 20:33:24 +00:00
rmcampos 749ddbcdad fix: ghcr references and old files (#18)
Frontend CD / build (push) Successful in 4m45s
# What

- Closes #17

# Why

- Keep the repo updated with needed files onlyReviewed-on: #18
2026-07-22 20:22:05 +00:00
renovate-botandrmcampos 294e1b7a6a chore(deps): update dependency com.puppycrawl.tools:checkstyle to v13.7.0 (#10)
Backend CD / build (push) Successful in 4m2s
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [com.puppycrawl.tools:checkstyle](https://checkstyle.org/) ([source](https://github.com/checkstyle/checkstyle)) | `13.3.0` → `13.7.0` | ![age](https://developer.mend.io/api/mc/badges/age/maven/com.puppycrawl.tools:checkstyle/13.7.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.puppycrawl.tools:checkstyle/13.3.0/13.7.0?slim=true) |

>  **Important**
>
> Release Notes retrieval for this PR were skipped because no github.com credentials were available.
> If you are self-hosted, please see [this instruction](https://github.com/renovatebot/renovate/blob/master/docs/usage/examples/self-hosting.md#githubcom-token-for-release-notes).

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yNTIuMSIsInVwZGF0ZWRJblZlciI6IjQzLjI1Mi4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

---------

Co-authored-by: Ricardo Campos <ricardompcampos@gmail.com>
Reviewed-on: #10
Reviewed-by: Ricardo Campos <ricardompcampos@gmail.com>
Co-authored-by: Renovate Bot <renovate-bot@lightroasted.vps-kinghost.net>
2026-07-22 18:47:58 +00:00
rmcampos 58dabc75a6 docs: update changelog file 2026-07-22 12:02:27 -03:00
rmcampos dd15837d4d feat: add closing and saving button to modal. Closes #15 (#16)
Frontend CD / build (push) Successful in 5m7s
# What

- Closes #15

# Why

- Make users life easier.Reviewed-on: #16
2026-07-22 14:53:51 +00:00
rmcampos a79e79c04f fix: wrong image name 2026-07-22 11:16:28 -03:00
rmcampos 488586be48 chore: drop staging and improve to adopt doppler in CI
Backend CD / build (push) Successful in 7m59s
2026-07-22 11:02:14 -03:00
rmcampos 8901084fa9 chore: small clean up and updates 2026-07-22 10:10:12 -03:00
rmcampos 466e255594 docs: restructure changelog file (only the last change) 2026-07-22 10:06:19 -03:00
gitea-actions[bot] 300b2e0576 chore: bump api version to 35 [skip ci] 2026-07-20 16:41:31 +00:00
rmcampos 0250b558cf Group three changes in a single PR (#13)
Main CI-Frontend / Build & Push (push) Successful in 46s
Main CI-Backend / Build & Push (push) Successful in 54s
## What

- fix: background image position moving around in the landing, login and register pages.
- chore: bump minor dependencies versions to latest version.
- feat: replace "Due in 1 day" and "Due in 0 days" to Time Ago format: "Due tomorrow" and "Due today".

## Why

- Keeping the app improving and updated.
- Date picker in mobile works better using the default/native.

## Mood
<img width="200" src="https://media0.giphy.com/media/TIiDh0ONXotAQpkw3f/giphy.gif?cid=5a38a5a2cea0tfjgne8sdpz1vz617kfqk02hnjrrjx34v08x&amp;ep=v1_gifs_search&amp;rid=giphy.gif&amp;ct=g" style="border:0;height:300px;width:300px"/>

Reviewed-on: #13
2026-07-20 16:40:53 +00:00
101 changed files with 1881 additions and 2653 deletions
-23
View File
@@ -1,23 +0,0 @@
---
name: client-updater
description: A bot that updates the client software to the latest version.
tools: [run_shell_command, read_file, write_file, replace, list_directory, glob, grep_search, ask_user]
---
# Instructions
1. Navigate to the `client` directory.
2. Execute the following command to check for updates:
`npx npm-check-updates --target minor`
3. Display the resulting list of available minor updates to the user.
4. If there are packages to update:
- Run `npx npm-check-updates --target minor -u` to update `package.json`.
- Run `npm install` to update the `package-lock.json` and install the new versions.
5. Run the validation script to ensure stability:
`../tools/check-frontend.sh`
6. If the validation passes:
- Create a new branch (e.g., `update-deps-[date]`).
- Commit the changes to `package.json` and `package-lock.json`.
- Push and create a Pull Request (using `gh pr create` if available).
7. If validation fails (lint, build, or test issues):
- Diagnose the failure using `grep_search` and `read_file`.
- Fix the issues, then retry the validation and PR steps.
8. Report the final status and the PR link to the user.
-12
View File
@@ -1,12 +0,0 @@
description = "Create a formatted GitHub issue"
prompt = """
Act as a project manager. Based on the following input: {{args}}
Create a GitHub issue using the `gh` CLI tool with this exact structure:
- Title: [Feat/Bug] <short summary>
- Body: A detailed description, technical requirements, and acceptance criteria.
- Label: Automatically determine if it's 'bug', 'enhencement', or 'task'.
Once the user confirms the plan, execute the command:
`gh issue create --title "[Type] Title" --body "..." --label "..."
"""
+84
View File
@@ -0,0 +1,84 @@
name: Backend CD
on:
workflow_dispatch:
push:
branches: [ main ]
paths:
- 'server/**'
- '.github/workflows/cd-backend.yml'
jobs:
build:
runs-on: graalvm-25
permissions:
contents: write
packages: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Cache Maven dependencies
uses: actions/cache@v3
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/server/pom.xml') }}
restore-keys: |
${{ runner.os }}-maven-
- name: Generate version tag
id: version
run: |
DATE=$(date +'%Y.%m.%d')
TAG="api-v${DATE}.${{ github.run_number }}"
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "Generated tag: ${TAG}"
- name: Build Docker image with Spring Boot
working-directory: ./server
run: |
./mvnw -Pnative -DskipTests spring-boot:build-image \
-Dspring-boot.build-image.imageName=rmcampos/tasknote-api:latest \
-Dspring-boot.build-image.builder=paketobuildpacks/builder-jammy-tiny:0.0.505
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: rmcampos/tasknote-api
tags: |
type=raw,value=${{ steps.version.outputs.tag }}
type=raw,value=latest,enable={{ is_default_branch }}
- name: Tag and push Docker image
run: |
docker tag docker.io/rmcampos/tasknote-api:latest docker.io/rmcampos/tasknote-api:${{ steps.version.outputs.tag }}
docker push docker.io/rmcampos/tasknote-api:latest
docker push docker.io/rmcampos/tasknote-api:${{ steps.version.outputs.tag }}
- name: Output Docker image URLs
run: |
echo ""
echo "=========================================="
echo "🚀 Docker Images Published Successfully!"
echo "=========================================="
echo ""
echo "API Image:"
echo " rmcampos/tasknote-api:${{ steps.version.outputs.tag }}"
echo " rmcampos/tasknote-api:latest"
echo ""
echo "=========================================="
- name: Create and push Git tag
run: |
git config user.name "gitea-actions[bot]"
git config user.email "gitea-actions[bot]@noreply.lightroasted.vps-kinghost.net"
git tag -a ${{ steps.version.outputs.tag }} -m "Release ${{ steps.version.outputs.tag }}"
git push origin ${{ steps.version.outputs.tag }}
+98
View File
@@ -0,0 +1,98 @@
name: Frontend CD
on:
workflow_dispatch:
push:
branches: [ main ]
paths:
- 'client/**'
- '.github/workflows/frontend-cd.yml'
jobs:
build:
runs-on: easynode-debian
permissions:
contents: write
packages: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Cache npm dependencies
uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/client/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- name: Install dependencies
run: npm install
working-directory: ./client
- name: Run build
run: npm run build
working-directory: ./client
- name: Generate version tag
id: version
run: |
DATE=$(date +'%Y.%m.%d')
TAG="app-v${DATE}.${{ github.run_number }}"
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "Generated tag: ${TAG}"
- name: Set up Docker Buildx
run: |
docker buildx create --name container-builder --driver docker-container --use --bootstrap || \
docker buildx use container-builder
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: rmcampos/tasknote-app
tags: |
type=raw,value=${{ steps.version.outputs.tag }}
type=raw,value=latest,enable={{ is_default_branch }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: ./client
push: true
tags: |
${{ steps.meta.outputs.tags }}
rmcampos/tasknote-app:${{ steps.version.outputs.tag }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=rmcampos/tasknote-app:buildcache
cache-to: type=registry,ref=rmcampos/tasknote-app:buildcache,mode=max
build-args: |
VITE_BUILD=${{ steps.version.outputs.tag }}
- name: Output Docker image URLs
run: |
echo ""
echo "=========================================="
echo "🚀 Docker Images Published Successfully!"
echo "=========================================="
echo ""
echo "App Image:"
echo " rmcampos/tasknote-app:${{ steps.version.outputs.tag }}"
echo " rmcampos/tasknote-app:latest"
echo ""
echo "=========================================="
- name: Create and push Git tag
run: |
git config user.name "gitea-actions[bot]"
git config user.email "gitea-actions[bot]@noreply.lightroasted.vps-kinghost.net"
git tag -a ${{ steps.version.outputs.tag }} -m "Release ${{ steps.version.outputs.tag }}"
git push origin ${{ steps.version.outputs.tag }}
-102
View File
@@ -1,102 +0,0 @@
name: Pull Request CD-Deploy to Staging
on:
workflow_dispatch:
workflow_run:
workflows: [ "Pull Request CI-Backend", "Pull Request CI-Frontend" ]
types: [ completed ]
jobs:
terraform-plan-stg:
name: Plan changs to staging
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
runs-on: easynode-debian
outputs:
has_changes: ${{ steps.check-changes.outputs.has_changes }}
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
- name: Show Terraform provider versions
working-directory: terraform-stg
run: terraform version
- name: Setup kubectl
uses: azure/setup-kubectl@v4
- name: Setup Kubeconfig
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config
chmod 600 ~/.kube/config
- name: Validate cluster access
run: |
kubectl cluster-info
kubectl get namespace tasknote-stg
- name: Determine deployment values
id: deploy-vars
run: |
backend_image="rmcampos/tasknote-api:candidate"
frontend_image="rmcampos/tasknote-app:candidate"
echo "backend_image=$backend_image" >> "$GITHUB_OUTPUT"
echo "frontend_image=$frontend_image" >> "$GITHUB_OUTPUT"
- name: Terraform Fmt -check -diff
working-directory: terraform-stg
run: terraform fmt -check -diff
- name: Terraform Init
working-directory: terraform-stg
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
run: terraform init -input=false
- name: Terraform Validate
working-directory: terraform-stg
run: terraform validate
- name: Terraform Plan
id: check-changes
working-directory: terraform-stg
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
run: |
timeout 3m terraform plan -input=false -out=tfplan \
-var="db_user=${{ secrets.DB_USER }}" \
-var="db_password=${{ secrets.DB_PASSWORD }}" \
-var="db_name=${{ secrets.DB_NAME }}" \
-var="security_key=${{ secrets.JWT_SECURITY_KEY }}" \
-var="mailgun_apikey=${{ secrets.MAILGUN_API_KEY }}" \
-var="backend_image=${{ steps.deploy-vars.outputs.backend_image }}" \
-var="frontend_image=${{ steps.deploy-vars.outputs.frontend_image }}" \
-var="deploy_version=${{ github.run_id }}"
terraform show -json tfplan > tfplan.json
if jq -e '.resource_changes | length == 0' tfplan.json >/dev/null; then
echo "has_changes=false" >> "$GITHUB_OUTPUT"
echo "No changes to apply."
exit 0
else
echo "Changes detected. Proceeding with apply"
echo "has_changes=true" >> "$GITHUB_OUTPUT"
fi
- name: Terraform Apply
working-directory: terraform-stg
if: steps.check-changes.outputs.has_changes == 'true'
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
KUBE_CONFIG_PATH: ~/.kube/config
run: timeout 1m terraform apply tfplan
-96
View File
@@ -1,96 +0,0 @@
name: Main CI-Backend
on:
workflow_dispatch:
push:
branches:
- main
paths:
- 'server/**/*.java'
- 'server/**/*.xml'
- 'server/pom.xml'
- '.github/workflows/ci-main-backend.yml'
jobs:
build-and-push:
name: Build & Push
runs-on: graalvm-25
permissions:
contents: write
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.REGISTRY_TOKEN }}
- name: Cache Maven dependencies
uses: actions/cache@v3
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/server/pom.xml') }}
restore-keys: |
${{ runner.os }}-maven-
- name: Increment version in pom.xml
id: version
working-directory: ./server
run: |
CURRENT_VERSION=$(./mvnw help:evaluate -Dexpression=project.version -q -DforceStdout)
echo "Current version: ${CURRENT_VERSION}"
NEW_VERSION=$((CURRENT_VERSION + 1))
echo "New version: ${NEW_VERSION}"
./mvnw versions:set -DnewVersion=${NEW_VERSION} -DgenerateBackupFiles=false -q
echo "version=${NEW_VERSION}" >> $GITHUB_OUTPUT
- name: Commit version bump
run: |
git config user.name "gitea-actions[bot]"
git config user.email "gitea-actions[bot]@noreply.lightroasted.vps-kinghost.net"
git add server/pom.xml
git commit -m "chore: bump api version to ${{ steps.version.outputs.version }} [skip ci]"
git push
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Find PR number
id: find_pr
run: |
PR_NUMBER=$(curl -s \
-H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \
"https://lightroasted.vps-kinghost.net/api/v1/repos/${{ github.repository }}/commits/${{ github.sha }}/pull" \
| jq -r 'if type == "object" and .number != null then .number | tostring else empty end')
if [ -z "$PR_NUMBER" ]; then
echo "No merged PR found for this commit. Falling back to 'candidate' tag."
PR_NUMBER="candidate"
else
PR_NUMBER="pr-${PR_NUMBER}"
fi
echo "tag=${PR_NUMBER}" >> $GITHUB_OUTPUT
- name: Promote Docker image
run: |
docker pull docker.io/rmcampos/tasknote-api:${{ steps.find_pr.outputs.tag }}
docker tag docker.io/rmcampos/tasknote-api:${{ steps.find_pr.outputs.tag }} docker.io/rmcampos/tasknote-api:latest
docker tag docker.io/rmcampos/tasknote-api:${{ steps.find_pr.outputs.tag }} docker.io/rmcampos/tasknote-api:${{ steps.version.outputs.version }}
docker push docker.io/rmcampos/tasknote-api:latest
docker push docker.io/rmcampos/tasknote-api:${{ steps.version.outputs.version }}
- name: Create and push Git tag
run: |
git config user.name "gitea-actions[bot]"
git config user.email "gitea-actions[bot]@noreply.lightroasted.vps-kinghost.net"
git tag -a api-v${{ steps.version.outputs.version }} -m "Release API v${{ steps.version.outputs.version }}"
git push origin api-v${{ steps.version.outputs.version }}
- name: Log pushed images
run: |
echo "Pushed images:"
echo " docker.io/rmcampos/tasknote-api:latest"
echo " docker.io/rmcampos/tasknote-api:${{ steps.version.outputs.version }}"
-89
View File
@@ -1,89 +0,0 @@
name: Main CI-Frontend
on:
workflow_dispatch:
push:
branches:
- main
paths:
- 'client/**/*.html'
- 'client/**/*.png'
- 'client/**/*.json'
- 'client/**/*.txt'
- 'client/**/*.ts'
- 'client/**/*.tsx'
- 'client/**/*.js'
- 'client/Dockerfile'
- 'client/Caddyfile'
- '.github/workflows/ci-main-frontend.yml'
jobs:
build-and-push:
name: Build & Push
runs-on: easynode-debian
permissions:
contents: write
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Docker Buildx
run: docker buildx inspect --bootstrap
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Find PR number
id: find_pr
run: |
PR_NUMBER=$(curl -s \
-H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \
"https://lightroasted.vps-kinghost.net/api/v1/repos/${{ github.repository }}/commits/${{ github.sha }}/pull" \
| jq -r 'if type == "object" and .number != null then .number | tostring else empty end')
if [ -z "$PR_NUMBER" ]; then
echo "No merged PR found for this commit. Falling back to 'candidate' tag."
PR_NUMBER="candidate"
else
PR_NUMBER="pr-${PR_NUMBER}"
fi
echo "tag=${PR_NUMBER}" >> $GITHUB_OUTPUT
- name: Extract version from image
id: version
run: |
docker pull rmcampos/tasknote-app:${{ steps.find_pr.outputs.tag }}
VITE_BUILD=$(docker inspect rmcampos/tasknote-app:${{ steps.find_pr.outputs.tag }} \
--format '{{ range .Config.Env }}{{ println . }}{{ end }}' \
| grep '^VITE_BUILD=' | cut -d= -f2)
if [ -z "$VITE_BUILD" ]; then
echo "Could not extract VITE_BUILD from image" >&2
exit 1
fi
echo "tag=${VITE_BUILD}" >> $GITHUB_OUTPUT
echo "Extracted version: ${VITE_BUILD}"
- name: Promote Docker image
run: |
docker buildx imagetools create \
--tag rmcampos/tasknote-app:latest \
rmcampos/tasknote-app:${{ steps.find_pr.outputs.tag }}
- name: Create and push Git tag
run: |
git config user.name "gitea-actions[bot]"
git config user.email "gitea-actions[bot]@noreply.lightroasted.vps-kinghost.net"
git tag -a ${{ steps.version.outputs.tag }} -m "Release ${{ steps.version.outputs.tag }}"
git push origin ${{ steps.version.outputs.tag }}
- name: Log pushed images
run: |
echo "Pushed images:"
echo " rmcampos/tasknote-app:latest"
echo " rmcampos/tasknote-app:${{ steps.version.outputs.tag }}"
-105
View File
@@ -1,105 +0,0 @@
name: Pull Request CI-Backend
on:
workflow_dispatch:
pull_request:
types: [opened, synchronize, reopened]
branches:
- 'main'
paths:
- 'server/**/*.java'
- 'server/**/*.xml'
- 'server/pom.xml'
- 'server/**/*.yml'
- '.github/workflows/ci-pr-backend.yml'
jobs:
run-checks:
name: Checks
runs-on: graalvm-25
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Cache Maven dependencies
uses: actions/cache@v3
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/server/pom.xml') }}
restore-keys: |
${{ runner.os }}-maven-
- name: Run Check Style
working-directory: ./server
run: ./mvnw --no-transfer-progress checkstyle:check -Dcheckstyle.skip=false
- name: Run build
working-directory: ./server
run: ./mvnw --no-transfer-progress clean compile -DskipTests
- name: Run tests
working-directory: ./server
run: ./mvnw --no-transfer-progress clean verify -P tests --file pom.xml
build-and-push:
name: Build & Push
runs-on: graalvm-25
needs: ["run-checks"]
permissions:
contents: read
deployments: write
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Cache Maven dependencies
uses: actions/cache@v3
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/server/pom.xml') }}
restore-keys: |
${{ runner.os }}-maven-
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build Docker image with Spring Boot
working-directory: ./server
run: |
./mvnw -Pnative -DskipTests spring-boot:build-image \
-Dspring-boot.build-image.imageName=rmcampos/tasknote-api:latest \
-Dspring-boot.build-image.builder=paketobuildpacks/builder-jammy-tiny:0.0.505
- name: Tag and push Docker image
run: |
docker tag docker.io/rmcampos/tasknote-api:latest docker.io/rmcampos/tasknote-api:candidate
docker tag docker.io/rmcampos/tasknote-api:latest docker.io/rmcampos/tasknote-api:pr-${{ github.event.pull_request.number }}
docker push docker.io/rmcampos/tasknote-api:candidate
docker push docker.io/rmcampos/tasknote-api:pr-${{ github.event.pull_request.number }}
- name: Create Gitea deployment for staging
if: ${{ github.event_name == 'pull_request' }}
run: |
curl -s -X POST \
-H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \
-H "Content-Type: application/json" \
"https://lightroasted.vps-kinghost.net/api/v1/repos/${{ github.repository }}/statuses/${{ github.event.pull_request.head.sha }}" \
-d "{\"context\": \"staging/deploy\", \"state\": \"success\", \"description\": \"PR #${{ github.event.pull_request.number }} staging ready\", \"target_url\": \"https://tasknote-stg.darkroasted.vps-kinghost.net\"}"
- name: Log pushed images
run: |
echo "Pushed images:"
echo " docker.io/rmcampos/tasknote-api:candidate"
echo " docker.io/rmcampos/tasknote-api:pr-${{ github.event.pull_request.number }}"
-135
View File
@@ -1,135 +0,0 @@
name: Pull Request CI-Frontend
on:
workflow_dispatch:
pull_request:
types: [opened, synchronize, reopened]
branches:
- 'main'
paths:
- 'client/**/*.html'
- 'client/**/*.png'
- 'client/**/*.json'
- 'client/**/*.txt'
- 'client/**/*.ts'
- 'client/**/*.tsx'
- 'client/**/*.js'
- 'client/Dockerfile'
- 'client/Caddyfile'
- '.github/workflows/ci-pr-frontend.yml'
jobs:
run-checks:
name: Checks
runs-on: easynode-debian
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Cache npm dependencies
uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/client/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- name: Debug cache env
run: |
echo "CACHE_URL=${ACTIONS_CACHE_URL}"
echo "RUNTIME_URL=${ACTIONS_RUNTIME_URL}"
curl -s -w "\nHTTP: %{http_code}\n" \
-H "Authorization: Bearer ${ACTIONS_RUNTIME_TOKEN}" \
"${ACTIONS_CACHE_URL}_apis/artifactcache/cache?keys=test&version=test"
- name: Install dependencies
run: npm install
working-directory: ./client
- name: Run lint
run: npm run lint
working-directory: ./client
- name: Run build
run: npm run build
working-directory: ./client
- name: Run tests
run: npm run test:no-watch
working-directory: ./client
build-and-push:
name: Build & Push
runs-on: easynode-debian
needs: ["run-checks"]
permissions:
contents: write
packages: write
deployments: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Docker Buildx
run: docker buildx inspect --bootstrap
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: rmcampos/tasknote-app
tags: |
type=raw,value=candidate
type=raw,value=pr-${{ github.event.pull_request.number }}
- name: Generate version tag
id: version
run: |
DATE=$(date +'%Y.%m.%d')
TAG="app-v${DATE}.${{ github.run_number }}"
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "Generated tag: ${TAG}"
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: ./client
push: true
tags: |
${{ steps.meta.outputs.tags }}
rmcampos/tasknote-app:${{ steps.version.outputs.tag }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=rmcampos/tasknote-app:buildcache
cache-to: type=registry,ref=rmcampos/tasknote-app:buildcache,mode=max
build-args: |
VITE_BUILD=${{ steps.version.outputs.tag }}
- name: Create Gitea deployment for staging
if: ${{ github.event_name == 'pull_request' }}
run: |
curl -s -X POST \
-H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \
-H "Content-Type: application/json" \
"https://lightroasted.vps-kinghost.net/api/v1/repos/${{ github.repository }}/statuses/${{ github.event.pull_request.head.sha }}" \
-d "{\"context\": \"staging/deploy\", \"state\": \"success\", \"description\": \"PR #${{ github.event.pull_request.number }} staging ready\", \"target_url\": \"https://tasknote-stg.darkroasted.vps-kinghost.net\"}"
- name: Log pushed images
run: |
echo "Pushed images:"
echo " rmcampos/tasknote-app:candidate"
echo " rmcampos/tasknote-app:pr-${{ github.event.pull_request.number }}"
echo " rmcampos/tasknote-app:${{ steps.version.outputs.tag }}"
+74
View File
@@ -0,0 +1,74 @@
name: CI
on:
pull_request:
branches: ['**/*']
jobs:
build-backend:
name: Build Backend
runs-on: graalvm-25
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Cache Maven dependencies
uses: actions/cache@v3
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/server/pom.xml') }}
restore-keys: |
${{ runner.os }}-maven-
- name: Run Check Style
working-directory: ./server
run: ./mvnw --no-transfer-progress checkstyle:check -Dcheckstyle.skip=false
- name: Run build
working-directory: ./server
run: ./mvnw --no-transfer-progress clean compile -DskipTests
- name: Run tests
working-directory: ./server
run: ./mvnw --no-transfer-progress clean verify -P tests --file pom.xml
build-frontend:
name: Build Frontend
runs-on: easynode-debian
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Cache npm dependencies
uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/client/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- name: Install dependencies
run: npm install
working-directory: ./client
- name: Run lint
run: npm run lint
working-directory: ./client
- name: Run build
run: npm run build
working-directory: ./client
- name: Run tests
run: npm run test:no-watch
working-directory: ./client
@@ -1,4 +1,8 @@
name: Main CD-Deploy to Prod
name: Deploy to Prod
concurrency:
group: deploy-production
cancel-in-progress: true
on:
workflow_dispatch:
@@ -14,7 +18,7 @@ on:
required: false
default: "true"
workflow_run:
workflows: [ "Main CI-Backend", "Main CI-Frontend" ]
workflows: [ "Backend CD", "Frontend CD" ]
types: [ completed ]
jobs:
@@ -37,10 +41,15 @@ jobs:
- name: Setup kubectl
uses: azure/setup-kubectl@v4
- name: Setup Doppler CLI
uses: dopplerhq/cli-action@v4
- name: Setup Kubeconfig
env:
DOPPLER_TOKEN: ${{ secrets.DOPPLER_AT_SECRETS }}
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config
doppler run --config prd -- bash -c 'echo "$KUBECONFIG_DATA" | base64 -d > ~/.kube/config'
chmod 600 ~/.kube/config
- name: Validate cluster access
@@ -54,25 +63,17 @@ jobs:
backend_image="${{ github.event.inputs.backend_image }}"
frontend_image="${{ github.event.inputs.frontend_image }}"
latest_backend_tag_tmp="$(git tag --list 'api-v*' | sort -V | tail -n1)"
latest_backend_tag="${latest_backend_tag_tmp#api-v}"
latest_backend_tag="$(git tag --list 'api-v*' | sort -V | tail -n1)"
echo "latest backend tag=$latest_backend_tag"
latest_frontend_tag="$(git tag --list 'app-v*' | sort -V | tail -n1)"
if [ -z "$latest_backend_tag" ]; then
echo "No backend tag found matching [0-9]*" >&2
exit 1
fi
if [ -z "$latest_frontend_tag" ]; then
echo "No frontend tag found matching app-v*" >&2
exit 1
fi
echo "latest frontend tag=$latest_frontend_tag"
if [ -z "$backend_image" ]; then
backend_image="rmcampos/tasknote-api:$latest_backend_tag"
backend_image="docker.io/rmcampos/tasknote-api:$latest_backend_tag"
fi
if [ -z "$frontend_image" ]; then
frontend_image="rmcampos/tasknote-app:$latest_frontend_tag"
frontend_image="docker.io/rmcampos/tasknote-app:$latest_frontend_tag"
fi
echo "Resolved backend_image=$backend_image"
@@ -88,9 +89,8 @@ jobs:
- name: Terraform Init
working-directory: terraform
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
run: terraform init -input=false
DOPPLER_TOKEN: ${{ secrets.DOPPLER_AT_SECRETS }}
run: doppler run --config prd -- terraform init -input=false
- name: Terraform Validate
working-directory: terraform
@@ -100,19 +100,22 @@ jobs:
id: check-changes
working-directory: terraform
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
DOPPLER_TOKEN: ${{ secrets.DOPPLER_AT_SECRETS }}
BACKEND_IMAGE: ${{ steps.deploy-vars.outputs.backend_image }}
FRONTEND_IMAGE: ${{ steps.deploy-vars.outputs.frontend_image }}
run: |
timeout 1m terraform plan -input=false -out=tfplan \
-var="db_user=${{ secrets.DB_USER }}" \
-var="db_password=${{ secrets.DB_PASSWORD }}" \
-var="db_name=${{ secrets.DB_NAME }}" \
-var="security_key=${{ secrets.JWT_SECURITY_KEY }}" \
-var="mailgun_apikey=${{ secrets.MAILGUN_API_KEY }}" \
-var="r2_access_key=${{ secrets.R2_ACCESS_KEY_ID }}" \
-var="r2_secret_key=${{ secrets.R2_SECRET_ACCESS_KEY }}" \
-var="backend_image=${{ steps.deploy-vars.outputs.backend_image }}" \
-var="frontend_image=${{ steps.deploy-vars.outputs.frontend_image }}"
doppler run --config prd -- bash -c '
TF_VAR_db_user="$DB_USER" \
TF_VAR_db_password="$DB_PASSWORD" \
TF_VAR_db_name="$DB_NAME" \
TF_VAR_security_key="$SECURITY_KEY" \
TF_VAR_mailgun_apikey="$MAILGUN_APIKEY" \
TF_VAR_r2_access_key="$AWS_ACCESS_KEY_ID" \
TF_VAR_r2_secret_key="$AWS_SECRET_ACCESS_KEY" \
TF_VAR_backend_image="$BACKEND_IMAGE" \
TF_VAR_frontend_image="$FRONTEND_IMAGE" \
timeout 1m terraform plan -input=false -out=tfplan
'
terraform show -json tfplan > tfplan.json
if jq -e '.resource_changes | length == 0' tfplan.json >/dev/null; then
echo "has_changes=false" >> "$GITHUB_OUTPUT"
@@ -127,6 +130,5 @@ jobs:
working-directory: terraform
if: steps.check-changes.outputs.has_changes == 'true'
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
run: timeout 1m terraform apply tfplan
DOPPLER_TOKEN: ${{ secrets.DOPPLER_AT_SECRETS }}
run: doppler run --config prd -- timeout 2m terraform apply tfplan
+4 -4
View File
@@ -25,10 +25,10 @@
- Frontend local dev: run from `client/` with `npm start`; backend local dev: run from `server/` with `./mvnw spring-boot:run`.
## CI/CD and release behavior
- PR workflows (`.github/workflows/ci-pr-frontend.yml`, `.github/workflows/ci-pr-backend.yml`) run checks then push `:candidate` and `:pr-<N>` images to GHCR.
- Main workflows (`.github/workflows/ci-main-frontend.yml`, `.github/workflows/ci-main-backend.yml`) push versioned tags (`app-v<date>.<run>` / `api-v<pom-version>`) + `latest`; backend workflow also increments `server/pom.xml` version.
- Staging deploy workflow (`.github/workflows/cd-pr.yml`) triggers on completion of either PR CI workflow and applies Terraform in `terraform-stg/` using a plan→apply split.
- Production deploy workflow (`.github/workflows/cd-main.yml`) triggers on completion of either Main CI workflow and applies Terraform in `terraform/` using a plan→apply split; `apply` can be skipped if there are no Terraform changes.
- PR workflows (`.github/workflows/drop-ci-pr-frontend.yml`, `.github/workflows/drop-ci-pr-backend.yml`) run checks then push `:candidate` and `:pr-<N>` images to GHCR.
- Main workflows (`.github/workflows/drop-ci-main-frontend.yml`, `.github/workflows/drop-ci-main-backend.yml`) push versioned tags (`app-v<date>.<run>` / `api-v<pom-version>`) + `latest`; backend workflow also increments `server/pom.xml` version.
- Staging deploy workflow (`.github/workflows/drop-cd-pr.yml`) triggers on completion of either PR CI workflow and applies Terraform in `terraform-stg/` using a plan→apply split.
- Production deploy workflow (`.github/workflows/deploy.yml`) triggers on completion of either Main CI workflow and applies Terraform in `terraform/` using a plan→apply split; `apply` can be skipped if there are no Terraform changes.
- Infra wiring (secrets, services, ingress, image vars) is defined in `terraform/main.tf`; an alternative GCP target is under `terraform-gcp/`.
## Project conventions to preserve
+97 -2
View File
@@ -7,7 +7,102 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## app-v2026.07.01.140 - 2026-07-01
## 2026-08-07
### Added
- Link to the build number to point to the changelog file. (build xxx)
### Changed
- All deps to latest version in client for patch target.
- All deps to latest version in client for minor target.
- Development files for ngrok locally.
### Fixed
- Buildx error in build phase in CI.
### Removed
- Lingering files from previous CI/CD workflows.
```bash
# Docker images
docker pull rmcampos/tasknote-app:app-v2026.07.28.195
docker pull rmcampos/tasknote-api:api-v2026.07.28.194
```
---
## 2026-07-28
### Added
- Option to archive notes.
### Changed
- Notes should be archived before deleting.
```bash
# Docker images
docker pull rmcampos/tasknote-app:app-v2026.07.28.195
docker pull rmcampos/tasknote-api:api-v2026.07.28.194
```
## 2026-07-23
### Added
- Section for completed tasks in the home page..
- Icons in tasks and notes to differentiate them.
- Modal confirming before delete tasks and notes.
### Changed
- Completed tasks are now kept in the database, unless deleted.
- Buttons in home screen notes view to match the system design.
- Add task form to be easier to see and better structured.
- Delete my account buttons layout to match the system design.
- Loaded tasks now has a light yellow styling.
```bash
# Docker images
docker pull rmcampos/tasknote-app:app-v2026.07.23.190
docker pull rmcampos/tasknote-api:api-v2026.07.23.189
```
### Fixed
- Dropped the untagged tag from loading in the add notes and tasks form.
## 2026-07-22
### Added
- Button to save notes from the preview modal. Closes [#15](https://lightroasted.vps-kinghost.net/rmcampos/tasknote/issues/15)
### Changed
- Removed deployments to staging in PR pipelines. PR only runs CI now. Closes [#14](https://lightroasted.vps-kinghost.net/rmcampos/tasknote/issues/14)
### Removed
- Old files from project and moved scripts to `tools` folder.
```bash
# Docker images
docker pull rmcampos/tasknote-app:app-v2026.07.22.161
docker pull rmcampos/tasknote-api:api-v2026.07.22.169
```
## 2026-07-20
### Changed
- Labels in tasks due date to use the time ago format.
- Bumped all minor deps in the frontend.
### Fixed
- Background image position in landing, login and register pages.
### Removed
- React Date Picker dependency in favor of regular browser input date UI.
```bash
# Docker images
docker pull rmcampos/tasknote-app:app-v2026.07.20.161
```
## 2026-07-01
### Added
- Support for `Draft` notes and tasks.
@@ -26,7 +121,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Bumped client minor and major dependencies.
### Docker images
- `docker.io/rmcampos/tasknote-app:app-v2026.06.24.?`
- `rmcampos/tasknote-app:app-v2026.06.25.102`
## api-v32 && app-v2026.06.15.97 - 2026-06-15
+10 -4
View File
@@ -9,7 +9,7 @@ If you want to contribute, please create a fork and a Merge Request. Take a look
## Steps to Contribute
1. Fork the Project
2. Clone it on your local (`git clone https://github.com/ricardo-campos-org/react-typescript-todolist`)
2. Clone it on your local (`git clone https://lightroasted.vps-kinghost.net/rmcampos/tasknote.git`)
3. Develop your amazing feature/changes
4. Make sure your name is set (`git config user.name 'YOUR NAME'; git config user.email 'YOUR EMAIL'`)
5. Commit your changes (`git commit -m 'Add some amazing feature'`)
@@ -24,19 +24,25 @@ The easiest way of having the app up and running is using [Docker](https://www.d
1. Start the database engine (PostgreSQL)
```sh
bash tools/run-docker-db.sh
task dev-run-db
```
2. Start the back-end engine (Java & Spring Boot)
```sh
bash tools/run-docker-server.sh
task dev-run-api
```
3. Start the app server
```sh
bash tools/run-docker-client.sh
task dev-run-web
```
> Remember to follow up logs with 'docker ps' and 'docker logs -f <name>'
Or start all services at once with Docker Compose:
```sh
docker compose -f docker-compose.dev.yml up -d
```
If everything went well, you can head to [http://localhost:5000](http://localhost:5000) and create your user.
## 🦾 Automation
+2 -5
View File
@@ -2,17 +2,14 @@
version: '3'
vars:
GREETING: Hello, World!
tasks:
docker-build-web:
desc: Build the tasknote-web prod-ready docker image, tagging it as candidate
cmd: docker build --no-cache --build-arg VITE_BUILD="v999-$(date '+%Y-%m-%d-%H%M%S')" --build-arg SOURCE_PR="v999-123456789-$(date '+%Y-%m-%d-%H%M%S')" -t ghcr.io/rmcampos/tasknote/app:latest ./client
cmd: docker build --no-cache --build-arg VITE_BUILD="v999-$(date '+%Y-%m-%d-%H%M%S')" --build-arg SOURCE_PR="v999-123456789-$(date '+%Y-%m-%d-%H%M%S')" -t rmcampos/tasknote-app:latest ./client
docker-build-api:
desc: Build the tasknote-api prod-ready docker image, tagging it as candidate
cmd: cd server && mvn -Pnative -DskipTests spring-boot:build-image -Dspring-boot.build-image.imageName=ghcr.io/rmcampos/tasknote/api:latest -Dspring-boot.build-image.builder=paketobuildpacks/builder-jammy-tiny:0.0.505
cmd: cd server && mvn -Pnative -DskipTests spring-boot:build-image -Dspring-boot.build-image.imageName=rmcampos/tasknote-api:latest -Dspring-boot.build-image.builder=paketobuildpacks/builder-jammy-tiny:0.0.505
prod-up-web:
desc: Speed up the tasknote-web prod-like image, building it if required
+2 -2
View File
@@ -14,7 +14,7 @@ RUN npm i --ignore-scripts --no-update-notifier --omit=dev && \
# Deploy container
# Caddy serves static files
FROM caddy:2.10.2-alpine
FROM caddy:2.11.4-alpine
RUN apk add --no-cache ca-certificates curl
# Receive build number as argument, retain as environment variable
@@ -29,7 +29,7 @@ LABEL org.opencontainers.image.authors="Ricardo Campos <ricardompcampos@gmail.co
org.opencontainers.image.title="TaskNoteApp client" \
org.opencontainers.image.description="React Web app application" \
org.opencontainers.image.version="${SOURCE_PR}" \
org.opencontainers.image.source="https://github.com/ricardo-campos-org/react-typescript-todolist"
org.opencontainers.image.source="https://lightroasted.vps-kinghost.net/rmcampos/tasknote"
# Copy files and run formatting
COPY --from=build /app/dist/ /app/dist
+401 -631
View File
File diff suppressed because it is too large Load Diff
+27 -28
View File
@@ -9,29 +9,28 @@
"node",
"nestjs"
],
"repository": "https://github.com/ricardo-campos-org/react-typescript-todolist",
"repository": "https://lightroasted.vps-kinghost.net/rmcampos/tasknote",
"private": true,
"dependencies": {
"@popperjs/core": "^2.11.8",
"@types/node": "^26.0.1",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"@types/node": "^26.1.2",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.0.5",
"bootstrap": "^5.3.8",
"dompurify": "^3.4.11",
"i18next": "^26.3.2",
"react": "^19.2.7",
"dompurify": "^3.4.13",
"i18next": "^26.3.6",
"react": "^19.2.8",
"react-bootstrap": "^2.10.10",
"react-bootstrap-icons": "^1.11.6",
"react-charts": "^3.0.0-beta.57",
"react-datepicker": "^9.1.0",
"react-dom": "^19.2.7",
"react-i18next": "^17.0.8",
"react-dom": "^19.2.8",
"react-i18next": "^17.0.11",
"react-markdown": "^10.1.0",
"react-router": "^8.0.1",
"react-router": "^8.3.0",
"react-router-bootstrap": "^0.26.3",
"remark-gfm": "^4.0.1",
"typescript": "^6.0.3",
"vite": "^8.1.0"
"vite": "^8.2.1"
},
"scripts": {
"start": "vite --host",
@@ -66,30 +65,30 @@
},
"devDependencies": {
"@eslint/compat": "^2.1.0",
"@eslint/eslintrc": "^3.3.5",
"@eslint/js": "^9.39.4",
"@eslint/eslintrc": "^3.3.6",
"@eslint/js": "^9.39.5",
"@stylistic/eslint-plugin": "^5.10.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/jest-dom": "^6.10.0",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@testing-library/user-event": "^14.6.3",
"@types/jest": "^30.0.0",
"@types/react": "^19.2.17",
"@types/react": "^19.2.18",
"@types/react-router-bootstrap": "^0.26.8",
"@vitest/coverage-v8": "^4.1.9",
"cypress": "^15.18.0",
"eslint": "^9.39.4",
"@vitest/coverage-v8": "^4.1.10",
"cypress": "^15.20.0",
"eslint": "^9.39.5",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import-x": "^4.17.0",
"eslint-plugin-jsdoc": "^63.0.7",
"eslint-plugin-n": "^18.1.0",
"eslint-plugin-import-x": "^4.17.1",
"eslint-plugin-jsdoc": "^63.3.3",
"eslint-plugin-n": "^18.2.2",
"eslint-plugin-promise": "^7.3.0",
"eslint-plugin-react": "^7.37.5",
"globals": "^17.7.0",
"globals": "^17.9.0",
"jsdom": "^29.1.1",
"prettier": "^3.8.4",
"sass": "^1.101.0",
"prettier": "^3.9.6",
"sass": "^1.102.0",
"source-map-support": "^0.5.21",
"typescript-eslint": "^8.62.0",
"vitest": "^4.1.9"
"typescript-eslint": "^8.66.0",
"vitest": "^4.1.10"
}
}
-21
View File
@@ -1,21 +0,0 @@
# Must be unique in a given SonarQube instance
sonar.projectKey=ricardo-campos-org_react-typescript-todolist_client
sonar.organization=ricardo-campos-org
# This is the name and version displayed in the SonarQube UI.
# Was mandatory prior to SonarQube 6.1.
sonar.projectName=tasknote-webapp
#sonar.projectVersion=1.0
# Path is relative to the sonar-project.properties file.
# Replace "\" by "/" on Windows.
# This property is optional if sonar.modules is set.
sonar.javascript.lcov.reportPaths=coverage/lcov.info
sonar.typescript.tsconfigPaths=tsconfig.json
sonar.sources=src/
sonar.exclusions=src/__test__/**
sonar.tests=src/__test__/
sonar.verbose=false
# Encoding of the source code. Default is default system encoding
sonar.sourceEncoding=UTF-8
@@ -4,22 +4,22 @@ import { describe, expect, it } from 'vitest';
import TaskTimeLeft from '../../components/TaskTimeLeft';
describe('TaskTimeLeft Component', () => {
const renderComponent = (done: boolean) => {
const renderComponent = (completed: boolean) => {
return render(
<TaskTimeLeft
text="2 days left"
done={done}
completed={completed}
tooltip="2025-03-20"
/>
);
};
it('should render the TaskTimeLeft component with text when task is not done', () => {
it('should render the TaskTimeLeft component with text when task is not completed', () => {
const { getByText } = renderComponent(false);
expect(getByText('2 days left')).toBeDefined();
});
it('should not render the TaskTimeLeft component when task is done', () => {
it('should not render the TaskTimeLeft component when task is completed', () => {
const { queryByText } = renderComponent(true);
expect(queryByText('2 days left')).toBeNull();
});
@@ -4,32 +4,32 @@ import { describe, expect, it } from 'vitest';
import TaskTitle from '../../components/TaskTitle';
describe('TaskTitle Component', () => {
it('should render the TaskTitle component with high priority and done', () => {
const { getByText, container } = render(<TaskTitle title="Test Task" highPriority={true} done={true} />);
it('should render the TaskTitle component with high priority and completed', () => {
const { getByText, container } = render(<TaskTitle title="Test Task" highPriority={true} completed={true} />);
expect(container.querySelector('.task-title-icon')).toBeDefined();
expect(container.querySelector('.text-strike')).toBeDefined();
expect(getByText('Test Task')).toBeDefined();
expect(container.querySelector('svg')).toBeDefined(); // Check2Circle icon
});
it('should render the TaskTitle component with high priority and not done', () => {
const { getByText, container } = render(<TaskTitle title="Test Task" highPriority={true} done={false} />);
it('should render the TaskTitle component with high priority and not completed', () => {
const { getByText, container } = render(<TaskTitle title="Test Task" highPriority={true} completed={false} />);
expect(container.querySelector('.task-title-icon')).toBeDefined();
expect(container.querySelector('.text-strike')).toBeNull();
expect(getByText('Test Task')).toBeDefined();
expect(container.querySelector('svg')).toBeDefined(); // Bell icon
});
it('should render the TaskTitle component with not high priority and done', () => {
const { getByText, container } = render(<TaskTitle title="Test Task" highPriority={false} done={true} />);
it('should render the TaskTitle component with not high priority and completed', () => {
const { getByText, container } = render(<TaskTitle title="Test Task" highPriority={false} completed={true} />);
expect(container.querySelector('.task-title-icon')).toBeDefined();
expect(container.querySelector('.text-strike')).toBeDefined();
expect(getByText('Test Task')).toBeDefined();
expect(container.querySelector('svg')).toBeDefined(); // Check2Circle icon
});
it('should render the TaskTitle component with not high priority and not done', () => {
const { getByText, container } = render(<TaskTitle title="Test Task" highPriority={false} done={false} />);
it('should render the TaskTitle component with not high priority and not completed', () => {
const { getByText, container } = render(<TaskTitle title="Test Task" highPriority={false} completed={false} />);
expect(container.querySelector('.task-title-icon')).toBeDefined();
expect(container.querySelector('.text-strike')).toBeNull();
expect(getByText('Test Task')).toBeDefined();
@@ -28,6 +28,8 @@ describe('Portuguese Utils unit tests', () => {
expect(translateTimeMessage('1 month left', 'pt_br')).toBe('1 mês restante');
expect(translateTimeMessage('2 days left', 'pt_br')).toBe('2 dias restantes');
expect(translateTimeMessage('1 day left', 'pt_br')).toBe('1 dia restante');
expect(translateTimeMessage('Due tomorrow', 'pt_br')).toBe('Vence amanhã');
expect(translateTimeMessage('Due today', 'pt_br')).toBe('Vence hoje');
expect(translateTimeMessage('lala', 'pt_br')).toBe('lala');
expect(translateTimeMessage('null', 'pt_br')).toBe('null');
});
@@ -49,6 +49,8 @@ describe('Russian Utils unit tests', () => {
expect(translateTimeMessage('7 days left', 'ru')).toBe('осталось 7 дней');
expect(translateTimeMessage('8 days left', 'ru')).toBe('осталось 8 дней');
expect(translateTimeMessage('9 days left', 'ru')).toBe('осталось 9 дней');
expect(translateTimeMessage('Due tomorrow', 'ru')).toBe('Срок завтра');
expect(translateTimeMessage('Due today', 'ru')).toBe('Срок сегодня');
expect(translateTimeMessage('lala', 'ru')).toBe('lala');
expect(translateTimeMessage('null', 'ru')).toBe('null');
});
@@ -28,6 +28,8 @@ describe('Spanish Utils unit tests', () => {
expect(translateTimeMessage('1 month left', 'es')).toBe('Falta 1 mes');
expect(translateTimeMessage('2 days left', 'es')).toBe('Faltan 2 días');
expect(translateTimeMessage('1 day left', 'es')).toBe('Falta 1 día');
expect(translateTimeMessage('Due tomorrow', 'es')).toBe('Vence mañana');
expect(translateTimeMessage('Due today', 'es')).toBe('Vence hoy');
expect(translateTimeMessage('lala', 'es')).toBe('lala');
expect(translateTimeMessage('null', 'es')).toBe('null');
});
@@ -6,7 +6,7 @@ const tasks: TaskResponse[] = [
{
id: 1,
description: 'description',
done: false,
completed: false,
highPriority: true,
dueDate: '',
dueDateFmt: '',
+38 -23
View File
@@ -22,7 +22,11 @@ vi.mock('react-i18next', () => ({
vi.mock('../../api-service/api', () => ({
default: {
getJSON: vi.fn(),
deleteNoContent: vi.fn()
postJSON: vi.fn(),
patchJSON: vi.fn(),
putJSON: vi.fn(),
deleteNoContent: vi.fn(),
getJSONNoAuth: vi.fn()
}
}));
@@ -42,7 +46,9 @@ vi.mock('react-router', () => ({
}));
vi.mock('react-bootstrap-icons', () => ({
ThreeDotsVertical: () => <div data-testid="three-dots-icon"></div>
ThreeDotsVertical: () => <div data-testid="three-dots-icon"></div>,
CheckSquare: () => <div data-testid="task-icon"></div>,
JournalText: () => <div data-testid="note-icon">📝</div>
}));
// Mock components
@@ -98,7 +104,7 @@ const mockTasks: TaskResponse[] = [
{
id: 1,
description: 'Task 1',
done: false,
completed: false,
urls: ['http://example.com'],
tags: ['work'],
lastUpdate: '2023-10-10',
@@ -109,7 +115,7 @@ const mockTasks: TaskResponse[] = [
{
id: 2,
description: 'Task 2',
done: true,
completed: true,
urls: [],
tags: ['home'],
lastUpdate: '2023-10-09',
@@ -128,7 +134,8 @@ const mockNotes: NoteResponse[] = [
lastUpdate: '2023-10-10',
url: 'http://example.com',
shared: false,
shareToken: null
shareToken: null,
archived: false
},
{
id: 2,
@@ -138,7 +145,8 @@ const mockNotes: NoteResponse[] = [
lastUpdate: '2023-10-09',
url: null,
shared: false,
shareToken: null
shareToken: null,
archived: false
}
];
@@ -189,6 +197,7 @@ describe('Home Component', () => {
return Promise.resolve([]);
});
(api.deleteNoContent as any).mockResolvedValue(undefined);
(api.putJSON as any).mockResolvedValue(undefined);
// Mock window.innerWidth for the cleanText function
Object.defineProperty(window, 'innerWidth', {
@@ -217,6 +226,8 @@ describe('Home Component', () => {
await waitFor(() => {
expect(screen.getAllByTestId('task-title').length).toBe(2);
expect(screen.getAllByTestId('note-title').length).toBe(2);
expect(screen.getAllByTestId('task-icon').length).toBe(2);
expect(screen.getAllByTestId('note-icon').length).toBe(2);
});
});
@@ -351,14 +362,19 @@ describe('Home Component', () => {
fireEvent.click(markAsDoneButton!);
});
// Should call deleteNoContent API
expect(api.deleteNoContent).toHaveBeenCalledWith(expect.stringContaining('/1'));
// Should call patchJSON API with completed: true
expect(api.patchJSON).toHaveBeenCalledWith(
expect.stringContaining('/1'),
expect.objectContaining({ completed: true })
);
// Should reload tasks
expect(api.getJSON).toHaveBeenCalledWith(expect.stringContaining('tasks'));
});
test('deletes note', async () => {
test('archives note', async () => {
(api.putJSON as any).mockResolvedValue(undefined);
await act(async () => {
renderHome();
});
@@ -371,25 +387,24 @@ describe('Home Component', () => {
const noteDropdownToggles = screen.getAllByTestId('three-dots-icon');
// Note dropdowns start after task dropdowns
const firstNoteDropdown = noteDropdownToggles[mockTasks.length];
// Click the dropdown toggle
await act(async () => {
fireEvent.click(firstNoteDropdown);
});
// Find and click the "Delete" option by testId
const deleteButtons = screen.getAllByRole('button');
const deleteButton = deleteButtons.find(
button => button.textContent === 'task_table_action_delete'
);
// Find and click the "Archive" option by testId
const archiveButton = screen.getByTestId('note-dropdown-archive-item-1');
await act(async () => {
fireEvent.click(deleteButton!);
fireEvent.click(archiveButton);
});
// Should call archive API immediately
await waitFor(() => {
expect(api.putJSON).toHaveBeenCalledWith(expect.stringContaining('/notes/1/archive'), {});
});
// Should call deleteNoContent API
expect(api.deleteNoContent).toHaveBeenCalledWith(expect.stringContaining('/2'));
// Should reload notes
expect(api.getJSON).toHaveBeenCalledWith(expect.stringContaining('notes'));
});
@@ -478,7 +493,7 @@ describe('Home Component', () => {
});
});
test('keeps filter selection after deleting a note', async () => {
test('keeps filter selection after archiving a note', async () => {
await act(async () => {
renderHome();
});
@@ -510,11 +525,11 @@ describe('Home Component', () => {
});
const deleteButtons = screen.getAllByRole('button');
const deleteButton = deleteButtons.find(
button => button.textContent === 'task_table_action_delete'
const archiveButton = deleteButtons.find(
button => button.textContent === 'note_action_archive'
);
await act(async () => {
fireEvent.click(deleteButton!);
fireEvent.click(archiveButton!);
});
// After reload, filter should still be applied - tasks should remain hidden
+2 -1
View File
@@ -155,7 +155,8 @@ describe('NoteAdd Component', () => {
tags: [],
lastUpdate: '',
shared: false,
shareToken: null
shareToken: null,
archived: false
}
expect(api.postJSON).toHaveBeenCalledWith(ApiConfig.notesUrl, newNote);
});
@@ -0,0 +1,6 @@
@import '../../styles/theme.scss';
.form-control[type="date"] {
font-size: 16px; /* Prevents iOS zoom on focus */
border-left: none;
}
@@ -1,47 +0,0 @@
@import '../../styles/theme.scss';
/* custom-datepicker.scss */
.react-datepicker-wrapper,
.react-datepicker__input-container {
flex: 1 1 auto !important;
width: 1% !important;
display: flex !important;
}
.react-datepicker__input-container {
/* Ensure it takes full width of the flex item */
width: 100%;
}
.react-datepicker__input-container input {
font-size: 16px; /* Prevents iOS zoom on focus */
width: 100%;
border-radius: 4px;
border: 1px solid var(--bs-border-color);
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.react-datepicker-popper {
z-index: 9999 !important;
}
.react-datepicker__day {
margin: 0.2rem;
padding: 0.5rem;
border-radius: 4px;
}
/* Larger touch targets on mobile */
@media (max-width: 768px) {
.react-datepicker__day,
.react-datepicker__month-text,
.react-datepicker__quarter-text,
.react-datepicker__year-text {
width: 2.5rem;
height: 2.5rem;
line-height: 2.5rem;
margin: 0.2rem;
}
}
+10 -36
View File
@@ -1,11 +1,7 @@
import React, { useEffect, useState } from 'react';
import { Col, Form, InputGroup, Row } from 'react-bootstrap';
import * as Icons from 'react-bootstrap-icons';
import DatePicker from 'react-datepicker';
import { MiddlewareReturn } from '@floating-ui/core';
import { MiddlewareState } from '@floating-ui/dom';
import 'react-datepicker/dist/react-datepicker.css';
import './custom-datepicker.scss';
import './FormInput.scss';
type IconName = keyof typeof Icons;
@@ -17,9 +13,7 @@ interface Props {
name: string;
placeholder?: string;
value?: string;
valueDate?: Date | null;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
onChangeDate?: (date: Date | null) => void;
dataTestId?: string;
pwdHideText?: string;
pwdShowText?: string;
@@ -67,36 +61,16 @@ function FormInput(props: React.PropsWithChildren<Props>): React.ReactNode {
<InputGroup.Text>
<Icon />
</InputGroup.Text>
{props.type == 'date'
{props.type === 'date'
? (
<DatePicker
selected={props?.valueDate}
onChange={(date: Date | null) => {
if (props.onChangeDate) {
props.onChangeDate(date);
}
}}
dateFormat="MMMM d, yyyy"
className="form-control"
id="date-input"
placeholderText={props.placeholder}
popperPlacement="bottom"
popperModifiers={[
{
name: 'preventOverflow',
options: {
enabled: true,
boundariesElement: 'viewport'
},
fn: function (state: MiddlewareState): MiddlewareReturn | Promise<MiddlewareReturn> {
return state;
}
}
]}
withPortal
showYearDropdown
showMonthDropdown
dropdownMode="select"
<Form.Control
required={props.required}
type="date"
name={props.name}
placeholder={props.placeholder ? props.placeholder : ''}
value={props?.value}
onChange={props.onChange}
data-testid={props.dataTestId}
/>
)
: (
+29 -9
View File
@@ -1,5 +1,4 @@
import React, { useState } from 'react';
import Button from 'react-bootstrap/Button';
import Modal from 'react-bootstrap/Modal';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
@@ -10,6 +9,8 @@ type Props = {
title: string;
markdownText: string;
onHide: () => void;
onSave?: () => Promise<boolean>;
saveButtonLabel?: string;
};
/**
@@ -73,23 +74,42 @@ const ModalMarkdown: React.FC<Props> = (props: Props): React.ReactNode => {
)}
</Modal.Body>
<Modal.Footer className="d-flex flex-wrap gap-2 justify-content-end">
<Button variant="outline-secondary" onClick={handleHide}>
<button
type="button"
onClick={handleHide}
className="home-new-item-secondary task-note-btn"
>
Close
</Button>
<Button
variant={showSource ? 'info' : 'outline-info'}
</button>
<button
type="button"
onClick={handleToggleSource}
data-testid="modal-source-button"
className={`${showSource ? 'home-new-item' : 'home-new-item-secondary'} task-note-btn`}
>
Source
</Button>
<Button
variant="outline-primary"
</button>
<button
type="button"
onClick={handleCopy}
data-testid="modal-copy-button"
className="home-new-item-secondary task-note-btn"
>
{copied ? 'Copied!' : 'Copy'}
</Button>
</button>
{props.onSave && (
<button
type="button"
onClick={async () => {
handleHide();
await props.onSave!();
}}
data-testid="modal-save-button"
className="home-new-item task-note-btn"
>
{props.saveButtonLabel ?? 'Save note'}
</button>
)}
</Modal.Footer>
</Modal>
)
+10 -1
View File
@@ -25,6 +25,7 @@ function Sidebar(props: React.PropsWithChildren<Props>): React.ReactNode {
const [lastSeen, setLastSeen] = useState('');
const { t } = useTranslation();
const build = `Build: ${env.VITE_BUILD}`;
const changeLogUrl = 'https://lightroasted.vps-kinghost.net/rmcampos/tasknote/src/branch/main/CHANGELOG.md';
// Note: when selected, change class to plus-jakarta-sans-thin and add background
@@ -119,7 +120,15 @@ function Sidebar(props: React.PropsWithChildren<Props>): React.ReactNode {
<small>{t('sidebar_last_seen', { time: lastSeen })}</small>
</div>
)}
<small data-testid="footer-text">{build}</small>
<a
data-testid="footer-text"
href={changeLogUrl}
target="_blank"
rel="noopener noreferrer"
className="footer-link"
>
<small>{build}</small>
</a>
</div>
</div>
+9
View File
@@ -96,3 +96,12 @@ a.active {
border-left: #4CD964 0.3rem solid;
background: #ced6da linear-gradient(270deg, rgba(53, 99, 233, 0.36) -416.06%, rgba(53, 99, 233, 0) 94.8%);
}
.footer-link {
color: inherit;
text-decoration: none;
}
.footer-link:hover {
text-decoration: underline;
}
+2 -2
View File
@@ -24,7 +24,7 @@ function TaskTag(props: React.PropsWithChildren<Props>): React.ReactNode {
return (
<Row>
<Col className="d-inline-block text-muted card-tag poppins-regular">
<Col className="d-inline-block card-tag poppins-regular">
{tagContent}
{' '}
{props.taskOrNote}
@@ -40,7 +40,7 @@ function TaskTag(props: React.PropsWithChildren<Props>): React.ReactNode {
</>
)}
</Col>
<Col className="d-inline-block text-muted card-tag ms-5 text-end poppins-regular">
<Col className="d-inline-block card-tag ms-5 text-end poppins-regular">
{props.lastUpdate}
</Col>
</Row>
+1
View File
@@ -1,3 +1,4 @@
.card-tag {
font-size: 13px;
color: rgba(var(--bs-body-color-rgb), 0.55);
}
+4 -4
View File
@@ -4,17 +4,17 @@ import { CalendarCheck } from 'react-bootstrap-icons';
interface Props {
text: string;
done: boolean;
completed: boolean;
tooltip: string;
}
/**
* Renders the TaskTimeLeft component if the task is not done, displaying
* Renders the TaskTimeLeft component if the task is not completed, displaying
* a calendar icon and the time left for the task.
*
* @param {Props} props - Props for the TaskTimeLeft component.
* @param {string} props.text - The time left for the task.
* @param {boolean} props.done - Boolean value indicating if the task is done.
* @param {boolean} props.completed - Boolean value indicating if the task is completed.
* @param {string} props.tooltip - The string representation of a Date instance.
* @returns
*/
@@ -27,7 +27,7 @@ function TaskTimeLeft(props: React.PropsWithChildren<Props>): React.ReactNode |
}).format(new Date(props.tooltip))
: '';
return props.done
return props.completed
? null
: (
<div className="d-block task-due-date">
+3 -3
View File
@@ -5,7 +5,7 @@ import './style.css';
interface Props {
readonly title: string;
readonly done: boolean;
readonly completed: boolean;
readonly taskUrl: string[];
}
@@ -14,14 +14,14 @@ interface Props {
*
* @param {Props} props - The props for the component.
* @param {string} [props.title] - The title for the task.
* @param {boolean} [props.done] - Define if the task is completed.
* @param {boolean} [props.completed] - Define if the task is completed.
* @returns {React.ReactNode} The rendered TaskTitle component.
*/
function TaskTitle(props: React.PropsWithChildren<Props>): React.ReactNode {
return (
<span className="task-title-icon" data-testid={`task-title-container-${props.title}`}>
<span
className={`${props.done ? 'ms-2 text-strike' : ''} poppins-semibold`}
className={`${props.completed ? 'ms-2 text-strike' : ''} poppins-semibold`}
data-testid={`task-title-text-${props.title}`}
>
{props.title}
+9
View File
@@ -76,6 +76,8 @@ const enTranslations = {
home_card_task_pending: 'Pending tasks',
home_card_task_empty: 'No pending tasks',
home_card_task_done: 'done tasks!',
home_completed_tasks_title: 'Completed tasks',
home_archived_notes_title: 'Archived notes',
home_card_task_done_empty: 'No done tasks!',
home_card_task_btn: 'Go to Tasks',
home_card_note_title: 'Notes Summary',
@@ -105,6 +107,10 @@ const enTranslations = {
task_table_action_edit: 'Edit',
task_table_action_clone: 'Clone',
task_table_action_delete: 'Delete',
delete_modal_title: 'Confirm deletion',
delete_modal_body: 'Are you sure you want to delete this item? This action cannot be undone.',
delete_modal_cancel: 'Cancel',
delete_modal_confirm: 'Delete',
note_form_title: 'Add note',
note_form_title_label: 'Title',
@@ -117,6 +123,9 @@ const enTranslations = {
note_action_share: 'Share',
note_action_unshare: 'Unshare',
note_action_copy_link: 'Copy link',
note_action_archive: 'Archive',
note_action_restore: 'Restore',
note_action_delete_permanently: 'Delete permanently',
about_page_title_one: 'About the',
about_page_title_two: 'TaskNote App',
+7 -1
View File
@@ -19,6 +19,8 @@ export const timeAgoTranslations: Record<string, string> = {
'month left_pt_br': '{X} mês restante',
'days left_pt_br': '{X} dias restantes',
'day left_pt_br': '{X} dia restante',
'due tomorrow_pt_br': 'Vence amanhã',
'due today_pt_br': 'Vence hoje',
'years ago_es': 'Hace {X} años',
'year ago_es': 'Hace {X} año',
@@ -40,6 +42,8 @@ export const timeAgoTranslations: Record<string, string> = {
'month left_es': 'Falta {X} mes',
'days left_es': 'Faltan {X} días',
'day left_es': 'Falta {X} día',
'due tomorrow_es': 'Vence mañana',
'due today_es': 'Vence hoy',
'years ago_ru': '{X} года назад',
'year ago_ru': '{X} год назад',
@@ -60,7 +64,9 @@ export const timeAgoTranslations: Record<string, string> = {
'months left_ru': 'осталось {X} месяца',
'month left_ru': 'Остался {X} месяц',
'days left_ru': 'осталось {X} дня',
'day left_ru': 'Остался {X} день'
'day left_ru': 'Остался {X} день',
'due tomorrow_ru': 'Срок завтра',
'due today_ru': 'Срок сегодня'
};
export const serverResponsesTranslations: Record<string, string> = {
+9
View File
@@ -76,6 +76,8 @@ const ptBrTranslations = {
home_card_task_pending: 'Tarefa(s) pendente(s)',
home_card_task_empty: 'Nenhuma tarefa pendente',
home_card_task_done: 'tarefa(s) concluída(s)',
home_completed_tasks_title: 'Tarefas concluídas',
home_archived_notes_title: 'Notas arquivadas',
home_card_task_done_empty: 'Nenhuma tarefa condluída',
home_card_task_btn: 'Ir para Tarefas',
home_card_note_title: 'Resumo de Notas',
@@ -105,6 +107,10 @@ const ptBrTranslations = {
task_table_action_edit: 'Alterar',
task_table_action_clone: 'Clonar',
task_table_action_delete: 'Excluir',
delete_modal_title: 'Confirmar exclusão',
delete_modal_body: 'Tem certeza de que deseja excluir este item? Esta ação não pode ser desfeita.',
delete_modal_cancel: 'Cancelar',
delete_modal_confirm: 'Excluir',
note_form_title: 'Adicionar nota',
note_form_title_label: 'Título',
@@ -117,6 +123,9 @@ const ptBrTranslations = {
note_action_share: 'Compartilhar',
note_action_unshare: 'Parar de compartilhar',
note_action_copy_link: 'Copiar link',
note_action_archive: 'Arquivar',
note_action_restore: 'Restaurar',
note_action_delete_permanently: 'Excluir permanentemente',
about_page_title_one: 'Sobre o',
about_page_title_two: 'App TaskNote',
+9
View File
@@ -76,6 +76,8 @@ const ruTranslations = {
home_card_task_pending: 'Незавершённые задачи',
home_card_task_empty: 'Нет незавершённых задач',
home_card_task_done: 'выполненные задачи!',
home_completed_tasks_title: 'Выполненные задачи',
home_archived_notes_title: 'Архивированные заметки',
home_card_task_done_empty: 'Нет выполненных задач!',
home_card_task_btn: 'Перейти к задачам',
home_card_note_title: 'Обзор заметок',
@@ -105,6 +107,10 @@ const ruTranslations = {
task_table_action_edit: 'Редактировать',
task_table_action_clone: 'Клонировать',
task_table_action_delete: 'Удалить',
delete_modal_title: 'Подтвердите удаление',
delete_modal_body: 'Вы уверены, что хотите удалить этот элемент? Это действие не может быть отменено.',
delete_modal_cancel: 'Отмена',
delete_modal_confirm: 'Удалить',
note_form_title: 'Добавить примечание',
note_form_title_label: 'Заголовок',
@@ -117,6 +123,9 @@ const ruTranslations = {
note_action_share: 'Поделиться',
note_action_unshare: 'Закрыть доступ',
note_action_copy_link: 'Копировать ссылку',
note_action_archive: 'Архивировать',
note_action_restore: 'Восстановить',
note_action_delete_permanently: 'Удалить навсегда',
about_page_title_one: 'около',
about_page_title_two: 'TaskNote App',
+9
View File
@@ -76,6 +76,8 @@ const esTranslations = {
home_card_task_pending: 'Tarea(s) pendiente(s)',
home_card_task_empty: 'No tienes tareas pendientes',
home_card_task_done: 'tarea(s) completada(s)',
home_completed_tasks_title: 'Tareas completadas',
home_archived_notes_title: 'Notas archivadas',
home_card_task_done_empty: 'No tareas completadas',
home_card_task_btn: 'Ir a Tareas',
home_card_note_title: 'Resumen de Notas',
@@ -105,6 +107,10 @@ const esTranslations = {
task_table_action_edit: 'Editar',
task_table_action_clone: 'Clonar',
task_table_action_delete: 'Eliminar',
delete_modal_title: 'Confirmar eliminación',
delete_modal_body: '¿Estás seguro de que deseas eliminar este elemento? Esta acción no se puede deshacer.',
delete_modal_cancel: 'Cancelar',
delete_modal_confirm: 'Eliminar',
note_form_title: 'Añadir nota',
note_form_title_label: 'Título',
@@ -117,6 +123,9 @@ const esTranslations = {
note_action_share: 'Compartir',
note_action_unshare: 'Dejar de compartir',
note_action_copy_link: 'Copiar enlace',
note_action_archive: 'Archivar',
note_action_restore: 'Restaurar',
note_action_delete_permanently: 'Eliminar permanentemente',
about_page_title_one: 'Acerca de',
about_page_title_two: 'TaskNote App',
+30 -3
View File
@@ -241,6 +241,27 @@ a:hover, .btn-link:hover {
background-color: #333b42;
}
.home-new-item-danger {
border: 1px solid #FB1A41;
border-radius: 3px;
padding: 8px 16px;
color: #fff;
background-color: #FB1A41;
font-weight: 500;
}
.home-new-item-danger:hover {
background-color: #d91638;
}
.home-item-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
margin-right: 0.5rem;
}
.task-note-btn {
height: 48px;
}
@@ -277,8 +298,7 @@ code {
padding: 0.375rem 0.10rem 0.375rem 0.75rem;
}
.input-group > .form-control,
.react-datepicker__input-container > .form-control {
.input-group > .form-control {
border-left: none;
}
@@ -379,7 +399,14 @@ p.search-result-item-title {
.task-card {
margin-bottom: 1rem;
padding: 0.5rem;
}
.task-completed {
opacity: 0.7;
}
.task-completed .card-title {
color: var(--bs-secondary-color);
}
.dropdown-toggle::after {
+1
View File
@@ -7,6 +7,7 @@ type NoteResponse = {
lastUpdate: string;
shared: boolean;
shareToken: string | null;
archived: boolean;
};
export type { NoteResponse };
+1 -1
View File
@@ -1,7 +1,7 @@
type TaskResponse = {
id: number;
description: string;
done: boolean;
completed: boolean;
highPriority: boolean;
dueDate: string;
dueDateFmt: string;
+8
View File
@@ -15,6 +15,14 @@ function translateTimeMessage(message: string, target: string): string {
return message;
}
if (message === 'Due tomorrow') {
return timeAgoTranslations[`due tomorrow_${target}`] ?? message;
}
if (message === 'Due today') {
return timeAgoTranslations[`due today_${target}`] ?? message;
}
const firstSpace = message.indexOf(' ');
const numberValue = message.substring(0, firstSpace);
const textValue = message.substring(firstSpace).trim();
+18 -11
View File
@@ -286,22 +286,29 @@ function Account(): React.ReactNode {
</span>
<p className="mt-4 mb-2">{t('account_privacy_text')}</p>
<Button
variant="danger"
type="button"
onClick={() => setShowAlert(true)}
className=""
>
{t('account_privacy_delete_btn')}
</Button>
<div className="d-grid">
<button
type="button"
onClick={() => setShowAlert(true)}
className="home-new-item-danger task-note-btn"
>
{t('account_privacy_delete_btn')}
</button>
</div>
{showAlert && (
<Alert className="mt-3" variant="danger" onClose={() => setShowAlert(false)} dismissible>
<Alert.Heading>{t('account_delete_title')}</Alert.Heading>
<p>{t('account_delete_description')}</p>
<Button onClick={() => deleteAccount()} variant="outline-danger">
{t('account_delete_btn')}
</Button>
<div className="d-grid">
<button
type="button"
onClick={() => deleteAccount()}
className="home-new-item-danger task-note-btn"
>
{t('account_delete_btn')}
</button>
</div>
</Alert>
)}
</Card.Body>
+384 -30
View File
@@ -2,12 +2,14 @@ import React, { useContext, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Badge,
Button,
Card,
Col,
Container,
Dropdown,
Form,
InputGroup,
Modal,
Row
} from 'react-bootstrap';
import { TaskResponse } from '../../types/TaskResponse';
@@ -20,7 +22,7 @@ import AuthContext from '../../context/AuthContext';
import FilterContext from '../../context/FilterContext';
import ContentHeader from '../../components/ContentHeader';
import AlertError from '../../components/AlertError';
import { ThreeDotsVertical } from 'react-bootstrap-icons';
import { CheckSquare, JournalText, ThreeDotsVertical } from 'react-bootstrap-icons';
import { NavLink } from 'react-router';
import ModalMarkdown from '../../components/ModalMarkdown';
import TaskTitle from '../../components/TaskTitle';
@@ -48,9 +50,13 @@ function Home(): React.ReactNode {
const [modalTitle, setModalTitle] = useState<string>('');
const [modalContent, setModalContent] = useState<string>('');
const [tasks, setTasks] = useState<TaskResponse[]>([]);
const [completedTasks, setCompletedTasks] = useState<TaskResponse[]>([]);
const [notes, setNotes] = useState<NoteResponse[]>([]);
const [archivedNotes, setArchivedNotes] = useState<NoteResponse[]>([]);
const [savedNotes, setSavedNotes] = useState<NoteResponse[]>([]);
const [savedTasks, setSavedTasks] = useState<TaskResponse[]>([]);
const [showDeleteModal, setShowDeleteModal] = useState<boolean>(false);
const [deleteTarget, setDeleteTarget] = useState<{ type: 'task' | 'note'; id: number } | null>(null);
/**
* Handles the error by setting the error message.
@@ -77,13 +83,28 @@ function Home(): React.ReactNode {
};
/**
* Mark a task as done or undone.
* Toggle a task's completed status.
*
* @param {TaskResponse} task The task to be marked as done or undone.
* @param {TaskResponse} task The task to be marked as completed or uncompleted.
*/
const markAsDone = async (task: TaskResponse): Promise<void> => {
const toggleTaskCompleted = async (task: TaskResponse): Promise<void> => {
try {
await api.deleteNoContent(`${ApiConfig.tasksUrl}/${task.id}`);
await api.patchJSON(`${ApiConfig.tasksUrl}/${task.id}`, { completed: !task.completed });
await loadAllTasks();
}
catch (e) {
handleError(e);
}
};
/**
* Delete a task.
*
* @param {number} taskIdParam The task ID to be deleted.
*/
const deleteTask = async (taskIdParam: number) => {
try {
await api.deleteNoContent(`${ApiConfig.tasksUrl}/${taskIdParam}`);
await loadAllTasks();
}
catch (e) {
@@ -106,6 +127,90 @@ function Home(): React.ReactNode {
}
};
/**
* Filter notes into active and archived sets.
*
* @param {NoteResponse[]} allNotes The full list of notes to partition.
* @returns {{ active: NoteResponse[]; archived: NoteResponse[] }} Active and archived notes.
*/
const partitionNotes = (allNotes: NoteResponse[]): { active: NoteResponse[]; archived: NoteResponse[] } => {
return allNotes.reduce<{ active: NoteResponse[]; archived: NoteResponse[] }>(
(acc, note) => {
if (note.archived) {
acc.archived.push(note);
}
else {
acc.active.push(note);
}
return acc;
},
{ active: [], archived: [] }
);
};
/**
* Opens the delete confirmation modal for a task or note.
*
* @param {object} target The target to delete with type and id.
*/
const confirmDelete = (target: { type: 'task' | 'note'; id: number }) => {
setDeleteTarget(target);
setShowDeleteModal(true);
};
/**
* Confirms and executes the delete action.
*/
const handleConfirmDelete = async () => {
if (!deleteTarget) {
return;
}
if (deleteTarget.type === 'task') {
await deleteTask(deleteTarget.id);
}
else {
await deleteNote(deleteTarget.id);
}
setShowDeleteModal(false);
setDeleteTarget(null);
};
/**
* Archive a note, moving it to the archived notes section.
*
* @param {number} noteId The note ID to be archived.
*/
const archiveNote = async (noteId: number): Promise<void> => {
try {
await api.putJSON(`${ApiConfig.notesUrl}/${noteId}/archive`, {});
await loadAllNotes();
}
catch (e) {
handleError(e);
}
};
/**
* Restore an archived note back to active notes.
*
* @param {number} noteId The note ID to be restored.
*/
const restoreNote = async (noteId: number): Promise<void> => {
try {
await api.putJSON(`${ApiConfig.notesUrl}/${noteId}/restore`, {});
await loadAllNotes();
}
catch (e) {
handleError(e);
}
};
const handleArchiveNote = (noteId: number): void => {
void archiveNote(noteId);
};
/**
* Share or unshare a note.
*
@@ -143,9 +248,15 @@ function Home(): React.ReactNode {
* @param {NoteResponse[]} allNotes - The full list of notes to filter from.
*/
const applyFilter = (text: string, radioFilter: string | undefined, allTasks: TaskResponse[], allNotes: NoteResponse[]): void => {
const activeTasks = allTasks.filter((task: TaskResponse) => !task.completed);
const doneTasks = allTasks.filter((task: TaskResponse) => task.completed);
const { active: activeNotes, archived: archivedNoteList } = partitionNotes(allNotes);
if (!text && (!radioFilter || radioFilter === 'everything')) {
setNotes([...allNotes]);
setTasks([...allTasks]);
setNotes([...activeNotes]);
setArchivedNotes([...archivedNoteList]);
setTasks([...activeTasks]);
setCompletedTasks([...doneTasks]);
return;
}
@@ -153,9 +264,10 @@ function Home(): React.ReactNode {
if (radioFilter && radioFilter === 'onlyTasks') {
setNotes([]);
setArchivedNotes([]);
}
else {
let filteredNotes = allNotes.filter((note: NoteResponse) => {
let filteredNotes = activeNotes.filter((note: NoteResponse) => {
const anyTitleMatch = note.title.toLowerCase().includes(text.toLowerCase());
const anyContentMatch = note.description.toLowerCase().includes(text.toLowerCase());
const anyUrlMatch = note.url?.includes(text.toLowerCase());
@@ -170,14 +282,31 @@ function Home(): React.ReactNode {
filteredNotes = filteredNotes.filter((note: NoteResponse) => note.tags && note.tags.includes(tagToFilter));
}
let filteredArchivedNotes = archivedNoteList.filter((note: NoteResponse) => {
const anyTitleMatch = note.title.toLowerCase().includes(text.toLowerCase());
const anyContentMatch = note.description.toLowerCase().includes(text.toLowerCase());
const anyUrlMatch = note.url?.includes(text.toLowerCase());
const anyTagMatch = note.tags?.some(tag => tag.toLowerCase().includes(text.toLowerCase()));
return anyTitleMatch || anyContentMatch || anyUrlMatch || anyTagMatch;
});
if (tagToFilter === 'untagged') {
filteredArchivedNotes = filteredArchivedNotes.filter((note: NoteResponse) => !note.tags || note.tags.length === 0);
}
else if (tagToFilter) {
filteredArchivedNotes = filteredArchivedNotes.filter((note: NoteResponse) => note.tags && note.tags.includes(tagToFilter));
}
setNotes([...filteredNotes]);
setArchivedNotes([...filteredArchivedNotes]);
}
if (radioFilter && radioFilter === 'onlyNotes') {
setTasks([]);
setCompletedTasks([]);
}
else {
let filteredTasks = allTasks.filter((task: TaskResponse) => {
let filteredTasks = activeTasks.filter((task: TaskResponse) => {
return task.description.toLowerCase().includes(text.toLowerCase())
|| task.tags?.some(tag => tag.toLowerCase().includes(text.toLowerCase()))
|| task.urls.filter((url: string) => url.includes(text.toLowerCase())).length > 0;
@@ -191,6 +320,21 @@ function Home(): React.ReactNode {
}
setTasks([...filteredTasks]);
let filteredCompletedTasks = doneTasks.filter((task: TaskResponse) => {
return task.description.toLowerCase().includes(text.toLowerCase())
|| task.tags?.some(tag => tag.toLowerCase().includes(text.toLowerCase()))
|| task.urls.filter((url: string) => url.includes(text.toLowerCase())).length > 0;
});
if (tagToFilter === 'untagged') {
filteredCompletedTasks = filteredCompletedTasks.filter((task: TaskResponse) => !task.tags || task.tags.length === 0);
}
else if (tagToFilter) {
filteredCompletedTasks = filteredCompletedTasks.filter((task: TaskResponse) => task.tags && task.tags.includes(tagToFilter));
}
setCompletedTasks([...filteredCompletedTasks]);
}
};
@@ -210,10 +354,16 @@ function Home(): React.ReactNode {
const tasksFetched: TaskResponse[] = await api.getJSON(ApiConfig.tasksUrl);
const translated = translateTaskResponse(tasksFetched, i18n.language);
translated.sort((t1, t2) => {
if (t1.highPriority === t2.highPriority) {
return 0;
if (t1.completed === t2.completed) {
if (t1.highPriority === t2.highPriority) {
return 0;
}
if (t1.highPriority) {
return -1;
}
return 1;
}
if (t1.highPriority) {
if (t1.completed) {
return -1;
}
return 1;
@@ -403,7 +553,9 @@ function Home(): React.ReactNode {
}}
/>
<Dropdown onSelect={eventKey => eventKey && handleOptionChange(eventKey)}>
<Dropdown
onSelect={eventKey => eventKey && handleOptionChange(eventKey)}
>
<Dropdown.Toggle
variant="success"
id="filter-dropdown"
@@ -426,7 +578,10 @@ function Home(): React.ReactNode {
</Badge>
</Dropdown.Toggle>
<Dropdown.Menu className="shadow-lg border-0" style={{ minWidth: '200px' }}>
<Dropdown.Menu
className="shadow-lg border-0"
style={{ minWidth: '200px' }}
>
<Dropdown.Header className="text-muted small">
<i className="bi bi-funnel me-2"></i>
Filter Options
@@ -493,25 +648,34 @@ function Home(): React.ReactNode {
<Row className="mt-3">
{tasks.map((task: TaskResponse) => (
<Col xs={12} key={task.id.toString()}>
<Card key={task.id.toString()} className={`task-card ${task.highPriority ? 'high-importance' : ''}`}>
<Card
key={task.id.toString()}
className={`task-card ${task.highPriority ? 'high-importance' : ''}`}
>
<Card.Body>
<Row>
<Col xs={10}>
<Card.Title>
<span className="home-item-icon">
<CheckSquare />
</span>
<TaskTitle
title={task.description}
done={task.done}
completed={task.completed}
taskUrl={task.urls}
/>
</Card.Title>
</Col>
<Col xs={2} className="text-end">
<Dropdown>
<Dropdown.Toggle variant="success" data-testid={`task-dropdown-menu-${task.id}`}>
<Dropdown.Toggle
variant="success"
data-testid={`task-dropdown-menu-${task.id}`}
>
<ThreeDotsVertical />
</Dropdown.Toggle>
<Dropdown.Menu>
{!task.done && (
{!task.completed && (
<NavLink to={`/tasks/edit/${task.id}`}>
<Dropdown.Item as="span">
{t('task_table_action_edit')}
@@ -520,10 +684,20 @@ function Home(): React.ReactNode {
)}
<Dropdown.Item
as="button"
onClick={() => markAsDone(task)}
onClick={() => toggleTaskCompleted(task)}
data-testid={`task-dropdown-done-item-${task.id}`}
>
{task.done ? t('task_table_action_undone') : t('task_table_action_done')}
{task.completed
? t('task_table_action_undone')
: t('task_table_action_done')}
</Dropdown.Item>
<Dropdown.Item
as="button"
onClick={() =>
confirmDelete({ type: 'task', id: task.id })}
data-testid={`task-dropdown-delete-item-${task.id}`}
>
{t('task_table_action_delete')}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
@@ -533,7 +707,7 @@ function Home(): React.ReactNode {
{task.dueDateFmt && (
<TaskTimeLeft
text={task.dueDateFmt}
done={task.done}
completed={task.completed}
tooltip={task.dueDate}
/>
)}
@@ -558,15 +732,18 @@ function Home(): React.ReactNode {
<Row>
<Col xs={10}>
<Card.Title>
<NoteTitle
title={note.title}
noteUrl={note.url}
/>
<span className="home-item-icon">
<JournalText />
</span>
<NoteTitle title={note.title} noteUrl={note.url} />
</Card.Title>
</Col>
<Col xs={2} className="text-end">
<Dropdown>
<Dropdown.Toggle variant="success" data-testid={`note-dropdown-menu-${note.id}`}>
<Dropdown.Toggle
variant="success"
data-testid={`note-dropdown-menu-${note.id}`}
>
<ThreeDotsVertical />
</Dropdown.Toggle>
<Dropdown.Menu>
@@ -585,7 +762,9 @@ function Home(): React.ReactNode {
onClick={() => toggleShareNote(note)}
data-testid={`note-dropdown-share-item-${note.id}`}
>
{note.shared ? t('note_action_unshare') : t('note_action_share')}
{note.shared
? t('note_action_unshare')
: t('note_action_share')}
</Dropdown.Item>
{note.shared && note.shareToken && (
<Dropdown.Item
@@ -598,10 +777,10 @@ function Home(): React.ReactNode {
)}
<Dropdown.Item
as="button"
onClick={() => deleteNote(note.id)}
data-testid={`note-dropdown-delete-item-${note.id}`}
onClick={() => handleArchiveNote(note.id)}
data-testid={`note-dropdown-archive-item-${note.id}`}
>
{t('task_table_action_delete')}
{t('note_action_archive')}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
@@ -632,12 +811,187 @@ function Home(): React.ReactNode {
))}
</Row>
{completedTasks.length > 0 && (
<Row className="mt-4">
<Col xs={12}>
<h5 className="text-muted">{t('home_completed_tasks_title')}</h5>
</Col>
{completedTasks.map((task: TaskResponse) => (
<Col xs={12} key={`completed-${task.id.toString()}`}>
<Card
className={`task-card task-completed ${task.highPriority ? 'high-importance' : ''}`}
>
<Card.Body>
<Row>
<Col xs={10}>
<Card.Title>
<span className="home-item-icon">
<CheckSquare />
</span>
<TaskTitle
title={task.description}
completed={task.completed}
taskUrl={task.urls}
/>
</Card.Title>
</Col>
<Col xs={2} className="text-end">
<Dropdown>
<Dropdown.Toggle
variant="success"
data-testid={`completed-task-dropdown-menu-${task.id}`}
>
<ThreeDotsVertical />
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item
as="button"
onClick={() => toggleTaskCompleted(task)}
data-testid={`completed-task-dropdown-undone-item-${task.id}`}
>
{t('task_table_action_undone')}
</Dropdown.Item>
<Dropdown.Item
as="button"
onClick={() =>
confirmDelete({ type: 'task', id: task.id })}
data-testid={`completed-task-dropdown-delete-item-${task.id}`}
>
{t('task_table_action_delete')}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</Col>
</Row>
</Card.Body>
<Card.Footer className="task-card-footer">
<TaskTag
tags={task.tags}
lastUpdate={task.lastUpdate}
taskOrNote="task"
/>
</Card.Footer>
</Card>
</Col>
))}
</Row>
)}
{archivedNotes.length > 0 && (
<Row className="mt-4">
<Col xs={12}>
<h5 className="text-muted">{t('home_archived_notes_title')}</h5>
</Col>
{archivedNotes.map((note: NoteResponse) => (
<Col xs={12} key={`archived-${note.id.toString()}`}>
<Card className="task-card task-completed mb-3">
<Card.Body>
<Row>
<Col xs={10}>
<Card.Title>
<span className="home-item-icon">
<JournalText />
</span>
<NoteTitle title={note.title} noteUrl={note.url} />
</Card.Title>
</Col>
<Col xs={2} className="text-end">
<Dropdown>
<Dropdown.Toggle
variant="success"
data-testid={`archived-note-dropdown-menu-${note.id}`}
>
<ThreeDotsVertical />
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item
as="button"
onClick={() => restoreNote(note.id)}
data-testid={`archived-note-dropdown-restore-item-${note.id}`}
>
{t('note_action_restore')}
</Dropdown.Item>
<Dropdown.Item
as="button"
onClick={() =>
confirmDelete({ type: 'note', id: note.id })}
data-testid={`archived-note-dropdown-delete-item-${note.id}`}
>
{t('note_action_delete_permanently')}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</Col>
</Row>
<span className="text-muted span-line-break font-size-14">
{getFirstRows(note.description)}
</span>
</Card.Body>
<Card.Footer className="task-card-footer">
<TaskTag
tags={note.tags}
lastUpdate={note.lastUpdate}
taskOrNote="note"
onClick={(e: React.MouseEvent<Element, MouseEvent>) => {
e.preventDefault();
e.stopPropagation();
setModalTitle(note.title);
setModalContent(note.description);
setShowMarkdownView(true);
localStorage.setItem(
OPEN_NOTE_ID_KEY,
note.id.toString()
);
}}
/>
</Card.Footer>
</Card>
</Col>
))}
</Row>
)}
<ModalMarkdown
show={showMarkdownView}
onHide={handleCloseModal}
title={modalTitle}
markdownText={modalContent}
/>
<Modal
show={showDeleteModal}
onHide={() => setShowDeleteModal(false)}
centered
backdrop="static"
>
<Modal.Header closeButton className="bg-danger-subtle">
<Modal.Title className="d-flex align-items-center gap-2">
<i className="bi bi-exclamation-triangle-fill text-danger"></i>
{t('delete_modal_title')}
</Modal.Title>
</Modal.Header>
<Modal.Body>{t('delete_modal_body')}</Modal.Body>
<Modal.Footer className="d-flex flex-wrap gap-2 justify-content-end">
<Button
variant="outline-secondary"
onClick={() => {
setShowDeleteModal(false);
}}
className="task-note-btn"
>
{t('delete_modal_cancel')}
</Button>
<Button
variant="danger"
onClick={handleConfirmDelete}
className="task-note-btn"
data-testid="confirm-delete-button"
>
{t('delete_modal_confirm')}
</Button>
</Modal.Footer>
</Modal>
</Container>
);
}
+2 -1
View File
@@ -4,8 +4,9 @@
position: relative;
text-align: center;
color: $dark-text;
background: var(--bs-landing-bg) no-repeat center center;
background: var(--bs-landing-bg) no-repeat center center fixed;
background-size: cover; // Ensures the image covers the whole background
min-height: 100vh;
&::before {
content: '';
+2 -1
View File
@@ -1,11 +1,12 @@
@import '../../styles/theme.scss';
.login-page {
position: relative;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: var(--bs-landing-bg) no-repeat center center;
background: var(--bs-landing-bg) no-repeat center center fixed;
background-size: cover;
&::before {
+97 -85
View File
@@ -62,7 +62,7 @@ function NoteAdd(): React.ReactNode {
const loadTags = async (): Promise<void> => {
try {
const response: string[] = await api.getJSON(`${ApiConfig.homeUrl}/tasks/tags`);
setTags(response);
setTags(response.filter(tag => tag !== 'untagged'));
}
catch (e) {
handleError(e);
@@ -201,6 +201,52 @@ function NoteAdd(): React.ReactNode {
saveDraft(noteTitle, noteContent, noteUrl, newTags);
};
/**
* Saves the note, either adding a new one or editing an existing one.
*
* @returns {Promise<boolean>} True if the note was saved successfully, false otherwise.
*/
const saveNote = async (): Promise<boolean> => {
setValidated(true);
if (!noteTitle.trim() || !noteContent.trim()) {
setErrorMessage(translateServerResponse('Please fill in all the fields', i18n.language));
return false;
}
const finalTags = [...selectedTags];
if (currentTag.trim()) {
const normalized = currentTag.trim().toLowerCase();
if (!finalTags.includes(normalized)) {
finalTags.push(normalized);
}
}
const payload: NoteResponse = {
id: action === 'edit' ? noteId : 0,
title: noteTitle,
description: noteContent,
url: noteUrl,
tags: finalTags,
lastUpdate: '',
shared: false,
shareToken: null,
archived: false
};
const saved = action === 'add'
? await addNote(payload)
: await submitEditNote(payload);
if (saved) {
clearDraft();
resetInputs();
navigate('/home');
}
return saved;
};
/**
* Handles the form submission.
*
@@ -217,54 +263,7 @@ function NoteAdd(): React.ReactNode {
return;
}
const finalTags = [...selectedTags];
if (currentTag.trim()) {
const normalized = currentTag.trim().toLowerCase();
if (!finalTags.includes(normalized)) {
finalTags.push(normalized);
}
}
if (action === 'add') {
const payload: NoteResponse = {
id: 0,
title: noteTitle,
description: noteContent,
url: noteUrl,
tags: finalTags,
lastUpdate: '',
shared: false,
shareToken: null
};
const added: boolean = await addNote(payload);
if (added) {
clearDraft();
form.reset();
resetInputs();
navigate('/home');
}
}
else if (action === 'edit') {
const payload: NoteResponse = {
id: noteId,
title: noteTitle,
description: noteContent,
url: noteUrl,
tags: finalTags,
lastUpdate: '',
shared: false,
shareToken: null
};
const edited: boolean = await submitEditNote(payload);
if (edited) {
clearDraft();
form.reset();
resetInputs();
navigate('/home');
}
}
await saveNote();
};
/**
@@ -401,24 +400,25 @@ function NoteAdd(): React.ReactNode {
onSubmit={handleSubmit}
autoComplete="off"
>
{/* Note title */}
<FormInput
labelText={t('note_form_title_label')}
iconName="JournalCheck"
required={true}
type="text"
name="note_title"
placeholder={t('note_form_title_placeholder')}
value={noteTitle}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setNoteTitle(e.target.value);
hasUserEdited.current = true;
saveDraft(e.target.value, noteContent, noteUrl, selectedTags);
}}
/>
<Row>
<Col xs={12} xl={9}>
<Col xs={12} md={6} xxl={6}>
{/* Note title */}
<FormInput
labelText={t('note_form_title_label')}
iconName="JournalCheck"
required={true}
type="text"
name="note_title"
placeholder={t('note_form_title_placeholder')}
value={noteTitle}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setNoteTitle(e.target.value);
hasUserEdited.current = true;
saveDraft(e.target.value, noteContent, noteUrl, selectedTags);
}}
/>
</Col>
<Col xs={12} md={6} xxl={6}>
{/* Note URL */}
<FormInput
labelText={t('task_form_url_label')}
@@ -435,11 +435,13 @@ function NoteAdd(): React.ReactNode {
}}
/>
</Col>
<Col xs={12} xl={3}>
</Row>
<Row>
<Col xs={12}>
{/* Tag with suggestion dropdown */}
<Form.Group className="mb-3" ref={tagContainerRef} style={{ position: 'relative' }}>
<Form.Label>Tags</Form.Label>
<InputGroup className="mb-3">
<InputGroup>
<InputGroup.Text>
<Hash />
</InputGroup.Text>
@@ -462,13 +464,16 @@ function NoteAdd(): React.ReactNode {
autoComplete="off"
/>
</InputGroup>
<Form.Text className="text-muted">
Type a tag and press Enter
</Form.Text>
<div className="mb-2 d-flex flex-wrap gap-1">
{selectedTags.map(t => (
<Badge
key={t}
bg="warning"
text="dark"
className="p-2"
className="p-2 mt-3"
style={{ cursor: 'pointer' }}
onClick={() => removeTag(t)}
>
@@ -496,11 +501,14 @@ function NoteAdd(): React.ReactNode {
<ListGroup.Item
key={t}
action
variant="warning"
className="d-flex align-items-center gap-2"
onMouseDown={(e: React.MouseEvent) => {
e.preventDefault();
addTag(t);
}}
>
<i className="bi bi-tag"></i>
#
{t}
</ListGroup.Item>
@@ -540,23 +548,25 @@ function NoteAdd(): React.ReactNode {
/>
</Form.Group>
<button
type="submit"
className="home-new-item task-note-btn mt-3"
>
{t('note_form_submit')}
</button>
<div className="d-flex justify-content-end gap-2 mt-3">
<button
type="submit"
className="home-new-item task-note-btn"
>
{t('note_form_submit')}
</button>
<button
type="button"
className="ms-2 home-new-item-secondary task-note-btn"
onClick={() => {
clearDraft();
navigate('/home');
}}
>
Cancel
</button>
<button
type="button"
className="home-new-item-secondary task-note-btn"
onClick={() => {
clearDraft();
navigate('/home');
}}
>
Cancel
</button>
</div>
</Form>
</Card.Body>
@@ -569,6 +579,8 @@ function NoteAdd(): React.ReactNode {
onHide={handleCloseModal}
title={noteTitle}
markdownText={noteContent}
onSave={saveNote}
saveButtonLabel={t('note_form_submit')}
/>
</Container>
);
+77 -69
View File
@@ -43,9 +43,9 @@ function TaskAdd(): React.ReactNode {
const [taskId, setTaskId] = useState<number>(0);
const [taskDescription, setTaskDescription] = useState<string>('');
const [taskUrl, setTaskUrl] = useState<string>('');
const [taskDone, setTaskDone] = useState<boolean>(false);
const [taskCompleted, setTaskCompleted] = useState<boolean>(false);
const [action, setAction] = useState<TaskAction>('add');
const [dueDate, setDueDate] = useState<Date | null>(null);
const [dueDate, setDueDate] = useState<string>('');
const [highPriority, setHighPriority] = useState<boolean>(false);
const [currentTag, setCurrentTag] = useState<string>('');
const [selectedTags, setSelectedTags] = useState<string[]>([]);
@@ -64,7 +64,7 @@ function TaskAdd(): React.ReactNode {
const loadTags = async (): Promise<void> => {
try {
const response: string[] = await api.getJSON(`${ApiConfig.homeUrl}/tasks/tags`);
setTags(response);
setTags(response.filter(tag => tag !== 'untagged'));
}
catch (e) {
handleError(e);
@@ -125,9 +125,9 @@ function TaskAdd(): React.ReactNode {
const resetInputs = (): void => {
setTaskId(0);
setTaskDescription('');
setTaskDone(false);
setTaskCompleted(false);
setTaskUrl('');
setDueDate(null);
setDueDate('');
setHighPriority(false);
setCurrentTag('');
setSelectedTags([]);
@@ -138,7 +138,7 @@ function TaskAdd(): React.ReactNode {
const saveDraft = (
description: string,
taskUrl: string,
due: Date | null,
due: string,
priority: boolean,
draftTags: string[]
): void => {
@@ -148,7 +148,7 @@ function TaskAdd(): React.ReactNode {
const draft: TaskDraft = {
description,
taskUrl,
dueDate: due ? due.toISOString() : null,
dueDate: due || null,
highPriority: priority,
tags: draftTags
};
@@ -168,8 +168,8 @@ function TaskAdd(): React.ReactNode {
const draft: TaskDraft = JSON.parse(raw);
setTaskDescription(draft.description ?? '');
setTaskUrl(draft.taskUrl ?? '');
const parsedDate = draft.dueDate ? new Date(draft.dueDate) : null;
setDueDate(parsedDate && !isNaN(parsedDate.getTime()) ? parsedDate : null);
const parsedDate = draft.dueDate ? draft.dueDate : '';
setDueDate(parsedDate);
setHighPriority(draft.highPriority ?? false);
setSelectedTags(draft.tags ?? []);
setDraftBanner(true);
@@ -183,9 +183,9 @@ function TaskAdd(): React.ReactNode {
setTaskId(task.id);
setTaskDescription(task.description);
setTaskUrl(task.urls.length ? task.urls[0] : '');
setTaskDone(task.done);
setTaskCompleted(task.completed);
if (task.dueDateFmt) {
setDueDate(new Date(task.dueDate));
setDueDate(task.dueDate);
}
setHighPriority(task.highPriority);
if (task.tags) {
@@ -249,10 +249,7 @@ function TaskAdd(): React.ReactNode {
return;
}
let dueDateFormatted: string = '';
if (dueDate) {
dueDateFormatted = dueDate.toISOString().substring(0, 10);
}
const dueDateFormatted: string = dueDate;
const finalTags = [...selectedTags];
if (currentTag.trim()) {
@@ -283,7 +280,7 @@ function TaskAdd(): React.ReactNode {
const editPayload: TaskResponse = {
id: taskId,
description: taskDescription.trim(),
done: taskDone,
completed: taskCompleted,
highPriority: highPriority,
dueDate: dueDateFormatted,
dueDateFmt: '',
@@ -384,24 +381,41 @@ function TaskAdd(): React.ReactNode {
onSubmit={handleSubmit}
autoComplete="off"
>
{/* Description */}
<FormInput
labelText={t('task_form_desc_label')}
iconName="PencilFill"
required={true}
type="text"
name="description"
placeholder={t('task_form_desc_placeholder')}
value={taskDescription}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setTaskDescription(e.target.value);
hasUserEdited.current = true;
saveDraft(e.target.value, taskUrl, dueDate, highPriority, selectedTags);
}}
/>
<Row>
<Col xs={12} sm={12} xxl={6}>
<Col xs={12} md={6} xxl={6}>
{/* Description */}
<FormInput
labelText={t('task_form_desc_label')}
iconName="PencilFill"
required={true}
type="text"
name="description"
placeholder={t('task_form_desc_placeholder')}
value={taskDescription}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setTaskDescription(e.target.value);
hasUserEdited.current = true;
saveDraft(e.target.value, taskUrl, dueDate, highPriority, selectedTags);
}}
/>
</Col>
<Col xs={12} sm={6} md={3} xxl={3}>
{/* Due date */}
<FormInput
labelText={t('task_form_duedate_label')}
iconName="CalendarCheck"
required={false}
type="date"
name="dueDate"
placeholder={t('task_form_duedate_placeholder')}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setDueDate(e.target.value);
hasUserEdited.current = true;
saveDraft(taskDescription, taskUrl, e.target.value, highPriority, selectedTags);
}}
/>
</Col>
<Col xs={12} sm={6} md={3} xxl={3}>
{/* Task URL */}
<FormInput
labelText={t('task_form_url_label')}
@@ -418,28 +432,14 @@ function TaskAdd(): React.ReactNode {
}}
/>
</Col>
<Col xs={12} sm={6} xxl={6}>
{/* Due date */}
<FormInput
labelText={t('task_form_duedate_label')}
iconName="CalendarCheck"
required={false}
type="date"
name="dueDate"
placeholder={t('task_form_duedate_placeholder')}
valueDate={dueDate}
onChangeDate={(date: Date | null) => {
setDueDate(date);
hasUserEdited.current = true;
saveDraft(taskDescription, taskUrl, date, highPriority, selectedTags);
}}
/>
</Col>
<Col xs={12} sm={12} xxl={12}>
</Row>
<Row>
<Col xs={12}>
{/* Tag with suggestion dropdown */}
<Form.Group className="mb-3" ref={tagContainerRef} style={{ position: 'relative' }}>
<Form.Label>Tags</Form.Label>
<InputGroup className="mb-3">
<InputGroup>
<InputGroup.Text>
<Hash />
</InputGroup.Text>
@@ -462,13 +462,16 @@ function TaskAdd(): React.ReactNode {
autoComplete="off"
/>
</InputGroup>
<Form.Text className="text-muted mb-3">
Type a tag and press Enter
</Form.Text>
<div className="mb-2 d-flex flex-wrap gap-1">
{selectedTags.map(t => (
<Badge
key={t}
bg="warning"
text="dark"
className="p-2"
className="p-2 mt-3"
style={{ cursor: 'pointer' }}
onClick={() => removeTag(t)}
>
@@ -496,11 +499,14 @@ function TaskAdd(): React.ReactNode {
<ListGroup.Item
key={t}
action
variant="warning"
className="d-flex align-items-center gap-2"
onMouseDown={(e: React.MouseEvent) => {
e.preventDefault();
addTag(t);
}}
>
<i className="bi bi-tag"></i>
#
{t}
</ListGroup.Item>
@@ -525,23 +531,25 @@ function TaskAdd(): React.ReactNode {
}}
/>
<button
type="submit"
className="home-new-item task-note-btn"
>
{t('task_form_submit')}
</button>
<div className="d-flex justify-content-end gap-2">
<button
type="submit"
className="home-new-item task-note-btn"
>
{t('task_form_submit')}
</button>
<button
type="button"
className="ms-2 home-new-item-secondary task-note-btn"
onClick={() => {
clearDraft();
navigate('/home');
}}
>
Cancel
</button>
<button
type="button"
className="home-new-item-secondary task-note-btn"
onClick={() => {
clearDraft();
navigate('/home');
}}
>
Cancel
</button>
</div>
</Form>
</Card.Body>
</Card>
+13 -1
View File
@@ -3,6 +3,16 @@ import react from '@vitejs/plugin-react';
import { fileURLToPath } from 'url';
import path from 'path';
const proxyConfig = process.env.NGROK
? {
'/api': {
target: `http://${process.env.BACKEND_HOST || 'localhost'}:8585`,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
}
: undefined;
export default defineConfig(({ mode }: ConfigEnv) => {
const config: UserConfig = {
define: {},
@@ -32,7 +42,9 @@ export default defineConfig(({ mode }: ConfigEnv) => {
sourcemap: mode === 'development'
},
server: {
port: 5000
port: 5000,
...(process.env.NGROK ? { allowedHosts: ['.ngrok-free.dev'] } : {}),
proxy: proxyConfig,
},
preview: {
port: 5000
-23
View File
@@ -1,23 +0,0 @@
#!/bin/bash
#
PR="$1"
echo "PR: $PR"
echo "Getting env vars..."
export $(cat .env | xargs)
docker run -d --rm \
--name server \
--network=host \
-e POSTGRES_DB=$POSTGRES_DB \
-e POSTGRES_USER=$POSTGRES_USER \
-e POSTGRES_PASSWORD=$POSTGRES_PASSWORD \
-e POSTGRES_PORT=$POSTGRES_PORT \
-e POSTGRES_HOST=$POSTGRES_HOST \
-e CORS_ALLOWED_ORIGINS=$CORS_ALLOWED_ORIGINS \
-e SERVER_SERVLET_CONTEXT_PATH=$SERVER_SERVLET_CONTEXT_PATH \
-e MAILGUN_APIKEY=$MAILGUN_APIKEY \
-e SECURITY_KEY=$SECURITY_KEY \
-e BUILD=ghcr.io/ricardo-campos-org/react-typescript-todolist/server:$PR \
ghcr.io/ricardo-campos-org/react-typescript-todolist/server:$PR
+3 -2
View File
@@ -7,10 +7,12 @@ services:
user: ${UID:-1000}:${GID:-1000}
ports:
- "5000:5000"
entrypoint: sh -c "npm i --no-update-notifier && npm start"
entrypoint: sh -c "npm i --no-update-notifier && export NGROK=1 && npm start"
environment:
VITE_BACKEND_SERVER: "/api"
VITE_BUILD: nightly
NGROK: 1
BACKEND_HOST: tasknote-api
volumes:
- "./client:/app"
working_dir: /app
@@ -91,4 +93,3 @@ services:
networks:
tasknote:
external: true
+2 -2
View File
@@ -6,7 +6,7 @@ services:
depends_on:
tasknote-api:
condition: service_healthy
image: ghcr.io/rmcampos/tasknote/app:latest
image: rmcampos/tasknote-app:latest
build:
context: ./client
dockerfile: Dockerfile
@@ -31,7 +31,7 @@ services:
SECURITY_KEY: ${SECURITY_KEY}
MAILGUN_APIKEY: ${MAILGUN_APIKEY}
ports: ["8585:8585"]
image: ghcr.io/rmcampos/tasknote/api:latest
image: rmcampos/tasknote-api:latest
healthcheck:
test: ["CMD", "/layers/local_healthcheck/healthcheck/bin/healthcheck"]
interval: 10s
+3
View File
@@ -0,0 +1,3 @@
setup:
project: tasknote
config: prd
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
CONTAINER="tasknote-db"
SQL_USER="tasknoteuser"
SQL_DATABASE="tasknote"
SQL_SCHEMA="tasknote"
SQL_TABLE_NAME="users"
SQL_QUERY="UPDATE $SQL_SCHEMA.$SQL_TABLE_NAME SET email_confirmed_at = created_at WHERE id = 1;"
docker exec -it $CONTAINER \
psql \
-U $SQL_USER \
-d $SQL_DATABASE \
-c "$SQL_QUERY"
-6
View File
@@ -1,6 +0,0 @@
{
"name": "react-typescript-todolist",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
+2 -2
View File
@@ -11,7 +11,7 @@
<groupId>br.com.tasknoteapp</groupId>
<artifactId>server</artifactId>
<version>34</version>
<version>35</version>
<name>tasknote-api</name>
<description>Java backend REST API to serve TaskNote frontend client</description>
@@ -337,7 +337,7 @@
<dependency>
<groupId>com.puppycrawl.tools</groupId>
<artifactId>checkstyle</artifactId>
<version>13.3.0</version>
<version>13.7.0</version>
</dependency>
</dependencies>
<configuration>
@@ -115,4 +115,28 @@ public class NoteController {
public ResponseEntity<NoteResponse> unshareNote(@PathVariable Long id) {
return ResponseEntity.ok(noteService.unshareNote(id));
}
/**
* Archive a note, disabling edits and revoking public sharing.
*
* @param id Note identification.
* @return NoteResponse with the archived note.
* @throws NoteNotFoundException when note not found.
*/
@PutMapping("/{id}/archive")
public ResponseEntity<NoteResponse> archiveNote(@PathVariable Long id) {
return ResponseEntity.ok(noteService.archiveNote(id));
}
/**
* Restore an archived note back to active state.
*
* @param id Note identification.
* @return NoteResponse with the restored note.
* @throws NoteNotFoundException when note not found.
*/
@PutMapping("/{id}/restore")
public ResponseEntity<NoteResponse> restoreNote(@PathVariable Long id) {
return ResponseEntity.ok(noteService.restoreNote(id));
}
}
@@ -49,6 +49,9 @@ public class NoteEntity {
@Column(name = "share_token", length = 36)
private String shareToken;
@Column(name = "archived", nullable = false)
private boolean archived = false;
public Long getId() {
return id;
}
@@ -113,6 +116,14 @@ public class NoteEntity {
this.shareToken = shareToken;
}
public boolean isArchived() {
return archived;
}
public void setArchived(boolean archived) {
this.archived = archived;
}
@Override
public boolean equals(Object o) {
if (this == o) {
@@ -145,6 +156,8 @@ public class NoteEntity {
+ tags
+ ", lastUpdate="
+ lastUpdate
+ ", archived="
+ archived
+ '}';
}
}
@@ -28,7 +28,7 @@ public class TaskEntity {
@Column(length = 2000)
private String description;
private Boolean done;
private Boolean completed;
@JoinColumn(name = "user_id", referencedColumnName = "id", nullable = false, updatable = false)
@ManyToOne(fetch = FetchType.LAZY)
@@ -66,12 +66,12 @@ public class TaskEntity {
this.description = description;
}
public Boolean getDone() {
return done;
public Boolean getCompleted() {
return completed;
}
public void setDone(Boolean done) {
this.done = done;
public void setCompleted(Boolean completed) {
this.completed = completed;
}
public UserEntity getUser() {
@@ -139,8 +139,8 @@ public class TaskEntity {
+ ", description='"
+ description
+ '\''
+ ", done="
+ done
+ ", completed="
+ completed
+ ", lastUpdate="
+ lastUpdate
+ ", dueDate="
@@ -0,0 +1,9 @@
package br.com.tasknoteapp.server.exception;
/** This class represents a conflict when an note is archived and cannot be modified. */
public class NoteArchivedException extends BaseBadRequestException {
public NoteArchivedException() {
super("note", "Note is archived");
}
}
@@ -24,7 +24,7 @@ public interface TaskRepository extends JpaRepository<TaskEntity, Long> {
upper(t.description) like upper(concat('%', :searchTerm, '%')) or
upper(tg.name) like upper(concat('%', :searchTerm, '%')) or
upper(tu.id.url) like upper(concat('%', :searchTerm, '%'))
) and t.user.id = :userId and t.done = false
) and t.user.id = :userId and t.completed = false
""")
List<TaskEntity> findAllBySearchTerm(
@Param("searchTerm") String searchTerm, @Param("userId") Long userId);
@@ -6,8 +6,8 @@ import java.util.List;
/** This record represents a task patch payload. */
public record TaskPatchRequest(
Boolean completed,
@Size(max = 2000) String description,
Boolean done,
List<
@Size(max = 200)
@Pattern(
@@ -14,7 +14,8 @@ public record NoteResponse(
String lastUpdate,
List<String> tags,
boolean shared,
String shareToken) {
String shareToken,
boolean archived) {
/**
* Creates a NoteResponse given a NoteEntity and its Urals.
@@ -34,6 +35,7 @@ public record NoteResponse(
timeAgoFmt,
entity.getTags().stream().map(TagEntity::getName).toList(),
entity.isShared(),
entity.getShareToken());
entity.getShareToken(),
entity.isArchived());
}
}
@@ -9,8 +9,8 @@ import java.util.List;
/** This record represents a task and its urls object to be returned. */
public record TaskResponse(
Long id,
Boolean completed,
String description,
Boolean done,
Boolean highPriority,
LocalDate dueDate,
String dueDateFmt,
@@ -31,8 +31,8 @@ public record TaskResponse(
return new TaskResponse(
entity.getId(),
entity.getCompleted(),
entity.getDescription(),
entity.getDone(),
entity.getHighPriority(),
entity.getDueDate(),
dueDateFmt,
@@ -4,6 +4,7 @@ import br.com.tasknoteapp.server.entity.NoteEntity;
import br.com.tasknoteapp.server.entity.NoteUrlEntity;
import br.com.tasknoteapp.server.entity.TagEntity;
import br.com.tasknoteapp.server.entity.UserEntity;
import br.com.tasknoteapp.server.exception.NoteArchivedException;
import br.com.tasknoteapp.server.exception.NoteNotFoundException;
import br.com.tasknoteapp.server.repository.NoteRepository;
import br.com.tasknoteapp.server.repository.NoteUrlRepository;
@@ -160,6 +161,10 @@ public class NoteService {
}
NoteEntity noteEntity = note.get();
if (noteEntity.isArchived()) {
throw new NoteArchivedException();
}
if (!Objects.isNull(patch.title()) && !patch.title().isBlank()) {
noteEntity.setTitle(patch.title().trim());
}
@@ -208,6 +213,9 @@ public class NoteService {
}
NoteEntity noteEntity = note.get();
if (!noteEntity.isArchived()) {
throw new NoteArchivedException();
}
noteUrlRepository.deleteByNote_id(noteId);
logger.info("URL deleted from note ID {}", noteId);
@@ -255,6 +263,10 @@ public class NoteService {
NoteEntity noteEntity = noteOpt.get();
if (noteEntity.isArchived()) {
throw new NoteArchivedException();
}
if (!noteEntity.isShared()) {
noteEntity.setShared(true);
noteEntity.setShareToken(UUID.randomUUID().toString());
@@ -282,6 +294,11 @@ public class NoteService {
}
NoteEntity noteEntity = noteOpt.get();
if (noteEntity.isArchived()) {
throw new NoteArchivedException();
}
noteEntity.setShared(false);
noteEntity.setShareToken(null);
noteRepository.save(noteEntity);
@@ -301,7 +318,7 @@ public class NoteService {
logger.info("Fetching shared note with token {}", shareToken);
Optional<NoteEntity> noteOpt = noteRepository.findByShareToken(shareToken);
if (noteOpt.isEmpty() || !noteOpt.get().isShared()) {
if (noteOpt.isEmpty() || !noteOpt.get().isShared() || noteOpt.get().isArchived()) {
throw new NoteNotFoundException();
}
@@ -352,6 +369,66 @@ public class NoteService {
return notes.stream().map(n -> NoteResponse.fromEntity(n, noteUrls.get(n.getId()))).toList();
}
/**
* Archive a note, disabling edits and revoking public sharing.
*
* @param noteId The note id from the database.
* @return {@link NoteResponse} containing the archived note.
*/
@Transactional
public NoteResponse archiveNote(Long noteId) {
UserEntity user = getCurrentUser();
logger.info("Archiving note ID {} for user ID {}", noteId, user.getId());
Optional<NoteEntity> noteOpt = noteRepository.findByIdAndUser_id(noteId, user.getId());
if (noteOpt.isEmpty()) {
throw new NoteNotFoundException();
}
NoteEntity noteEntity = noteOpt.get();
if (noteEntity.isArchived()) {
throw new NoteArchivedException();
}
noteEntity.setArchived(true);
noteEntity.setShared(false);
noteEntity.setShareToken(null);
noteEntity.setLastUpdate(LocalDateTime.now());
noteRepository.save(noteEntity);
logger.info("Note ID {} archived", noteId);
return NoteResponse.fromEntity(noteEntity, getNoteUrl(noteEntity.getId()));
}
/**
* Restore an archived note back to active state.
*
* @param noteId The note id from the database.
* @return {@link NoteResponse} containing the restored note.
*/
@Transactional
public NoteResponse restoreNote(Long noteId) {
UserEntity user = getCurrentUser();
logger.info("Restoring note ID {} for user ID {}", noteId, user.getId());
Optional<NoteEntity> noteOpt = noteRepository.findByIdAndUser_id(noteId, user.getId());
if (noteOpt.isEmpty()) {
throw new NoteNotFoundException();
}
NoteEntity noteEntity = noteOpt.get();
if (!noteEntity.isArchived()) {
throw new NoteArchivedException();
}
noteEntity.setArchived(false);
noteEntity.setLastUpdate(LocalDateTime.now());
noteRepository.save(noteEntity);
logger.info("Note ID {} restored", noteId);
return NoteResponse.fromEntity(noteEntity, getNoteUrl(noteEntity.getId()));
}
private NoteUrlEntity saveUrl(NoteEntity noteEntity, String url) {
NoteUrlEntity noteUrl = new NoteUrlEntity();
noteUrl.setUrl(url);
@@ -117,7 +117,7 @@ public class TaskService {
TaskEntity task = new TaskEntity();
task.setDescription(taskRequest.description());
task.setDone(false);
task.setCompleted(false);
task.setUser(user);
task.setLastUpdate(LocalDateTime.now());
if (!Objects.isNull(taskRequest.dueDate()) && !taskRequest.dueDate().isBlank()) {
@@ -161,8 +161,8 @@ public class TaskService {
if (!Objects.isNull(patch.description()) && !patch.description().isBlank()) {
taskEntity.setDescription(patch.description().trim());
}
if (!Objects.isNull(patch.done())) {
taskEntity.setDone(patch.done());
if (!Objects.isNull(patch.completed())) {
taskEntity.setCompleted(patch.completed());
}
patchDueDate(taskEntity, patch);
@@ -257,7 +257,7 @@ public class TaskService {
List<TaskEntity> allTasks =
taskRepository.findAllByUser_id(user.getId()).stream()
.filter(t -> t.getDone().equals(Boolean.FALSE))
.filter(t -> t.getCompleted().equals(Boolean.FALSE))
.toList();
if (allTasks.isEmpty()) {
return List.of();
@@ -89,9 +89,9 @@ public class TimeAgoUtil {
} else if (period.getDays() > 1) {
sb.append(String.format("%d days left", period.getDays()));
} else if (period.getDays() > 0) {
sb.append(String.format("%d day left", period.getDays()));
sb.append("Due tomorrow");
} else if (period.getDays() == 0) {
sb.append("0 days left");
sb.append("Due today");
} else {
sb.append("Due");
}
@@ -0,0 +1 @@
ALTER TABLE tasknote.tasks RENAME COLUMN done TO completed;
@@ -0,0 +1,4 @@
ALTER TABLE tasknote.notes
ADD COLUMN archived BOOLEAN NOT NULL DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_notes_archived ON tasknote.notes (archived);
@@ -44,7 +44,7 @@ class NoteControllerTest {
void getAllNotes_notesFound_shouldSucceed() throws Exception {
NoteUrlResponse noteUrl = new NoteUrlResponse(111L, "https://test.com");
NoteResponse note =
new NoteResponse(111L, "title", "description", "https://test.com", null, List.of("tag"), false, null);
new NoteResponse(111L, "title", "description", "https://test.com", null, List.of("tag"), false, null, false);
when(noteService.getAllNotes()).thenReturn(List.of(note));
@@ -109,7 +109,8 @@ class NoteControllerTest {
null,
List.of("tag"),
false,
null);
null,
false);
when(noteService.patchNote(noteId, patchRequest)).thenReturn(response);
@@ -201,7 +202,7 @@ class NoteControllerTest {
NoteRequest request = new NoteRequest("Title", "Description", null, List.of("tag"));
NoteResponse entity = new NoteResponse(1L, request.title(), request.description(),
null, null, List.of("tag"), false, null);
null, null, List.of("tag"), false, null, false);
when(noteService.createNote(request)).thenReturn(entity);
@@ -331,7 +332,8 @@ class NoteControllerTest {
final Long noteId = 1L;
final String token = "test-token-uuid";
NoteResponse response =
new NoteResponse(noteId, "title", "description", null, null, List.of("tag"), true, token);
new NoteResponse(
noteId, "title", "description", null, null, List.of("tag"), true, token, false);
when(noteService.shareNote(noteId)).thenReturn(response);
@@ -366,7 +368,8 @@ class NoteControllerTest {
void unshareNote_happyPath_shouldSucceed() throws Exception {
final Long noteId = 1L;
NoteResponse response =
new NoteResponse(noteId, "title", "description", null, null, List.of("tag"), false, null);
new NoteResponse(
noteId, "title", "description", null, null, List.of("tag"), false, null, false);
when(noteService.unshareNote(noteId)).thenReturn(response);
@@ -32,7 +32,8 @@ class PublicNoteControllerTest {
void getSharedNote_happyPath_shouldSucceed() throws Exception {
final String token = "test-share-token";
NoteResponse response =
new NoteResponse(1L, "title", "description", null, null, List.of("tag"), true, token);
new NoteResponse(
1L, "title", "description", null, null, List.of("tag"), true, token, false);
when(noteService.getSharedNote(token)).thenReturn(response);
@@ -42,7 +42,7 @@ class TaskControllerTest {
void getAllTasks_tasksFound_shouldSucceed() throws Exception {
TaskResponse taskResponse =
new TaskResponse(
1L, "Desc", false, true, null, null, "Moments ago", List.of("tag"), List.of("http://test.com"));
1L, false, "Desc", true, null, null, "Moments ago", List.of("tag"), List.of("http://test.com"));
when(taskService.getAllTasks()).thenReturn(List.of(taskResponse));
mockMvc
@@ -54,7 +54,7 @@ class TaskControllerTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].id").value(taskResponse.id()))
.andExpect(jsonPath("$[0].description").value(taskResponse.description()))
.andExpect(jsonPath("$[0].done", Matchers.is(false)))
.andExpect(jsonPath("$[0].completed", Matchers.is(false)))
.andExpect(jsonPath("$[0].highPriority", Matchers.is(true)))
.andExpect(jsonPath("$[0].dueDate", Matchers.nullValue()))
.andExpect(jsonPath("$[0].dueDateFmt", Matchers.nullValue()))
@@ -101,8 +101,8 @@ class TaskControllerTest {
TaskResponse taskResponse =
new TaskResponse(
taskId,
"Desc",
false,
"Desc",
true,
null,
null,
@@ -120,7 +120,7 @@ class TaskControllerTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(taskResponse.id()))
.andExpect(jsonPath("$.description").value(taskResponse.description()))
.andExpect(jsonPath("$.done", Matchers.is(false)))
.andExpect(jsonPath("$.completed", Matchers.is(false)))
.andExpect(jsonPath("$.highPriority", Matchers.is(true)))
.andExpect(jsonPath("$.dueDate", Matchers.nullValue()))
.andExpect(jsonPath("$.dueDateFmt", Matchers.nullValue()))
@@ -167,13 +167,13 @@ class TaskControllerTest {
void patchTask_happyPath_shouldSucceed() throws Exception {
Long taskId = 111L;
TaskPatchRequest patchRequest =
new TaskPatchRequest("Description patched", false, List.of(), null, true, List.of("tag"));
new TaskPatchRequest(false, "Description patched", List.of(), null, true, List.of("tag"));
TaskResponse taskResponse =
new TaskResponse(
taskId,
"Description patched",
false,
"Description patched",
true,
null,
null,
@@ -186,7 +186,7 @@ class TaskControllerTest {
"""
{
"description": "Description patched",
"done": false,
"completed": false,
"urls": [],
"highPriority": true
}
@@ -209,7 +209,7 @@ class TaskControllerTest {
void patchTask_notFound_shouldFail() throws Exception {
Long taskId = 118L;
TaskPatchRequest patchRequest =
new TaskPatchRequest("Description patched", false, List.of(), null, true, List.of("tag"));
new TaskPatchRequest(false, "Description patched", List.of(), null, true, List.of("tag"));
when(taskService.patchTask(taskId, patchRequest)).thenThrow(new TaskNotFoundException());
@@ -217,7 +217,7 @@ class TaskControllerTest {
"""
{
"description": "Description patched",
"done": false,
"completed": false,
"urls": [],
"highPriority": true,
"tags": ["tag"]
@@ -244,7 +244,7 @@ class TaskControllerTest {
"""
{
"description": "Description patched",
"done": false,
"completed": false,
"urls": [],
"highPriority": true
}
@@ -271,8 +271,8 @@ class TaskControllerTest {
TaskResponse taskResponse =
new TaskResponse(
858L,
"Description patched",
false,
"Description patched",
true,
null,
null,
@@ -61,7 +61,7 @@ class HomeServiceTest {
.thenReturn(List.of(tag1, tag2, tag3, tag4, tag5, tag6));
TaskResponse task1 =
new TaskResponse(1L, "Task 1", false, false, null, null, null, List.of("tag1"), List.of());
new TaskResponse(1L, false, "Task 1", false, null, null, null, List.of("tag1"), List.of());
when(taskService.getTasksByFilter("all")).thenReturn(List.of(task1));
when(noteService.getAllNotes()).thenReturn(List.of());
@@ -94,7 +94,7 @@ class HomeServiceTest {
when(tagRepository.findAllByUser_idOrderByNameAsc(user.getId())).thenReturn(List.of(tag1));
TaskResponse task1 =
new TaskResponse(1L, "Task 1", false, false, null, null, null, List.of(), List.of());
new TaskResponse(1L, false, "Task 1", false, null, null, null, List.of(), List.of());
when(taskService.getTasksByFilter("all")).thenReturn(List.of(task1));
when(noteService.getAllNotes()).thenReturn(List.of());
@@ -150,6 +150,7 @@ class NoteServiceTest {
@Test
void deleteNote() {
note.setArchived(true);
when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(user.getEmail()));
when(authService.findByEmail(user.getEmail())).thenReturn(Optional.of(user));
when(noteRepository.findByIdAndUser_id(note.getId(), user.getId()))
@@ -356,7 +356,7 @@ class TaskServiceTest {
taskEntity.setId(taskId);
taskEntity.setDescription("Test task");
taskEntity.setHighPriority(true);
taskEntity.setDone(false);
taskEntity.setCompleted(false);
taskEntity.setTags(Set.of(new TagEntity("test", userEntity)));
taskEntity.setUser(userEntity);
when(taskRepository.findByIdAndUser_id(taskId, USER_ID)).thenReturn(Optional.of(taskEntity));
@@ -373,18 +373,18 @@ class TaskServiceTest {
savedTask.setDescription("Test task updated");
savedTask.setHighPriority(false);
savedTask.setDueDate(LocalDate.parse(dueDate));
savedTask.setDone(true);
savedTask.setCompleted(true);
savedTask.setTags(taskEntity.getTags());
when(taskRepository.save(any())).thenReturn(savedTask);
List<String> tags = List.of("test");
TaskPatchRequest patch =
new TaskPatchRequest("Test task updated", true, null, dueDate, false, tags);
new TaskPatchRequest(true, "Test task updated", null, dueDate, false, tags);
TaskResponse patched = taskService.patchTask(taskId, patch);
assertNotNull(patched);
assertEquals("Test task updated", patched.description());
assertTrue(patched.done());
assertTrue(patched.completed());
assertEquals(TimeAgoUtil.formatDueDate(LocalDate.parse(dueDate)), patched.dueDateFmt());
assertFalse(patched.highPriority());
assertTrue(patched.tags().contains("test"));
@@ -407,7 +407,7 @@ class TaskServiceTest {
taskEntity.setId(taskId);
taskEntity.setDescription("Test task");
taskEntity.setHighPriority(true);
taskEntity.setDone(false);
taskEntity.setCompleted(false);
taskEntity.setTags(Set.of(new TagEntity("test", userEntity)));
taskEntity.setUser(userEntity);
when(taskRepository.findByIdAndUser_id(taskId, USER_ID)).thenReturn(Optional.of(taskEntity));
@@ -427,21 +427,21 @@ class TaskServiceTest {
savedTask.setDescription("Test task updated");
savedTask.setHighPriority(false);
savedTask.setDueDate(LocalDate.parse(dueDate));
savedTask.setDone(true);
savedTask.setCompleted(true);
savedTask.setTags(taskEntity.getTags());
when(taskRepository.save(any())).thenReturn(savedTask);
String url = "http://test.com";
List<String> tags = List.of("test");
TaskPatchRequest patch =
new TaskPatchRequest("Test task updated", true, List.of(url), dueDate, false, tags);
new TaskPatchRequest(true, "Test task updated", List.of(url), dueDate, false, tags);
when(taskUrlRepository.saveAll(any())).thenReturn(List.of());
TaskResponse patched = taskService.patchTask(taskId, patch);
assertNotNull(patched);
assertEquals("Test task updated", patched.description());
assertTrue(patched.done());
assertTrue(patched.completed());
assertEquals(TimeAgoUtil.formatDueDate(LocalDate.parse(dueDate)), patched.dueDateFmt());
assertFalse(patched.highPriority());
assertTrue(patched.tags().contains("test"));
@@ -464,7 +464,7 @@ class TaskServiceTest {
List<String> tags = List.of("test");
TaskPatchRequest patch =
new TaskPatchRequest("Test task updated", true, null, "2025-12-31", false, tags);
new TaskPatchRequest(true, "Test task updated", null, "2025-12-31", false, tags);
assertThrows(TaskNotFoundException.class, () -> taskService.patchTask(taskId, patch));
}
@@ -485,7 +485,7 @@ class TaskServiceTest {
taskEntity.setId(taskId);
taskEntity.setDescription("Test task");
taskEntity.setHighPriority(true);
taskEntity.setDone(false);
taskEntity.setCompleted(false);
taskEntity.setTags(Set.of(new TagEntity("test", userEntity)));
taskEntity.setUser(userEntity);
when(taskRepository.findByIdAndUser_id(taskId, USER_ID)).thenReturn(Optional.of(taskEntity));
@@ -499,7 +499,7 @@ class TaskServiceTest {
TaskEntity savedTask = new TaskEntity();
savedTask.setDescription("Test task updated");
savedTask.setHighPriority(false);
savedTask.setDone(true);
savedTask.setCompleted(true);
savedTask.setTags(taskEntity.getTags());
when(taskRepository.save(any())).thenReturn(savedTask);
@@ -508,12 +508,12 @@ class TaskServiceTest {
List<String> tags = List.of("test");
TaskPatchRequest patch =
new TaskPatchRequest("Test task updated", true, null, dueDate, false, tags);
new TaskPatchRequest(true, "Test task updated", null, dueDate, false, tags);
TaskResponse patched = taskService.patchTask(taskId, patch);
assertNotNull(patched);
assertEquals("Test task updated", patched.description());
assertTrue(patched.done());
assertTrue(patched.completed());
assertNull(patched.dueDate());
assertNull(patched.dueDateFmt());
assertFalse(patched.highPriority());
@@ -602,14 +602,14 @@ class TaskServiceTest {
task1.setId(1L);
task1.setDescription("Task 1");
task1.setHighPriority(false);
task1.setDone(false);
task1.setCompleted(false);
task1.setTags(Set.of(new TagEntity("tag1", userEntity)));
TaskEntity task2 = new TaskEntity();
task2.setId(2L);
task2.setDescription("Task 2");
task2.setHighPriority(true);
task2.setDone(false);
task2.setCompleted(false);
task2.setTags(Set.of(new TagEntity("tag2", userEntity)));
when(taskRepository.findAllByUser_id(USER_ID)).thenReturn(List.of(task1, task2));
@@ -635,14 +635,14 @@ class TaskServiceTest {
task1.setId(1L);
task1.setDescription("Task 1");
task1.setHighPriority(false);
task1.setDone(false);
task1.setCompleted(false);
task1.setTags(Set.of(new TagEntity("tag1", userEntity)));
TaskEntity task2 = new TaskEntity();
task2.setId(2L);
task2.setDescription("Task 2");
task2.setHighPriority(true);
task2.setDone(false);
task2.setCompleted(false);
task2.setTags(Set.of(new TagEntity("tag2", userEntity)));
when(taskRepository.findAllByUser_id(USER_ID)).thenReturn(List.of(task1, task2));
@@ -667,14 +667,14 @@ class TaskServiceTest {
task1.setId(1L);
task1.setDescription("Task 1");
task1.setHighPriority(false);
task1.setDone(false);
task1.setCompleted(false);
task1.setTags(Set.of());
TaskEntity task2 = new TaskEntity();
task2.setId(2L);
task2.setDescription("Task 2");
task2.setHighPriority(true);
task2.setDone(false);
task2.setCompleted(false);
task2.setTags(Set.of());
when(taskRepository.findAllByUser_id(USER_ID)).thenReturn(List.of(task1, task2));
@@ -700,14 +700,14 @@ class TaskServiceTest {
task1.setId(1L);
task1.setDescription("Task 1");
task1.setHighPriority(false);
task1.setDone(false);
task1.setCompleted(false);
task1.setTags(Set.of(new TagEntity("tag1", userEntity)));
TaskEntity task2 = new TaskEntity();
task2.setId(2L);
task2.setDescription("Task 2");
task2.setHighPriority(true);
task2.setDone(false);
task2.setCompleted(false);
task2.setTags(Set.of(new TagEntity("tag2", userEntity)));
when(taskRepository.findAllByUser_id(USER_ID)).thenReturn(List.of(task1, task2));
@@ -42,9 +42,9 @@ class UserSessionServiceTest {
user.setEmail("user@domain.com");
TaskResponse task =
new TaskResponse(1L, "Task 1", false, true, null, null, null, null, List.of());
new TaskResponse(1L, false, "Task 1", true, null, null, null, null, List.of());
NoteResponse note =
new NoteResponse(1L, "Note 1", "Description", null, null, null, false, null);
new NoteResponse(1L, "Note 1", "Description", null, null, null, false, null, false);
when(authService.getCurrentUser()).thenReturn(Optional.of(user));
when(taskService.getAllTasks()).thenReturn(List.of(task));
@@ -41,7 +41,7 @@ class TimeAgoUtilTest {
Assertions.assertNull(TimeAgoUtil.formatDueDate(null));
LocalDate localDate1 = LocalDate.now().plusDays(1L);
String expected1 = "1 day left" + getFormattedSuffix(localDate1);
String expected1 = "Due tomorrow" + getFormattedSuffix(localDate1);
Assertions.assertEquals(expected1, TimeAgoUtil.formatDueDate(localDate1));
LocalDate localDate2 = LocalDate.now().plusDays(12L);
@@ -74,7 +74,7 @@ class TimeAgoUtilTest {
void formatDueDateEdgeCasesTest() {
// Test for today
LocalDate today = LocalDate.now();
String expectedToday = "0 days left" + getFormattedSuffix(today);
String expectedToday = "Due today" + getFormattedSuffix(today);
Assertions.assertEquals(expectedToday, TimeAgoUtil.formatDueDate(today));
// Test for a past date
@@ -13,11 +13,11 @@ select 'cleaning', (select id from users where email='test@domain.com')
where not exists (select 1 from tags where name = 'cleaning' and user_id = (select id from users where email='test@domain.com'));
-- Create some tasks
insert into tasks (description, done, user_id, last_update, due_date, high_priority)
insert into tasks (description, completed, user_id, last_update, due_date, high_priority)
select 'Refactor', false, (select id from users where email='test@domain.com'), current_timestamp, '2025-12-12', false
where not exists (select 1 from tasks where description = 'Refactor' and user_id = (select id from users where email='test@domain.com'));
insert into tasks (description, done, user_id, last_update, due_date, high_priority)
insert into tasks (description, completed, user_id, last_update, due_date, high_priority)
select 'Cleanup', false, (select id from users where email='test@domain.com'), current_timestamp, '2025-12-12', false
where not exists (select 1 from tasks where description = 'Cleanup' and user_id = (select id from users where email='test@domain.com'));
@@ -13,7 +13,7 @@ select 'health', (select id from users where email='test@domain.com')
where not exists (select 1 from tags where name = 'health' and user_id = (select id from users where email='test@domain.com'));
-- Create a task
insert into tasks (description, done, user_id, last_update, due_date, high_priority)
insert into tasks (description, completed, user_id, last_update, due_date, high_priority)
select 'Install Debian', false, (select id from users where email='test@domain.com'), current_timestamp, '2025-12-12', false
where not exists (select 1 from tasks where description = 'Install Debian' and user_id = (select id from users where email='test@domain.com'));
@@ -31,7 +31,7 @@ select (select id from tasks where description = 'Install Debian'), 'debian.org'
where not exists (select 1 from task_url where url = 'debian.org');
-- Create another task
insert into tasks (description, done, user_id, last_update, due_date, high_priority)
insert into tasks (description, completed, user_id, last_update, due_date, high_priority)
select 'Workout', false, (select id from users where email='test@domain.com'), current_timestamp, '2025-12-12', false
where not exists (select 1 from tasks where description = 'Workout' and user_id = (select id from users where email='test@domain.com'));
-22
View File
@@ -1,22 +0,0 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/google" {
version = "5.45.2"
constraints = "~> 5.0"
hashes = [
"h1:k8taQAdfHrv2F/AiGV5BZBZfI+1uaq8g6O8dWzjx42c=",
"zh:0d09c8f20b556305192cdbe0efa6d333ceebba963a8ba91f9f1714b5a20c4b7a",
"zh:117143fc91be407874568df416b938a6896f94cb873f26bba279cedab646a804",
"zh:16ccf77d18dd2c5ef9c0625f9cf546ebdf3213c0a452f432204c69feed55081e",
"zh:3e555cf22a570a4bd247964671f421ed7517970cd9765ceb46f335edc2c6f392",
"zh:688bd5b05a75124da7ae6e885b2b92bd29f4261808b2b78bd5f51f525c1052ca",
"zh:6db3ef37a05010d82900bfffb3261c59a0c247e0692049cb3eb8c2ef16c9d7bf",
"zh:70316fde75f6a15d72749f66d994ccbdde5f5ed4311b6d06b99850f698c9bbf9",
"zh:84b8e583771a4f2bd514e519d98ed7fd28dce5efe0634e973170e1cfb5556fb4",
"zh:9d4b8ef0a9b6677935c604d94495042e68ff5489932cfd1ec41052e094a279d3",
"zh:a2089dd9bd825c107b148dd12d6b286f71aa37dfd4ca9c35157f2dcba7bc19d8",
"zh:f03d795c0fd9721e59839255ee7ba7414173017dc530b4ce566daf3802a0d6dd",
"zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
]
}
-127
View File
@@ -1,127 +0,0 @@
resource "google_cloud_run_v2_service" "backend" {
name = "tasknote-api"
location = var.region
ingress = "INGRESS_TRAFFIC_ALL"
depends_on = [google_project_service.run]
template {
service_account = google_service_account.cloudrun_sa.email
vpc_access {
connector = google_vpc_access_connector.connector.id
egress = "PRIVATE_RANGES_ONLY"
}
containers {
image = var.backend_image
ports {
container_port = 8585
}
env {
name = "POSTGRES_DB"
value = var.db_name
}
env {
name = "POSTGRES_HOST"
value = google_sql_database_instance.instance.private_ip_address
}
env {
name = "POSTGRES_USER"
value_source {
secret_key_ref {
secret = google_secret_manager_secret.db_user.secret_id
version = google_secret_manager_secret_version.db_user_version.version
}
}
}
env {
name = "POSTGRES_PASSWORD"
value_source {
secret_key_ref {
secret = google_secret_manager_secret.db_password.secret_id
version = google_secret_manager_secret_version.db_password_version.version
}
}
}
env {
name = "POSTGRES_PORT"
value = "5432"
}
env {
name = "CORS_ALLOWED_ORIGINS"
value = var.cors_allowed_origins
}
env {
name = "SERVER_SERVLET_CONTEXT_PATH"
value = "/"
}
env {
name = "TARGET_ENV"
value = "production"
}
env {
name = "SECURITY_KEY"
value_source {
secret_key_ref {
secret = google_secret_manager_secret.security_key.secret_id
version = google_secret_manager_secret_version.security_key_version.version
}
}
}
env {
name = "MAILGUN_APIKEY"
value_source {
secret_key_ref {
secret = google_secret_manager_secret.mailgun_apikey.secret_id
version = google_secret_manager_secret_version.mailgun_apikey_version.version
}
}
}
}
}
}
resource "google_cloud_run_v2_service" "frontend" {
name = "tasknote-app"
location = var.region
ingress = "INGRESS_TRAFFIC_ALL"
depends_on = [google_project_service.run]
template {
service_account = google_service_account.cloudrun_sa.email
containers {
image = var.frontend_image
ports {
container_port = 5000
}
env {
name = "VITE_BACKEND_SERVER"
value = google_cloud_run_v2_service.backend.uri
}
}
}
}
# Allow unauthenticated access to both services
resource "google_cloud_run_service_iam_member" "backend_public" {
location = google_cloud_run_v2_service.backend.location
service = google_cloud_run_v2_service.backend.name
role = "roles/run.invoker"
member = "allUsers"
}
resource "google_cloud_run_service_iam_member" "frontend_public" {
location = google_cloud_run_v2_service.frontend.location
service = google_cloud_run_v2_service.frontend.name
role = "roles/run.invoker"
member = "allUsers"
}
# Grant Cloud SQL Client role to the service account
resource "google_project_iam_member" "cloudsql_client" {
project = var.project_id
role = "roles/cloudsql.client"
member = "serviceAccount:${google_service_account.cloudrun_sa.email}"
}
-32
View File
@@ -1,32 +0,0 @@
resource "google_sql_database_instance" "instance" {
name = "tasknote-db-instance"
region = var.region
database_version = "POSTGRES_15"
depends_on = [
google_service_networking_connection.private_vpc_connection,
google_project_service.sqladmin,
google_project_service.servicenetworking
]
settings {
tier = "db-f1-micro" # Smallest tier for dev/small app
ip_configuration {
ipv4_enabled = false
private_network = google_compute_network.vpc_network.id
}
}
deletion_protection = false # Set to true for production
}
resource "google_sql_database" "database" {
name = var.db_name
instance = google_sql_database_instance.instance.name
}
resource "google_sql_user" "users" {
name = var.db_user
instance = google_sql_database_instance.instance.name
password = var.db_password
}
-45
View File
@@ -1,45 +0,0 @@
#!/bin/bash
PROJECT_ID="replace-me"
VERSION="23"
SOURCE_IMG="ghcr.io/rmcampos/tasknote/api:$VERSION"
DESTINATION_IMG="us-central1-docker.pkg.dev/$PROJECT_ID/tasknote-repo/api:$VERSION"
GHCR_PAT="replace-me"
# Login
echo $GHCR_PAT | docker login ghcr.io -u user --password-stdin
echo "Logged in on ghcr.io"
gcloud auth configure-docker us-central1-docker.pkg.dev
docker login us-central1-docker.pkg.dev
echo "Logged in on GCP Container Registry"
crane auth login ghcr.io -u RMCampos -p $GHCR_PAT
echo "Logged crane in on ghcr.io"
# Check if image already exists in destination
echo "Checking if $DESTINATION_IMG already exists in destination registry..."
if crane manifest "$DESTINATION_IMG" > /dev/null 2>&1; then
echo "Image $DESTINATION_IMG already exists. Skipping push."
else
echo "Image not found in destination. Proceeding with push..."
docker pull $SOURCE_IMG
docker tag $SOURCE_IMG $DESTINATION_IMG
docker push $DESTINATION_IMG
echo "Pushed image to GCP Container Registry"
crane copy $SOURCE_IMG $DESTINATION_IMG
echo "Copied image to GCP Container Registry with crane"
fi
# Verification
echo "Verifying push..."
if crane manifest "$DESTINATION_IMG" > /dev/null 2>&1; then
echo "SUCCESS: Image $DESTINATION_IMG is properly pushed and available in the registry."
gcloud artifacts docker images list us-central1-docker.pkg.dev/project-9f22294e-b6f5-41de-83c/tasknote-repo | grep "api:$VERSION"
else
echo "FAILURE: Image $DESTINATION_IMG was not found in the destination registry."
exit 1
fi
-35
View File
@@ -1,35 +0,0 @@
resource "google_compute_network" "vpc_network" {
name = "tasknote-vpc"
auto_create_subnetworks = false
}
resource "google_compute_subnetwork" "subnet" {
name = "tasknote-subnet"
ip_cidr_range = "10.0.0.0/24"
region = var.region
network = google_compute_network.vpc_network.id
}
# Private IP for Cloud SQL
resource "google_compute_global_address" "private_ip_address" {
name = "tasknote-private-ip"
purpose = "VPC_PEERING"
address_type = "INTERNAL"
prefix_length = 16
network = google_compute_network.vpc_network.id
}
resource "google_service_networking_connection" "private_vpc_connection" {
network = google_compute_network.vpc_network.id
service = "servicenetworking.googleapis.com"
reserved_peering_ranges = [google_compute_global_address.private_ip_address.name]
}
# Serverless VPC Access Connector
resource "google_vpc_access_connector" "connector" {
name = "tasknote-vpc-connector"
region = var.region
ip_cidr_range = "10.8.0.0/28"
network = google_compute_network.vpc_network.name
depends_on = [google_project_service.vpcaccess, google_project_service.compute]
}
-15
View File
@@ -1,15 +0,0 @@
output "backend_url" {
value = google_cloud_run_v2_service.backend.uri
}
output "frontend_url" {
value = google_cloud_run_v2_service.frontend.uri
}
output "cloud_sql_instance_ip" {
value = google_sql_database_instance.instance.private_ip_address
}
output "artifact_registry_repo" {
value = google_artifact_registry_repository.repo.name
}
-25
View File
@@ -1,25 +0,0 @@
terraform {
required_providers {
google = {
source = "hashicorp/google"
version = "~> 5.0"
}
}
backend "s3" {
bucket = "tasknote"
key = "gcp/terraform.tfstate"
region = "auto"
endpoints = { s3 = "https://d17eb09b6bce2f90e16e800bb2a6baf9.r2.cloudflarestorage.com" }
skip_credentials_validation = true
skip_region_validation = true
skip_requesting_account_id = true
skip_metadata_api_check = true
skip_s3_checksum = true
}
}
provider "google" {
project = var.project_id
region = var.region
}
-7
View File
@@ -1,7 +0,0 @@
resource "google_artifact_registry_repository" "repo" {
location = var.region
repository_id = "tasknote-repo"
description = "Docker repository for TaskNote images"
format = "DOCKER"
depends_on = [google_project_service.artifactregistry]
}
-95
View File
@@ -1,95 +0,0 @@
resource "google_service_account" "cloudrun_sa" {
account_id = "tasknote-cloudrun-sa"
display_name = "TaskNote Cloud Run Service Account"
}
resource "google_secret_manager_secret" "db_password" {
secret_id = "db-password"
replication {
user_managed {
replicas {
location = var.region
}
}
}
depends_on = [google_project_service.secretmanager]
}
resource "google_secret_manager_secret_version" "db_password_version" {
secret = google_secret_manager_secret.db_password.id
secret_data = var.db_password
}
resource "google_secret_manager_secret" "db_user" {
secret_id = "db-user"
replication {
user_managed {
replicas {
location = var.region
}
}
}
depends_on = [google_project_service.secretmanager]
}
resource "google_secret_manager_secret_version" "db_user_version" {
secret = google_secret_manager_secret.db_user.id
secret_data = var.db_user
}
resource "google_secret_manager_secret_iam_member" "db_password_access" {
secret_id = google_secret_manager_secret.db_password.id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.cloudrun_sa.email}"
}
resource "google_secret_manager_secret_iam_member" "db_user_access" {
secret_id = google_secret_manager_secret.db_user.id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.cloudrun_sa.email}"
}
resource "google_secret_manager_secret" "security_key" {
secret_id = "security-key"
replication {
user_managed {
replicas {
location = var.region
}
}
}
}
resource "google_secret_manager_secret_version" "security_key_version" {
secret = google_secret_manager_secret.security_key.id
secret_data = var.security_key
}
resource "google_secret_manager_secret" "mailgun_apikey" {
secret_id = "mailgun-apikey"
replication {
user_managed {
replicas {
location = var.region
}
}
}
depends_on = [google_project_service.secretmanager]
}
resource "google_secret_manager_secret_version" "mailgun_apikey_version" {
secret = google_secret_manager_secret.mailgun_apikey.id
secret_data = var.mailgun_apikey
}
resource "google_secret_manager_secret_iam_member" "security_key_access" {
secret_id = google_secret_manager_secret.security_key.id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.cloudrun_sa.email}"
}
resource "google_secret_manager_secret_iam_member" "mailgun_apikey_access" {
secret_id = google_secret_manager_secret.mailgun_apikey.id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.cloudrun_sa.email}"
}
-34
View File
@@ -1,34 +0,0 @@
resource "google_project_service" "compute" {
service = "compute.googleapis.com"
disable_on_destroy = false
}
resource "google_project_service" "sqladmin" {
service = "sqladmin.googleapis.com"
disable_on_destroy = false
}
resource "google_project_service" "run" {
service = "run.googleapis.com"
disable_on_destroy = false
}
resource "google_project_service" "secretmanager" {
service = "secretmanager.googleapis.com"
disable_on_destroy = false
}
resource "google_project_service" "vpcaccess" {
service = "vpcaccess.googleapis.com"
disable_on_destroy = false
}
resource "google_project_service" "artifactregistry" {
service = "artifactregistry.googleapis.com"
disable_on_destroy = false
}
resource "google_project_service" "servicenetworking" {
service = "servicenetworking.googleapis.com"
disable_on_destroy = false
}
-10
View File
@@ -1,10 +0,0 @@
project_id = "your-gcp-project-id"
region = "us-central1"
db_user = "tasknoteuser"
db_password = "your-db-password"
db_name = "tasknote"
security_key = "your-security-key"
mailgun_apikey = "your-mailgun-apikey"
backend_image = "ghcr.io/rmcampos/tasknote/api:latest"
frontend_image = "ghcr.io/rmcampos/tasknote/app:latest"
cors_allowed_origins = "*"
-50
View File
@@ -1,50 +0,0 @@
variable "project_id" {
type = string
description = "The GCP Project ID"
}
variable "region" {
type = string
default = "us-central1"
description = "The GCP region to deploy resources"
}
variable "db_user" {
type = string
sensitive = true
}
variable "db_password" {
type = string
sensitive = true
}
variable "db_name" {
type = string
default = "tasknote"
}
variable "security_key" {
type = string
sensitive = true
}
variable "mailgun_apikey" {
type = string
sensitive = true
}
variable "backend_image" {
type = string
default = "ghcr.io/rmcampos/tasknote/api:latest"
}
variable "frontend_image" {
type = string
default = "ghcr.io/rmcampos/tasknote/app:latest"
}
variable "cors_allowed_origins" {
type = string
default = "*"
}
-22
View File
@@ -1,22 +0,0 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/kubernetes" {
version = "2.38.0"
constraints = "2.38.0"
hashes = [
"h1:5CkveFo5ynsLdzKk+Kv+r7+U9rMrNjfZPT3a0N/fhgE=",
"zh:0af928d776eb269b192dc0ea0f8a3f0f5ec117224cd644bdacdc682300f84ba0",
"zh:1be998e67206f7cfc4ffe77c01a09ac91ce725de0abaec9030b22c0a832af44f",
"zh:326803fe5946023687d603f6f1bab24de7af3d426b01d20e51d4e6fbe4e7ec1b",
"zh:4a99ec8d91193af961de1abb1f824be73df07489301d62e6141a656b3ebfff12",
"zh:5136e51765d6a0b9e4dbcc3b38821e9736bd2136cf15e9aac11668f22db117d2",
"zh:63fab47349852d7802fb032e4f2b6a101ee1ce34b62557a9ad0f0f0f5b6ecfdc",
"zh:924fb0257e2d03e03e2bfe9c7b99aa73c195b1f19412ca09960001bee3c50d15",
"zh:b63a0be5e233f8f6727c56bed3b61eb9456ca7a8bb29539fba0837f1badf1396",
"zh:d39861aa21077f1bc899bc53e7233262e530ba8a3a2d737449b100daeb303e4d",
"zh:de0805e10ebe4c83ce3b728a67f6b0f9d18be32b25146aa89116634df5145ad4",
"zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
"zh:faf23e45f0090eef8ba28a8aac7ec5d4fdf11a36c40a8d286304567d71c1e7db",
]
}
-387
View File
@@ -1,387 +0,0 @@
terraform {
required_providers {
kubernetes = {
source = "hashicorp/kubernetes"
version = "= 2.38.0"
}
}
backend "s3" {
bucket = "tasknote-stg"
key = "kubernetes/terraform.tfstate"
region = "auto"
endpoints = { s3 = "https://d17eb09b6bce2f90e16e800bb2a6baf9.r2.cloudflarestorage.com" }
skip_credentials_validation = true
skip_region_validation = true
skip_requesting_account_id = true
skip_metadata_api_check = true
skip_s3_checksum = true
}
}
provider "kubernetes" {
config_path = "~/.kube/config"
}
variable "db_user" {
type = string
sensitive = true
}
variable "db_password" {
type = string
sensitive = true
}
variable "db_name" {
type = string
sensitive = true
}
variable "security_key" {
type = string
sensitive = true
}
variable "mailgun_apikey" {
type = string
sensitive = true
}
variable "cors_allowed_origins" {
type = string
default = "https://tasknote-stg.darkroasted.vps-kinghost.net"
}
variable "root_log_level" {
type = string
default = "INFO"
}
variable "backend_image" {
type = string
default = "ghcr.io/rmcampos/tasknote/api:candidate"
}
variable "frontend_image" {
type = string
default = "ghcr.io/rmcampos/tasknote/app:candidate"
}
variable "deploy_version" {
type = string
default = "manual"
}
resource "kubernetes_namespace_v1" "tasknote_stg" {
metadata {
name = "tasknote-stg"
}
}
resource "kubernetes_secret_v1" "tasknote_stg_secrets" {
metadata {
name = "tasknote-stg-secrets"
namespace = kubernetes_namespace_v1.tasknote_stg.metadata[0].name
}
data = {
postgres_user = var.db_user
postgres_password = var.db_password
postgres_db = var.db_name
security_key = var.security_key
mailgun_apikey = var.mailgun_apikey
}
}
resource "kubernetes_persistent_volume_claim_v1" "tasknote_stg_db_data" {
metadata {
name = "postgres-data-pvc"
namespace = kubernetes_namespace_v1.tasknote_stg.metadata[0].name
}
spec {
access_modes = ["ReadWriteOnce"]
resources {
requests = {
storage = "1Gi"
}
}
}
}
resource "kubernetes_deployment_v1" "tasknote_stg_db" {
metadata {
name = "tasknote-stg-db"
namespace = kubernetes_namespace_v1.tasknote_stg.metadata[0].name
}
spec {
replicas = 1
selector { match_labels = { app = "tasknote-stg-db" } }
template {
metadata { labels = { app = "tasknote-stg-db" } }
spec {
container {
image = "postgres:15.8-bookworm"
name = "postgres"
volume_mount {
name = "postgres-storage"
mount_path = "/var/lib/postgresql/data"
}
env {
name = "POSTGRES_USER"
value_from {
secret_key_ref {
name = kubernetes_secret_v1.tasknote_stg_secrets.metadata[0].name
key = "postgres_user"
}
}
}
env {
name = "POSTGRES_PASSWORD"
value_from {
secret_key_ref {
name = kubernetes_secret_v1.tasknote_stg_secrets.metadata[0].name
key = "postgres_password"
}
}
}
env {
name = "POSTGRES_DB"
value_from {
secret_key_ref {
name = kubernetes_secret_v1.tasknote_stg_secrets.metadata[0].name
key = "postgres_db"
}
}
}
port { container_port = 5432 }
}
volume {
name = "postgres-storage"
persistent_volume_claim {
claim_name = kubernetes_persistent_volume_claim_v1.tasknote_stg_db_data.metadata[0].name
}
}
}
}
}
}
resource "kubernetes_service_v1" "tasknote_stg_db_svc" {
metadata {
name = "tasknote-stg-db-svc"
namespace = kubernetes_namespace_v1.tasknote_stg.metadata[0].name
}
spec {
selector = { app = "tasknote-stg-db" }
port { port = 5432 }
type = "ClusterIP"
}
}
resource "kubernetes_deployment_v1" "tasknote_stg_backend" {
metadata {
name = "tasknote-stg-backend"
namespace = kubernetes_namespace_v1.tasknote_stg.metadata[0].name
}
spec {
replicas = 1
selector { match_labels = { app = "tasknote-stg-backend" } }
template {
metadata {
labels = { app = "tasknote-stg-backend" }
annotations = {
"deploy_id" = var.deploy_version
}
}
spec {
container {
image = var.backend_image
name = "backend"
image_pull_policy = "Always"
env {
name = "POSTGRES_DB"
value_from {
secret_key_ref {
name = kubernetes_secret_v1.tasknote_stg_secrets.metadata[0].name
key = "postgres_db"
}
}
}
env {
name = "POSTGRES_HOST"
value = "tasknote-stg-db-svc"
}
env {
name = "POSTGRES_USER"
value_from {
secret_key_ref {
name = kubernetes_secret_v1.tasknote_stg_secrets.metadata[0].name
key = "postgres_user"
}
}
}
env {
name = "POSTGRES_PASSWORD"
value_from {
secret_key_ref {
name = kubernetes_secret_v1.tasknote_stg_secrets.metadata[0].name
key = "postgres_password"
}
}
}
env {
name = "POSTGRES_PORT"
value = "5432"
}
env {
name = "CORS_ALLOWED_ORIGINS"
value = var.cors_allowed_origins
}
env {
name = "SERVER_SERVLET_CONTEXT_PATH"
value = "/"
}
env {
name = "ROOT_LOG_LEVEL"
value = var.root_log_level
}
env {
name = "SECURITY_KEY"
value_from {
secret_key_ref {
name = kubernetes_secret_v1.tasknote_stg_secrets.metadata[0].name
key = "security_key"
}
}
}
env {
name = "TARGET_ENV"
value = "staging"
}
env {
name = "MAILGUN_APIKEY"
value_from {
secret_key_ref {
name = kubernetes_secret_v1.tasknote_stg_secrets.metadata[0].name
key = "mailgun_apikey"
}
}
}
resources {
limits = { memory = "256Mi", cpu = "500m" }
requests = { memory = "256Mi", cpu = "250m" }
}
}
}
}
}
}
resource "kubernetes_service_v1" "tasknote_stg_backend_svc" {
metadata {
name = "tasknote-stg-backend-svc"
namespace = kubernetes_namespace_v1.tasknote_stg.metadata[0].name
}
spec {
selector = { app = "tasknote-stg-backend" }
port {
port = 8585
target_port = 8585
}
}
}
resource "kubernetes_deployment_v1" "tasknote_stg_frontend" {
metadata {
name = "tasknote-stg-frontend"
namespace = kubernetes_namespace_v1.tasknote_stg.metadata[0].name
}
spec {
replicas = 1
selector { match_labels = { app = "tasknote-stg-app" } }
template {
metadata {
labels = { app = "tasknote-stg-app" }
annotations = {
"deploy_id" = var.deploy_version
}
}
spec {
container {
image = var.frontend_image
name = "frontend"
image_pull_policy = "Always"
port { container_port = 5000 }
env {
name = "VITE_BACKEND_SERVER"
value = "https://tasknoteapi-stg.darkroasted.vps-kinghost.net"
}
}
}
}
}
}
resource "kubernetes_service_v1" "tasknote_stg_frontend_svc" {
metadata {
name = "tasknote-stg-frontend-svc"
namespace = kubernetes_namespace_v1.tasknote_stg.metadata[0].name
}
spec {
selector = { app = "tasknote-stg-app" }
port {
port = 5000
target_port = 5000
}
type = "ClusterIP"
}
}
# Unified Ingress for App and API
resource "kubernetes_ingress_v1" "tasknote_stg_ingress" {
metadata {
name = "tasknote-stg-ingress"
namespace = kubernetes_namespace_v1.tasknote_stg.metadata[0].name
annotations = {
"kubernetes.io/ingress.class" = "traefik"
"cert-manager.io/cluster-issuer" = "letsencrypt-prod"
}
}
spec {
tls {
hosts = ["tasknote-stg.darkroasted.vps-kinghost.net", "tasknoteapi-stg.darkroasted.vps-kinghost.net"]
secret_name = "tasknote-stg-tls-certs"
}
rule {
host = "tasknote-stg.darkroasted.vps-kinghost.net"
http {
path {
path = "/"
path_type = "Prefix"
backend {
service {
name = kubernetes_service_v1.tasknote_stg_frontend_svc.metadata[0].name
port { number = 5000 }
}
}
}
}
}
rule {
host = "tasknoteapi-stg.darkroasted.vps-kinghost.net"
http {
path {
path = "/"
path_type = "Prefix"
backend {
service {
name = kubernetes_service_v1.tasknote_stg_backend_svc.metadata[0].name
port { number = 8585 }
}
}
}
}
}
}
}
+2 -2
View File
@@ -80,12 +80,12 @@ variable "root_log_level" {
variable "backend_image" {
type = string
default = "pull ghcr.io/rmcampos/tasknote/api:15"
default = "rmcampos/tasknote-api:latest"
}
variable "frontend_image" {
type = string
default = "ghcr.io/rmcampos/tasknote/app:app-v2026.03.17.18"
default = "rmcampos/tasknote-app:latest"
}
resource "kubernetes_namespace_v1" "tasknote" {

Some files were not shown because too many files have changed in this diff Show More