Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0250b558cf | ||
|
|
891c526363 | ||
|
|
642eee6de1 | ||
|
|
4b04bfbbcf | ||
|
|
c9c2e97d06
|
||
|
|
acb08f7e14 | ||
|
|
4164b7dd97
|
||
|
|
0fc1cc3d65
|
||
|
|
3b591ac85e
|
||
|
|
b1cece9138
|
||
|
|
1ff3ba964c | ||
|
|
eea8313f30
|
||
|
|
419a33f7fc | ||
|
|
169f07801a | ||
|
|
a55a80e916 | ||
|
|
6272650ef9 | ||
|
|
54eeb66f0d | ||
|
|
a9f2b7b3a4
|
||
|
|
d6a7ee34ef
|
||
|
|
ce76f94237 | ||
|
|
68f8000ed8
|
||
|
|
10c11e8f0b | ||
|
|
0a35bc0f77
|
||
|
|
31b2c9da8e | ||
|
|
17bcdd53bf
|
||
|
|
10e0bba9fb | ||
|
|
b93caa1397
|
||
|
|
894aec1ff7 | ||
|
|
93efe09407 | ||
|
|
a88a6788c6 | ||
|
|
ae24edfa2a | ||
|
|
01577a773a | ||
|
|
931e361836 | ||
|
|
db0cdeda91 | ||
|
|
4d9b3e2986 | ||
|
|
bceb9a2e49 | ||
|
|
39cbe80582 | ||
|
|
5fec30fea0 | ||
|
|
a35c037f20 | ||
|
|
f34c359720 |
@@ -6,7 +6,7 @@ on:
|
||||
jobs:
|
||||
build-and-push-app:
|
||||
name: Build & Push App
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: easynode-debian
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -19,20 +19,19 @@ jobs:
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
run: docker buildx inspect --bootstrap
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}/app
|
||||
images: rmcampos/tasknote-app
|
||||
tags: |
|
||||
type=raw,value=candidate
|
||||
|
||||
@@ -49,9 +48,17 @@ jobs:
|
||||
with:
|
||||
context: ./client
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
tags: |
|
||||
${{ steps.meta.outputs.tags }}
|
||||
rmcampos/tasknote-app:${{ steps.version.outputs.tag }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
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: Log pushed images
|
||||
run: |
|
||||
echo "Pushed images:"
|
||||
echo " rmcampos/tasknote-app:candidate"
|
||||
echo " rmcampos/tasknote-app:${{ steps.version.outputs.tag }}"
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
jobs:
|
||||
build-and-push-server:
|
||||
name: Build & Push Server
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: graalvm-25
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -18,42 +18,34 @@ jobs:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Set lowercase repo name
|
||||
id: repo
|
||||
run: echo "name=${GITHUB_REPOSITORY,,}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v4
|
||||
- name: Cache Maven dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '25'
|
||||
cache: 'maven'
|
||||
cache-dependency-path: 'server/pom.xml'
|
||||
path: ~/.m2/repository
|
||||
key: ${{ runner.os }}-maven-${{ hashFiles('**/server/pom.xml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-maven-
|
||||
|
||||
- name: Log in to GHCR
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Cache Buildpack layers
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/reproducible-builds
|
||||
key: ${{ runner.os }}-buildpack-${{ hashFiles('server/pom.xml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildpack-
|
||||
|
||||
- name: Build Docker image
|
||||
working-directory: ./server
|
||||
run: |
|
||||
# Use the dynamic repo name to prevent tagging errors
|
||||
./mvnw -Pnative -DskipTests spring-boot:build-image \
|
||||
-Dspring-boot.build-image.imageName=ghcr.io/${{ steps.repo.outputs.name }}/api:latest \
|
||||
-Dspring-boot.build-image.builder=paketobuildpacks/builder-jammy-tiny:latest
|
||||
-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 candidate
|
||||
run: |
|
||||
docker tag ghcr.io/${{ steps.repo.outputs.name }}/api:latest ghcr.io/${{ steps.repo.outputs.name }}/api:candidate
|
||||
docker push ghcr.io/${{ steps.repo.outputs.name }}/api:candidate
|
||||
docker tag rmcampos/tasknote-api:latest rmcampos/tasknote-api:candidate
|
||||
docker push rmcampos/tasknote-api:candidate
|
||||
|
||||
- name: Log pushed images
|
||||
run: |
|
||||
echo "Pushed images:"
|
||||
echo " rmcampos/tasknote-api:latest"
|
||||
echo " rmcampos/tasknote-api:candidate"
|
||||
@@ -20,14 +20,14 @@ on:
|
||||
jobs:
|
||||
terraform-plan:
|
||||
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: easynode-debian
|
||||
outputs:
|
||||
no_changes: ${{ steps.check-changes.outputs.no_changes }}
|
||||
has_changes: ${{ steps.check-changes.outputs.has_changes }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -69,10 +69,10 @@ jobs:
|
||||
fi
|
||||
|
||||
if [ -z "$backend_image" ]; then
|
||||
backend_image="ghcr.io/rmcampos/tasknote/api:$latest_backend_tag"
|
||||
backend_image="rmcampos/tasknote-api:$latest_backend_tag"
|
||||
fi
|
||||
if [ -z "$frontend_image" ]; then
|
||||
frontend_image="ghcr.io/rmcampos/tasknote/app:$latest_frontend_tag"
|
||||
frontend_image="rmcampos/tasknote-app:$latest_frontend_tag"
|
||||
fi
|
||||
|
||||
echo "Resolved backend_image=$backend_image"
|
||||
@@ -115,59 +115,17 @@ jobs:
|
||||
-var="frontend_image=${{ steps.deploy-vars.outputs.frontend_image }}"
|
||||
terraform show -json tfplan > tfplan.json
|
||||
if jq -e '.resource_changes | length == 0' tfplan.json >/dev/null; then
|
||||
echo "no_changes=true" >> "$GITHUB_OUTPUT"
|
||||
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No changes to apply."
|
||||
exit 0
|
||||
else
|
||||
echo "Changes detected. Proceeding with apply"
|
||||
echo "no_changes=false" >> "$GITHUB_OUTPUT"
|
||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Upload plan artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: tfplan
|
||||
path: terraform/tfplan
|
||||
|
||||
terraform-apply:
|
||||
runs-on: ubuntu-latest
|
||||
needs: terraform-plan
|
||||
if: >
|
||||
(github.event_name == 'push' || github.event_name == 'workflow_run' || inputs.apply == 'true')
|
||||
&& needs.terraform-plan.outputs.no_changes == 'false'
|
||||
environment:
|
||||
name: production
|
||||
url: https://tasknote.darkroasted.vps-kinghost.net
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Terraform
|
||||
uses: hashicorp/setup-terraform@v3
|
||||
|
||||
- name: Download plan artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: tfplan
|
||||
path: terraform
|
||||
|
||||
- name: Setup Kubeconfig
|
||||
run: |
|
||||
mkdir -p ~/.kube
|
||||
echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config
|
||||
chmod 600 ~/.kube/config
|
||||
|
||||
- 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
|
||||
|
||||
- name: Terraform Apply
|
||||
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 }}
|
||||
|
||||
@@ -9,10 +9,10 @@ on:
|
||||
jobs:
|
||||
terraform-plan-stg:
|
||||
name: Plan changs to staging
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: easynode-debian
|
||||
outputs:
|
||||
no_changes: ${{ steps.check-changes.outputs.no_changes }}
|
||||
has_changes: ${{ steps.check-changes.outputs.has_changes }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
@@ -45,8 +45,8 @@ jobs:
|
||||
- name: Determine deployment values
|
||||
id: deploy-vars
|
||||
run: |
|
||||
backend_image="ghcr.io/rmcampos/tasknote/api:candidate"
|
||||
frontend_image="ghcr.io/rmcampos/tasknote/app:candidate"
|
||||
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"
|
||||
@@ -84,57 +84,17 @@ jobs:
|
||||
-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 "no_changes=true" >> "$GITHUB_OUTPUT"
|
||||
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No changes to apply."
|
||||
exit 0
|
||||
else
|
||||
echo "Changes detected. Proceeding with apply"
|
||||
echo "no_changes=false" >> "$GITHUB_OUTPUT"
|
||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Upload plan artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: tfplan
|
||||
path: terraform-stg/tfplan
|
||||
|
||||
terraform-apply:
|
||||
runs-on: ubuntu-latest
|
||||
needs: terraform-plan-stg
|
||||
if: needs.terraform-plan-stg.outputs.no_changes == 'false'
|
||||
environment:
|
||||
name: staging
|
||||
url: https://tasknote-stg.darkroasted.vps-kinghost.net
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Terraform
|
||||
uses: hashicorp/setup-terraform@v3
|
||||
|
||||
- name: Download plan artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: tfplan
|
||||
path: terraform-stg
|
||||
|
||||
- name: Setup Kubeconfig
|
||||
run: |
|
||||
mkdir -p ~/.kube
|
||||
echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config
|
||||
chmod 600 ~/.kube/config
|
||||
|
||||
- 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 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 }}
|
||||
|
||||
@@ -9,75 +9,63 @@ on:
|
||||
- 'server/**/*.java'
|
||||
- 'server/**/*.xml'
|
||||
- 'server/pom.xml'
|
||||
- '.github/workflows/main-server.yml'
|
||||
- '.github/workflows/ci-main-backend.yml'
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build & Push
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: graalvm-25
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Set lowercase repo name
|
||||
id: repo
|
||||
run: echo "name=${GITHUB_REPOSITORY,,}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v4
|
||||
- name: Cache Maven dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '25'
|
||||
cache: 'maven'
|
||||
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: |
|
||||
# Extract current version from pom.xml
|
||||
CURRENT_VERSION=$(./mvnw help:evaluate -Dexpression=project.version -q -DforceStdout)
|
||||
echo "Current version: ${CURRENT_VERSION}"
|
||||
|
||||
# Increment version
|
||||
NEW_VERSION=$((CURRENT_VERSION + 1))
|
||||
echo "New version: ${NEW_VERSION}"
|
||||
|
||||
# Update pom.xml with new version
|
||||
./mvnw versions:set -DnewVersion=${NEW_VERSION} -DgenerateBackupFiles=false -q
|
||||
|
||||
# Output for later steps
|
||||
echo "version=${NEW_VERSION}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Commit version bump
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
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 GitHub Container Registry
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Find PR number
|
||||
id: find_pr
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
PR_NUMBER=$(gh pr list --search "${{ github.sha }}" --state merged --json number --jq '.[0].number')
|
||||
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"
|
||||
@@ -88,14 +76,21 @@ jobs:
|
||||
|
||||
- name: Promote Docker image
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--tag ghcr.io/${{ steps.repo.outputs.name }}/api:latest \
|
||||
--tag ghcr.io/${{ steps.repo.outputs.name }}/api:${{ steps.version.outputs.version }} \
|
||||
ghcr.io/${{ steps.repo.outputs.name }}/api:${{ steps.find_pr.outputs.tag }}
|
||||
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 "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
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 }}"
|
||||
@@ -15,50 +15,38 @@ on:
|
||||
- 'client/**/*.js'
|
||||
- 'client/Dockerfile'
|
||||
- 'client/Caddyfile'
|
||||
- '.github/workflows/main-client.yml'
|
||||
- '.github/workflows/ci-main-frontend.yml'
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build & Push
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: easynode-debian
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set lowercase repo name
|
||||
id: repo
|
||||
run: echo "name=${GITHUB_REPOSITORY,,}" >> $GITHUB_OUTPUT
|
||||
|
||||
- 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
|
||||
uses: docker/setup-buildx-action@v3
|
||||
run: docker buildx inspect --bootstrap
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Find PR number
|
||||
id: find_pr
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
PR_NUMBER=$(gh pr list --search "${{ github.sha }}" --state merged --json number --jq '.[0].number')
|
||||
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"
|
||||
@@ -67,17 +55,35 @@ jobs:
|
||||
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 ghcr.io/${{ steps.repo.outputs.name }}/app:latest \
|
||||
--tag ghcr.io/${{ steps.repo.outputs.name }}/app:${{ steps.version.outputs.tag }} \
|
||||
ghcr.io/${{ steps.repo.outputs.name }}/app:${{ steps.find_pr.outputs.tag }}
|
||||
--tag rmcampos/tasknote-app:latest \
|
||||
rmcampos/tasknote-app:${{ steps.find_pr.outputs.tag }}
|
||||
|
||||
- name: Create and push Git tag
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
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 }}"
|
||||
@@ -11,28 +11,28 @@ on:
|
||||
- 'server/**/*.xml'
|
||||
- 'server/pom.xml'
|
||||
- 'server/**/*.yml'
|
||||
- '.github/workflows/server-ci.yml'
|
||||
- '.github/workflows/ci-pr-backend.yml'
|
||||
|
||||
jobs:
|
||||
run-checks:
|
||||
name: Checks
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: graalvm-25
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v4
|
||||
- name: Cache Maven dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '25'
|
||||
cache: 'maven'
|
||||
cache-dependency-path: 'server/pom.xml'
|
||||
path: ~/.m2/repository
|
||||
key: ${{ runner.os }}-maven-${{ hashFiles('**/server/pom.xml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-maven-
|
||||
|
||||
- name: Run Check Style
|
||||
working-directory: ./server
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
|
||||
build-and-push:
|
||||
name: Build & Push
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: graalvm-25
|
||||
needs: ["run-checks"]
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -57,74 +57,49 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set lowercase repo name
|
||||
id: repo
|
||||
run: echo "name=${GITHUB_REPOSITORY,,}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v4
|
||||
- name: Cache Maven dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '25'
|
||||
cache: 'maven'
|
||||
cache-dependency-path: 'server/pom.xml'
|
||||
path: ~/.m2/repository
|
||||
key: ${{ runner.os }}-maven-${{ hashFiles('**/server/pom.xml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-maven-
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Cache Buildpack layers
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/reproducible-builds
|
||||
key: ${{ runner.os }}-buildpack-${{ hashFiles('server/pom.xml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildpack-
|
||||
|
||||
- name: Build Docker image with Spring Boot
|
||||
working-directory: ./server
|
||||
run: |
|
||||
./mvnw -Pnative -DskipTests spring-boot:build-image \
|
||||
-Dspring-boot.build-image.imageName=ghcr.io/${{ steps.repo.outputs.name }}/api:latest \
|
||||
-Dspring-boot.build-image.builder=paketobuildpacks/builder-jammy-tiny:latest
|
||||
-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 ghcr.io/${{ steps.repo.outputs.name }}/api:latest ghcr.io/${{ steps.repo.outputs.name }}/api:candidate
|
||||
docker tag ghcr.io/${{ steps.repo.outputs.name }}/api:latest ghcr.io/${{ steps.repo.outputs.name }}/api:pr-${{ github.event.pull_request.number }}
|
||||
docker push ghcr.io/${{ steps.repo.outputs.name }}/api:candidate
|
||||
docker push ghcr.io/${{ steps.repo.outputs.name }}/api:pr-${{ github.event.pull_request.number }}
|
||||
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 GitHub deployment for staging
|
||||
- name: Create Gitea deployment for staging
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
const ref = context.payload.pull_request.head.sha;
|
||||
const env = 'staging';
|
||||
const resp = await github.rest.repos.createDeployment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
ref,
|
||||
required_contexts: [],
|
||||
environment: env,
|
||||
description: `PR #${context.payload.pull_request.number} preview deployment`,
|
||||
transient_environment: true,
|
||||
auto_merge: false
|
||||
});
|
||||
// create a deployment status pointing to the staging URL
|
||||
await github.rest.repos.createDeploymentStatus({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
deployment_id: resp.data.id,
|
||||
state: 'success',
|
||||
environment_url: 'https://tasknote-stg.darkroasted.vps-kinghost.net'
|
||||
});
|
||||
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 }}"
|
||||
@@ -16,26 +16,36 @@ on:
|
||||
- 'client/**/*.js'
|
||||
- 'client/Dockerfile'
|
||||
- 'client/Caddyfile'
|
||||
- '.github/workflows/client-ci.yml'
|
||||
- '.github/workflows/ci-pr-frontend.yml'
|
||||
|
||||
jobs:
|
||||
run-checks:
|
||||
name: Checks
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: easynode-debian
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v3
|
||||
- name: Cache npm dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
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
|
||||
@@ -55,7 +65,7 @@ jobs:
|
||||
|
||||
build-and-push:
|
||||
name: Build & Push
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: easynode-debian
|
||||
needs: ["run-checks"]
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -69,20 +79,19 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
run: docker buildx inspect --bootstrap
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}/app
|
||||
images: rmcampos/tasknote-app
|
||||
tags: |
|
||||
type=raw,value=candidate
|
||||
type=raw,value=pr-${{ github.event.pull_request.number }}
|
||||
@@ -100,35 +109,27 @@ jobs:
|
||||
with:
|
||||
context: ./client
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
tags: |
|
||||
${{ steps.meta.outputs.tags }}
|
||||
rmcampos/tasknote-app:${{ steps.version.outputs.tag }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
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 GitHub deployment for staging
|
||||
- name: Create Gitea deployment for staging
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
const ref = context.payload.pull_request.head.sha;
|
||||
const env = 'staging';
|
||||
const resp = await github.rest.repos.createDeployment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
ref,
|
||||
required_contexts: [],
|
||||
environment: env,
|
||||
description: `PR #${context.payload.pull_request.number} preview deployment`,
|
||||
transient_environment: true,
|
||||
auto_merge: false
|
||||
});
|
||||
// create a deployment status pointing to the staging URL
|
||||
await github.rest.repos.createDeploymentStatus({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
deployment_id: resp.data.id,
|
||||
state: 'success',
|
||||
environment_url: 'https://tasknote-stg.darkroasted.vps-kinghost.net'
|
||||
});
|
||||
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 }}"
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to Tasknote will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## 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 favour of regular browser input date UI.
|
||||
|
||||
## app-v2026.07.01.140 - 2026-07-01
|
||||
|
||||
### Added
|
||||
- Support for `Draft` notes and tasks.
|
||||
- Memory for open notes in the home page, if a tab is closed, the app will remember.
|
||||
|
||||
### Fixed
|
||||
- Frontend app build version release getting lost in workflows.
|
||||
|
||||
### Security
|
||||
- Addressed a list of critical security issues including validations, logging, and passwords.
|
||||
|
||||
### Docker images
|
||||
- `rmcampos/tasknote-app:app-v2026.07.01.140`
|
||||
|
||||
### Changed
|
||||
- Bumped client minor and major dependencies.
|
||||
|
||||
### Docker images
|
||||
- `docker.io/rmcampos/tasknote-app:app-v2026.06.24.?`
|
||||
|
||||
## api-v32 && app-v2026.06.15.97 - 2026-06-15
|
||||
|
||||
### Changed
|
||||
- Bumped Spring Boot to 4.0.7
|
||||
- CI/CD workflow files updated to run on Gitea.
|
||||
- Container registry switched to Docker Hub.
|
||||
|
||||
### Docker images
|
||||
- [rmcampos/tasknote-api:32](https://hub.docker.com/layers/rmcampos/tasknote-api/32/images/sha256-4b719a08dbed4a9d4a6eece0059573954ee5193ab8247787fb0e30c037f6b1c6)
|
||||
- [rmcampos/tasknote-app:app-v2026.06.15.97](https://hub.docker.com/layers/rmcampos/tasknote-app/app-v2026.06.15.97/images/sha256-945a215a7105e34f97ab8e43094092e157156c0b557364260c019c4036cf845d)
|
||||
|
||||
## [app-v2026.06.08.22](https://github.com/RMCampos/tasknote/releases/tag/app-v2026.06.08.22) - 2026-06-08
|
||||
|
||||
### Added
|
||||
- Shell Script to confirm new users using docker and sql;
|
||||
|
||||
### Changed
|
||||
- Bumped frontend dependencies to latest versions;
|
||||
- `@types/node` from `25.9.1` to `25.9.2`
|
||||
- `dompurify` from `3.4.7` to `3.4.8`
|
||||
- `i18next` from `26.3.0` to `26.3.1`
|
||||
- `react` from `19.2.6` to `19.2.7`
|
||||
- `react-dom` from `19.2.6` to `19.2.7`
|
||||
- `react-router` from `7.16.0` to `7.17.0`
|
||||
- `@types/react` from `19.2.15` to `19.2.17`
|
||||
- `eslint-plugin-n` from `18.0.1` to `18.1.0`
|
||||
- `typescript-eslint` from `8.60.0` to `8.61.0`
|
||||
- Ngrok and Dev Docker composer files to run using local users id and group id (`UID` and `GID`);
|
||||
|
||||
## [app-v2026.06.08.21](https://github.com/RMCampos/tasknote/releases/tag/app-v2026.06.08.21) - 2026-06-08
|
||||
|
||||
### Changed
|
||||
- The About page to list all current features and tech stack. ([#62](https://github.com/RMCampos/tasknote/issues/62))
|
||||
|
||||
## [app-v2026.06.01.20] - 2026-06-01
|
||||
|
||||
### Changed
|
||||
- Bumped backend and frontend dependencies to latest versions. (#61)
|
||||
|
||||
## [app-v2026.05.26.19] - 2026-05-26
|
||||
|
||||
### Added
|
||||
- SDD and DDD specification files for AI-assisted development, including Spec 001 implementation. (#60)
|
||||
|
||||
## [app-v2026.05.19.18] - 2026-05-19
|
||||
|
||||
### Changed
|
||||
- Auth session refresh now uses server-authoritative current user data instead of stale client state. (#59)
|
||||
|
||||
## [app-v2026.05.18.17] - 2026-05-18
|
||||
|
||||
### Added
|
||||
- Last activity date/time is now tracked and displayed for tasks and notes. (#57)
|
||||
|
||||
## [app-v2026.05.17.16] - 2026-05-17
|
||||
|
||||
### Added
|
||||
- Users must confirm their email address before they can log in. (#56)
|
||||
- Migrated app to new domain with Traefik redirect middleware. (#53)
|
||||
|
||||
### Fixed
|
||||
- Mailgun authentication error (401) when sending emails. (#55)
|
||||
- New domain correctly allowed in CORS and CSP configuration. (#54)
|
||||
|
||||
## [app-v2026.05.13.15] - 2026-05-13
|
||||
|
||||
### Changed
|
||||
- Dropped refresh token logic; auth now relies solely on short-lived access tokens, simplifying the session flow. (#51)
|
||||
|
||||
## [app-v2026.05.13.14] - 2026-05-13
|
||||
|
||||
### Added
|
||||
- Cypress E2E tests covering Home, Task, and Notes management flows. (#46)
|
||||
- Cypress E2E tests for authentication flows. (#39)
|
||||
- Scheduled database backups. (#48)
|
||||
|
||||
### Fixed
|
||||
- Premature route resolution before initial auth check completes. (#45)
|
||||
- Blocked inline styles in CSP via SHA-256 hash in `style-src`. (#43)
|
||||
- Frontend test warnings and errors. (#37)
|
||||
|
||||
### Changed
|
||||
- Upgraded Vite to v8. (#35)
|
||||
|
||||
## [app-v2026.04.29.4] - 2026-04-29
|
||||
|
||||
### Added
|
||||
- Gemini CLI commands and updated AI agent configuration.
|
||||
|
||||
### Changed
|
||||
- Upgraded TypeScript to v6. (#34)
|
||||
|
||||
## [app-v2026.04.29.3] - 2026-04-29
|
||||
|
||||
### Changed
|
||||
- Updated client dependencies to latest minor versions. (#33, #32)
|
||||
|
||||
## [app-v2026.04.16.1] - 2026-04-16
|
||||
|
||||
### Added
|
||||
- Dev environment config files and scripts to run the app locally on VPS. (#29)
|
||||
|
||||
### Security
|
||||
- Improved security posture and added protections against XSS attacks. (#30)
|
||||
|
||||
## [app-v2026.04.08.20] - 2026-04-08
|
||||
|
||||
### Added
|
||||
- GitHub Actions workflow for building server candidate images.
|
||||
|
||||
### Changed
|
||||
- Bumped Spring Boot to 4.0.5. (#27)
|
||||
|
||||
## [app-v2026.04.06.19] - 2026-04-06
|
||||
|
||||
### Added
|
||||
- Kubernetes CD pipeline via GitHub Actions.
|
||||
- Dev container for Java development.
|
||||
- Workflow skips deployment when no source changes detected.
|
||||
|
||||
## [app-v2026.03.17.17] - 2026-03-17
|
||||
|
||||
### Fixed
|
||||
- Missing email templates and configuration for new domain.
|
||||
|
||||
## [app-v2026.03.16.16] - 2026-03-16
|
||||
|
||||
### Fixed
|
||||
- Backend memory leak and state management issues. (#23)
|
||||
- Error messages not propagating back to client in Spring v4.
|
||||
|
||||
### Changed
|
||||
- Updated error messages and translations for improved user feedback.
|
||||
|
||||
## [app-v2026.02.28.15] - 2026-02-28
|
||||
|
||||
### Added
|
||||
- Public note sharing: users can share a note via a public link. (#22)
|
||||
|
||||
## [app-v2026.02.27.13] - 2026-02-27
|
||||
|
||||
### Added
|
||||
- Source and Copy buttons in the note markdown preview modal. (#21)
|
||||
|
||||
### Changed
|
||||
- Upgraded backend to Spring Boot 4.0.3 and Java 25. (#20)
|
||||
|
||||
## [app-v2026.02.05.5] - 2026-02-05
|
||||
|
||||
### Changed
|
||||
- Improved markdown rendering and added home filter context.
|
||||
- Added notes tags support throughout the app. (#13)
|
||||
- Tag suggestion dropdown for Notes and Tasks. (#12)
|
||||
|
||||
### Fixed
|
||||
- Backend null pointer exception.
|
||||
- Filters now persist between actions in the Home view. (#9)
|
||||
|
||||
## [app-v2026.01.18.4] - 2026-01-18
|
||||
|
||||
### Added
|
||||
- CI now uses GHCR (GitHub Container Registry) with unified version tagging for backend images.
|
||||
|
||||
### Changed
|
||||
- Upgraded Spring Boot to 3.5.9.
|
||||
|
||||
### Fixed
|
||||
- Docker image name casing issue causing deployment failures.
|
||||
|
||||
## [app-v2026.01.14.3] - 2026-01-14
|
||||
|
||||
### Changed
|
||||
- Dropped Lombok dependency; bumped to Spring 3.5.9.
|
||||
- Updated backend dependencies to latest versions.
|
||||
|
||||
## [app-v2025.12.15.1] - 2025-12-15
|
||||
|
||||
### Added
|
||||
- Initial release with core task and note management features.
|
||||
- Bruno API collections for local development.
|
||||
|
||||
[app-v2026.06.01.20]: https://github.com/RMCampos/tasknote/compare/app-v2026.05.26.19...app-v2026.06.01.20
|
||||
[app-v2026.05.26.19]: https://github.com/RMCampos/tasknote/compare/app-v2026.05.19.18...app-v2026.05.26.19
|
||||
[app-v2026.05.19.18]: https://github.com/RMCampos/tasknote/compare/app-v2026.05.18.17...app-v2026.05.19.18
|
||||
[app-v2026.05.18.17]: https://github.com/RMCampos/tasknote/compare/app-v2026.05.17.16...app-v2026.05.18.17
|
||||
[app-v2026.05.17.16]: https://github.com/RMCampos/tasknote/compare/app-v2026.05.13.15...app-v2026.05.17.16
|
||||
[app-v2026.05.13.15]: https://github.com/RMCampos/tasknote/compare/app-v2026.05.13.14...app-v2026.05.13.15
|
||||
[app-v2026.05.13.14]: https://github.com/RMCampos/tasknote/compare/app-v2026.04.29.4...app-v2026.05.13.14
|
||||
[app-v2026.04.29.4]: https://github.com/RMCampos/tasknote/compare/app-v2026.04.29.3...app-v2026.04.29.4
|
||||
[app-v2026.04.29.3]: https://github.com/RMCampos/tasknote/compare/app-v2026.04.16.1...app-v2026.04.29.3
|
||||
[app-v2026.04.16.1]: https://github.com/RMCampos/tasknote/compare/app-v2026.04.08.20...app-v2026.04.16.1
|
||||
[app-v2026.04.08.20]: https://github.com/RMCampos/tasknote/compare/app-v2026.04.06.19...app-v2026.04.08.20
|
||||
[app-v2026.04.06.19]: https://github.com/RMCampos/tasknote/compare/app-v2026.03.17.17...app-v2026.04.06.19
|
||||
[app-v2026.03.17.17]: https://github.com/RMCampos/tasknote/compare/app-v2026.03.16.16...app-v2026.03.17.17
|
||||
[app-v2026.03.16.16]: https://github.com/RMCampos/tasknote/compare/app-v2026.02.28.15...app-v2026.03.16.16
|
||||
[app-v2026.02.28.15]: https://github.com/RMCampos/tasknote/compare/app-v2026.02.27.13...app-v2026.02.28.15
|
||||
[app-v2026.02.27.13]: https://github.com/RMCampos/tasknote/compare/app-v2026.02.05.5...app-v2026.02.27.13
|
||||
[app-v2026.02.05.5]: https://github.com/RMCampos/tasknote/compare/app-v2026.01.18.4...app-v2026.02.05.5
|
||||
[app-v2026.01.18.4]: https://github.com/RMCampos/tasknote/compare/app-v2026.01.14.3...app-v2026.01.18.4
|
||||
[app-v2026.01.14.3]: https://github.com/RMCampos/tasknote/compare/app-v2025.12.15.1...app-v2026.01.14.3
|
||||
[app-v2025.12.15.1]: https://github.com/RMCampos/tasknote/releases/tag/app-v2025.12.15.1
|
||||
@@ -1,10 +1,9 @@
|
||||
# TaskNote
|
||||
|
||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||
[](https://github.com/RMCampos/tasknote/actions/workflows/client-ci.yml)
|
||||
[](https://github.com/RMCampos/tasknote/actions/workflows/server-ci.yml)
|
||||
[](https://github.com/RMCampos/tasknote/actions/workflows/main-client.yml)
|
||||
[](https://github.com/RMCampos/tasknote/actions/workflows/main-server.yml)
|
||||
[](https://lightroasted.vps-kinghost.net/rmcampos/tasknote/actions/?workflow=ci-main-frontend.yml)
|
||||
[](https://lightroasted.vps-kinghost.net/rmcampos/tasknote/actions/?workflow=ci-main-backend.yml)
|
||||
[](https://lightroasted.vps-kinghost.net/rmcampos/tasknote/actions/?workflow=cd-main.yml)
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
||||
@@ -102,113 +101,50 @@ tasknote/
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Prerequisites
|
||||
- **Docker & Docker Compose** (recommended for easy setup)
|
||||
- **Node.js 20+** and **npm** (for frontend development)
|
||||
- **Java 25+** and **Maven 3.6+** (for backend development)
|
||||
- **PostgreSQL 15+** (if running without Docker)
|
||||
- [Docker](https://docs.docker.com/engine/install/)
|
||||
- [Docker Compose](https://docs.docker.com/compose/install/)
|
||||
- [Task](https://taskfile.dev) (`brew install go-task` / `npm install -g @go-task/cli`)
|
||||
- [Doppler CLI](https://docs.doppler.com/docs/install-cli) (`brew install dopplerhq/cli/doppler`)
|
||||
|
||||
### Quick Start with Docker
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone https://github.com/rmcampos/tasknote.git
|
||||
cd tasknote
|
||||
```
|
||||
### Setup
|
||||
|
||||
2. **Start the database**
|
||||
```bash
|
||||
bash tools/run-docker-db.sh
|
||||
```
|
||||
|
||||
3. **Start the backend server**
|
||||
```bash
|
||||
bash tools/run-docker-server.sh
|
||||
```
|
||||
|
||||
4. **Start the frontend application**
|
||||
```bash
|
||||
bash tools/run-docker-client.sh
|
||||
```
|
||||
|
||||
5. **Access the application**
|
||||
- Frontend: http://localhost:5000
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
### Frontend Development
|
||||
```bash
|
||||
cd client
|
||||
npm install # Install dependencies
|
||||
npm start # Start development server (port 5000)
|
||||
npm run build # Build for production
|
||||
npm run preview # Preview production build
|
||||
npm run lint # Run ESLint
|
||||
npm run lint:fix # Fix ESLint issues
|
||||
# 1. Authenticate with Doppler and link the project
|
||||
doppler login
|
||||
doppler setup # uses doppler.yaml to link to the shell-whats project
|
||||
```
|
||||
|
||||
### Backend Development
|
||||
### Running locally
|
||||
|
||||
```bash
|
||||
cd server
|
||||
./mvnw spring-boot:run # Start development server
|
||||
./mvnw clean compile # Compile sources
|
||||
./mvnw spring-boot:build-image # Build Docker image
|
||||
./mvnw clean verify -Pnative # Build GraalVM native image
|
||||
task dev-run
|
||||
```
|
||||
|
||||
### Quality Checks
|
||||
Run quality checks before submitting changes:
|
||||
This exports the public vars from the `dev_tokens` Doppler config and starts the server in watch mode with secrets injected from `dev_secrets`. No `.env` file needed.
|
||||
|
||||
## Building the Docker images
|
||||
|
||||
```bash
|
||||
bash tools/check-frontend.sh # Frontend linting, testing, coverage
|
||||
bash tools/check-backend.sh # Backend compilation, tests, checkstyle
|
||||
# Build the backend
|
||||
task docker-build-api
|
||||
|
||||
# Build the frontend
|
||||
task docker-build-web
|
||||
```
|
||||
|
||||
## 🧪 Testing
|
||||
## 🧪 Testing & Checks
|
||||
|
||||
### Frontend Testing
|
||||
- **Framework**: Vitest with React Testing Library
|
||||
- **Coverage**: Comprehensive test coverage with reports in `client/coverage/`
|
||||
- **Commands**:
|
||||
```bash
|
||||
npm test # Run tests in watch mode
|
||||
npm run test:coverage # Generate coverage report
|
||||
```
|
||||
|
||||
```bash
|
||||
./tools/check-frontend.sh
|
||||
```
|
||||
|
||||
### Backend Testing
|
||||
- **Unit Tests**: Fast, isolated tests with mocked dependencies
|
||||
- **Integration Tests**: Full application context with test database
|
||||
- **Coverage**: JaCoCo reporting with 75% minimum requirement
|
||||
- **Commands**:
|
||||
```bash
|
||||
./mvnw test # Unit tests only
|
||||
./mvnw clean verify -Ptests # All tests with coverage
|
||||
```
|
||||
|
||||
## 📦 Deployment
|
||||
|
||||
### Production Deployment
|
||||
The application supports multiple deployment strategies:
|
||||
|
||||
1. **Docker Containers** (recommended)
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
2. **Traditional JAR Deployment**
|
||||
```bash
|
||||
cd server && ./mvnw clean package
|
||||
java -jar target/tasknote-api.jar
|
||||
```
|
||||
|
||||
3. **GraalVM Native Image** (for optimal performance)
|
||||
```bash
|
||||
cd server && ./mvnw clean verify -Pnative
|
||||
./target/tasknote-api
|
||||
```
|
||||
|
||||
### Environment Configuration
|
||||
- Database connection via environment variables
|
||||
- JWT secret configuration for production
|
||||
- Email service configuration for notifications
|
||||
- CORS settings for frontend domain
|
||||
```bash
|
||||
./tools/check-backend.sh
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
@@ -250,34 +186,6 @@ We welcome contributions from the community! This project follows the **Fork & M
|
||||
|
||||
For detailed setup instructions and development workflows, see [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## 👨💻 Developer
|
||||
|
||||
**Ricardo Campos** - Full-Stack Developer & Project Maintainer
|
||||
|
||||
- **GitHub**: [@RMCampos](https://github.com/RMCampos)
|
||||
- **Twitter/X**: [@RMCamposs](https://x.com/RMCamposs)
|
||||
- **LinkedIn**: [Ricardo Campos](https://www.linkedin.com/in/ricardompcampos/)
|
||||
|
||||
### About the Developer
|
||||
Ricardo is a passionate full-stack developer with expertise in modern web technologies, cloud architecture, and agile development practices. This project showcases his skills in:
|
||||
|
||||
- **Frontend Development**: React, TypeScript, modern CSS, responsive design
|
||||
- **Backend Development**: Java, Spring Boot, RESTful APIs, microservices
|
||||
- **DevOps & Infrastructure**: Docker, CI/CD, cloud deployment, monitoring
|
||||
- **Software Quality**: Testing strategies, code coverage, static analysis
|
||||
- **Open Source**: Community engagement, documentation, maintainership
|
||||
|
||||
The TaskNote project represents a commitment to clean code, comprehensive testing, and user-centered design principles.
|
||||
|
||||
## 📞 Contact
|
||||
|
||||
For questions, suggestions, or collaboration opportunities:
|
||||
|
||||
- **Email**: Contact via GitHub issues or discussions
|
||||
- **Twitter/X**: [@RMCamposs](https://x.com/RMCamposs) for quick questions
|
||||
- **GitHub Issues**: [Create an issue](https://github.com/rmcampos/tasknote/issues) for bugs or feature requests
|
||||
- **GitHub Discussions**: [Join discussions](https://github.com/rmcampos/tasknote/discussions) for general questions
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the **GNU General Public License v3.0** - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ tasks:
|
||||
|
||||
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:latest
|
||||
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
|
||||
|
||||
prod-up-web:
|
||||
desc: Speed up the tasknote-web prod-like image, building it if required
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Architecture Principles
|
||||
|
||||
## Core Principles
|
||||
|
||||
- Keep solutions simple and focused on the problem at hand.
|
||||
- Prefer readability and maintainability over cleverness.
|
||||
- Small functions and modules that do one thing well.
|
||||
- Use clear and descriptive names for variables, functions, and classes.
|
||||
- Avoid premature optimization; optimize only when necessary.
|
||||
- Avoid over-engineering; build only what is needed for the current problem.
|
||||
- Clear boundaries between components to promote separation of concerns.
|
||||
- Prefer to maintainability over perfection
|
||||
|
||||
## Delivery Principles
|
||||
|
||||
Prefer:
|
||||
- small specs
|
||||
- iterative delivery
|
||||
- vertical slices of functionality
|
||||
- simple APIs
|
||||
- clear documentation
|
||||
- understandable code
|
||||
|
||||
Avoid:
|
||||
- giant upfront architecture
|
||||
- premature optimization
|
||||
- speculative abstractions
|
||||
- over-engineering
|
||||
- unnecessary complexity
|
||||
- unnecessary microservices
|
||||
@@ -0,0 +1,17 @@
|
||||
# Domain Glossary
|
||||
|
||||
This glossary provides definitions for key terms and concepts related to the domain of artificial intelligence (AI). It serves as a reference for understanding the terminology used in AI research, development, and applications.
|
||||
|
||||
Use this file to maintain shared terminology.
|
||||
|
||||
Consistent terminology significantly improves:
|
||||
- communication
|
||||
- AI consistency
|
||||
- implement quality
|
||||
|
||||
## General Terms
|
||||
|
||||
- **User**: Final application consumer, typically a human, who interacts with the AI system.
|
||||
- **Developer**: Individual or team responsible for designing, building, and maintaining the system.
|
||||
- **Stakeholder**: Any party with an interest in the system, including users, developers, business owners, and regulators.
|
||||
- **Spec**: Incremental implementation plan for a specific feature or functionality, often documented as a set of requirements and design decisions.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Project constraints
|
||||
|
||||
## Backend
|
||||
|
||||
Always:
|
||||
|
||||
- Use the `SERIAL` type for `id` columns when creating migration SQL files;
|
||||
- Define a table PRIMARY KEY in the end of the table columns using the table name plus `_pk` and the needed columns;
|
||||
- Import needed packages one by one;
|
||||
- Create JavaDoc for public classes and methods;
|
||||
- Run `./mvnw -Ptests test` to check possible check style issues or failing tests;
|
||||
- Fix existing test cases, if needed;
|
||||
- Update docker-compose.yml and github workflow files if a new variable or application.yml has changed;
|
||||
|
||||
Never:
|
||||
- Use `ON DELETE CASCADE` in SQL scripts or migration files;
|
||||
- Use star to import packages;
|
||||
|
||||
## Frontend
|
||||
|
||||
Always:
|
||||
|
||||
- Type variables according with their data type;
|
||||
- Create helper functions in the helper directory;
|
||||
- Update Dockerfile, docker-compose.yml and github workflow files if a new environment variable was added;
|
||||
|
||||
Never:
|
||||
|
||||
- Use the `any` type;
|
||||
- Add new dependencies for small helpers or util functions, prefer implementing them;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Project Vision
|
||||
|
||||
## Project Name
|
||||
|
||||
TaskNote
|
||||
|
||||
## Problem Statement
|
||||
|
||||
In today's fast-paced world, individuals and teams often struggle to keep track of their tasks, deadlines, and project
|
||||
progress. Traditional task management tools can be overwhelming and lack the flexibility needed to adapt to different
|
||||
workflows. This leads to decreased productivity, missed deadlines, and increased stress.
|
||||
|
||||
As for notes, people often take notes in various formats and locations, such as notebooks, sticky notes, or digital
|
||||
documents. This scattered approach can make it difficult to find and organize information, leading to inefficiency and
|
||||
frustration.
|
||||
|
||||
## Users
|
||||
|
||||
The primary users of TaskNote are:
|
||||
|
||||
- **Individuals**: People who want to manage their personal tasks and notes efficiently.
|
||||
- **Casual**: Users who need a simple and intuitive tool for managing their tasks and notes without the complexity of traditional task management software.
|
||||
- **Developers**: Individuals who want to manage their coding tasks and notes in a streamlined manner.
|
||||
|
||||
## Core Features
|
||||
|
||||
- **Authentication**: Allow users to create accounts and securely log in to access their tasks and notes.
|
||||
- **Task Management**: Create, organize, and prioritize tasks with deadlines and reminders.
|
||||
- **Note-Taking**: Capture and organize notes in a flexible format, allowing for easy retrieval and organization.
|
||||
- **Collaboration**: Enable users to share notes with others for better collaboration and teamwork.
|
||||
- **Search and Organization**: Provide powerful search capabilities and organizational tools to help users find and manage their tasks and notes efficiently.
|
||||
- **Multi-Language Support**: Support multiple languages to cater to a global user base.
|
||||
|
||||
## General Constraints
|
||||
|
||||
- **GraalVM Compatibility**: The application must be compatible with GraalVM to leverage its performance benefits and support for multiple languages.
|
||||
- **Mobile-first UI**: The user interface should be designed with a mobile-first approach to ensure a seamless experience across devices.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Users can manage their tasks and notes efficiently, leading to increased productivity and reduced stress.
|
||||
- Positive user feedback and high engagement with the application.
|
||||
- Specs remain under control, with a clear roadmap for future features and improvements.
|
||||
- Codebase remains maintainable and scalable, allowing for easy addition of new features and improvements over time.
|
||||
@@ -0,0 +1,34 @@
|
||||
# Tech Stack
|
||||
|
||||
## Frontend
|
||||
|
||||
- **React**: A popular JavaScript library for building user interfaces, particularly single-page applications.
|
||||
- **TypeScript**: A superset of JavaScript that adds static typing, improving code quality and maintainability.
|
||||
- **Bootstrap**: A widely used CSS framework for building responsive and mobile-first websites.
|
||||
- **Fetch API**: A modern interface for making HTTP requests from the browser, used for communicating with the backend.
|
||||
- **Vite**: A build tool that provides a fast development environment and optimized production builds for modern web applications.
|
||||
|
||||
## Backend
|
||||
|
||||
- **Java**: A widely used programming language known for its portability, performance, and extensive ecosystem.
|
||||
- **Spring Boot**: A framework for building production-ready applications with Java, providing features like dependency injection, security, and data access.
|
||||
- **Spring Security**: A framework for securing Java applications, providing authentication and authorization features to protect resources and manage user access.
|
||||
- **GraalVM**: A high-performance runtime that supports multiple programming languages, allowing for efficient execution of Java applications and interoperability with other languages.
|
||||
- **JPA (Java Persistence API)**: A specification for managing relational data in Java applications, providing a standard way to interact with databases using object-relational mapping (ORM).
|
||||
|
||||
## Database
|
||||
- **PostgreSQL**: A powerful, open-source relational database management system known for its reliability and performance.
|
||||
|
||||
## Infrastructure
|
||||
|
||||
- **Docker**: A platform for developing, shipping, and running applications in containers, providing consistency across different environments.
|
||||
- **Kubernetes**: An open-source container orchestration system for automating the deployment, scaling, and management of containerized applications.
|
||||
- **Terraform**: An infrastructure as code tool that allows for the provisioning and management of cloud resources in a declarative manner.
|
||||
- **VPS**: Virtual Private Server, a virtualized server that provides dedicated resources and control over the hosting environment for deploying applications.
|
||||
|
||||
## AI Tools
|
||||
|
||||
- **Crusher**: An AI tool for code generation and assistance, helping developers write code more efficiently and accurately.
|
||||
- **GitHub Copilot**: An AI-powered code completion tool that provides suggestions and helps developers write code faster by leveraging machine learning models trained on a vast amount of code from GitHub repositories.
|
||||
- **Gemini**: An AI tool for natural language processing and understanding, enabling developers to build applications that can process and generate human-like text.
|
||||
- **Groq**: An AI tool for optimizing and accelerating machine learning models, providing efficient execution and improved performance for AI applications.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Feedback Contract
|
||||
|
||||
This contract is used for:
|
||||
- reviews
|
||||
- implementation feedback
|
||||
- architecture feedback
|
||||
- iterative improvement
|
||||
|
||||
---
|
||||
|
||||
## 1. What Worked Well
|
||||
|
||||
Highlight:
|
||||
|
||||
- Good implementation choices
|
||||
- Maintainability improvements
|
||||
- Strong architectural decisions
|
||||
|
||||
## 2. Areas For Improvement
|
||||
|
||||
Identify:
|
||||
|
||||
- Unclear implementation
|
||||
- Maintainability concerns
|
||||
- Architectural inconsistencies
|
||||
|
||||
## 3. Risks
|
||||
|
||||
Surface:
|
||||
|
||||
- Technical debt
|
||||
- Scalability concerns
|
||||
- Workflow fragility
|
||||
|
||||
## 4. Recommendations
|
||||
|
||||
Provide:
|
||||
|
||||
- Practical improvements
|
||||
- Simplification opportunities
|
||||
- Next iteration suggestions
|
||||
|
||||
## Philosophy
|
||||
|
||||
Feedback should:
|
||||
|
||||
- Improve clarity
|
||||
- Support maintainability
|
||||
- Remain constructive
|
||||
- Avoid unnecessary perfectionism
|
||||
@@ -0,0 +1,68 @@
|
||||
# Handoff Contract
|
||||
|
||||
This contract is used whenever work moves between roles.
|
||||
|
||||
Example:
|
||||
- PM → Architect
|
||||
- Architect → Developer
|
||||
- Developer → Reviewer
|
||||
|
||||
---
|
||||
|
||||
## 1. Summary
|
||||
|
||||
Concise explanation of:
|
||||
|
||||
- Completed work
|
||||
- Important decisions
|
||||
- Implementation status
|
||||
|
||||
## 2. Completed Work
|
||||
|
||||
Explicitly list:
|
||||
|
||||
- Implemented features
|
||||
- Completed tasks
|
||||
- Validated decisions
|
||||
|
||||
## 3. Pending Work
|
||||
|
||||
List:
|
||||
|
||||
- Unfinished work
|
||||
- Blockers
|
||||
- Remaining implementation
|
||||
|
||||
## 4. Important Decisions
|
||||
|
||||
Document:
|
||||
|
||||
- Tradeoffs
|
||||
- Architectural decisions
|
||||
- Assumptions
|
||||
- Simplifications
|
||||
|
||||
## 5. Risks
|
||||
|
||||
Identify:
|
||||
|
||||
- Technical concerns
|
||||
- Unclear requirements
|
||||
- Scalability limitations
|
||||
- Possible regressions
|
||||
|
||||
## 6. Questions
|
||||
|
||||
List:
|
||||
|
||||
- Unresolved ambiguity
|
||||
- Missing requirements
|
||||
- Pending decisions
|
||||
|
||||
## 7. Recommended Next Step
|
||||
|
||||
Clearly explain:
|
||||
|
||||
- What should happen next
|
||||
- Which role should act next
|
||||
- What should be prioritized
|
||||
@@ -0,0 +1,58 @@
|
||||
# Question Contract
|
||||
|
||||
## Description
|
||||
|
||||
This contract is used when:
|
||||
|
||||
- Requirements are not clear
|
||||
- Assumptions must be validated
|
||||
- More information is needed to proceed
|
||||
- Implementation direction is uncertain
|
||||
|
||||
## 1. Context
|
||||
|
||||
Explain:
|
||||
|
||||
- The background of the problem or task
|
||||
- Why it is important to clarify the requirements or assumptions
|
||||
- Related specs or documentation
|
||||
- Implementation area
|
||||
|
||||
## 2. Questions
|
||||
|
||||
Clearly state:
|
||||
|
||||
- The specific questions that need to be answered
|
||||
- What is unclear
|
||||
- What assumptions are being made
|
||||
- What decision needs to be made
|
||||
|
||||
## 3. Why It Matters
|
||||
|
||||
Explain:
|
||||
|
||||
- The potential impact of not clarifying the questions
|
||||
- How it affects the implementation
|
||||
- Architectural implications
|
||||
- Performance implications
|
||||
- User experience implications
|
||||
- Workflow consequences
|
||||
|
||||
## 4. Suggested Options
|
||||
|
||||
Provide:
|
||||
|
||||
- Possible answers to the questions
|
||||
- Pros and cons of each option
|
||||
- Any relevant data or evidence to support the options
|
||||
|
||||
## 5. Philosophy
|
||||
|
||||
Good questions reduce:
|
||||
|
||||
- Hallucinations
|
||||
- Unnecessary work
|
||||
- Rework
|
||||
- Implementation drift
|
||||
- Misalignment with requirements
|
||||
- Hidden assumptions
|
||||
@@ -0,0 +1,56 @@
|
||||
# Context Policy
|
||||
|
||||
AI systems perform significantly better when context is:
|
||||
- structured
|
||||
- concise
|
||||
- consistent
|
||||
- relevant
|
||||
|
||||
## Core Rules
|
||||
|
||||
### 1. Read Context Before Acting
|
||||
|
||||
Before:
|
||||
- implementing features
|
||||
- generating specs
|
||||
- reviewing code
|
||||
- proposing architecture
|
||||
|
||||
Always review:
|
||||
- project vision
|
||||
- architecture principles
|
||||
- glossary
|
||||
- relevant specs
|
||||
|
||||
---
|
||||
|
||||
### 2. Respect Shared Terminology
|
||||
|
||||
Use terminology consistently.
|
||||
|
||||
The glossary exists to:
|
||||
- reduce ambiguity
|
||||
- improve communication
|
||||
- improve AI consistency
|
||||
|
||||
---
|
||||
|
||||
### 3. Avoid Context Overload
|
||||
|
||||
More context is NOT always better.
|
||||
|
||||
Prefer:
|
||||
- focused context
|
||||
- relevant files
|
||||
- small scoped discussions
|
||||
|
||||
---
|
||||
|
||||
### 4. Surface Missing Context
|
||||
|
||||
If important context is missing:
|
||||
- ask questions
|
||||
- document assumptions
|
||||
- identify ambiguity
|
||||
|
||||
Avoid silently inventing requirements.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Handoff Rules
|
||||
|
||||
Every handoff between roles should include:
|
||||
|
||||
- completed work
|
||||
- pending work
|
||||
- risks
|
||||
- important decisions
|
||||
- open questions
|
||||
- recommended next step
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
Handoffs exist to:
|
||||
- preserve continuity
|
||||
- reduce ambiguity
|
||||
- improve collaboration
|
||||
- support incremental delivery
|
||||
|
||||
---
|
||||
|
||||
## Good Handoffs
|
||||
|
||||
Good handoffs are:
|
||||
- concise
|
||||
- explicit
|
||||
- actionable
|
||||
- context-aware
|
||||
|
||||
Avoid vague summaries.
|
||||
|
||||
---
|
||||
|
||||
## Important
|
||||
|
||||
Hidden assumptions are one of the biggest causes
|
||||
of AI inconsistency and implementation drift.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Orchestrator
|
||||
|
||||
The orchestrator coordinates:
|
||||
- context
|
||||
- specs
|
||||
- architecture
|
||||
- implementation
|
||||
- review workflows
|
||||
|
||||
## Primary Goals
|
||||
|
||||
- maintain incremental progress
|
||||
- reduce ambiguity
|
||||
- preserve consistency
|
||||
- support maintainability
|
||||
|
||||
---
|
||||
|
||||
## Orchestration Responsibilities
|
||||
|
||||
- identify next executable spec
|
||||
- validate dependencies
|
||||
- coordinate role transitions
|
||||
- ensure contracts are respected
|
||||
- maintain delivery momentum
|
||||
|
||||
---
|
||||
|
||||
## Workflow Philosophy
|
||||
|
||||
Prefer:
|
||||
- small focused steps
|
||||
- explicit transitions
|
||||
- structured handoffs
|
||||
- iterative delivery
|
||||
|
||||
Avoid:
|
||||
- giant implementation phases
|
||||
- uncontrolled context growth
|
||||
- hidden assumptions
|
||||
@@ -0,0 +1,71 @@
|
||||
# AI Workflow
|
||||
|
||||
This repository follows a lightweight AI-native engineering workflow.
|
||||
|
||||
```text
|
||||
Context → Specs → Architecture → Implementation → Review → Iteration
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 1. Context Definition
|
||||
|
||||
Goal: create shared understanding.
|
||||
|
||||
Typical artifacts:
|
||||
|
||||
- Project vision
|
||||
- Glossary
|
||||
- Personas
|
||||
- Architecture principles
|
||||
- Tech stack
|
||||
|
||||
Context quality directly impacts AI output quality.
|
||||
|
||||
# 2. Spec Generation
|
||||
|
||||
Goal: break work into small incremental deliverables.
|
||||
|
||||
Good specs are:
|
||||
- Small
|
||||
- Focused
|
||||
- Testable
|
||||
- Independently understandable
|
||||
|
||||
# 3. Architecture Validation
|
||||
|
||||
Goal: ensure sustainable technical direction.
|
||||
|
||||
Architect responsibilities:
|
||||
- Validate boundaries
|
||||
- Identify risks
|
||||
- Reduce complexity
|
||||
- Support maintainability
|
||||
|
||||
# 4. Implementation
|
||||
|
||||
Goal: deliver working software incrementally.
|
||||
|
||||
Developer responsibilities:
|
||||
- Implement clearly
|
||||
- Respect architecture
|
||||
- Preserve maintainability
|
||||
|
||||
# 5. Testing & Review
|
||||
|
||||
Goal: validate correctness and maintainability.
|
||||
|
||||
Focus on:
|
||||
- Spec completion
|
||||
- Workflow correctness
|
||||
- Architectural consistency
|
||||
|
||||
# 6. Iteration
|
||||
|
||||
Goal: continuously improve:
|
||||
- Specs
|
||||
- Workflows
|
||||
- Context
|
||||
- Implementation
|
||||
|
||||
AI-native engineering is iterative by nature.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Role: Architect
|
||||
|
||||
You are responsible for maintaining technical clarity,
|
||||
system boundaries, and implementation simplicity.
|
||||
|
||||
## Primary Responsibilities
|
||||
|
||||
- Define system boundaries
|
||||
- Validate architecture decisions
|
||||
- Reduce unnecessary complexity
|
||||
- Identify technical risks
|
||||
- Support maintainability
|
||||
- Guide incremental delivery
|
||||
|
||||
---
|
||||
|
||||
## Prioritize
|
||||
|
||||
- clarity
|
||||
- maintainability
|
||||
- simplicity
|
||||
- developer experience
|
||||
- incremental progress
|
||||
|
||||
---
|
||||
|
||||
## Avoid
|
||||
|
||||
- overengineering
|
||||
- speculative abstractions
|
||||
- premature optimization
|
||||
- architecture astronautics
|
||||
|
||||
---
|
||||
|
||||
## Expected Outputs
|
||||
|
||||
- architecture notes
|
||||
- API guidance
|
||||
- data flow recommendations
|
||||
- technical decisions
|
||||
- risk analysis
|
||||
|
||||
---
|
||||
|
||||
## Collaboration
|
||||
|
||||
Work closely with:
|
||||
- PM for requirement clarification
|
||||
- Developers for implementation guidance
|
||||
- Reviewers for consistency validation
|
||||
|
||||
Your role is guidance and structure, not implementation ownership.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Role: Developer
|
||||
|
||||
You are responsible for implementing specs incrementally,
|
||||
clearly, and maintainably.
|
||||
|
||||
## Primary Responsibilities
|
||||
|
||||
- Implement specs
|
||||
- Respect architecture boundaries
|
||||
- Keep code maintainable
|
||||
- Surface blockers early
|
||||
- Preserve readability
|
||||
|
||||
---
|
||||
|
||||
## Engineering Philosophy
|
||||
|
||||
Prefer:
|
||||
- explicit logic
|
||||
- composable systems
|
||||
- simple implementations
|
||||
- incremental delivery
|
||||
|
||||
Avoid:
|
||||
- unnecessary abstractions
|
||||
- giant refactors
|
||||
- hidden side effects
|
||||
- premature optimization
|
||||
|
||||
---
|
||||
|
||||
## Before Implementation
|
||||
|
||||
Always review:
|
||||
- project context
|
||||
- architecture principles
|
||||
- current spec
|
||||
- related workflows
|
||||
|
||||
---
|
||||
|
||||
## Expected Outputs
|
||||
|
||||
- implementation code
|
||||
- tests
|
||||
- documentation
|
||||
- migrations
|
||||
- APIs
|
||||
- technical notes
|
||||
@@ -0,0 +1,40 @@
|
||||
# Role: Product Manager
|
||||
|
||||
You are responsible for:
|
||||
- reducing ambiguity
|
||||
- clarifying requirements
|
||||
- prioritizing delivery
|
||||
- maintaining user focus
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- refine specs
|
||||
- clarify user value
|
||||
- define acceptance criteria
|
||||
- reduce scope ambiguity
|
||||
- support incremental delivery
|
||||
|
||||
---
|
||||
|
||||
## Product Philosophy
|
||||
|
||||
Prefer:
|
||||
- small focused specs
|
||||
- clear workflows
|
||||
- simple user journeys
|
||||
- fast iteration
|
||||
|
||||
Avoid:
|
||||
- oversized features
|
||||
- vague requirements
|
||||
- unnecessary complexity
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
A good spec should be:
|
||||
- understandable
|
||||
- implementable
|
||||
- testable
|
||||
- incremental
|
||||
@@ -0,0 +1,36 @@
|
||||
# Role: Reviewer
|
||||
|
||||
You are responsible for validating:
|
||||
- quality
|
||||
- maintainability
|
||||
- spec completion
|
||||
- architectural consistency
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Verify acceptance criteria
|
||||
- Validate readability
|
||||
- Identify technical debt
|
||||
- Surface risks
|
||||
- Recommend improvements
|
||||
|
||||
---
|
||||
|
||||
## Review Philosophy
|
||||
|
||||
Focus on:
|
||||
- practical maintainability
|
||||
- implementation clarity
|
||||
- architecture consistency
|
||||
|
||||
Avoid:
|
||||
- perfectionism
|
||||
- unnecessary nitpicks
|
||||
- overcomplicated suggestions
|
||||
|
||||
---
|
||||
|
||||
## Important
|
||||
|
||||
The goal is sustainable delivery,
|
||||
not theoretical perfection.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Role: Tester
|
||||
|
||||
You are responsible for validating:
|
||||
- expected behavior
|
||||
- acceptance criteria
|
||||
- workflow correctness
|
||||
- critical edge cases
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- validate specs
|
||||
- test workflows
|
||||
- identify regressions
|
||||
- surface inconsistencies
|
||||
|
||||
---
|
||||
|
||||
## Testing Philosophy
|
||||
|
||||
Focus on:
|
||||
- critical workflows
|
||||
- realistic scenarios
|
||||
- maintainability
|
||||
- clarity
|
||||
|
||||
Avoid:
|
||||
- unnecessary exhaustive testing
|
||||
- unrealistic edge cases
|
||||
- overcomplicated testing strategies
|
||||
|
||||
---
|
||||
|
||||
## Important
|
||||
|
||||
The goal is confidence and reliability,
|
||||
not perfect theoretical coverage.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Skill: Create Handoff Contract
|
||||
|
||||
Good handoffs preserve:
|
||||
- continuity
|
||||
- implementation context
|
||||
- architectural decisions
|
||||
|
||||
Include:
|
||||
- summary
|
||||
- completed work
|
||||
- pending work
|
||||
- risks
|
||||
- questions
|
||||
- recommended next step
|
||||
@@ -0,0 +1,14 @@
|
||||
# Skill: Review Code Quality
|
||||
|
||||
## Focus Areas
|
||||
|
||||
- readability
|
||||
- maintainability
|
||||
- consistency
|
||||
- architectural alignment
|
||||
|
||||
## Avoid
|
||||
|
||||
- unnecessary nitpicks
|
||||
- perfectionism
|
||||
- speculative suggestions
|
||||
@@ -0,0 +1,19 @@
|
||||
# Skill: Write Spec
|
||||
|
||||
## Goal
|
||||
|
||||
Create small incremental implementation specs.
|
||||
|
||||
## Good Specs
|
||||
|
||||
Good specs:
|
||||
- have clear goals
|
||||
- define acceptance criteria
|
||||
- remain focused
|
||||
- avoid ambiguity
|
||||
|
||||
## Avoid
|
||||
|
||||
- giant specs
|
||||
- vague requirements
|
||||
- mixing unrelated concerns
|
||||
@@ -0,0 +1,14 @@
|
||||
# Skill: Write Tests
|
||||
|
||||
## Prioritize
|
||||
|
||||
- critical workflows
|
||||
- expected behavior
|
||||
- important edge cases
|
||||
|
||||
## Philosophy
|
||||
|
||||
Testing should improve:
|
||||
- confidence
|
||||
- reliability
|
||||
- maintainability
|
||||
@@ -0,0 +1,58 @@
|
||||
# Spec: Support Multiple Tags for Tasks and Notes
|
||||
|
||||
## Goal
|
||||
|
||||
Enable users to associate multiple tags with both tasks and notes, enhancing organization and search capabilities.
|
||||
|
||||
---
|
||||
|
||||
## User Value
|
||||
|
||||
Users can categorize their tasks and notes more granularly, making it easier to find and filter information. This improves overall productivity and reduces the time spent searching for specific items.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- Users must be able to add multiple tags to a single task.
|
||||
- Users must be able to add multiple tags to a single note.
|
||||
- The system should support adding new tags and selecting existing tags.
|
||||
- Display of multiple tags associated with tasks and notes in the UI.
|
||||
- Backend API endpoints must be updated to support multiple tags for tasks and notes.
|
||||
- Database schema must be updated to store multiple tags for tasks and notes.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] When editing a task, a user can add more than one tag.
|
||||
- [ ] When editing a note, a user can add more than one tag.
|
||||
- [ ] All tags associated with a task are displayed when viewing the task.
|
||||
- [ ] All tags associated with a note are displayed when viewing the note.
|
||||
- [ ] Users can filter tasks by multiple tags.
|
||||
- [ ] Users can filter notes by multiple tags.
|
||||
- [ ] The API for tasks and notes allows for creation and update with multiple tags.
|
||||
- [ ] The database correctly stores and retrieves multiple tags for tasks and notes.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
---
|
||||
|
||||
## Risks
|
||||
|
||||
- **Data Migration**: Existing single-tag data will need to be migrated to the new multi-tag schema, potentially requiring a database migration script.
|
||||
- **Performance Impact**: Storing and querying multiple tags might introduce performance overhead, especially for large datasets. This will need careful indexing and optimization.
|
||||
- **UI Complexity**: Designing a user-friendly interface for managing multiple tags could add complexity to the frontend.
|
||||
- **API backward compatibility**: Changes to the API might break existing clients if not handled carefully with versioning or clear migration paths.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Consider a many-to-many relationship between tasks/notes and tags in the database.
|
||||
- Frontend tag input should allow for auto-completion of existing tags.
|
||||
- The API should handle tag creation on the fly if a new tag is submitted.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Spec 002: Markdown Formatting Toolbar for Notes
|
||||
|
||||
## Goal
|
||||
Enhance the Note editing experience by adding a Markdown formatting toolbar to the note creation and editing interface. This provides a "rich text" editing capability while preserving the existing Markdown-based storage and rendering system.
|
||||
|
||||
## User Value
|
||||
Users who are unfamiliar with Markdown syntax can easily format their notes (bold, italic, lists, etc.) using familiar UI controls. This reduces the cognitive load and makes the application more accessible to a broader audience without losing the power of Markdown for advanced users.
|
||||
|
||||
## Requirements
|
||||
- **Toolbar Placement**: A horizontal toolbar should be placed immediately above the "Content" textarea in the Note creation/editing form.
|
||||
- **Formatting Actions**: The toolbar must include buttons for the following actions:
|
||||
- **Bold**: Wraps selection with `**`.
|
||||
- **Italic**: Wraps selection with `_`.
|
||||
- **Heading**: Prepends `### ` to the current line or selection.
|
||||
- **Bullet List**: Prepends `- ` to the current line or selection.
|
||||
- **Link**: Inserts a Markdown link template `[text](url)`.
|
||||
- **Interaction Logic**:
|
||||
- If text is selected, clicking a button should wrap/prefix the selection.
|
||||
- If no text is selected, clicking a button should insert the Markdown symbols at the cursor position.
|
||||
- The textarea should regain focus immediately after a toolbar button is clicked.
|
||||
- **Styling**: The toolbar should use standard Bootstrap components (e.g., `ButtonGroup`) and icons (e.g., `react-bootstrap-icons`) to match the existing project aesthetic.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] The toolbar is visible in the `NoteAdd` view for both adding a new note and editing an existing one.
|
||||
- [ ] Clicking the **Bold** button wraps the selected text in the textarea with `**`.
|
||||
- [ ] Clicking the **Italic** button wraps the selected text with `_`.
|
||||
- [ ] Clicking the **Heading** button adds `### ` at the cursor or selection start.
|
||||
- [ ] Clicking the **Bullet List** button adds `- ` at the start of the line.
|
||||
- [ ] Clicking the **Link** button inserts `[](url)` or wraps selection as `[selection](url)`.
|
||||
- [ ] The note can be saved successfully with the newly formatted content.
|
||||
- [ ] The "Preview Markdown" modal correctly renders the formatted content.
|
||||
|
||||
## Risks
|
||||
- **Cursor Management**: Maintaining or restoring cursor position/selection after formatting might be technically challenging in a standard HTML `textarea`.
|
||||
- **Mobile UX**: A long toolbar might overflow on small screens, requiring careful responsive design (e.g., horizontal scrolling or wrapping).
|
||||
- **Undo/Redo**: Standard browser undo/redo might behave unexpectedly if the textarea value is manipulated programmatically.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Specs
|
||||
|
||||
Specs are the primary implementation units in this repository.
|
||||
|
||||
## Good Specs
|
||||
|
||||
Good specs are:
|
||||
- small
|
||||
- incremental
|
||||
- independently understandable
|
||||
- testable
|
||||
- focused on one responsibility
|
||||
|
||||
---
|
||||
|
||||
## Recommended Structure
|
||||
|
||||
Each spec should contain:
|
||||
- spec.md
|
||||
- architecture.md
|
||||
- tasks.md
|
||||
- optional contracts/
|
||||
|
||||
---
|
||||
|
||||
## Recommended Size
|
||||
|
||||
Prefer:
|
||||
- 3 to 8 specs maximum for dojo projects
|
||||
|
||||
Avoid:
|
||||
- giant umbrella specs
|
||||
- multi-week implementation specs
|
||||
@@ -0,0 +1,43 @@
|
||||
# Spec: <name>
|
||||
|
||||
## Goal
|
||||
|
||||
What are we building?
|
||||
|
||||
---
|
||||
|
||||
## User Value
|
||||
|
||||
Why does this matter?
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- Requirement 1
|
||||
- Requirement 2
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Criteria 1
|
||||
- [ ] Criteria 2
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
List required previous specs.
|
||||
|
||||
---
|
||||
|
||||
## Risks
|
||||
|
||||
Identify possible implementation risks.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
Additional implementation guidance.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Orchestration Prompt
|
||||
|
||||
Using the orchestrator workflow defined at `ai/orchestration/orchestrator.md`,
|
||||
execute the first spec file, 001-add-multiple-tags.md.
|
||||
|
||||
Respect:
|
||||
- architecture principles
|
||||
- role responsibilities
|
||||
- context policies
|
||||
- handoff rules
|
||||
|
||||
Keep implementation:
|
||||
- simple
|
||||
- maintainable
|
||||
- incremental
|
||||
- production-like
|
||||
@@ -0,0 +1,22 @@
|
||||
# Spec for a feature prompt example
|
||||
|
||||
Based on the project context, defined at `ai/context/*.md` files, generate one small increment spec file for this
|
||||
feature: <brief description of the feature>.
|
||||
|
||||
Requirements:
|
||||
- small scope
|
||||
- independently implementable
|
||||
- clear acceptance criteria
|
||||
- incremental delivery
|
||||
- strictly follow all constraints in `ai/context/project_constraints.md` during implementation
|
||||
|
||||
The spec file should include:
|
||||
- goal
|
||||
- user value
|
||||
- requirements
|
||||
- acceptance criteria
|
||||
- risks
|
||||
|
||||
Name the spec file as `000-short-descriptive-name.md` similar to git branch naming convention, starting with 001, 002,
|
||||
003, and so on, and place it in the appropriate directory under `ai/specs/`.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Spec Generation Prompt
|
||||
|
||||
Based on the project context,
|
||||
generate 3 to 8 small incremental specs.
|
||||
|
||||
Requirements:
|
||||
- small scope
|
||||
- independently implementable
|
||||
- clear acceptance criteria
|
||||
- incremental delivery
|
||||
- avoid overengineering
|
||||
|
||||
Each spec should include:
|
||||
- goal
|
||||
- user value
|
||||
- requirements
|
||||
- acceptance criteria
|
||||
- risks
|
||||
@@ -21,7 +21,7 @@ const mockTasks = [
|
||||
dueDate: '2026-06-01',
|
||||
dueDateFmt: 'Jun 1, 2026',
|
||||
lastUpdate: '2026-05-01',
|
||||
tag: 'personal',
|
||||
tags: ['personal'],
|
||||
urls: []
|
||||
},
|
||||
{
|
||||
@@ -32,7 +32,7 @@ const mockTasks = [
|
||||
dueDate: '',
|
||||
dueDateFmt: '',
|
||||
lastUpdate: '2026-05-02',
|
||||
tag: 'work',
|
||||
tags: ['work'],
|
||||
urls: []
|
||||
}
|
||||
];
|
||||
@@ -43,7 +43,7 @@ const mockNotes = [
|
||||
title: 'Meeting notes',
|
||||
description: 'Discuss quarterly goals',
|
||||
url: null,
|
||||
tag: 'work',
|
||||
tags: ['work'],
|
||||
lastUpdate: '2026-05-01',
|
||||
shared: false,
|
||||
shareToken: null
|
||||
@@ -53,7 +53,7 @@ const mockNotes = [
|
||||
title: 'Recipe',
|
||||
description: 'Pasta carbonara recipe',
|
||||
url: null,
|
||||
tag: 'personal',
|
||||
tags: ['personal'],
|
||||
lastUpdate: '2026-05-02',
|
||||
shared: false,
|
||||
shareToken: null
|
||||
@@ -69,6 +69,11 @@ describe('Home Management', () => {
|
||||
body: { token: 'fake-jwt-token', ...mockUser }
|
||||
}).as('refreshToken');
|
||||
|
||||
cy.intercept('GET', /\/rest\/users\/me/, {
|
||||
statusCode: 200,
|
||||
body: mockUser
|
||||
}).as('getCurrentUser');
|
||||
|
||||
cy.intercept('GET', /\/rest\/home\/tasks\/tags/, {
|
||||
statusCode: 200,
|
||||
body: mockTags
|
||||
|
||||
@@ -17,7 +17,7 @@ const mockNote = {
|
||||
title: 'Meeting notes',
|
||||
description: 'Discuss quarterly goals',
|
||||
url: null,
|
||||
tag: 'work',
|
||||
tags: ['work'],
|
||||
lastUpdate: '2026-05-01',
|
||||
shared: false,
|
||||
shareToken: null
|
||||
@@ -35,6 +35,11 @@ describe('Notes Management', () => {
|
||||
body: { token: 'fake-jwt-token', ...mockUser }
|
||||
}).as('refreshToken');
|
||||
|
||||
cy.intercept('GET', /\/rest\/users\/me/, {
|
||||
statusCode: 200,
|
||||
body: mockUser
|
||||
}).as('getCurrentUser');
|
||||
|
||||
cy.intercept('GET', /\/rest\/home\/tasks\/tags/, {
|
||||
statusCode: 200,
|
||||
body: ['work', 'personal']
|
||||
@@ -84,7 +89,7 @@ describe('Notes Management', () => {
|
||||
title: 'New note',
|
||||
description: 'Some content',
|
||||
url: null,
|
||||
tag: '',
|
||||
tags: [],
|
||||
lastUpdate: '',
|
||||
shared: false,
|
||||
shareToken: null
|
||||
|
||||
@@ -20,7 +20,7 @@ const mockTask = {
|
||||
dueDate: '',
|
||||
dueDateFmt: '',
|
||||
lastUpdate: '2026-05-01',
|
||||
tag: 'personal',
|
||||
tags: ['personal'],
|
||||
urls: []
|
||||
};
|
||||
|
||||
@@ -36,6 +36,11 @@ describe('Task Management', () => {
|
||||
body: { token: 'fake-jwt-token', ...mockUser }
|
||||
}).as('refreshToken');
|
||||
|
||||
cy.intercept('GET', /\/rest\/users\/me/, {
|
||||
statusCode: 200,
|
||||
body: mockUser
|
||||
}).as('getCurrentUser');
|
||||
|
||||
cy.intercept('GET', /\/rest\/home\/tasks\/tags/, {
|
||||
statusCode: 200,
|
||||
body: ['personal', 'work']
|
||||
@@ -82,7 +87,7 @@ describe('Task Management', () => {
|
||||
dueDate: '',
|
||||
dueDateFmt: '',
|
||||
lastUpdate: '',
|
||||
tag: '',
|
||||
tags: [],
|
||||
urls: []
|
||||
}
|
||||
}).as('createTask');
|
||||
|
||||
Generated
+1365
-1343
File diff suppressed because it is too large
Load Diff
+23
-24
@@ -13,25 +13,24 @@
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@types/node": "^25.7.0",
|
||||
"@types/node": "^26.1.1",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"bootstrap": "^5.3.8",
|
||||
"dompurify": "^3.4.2",
|
||||
"i18next": "^26.1.0",
|
||||
"react": "^19.2.6",
|
||||
"dompurify": "^3.4.12",
|
||||
"i18next": "^26.3.6",
|
||||
"react": "^19.2.7",
|
||||
"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.6",
|
||||
"react-i18next": "^17.0.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-i18next": "^17.0.10",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.15.0",
|
||||
"react-router": "^8.2.0",
|
||||
"react-router-bootstrap": "^0.26.3",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.12"
|
||||
"vite": "^8.1.5"
|
||||
},
|
||||
"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/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-router-bootstrap": "^0.26.8",
|
||||
"@vitest/coverage-v8": "^4.1.6",
|
||||
"cypress": "^15.14.2",
|
||||
"eslint": "^9.39.4",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"cypress": "^15.18.1",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-import-x": "^4.16.2",
|
||||
"eslint-plugin-jsdoc": "^62.9.0",
|
||||
"eslint-plugin-n": "^18.0.1",
|
||||
"eslint-plugin-import-x": "^4.17.1",
|
||||
"eslint-plugin-jsdoc": "^63.2.0",
|
||||
"eslint-plugin-n": "^18.2.2",
|
||||
"eslint-plugin-promise": "^7.3.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"globals": "^17.6.0",
|
||||
"globals": "^17.7.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"prettier": "^3.8.3",
|
||||
"sass": "^1.99.0",
|
||||
"prettier": "^3.9.5",
|
||||
"sass": "^1.101.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"typescript-eslint": "^8.59.3",
|
||||
"vitest": "^4.1.6"
|
||||
"typescript-eslint": "^8.64.0",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { describe, vi, it, expect, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
|
||||
import { describe, vi, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, act, waitFor, cleanup } from '@testing-library/react';
|
||||
import ModalMarkdown from '../../components/ModalMarkdown';
|
||||
|
||||
describe('ModalMarkdown Component', () => {
|
||||
@@ -12,13 +12,19 @@ describe('ModalMarkdown Component', () => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(navigator, {
|
||||
vi.stubGlobal('navigator', {
|
||||
clipboard: {
|
||||
writeText: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('should render the modal with the correct title and markdown text', () => {
|
||||
render(<ModalMarkdown {...props} />);
|
||||
|
||||
@@ -84,15 +90,29 @@ describe('ModalMarkdown Component', () => {
|
||||
});
|
||||
|
||||
it('should show "Copied!" text after Copy button is clicked', async () => {
|
||||
vi.useFakeTimers();
|
||||
render(<ModalMarkdown {...props} />);
|
||||
|
||||
const copyButton = screen.getByTestId('modal-copy-button');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('modal-copy-button'));
|
||||
fireEvent.click(copyButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('modal-copy-button').textContent).toBe('Copied!');
|
||||
// Resolve microtasks for the clipboard promise
|
||||
await act(async () => {
|
||||
await vi.runAllTicks();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('modal-copy-button').textContent).toBe('Copied!');
|
||||
|
||||
// Advance timers to see it go back to "Copy"
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('modal-copy-button').textContent).toBe('Copy');
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should reset source view state when modal is closed', () => {
|
||||
|
||||
@@ -5,14 +5,14 @@ import TaskTag from '../../components/TaskTag';
|
||||
|
||||
describe('TaskTag Component', () => {
|
||||
it('should render the TaskTag component with provided tag and last update', () => {
|
||||
const { getByText } = render(<TaskTag tag="important" lastUpdate="2025-02-20" />);
|
||||
expect(getByText('#important')).toBeDefined();
|
||||
const { getByText } = render(<TaskTag tags={['important']} lastUpdate="2025-02-20" taskOrNote="task" />);
|
||||
expect(getByText('#important task')).toBeDefined();
|
||||
expect(getByText('2025-02-20')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should render the TaskTag component with default tag when no tag is provided', () => {
|
||||
const { getByText } = render(<TaskTag lastUpdate="2025-02-20" />);
|
||||
expect(getByText('#untagged')).toBeDefined();
|
||||
const { getByText } = render(<TaskTag lastUpdate="2025-02-20" taskOrNote="task" />);
|
||||
expect(getByText('#untagged task')).toBeDefined();
|
||||
expect(getByText('2025-02-20')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import AuthProvider from '../../context/AuthProvider';
|
||||
import AuthContext, { AuthContextData } from '../../context/AuthContext';
|
||||
import api from '../../api-service/api';
|
||||
import { API_TOKEN, USER_DATA } from '../../app-constants/app-constants';
|
||||
import ApiConfig from '../../api-service/apiConfig';
|
||||
|
||||
// Mock the API service methods.
|
||||
vi.mock('../../api-service/api');
|
||||
@@ -119,17 +120,29 @@ describe('AuthProvider', () => {
|
||||
});
|
||||
|
||||
it('should set loading to false and signed to true after successful initial auth check', async () => {
|
||||
const fakeResponse = {
|
||||
const fakeTokenResponse = {
|
||||
token: 'refresh-token',
|
||||
};
|
||||
const fakeCurrentUser = {
|
||||
userId: '789',
|
||||
name: 'Refreshed User',
|
||||
email: 'refreshed@example.com',
|
||||
admin: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
gravatarImageUrl: 'http://dummyimage.com'
|
||||
gravatarImageUrl: 'http://dummyimage.com',
|
||||
lang: 'en',
|
||||
lastLogin: new Date().toISOString()
|
||||
};
|
||||
|
||||
vi.spyOn(api, 'getJSON').mockResolvedValue(fakeResponse);
|
||||
vi.spyOn(api, 'getJSON').mockImplementation(async(url: string) => {
|
||||
if (url === ApiConfig.refreshTokenUrl) {
|
||||
return fakeTokenResponse;
|
||||
}
|
||||
if (url === ApiConfig.currentUserUrl) {
|
||||
return fakeCurrentUser;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
localStorage.setItem(API_TOKEN, 'dummy');
|
||||
|
||||
const { getByTestId } = render(
|
||||
@@ -142,6 +155,7 @@ describe('AuthProvider', () => {
|
||||
expect(getByTestId('loading').textContent).toBe('false')
|
||||
);
|
||||
expect(getByTestId('signed').textContent).toBe('true');
|
||||
expect(getByTestId('user').textContent).toBe('Refreshed User');
|
||||
});
|
||||
|
||||
it('should sign in a user successfully', async () => {
|
||||
@@ -251,17 +265,29 @@ describe('AuthProvider', () => {
|
||||
});
|
||||
|
||||
it('should call fetchCurrentSession when checking current auth user', async () => {
|
||||
const fakeResponse = {
|
||||
const fakeTokenResponse = {
|
||||
token: 'refresh-token',
|
||||
};
|
||||
const fakeCurrentUser = {
|
||||
userId: '789',
|
||||
name: 'Refreshed User',
|
||||
email: 'refreshed@example.com',
|
||||
admin: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
gravatarImageUrl: 'http://dummyimage.com'
|
||||
gravatarImageUrl: 'http://dummyimage.com',
|
||||
lang: 'en',
|
||||
lastLogin: new Date().toISOString()
|
||||
};
|
||||
|
||||
vi.spyOn(api, 'getJSON').mockResolvedValue(fakeResponse);
|
||||
vi.spyOn(api, 'getJSON').mockImplementation(async(url: string) => {
|
||||
if (url === ApiConfig.refreshTokenUrl) {
|
||||
return fakeTokenResponse;
|
||||
}
|
||||
if (url === ApiConfig.currentUserUrl) {
|
||||
return fakeCurrentUser;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
// Store API_TOKEN so that fetchCurrentSession runs the refresh logic.
|
||||
localStorage.setItem(API_TOKEN, 'dummy');
|
||||
@@ -283,8 +309,9 @@ describe('AuthProvider', () => {
|
||||
|
||||
await user.click(getByTestIdFunction('checkCurrentAuthUser'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(localStorage.getItem(API_TOKEN)).toBe('refresh-token')
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem(API_TOKEN)).toBe('refresh-token');
|
||||
expect(localStorage.getItem(USER_DATA)).toContain('Refreshed User');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -25,5 +25,8 @@ describe('Renders the about view', () => {
|
||||
expect(getByText('Find more information about us and the app')).toBeDefined();
|
||||
expect(getByText('Tasks and notes made')).toBeDefined();
|
||||
expect(getByText('Easy')).toBeDefined();
|
||||
expect(getByText('About TaskNote')).toBeDefined();
|
||||
expect(getByText('Technology')).toBeDefined();
|
||||
expect(getByText('About the Developer')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,7 +55,19 @@ vi.mock('../../components/AlertError', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../components/ModalMarkdown', () => ({
|
||||
default: (props: any) => <div data-testid="modal-markdown">{props.show ? 'Modal Open' : ''}</div>
|
||||
default: (props: any) => (
|
||||
<div data-testid="modal-markdown">
|
||||
{props.show ? (
|
||||
<div>
|
||||
<div data-testid="modal-title">{props.title}</div>
|
||||
<div data-testid="modal-content">{props.markdownText}</div>
|
||||
<button data-testid="modal-close" onClick={props.onHide}>Close</button>
|
||||
</div>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}));
|
||||
|
||||
vi.mock('../../components/TaskTitle', () => ({
|
||||
@@ -67,7 +79,14 @@ vi.mock('../../components/TaskTimeLeft', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../components/TaskTag', () => ({
|
||||
default: (props: any) => <div data-testid="task-tag">{props.tag}</div>
|
||||
default: (props: any) => (
|
||||
<div data-testid="task-tag">
|
||||
{props.tag}
|
||||
{props.taskOrNote === 'note' && props.onClick && (
|
||||
<a href="#" data-testid="open-it" onClick={props.onClick}>Open it</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}));
|
||||
|
||||
vi.mock('../../components/NoteTitle', () => ({
|
||||
@@ -81,7 +100,7 @@ const mockTasks: TaskResponse[] = [
|
||||
description: 'Task 1',
|
||||
done: false,
|
||||
urls: ['http://example.com'],
|
||||
tag: 'work',
|
||||
tags: ['work'],
|
||||
lastUpdate: '2023-10-10',
|
||||
highPriority: true,
|
||||
dueDateFmt: '2 days left',
|
||||
@@ -92,7 +111,7 @@ const mockTasks: TaskResponse[] = [
|
||||
description: 'Task 2',
|
||||
done: true,
|
||||
urls: [],
|
||||
tag: 'home',
|
||||
tags: ['home'],
|
||||
lastUpdate: '2023-10-09',
|
||||
highPriority: false,
|
||||
dueDateFmt: '',
|
||||
@@ -105,17 +124,21 @@ const mockNotes: NoteResponse[] = [
|
||||
id: 1,
|
||||
title: 'Note 1',
|
||||
description: 'Line 1\nLine 2\nLine 3',
|
||||
tag: 'work',
|
||||
tags: ['work'],
|
||||
lastUpdate: '2023-10-10',
|
||||
url: 'http://example.com'
|
||||
url: 'http://example.com',
|
||||
shared: false,
|
||||
shareToken: null
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Note 2',
|
||||
description: 'This is a sample\nnote content',
|
||||
tag: 'personal',
|
||||
tags: ['personal'],
|
||||
lastUpdate: '2023-10-09',
|
||||
url: null
|
||||
url: null,
|
||||
shared: false,
|
||||
shareToken: null
|
||||
}
|
||||
];
|
||||
|
||||
@@ -541,6 +564,79 @@ describe('Home Component', () => {
|
||||
expect(screen.getAllByTestId('task-title')[0].textContent).toBe('Task 1');
|
||||
});
|
||||
});
|
||||
|
||||
test('saves note ID to localStorage when opening modal', async () => {
|
||||
await act(async () => {
|
||||
renderHome();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('open-it').length).toBe(2);
|
||||
});
|
||||
|
||||
const openItLinks = screen.getAllByTestId('open-it');
|
||||
await act(async () => {
|
||||
fireEvent.click(openItLinks[1]);
|
||||
});
|
||||
|
||||
expect(localStorage.getItem('OPEN_NOTE_ID')).toBe('1');
|
||||
expect(screen.getByTestId('modal-title').textContent).toBe('Note 1');
|
||||
expect(screen.getByTestId('modal-content').textContent).toBe('Line 1\nLine 2\nLine 3');
|
||||
});
|
||||
|
||||
test('restores open note modal from localStorage on reload', async () => {
|
||||
localStorage.setItem('OPEN_NOTE_ID', '2');
|
||||
|
||||
await act(async () => {
|
||||
renderHome();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('modal-title').textContent).toBe('Note 2');
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('modal-content').textContent).toBe('This is a sample\nnote content');
|
||||
});
|
||||
|
||||
test('does not restore modal if localStorage note ID not found', async () => {
|
||||
localStorage.setItem('OPEN_NOTE_ID', '999');
|
||||
|
||||
await act(async () => {
|
||||
renderHome();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('note-title').length).toBe(2);
|
||||
});
|
||||
|
||||
const modal = screen.getByTestId('modal-markdown');
|
||||
expect(modal.textContent).toBe('');
|
||||
expect(localStorage.getItem('OPEN_NOTE_ID')).toBeNull();
|
||||
});
|
||||
|
||||
test('clears localStorage when closing modal', async () => {
|
||||
await act(async () => {
|
||||
renderHome();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('open-it').length).toBe(2);
|
||||
});
|
||||
|
||||
const openItLinks = screen.getAllByTestId('open-it');
|
||||
await act(async () => {
|
||||
fireEvent.click(openItLinks[1]);
|
||||
});
|
||||
|
||||
expect(localStorage.getItem('OPEN_NOTE_ID')).toBe('1');
|
||||
|
||||
const closeButton = screen.getByTestId('modal-close');
|
||||
await act(async () => {
|
||||
fireEvent.click(closeButton);
|
||||
});
|
||||
|
||||
expect(localStorage.getItem('OPEN_NOTE_ID')).toBeNull();
|
||||
});
|
||||
/*
|
||||
test('getFirstRows properly formats note preview', async () => {
|
||||
await act(async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { act } from 'react';
|
||||
import { render, fireEvent, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
@@ -16,6 +17,7 @@ vi.mock('../../api-service/api', () => ({
|
||||
default: {
|
||||
postJSON: vi.fn(),
|
||||
getJSON: vi.fn(),
|
||||
patchJSON: vi.fn(),
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -82,6 +84,7 @@ vi.mock('../../utils/TranslatorUtils', () => ({
|
||||
|
||||
const mockedApi = vi.mocked(api);
|
||||
const mockedUseParams = vi.mocked(useParams);
|
||||
const mockedUseSearchParams = vi.mocked(useSearchParams);
|
||||
|
||||
describe('NoteAdd Component', () => {
|
||||
const renderNoteAdd = () => {
|
||||
@@ -100,9 +103,9 @@ describe('NoteAdd Component', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mock between tests
|
||||
(useSearchParams as unknown as ReturnType<typeof vi.fn>).mockReset();
|
||||
mockedUseSearchParams.mockReturnValue([new URLSearchParams(), vi.fn()]);
|
||||
mockedUseParams.mockReturnValue({});
|
||||
vi.clearAllMocks();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should render the NoteAdd component', async () => {
|
||||
@@ -118,28 +121,23 @@ describe('NoteAdd Component', () => {
|
||||
});
|
||||
|
||||
it('should show error message when form is invalid', async () => {
|
||||
let result: any;
|
||||
await act(async () => {
|
||||
result = renderNoteAdd();
|
||||
});
|
||||
const { getByText, getByRole } = result;
|
||||
const { getByText, getByRole } = renderNoteAdd();
|
||||
const submitButton = getByRole('button', { name: 'note_form_submit' });
|
||||
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Please fill in all the fields')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('should add a new note when form is valid', async () => {
|
||||
(useSearchParams as unknown as ReturnType<typeof vi.fn>).mockReturnValue([
|
||||
mockedUseSearchParams.mockReturnValue([
|
||||
new URLSearchParams("backTo=home"),
|
||||
vi.fn(),
|
||||
]);
|
||||
|
||||
let result: any;
|
||||
await act(async () => {
|
||||
result = renderNoteAdd();
|
||||
});
|
||||
const { getByLabelText, getByTestId, getByRole } = result;
|
||||
const { getByLabelText, getByTestId, getByRole } = renderNoteAdd();
|
||||
const descriptionInput = getByLabelText('note_form_title_label') as HTMLInputElement;
|
||||
const noteContentInput = getByTestId('note-content-input-area') as HTMLAreaElement;
|
||||
const submitButton = getByRole('button', { name: 'note_form_submit' });
|
||||
@@ -154,7 +152,7 @@ describe('NoteAdd Component', () => {
|
||||
title: 'New Note',
|
||||
description: 'Note content',
|
||||
url: '',
|
||||
tag: '',
|
||||
tags: [],
|
||||
lastUpdate: '',
|
||||
shared: false,
|
||||
shareToken: null
|
||||
@@ -185,8 +183,10 @@ describe('NoteAdd Component', () => {
|
||||
title: 'Note one',
|
||||
description: 'Description of note one',
|
||||
url: 'http://notes.domain.com',
|
||||
tag: 'dev',
|
||||
tags: ['dev'],
|
||||
lastUpdate: '3 minutes ago',
|
||||
shared: false,
|
||||
shareToken: null
|
||||
};
|
||||
|
||||
vi.spyOn(api, 'getJSON').mockResolvedValue(toEdit);
|
||||
@@ -211,8 +211,10 @@ describe('NoteAdd Component', () => {
|
||||
title: 'Old title',
|
||||
description: 'Old description',
|
||||
url: 'http://notes.domain.com',
|
||||
tag: 'dev',
|
||||
tags: ['dev'],
|
||||
lastUpdate: '1 minute ago',
|
||||
shared: false,
|
||||
shareToken: null
|
||||
};
|
||||
|
||||
vi.spyOn(api, 'getJSON').mockResolvedValue(toClone);
|
||||
|
||||
@@ -130,7 +130,7 @@ describe('TaskAdd Component', () => {
|
||||
description: 'New Task',
|
||||
dueDate: '',
|
||||
highPriority: false,
|
||||
tag: '',
|
||||
tags: [],
|
||||
urls: []
|
||||
}
|
||||
expect(api.postJSON).toHaveBeenCalledWith(ApiConfig.tasksUrl, newTask);
|
||||
|
||||
@@ -28,7 +28,9 @@ const ApiConfig = {
|
||||
|
||||
publicNotesUrl: `${server}/public/notes`,
|
||||
|
||||
userUrl: `${server}/rest/users`
|
||||
userUrl: `${server}/rest/users`,
|
||||
|
||||
currentUserUrl: `${server}/rest/users/me`
|
||||
};
|
||||
|
||||
export default ApiConfig;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
@import '../../styles/theme.scss';
|
||||
|
||||
.form-control[type="date"] {
|
||||
font-size: 16px; /* Prevents iOS zoom on focus */
|
||||
border-left: none;
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
@import '../../styles/theme.scss';
|
||||
|
||||
/* custom-datepicker.scss */
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -63,40 +57,20 @@ function FormInput(props: React.PropsWithChildren<Props>): React.ReactNode {
|
||||
)}
|
||||
</Form.Label>
|
||||
|
||||
<InputGroup className="mb-3">
|
||||
<InputGroup className="mb-3 flex-nowrap">
|
||||
<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}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
|
||||
@@ -3,27 +3,29 @@ import { Col, Row } from 'react-bootstrap';
|
||||
import './style.css';
|
||||
|
||||
interface Props {
|
||||
readonly tag?: string;
|
||||
readonly tags?: string[];
|
||||
readonly lastUpdate: string;
|
||||
readonly taskOrNote: 'task' | 'note';
|
||||
readonly onClick?: (e: React.MouseEvent<Element, MouseEvent>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the TaskTag component, displaying a tag and the last update time.
|
||||
* Renders the TaskTag component, displaying tags and the last update time.
|
||||
*
|
||||
* @param {Props} props - The props for the component.
|
||||
* @param {string} [props.tag] - The tag for the task. If not provided, defaults to '#untagged'.
|
||||
* @param {string[]} [props.tags] - The tags for the task. If not provided or empty, defaults to '#untagged'.
|
||||
* @param {string} props.lastUpdate - The last update time for the task.
|
||||
* @returns {React.ReactNode} The rendered TaskTag component.
|
||||
*/
|
||||
function TaskTag(props: React.PropsWithChildren<Props>): React.ReactNode {
|
||||
const tagText = props.tag ? `#${props.tag}` : '#untagged';
|
||||
const tagContent = props.tags && props.tags.length > 0
|
||||
? props.tags.map(tag => `#${tag}`).join(' ')
|
||||
: '#untagged';
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<Col className="d-inline-block text-muted card-tag poppins-regular">
|
||||
{tagText}
|
||||
{tagContent}
|
||||
{' '}
|
||||
{props.taskOrNote}
|
||||
{props.taskOrNote === 'note' && (
|
||||
|
||||
@@ -132,23 +132,31 @@ const enTranslations = {
|
||||
about_app_features_one: 'Quickly add and manage tasks and notes',
|
||||
about_app_features_two: 'Search and filter notes for easy access',
|
||||
about_app_features_three: 'Intuitive and clean user interface',
|
||||
about_app_features_four: 'Dark and light theme support with responsive design',
|
||||
about_app_features_five: 'URL attachments for tasks and notes',
|
||||
about_app_features_six: 'Tagging system with multiple tags per item',
|
||||
about_app_features_seven: 'Public note sharing via public links',
|
||||
about_app_features_eight: 'Markdown formatting toolbar for rich text editing',
|
||||
about_app_features_nine: 'Email confirmation required for account security',
|
||||
about_app_help_title: 'Help & How to Use',
|
||||
about_app_help_description: `To get started, simply sign up or log in, and
|
||||
you'll have access to your personalized dashboard. From there, you can
|
||||
create, edit, and delete tasks and notes, and organize them however you
|
||||
like. Need assistance? Visit our Help page (in the future) for tutorials
|
||||
and FAQs.`,
|
||||
like. Need assistance? Check out the sidebar for quick navigation.`,
|
||||
|
||||
about_tech_title: 'Technology',
|
||||
about_tech_description: `TaskNote was built using modern web technologies
|
||||
that ensure speed, reliability, and security.`,
|
||||
about_tech_list_one: 'React with TypeScript for the front-end',
|
||||
about_tech_list_two: 'Bootstrap 5 for components and responsive design',
|
||||
about_tech_list_three: 'Java and Spring Boot plus GraalVM for the back-end and Cloud Native',
|
||||
about_tech_list_four: 'PostgreSQL for database management',
|
||||
about_tech_list_five: 'Docker for containerization and deployment',
|
||||
about_tech_list_six: 'GitHub Actions for CI/CD, testing and linting enforcement',
|
||||
about_tech_list_seven: 'SonarCloud, and GitHub QL for security and improvements checks',
|
||||
about_tech_list_one: 'React 19 with TypeScript for the front-end',
|
||||
about_tech_list_two: 'Vite for fast builds and development',
|
||||
about_tech_list_three: 'React Router 7 for client-side routing',
|
||||
about_tech_list_four: 'i18next for internationalization support',
|
||||
about_tech_list_five: 'Bootstrap 5 for components and responsive design',
|
||||
about_tech_list_six: 'Java and Spring Boot 4.x plus GraalVM for the back-end and Cloud Native',
|
||||
about_tech_list_seven: 'PostgreSQL for database management',
|
||||
about_tech_list_eight: 'Docker for containerization and deployment',
|
||||
about_tech_list_nine: 'GitHub Actions for CI/CD, testing and linting enforcement',
|
||||
about_tech_list_ten: 'SonarCloud and GitHub QL for security and improvements checks',
|
||||
|
||||
about_dev_title: 'About the Developer',
|
||||
about_dev_description: `Hi! I'm Ricardo, the developer of TaskNote. I'm
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -133,23 +133,31 @@ const ptBrTranslations = {
|
||||
about_app_features_one: 'Adicionar e gerenciar tarefas e notas rapidamente',
|
||||
about_app_features_two: 'Buscar e filtrar notas para fácil acesso',
|
||||
about_app_features_three: 'Interface limpa e intuitiva',
|
||||
about_app_features_four: 'Suporte a tema escuro e claro com design responsivo',
|
||||
about_app_features_five: 'Anexos de URL para tarefas e notas',
|
||||
about_app_features_six: 'Sistema de tags com múltiplas tags por item',
|
||||
about_app_features_seven: 'Compartilhamento público de notas via links públicos',
|
||||
about_app_features_eight: 'Barra de ferramentas de formatação Markdown para edição de texto rico',
|
||||
about_app_features_nine: 'Confirmação de e-mail obrigatória para segurança da conta',
|
||||
about_app_help_title: 'Ajuda & Como usar',
|
||||
about_app_help_description: `Para começar, simplemente crie sua conta ou entre,
|
||||
e você terá acesso ao seu painel personalizado. A partir daí, você pode criar,
|
||||
alterar, e apagar tarefas e notas, e organizá-las da forma que preferir. Precisa
|
||||
de alguma ajuda? Visite nossa página de ajuda (em breve) para tutoriais e perguntas
|
||||
frequentes.`,
|
||||
de alguma ajuda? Confira a barra lateral para navegação rápida.`,
|
||||
|
||||
about_tech_title: 'Tecnologia',
|
||||
about_tech_description: `TaskNote foi contruído com tecnologia web atual
|
||||
que garante velocidade, confiabilidade e segurança.`,
|
||||
about_tech_list_one: 'React com TypeScript para o desenvolvimento do front-end',
|
||||
about_tech_list_two: 'Bootstrap 5 para os componentes e design responsivo',
|
||||
about_tech_list_three: 'Java e Spring Boot mais GraalVM para o back-end Nativo em Cloud',
|
||||
about_tech_list_four: 'PostgreSQL para gestão do banco de dados',
|
||||
about_tech_list_five: 'Docker para isolamento em containers e lançamentos',
|
||||
about_tech_list_six: 'GitHub Actions para integração contínua, testes e controle de qualidade',
|
||||
about_tech_list_seven: 'SonarCloud e GitHub QL para reforço de qualidade e melhorias',
|
||||
about_tech_list_one: 'React 19 com TypeScript para o desenvolvimento do front-end',
|
||||
about_tech_list_two: 'Vite para builds e desenvolvimento rápidos',
|
||||
about_tech_list_three: 'React Router 7 para roteamento no lado do cliente',
|
||||
about_tech_list_four: 'i18next para suporte à internacionalização',
|
||||
about_tech_list_five: 'Bootstrap 5 para os componentes e design responsivo',
|
||||
about_tech_list_six: 'Java e Spring Boot 4.x mais GraalVM para o back-end Nativo em Cloud',
|
||||
about_tech_list_seven: 'PostgreSQL para gestão do banco de dados',
|
||||
about_tech_list_eight: 'Docker para isolamento em containers e lançamentos',
|
||||
about_tech_list_nine: 'GitHub Actions para integração contínua, testes e controle de qualidade',
|
||||
about_tech_list_ten: 'SonarCloud e GitHub QL para reforço de qualidade e melhorias',
|
||||
|
||||
about_dev_title: 'Sobre o Desenvolvedor',
|
||||
about_dev_description: `Olá! Sou o Ricardo, o desenvolvedor do TaskNote.
|
||||
|
||||
@@ -132,23 +132,31 @@ const ruTranslations = {
|
||||
about_app_features_one: 'Быстро добавляйте и управляйте задачами и заметками',
|
||||
about_app_features_two: 'Поиск и фильтрация заметок для легкого доступа',
|
||||
about_app_features_three: 'Интуитивно понятный пользовательский интерфейс',
|
||||
about_app_features_four: 'Поддержка тёмной и светлой темы с адаптивным дизайном',
|
||||
about_app_features_five: 'URL-вложения для задач и заметок',
|
||||
about_app_features_six: 'Система тегов с несколькими тегами на элемент',
|
||||
about_app_features_seven: 'Публичный доступ к заметкам через публичные ссылки',
|
||||
about_app_features_eight: 'Панель инструментов форматирования Markdown для редактирования',
|
||||
about_app_features_nine: 'Требуется подтверждение электронной почты для безопасности аккаунта',
|
||||
about_app_help_title: 'Помощь и как использовать',
|
||||
about_app_help_description: `Чтобы начать, просто зарегистрируйтесь или войдите в систему, и
|
||||
вы получите доступ к своей персонализированной панели. Оттуда вы можете
|
||||
создавать, редактировать и удалять задачи и заметки, а также организовывать их так, как вам
|
||||
нравится. Нужна помощь? Посетите нашу страницу «Справка» (в будущем) для получения руководств
|
||||
и часто задаваемых вопросов.`,
|
||||
нравится. Нужна помощь? Ознакомьтесь с боковой панелью для быстрой навигации.`,
|
||||
|
||||
about_tech_title: 'Технологии',
|
||||
about_tech_description: `TaskNote был создан с использованием современных веб-технологий,
|
||||
которые обеспечивают скорость, надежность и безопасность.`,
|
||||
about_tech_list_one: 'React с TypeScript для фронтенда',
|
||||
about_tech_list_two: 'Bootstrap 5 для компонентов и адаптивного дизайна',
|
||||
about_tech_list_three: 'Java и Spring Boot плюс GraalVM для бэкенда и Cloud Native',
|
||||
about_tech_list_four: 'PostgreSQL для управления базами данных',
|
||||
about_tech_list_five: 'Docker для контейнеризации и развертывания',
|
||||
about_tech_list_six: 'GitHub Actions для CI/CD, тестирования и принудительного линтинга',
|
||||
about_tech_list_seven: 'SonarCloud и GitHub QL для проверок безопасности и улучшений',
|
||||
about_tech_list_one: 'React 19 с TypeScript для фронтенда',
|
||||
about_tech_list_two: 'Vite для быстрой сборки и разработки',
|
||||
about_tech_list_three: 'React Router 7 для маршрутизации на стороне клиента',
|
||||
about_tech_list_four: 'i18next для поддержки интернационализации',
|
||||
about_tech_list_five: 'Bootstrap 5 для компонентов и адаптивного дизайна',
|
||||
about_tech_list_six: 'Java и Spring Boot 4.x плюс GraalVM для бэкенда и Cloud Native',
|
||||
about_tech_list_seven: 'PostgreSQL для управления базами данных',
|
||||
about_tech_list_eight: 'Docker для контейнеризации и развертывания',
|
||||
about_tech_list_nine: 'GitHub Actions для CI/CD, тестирования и принудительного линтинга',
|
||||
about_tech_list_ten: 'SonarCloud и GitHub QL для проверок безопасности и улучшений',
|
||||
|
||||
about_dev_title: 'О разработчике',
|
||||
about_dev_description: `Привет! Я Рикардо, разработчик TaskNote. Я увлечен созданием приложений,
|
||||
|
||||
@@ -132,23 +132,31 @@ const esTranslations = {
|
||||
about_app_features_one: 'Añade y gestiona tareas y notas de manera rápida',
|
||||
about_app_features_two: 'Busca y filtra notas fácilmente',
|
||||
about_app_features_three: 'Interfaz intuitiva y limpia',
|
||||
about_app_features_four: 'Soporte para tema oscuro y claro con diseño responsivo',
|
||||
about_app_features_five: 'Adjuntos de URL para tareas y notas',
|
||||
about_app_features_six: 'Sistema de etiquetas con múltiples etiquetas por elemento',
|
||||
about_app_features_seven: 'Compartir notas públicamente mediante enlaces públicos',
|
||||
about_app_features_eight: 'Barra de herramientas de formato Markdown para edición de texto enriquecido',
|
||||
about_app_features_nine: 'Confirmación de correo electrónico requerida para la seguridad de la cuenta',
|
||||
about_app_help_title: 'Ayuda & Cómo usar',
|
||||
about_app_help_description: `Para empezar, regístrate o inicia sesión,
|
||||
y tendrás acceso a tu panel personalizado. Desde allí, podrás crear,
|
||||
editar y eliminar tareas y notas, organizándolas como desees. ¿Necesitas
|
||||
ayuda? Visita nuestra página de ayuda (próximamente) para tutoriales y
|
||||
preguntas frecuentes.`,
|
||||
ayuda? Consulta la barra lateral para navegación rápida.`,
|
||||
|
||||
about_tech_title: 'Acerca de la Tecnología',
|
||||
about_tech_description: `TaskNote fue construido con tecnologías web modernas
|
||||
que garantizan velocidad, fiabilidad y seguridad.`,
|
||||
about_tech_list_one: 'React con TypeScript para el desarrollo del frontend',
|
||||
about_tech_list_two: 'Bootstrap 5 para diseño y componentes responsivos',
|
||||
about_tech_list_three: 'Java y Spring Boot más GraalVM para el back-end y Cloud Nativo',
|
||||
about_tech_list_four: 'PostgreSQL para la gestión de bases de datos',
|
||||
about_tech_list_five: 'Docker para la contenedorización y despliegue',
|
||||
about_tech_list_six: 'GitHub Actions para CI/CD, pruebas y control de calidad',
|
||||
about_tech_list_seven: 'SonarCloud y GitHub QL para chequeos de seguridad y mejoras',
|
||||
about_tech_list_one: 'React 19 con TypeScript para el desarrollo del frontend',
|
||||
about_tech_list_two: 'Vite para builds y desarrollo rápidos',
|
||||
about_tech_list_three: 'React Router 7 para enrutamiento del lado del cliente',
|
||||
about_tech_list_four: 'i18next para soporte de internacionalización',
|
||||
about_tech_list_five: 'Bootstrap 5 para diseño y componentes responsivos',
|
||||
about_tech_list_six: 'Java y Spring Boot 4.x más GraalVM para el back-end y Cloud Nativo',
|
||||
about_tech_list_seven: 'PostgreSQL para la gestión de bases de datos',
|
||||
about_tech_list_eight: 'Docker para la contenedorización y despliegue',
|
||||
about_tech_list_nine: 'GitHub Actions para CI/CD, pruebas y control de calidad',
|
||||
about_tech_list_ten: 'SonarCloud y GitHub QL para chequeos de seguridad y mejoras',
|
||||
|
||||
about_dev_title: 'Acerca del Desarrollador',
|
||||
about_dev_description: `¡Hola! Soy Ricardo, el desarrollador de TaskNote.
|
||||
|
||||
@@ -24,7 +24,6 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
|
||||
}
|
||||
try {
|
||||
const bearerToken: SignInResponse = await api.getJSON(ApiConfig.refreshTokenUrl);
|
||||
setSigned(true);
|
||||
return bearerToken;
|
||||
}
|
||||
catch (e) {
|
||||
@@ -66,8 +65,10 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
|
||||
const checkCurrentAuthUser = async (pathname: string): Promise<void> => {
|
||||
const bearerToken: SignInResponse | undefined = await fetchCurrentSession(pathname);
|
||||
if (bearerToken && bearerToken.token) {
|
||||
const userLocal = updateUserSession(null, bearerToken.token);
|
||||
const currentUser: UserResponse = await api.getJSON(ApiConfig.currentUserUrl);
|
||||
const userLocal = updateUserSession(currentUser, bearerToken.token);
|
||||
if (userLocal) {
|
||||
setSigned(true);
|
||||
setUser(userLocal);
|
||||
}
|
||||
}
|
||||
@@ -126,6 +127,19 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!signed) return;
|
||||
const TWENTY_FIVE_MINUTES = 25 * 60 * 1000;
|
||||
const intervalId = setInterval(() => {
|
||||
checkCurrentAuthUser(window.location.pathname).catch(() => {
|
||||
setSigned(false);
|
||||
setUser(undefined);
|
||||
localStorage.clear();
|
||||
});
|
||||
}, TWENTY_FIVE_MINUTES);
|
||||
return () => clearInterval(intervalId);
|
||||
}, [signed]);
|
||||
|
||||
const updateUser = (userUpdated: UserResponse): void => {
|
||||
setUser(userUpdated);
|
||||
localStorage.setItem(USER_DATA, JSON.stringify(userUpdated));
|
||||
|
||||
@@ -277,8 +277,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ type NoteResponse = {
|
||||
title: string;
|
||||
description: string;
|
||||
url: string | null;
|
||||
tag: string;
|
||||
tags: string[];
|
||||
lastUpdate: string;
|
||||
shared: boolean;
|
||||
shareToken: string | null;
|
||||
|
||||
@@ -4,7 +4,7 @@ type TaskNoteRequest = {
|
||||
urls?: string[];
|
||||
dueDate?: string;
|
||||
highPriority?: boolean;
|
||||
tag: string;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
export default TaskNoteRequest;
|
||||
|
||||
@@ -6,7 +6,7 @@ type TaskResponse = {
|
||||
dueDate: string;
|
||||
dueDateFmt: string;
|
||||
lastUpdate: string;
|
||||
tag: string;
|
||||
tags: string[];
|
||||
urls: string[];
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -35,6 +35,12 @@ function About(): React.ReactNode {
|
||||
<li>{t('about_app_features_one')}</li>
|
||||
<li>{t('about_app_features_two')}</li>
|
||||
<li>{t('about_app_features_three')}</li>
|
||||
<li>{t('about_app_features_four')}</li>
|
||||
<li>{t('about_app_features_five')}</li>
|
||||
<li>{t('about_app_features_six')}</li>
|
||||
<li>{t('about_app_features_seven')}</li>
|
||||
<li>{t('about_app_features_eight')}</li>
|
||||
<li>{t('about_app_features_nine')}</li>
|
||||
</ul>
|
||||
<h4 className="mt-4 poppins-medium">{t('about_app_help_title')}</h4>
|
||||
<p className="poppins-light">{t('about_app_help_description')}</p>
|
||||
@@ -57,6 +63,9 @@ function About(): React.ReactNode {
|
||||
<li>{t('about_tech_list_five')}</li>
|
||||
<li>{t('about_tech_list_six')}</li>
|
||||
<li>{t('about_tech_list_seven')}</li>
|
||||
<li>{t('about_tech_list_eight')}</li>
|
||||
<li>{t('about_tech_list_nine')}</li>
|
||||
<li>{t('about_tech_list_ten')}</li>
|
||||
</ul>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
@@ -75,7 +84,7 @@ function About(): React.ReactNode {
|
||||
<p className="poppins-light">
|
||||
{t('about_buy_coffee_one')}
|
||||
<a
|
||||
href="https://buy-me-a-coffee-two-nu.vercel.app/"
|
||||
href="https://rmcampos.github.io/buy-me-a-coffee/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
|
||||
@@ -28,6 +28,8 @@ import TaskTimeLeft from '../../components/TaskTimeLeft';
|
||||
import TaskTag from '../../components/TaskTag';
|
||||
import NoteTitle from '../../components/NoteTitle';
|
||||
|
||||
const OPEN_NOTE_ID_KEY = 'OPEN_NOTE_ID';
|
||||
|
||||
/**
|
||||
* Home page component.
|
||||
*
|
||||
@@ -157,15 +159,15 @@ function Home(): React.ReactNode {
|
||||
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.tag?.toLowerCase().includes(text.toLowerCase());
|
||||
const anyTagMatch = note.tags?.some(tag => tag.toLowerCase().includes(text.toLowerCase()));
|
||||
return anyTitleMatch || anyContentMatch || anyUrlMatch || anyTagMatch;
|
||||
});
|
||||
|
||||
if (tagToFilter === 'untagged') {
|
||||
filteredNotes = filteredNotes.filter((note: NoteResponse) => !note.tag);
|
||||
filteredNotes = filteredNotes.filter((note: NoteResponse) => !note.tags || note.tags.length === 0);
|
||||
}
|
||||
else if (tagToFilter) {
|
||||
filteredNotes = filteredNotes.filter((note: NoteResponse) => note.tag && note.tag === tagToFilter);
|
||||
filteredNotes = filteredNotes.filter((note: NoteResponse) => note.tags && note.tags.includes(tagToFilter));
|
||||
}
|
||||
|
||||
setNotes([...filteredNotes]);
|
||||
@@ -177,15 +179,15 @@ function Home(): React.ReactNode {
|
||||
else {
|
||||
let filteredTasks = allTasks.filter((task: TaskResponse) => {
|
||||
return task.description.toLowerCase().includes(text.toLowerCase())
|
||||
|| task.tag.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') {
|
||||
filteredTasks = filteredTasks.filter((task: TaskResponse) => !task.tag);
|
||||
filteredTasks = filteredTasks.filter((task: TaskResponse) => !task.tags || task.tags.length === 0);
|
||||
}
|
||||
else if (tagToFilter) {
|
||||
filteredTasks = filteredTasks.filter((task: TaskResponse) => task.tag && task.tag === tagToFilter);
|
||||
filteredTasks = filteredTasks.filter((task: TaskResponse) => task.tags && task.tags.includes(tagToFilter));
|
||||
}
|
||||
|
||||
setTasks([...filteredTasks]);
|
||||
@@ -299,7 +301,10 @@ function Home(): React.ReactNode {
|
||||
return preview.join('\n');
|
||||
};
|
||||
|
||||
const handleCloseModal = () => setShowMarkdownView(false);
|
||||
const handleCloseModal = () => {
|
||||
setShowMarkdownView(false);
|
||||
localStorage.removeItem(OPEN_NOTE_ID_KEY);
|
||||
};
|
||||
|
||||
const getSelectedLabel = (): string => {
|
||||
if (selectedOption === 'everything') return t('home_radio_everything');
|
||||
@@ -345,6 +350,22 @@ function Home(): React.ReactNode {
|
||||
applyFilter(filterText, selectedOption, savedTasks, savedNotes);
|
||||
}, [savedTasks, savedNotes, filterText, selectedOption]);
|
||||
|
||||
useEffect(() => {
|
||||
const openNoteId = localStorage.getItem(OPEN_NOTE_ID_KEY);
|
||||
if (openNoteId && notes.length > 0) {
|
||||
const noteId = Number(openNoteId);
|
||||
const foundNote = notes.find(n => n.id === noteId);
|
||||
if (foundNote) {
|
||||
setModalTitle(foundNote.title);
|
||||
setModalContent(foundNote.description);
|
||||
setShowMarkdownView(true);
|
||||
}
|
||||
else {
|
||||
localStorage.removeItem(OPEN_NOTE_ID_KEY);
|
||||
}
|
||||
}
|
||||
}, [notes]);
|
||||
|
||||
return (
|
||||
<Container fluid>
|
||||
<ContentHeader
|
||||
@@ -519,7 +540,7 @@ function Home(): React.ReactNode {
|
||||
</Card.Body>
|
||||
<Card.Footer className="task-card-footer">
|
||||
<TaskTag
|
||||
tag={task.tag}
|
||||
tags={task.tags}
|
||||
lastUpdate={task.lastUpdate}
|
||||
taskOrNote="task"
|
||||
/>
|
||||
@@ -593,7 +614,7 @@ function Home(): React.ReactNode {
|
||||
</Card.Body>
|
||||
<Card.Footer className="task-card-footer">
|
||||
<TaskTag
|
||||
tag={note.tag}
|
||||
tags={note.tags}
|
||||
lastUpdate={note.lastUpdate}
|
||||
taskOrNote="note"
|
||||
onClick={(e: React.MouseEvent<Element, MouseEvent>) => {
|
||||
@@ -602,6 +623,7 @@ function Home(): React.ReactNode {
|
||||
setModalTitle(note.title);
|
||||
setModalContent(note.description);
|
||||
setShowMarkdownView(true);
|
||||
localStorage.setItem(OPEN_NOTE_ID_KEY, note.id.toString());
|
||||
}}
|
||||
/>
|
||||
</Card.Footer>
|
||||
|
||||
@@ -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: '';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Card,
|
||||
Col,
|
||||
Container,
|
||||
@@ -22,6 +24,13 @@ import ContentHeader from '../../components/ContentHeader';
|
||||
|
||||
type NoteAction = 'add' | 'edit';
|
||||
|
||||
interface NoteDraft {
|
||||
title: string;
|
||||
content: string;
|
||||
noteUrl: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* NoteAdd component for adding and editing notes.
|
||||
*
|
||||
@@ -34,15 +43,21 @@ function NoteAdd(): React.ReactNode {
|
||||
const [noteTitle, setNoteTitle] = useState<string>('');
|
||||
const [noteContent, setNoteContent] = useState<string>('');
|
||||
const [noteUrl, setNoteUrl] = useState<string>('');
|
||||
const [noteTag, setNoteTag] = useState<string>('');
|
||||
const [currentTag, setCurrentTag] = useState<string>('');
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [showTagDropdown, setShowTagDropdown] = useState<boolean>(false);
|
||||
const [action, setAction] = useState<NoteAction>('add');
|
||||
const [showPreviewMd, setShowPreviewMd] = useState<boolean>(false);
|
||||
const [draftBanner, setDraftBanner] = useState<boolean>(false);
|
||||
const { i18n, t } = useTranslation();
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
const tagContainerRef = useRef<HTMLDivElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const hasUserEdited = useRef<boolean>(false);
|
||||
|
||||
const draftKey = params?.id ? `draft:note:edit:${params.id}` : 'draft:note:new';
|
||||
|
||||
const loadTags = async (): Promise<void> => {
|
||||
try {
|
||||
@@ -71,8 +86,8 @@ function NoteAdd(): React.ReactNode {
|
||||
/**
|
||||
* Adds a new note.
|
||||
*
|
||||
* @param {TaskNoteRequest} payload - The task data to add.
|
||||
* @returns {Promise<boolean>} True if the task was added successfully, false otherwise.
|
||||
* @param {NoteResponse} payload - The note data to add.
|
||||
* @returns {Promise<boolean>} True if the note was added successfully, false otherwise.
|
||||
*/
|
||||
const addNote = async (payload: NoteResponse): Promise<boolean> => {
|
||||
try {
|
||||
@@ -82,15 +97,14 @@ function NoteAdd(): React.ReactNode {
|
||||
catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Submits the edited task.
|
||||
* Submits the edited note.
|
||||
*
|
||||
* @param {TaskResponse} payload - The task data to edit.
|
||||
* @returns {Promise<boolean>} True if the task was edited successfully, false otherwise.
|
||||
* @param {NoteResponse} payload - The note data to edit.
|
||||
* @returns {Promise<boolean>} True if the note was edited successfully, false otherwise.
|
||||
*/
|
||||
const submitEditNote = async (payload: NoteResponse): Promise<boolean> => {
|
||||
try {
|
||||
@@ -111,16 +125,86 @@ function NoteAdd(): React.ReactNode {
|
||||
setNoteTitle('');
|
||||
setNoteUrl('');
|
||||
setNoteContent('');
|
||||
setNoteTag('');
|
||||
|
||||
setCurrentTag('');
|
||||
setSelectedTags([]);
|
||||
setAction('add');
|
||||
setValidated(false);
|
||||
};
|
||||
|
||||
const saveDraft = (title: string, content: string, noteUrl: string, draftTags: string[]): void => {
|
||||
if (!hasUserEdited.current) return;
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
const draft: NoteDraft = { title, content, noteUrl, tags: draftTags };
|
||||
localStorage.setItem(draftKey, JSON.stringify(draft));
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const clearDraft = (): void => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
localStorage.removeItem(draftKey);
|
||||
};
|
||||
|
||||
const applyDraft = (): void => {
|
||||
const raw = localStorage.getItem(draftKey);
|
||||
if (!raw) return;
|
||||
try {
|
||||
const draft: NoteDraft = JSON.parse(raw);
|
||||
setNoteTitle(draft.title ?? '');
|
||||
setNoteContent(draft.content ?? '');
|
||||
setNoteUrl(draft.noteUrl ?? '');
|
||||
setSelectedTags(draft.tags ?? []);
|
||||
setDraftBanner(true);
|
||||
}
|
||||
catch {
|
||||
localStorage.removeItem(draftKey);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscardDraft = async (): Promise<void> => {
|
||||
setDraftBanner(false);
|
||||
if (params?.id) {
|
||||
try {
|
||||
const noteToEdit: NoteResponse = await api.getJSON(`${ApiConfig.notesUrl}/${params.id}`);
|
||||
setNoteFromServer(noteToEdit);
|
||||
}
|
||||
catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
finally {
|
||||
clearDraft();
|
||||
}
|
||||
}
|
||||
else {
|
||||
clearDraft();
|
||||
resetInputs();
|
||||
}
|
||||
};
|
||||
|
||||
const addTag = (tagName: string): void => {
|
||||
const normalized = tagName.trim().toLowerCase();
|
||||
let newTags = [...selectedTags];
|
||||
if (normalized && !selectedTags.includes(normalized)) {
|
||||
newTags = [...selectedTags, normalized];
|
||||
setSelectedTags(newTags);
|
||||
}
|
||||
hasUserEdited.current = true;
|
||||
saveDraft(noteTitle, noteContent, noteUrl, newTags);
|
||||
setCurrentTag('');
|
||||
setShowTagDropdown(false);
|
||||
};
|
||||
|
||||
const removeTag = (tagToRemove: string): void => {
|
||||
const newTags = selectedTags.filter(t => t !== tagToRemove);
|
||||
setSelectedTags(newTags);
|
||||
hasUserEdited.current = true;
|
||||
saveDraft(noteTitle, noteContent, noteUrl, newTags);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the form submission.
|
||||
*
|
||||
* @param {React.FormEvent<HTMLFormElement>} event - The form submission event.
|
||||
* @param {React.SubmitEvent<HTMLFormElement>} event - The form submission event.
|
||||
*/
|
||||
const handleSubmit = async (event: React.SubmitEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault();
|
||||
@@ -133,13 +217,21 @@ 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,
|
||||
tag: noteTag,
|
||||
tags: finalTags,
|
||||
lastUpdate: '',
|
||||
shared: false,
|
||||
shareToken: null
|
||||
@@ -147,6 +239,7 @@ function NoteAdd(): React.ReactNode {
|
||||
|
||||
const added: boolean = await addNote(payload);
|
||||
if (added) {
|
||||
clearDraft();
|
||||
form.reset();
|
||||
resetInputs();
|
||||
navigate('/home');
|
||||
@@ -158,7 +251,7 @@ function NoteAdd(): React.ReactNode {
|
||||
title: noteTitle,
|
||||
description: noteContent,
|
||||
url: noteUrl,
|
||||
tag: noteTag,
|
||||
tags: finalTags,
|
||||
lastUpdate: '',
|
||||
shared: false,
|
||||
shareToken: null
|
||||
@@ -166,6 +259,7 @@ function NoteAdd(): React.ReactNode {
|
||||
|
||||
const edited: boolean = await submitEditNote(payload);
|
||||
if (edited) {
|
||||
clearDraft();
|
||||
form.reset();
|
||||
resetInputs();
|
||||
navigate('/home');
|
||||
@@ -174,7 +268,7 @@ function NoteAdd(): React.ReactNode {
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the URL is for editing a task and loads the task data if it is.
|
||||
* Checks if the URL is for editing a note and loads the note data if it is.
|
||||
*/
|
||||
const checkEditUrl = async (): Promise<void> => {
|
||||
if (params?.id) {
|
||||
@@ -182,6 +276,7 @@ function NoteAdd(): React.ReactNode {
|
||||
const noteToEdit: NoteResponse = await api.getJSON(`${ApiConfig.notesUrl}/${params.id}`);
|
||||
setNoteFromServer(noteToEdit);
|
||||
setAction('edit');
|
||||
applyDraft();
|
||||
}
|
||||
catch (e) {
|
||||
handleError(e);
|
||||
@@ -213,16 +308,16 @@ function NoteAdd(): React.ReactNode {
|
||||
}
|
||||
};
|
||||
|
||||
const setNoteFromServer = (noteContent: NoteResponse) => {
|
||||
setNoteId(noteContent.id);
|
||||
setNoteTitle(noteContent.title);
|
||||
if (noteContent.url) {
|
||||
setNoteUrl(noteContent.url);
|
||||
const setNoteFromServer = (noteData: NoteResponse) => {
|
||||
setNoteId(noteData.id);
|
||||
setNoteTitle(noteData.title);
|
||||
if (noteData.url) {
|
||||
setNoteUrl(noteData.url);
|
||||
}
|
||||
if (noteContent.tag) {
|
||||
setNoteTag(noteContent.tag);
|
||||
if (noteData.tags) {
|
||||
setSelectedTags(noteData.tags);
|
||||
}
|
||||
setNoteContent(noteContent.description);
|
||||
setNoteContent(noteData.description);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -246,6 +341,10 @@ function NoteAdd(): React.ReactNode {
|
||||
checkEditUrl();
|
||||
checkCloneUrl();
|
||||
|
||||
if (!params?.id && !window.location.search.includes('cloneFrom=')) {
|
||||
applyDraft();
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: MouseEvent): void => {
|
||||
if (tagContainerRef.current && !tagContainerRef.current.contains(event.target as Node)) {
|
||||
setShowTagDropdown(false);
|
||||
@@ -255,6 +354,7 @@ function NoteAdd(): React.ReactNode {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -279,6 +379,22 @@ function NoteAdd(): React.ReactNode {
|
||||
onClose={() => setErrorMessage('')}
|
||||
/>
|
||||
|
||||
{draftBanner && (
|
||||
<Alert variant="warning" dismissible onClose={() => setDraftBanner(false)}>
|
||||
Draft restored from a previous session.
|
||||
{' '}
|
||||
<Alert.Link
|
||||
href="#"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
void handleDiscardDraft();
|
||||
}}
|
||||
>
|
||||
Discard draft
|
||||
</Alert.Link>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Form
|
||||
noValidate
|
||||
validated={validated}
|
||||
@@ -296,6 +412,8 @@ function NoteAdd(): React.ReactNode {
|
||||
value={noteTitle}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNoteTitle(e.target.value);
|
||||
hasUserEdited.current = true;
|
||||
saveDraft(e.target.value, noteContent, noteUrl, selectedTags);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -312,13 +430,15 @@ function NoteAdd(): React.ReactNode {
|
||||
value={noteUrl}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNoteUrl(e.target.value);
|
||||
hasUserEdited.current = true;
|
||||
saveDraft(noteTitle, noteContent, e.target.value, selectedTags);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} xl={3}>
|
||||
{/* Tag with suggestion dropdown */}
|
||||
<Form.Group className="mb-3" ref={tagContainerRef} style={{ position: 'relative' }}>
|
||||
<Form.Label>Tag</Form.Label>
|
||||
<Form.Label>Tags</Form.Label>
|
||||
<InputGroup className="mb-3">
|
||||
<InputGroup.Text>
|
||||
<Hash />
|
||||
@@ -327,16 +447,39 @@ function NoteAdd(): React.ReactNode {
|
||||
type="text"
|
||||
name="tag"
|
||||
placeholder="my-tag (Optional)"
|
||||
value={noteTag}
|
||||
value={currentTag}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNoteTag(e.target.value);
|
||||
setCurrentTag(e.target.value);
|
||||
setShowTagDropdown(true);
|
||||
}}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && currentTag.trim()) {
|
||||
e.preventDefault();
|
||||
addTag(currentTag);
|
||||
}
|
||||
}}
|
||||
onFocus={() => setShowTagDropdown(true)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</InputGroup>
|
||||
{showTagDropdown && tags.filter(t => t.toLowerCase().includes(noteTag.toLowerCase())).length > 0 && (
|
||||
<div className="mb-2 d-flex flex-wrap gap-1">
|
||||
{selectedTags.map(t => (
|
||||
<Badge
|
||||
key={t}
|
||||
bg="warning"
|
||||
text="dark"
|
||||
className="p-2"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => removeTag(t)}
|
||||
>
|
||||
#
|
||||
{t}
|
||||
{' '}
|
||||
×
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
{showTagDropdown && tags.filter(t => t.toLowerCase().includes(currentTag.toLowerCase())).length > 0 && (
|
||||
<ListGroup
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -344,20 +487,18 @@ function NoteAdd(): React.ReactNode {
|
||||
width: '100%',
|
||||
maxHeight: '200px',
|
||||
overflowY: 'auto',
|
||||
top: '100%',
|
||||
left: 0
|
||||
}}
|
||||
>
|
||||
{tags
|
||||
.filter(t => t.toLowerCase().includes(noteTag.toLowerCase()))
|
||||
.filter(t => t.toLowerCase().includes(currentTag.toLowerCase()))
|
||||
.map(t => (
|
||||
<ListGroup.Item
|
||||
key={t}
|
||||
action
|
||||
onMouseDown={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setNoteTag(t);
|
||||
setShowTagDropdown(false);
|
||||
addTag(t);
|
||||
}}
|
||||
>
|
||||
#
|
||||
@@ -392,6 +533,8 @@ function NoteAdd(): React.ReactNode {
|
||||
value={noteContent}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setNoteContent(e.target.value);
|
||||
hasUserEdited.current = true;
|
||||
saveDraft(noteTitle, e.target.value, noteUrl, selectedTags);
|
||||
}}
|
||||
data-testid="note-content-input-area"
|
||||
/>
|
||||
@@ -408,6 +551,7 @@ function NoteAdd(): React.ReactNode {
|
||||
type="button"
|
||||
className="ms-2 home-new-item-secondary task-note-btn"
|
||||
onClick={() => {
|
||||
clearDraft();
|
||||
navigate('/home');
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -72,10 +72,9 @@ function SharedNote(): React.ReactNode {
|
||||
<Card>
|
||||
<Card.Header className="d-flex justify-content-between align-items-center">
|
||||
<small className="text-muted">TaskNote · Shared Note (Read only)</small>
|
||||
{note.tag && (
|
||||
{note.tags && note.tags.length > 0 && (
|
||||
<small className="text-muted">
|
||||
#
|
||||
{note.tag}
|
||||
{note.tags.map(t => `#${t}`).join(' ')}
|
||||
</small>
|
||||
)}
|
||||
</Card.Header>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Card,
|
||||
Col,
|
||||
Container,
|
||||
@@ -22,6 +24,14 @@ import AlertError from '../../components/AlertError';
|
||||
|
||||
type TaskAction = 'add' | 'edit';
|
||||
|
||||
interface TaskDraft {
|
||||
description: string;
|
||||
taskUrl: string;
|
||||
dueDate: string | null;
|
||||
highPriority: boolean;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* TaskAdd component for adding and editing tasks.
|
||||
*
|
||||
@@ -35,15 +45,21 @@ function TaskAdd(): React.ReactNode {
|
||||
const [taskUrl, setTaskUrl] = useState<string>('');
|
||||
const [taskDone, setTaskDone] = 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 [tag, setTag] = useState<string>('');
|
||||
const [currentTag, setCurrentTag] = useState<string>('');
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [showTagDropdown, setShowTagDropdown] = useState<boolean>(false);
|
||||
const [draftBanner, setDraftBanner] = useState<boolean>(false);
|
||||
const { i18n, t } = useTranslation();
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
const tagContainerRef = useRef<HTMLDivElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const hasUserEdited = useRef<boolean>(false);
|
||||
|
||||
const draftKey = params?.id ? `draft:task:edit:${params.id}` : 'draft:task:new';
|
||||
|
||||
const loadTags = async (): Promise<void> => {
|
||||
try {
|
||||
@@ -83,7 +99,6 @@ function TaskAdd(): React.ReactNode {
|
||||
catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -112,18 +127,116 @@ function TaskAdd(): React.ReactNode {
|
||||
setTaskDescription('');
|
||||
setTaskDone(false);
|
||||
setTaskUrl('');
|
||||
setDueDate(null);
|
||||
setDueDate('');
|
||||
setHighPriority(false);
|
||||
setTag('');
|
||||
|
||||
setCurrentTag('');
|
||||
setSelectedTags([]);
|
||||
setAction('add');
|
||||
setValidated(false);
|
||||
};
|
||||
|
||||
const saveDraft = (
|
||||
description: string,
|
||||
taskUrl: string,
|
||||
due: string,
|
||||
priority: boolean,
|
||||
draftTags: string[]
|
||||
): void => {
|
||||
if (!hasUserEdited.current) return;
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
const draft: TaskDraft = {
|
||||
description,
|
||||
taskUrl,
|
||||
dueDate: due || null,
|
||||
highPriority: priority,
|
||||
tags: draftTags
|
||||
};
|
||||
localStorage.setItem(draftKey, JSON.stringify(draft));
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const clearDraft = (): void => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
localStorage.removeItem(draftKey);
|
||||
};
|
||||
|
||||
const applyDraft = (): void => {
|
||||
const raw = localStorage.getItem(draftKey);
|
||||
if (!raw) return;
|
||||
try {
|
||||
const draft: TaskDraft = JSON.parse(raw);
|
||||
setTaskDescription(draft.description ?? '');
|
||||
setTaskUrl(draft.taskUrl ?? '');
|
||||
const parsedDate = draft.dueDate ? draft.dueDate : '';
|
||||
setDueDate(parsedDate);
|
||||
setHighPriority(draft.highPriority ?? false);
|
||||
setSelectedTags(draft.tags ?? []);
|
||||
setDraftBanner(true);
|
||||
}
|
||||
catch {
|
||||
localStorage.removeItem(draftKey);
|
||||
}
|
||||
};
|
||||
|
||||
const setTaskFromServer = (task: TaskResponse): void => {
|
||||
setTaskId(task.id);
|
||||
setTaskDescription(task.description);
|
||||
setTaskUrl(task.urls.length ? task.urls[0] : '');
|
||||
setTaskDone(task.done);
|
||||
if (task.dueDateFmt) {
|
||||
setDueDate(task.dueDate);
|
||||
}
|
||||
setHighPriority(task.highPriority);
|
||||
if (task.tags) {
|
||||
setSelectedTags(task.tags);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscardDraft = async (): Promise<void> => {
|
||||
setDraftBanner(false);
|
||||
if (params?.id) {
|
||||
try {
|
||||
const taskToEdit: TaskResponse = await api.getJSON(`${ApiConfig.tasksUrl}/${params.id}`);
|
||||
setTaskFromServer(taskToEdit);
|
||||
}
|
||||
catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
finally {
|
||||
clearDraft();
|
||||
}
|
||||
}
|
||||
else {
|
||||
clearDraft();
|
||||
resetInputs();
|
||||
}
|
||||
};
|
||||
|
||||
const addTag = (tagName: string): void => {
|
||||
const normalized = tagName.trim().toLowerCase();
|
||||
let newTags = [...selectedTags];
|
||||
if (normalized && !selectedTags.includes(normalized)) {
|
||||
newTags = [...selectedTags, normalized];
|
||||
setSelectedTags(newTags);
|
||||
}
|
||||
hasUserEdited.current = true;
|
||||
saveDraft(taskDescription, taskUrl, dueDate, highPriority, newTags);
|
||||
setCurrentTag('');
|
||||
setShowTagDropdown(false);
|
||||
};
|
||||
|
||||
const removeTag = (tagToRemove: string): void => {
|
||||
const newTags = selectedTags.filter(t => t !== tagToRemove);
|
||||
setSelectedTags(newTags);
|
||||
hasUserEdited.current = true;
|
||||
saveDraft(taskDescription, taskUrl, dueDate, highPriority, newTags);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the form submission.
|
||||
*
|
||||
* @param {React.FormEvent<HTMLFormElement>} event - The form submission event.
|
||||
* @param {React.SubmitEvent<HTMLFormElement>} event - The form submission event.
|
||||
*/
|
||||
const handleSubmit = async (event: React.SubmitEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault();
|
||||
@@ -136,9 +249,14 @@ 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()) {
|
||||
const normalized = currentTag.trim().toLowerCase();
|
||||
if (!finalTags.includes(normalized)) {
|
||||
finalTags.push(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'add') {
|
||||
@@ -146,12 +264,13 @@ function TaskAdd(): React.ReactNode {
|
||||
description: taskDescription.trim(),
|
||||
highPriority: highPriority,
|
||||
dueDate: dueDateFormatted,
|
||||
tag: tag,
|
||||
tags: finalTags,
|
||||
urls: taskUrl ? [taskUrl] : []
|
||||
};
|
||||
|
||||
const added: boolean = await addTask(addPayload);
|
||||
if (added) {
|
||||
clearDraft();
|
||||
form.reset();
|
||||
resetInputs();
|
||||
navigate('/home');
|
||||
@@ -166,12 +285,13 @@ function TaskAdd(): React.ReactNode {
|
||||
dueDate: dueDateFormatted,
|
||||
dueDateFmt: '',
|
||||
lastUpdate: '',
|
||||
tag: tag,
|
||||
tags: finalTags,
|
||||
urls: taskUrl ? [taskUrl] : []
|
||||
};
|
||||
|
||||
const edited: boolean = await submitEditTask(editPayload);
|
||||
if (edited) {
|
||||
clearDraft();
|
||||
form.reset();
|
||||
resetInputs();
|
||||
navigate('/home');
|
||||
@@ -186,18 +306,9 @@ function TaskAdd(): React.ReactNode {
|
||||
if (params.id) {
|
||||
try {
|
||||
const taskToEdit: TaskResponse = await api.getJSON(`${ApiConfig.tasksUrl}/${params.id}`);
|
||||
setTaskId(taskToEdit.id);
|
||||
setTaskDescription(taskToEdit.description);
|
||||
setTaskUrl(taskToEdit.urls.length ? taskToEdit.urls[0] : '');
|
||||
setTaskDone(taskToEdit.done);
|
||||
if (taskToEdit.dueDateFmt) {
|
||||
setDueDate(new Date(taskToEdit.dueDate));
|
||||
}
|
||||
setHighPriority(taskToEdit.highPriority);
|
||||
if (taskToEdit.tag) {
|
||||
setTag(taskToEdit.tag);
|
||||
}
|
||||
setTaskFromServer(taskToEdit);
|
||||
setAction('edit');
|
||||
applyDraft();
|
||||
}
|
||||
catch (e) {
|
||||
handleError(e);
|
||||
@@ -209,6 +320,10 @@ function TaskAdd(): React.ReactNode {
|
||||
loadTags();
|
||||
checkEditUrl();
|
||||
|
||||
if (!params?.id) {
|
||||
applyDraft();
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: MouseEvent): void => {
|
||||
if (tagContainerRef.current && !tagContainerRef.current.contains(event.target as Node)) {
|
||||
setShowTagDropdown(false);
|
||||
@@ -218,6 +333,7 @@ function TaskAdd(): React.ReactNode {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -243,6 +359,22 @@ function TaskAdd(): React.ReactNode {
|
||||
onClose={() => setErrorMessage('')}
|
||||
/>
|
||||
|
||||
{draftBanner && (
|
||||
<Alert variant="warning" dismissible onClose={() => setDraftBanner(false)}>
|
||||
Draft restored from a previous session.
|
||||
{' '}
|
||||
<Alert.Link
|
||||
href="#"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
void handleDiscardDraft();
|
||||
}}
|
||||
>
|
||||
Discard draft
|
||||
</Alert.Link>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Form
|
||||
noValidate
|
||||
validated={validated}
|
||||
@@ -260,6 +392,8 @@ function TaskAdd(): React.ReactNode {
|
||||
value={taskDescription}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTaskDescription(e.target.value);
|
||||
hasUserEdited.current = true;
|
||||
saveDraft(e.target.value, taskUrl, dueDate, highPriority, selectedTags);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -276,10 +410,12 @@ function TaskAdd(): React.ReactNode {
|
||||
value={taskUrl}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTaskUrl(e.target.value);
|
||||
hasUserEdited.current = true;
|
||||
saveDraft(taskDescription, e.target.value, dueDate, highPriority, selectedTags);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={6} xxl={3}>
|
||||
<Col xs={12} sm={6} xxl={6}>
|
||||
{/* Due date */}
|
||||
<FormInput
|
||||
labelText={t('task_form_duedate_label')}
|
||||
@@ -288,16 +424,17 @@ function TaskAdd(): React.ReactNode {
|
||||
type="date"
|
||||
name="dueDate"
|
||||
placeholder={t('task_form_duedate_placeholder')}
|
||||
valueDate={dueDate}
|
||||
onChangeDate={(date: Date | null) => {
|
||||
setDueDate(date);
|
||||
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} xxl={3}>
|
||||
<Col xs={12} sm={12} xxl={12}>
|
||||
{/* Tag with suggestion dropdown */}
|
||||
<Form.Group className="mb-3" ref={tagContainerRef} style={{ position: 'relative' }}>
|
||||
<Form.Label>Tag</Form.Label>
|
||||
<Form.Label>Tags</Form.Label>
|
||||
<InputGroup className="mb-3">
|
||||
<InputGroup.Text>
|
||||
<Hash />
|
||||
@@ -306,16 +443,39 @@ function TaskAdd(): React.ReactNode {
|
||||
type="text"
|
||||
name="tag"
|
||||
placeholder="my-tag (Optional)"
|
||||
value={tag}
|
||||
value={currentTag}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTag(e.target.value);
|
||||
setCurrentTag(e.target.value);
|
||||
setShowTagDropdown(true);
|
||||
}}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && currentTag.trim()) {
|
||||
e.preventDefault();
|
||||
addTag(currentTag);
|
||||
}
|
||||
}}
|
||||
onFocus={() => setShowTagDropdown(true)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</InputGroup>
|
||||
{showTagDropdown && tags.filter(t => t.toLowerCase().includes(tag.toLowerCase())).length > 0 && (
|
||||
<div className="mb-2 d-flex flex-wrap gap-1">
|
||||
{selectedTags.map(t => (
|
||||
<Badge
|
||||
key={t}
|
||||
bg="warning"
|
||||
text="dark"
|
||||
className="p-2"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => removeTag(t)}
|
||||
>
|
||||
#
|
||||
{t}
|
||||
{' '}
|
||||
×
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
{showTagDropdown && tags.filter(t => t.toLowerCase().includes(currentTag.toLowerCase())).length > 0 && (
|
||||
<ListGroup
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -323,20 +483,18 @@ function TaskAdd(): React.ReactNode {
|
||||
width: '100%',
|
||||
maxHeight: '200px',
|
||||
overflowY: 'auto',
|
||||
top: '100%',
|
||||
left: 0
|
||||
}}
|
||||
>
|
||||
{tags
|
||||
.filter(t => t.toLowerCase().includes(tag.toLowerCase()))
|
||||
.filter(t => t.toLowerCase().includes(currentTag.toLowerCase()))
|
||||
.map(t => (
|
||||
<ListGroup.Item
|
||||
key={t}
|
||||
action
|
||||
onMouseDown={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setTag(t);
|
||||
setShowTagDropdown(false);
|
||||
addTag(t);
|
||||
}}
|
||||
>
|
||||
#
|
||||
@@ -356,7 +514,11 @@ function TaskAdd(): React.ReactNode {
|
||||
className="mb-3"
|
||||
name="highPriority"
|
||||
checked={highPriority}
|
||||
onChange={() => setHighPriority(!highPriority)}
|
||||
onChange={() => {
|
||||
setHighPriority(!highPriority);
|
||||
hasUserEdited.current = true;
|
||||
saveDraft(taskDescription, taskUrl, dueDate, !highPriority, selectedTags);
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
@@ -370,6 +532,7 @@ function TaskAdd(): React.ReactNode {
|
||||
type="button"
|
||||
className="ms-2 home-new-item-secondary task-note-btn"
|
||||
onClick={() => {
|
||||
clearDraft();
|
||||
navigate('/home');
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -29,7 +29,7 @@ export default defineConfig(({ mode }: ConfigEnv) => {
|
||||
],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true
|
||||
sourcemap: mode === 'development'
|
||||
},
|
||||
server: {
|
||||
port: 5000
|
||||
|
||||
@@ -4,6 +4,7 @@ services:
|
||||
tasknote-web:
|
||||
container_name: tasknote-web
|
||||
image: node:22.14-bookworm-slim
|
||||
user: ${UID:-1000}:${GID:-1000}
|
||||
ports:
|
||||
- "5000:5000"
|
||||
entrypoint: sh -c "npm i --no-update-notifier && npm start"
|
||||
@@ -25,6 +26,7 @@ services:
|
||||
depends_on:
|
||||
tasknote-db:
|
||||
condition: service_started
|
||||
user: ${UID:-1000}:${GID:-1000}
|
||||
environment:
|
||||
POSTGRES_DB: tasknote
|
||||
POSTGRES_HOST: tasknote-db
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
|
||||
services:
|
||||
tasknote-web:
|
||||
container_name: tasknote-web
|
||||
image: node:22.14-bookworm-slim
|
||||
user: ${UID:-1000}:${GID:-1000}
|
||||
ports:
|
||||
- "5000:5000"
|
||||
entrypoint: sh -c "npm i --no-update-notifier && npm start"
|
||||
environment:
|
||||
VITE_BACKEND_SERVER: "/api"
|
||||
VITE_BUILD: nightly
|
||||
volumes:
|
||||
- "./client:/app"
|
||||
working_dir: /app
|
||||
healthcheck:
|
||||
test: timeout 10s bash -c 'true > /dev/tcp/127.0.0.1/5000'
|
||||
interval: 1m30s
|
||||
timeout: 15s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
networks:
|
||||
- tasknote
|
||||
|
||||
tasknote-api:
|
||||
container_name: tasknote-api
|
||||
depends_on:
|
||||
tasknote-db:
|
||||
condition: service_started
|
||||
user: ${UID:-1000}:${GID:-1000}
|
||||
environment:
|
||||
POSTGRES_DB: tasknote
|
||||
POSTGRES_HOST: tasknote-db
|
||||
POSTGRES_USER: tasknoteuser
|
||||
POSTGRES_PASSWORD: default
|
||||
POSTGRES_PORT: 5432
|
||||
CORS_ALLOWED_ORIGINS: http://localhost:5000,http://tasknote-web:5000,https://flattop-depth-dropper.ngrok-free.dev
|
||||
SERVER_SERVLET_CONTEXT_PATH: /
|
||||
TARGET_ENV: development
|
||||
SECURITY_KEY: this-is-a-very-long-security-key-for-dev
|
||||
MAILGUN_APIKEY: invalid-api-key-only-placeholder
|
||||
ports:
|
||||
- "8585:8585"
|
||||
- "5005:5005"
|
||||
image: maven:3.9.12-eclipse-temurin-25
|
||||
entrypoint: './mvnw -ntp spring-boot:run -Dspring-boot.run.jvmArguments="-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=*:5005" -Dmaven.plugin.validation=VERBOSE'
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- "./server:/app"
|
||||
healthcheck:
|
||||
test: curl -f http://localhost:8585/health | grep '"status":"UP"'
|
||||
interval: 1m30s
|
||||
timeout: 15s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
networks:
|
||||
- tasknote
|
||||
|
||||
schemaspy:
|
||||
container_name: schemaspy
|
||||
profiles: ["schemaspy"]
|
||||
image: schemaspy/schemaspy:7.0.2
|
||||
user: ${UID:-1000}:${GID:-1000}
|
||||
volumes:
|
||||
- "./schemaspy/output:/output"
|
||||
- "./schemaspy/postgres.properties:/schemaspy.properties"
|
||||
depends_on:
|
||||
tasknote-db:
|
||||
condition: service_healthy
|
||||
tasknote-api:
|
||||
condition: service_healthy
|
||||
|
||||
tasknote-db:
|
||||
container_name: tasknote-db
|
||||
image: postgres:15.8-bookworm
|
||||
environment:
|
||||
POSTGRES_DB: tasknote
|
||||
POSTGRES_USER: tasknoteuser
|
||||
POSTGRES_PASSWORD: default
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: psql -q -U $${POSTGRES_USER} -d $${POSTGRES_DB} -c 'SELECT 1'
|
||||
interval: 1m30s
|
||||
timeout: 15s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
networks:
|
||||
- tasknote
|
||||
|
||||
networks:
|
||||
tasknote:
|
||||
external: true
|
||||
@@ -23,13 +23,13 @@ services:
|
||||
POSTGRES_DB: tasknote
|
||||
POSTGRES_HOST: tasknote-db
|
||||
POSTGRES_USER: tasknoteuser
|
||||
POSTGRES_PASSWORD: default
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_PORT: 5432
|
||||
CORS_ALLOWED_ORIGINS: http://tasknote-web:5000, http://localhost:5000, https://flattop-depth-dropper.ngrok-free.dev
|
||||
CORS_ALLOWED_ORIGINS: http://tasknote-web:5000, http://localhost:5000
|
||||
SERVER_SERVLET_CONTEXT_PATH: /
|
||||
TARGET_ENV: production
|
||||
SECURITY_KEY: ${SECURITY_KEY:-default-security-key}
|
||||
MAILGUN_APIKEY: ${MAILGUN_APIKEY:-default-mailgun-apikey}
|
||||
SECURITY_KEY: ${SECURITY_KEY}
|
||||
MAILGUN_APIKEY: ${MAILGUN_APIKEY}
|
||||
ports: ["8585:8585"]
|
||||
image: ghcr.io/rmcampos/tasknote/api:latest
|
||||
healthcheck:
|
||||
@@ -47,8 +47,7 @@ services:
|
||||
environment:
|
||||
POSTGRES_DB: tasknote
|
||||
POSTGRES_USER: tasknoteuser
|
||||
POSTGRES_PASSWORD: default
|
||||
ports: ["5432:5432"]
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
healthcheck:
|
||||
test: psql -q -U $${POSTGRES_USER} -d $${POSTGRES_DB} -c 'SELECT 1'
|
||||
interval: 1m30s
|
||||
|
||||
@@ -6,5 +6,5 @@ docker run -d \
|
||||
-p 127.0.0.1:8181:8181 \
|
||||
-v ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro \
|
||||
--restart unless-stopped \
|
||||
--network tasknote-network \
|
||||
--network tasknote \
|
||||
nginx:stable
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended"
|
||||
]
|
||||
}
|
||||
+13
-7
@@ -5,13 +5,13 @@
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>4.0.6</version>
|
||||
<version>4.0.7</version>
|
||||
<relativePath/> <!-- lookup parent from repository -->
|
||||
</parent>
|
||||
|
||||
<groupId>br.com.tasknoteapp</groupId>
|
||||
<artifactId>server</artifactId>
|
||||
<version>26</version>
|
||||
<version>34</version>
|
||||
<name>tasknote-api</name>
|
||||
<description>Java backend REST API to serve TaskNote frontend client</description>
|
||||
|
||||
@@ -49,11 +49,11 @@
|
||||
<jacoco.output.data>${project.build.directory}/coverage-reports</jacoco.output.data>
|
||||
<timestamp>${maven.build.timestamp}</timestamp>
|
||||
<maven.build.timestamp.format>yyyy-MM-dd HH:mm:ss</maven.build.timestamp.format>
|
||||
<failsafe.version>3.5.5</failsafe.version>
|
||||
<surefire.version>3.5.5</surefire.version>
|
||||
<jacoco.version>0.8.14</jacoco.version>
|
||||
<failsafe.version>3.5.6</failsafe.version>
|
||||
<surefire.version>3.5.6</surefire.version>
|
||||
<jacoco.version>0.8.15</jacoco.version>
|
||||
<checkstyle.version>3.6.0</checkstyle.version>
|
||||
<springboot.version>4.0.6</springboot.version>
|
||||
<springboot.version>4.0.7</springboot.version>
|
||||
<jjwt.version>0.12.6</jjwt.version>
|
||||
</properties>
|
||||
|
||||
@@ -129,6 +129,12 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test-classic</artifactId>
|
||||
<scope>test</scope>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-grpc-test</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
@@ -364,7 +370,7 @@
|
||||
<version>${springboot.version}</version>
|
||||
<configuration>
|
||||
<image>
|
||||
<name>ghcr.io/rmcampos/tasknote/api:latest</name>
|
||||
<name>docker.io/rmcampos/tasknote-api:latest</name>
|
||||
<buildpacks>
|
||||
<buildpack>file://${project.basedir}/buildpacks/healthcheck</buildpack>
|
||||
<buildpack>urn:cnb:builder:paketo-buildpacks/java-native-image</buildpack>
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
[build]
|
||||
builder = "paketobuildpacks/builder-jammy-tiny:latest"
|
||||
builder = "paketobuildpacks/builder-jammy-tiny:0.0.505"
|
||||
|
||||
[[build.buildpacks]]
|
||||
uri = "buildpacks/healthcheck"
|
||||
|
||||
@@ -56,7 +56,7 @@ public class SecurityConfig {
|
||||
.requestMatchers("/rest/**")
|
||||
.authenticated()
|
||||
.anyRequest()
|
||||
.permitAll())
|
||||
.denyAll())
|
||||
.httpBasic(AbstractHttpConfigurer::disable)
|
||||
.formLogin(AbstractHttpConfigurer::disable)
|
||||
.sessionManagement(
|
||||
@@ -77,7 +77,7 @@ public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
return new BCryptPasswordEncoder(12);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,6 +33,16 @@ public class UserController {
|
||||
return authService.getAllUsers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current logged user.
|
||||
*
|
||||
* @return UserEntity with the current user information.
|
||||
*/
|
||||
@GetMapping("/me")
|
||||
public UserResponse getCurrentUser() {
|
||||
return authService.getCurrentUserResponse();
|
||||
}
|
||||
|
||||
@PatchMapping
|
||||
public ResponseEntity<UserResponse> patchUserInfo(
|
||||
@RequestBody @Valid UserPatchRequest taskRequest) {
|
||||
|
||||
@@ -7,9 +7,13 @@ import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.JoinTable;
|
||||
import jakarta.persistence.ManyToMany;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/** This class represents a note in the database. */
|
||||
@Entity
|
||||
@@ -29,8 +33,12 @@ public class NoteEntity {
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
private UserEntity user;
|
||||
|
||||
@Column(name = "tag", length = 30)
|
||||
private String tag;
|
||||
@ManyToMany(fetch = FetchType.LAZY)
|
||||
@JoinTable(
|
||||
name = "note_tags",
|
||||
joinColumns = @JoinColumn(name = "note_id"),
|
||||
inverseJoinColumns = @JoinColumn(name = "tag_id"))
|
||||
private Set<TagEntity> tags = new HashSet<>();
|
||||
|
||||
@Column(name = "last_update")
|
||||
private LocalDateTime lastUpdate;
|
||||
@@ -73,12 +81,12 @@ public class NoteEntity {
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
public String getTag() {
|
||||
return tag;
|
||||
public Set<TagEntity> getTags() {
|
||||
return tags;
|
||||
}
|
||||
|
||||
public void setTag(String tag) {
|
||||
this.tag = tag;
|
||||
public void setTags(Set<TagEntity> tags) {
|
||||
this.tags = tags;
|
||||
}
|
||||
|
||||
public LocalDateTime getLastUpdate() {
|
||||
@@ -133,9 +141,8 @@ public class NoteEntity {
|
||||
+ ", description='"
|
||||
+ description
|
||||
+ '\''
|
||||
+ ", tag='"
|
||||
+ tag
|
||||
+ '\''
|
||||
+ ", tags="
|
||||
+ tags
|
||||
+ ", lastUpdate="
|
||||
+ lastUpdate
|
||||
+ '}';
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package br.com.tasknoteapp.server.entity;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
|
||||
/** This class represents a tag in the database. */
|
||||
@Entity
|
||||
@Table(
|
||||
name = "tags",
|
||||
uniqueConstraints = {@UniqueConstraint(columnNames = {"name", "user_id"})})
|
||||
public class TagEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, length = 30)
|
||||
private String name;
|
||||
|
||||
@JoinColumn(name = "user_id", referencedColumnName = "id", nullable = false, updatable = false)
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
private UserEntity user;
|
||||
|
||||
public TagEntity() {}
|
||||
|
||||
public TagEntity(String name, UserEntity user) {
|
||||
this.name = name;
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public UserEntity getUser() {
|
||||
return user;
|
||||
}
|
||||
|
||||
public void setUser(UserEntity user) {
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
if (o == null || getClass() != o.getClass()) {
|
||||
return false;
|
||||
}
|
||||
TagEntity that = (TagEntity) o;
|
||||
return id != null && id.equals(that.id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return getClass().hashCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "TagEntity{" + "id=" + id + ", name='" + name + '\'' + '}';
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,14 @@ import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.JoinTable;
|
||||
import jakarta.persistence.ManyToMany;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/** This class represents a task in the database. */
|
||||
@Entity
|
||||
@@ -39,8 +43,12 @@ public class TaskEntity {
|
||||
@Column(name = "high_priority")
|
||||
private Boolean highPriority;
|
||||
|
||||
@Column(name = "tag", length = 30)
|
||||
private String tag;
|
||||
@ManyToMany(fetch = FetchType.LAZY)
|
||||
@JoinTable(
|
||||
name = "task_tags",
|
||||
joinColumns = @JoinColumn(name = "task_id"),
|
||||
inverseJoinColumns = @JoinColumn(name = "tag_id"))
|
||||
private Set<TagEntity> tags = new HashSet<>();
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
@@ -98,12 +106,12 @@ public class TaskEntity {
|
||||
this.highPriority = highPriority;
|
||||
}
|
||||
|
||||
public String getTag() {
|
||||
return tag;
|
||||
public Set<TagEntity> getTags() {
|
||||
return tags;
|
||||
}
|
||||
|
||||
public void setTag(String tag) {
|
||||
this.tag = tag;
|
||||
public void setTags(Set<TagEntity> tags) {
|
||||
this.tags = tags;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -139,9 +147,8 @@ public class TaskEntity {
|
||||
+ dueDate
|
||||
+ ", highPriority="
|
||||
+ highPriority
|
||||
+ ", tag='"
|
||||
+ tag
|
||||
+ '\''
|
||||
+ ", tags="
|
||||
+ tags
|
||||
+ '}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
/** This interface represents a note repository, for database access. */
|
||||
public interface NoteRepository extends JpaRepository<NoteEntity, Long> {
|
||||
@@ -16,7 +17,16 @@ public interface NoteRepository extends JpaRepository<NoteEntity, Long> {
|
||||
Optional<NoteEntity> findByIdAndUser_id(Long id, Long userId);
|
||||
|
||||
@Query(
|
||||
"select n from NoteEntity n where (upper(n.title) like %?1% or upper(n.description) like"
|
||||
+ " %?1%) and n.user.id = ?2")
|
||||
List<NoteEntity> findAllBySearchTerm(String searchTerm, Long userId);
|
||||
"""
|
||||
select distinct n
|
||||
from NoteEntity n
|
||||
left join n.tags tg
|
||||
where (
|
||||
upper(n.title) like upper(concat('%', :searchTerm, '%')) or
|
||||
upper(n.description) like upper(concat('%', :searchTerm, '%')) or
|
||||
upper(tg.name) like upper(concat('%', :searchTerm, '%'))
|
||||
) and n.user.id = :userId
|
||||
""")
|
||||
List<NoteEntity> findAllBySearchTerm(
|
||||
@Param("searchTerm") String searchTerm, @Param("userId") Long userId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package br.com.tasknoteapp.server.repository;
|
||||
|
||||
import br.com.tasknoteapp.server.entity.TagEntity;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
/** This interface represents a tag repository, for database access. */
|
||||
public interface TagRepository extends JpaRepository<TagEntity, Long> {
|
||||
|
||||
Optional<TagEntity> findByNameAndUser_id(String name, Long userId);
|
||||
|
||||
List<TagEntity> findAllByUser_idOrderByNameAsc(Long userId);
|
||||
|
||||
@Modifying
|
||||
@Query(
|
||||
"""
|
||||
delete from TagEntity t
|
||||
where t.user.id = :userId
|
||||
and not exists (select 1 from TaskEntity tk where t member of tk.tags)
|
||||
and not exists (select 1 from NoteEntity n where t member of n.tags)
|
||||
""")
|
||||
void deleteOrphanedTags(@Param("userId") Long userId);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package br.com.tasknoteapp.server.repository;
|
||||
|
||||
import br.com.tasknoteapp.server.entity.TaskEntity;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
@@ -11,14 +12,17 @@ public interface TaskRepository extends JpaRepository<TaskEntity, Long> {
|
||||
|
||||
List<TaskEntity> findAllByUser_id(Long userId);
|
||||
|
||||
Optional<TaskEntity> findByIdAndUser_id(Long id, Long userId);
|
||||
|
||||
@Query(
|
||||
"""
|
||||
select distinct t
|
||||
from TaskEntity t
|
||||
left join TaskUrlEntity tu on tu.id.taskId = t.id
|
||||
left join t.tags tg
|
||||
where (
|
||||
upper(t.description) like upper(concat('%', :searchTerm, '%')) or
|
||||
upper(t.tag) 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
|
||||
""")
|
||||
|
||||
@@ -78,12 +78,8 @@ public class LoginRequest {
|
||||
+ "email='"
|
||||
+ email
|
||||
+ '\''
|
||||
+ ", password='"
|
||||
+ password
|
||||
+ '\''
|
||||
+ ", passwordAgain='"
|
||||
+ passwordAgain
|
||||
+ '\''
|
||||
+ ", password='[REDACTED]'"
|
||||
+ ", passwordAgain='[REDACTED]'"
|
||||
+ ", lang='"
|
||||
+ lang
|
||||
+ '\''
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
package br.com.tasknoteapp.server.request;
|
||||
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.util.List;
|
||||
|
||||
/** This record represents a note patch payload. */
|
||||
public record NotePatchRequest(
|
||||
String title,
|
||||
String description,
|
||||
@Pattern(
|
||||
@Size(max = 100) String title,
|
||||
@Size(max = 50000) String description,
|
||||
@Size(max = 200)
|
||||
@Pattern(
|
||||
regexp = "^(https?://.*|#.*)?$",
|
||||
message = "URL must start with https:// or #")
|
||||
String url,
|
||||
String tag) {}
|
||||
List<String> tags) {}
|
||||
|
||||
@@ -2,13 +2,16 @@ package br.com.tasknoteapp.server.request;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.util.List;
|
||||
|
||||
/** This record represents a note request to be created. */
|
||||
public record NoteRequest(
|
||||
@NotNull String title,
|
||||
@NotNull String description,
|
||||
@Pattern(
|
||||
@NotNull @Size(max = 100) String title,
|
||||
@NotNull @Size(max = 50000) String description,
|
||||
@Size(max = 200)
|
||||
@Pattern(
|
||||
regexp = "^(https?://.*|#.*)?$",
|
||||
message = "URL must start with https:// or #")
|
||||
String url,
|
||||
String tag) {}
|
||||
List<String> tags) {}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
package br.com.tasknoteapp.server.request;
|
||||
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.util.List;
|
||||
|
||||
/** This record represents a task patch payload. */
|
||||
public record TaskPatchRequest(
|
||||
String description,
|
||||
@Size(max = 2000) String description,
|
||||
Boolean done,
|
||||
List<
|
||||
@Size(max = 200)
|
||||
@Pattern(
|
||||
regexp = "^(https?://.*|#.*)?$",
|
||||
message = "URL must start with https:// or #")
|
||||
@@ -15,4 +17,4 @@ public record TaskPatchRequest(
|
||||
urls,
|
||||
String dueDate,
|
||||
Boolean highPriority,
|
||||
String tag) {}
|
||||
List<String> tags) {}
|
||||
|
||||
@@ -3,12 +3,14 @@ package br.com.tasknoteapp.server.request;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.util.List;
|
||||
|
||||
/** This record represents a task request to be created. */
|
||||
public record TaskRequest(
|
||||
@NotNull @NotEmpty String description,
|
||||
@NotNull @NotEmpty @Size(max = 2000) String description,
|
||||
List<
|
||||
@Size(max = 200)
|
||||
@Pattern(
|
||||
regexp = "^(https?://.*|#.*)?$",
|
||||
message = "URL must start with https:// or #")
|
||||
@@ -16,4 +18,4 @@ public record TaskRequest(
|
||||
urls,
|
||||
String dueDate,
|
||||
Boolean highPriority,
|
||||
String tag) {}
|
||||
List<String> tags) {}
|
||||
|
||||
@@ -2,4 +2,10 @@ package br.com.tasknoteapp.server.request;
|
||||
|
||||
/** This record represents a user patch payload. */
|
||||
public record UserPatchRequest(
|
||||
String name, String email, String password, String passwordAgain, String lang) {}
|
||||
String name,
|
||||
String email,
|
||||
String password,
|
||||
String passwordAgain,
|
||||
String lang,
|
||||
String currentPassword) {}
|
||||
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
package br.com.tasknoteapp.server.response;
|
||||
|
||||
import br.com.tasknoteapp.server.entity.NoteEntity;
|
||||
import br.com.tasknoteapp.server.entity.TagEntity;
|
||||
import br.com.tasknoteapp.server.util.TimeAgoUtil;
|
||||
import java.util.List;
|
||||
|
||||
/** This record represents a task and its URLs object to be returned. */
|
||||
public record NoteResponse(
|
||||
Long id, String title, String description, String url, String lastUpdate, String tag,
|
||||
boolean shared, String shareToken) {
|
||||
Long id,
|
||||
String title,
|
||||
String description,
|
||||
String url,
|
||||
String lastUpdate,
|
||||
List<String> tags,
|
||||
boolean shared,
|
||||
String shareToken) {
|
||||
|
||||
/**
|
||||
* Creates a NoteResponse given a NoteEntity and its Urals.
|
||||
*
|
||||
* @param entity The NoteEntity source data.
|
||||
* @param url The URL associated with the note.
|
||||
* @return NoteResponse instance with all note data and URLs, if any.
|
||||
*/
|
||||
public static NoteResponse fromEntity(NoteEntity entity, String url) {
|
||||
@@ -23,7 +32,7 @@ public record NoteResponse(
|
||||
entity.getDescription(),
|
||||
url,
|
||||
timeAgoFmt,
|
||||
entity.getTag(),
|
||||
entity.getTags().stream().map(TagEntity::getName).toList(),
|
||||
entity.isShared(),
|
||||
entity.getShareToken());
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user