Compare commits
16 Commits
f574fdc2d9
...
Dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 82a75411f2 | |||
| 214c050c8f | |||
| e9c380c35c | |||
| 738b6aad34 | |||
| f1200a2c78 | |||
| 15f518c58f | |||
| e5d579051d | |||
| 42531b37a5 | |||
| 7ee6b795f7 | |||
| d9a2b05bb1 | |||
| 8020e16ca1 | |||
| 7096f32ea5 | |||
| 6de09411a1 | |||
| 5bf00de940 | |||
| 14ebb21049 | |||
| 870ecd9052 |
@@ -0,0 +1,87 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
DEPLOY_HOST: "10.10.1.82"
|
||||
APP_PORT: "4000"
|
||||
DEPLOY_PATH: "/var/www/tasgrid"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
container:
|
||||
image: oven/bun:1
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: bun run build
|
||||
env:
|
||||
VITE_POCKETBASE_URL: ${{ vars.VITE_POCKETBASE_URL || 'https://pocketbase.ccllc.pro' }}
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
|
||||
container:
|
||||
image: node:20-bookworm
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install curl and ca-certificates
|
||||
run: apt-get update -qq && apt-get install -y curl ca-certificates
|
||||
|
||||
- name: Install Bun
|
||||
run: |
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
echo "$HOME/.bun/bin" >> $GITHUB_PATH
|
||||
echo "PATH=$HOME/.bun/bin:$PATH" >> $GITHUB_ENV
|
||||
|
||||
- name: Install deploy tools
|
||||
run: apt-get update -qq && apt-get install -y rsync openssh-client sshpass
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: bun run build
|
||||
env:
|
||||
VITE_POCKETBASE_URL: ${{ secrets.VITE_POCKETBASE_URL || vars.VITE_POCKETBASE_URL || 'https://pocketbase.ccllc.pro' }}
|
||||
|
||||
- name: Verify build output
|
||||
run: |
|
||||
ls -la
|
||||
test -d dist || (echo "Missing dist/ (Vite output). Check Build step." && exit 1)
|
||||
|
||||
- name: Setup SSH known hosts
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
ssh-keyscan -p ${{ secrets.DEPLOY_SSH_PORT || '22' }} -H ${{ secrets.DEPLOY_HOST || env.DEPLOY_HOST }} >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||
|
||||
- name: Deploy via rsync
|
||||
env:
|
||||
SSH_PASSWORD: ${{ secrets.DEPLOY_PASSWORD }}
|
||||
run: |
|
||||
sshpass -p "$SSH_PASSWORD" rsync -avz --delete \
|
||||
-e "ssh -o StrictHostKeyChecking=accept-new -p ${{ secrets.DEPLOY_SSH_PORT || '22' }}" \
|
||||
dist/ \
|
||||
${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST || env.DEPLOY_HOST }}:${{ secrets.DEPLOY_PATH || env.DEPLOY_PATH }}/
|
||||
|
||||
- name: Restart app on server
|
||||
env:
|
||||
SSH_PASSWORD: ${{ secrets.DEPLOY_PASSWORD }}
|
||||
run: |
|
||||
sshpass -p "$SSH_PASSWORD" ssh -o StrictHostKeyChecking=accept-new -p ${{ secrets.DEPLOY_SSH_PORT || '22' }} \
|
||||
${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST || env.DEPLOY_HOST }} \
|
||||
'bash -lc "cd ${{ secrets.DEPLOY_PATH || env.DEPLOY_PATH }} && export PORT=${{ env.APP_PORT }} && (pm2 restart tasgrid 2>/dev/null || pm2 start \"bun run serve\" --name tasgrid --update-env)"'
|
||||
@@ -1,3 +1,7 @@
|
||||
> **Branch notice:** This README belongs to the `Dev` branch and describes the **dev environment**. For live/production information, use the `main` branch.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
This project uses **Bun** as the package manager and runtime. Ensure you have Bun and PM2 installed globally before proceeding.
|
||||
|
||||
@@ -5,13 +5,26 @@
|
||||
"": {
|
||||
"name": "tasgrid",
|
||||
"dependencies": {
|
||||
"@formkit/auto-animate": "^0.9.0",
|
||||
"@kobalte/core": "^0.13.11",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tiptap/core": "^3.18.0",
|
||||
"@tiptap/extension-bubble-menu": "^3.20.0",
|
||||
"@tiptap/extension-dropcursor": "^3.20.0",
|
||||
"@tiptap/extension-floating-menu": "^3.20.0",
|
||||
"@tiptap/extension-highlight": "^3.20.0",
|
||||
"@tiptap/extension-image": "^3.20.0",
|
||||
"@tiptap/extension-link": "^3.20.0",
|
||||
"@tiptap/extension-mention": "^3.20.0",
|
||||
"@tiptap/extension-placeholder": "^3.18.0",
|
||||
"@tiptap/extension-table": "^3.20.0",
|
||||
"@tiptap/extension-table-cell": "^3.20.0",
|
||||
"@tiptap/extension-table-header": "^3.20.0",
|
||||
"@tiptap/extension-table-row": "^3.20.0",
|
||||
"@tiptap/extension-task-item": "^3.18.0",
|
||||
"@tiptap/extension-task-list": "^3.18.0",
|
||||
"@tiptap/extension-text-align": "^3.20.0",
|
||||
"@tiptap/extension-typography": "^3.20.0",
|
||||
"@tiptap/extension-underline": "^3.20.0",
|
||||
"@tiptap/starter-kit": "^3.18.0",
|
||||
"@tiptap/suggestion": "^3.18.0",
|
||||
@@ -285,6 +298,8 @@
|
||||
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
|
||||
|
||||
"@formkit/auto-animate": ["@formkit/auto-animate@0.9.0", "", {}, "sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA=="],
|
||||
|
||||
"@internationalized/date": ["@internationalized/date@3.10.1", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-oJrXtQiAXLvT9clCf1K4kxp3eKsQhIaZqxEyowkBcsvZDdZkbWrVmnGknxs5flTD0VGsxrxKgBCZty1EzoiMzA=="],
|
||||
|
||||
"@internationalized/number": ["@internationalized/number@3.6.5", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g=="],
|
||||
@@ -451,6 +466,8 @@
|
||||
|
||||
"@tiptap/extension-bold": ["@tiptap/extension-bold@3.18.0", "", { "peerDependencies": { "@tiptap/core": "^3.18.0" } }, "sha512-xUgOvHCdGXh9Lfxd7DtgsSr0T/egIwBllWHIBWDjQEQQ0b+ICn+0+i703btHMB4hjdduZtgVDrhK8jAW3U6swA=="],
|
||||
|
||||
"@tiptap/extension-bubble-menu": ["@tiptap/extension-bubble-menu@3.20.0", "", { "dependencies": { "@floating-ui/dom": "^1.0.0" }, "peerDependencies": { "@tiptap/core": "^3.20.0", "@tiptap/pm": "^3.20.0" } }, "sha512-MDosUfs8Tj+nwg8RC+wTMWGkLJORXmbR6YZgbiX4hrc7G90Gopdd6kj6ht5/T8t7dLLaX7N0+DEHdUEPGED7dw=="],
|
||||
|
||||
"@tiptap/extension-bullet-list": ["@tiptap/extension-bullet-list@3.18.0", "", { "peerDependencies": { "@tiptap/extension-list": "^3.18.0" } }, "sha512-8sEpY0nxAGGFDYlF+WVFPKX00X2dAAjmoi0+2eWvK990PdQqwXrQsRs7pkUbpE2mDtATV8+GlDXk9KDkK/ZXhA=="],
|
||||
|
||||
"@tiptap/extension-code": ["@tiptap/extension-code@3.18.0", "", { "peerDependencies": { "@tiptap/core": "^3.18.0" } }, "sha512-0SU53O0NRmdtRM2Hgzm372dVoHjs2F40o/dtB7ls4kocf4W89FyWeC2R6ZsFQqcXisNh9RTzLtYfbNyizGuZIw=="],
|
||||
@@ -459,7 +476,9 @@
|
||||
|
||||
"@tiptap/extension-document": ["@tiptap/extension-document@3.18.0", "", { "peerDependencies": { "@tiptap/core": "^3.18.0" } }, "sha512-e0hOGrjTMpCns8IC5p+c5CEiE1BBmFBFL+RpIxU/fjT2SaZ7q2xsFguBu94lQDT0cD6fdZokFRpGwEMxZNVGCg=="],
|
||||
|
||||
"@tiptap/extension-dropcursor": ["@tiptap/extension-dropcursor@3.18.0", "", { "peerDependencies": { "@tiptap/extensions": "^3.18.0" } }, "sha512-pIW/K9fGth221dkfA5SInHcqfnCr0aG9LGkRiEh4gwM4cf6ceUBrvcD+QlemSZ4q9oktNGJmXT+sEXVOQ8QoeQ=="],
|
||||
"@tiptap/extension-dropcursor": ["@tiptap/extension-dropcursor@3.20.0", "", { "peerDependencies": { "@tiptap/extensions": "^3.20.0" } }, "sha512-d+cxplRlktVgZPwatnc34IArlppM0IFKS1J5wLk+ba1jidizsbMVh45tP/BTK2flhyfRqcNoB5R0TArhUpbkNQ=="],
|
||||
|
||||
"@tiptap/extension-floating-menu": ["@tiptap/extension-floating-menu@3.20.0", "", { "peerDependencies": { "@floating-ui/dom": "^1.0.0", "@tiptap/core": "^3.20.0", "@tiptap/pm": "^3.20.0" } }, "sha512-rYs4Bv5pVjqZ/2vvR6oe7ammZapkAwN51As/WDbemvYDjfOGRqK58qGauUjYZiDzPOEIzI2mxGwsZ4eJhPW4Ig=="],
|
||||
|
||||
"@tiptap/extension-gapcursor": ["@tiptap/extension-gapcursor@3.18.0", "", { "peerDependencies": { "@tiptap/extensions": "^3.18.0" } }, "sha512-covioXPPHX3SnlTwC/1rcHUHAc7/JFd4vN0kZQmZmvGHlxqq2dPmtrPh8D7TuDuhG0k/3Z6i8dJFP0phfRAhuA=="],
|
||||
|
||||
@@ -467,13 +486,15 @@
|
||||
|
||||
"@tiptap/extension-heading": ["@tiptap/extension-heading@3.18.0", "", { "peerDependencies": { "@tiptap/core": "^3.18.0" } }, "sha512-MTamVnYsFWVndLSq5PRQ7ZmbF6AExsFS9uIvGtUAwuhzvR4of/WHh6wpvWYjA+BLXTWRrfuGHaZTl7UXBN13fg=="],
|
||||
|
||||
"@tiptap/extension-highlight": ["@tiptap/extension-highlight@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-ANL1wFz0s1ScNR4uBfO0s6Sz+qqGp2u6ynrCVk6TCT3d10CQ+gD1gSDTrVRC3CtlMKtHHH4fYrFAq284+J0gKA=="],
|
||||
|
||||
"@tiptap/extension-horizontal-rule": ["@tiptap/extension-horizontal-rule@3.18.0", "", { "peerDependencies": { "@tiptap/core": "^3.18.0", "@tiptap/pm": "^3.18.0" } }, "sha512-fEq7DwwQZ496RHNbMQypBVNqoWnhDEERbzWMBqlmfCfc/0FvJrHtsQkk3k4lgqMYqmBwym3Wp0SrRYiyKCPGTw=="],
|
||||
|
||||
"@tiptap/extension-image": ["@tiptap/extension-image@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-0t7HYncV0kYEQS79NFczxdlZoZ8zu8X4VavDqt+mbSAUKRq3gCvgtZ5Zyd778sNmtmbz3arxkEYMIVou2swD0g=="],
|
||||
|
||||
"@tiptap/extension-italic": ["@tiptap/extension-italic@3.18.0", "", { "peerDependencies": { "@tiptap/core": "^3.18.0" } }, "sha512-1C4nB08psiRo0BPxAbpYq8peUOKnjQWtBCLPbE6B9ToTK3vmUk0AZTqLO11FvokuM1GF5l2Lg3sKrKFuC2hcjQ=="],
|
||||
|
||||
"@tiptap/extension-link": ["@tiptap/extension-link@3.18.0", "", { "dependencies": { "linkifyjs": "^4.3.2" }, "peerDependencies": { "@tiptap/core": "^3.18.0", "@tiptap/pm": "^3.18.0" } }, "sha512-1J28C4+fKAMQi7q/UsTjAmgmKTnzjExXY98hEBneiVzFDxqF69n7+Vb7nVTNAIhmmJkZMA0DEcMhSiQC/1/u4A=="],
|
||||
"@tiptap/extension-link": ["@tiptap/extension-link@3.20.0", "", { "dependencies": { "linkifyjs": "^4.3.2" }, "peerDependencies": { "@tiptap/core": "^3.20.0", "@tiptap/pm": "^3.20.0" } }, "sha512-qI/5A+R0ZWBxo/8HxSn1uOyr7odr3xHBZ/gzOR1GUJaZqjlJxkWFX0RtXMbLKEGEvT25o345cF7b0wFznEh8qA=="],
|
||||
|
||||
"@tiptap/extension-list": ["@tiptap/extension-list@3.18.0", "", { "peerDependencies": { "@tiptap/core": "^3.18.0", "@tiptap/pm": "^3.18.0" } }, "sha512-9lQBo45HNqIFcLEHAk+CY3W51eMMxIJjWbthm2CwEWr4PB3+922YELlvq8JcLH1nVFkBVpmBFmQe/GxgnCkzwQ=="],
|
||||
|
||||
@@ -481,6 +502,8 @@
|
||||
|
||||
"@tiptap/extension-list-keymap": ["@tiptap/extension-list-keymap@3.18.0", "", { "peerDependencies": { "@tiptap/extension-list": "^3.18.0" } }, "sha512-ZzO5r/cW7G0zpL/eM69WPnMpzb0YsSjtI60CYGA0iQDRJnK9INvxu0RU0ewM2faqqwASmtjuNJac+Fjk6scdXg=="],
|
||||
|
||||
"@tiptap/extension-mention": ["@tiptap/extension-mention@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0", "@tiptap/pm": "^3.20.0", "@tiptap/suggestion": "^3.20.0" } }, "sha512-wUjsq7Za0JJdJzrGNG+g8nrCpek/85GQ0Rm9bka3PynIVRwus+xQqW6IyWVPBdl1BSkrbgMAUqtrfoh1ymznbg=="],
|
||||
|
||||
"@tiptap/extension-ordered-list": ["@tiptap/extension-ordered-list@3.18.0", "", { "peerDependencies": { "@tiptap/extension-list": "^3.18.0" } }, "sha512-5bUAfklYLS5o6qvLLfreGyGvD1JKXqOQF0YntLyPuCGrXv7+XjPWQL2BmEf59fOn2UPT2syXLQ1WN5MHTArRzg=="],
|
||||
|
||||
"@tiptap/extension-paragraph": ["@tiptap/extension-paragraph@3.18.0", "", { "peerDependencies": { "@tiptap/core": "^3.18.0" } }, "sha512-uvFhdwiur4NhhUdBmDsajxjGAIlg5qga55fYag2DzOXxIQE2M7/aVMRkRpuJzb88GY4EHSh8rY34HgMK2FJt2Q=="],
|
||||
@@ -489,12 +512,24 @@
|
||||
|
||||
"@tiptap/extension-strike": ["@tiptap/extension-strike@3.18.0", "", { "peerDependencies": { "@tiptap/core": "^3.18.0" } }, "sha512-kl/fa68LZg8NWUqTkRTfgyCx+IGqozBmzJxQDc1zxurrIU+VFptDV9UuZim587sbM2KGjCi/PNPjPGk1Uu0PVg=="],
|
||||
|
||||
"@tiptap/extension-table": ["@tiptap/extension-table@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0", "@tiptap/pm": "^3.20.0" } }, "sha512-vaaMtQ2KnSSr8WVwgWf7NYNzPwrHx/6T0ekA5CxV8qNUEpXIaLXa5+tE7tJHWEdNR2KY3gUJ46D3lfOkxyFrBQ=="],
|
||||
|
||||
"@tiptap/extension-table-cell": ["@tiptap/extension-table-cell@3.20.0", "", { "peerDependencies": { "@tiptap/extension-table": "^3.20.0" } }, "sha512-9Dg4zda3UWwtpBwSG7b9BeQy5oT27a/yEIBeARuxe19bloMLZgqpPRtnSrOK0OAITtVnjA+NZdKPcVLRMS2E8A=="],
|
||||
|
||||
"@tiptap/extension-table-header": ["@tiptap/extension-table-header@3.20.0", "", { "peerDependencies": { "@tiptap/extension-table": "^3.20.0" } }, "sha512-2tVHHlihpeHO/gh2uU46gAX3NTGdKR+yDmfLlO2l0QAvx2TXNfNzX2pOM4MmyostW5Ko9TCWV4x0D9h3IQDhPw=="],
|
||||
|
||||
"@tiptap/extension-table-row": ["@tiptap/extension-table-row@3.20.0", "", { "peerDependencies": { "@tiptap/extension-table": "^3.20.0" } }, "sha512-clkfQahkYW/U48QBh1rPZv3AWWSC9AqGKp2DLTH/SGIorM/NwI0jpPtBETMlvowyQu0ivlH9B896smEph+Do2A=="],
|
||||
|
||||
"@tiptap/extension-task-item": ["@tiptap/extension-task-item@3.18.0", "", { "peerDependencies": { "@tiptap/extension-list": "^3.18.0" } }, "sha512-XWrprk9YN2sc6coDypLxFGnFN51ZW+nKwz+9CHefINK6ndmUOeG1QNDGkQBnU5IB1fZBSEpyJga/shmJ7RfOAg=="],
|
||||
|
||||
"@tiptap/extension-task-list": ["@tiptap/extension-task-list@3.18.0", "", { "peerDependencies": { "@tiptap/extension-list": "^3.18.0" } }, "sha512-/znJ9/B4dBa4PizWtKFbVyuR7ROnlQA2HngL9Z5IvZQUlru1djpVfU4nuUImOkRWg4H7CRV5i2xL21JkcS57UA=="],
|
||||
|
||||
"@tiptap/extension-text": ["@tiptap/extension-text@3.18.0", "", { "peerDependencies": { "@tiptap/core": "^3.18.0" } }, "sha512-9TvctdnBCwK/zyTi9kS7nGFNl5OvGM8xE0u38ZmQw5t79JOqJHgOroyqMjw8LHK/1PWrozfNCmsZbpq4IZuKXw=="],
|
||||
|
||||
"@tiptap/extension-text-align": ["@tiptap/extension-text-align@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-4s0r+bovtH6yeGDUD+Ui8j5WOV5koB5P6AuzOMqoLwaFGRSkKf64ly6DXjjmjIgnYCLZN/XO6llaQKVVdvad2g=="],
|
||||
|
||||
"@tiptap/extension-typography": ["@tiptap/extension-typography@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-XpstIJ/hCK6+N87DdzjaYie1iH1gunbDK0UCsqsLemd8rMR/WAlF04M/yeJ6F2QtNzNh83bkoTxyNAHLDdUm2A=="],
|
||||
|
||||
"@tiptap/extension-underline": ["@tiptap/extension-underline@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-LzNXuy2jwR/y+ymoUqC72TiGzbOCjioIjsDu0MNYpHuHqTWPK5aV9Mh0nbZcYFy/7fPlV1q0W139EbJeYBZEAQ=="],
|
||||
|
||||
"@tiptap/extensions": ["@tiptap/extensions@3.18.0", "", { "peerDependencies": { "@tiptap/core": "^3.18.0", "@tiptap/pm": "^3.18.0" } }, "sha512-uSRIE9HGshBN6NRFR3LX2lZqBLvX92SgU5A9AvUbJD4MqU63E+HdruJnRjsVlX3kPrmbIDowxrzXlUcg3K0USQ=="],
|
||||
@@ -1393,6 +1428,10 @@
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@tiptap/starter-kit/@tiptap/extension-dropcursor": ["@tiptap/extension-dropcursor@3.18.0", "", { "peerDependencies": { "@tiptap/extensions": "^3.18.0" } }, "sha512-pIW/K9fGth221dkfA5SInHcqfnCr0aG9LGkRiEh4gwM4cf6ceUBrvcD+QlemSZ4q9oktNGJmXT+sEXVOQ8QoeQ=="],
|
||||
|
||||
"@tiptap/starter-kit/@tiptap/extension-link": ["@tiptap/extension-link@3.18.0", "", { "dependencies": { "linkifyjs": "^4.3.2" }, "peerDependencies": { "@tiptap/core": "^3.18.0", "@tiptap/pm": "^3.18.0" } }, "sha512-1J28C4+fKAMQi7q/UsTjAmgmKTnzjExXY98hEBneiVzFDxqF69n7+Vb7nVTNAIhmmJkZMA0DEcMhSiQC/1/u4A=="],
|
||||
|
||||
"@tiptap/starter-kit/@tiptap/extension-underline": ["@tiptap/extension-underline@3.18.0", "", { "peerDependencies": { "@tiptap/core": "^3.18.0" } }, "sha512-009IeXURNJ/sm1pBqbj+2YQgjQaBtNlJR3dbl6xu49C+qExqCmI7klhKQuwsVVGLR7ahsYlp7d9RlftnhCXIcQ=="],
|
||||
|
||||
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
+14
-1
@@ -12,16 +12,29 @@
|
||||
"logs": "pm2 logs tasgrid",
|
||||
"stop": "pm2 stop tasgrid",
|
||||
"start-prod": "pm2 start \"bun run serve\" --name \"tasgrid\"",
|
||||
"deploy": "git pull && bun run build && (pm2 restart tasgrid || bun run start-prod)"
|
||||
"deploy": "git pull && bun install && bun run build && (pm2 restart tasgrid || bun run start-prod)"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formkit/auto-animate": "^0.9.0",
|
||||
"@kobalte/core": "^0.13.11",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tiptap/core": "^3.18.0",
|
||||
"@tiptap/extension-bubble-menu": "^3.20.0",
|
||||
"@tiptap/extension-dropcursor": "^3.20.0",
|
||||
"@tiptap/extension-floating-menu": "^3.20.0",
|
||||
"@tiptap/extension-highlight": "^3.20.0",
|
||||
"@tiptap/extension-image": "^3.20.0",
|
||||
"@tiptap/extension-link": "^3.20.0",
|
||||
"@tiptap/extension-mention": "^3.20.0",
|
||||
"@tiptap/extension-placeholder": "^3.18.0",
|
||||
"@tiptap/extension-table": "^3.20.0",
|
||||
"@tiptap/extension-table-cell": "^3.20.0",
|
||||
"@tiptap/extension-table-header": "^3.20.0",
|
||||
"@tiptap/extension-table-row": "^3.20.0",
|
||||
"@tiptap/extension-task-item": "^3.18.0",
|
||||
"@tiptap/extension-task-list": "^3.18.0",
|
||||
"@tiptap/extension-text-align": "^3.20.0",
|
||||
"@tiptap/extension-typography": "^3.20.0",
|
||||
"@tiptap/extension-underline": "^3.20.0",
|
||||
"@tiptap/starter-kit": "^3.18.0",
|
||||
"@tiptap/suggestion": "^3.18.0",
|
||||
|
||||
+5
-1
@@ -30,12 +30,16 @@ const App: Component = () => {
|
||||
window.addEventListener('popstate', handleLocChange);
|
||||
window.addEventListener('hashchange', handleLocChange);
|
||||
|
||||
let isFirstRun = true;
|
||||
const removeListener = pb.authStore.onChange((token) => {
|
||||
console.log('[DEBUG] pb.authStore.onChange fired, token present:', !!token);
|
||||
const wasAuthenticated = isAuthenticated();
|
||||
setIsAuthenticated(!!token);
|
||||
if (token) {
|
||||
|
||||
if (token && (isFirstRun || !wasAuthenticated)) {
|
||||
initStore();
|
||||
}
|
||||
isFirstRun = false;
|
||||
}, true);
|
||||
|
||||
onCleanup(() => {
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core'
|
||||
export const CustomTaskItem = Node.create({
|
||||
name: 'taskItem',
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
nested: false,
|
||||
HTMLAttributes: {},
|
||||
}
|
||||
},
|
||||
|
||||
content: 'paragraph block*',
|
||||
|
||||
defining: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
checked: {
|
||||
default: false,
|
||||
keepOnSplit: false,
|
||||
parseHTML: element => element.getAttribute('data-checked') === 'true',
|
||||
renderHTML: attributes => ({
|
||||
'data-checked': attributes.checked,
|
||||
}),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'li[data-type="taskItem"]',
|
||||
priority: 51,
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
return [
|
||||
'li',
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||
'data-type': 'taskItem',
|
||||
}),
|
||||
[
|
||||
'label',
|
||||
[
|
||||
'input',
|
||||
{
|
||||
type: 'checkbox',
|
||||
checked: node.attrs.checked ? 'checked' : null,
|
||||
},
|
||||
],
|
||||
['span', ''],
|
||||
],
|
||||
['div', 0],
|
||||
]
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Enter: () => this.editor.commands.splitListItem(this.name),
|
||||
'Shift-Tab': () => this.editor.commands.liftListItem(this.name),
|
||||
Tab: () => this.editor.commands.sinkListItem(this.name),
|
||||
}
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ({ node, getPos, editor }) => {
|
||||
let currentNode = node;
|
||||
|
||||
const dom = document.createElement('li')
|
||||
dom.classList.add('flex', 'items-center', 'group', 'relative', 'my-1')
|
||||
dom.dataset.type = 'taskItem'
|
||||
if (currentNode.attrs.checked) {
|
||||
dom.dataset.checked = 'true'
|
||||
}
|
||||
|
||||
// Wrapper for the checkbox
|
||||
const handle = document.createElement('label')
|
||||
handle.contentEditable = 'false'
|
||||
handle.classList.add(
|
||||
'flex', 'items-center', 'justify-center',
|
||||
'mr-3', 'select-none', 'relative'
|
||||
)
|
||||
|
||||
const checkbox = document.createElement('div')
|
||||
checkbox.classList.add(
|
||||
'w-5', 'h-5',
|
||||
'flex', 'items-center', 'justify-center',
|
||||
'rounded-md', 'border-[1.5px]',
|
||||
'transition-colors', 'duration-300', 'cursor-pointer',
|
||||
'shrink-0' // removed mt-0.5
|
||||
)
|
||||
|
||||
// The SVG checkmark
|
||||
const checkIcon = document.createElement('div')
|
||||
checkIcon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>'
|
||||
checkIcon.classList.add('transition-opacity', 'duration-300', 'ease-in-out')
|
||||
checkbox.appendChild(checkIcon)
|
||||
|
||||
const updateCheckboxVisuals = (isChecked: boolean) => {
|
||||
if (isChecked) {
|
||||
checkbox.classList.add('bg-primary', 'border-primary', 'text-primary-foreground')
|
||||
checkbox.classList.remove('border-muted-foreground/30', 'bg-transparent', 'text-transparent')
|
||||
checkIcon.style.opacity = '1'
|
||||
} else {
|
||||
checkbox.classList.remove('bg-primary', 'border-primary', 'text-primary-foreground')
|
||||
checkbox.classList.add('border-muted-foreground/30', 'bg-transparent', 'text-transparent')
|
||||
checkIcon.style.opacity = '0'
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize visually
|
||||
updateCheckboxVisuals(currentNode.attrs.checked)
|
||||
|
||||
handle.addEventListener('click', (e) => {
|
||||
// Prevent label default toggling since we handle it manually
|
||||
e.preventDefault();
|
||||
if (typeof getPos === 'function') {
|
||||
editor.commands.command(({ tr }) => {
|
||||
const position = getPos()
|
||||
if (typeof position === 'number') {
|
||||
tr.setNodeMarkup(position, undefined, {
|
||||
checked: !currentNode.attrs.checked,
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
handle.appendChild(checkbox)
|
||||
|
||||
const content = document.createElement('div')
|
||||
content.classList.add('flex-1', 'min-w-0', 'min-h-[1.5rem]')
|
||||
|
||||
// Add a class that can be targeted for the zero-width-space hack
|
||||
content.classList.add('tiptap-task-content')
|
||||
|
||||
dom.appendChild(handle)
|
||||
dom.appendChild(content)
|
||||
|
||||
return {
|
||||
dom,
|
||||
contentDOM: content,
|
||||
update: (updatedNode) => {
|
||||
if (updatedNode.type.name !== this.name) {
|
||||
return false
|
||||
}
|
||||
currentNode = updatedNode;
|
||||
|
||||
updateCheckboxVisuals(updatedNode.attrs.checked)
|
||||
if (updatedNode.attrs.checked) {
|
||||
dom.dataset.checked = 'true'
|
||||
} else {
|
||||
delete dom.dataset.checked
|
||||
}
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type Component, type JSX, createSignal, createEffect, Show, lazy, Suspense, onMount } from "solid-js";
|
||||
import { type Component, type JSX, createSignal, createEffect, Show, lazy, Suspense, onMount, onCleanup } from "solid-js";
|
||||
import { Sidebar, BottomNav, ContextSwitcher } from "./Navigation";
|
||||
import { store, setStore, activeTaskId, setActiveTaskId } from "@/store";
|
||||
import { PanelLeftOpen, PanelLeftClose } from "lucide-solid";
|
||||
@@ -20,6 +20,7 @@ interface LayoutProps {
|
||||
export const Layout: Component<LayoutProps> = (props) => {
|
||||
const [isSidebarLocked, setIsSidebarLocked] = createSignal(true);
|
||||
const [isSidebarPeeking, setIsSidebarPeeking] = createSignal(false);
|
||||
const [isDropdownOpen, setIsDropdownOpen] = createSignal(false);
|
||||
|
||||
// Lift selectedNoteId up to Layout so it can be passed to NotesSidebar and NotepadView
|
||||
const [selectedNoteId, setSelectedNoteId] = createSignal<string | null>(null);
|
||||
@@ -30,6 +31,16 @@ export const Layout: Component<LayoutProps> = (props) => {
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const handleOpenNote = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (detail && detail.noteId) {
|
||||
setStore('isNotepadMode', true);
|
||||
setSelectedNoteId(detail.noteId);
|
||||
}
|
||||
};
|
||||
window.addEventListener('open-note', handleOpenNote);
|
||||
onCleanup(() => window.removeEventListener('open-note', handleOpenNote));
|
||||
|
||||
// Preload heavy components in background when idle
|
||||
const idleCallback = (window as any).requestIdleCallback || ((cb: any) => setTimeout(cb, 2000));
|
||||
idleCallback(() => {
|
||||
@@ -51,10 +62,14 @@ export const Layout: Component<LayoutProps> = (props) => {
|
||||
class={cn(
|
||||
"hidden md:flex flex-col border-r border-border bg-card h-screen fixed left-0 top-0 transition-all duration-200 ease-in-out z-[50] overflow-hidden",
|
||||
isSidebarLocked() ? (store.isNotepadMode ? "w-80 translate-x-0" : "w-48 translate-x-0") : (store.isNotepadMode ? "w-80 -translate-x-full" : "w-48 -translate-x-full"),
|
||||
!isSidebarLocked() && isSidebarPeeking() && "translate-x-0 shadow-2xl ring-1 ring-border"
|
||||
!isSidebarLocked() && (isSidebarPeeking() || (store.isNotepadMode && !selectedNoteId())) && "translate-x-0 shadow-2xl ring-1 ring-border"
|
||||
)}
|
||||
onMouseEnter={() => !isSidebarLocked() && setIsSidebarPeeking(true)}
|
||||
onMouseLeave={() => !isSidebarLocked() && setIsSidebarPeeking(false)}>
|
||||
onMouseLeave={() => {
|
||||
if (!isSidebarLocked() && !isDropdownOpen()) {
|
||||
setIsSidebarPeeking(false);
|
||||
}
|
||||
}}>
|
||||
|
||||
<div class="relative flex-1 flex flex-col min-h-0">
|
||||
{/* Task Sidebar Wrapper */}
|
||||
@@ -72,6 +87,7 @@ export const Layout: Component<LayoutProps> = (props) => {
|
||||
isPeeking={isSidebarPeeking()}
|
||||
setIsPeeking={setIsSidebarPeeking}
|
||||
isEmbed={true}
|
||||
onDropdownOpenChange={setIsDropdownOpen}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -106,7 +122,7 @@ export const Layout: Component<LayoutProps> = (props) => {
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class={cn("flex-1 overflow-hidden transition-all duration-200", (!isSidebarLocked() && !isSidebarPeeking()) ? "opacity-0 pointer-events-none scale-95" : "opacity-100 scale-100")}>
|
||||
<div class={cn("flex-1 overflow-hidden transition-all duration-200", (!isSidebarLocked() && !isSidebarPeeking() && !(store.isNotepadMode && !selectedNoteId())) ? "opacity-0 pointer-events-none scale-95" : "opacity-100 scale-100")}>
|
||||
<NotesSidebar selectedNoteId={selectedNoteId()} setSelectedNoteId={setSelectedNoteId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { type Component, createSignal, For, createEffect } from "solid-js";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface MentionItem {
|
||||
id: string;
|
||||
label: string;
|
||||
sublabel?: string;
|
||||
type: 'user' | 'note';
|
||||
}
|
||||
|
||||
interface MentionListProps {
|
||||
items: MentionItem[];
|
||||
command: (item: MentionItem) => void;
|
||||
ref: (handlers: { onKeyDown: (e: KeyboardEvent) => boolean }) => void;
|
||||
}
|
||||
|
||||
export const MentionList: Component<MentionListProps> = (props) => {
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
||||
|
||||
createEffect(() => {
|
||||
props.items;
|
||||
setSelectedIndex(0);
|
||||
});
|
||||
|
||||
const selectItem = (index: number) => {
|
||||
const item = props.items[index];
|
||||
if (item) {
|
||||
props.command(item);
|
||||
}
|
||||
};
|
||||
|
||||
props.ref({
|
||||
onKeyDown: (e: KeyboardEvent) => {
|
||||
if (e.key === "ArrowUp") {
|
||||
setSelectedIndex((prev) => (prev + props.items.length - 1) % props.items.length);
|
||||
return true;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
setSelectedIndex((prev) => (prev + 1) % props.items.length);
|
||||
return true;
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
selectItem(selectedIndex());
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="z-[200] w-64 bg-popover border border-border rounded-lg shadow-xl overflow-hidden p-1 bg-background pointer-events-auto">
|
||||
<div class="max-h-[300px] overflow-y-auto scrollbar-thin scrollbar-thumb-muted-foreground/20 overscroll-contain">
|
||||
<For each={props.items}>
|
||||
{(item, index) => (
|
||||
<button
|
||||
class={cn(
|
||||
"flex flex-col w-full px-2 py-1.5 text-left rounded-md transition-colors outline-none",
|
||||
index() === selectedIndex() ? "bg-accent text-accent-foreground" : "hover:bg-muted"
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
selectItem(index());
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(index())}
|
||||
>
|
||||
<span class="text-sm font-medium leading-none">{item.label}</span>
|
||||
{item.sublabel && (
|
||||
<span class="text-[0.6875rem] text-muted-foreground truncate mt-1">{item.sublabel}</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
{props.items.length === 0 && (
|
||||
<div class="p-2 text-xs text-muted-foreground text-center">No results found</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -300,6 +300,7 @@ export const Sidebar: Component<{
|
||||
isPeeking: boolean;
|
||||
setIsPeeking: (v: boolean) => void;
|
||||
isEmbed?: boolean;
|
||||
onDropdownOpenChange?: (open: boolean) => void;
|
||||
}> = (props) => {
|
||||
const [switcherOpen, setSwitcherOpen] = createSignal(false);
|
||||
|
||||
@@ -374,7 +375,10 @@ export const Sidebar: Component<{
|
||||
isLocked={props.isLocked}
|
||||
isPeeking={props.isPeeking}
|
||||
setIsPeeking={props.setIsPeeking}
|
||||
onOpenChange={setSwitcherOpen}
|
||||
onOpenChange={(open) => {
|
||||
setSwitcherOpen(open);
|
||||
props.onDropdownOpenChange?.(open);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type Component, createSignal, createEffect, onCleanup, onMount } from "solid-js";
|
||||
import { type Component, createSignal, createMemo, onCleanup, onMount } from "solid-js";
|
||||
import { store, currentTaskContext } from "@/store";
|
||||
import { Plus, X } from "lucide-solid";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -7,37 +7,36 @@ import { QuickEntryForm } from "./QuickEntryForm";
|
||||
|
||||
export const QuickEntry: Component = () => {
|
||||
const [isOpen, setIsOpen] = createSignal(false);
|
||||
const [initialTags, setInitialTags] = createSignal<string[]>([]);
|
||||
|
||||
// Autofill tags based on context when opening
|
||||
createEffect(() => {
|
||||
if (isOpen()) {
|
||||
const tags: string[] = [];
|
||||
const ctx = currentTaskContext();
|
||||
if (store.isNotepadMode) {
|
||||
// Not in a specific context, but in Notes mode.
|
||||
// We use global window variable set by Layout to get the active note id
|
||||
const activeNoteId = (window as any)._activeNoteId;
|
||||
if (activeNoteId) {
|
||||
const note = store.notes.find(n => n.id === activeNoteId);
|
||||
if (note && note.title) {
|
||||
const noteTagName = note.title.trim();
|
||||
if (noteTagName) tags.push(noteTagName);
|
||||
}
|
||||
}
|
||||
} else if (typeof ctx === 'object') {
|
||||
if ('bucketId' in ctx) {
|
||||
// Bucket View: Autofill bucket name
|
||||
const bucketName = ctx.name;
|
||||
if (bucketName) tags.push(bucketName);
|
||||
} else if ('userId' in ctx) {
|
||||
// Shared User View: Autofill user name (assuming name IS the tag for sharing)
|
||||
const userName = ctx.name;
|
||||
if (userName) tags.push(userName);
|
||||
const initialTags = createMemo(() => {
|
||||
if (!isOpen()) return [];
|
||||
|
||||
const tags: string[] = [];
|
||||
const ctx = currentTaskContext();
|
||||
|
||||
if (store.isNotepadMode) {
|
||||
// Not in a specific context, but in Notes mode.
|
||||
// We use global window variable set by Layout to get the active note id
|
||||
const activeNoteId = (window as any)._activeNoteId;
|
||||
if (activeNoteId) {
|
||||
const note = store.notes.find(n => n.id === activeNoteId);
|
||||
if (note && note.title) {
|
||||
const noteTagName = note.title.trim();
|
||||
if (noteTagName) tags.push(noteTagName);
|
||||
}
|
||||
}
|
||||
setInitialTags(tags);
|
||||
} else if (typeof ctx === 'object') {
|
||||
if ('bucketId' in ctx) {
|
||||
// Bucket View: Autofill bucket name
|
||||
const bucketName = ctx.name;
|
||||
if (bucketName) tags.push(`@${bucketName}`);
|
||||
} else if ('userId' in ctx) {
|
||||
// Shared User View: Autofill user name (assuming name IS the tag for sharing)
|
||||
const userName = ctx.name;
|
||||
if (userName) tags.push(`@${userName}`);
|
||||
}
|
||||
}
|
||||
return tags;
|
||||
});
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
|
||||
@@ -43,6 +43,21 @@ export const QuickEntryForm: Component<QuickEntryFormProps> = (props) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Reactive sync from props (handles context changes while open)
|
||||
createEffect(() => {
|
||||
const initial = props.initialTags || [];
|
||||
if (initial.length > 0) {
|
||||
setTags(prev => {
|
||||
// Merge initial tags if not already present
|
||||
const next = [...prev];
|
||||
initial.forEach(t => {
|
||||
if (!next.includes(t)) next.push(t);
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Reactive shorthand parsing
|
||||
createEffect(() => {
|
||||
const text = input();
|
||||
|
||||
@@ -4,7 +4,10 @@ import {
|
||||
List, ListTodo, Type,
|
||||
Code, Quote,
|
||||
Image as ImageIcon, Minus,
|
||||
Bold, Italic, Underline
|
||||
Bold, Italic, Underline,
|
||||
Grid3X3, Highlighter,
|
||||
AlignLeft, AlignCenter, AlignRight,
|
||||
Trash2
|
||||
} from "lucide-solid";
|
||||
import { cn, convertImageToWebpFile } from "@/lib/utils";
|
||||
import { activeTaskId, uploadTaskAttachment } from "@/store";
|
||||
@@ -170,6 +173,104 @@ export const getSuggestionItems = ({ query }: { query: string }): CommandItem[]
|
||||
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Table",
|
||||
description: "Insert a 3x3 table.",
|
||||
aliases: ["grid", "table"],
|
||||
icon: Grid3X3,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Highlight",
|
||||
description: "Highlight selected text.",
|
||||
aliases: ["mark"],
|
||||
icon: Highlighter,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).toggleHighlight().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Align Left",
|
||||
description: "Align text to the left.",
|
||||
icon: AlignLeft,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).setTextAlign("left").run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Align Center",
|
||||
description: "Align text to the center.",
|
||||
icon: AlignCenter,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).setTextAlign("center").run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Align Right",
|
||||
description: "Align text to the right.",
|
||||
icon: AlignRight,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).setTextAlign("right").run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Add Row Above",
|
||||
description: "Insert a new row above the cursor.",
|
||||
icon: Grid3X3,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).addRowBefore().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Add Row Below",
|
||||
description: "Insert a new row below the cursor.",
|
||||
icon: Grid3X3,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).addRowAfter().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Delete Row",
|
||||
description: "Delete the current row.",
|
||||
icon: Trash2,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).deleteRow().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Add Column Left",
|
||||
description: "Insert a new column to the left.",
|
||||
icon: Grid3X3,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).addColumnBefore().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Add Column Right",
|
||||
description: "Insert a new column to the right.",
|
||||
icon: Grid3X3,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).addColumnAfter().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Delete Column",
|
||||
description: "Delete the current column.",
|
||||
icon: Trash2,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).deleteColumn().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Delete Table",
|
||||
description: "Delete the entire table.",
|
||||
icon: Trash2,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).deleteTable().run();
|
||||
},
|
||||
},
|
||||
].filter(item => {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return item.title.toLowerCase().startsWith(lowerQuery) ||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/u
|
||||
import { getStatusOptionsForTask } from "@/lib/constants";
|
||||
import { StatusCircle } from "./StatusCircle";
|
||||
|
||||
export const TaskCard: Component<{ task: Task }> = (props) => {
|
||||
export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props) => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const urgencyLevel = createMemo(() => calculateUrgencyFromDate(props.task.dueDate));
|
||||
|
||||
@@ -58,8 +58,9 @@ export const TaskCard: Component<{ task: Task }> = (props) => {
|
||||
return (
|
||||
<div
|
||||
class={cn(
|
||||
"group relative flex items-center p-3 sm:p-4 bg-card border border-border rounded-2xl transition-all duration-500 hover:duration-100 hover:shadow-xl hover:shadow-primary/5 hover:-translate-y-0.5 cursor-pointer w-full min-w-0 overflow-hidden",
|
||||
props.task.completed && "opacity-60"
|
||||
"group relative flex items-center p-3 sm:p-4 bg-card border border-border rounded-2xl transition-[background-color,border-color,box-shadow,opacity] duration-500 hover:duration-100 hover:shadow-xl hover:shadow-primary/5 hover:-translate-y-0.5 cursor-pointer w-full min-w-0 overflow-hidden",
|
||||
props.task.completed && "opacity-60",
|
||||
props.isShaking && "animate-task-shake bg-accent/20"
|
||||
)}
|
||||
onClick={() => setActiveTaskId(props.task.id)}
|
||||
>
|
||||
|
||||
+106
-44
@@ -48,6 +48,21 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
||||
}
|
||||
});
|
||||
|
||||
const [trackedMentions, setTrackedMentions] = createSignal<Set<string>>(new Set());
|
||||
|
||||
createEffect(() => {
|
||||
if (props.isOpen && props.task.content) {
|
||||
const html = props.task.content;
|
||||
const mentionRegex = /data-type="mention"[^>]*data-id="([^"]+)"/g;
|
||||
let match;
|
||||
const initial = new Set<string>();
|
||||
while ((match = mentionRegex.exec(html)) !== null) {
|
||||
initial.add(match[1]);
|
||||
}
|
||||
setTrackedMentions(initial);
|
||||
}
|
||||
});
|
||||
|
||||
let debounceTimer: number | undefined;
|
||||
let pendingContent: string | null = null;
|
||||
|
||||
@@ -57,6 +72,39 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
||||
debounceTimer = setTimeout(() => {
|
||||
if (pendingContent !== null) {
|
||||
updateTask(props.task.id, { content: pendingContent });
|
||||
|
||||
// Sync mentions deletion
|
||||
const mentionRegex = /data-type="mention"[^>]*data-id="([^"]+)"/g;
|
||||
let match;
|
||||
const currentMentions = new Set<string>();
|
||||
while ((match = mentionRegex.exec(pendingContent)) !== null) {
|
||||
currentMentions.add(match[1].toLowerCase());
|
||||
}
|
||||
|
||||
const currentTags = props.task.tags || [];
|
||||
let tagsChanged = false;
|
||||
const newTags = currentTags.filter(t => {
|
||||
const tagLower = t.toLowerCase();
|
||||
const isTracked = [...trackedMentions()].some(tm => tm.toLowerCase() === tagLower);
|
||||
if (isTracked && !currentMentions.has(tagLower)) {
|
||||
tagsChanged = true;
|
||||
// Also remove from tracked
|
||||
setTrackedMentions(prev => {
|
||||
const next = new Set(prev);
|
||||
for (let p of next) {
|
||||
if (p.toLowerCase() === tagLower) next.delete(p);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return false; // remove from tags
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (tagsChanged) {
|
||||
updateTags(newTags);
|
||||
}
|
||||
|
||||
pendingContent = null;
|
||||
}
|
||||
}, 1000);
|
||||
@@ -353,61 +401,75 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
||||
content={props.task.content}
|
||||
onUpdate={updateTaskContent}
|
||||
onEditorReady={setEditorInstance}
|
||||
onNoteOpen={(noteId) => {
|
||||
props.onClose();
|
||||
window.dispatchEvent(new CustomEvent('open-note', { detail: { noteId } }));
|
||||
}}
|
||||
onUserMention={(name) => {
|
||||
const tagName = '@' + name;
|
||||
setTrackedMentions(prev => new Set([...prev, tagName]));
|
||||
const currentTags = props.task.tags || [];
|
||||
if (!currentTags.some(t => t.toLowerCase() === tagName.toLowerCase())) {
|
||||
updateTags([...currentTags, tagName]);
|
||||
toast.success(`Added tag ${tagName} to share task.`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
{/* Tags at the bottom */}
|
||||
<div class="mt-auto pt-6 border-t border-border/50">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="text-[0.625rem] uppercase font-bold tracking-wider text-muted-foreground/60 shrink-0">Tags</span>
|
||||
<div class="flex flex-wrap items-center gap-1.5 min-w-0 flex-1">
|
||||
<TagPicker
|
||||
selectedTags={visibleTags()}
|
||||
onTagsChange={updateTags}
|
||||
/>
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<For each={visibleTags()}>
|
||||
{(tag) => (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="h-7 px-2 text-xs gap-1.5 bg-muted/30 border-border/50 hover:bg-destructive/10 hover:border-destructive/30 cursor-pointer group/tag transition-all whitespace-nowrap shrink-0"
|
||||
</div>
|
||||
|
||||
{/* Sticky Tags Area */}
|
||||
<div class="px-6 py-3 border-t border-border/50 shrink-0 bg-card/80 backdrop-blur-sm z-10 w-full overflow-hidden">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="text-[0.625rem] uppercase font-bold tracking-wider text-muted-foreground/60 shrink-0">Tags</span>
|
||||
<div class="flex flex-nowrap overflow-x-auto no-scrollbar items-center gap-1.5 min-w-0 flex-1 pb-1">
|
||||
<TagPicker
|
||||
selectedTags={visibleTags()}
|
||||
onTagsChange={updateTags}
|
||||
/>
|
||||
<div class="flex flex-nowrap items-center gap-1.5 shrink-0 pr-2">
|
||||
<For each={visibleTags()}>
|
||||
{(tag) => (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="h-7 px-2 text-xs gap-1.5 bg-muted/30 border-border/50 hover:bg-destructive/10 hover:border-destructive/30 cursor-pointer group/tag transition-all whitespace-nowrap shrink-0"
|
||||
style={{
|
||||
"background-color": (() => {
|
||||
const def = store.tagDefinitions.find(d => d.name === tag);
|
||||
const color = getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme());
|
||||
return color ? `${color}15` : undefined;
|
||||
})(),
|
||||
"border-color": (() => {
|
||||
const def = store.tagDefinitions.find(d => d.name === tag);
|
||||
const color = getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme());
|
||||
return color ? `${color}30` : undefined;
|
||||
})(),
|
||||
"color": (() => {
|
||||
const def = store.tagDefinitions.find(d => d.name === tag);
|
||||
return getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme()) || "inherit";
|
||||
})()
|
||||
}}
|
||||
onClick={() => updateTags(visibleTags().filter(t => t !== tag))}
|
||||
>
|
||||
<div
|
||||
class="w-1.5 h-1.5 rounded-full"
|
||||
style={{
|
||||
"background-color": (() => {
|
||||
const def = store.tagDefinitions.find(d => d.name === tag);
|
||||
const color = getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme());
|
||||
return color ? `${color}15` : undefined;
|
||||
})(),
|
||||
"border-color": (() => {
|
||||
const def = store.tagDefinitions.find(d => d.name === tag);
|
||||
const color = getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme());
|
||||
return color ? `${color}30` : undefined;
|
||||
})(),
|
||||
"color": (() => {
|
||||
const def = store.tagDefinitions.find(d => d.name === tag);
|
||||
return getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme()) || "inherit";
|
||||
})()
|
||||
"background-color": (store.tagDefinitions.find(d => d.name === tag)?.value ?? 5) > 5 ? "#22c55e" : (store.tagDefinitions.find(d => d.name === tag)?.value ?? 5) < 5 ? "#ef4444" : "#94a3b8"
|
||||
}}
|
||||
onClick={() => updateTags(visibleTags().filter(t => t !== tag))}
|
||||
>
|
||||
<div
|
||||
class="w-1.5 h-1.5 rounded-full"
|
||||
style={{
|
||||
"background-color": (store.tagDefinitions.find(d => d.name === tag)?.value ?? 5) > 5 ? "#22c55e" : (store.tagDefinitions.find(d => d.name === tag)?.value ?? 5) < 5 ? "#ef4444" : "#94a3b8"
|
||||
}}
|
||||
/>
|
||||
{tag}
|
||||
<X size={12} class="opacity-0 group-hover/tag:opacity-50 transition-opacity" />
|
||||
</Badge>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
/>
|
||||
{tag}
|
||||
<X size={12} class="opacity-0 group-hover/tag:opacity-50 transition-opacity" />
|
||||
</Badge>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sticky Footer Actions */}
|
||||
<div class="px-6 py-4 border-t border-border/50 flex items-center justify-between shrink-0 bg-background/80 backdrop-blur-sm">
|
||||
<div class="px-6 py-4 border-t border-border/50 flex items-center justify-between shrink-0 bg-background/80 backdrop-blur-sm z-20 w-full relative">
|
||||
{/* Delete button (persistent) */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -3,21 +3,37 @@ import { createTiptapEditor } from "solid-tiptap";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import TaskList from "@tiptap/extension-task-list";
|
||||
import TaskItem from "@tiptap/extension-task-item";
|
||||
import { CustomTaskItem } from "./CustomTaskItem";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import Underline from "@tiptap/extension-underline";
|
||||
import { X } from "lucide-solid";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { store } from "@/store";
|
||||
|
||||
import { SlashCommands } from "@/lib/slash-command";
|
||||
import { suggestion } from "@/lib/slash-renderer";
|
||||
import { MobileIndent } from "@/lib/mobile-indent";
|
||||
|
||||
// New Extensions
|
||||
import { Link } from "@tiptap/extension-link";
|
||||
import { Highlight } from "@tiptap/extension-highlight";
|
||||
import { TextAlign } from "@tiptap/extension-text-align";
|
||||
import { Table } from "@tiptap/extension-table";
|
||||
import { TableRow } from "@tiptap/extension-table-row";
|
||||
import { TableCell } from "@tiptap/extension-table-cell";
|
||||
import { TableHeader } from "@tiptap/extension-table-header";
|
||||
import { Typography } from "@tiptap/extension-typography";
|
||||
import { Dropcursor } from "@tiptap/extension-dropcursor";
|
||||
import { Mention } from "@tiptap/extension-mention";
|
||||
import { userSuggestion, noteSuggestion } from "@/lib/mention-suggestion";
|
||||
|
||||
interface TaskEditorProps {
|
||||
content?: string;
|
||||
onUpdate: (html: string) => void;
|
||||
onEditorReady?: (editor: any) => void;
|
||||
editable?: boolean;
|
||||
onUserMention?: (userName: string) => void;
|
||||
onNoteOpen?: (noteId: string) => void;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
@@ -28,6 +44,7 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
|
||||
const [zoomOffset, setZoomOffset] = createSignal({ x: 0, y: 0 });
|
||||
const [isDragging, setIsDragging] = createSignal(false);
|
||||
const [dragStart, setDragStart] = createSignal({ x: 0, y: 0 });
|
||||
const [previewNote, setPreviewNote] = createSignal<{ noteId: string, x: number, y: number } | null>(null);
|
||||
|
||||
const resetZoom = () => {
|
||||
setZoomScale(1);
|
||||
@@ -57,7 +74,7 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
|
||||
class: "not-prose pl-2",
|
||||
},
|
||||
}),
|
||||
TaskItem.configure({
|
||||
CustomTaskItem.configure({
|
||||
nested: true,
|
||||
HTMLAttributes: {
|
||||
class: "flex gap-2 items-start my-1",
|
||||
@@ -73,6 +90,75 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
|
||||
}),
|
||||
Underline.configure({}),
|
||||
MobileIndent.configure({}),
|
||||
Link.configure({
|
||||
autolink: true,
|
||||
openOnClick: false,
|
||||
HTMLAttributes: {
|
||||
class: "text-primary underline underline-offset-4 cursor-pointer hover:text-primary/80 transition-colors",
|
||||
},
|
||||
}),
|
||||
Highlight.configure({
|
||||
multicolor: true,
|
||||
}),
|
||||
TextAlign.configure({
|
||||
types: ["heading", "paragraph"],
|
||||
}),
|
||||
Table.configure({
|
||||
resizable: true,
|
||||
allowTableNodeSelection: true,
|
||||
}),
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
Typography.configure({
|
||||
openDoubleQuote: false,
|
||||
closeDoubleQuote: false,
|
||||
openSingleQuote: false,
|
||||
closeSingleQuote: false,
|
||||
}),
|
||||
Mention.configure({
|
||||
HTMLAttributes: {
|
||||
class: "mention bg-primary/10 text-primary font-medium py-0.5 px-1 rounded-md border border-primary/20",
|
||||
},
|
||||
suggestion: {
|
||||
...userSuggestion,
|
||||
command: ({ editor, range, props: mentionProps }: any) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(range, [
|
||||
{
|
||||
type: "mention",
|
||||
attrs: mentionProps,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: " ",
|
||||
},
|
||||
])
|
||||
.run();
|
||||
|
||||
if (mentionProps.label) {
|
||||
props.onUserMention?.(mentionProps.label);
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
Mention.extend({
|
||||
name: "noteMention",
|
||||
}).configure({
|
||||
suggestion: {
|
||||
...noteSuggestion,
|
||||
char: "#",
|
||||
},
|
||||
HTMLAttributes: {
|
||||
class: "note-mention bg-muted text-muted-foreground font-medium py-0.5 px-1 rounded-md border border-border italic cursor-pointer hover:bg-muted/80",
|
||||
},
|
||||
}),
|
||||
Dropcursor.configure({
|
||||
color: "var(--primary)",
|
||||
width: 2,
|
||||
}),
|
||||
],
|
||||
// Use untrack so the editor doesn't re-initialize when props.content changes
|
||||
content: untrack(() => props.content) || "",
|
||||
@@ -88,12 +174,21 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
|
||||
"prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1",
|
||||
"prose-pre:bg-muted prose-pre:rounded-lg prose-pre:p-4",
|
||||
"prose-img:rounded-lg prose-img:border prose-img:border-border prose-img:cursor-zoom-in",
|
||||
// New Extension styles
|
||||
"prose-a:text-primary prose-a:underline prose-a:underline-offset-4",
|
||||
"prose-blockquote:border-l-4 prose-blockquote:border-primary prose-blockquote:pl-4 prose-blockquote:italic",
|
||||
"[&_table]:border-collapse [&_table]:w-full [&_table]:my-4",
|
||||
"[&_th]:border [&_th]:border-border [&_th]:bg-muted/50 [&_th]:p-2 [&_th]:text-left [&_th]:font-bold",
|
||||
"[&_td]:border [&_td]:border-border [&_td]:p-2 [&_td]:align-top",
|
||||
"[&_mark]:bg-primary/20 dark:[&_mark]:bg-primary/40 [&_mark]:text-foreground dark:[&_mark]:text-primary-foreground [&_mark]:px-0.5 [&_mark]:rounded-sm",
|
||||
"[&_.mention]:mx-0.5 [&_.note-mention]:mx-0.5",
|
||||
// Custom Task List Styling
|
||||
"[&_ul[data-type='taskList']]:list-none [&_ul[data-type='taskList']]:p-0",
|
||||
"[&_li[data-type='taskItem']]:flex [&_li[data-type='taskItem']]:items-start",
|
||||
"[&_li[data-type='taskItem']>label]:mr-3 [&_li[data-type='taskItem']>label]:mt-1 [&_li[data-type='taskItem']>label]:select-none",
|
||||
"[&_li[data-type='taskItem']>label>input]:w-5 [&_li[data-type='taskItem']>label>input]:h-5 [&_li[data-type='taskItem']>label>input]:cursor-pointer",
|
||||
"[&_li[data-type='taskItem']>div]:flex-1 [&_li[data-type='taskItem']>div]:min-h-[1.5rem]",
|
||||
"[&_.tiptap-task-content]:flex-1 [&_.tiptap-task-content]:min-w-0 [&_.tiptap-task-content]:min-h-[1.5rem]",
|
||||
"[&_.tiptap-task-content>p]:min-h-[1.5rem] [&_.tiptap-task-content>p]:m-0 [&_.tiptap-task-content>p]:w-full",
|
||||
"[&_.tiptap-task-content>p:empty]:after:content-['\\200B'] [&_.tiptap-task-content>p:empty]:after:inline-block",
|
||||
"[&_.tiptap-task-content>p>br]:after:content-['\\200B'] [&_.tiptap-task-content>p>br]:after:inline-block", // if tiptap adds br
|
||||
props.class
|
||||
),
|
||||
},
|
||||
@@ -117,20 +212,105 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Handle note mention clicks
|
||||
createEffect(() => {
|
||||
const e = editor();
|
||||
if (!e) return;
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.classList.contains('note-mention')) {
|
||||
const noteId = target.getAttribute('data-id');
|
||||
if (noteId) {
|
||||
const rect = target.getBoundingClientRect();
|
||||
setPreviewNote({ noteId, x: rect.left, y: rect.bottom + window.scrollY });
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Close preview if clicking elsewhere
|
||||
setPreviewNote(null);
|
||||
};
|
||||
|
||||
const element = e.view.dom;
|
||||
if (element) {
|
||||
element.addEventListener('click', handleClick);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle full screen images as before
|
||||
return (
|
||||
<>
|
||||
<div class="relative w-full flex flex-col flex-1">
|
||||
<div
|
||||
class="absolute -top-6 -left-4 -right-4 h-8 cursor-pointer group flex items-center justify-center z-10"
|
||||
onClick={() => {
|
||||
const e = editor();
|
||||
if (!e) return;
|
||||
e.chain().insertContentAt(0, '<p></p>').focus('start').run();
|
||||
}}
|
||||
title="Add empty line at the top"
|
||||
>
|
||||
<span class="opacity-0 group-hover:opacity-100 text-[10px] uppercase font-bold text-muted-foreground tracking-widest transition-opacity mt-1">
|
||||
Click to insert newline above
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
ref={editorRef}
|
||||
class="w-full cursor-text"
|
||||
class="w-full flex-1 cursor-text"
|
||||
onClick={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName.toLowerCase() === 'img') {
|
||||
setFullscreenImage((target as HTMLImageElement).src);
|
||||
} else {
|
||||
} else if (e.target === editorRef) {
|
||||
// Focus end if clicking on padding below content
|
||||
editor()?.chain().focus().run();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Note Preview Popover */}
|
||||
<Show when={previewNote() && store.notes.find(n => n.id === previewNote()!.noteId)}>
|
||||
{(note) => {
|
||||
const pos = previewNote()!;
|
||||
return (
|
||||
<div
|
||||
class="fixed z-[250] max-w-[280px] w-full bg-popover border border-border shadow-xl rounded-lg p-3 animate-in fade-in zoom-in-95 duration-200 pointer-events-auto"
|
||||
style={{ left: `${Math.min(pos.x, window.innerWidth - 300)}px`, top: `${pos.y + 4}px` }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h4 class="font-bold text-sm mb-1 truncate text-foreground">{note().title}</h4>
|
||||
<p class="text-xs text-muted-foreground line-clamp-3 mb-3 break-words whitespace-pre-wrap">
|
||||
{note().content?.replace(/<[^>]*>?/gm, '') || "Empty note"}
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs px-2.5 py-1.5 rounded bg-muted hover:bg-muted/80 text-foreground font-medium transition-colors"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setPreviewNote(null);
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs px-2.5 py-1.5 rounded bg-primary hover:bg-primary/90 text-primary-foreground font-medium transition-colors shadow-sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (props.onNoteOpen) {
|
||||
props.onNoteOpen(note().id);
|
||||
}
|
||||
setPreviewNote(null);
|
||||
}}
|
||||
>
|
||||
Open Note
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Show>
|
||||
|
||||
<Show when={fullscreenImage()}>
|
||||
<div
|
||||
class="fixed inset-0 z-[9999] p-4 bg-background/90 backdrop-blur-sm flex items-center justify-center animate-in fade-in duration-200 overflow-hidden"
|
||||
@@ -205,6 +385,6 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ const Toaster = (props: ToasterProps) => {
|
||||
theme={theme() as ToasterProps["theme"]}
|
||||
class="toaster group"
|
||||
position="top-center"
|
||||
closeButton
|
||||
toastOptions={{
|
||||
classes: {
|
||||
toast:
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createSignal, createEffect, onCleanup, untrack } from "solid-js";
|
||||
import type { Task } from "@/store";
|
||||
|
||||
export function useDelayedSort(
|
||||
sourceTasks: () => Task[],
|
||||
sortFn: (a: Task, b: Task) => number,
|
||||
delayMs = 300
|
||||
) {
|
||||
const initialTasks = untrack(() => [...sourceTasks()].sort(sortFn));
|
||||
const [displayedTasks, setDisplayedTasks] = createSignal<Task[]>(initialTasks);
|
||||
const [shakingTaskIds, setShakingTaskIds] = createSignal<string[]>([]);
|
||||
|
||||
let timer: number | undefined;
|
||||
|
||||
createEffect(() => {
|
||||
const sorted = [...sourceTasks()].sort(sortFn);
|
||||
const currentDisplayed = untrack(() => displayedTasks());
|
||||
|
||||
// Initial load (though now handled by initial state, kept for safety if it resets)
|
||||
if (currentDisplayed.length === 0 && sorted.length > 0) {
|
||||
setDisplayedTasks(sorted);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if anything fundamentally changed order
|
||||
const newIds = sorted.map(t => t.id).join(',');
|
||||
const oldIds = currentDisplayed.map(t => t.id).join(',');
|
||||
|
||||
if (newIds !== oldIds) {
|
||||
// Find which IDs have moved or changed their presence
|
||||
// To be safe, let's just shake all currently visible ones, or maybe just the ones moving.
|
||||
// A simple "shake everything" works perfectly for a brief warning: "Items are shifting!"
|
||||
// The user wanted "tasks an animation when they rearange where they shake"
|
||||
const changedSet = new Set<string>();
|
||||
sorted.forEach((t: Task, i: number) => {
|
||||
if (currentDisplayed[i]?.id !== t.id) {
|
||||
changedSet.add(t.id);
|
||||
if (currentDisplayed[i]) changedSet.add(currentDisplayed[i].id);
|
||||
}
|
||||
});
|
||||
|
||||
if (changedSet.size > 0) {
|
||||
setShakingTaskIds(Array.from(changedSet));
|
||||
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = window.setTimeout(() => {
|
||||
setShakingTaskIds([]);
|
||||
setDisplayedTasks(sorted);
|
||||
}, delayMs);
|
||||
} else {
|
||||
setDisplayedTasks(sorted);
|
||||
}
|
||||
} else {
|
||||
// Identical order, just data updates (e.g. title changes)
|
||||
// We want these to pass through instantly without rearranging
|
||||
setDisplayedTasks(sorted);
|
||||
}
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
if (timer) clearTimeout(timer);
|
||||
});
|
||||
|
||||
return { displayedTasks, shakingTaskIds };
|
||||
}
|
||||
@@ -55,6 +55,40 @@
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
--animate-task-shake: task-shake 0.3s ease-in-out;
|
||||
|
||||
@keyframes task-shake {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
25% {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateX(-3px);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
}
|
||||
|
||||
--animate-task-enter: task-enter 0.25s ease-out backwards;
|
||||
|
||||
@keyframes task-enter {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { store } from "@/store";
|
||||
import { render } from "solid-js/web";
|
||||
import tippy, { type Instance as TippyInstance } from "tippy.js";
|
||||
import { MentionList } from "@/components/MentionList";
|
||||
|
||||
export const userSuggestion = {
|
||||
items: ({ query }: { query: string }) => {
|
||||
return store.tagDefinitions
|
||||
.filter(tag => tag.isUser && tag.name.toLowerCase().substring(1).startsWith(query.toLowerCase()))
|
||||
.map(tag => ({
|
||||
id: tag.name, // Use the tag name as ID for sharing logic
|
||||
label: tag.name.substring(1),
|
||||
type: 'user' as const
|
||||
}));
|
||||
},
|
||||
|
||||
render: () => {
|
||||
let componentDispose: (() => void) | undefined;
|
||||
let popup: TippyInstance | undefined;
|
||||
let container: HTMLDivElement | undefined;
|
||||
let componentHandlers: { onKeyDown: (e: KeyboardEvent) => boolean } | undefined;
|
||||
|
||||
return {
|
||||
onStart: (props: any) => {
|
||||
container = document.createElement("div");
|
||||
|
||||
componentDispose = render(
|
||||
() => <MentionList
|
||||
items={props.items}
|
||||
command={props.command}
|
||||
ref={(h) => componentHandlers = h}
|
||||
/>,
|
||||
container
|
||||
);
|
||||
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => props.editor.options.element || document.body,
|
||||
content: container,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
})[0];
|
||||
},
|
||||
|
||||
onUpdate(props: any) {
|
||||
if (componentDispose) componentDispose();
|
||||
|
||||
componentDispose = render(
|
||||
() => <MentionList
|
||||
items={props.items}
|
||||
command={props.command}
|
||||
ref={(h) => componentHandlers = h}
|
||||
/>,
|
||||
container!
|
||||
);
|
||||
|
||||
if (props.clientRect) {
|
||||
popup?.setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
onKeyDown(props: any) {
|
||||
if (props.event.key === "Escape") {
|
||||
popup?.hide();
|
||||
return true;
|
||||
}
|
||||
return componentHandlers?.onKeyDown(props.event) ?? false;
|
||||
},
|
||||
|
||||
onExit() {
|
||||
if (popup && !popup.state.isDestroyed) {
|
||||
popup.destroy();
|
||||
}
|
||||
popup = undefined;
|
||||
if (componentDispose) {
|
||||
componentDispose();
|
||||
componentDispose = undefined;
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const noteSuggestion = {
|
||||
char: '#',
|
||||
items: ({ query }: { query: string }) => {
|
||||
return store.notes
|
||||
.filter(note => note.title.toLowerCase().includes(query.toLowerCase()))
|
||||
.map(note => ({
|
||||
id: note.id,
|
||||
label: note.title,
|
||||
sublabel: note.content?.substring(0, 50).replace(/<[^>]*>?/gm, ''),
|
||||
type: 'note' as const
|
||||
}));
|
||||
},
|
||||
|
||||
render: userSuggestion.render, // Share the same renderer
|
||||
};
|
||||
+92
-31
@@ -30,7 +30,7 @@ export interface Task {
|
||||
tags: string[];
|
||||
ownerId: string | null; // Add ownerId to interface
|
||||
content?: string; // HTML content from Tiptap
|
||||
deletedAt?: number; // Timestamp of soft delete
|
||||
deletedAt?: number | null; // Timestamp of soft delete or null if restored
|
||||
created: string; // ISO string from PB
|
||||
updated: string; // ISO string from PB
|
||||
recurrence?: {
|
||||
@@ -202,6 +202,7 @@ interface TaskStore {
|
||||
subscribedBuckets: string[]; // IDs of buckets I'm subscribed to
|
||||
notes: Note[];
|
||||
isNotepadMode: boolean;
|
||||
quickloadTasks: string[];
|
||||
}
|
||||
|
||||
// Initial empty state
|
||||
@@ -227,7 +228,8 @@ export const [store, setStore] = createStore<TaskStore>({
|
||||
buckets: [],
|
||||
subscribedBuckets: [],
|
||||
notes: [],
|
||||
isNotepadMode: false
|
||||
isNotepadMode: false,
|
||||
quickloadTasks: []
|
||||
});
|
||||
|
||||
export const matchesFilter = (task: Task) => {
|
||||
@@ -336,6 +338,38 @@ createRoot(() => {
|
||||
localStorage.setItem(key, JSON.stringify(store));
|
||||
}
|
||||
});
|
||||
|
||||
let debouncedSync: number | undefined;
|
||||
createEffect(() => {
|
||||
if (store.isInitializing || !pb.authStore.model?.id) return;
|
||||
|
||||
// Calculate Top 20 Tasks
|
||||
const incompleteTasks = store.tasks.filter(t =>
|
||||
!t.completed &&
|
||||
t.ownerId === pb.authStore.model?.id &&
|
||||
!t.tags?.includes("__template__") &&
|
||||
!t.deletedAt
|
||||
);
|
||||
|
||||
const scoredTasks = incompleteTasks.map(t => ({ id: t.id, score: getCombinedScore(t) }));
|
||||
scoredTasks.sort((a, b) => b.score - a.score);
|
||||
|
||||
const top20Ids = scoredTasks.slice(0, 20).map(t => t.id);
|
||||
|
||||
// Compare with current
|
||||
const currentIds = store.quickloadTasks || [];
|
||||
const isDifferent = top20Ids.length !== currentIds.length || top20Ids.some((id, i) => id !== currentIds[i]);
|
||||
|
||||
if (isDifferent) {
|
||||
setStore("quickloadTasks", top20Ids);
|
||||
|
||||
// Debounce save by 10s
|
||||
if (debouncedSync) window.clearTimeout(debouncedSync);
|
||||
debouncedSync = window.setTimeout(() => {
|
||||
syncPreferences();
|
||||
}, 10000);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -- Calibration --
|
||||
@@ -762,6 +796,45 @@ export const initStore = async () => {
|
||||
if (!pb.authStore.isValid || store.isInitializing) return;
|
||||
setStore("isInitializing", true);
|
||||
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
|
||||
// STAGE 1: Instant Focus from Local Auth Model (Top 20 + Fallback)
|
||||
// Goal: < 100ms First Paint. Doesn't wait for any other network requests.
|
||||
if (currentUserId) {
|
||||
try {
|
||||
const prefs = pb.authStore.model?.Taskgrid_pref || {};
|
||||
const cachedQuickload = prefs.quickloadTasks || [];
|
||||
|
||||
let focusRecords;
|
||||
if (cachedQuickload.length > 0) {
|
||||
const idFilter = cachedQuickload.map((id: string) => `id = "${id}"`).join(' || ');
|
||||
const res = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: `(${idFilter}) && deletedAt = ""`,
|
||||
requestKey: null
|
||||
});
|
||||
|
||||
// Sort manually to match quickload order
|
||||
res.sort((a, b) => cachedQuickload.indexOf(a.id) - cachedQuickload.indexOf(b.id));
|
||||
focusRecords = { items: res };
|
||||
} else {
|
||||
focusRecords = await pb.collection(TASGRID_COLLECTION).getList(1, 30, {
|
||||
filter: `user = "${currentUserId}" && completed = false && deletedAt = ""`,
|
||||
sort: '-priority,dueDate',
|
||||
requestKey: null
|
||||
});
|
||||
}
|
||||
|
||||
// Filter out templates client-side to ensure tasks with empty/null tags aren't excluded by DB
|
||||
const focusTasks = focusRecords.items
|
||||
.map(mapRecordToTask)
|
||||
.filter(t => !t.tags?.includes("__template__"));
|
||||
|
||||
setStore("tasks", focusTasks);
|
||||
} catch (focusErr) {
|
||||
console.warn("Stage 1 load failed:", focusErr);
|
||||
}
|
||||
}
|
||||
|
||||
// 0. Synchronous hydration from localStorage for "instant" first paint
|
||||
const key = getStorageKey();
|
||||
if (key) {
|
||||
@@ -795,7 +868,8 @@ export const initStore = async () => {
|
||||
uWeight: prefs.uWeight || 1.0,
|
||||
matrixScaleDays: prefs.matrixScaleDays || 30,
|
||||
prefId: userId,
|
||||
subscribedBuckets: user.subscribedBuckets || []
|
||||
subscribedBuckets: user.subscribedBuckets || [],
|
||||
quickloadTasks: prefs.quickloadTasks || []
|
||||
});
|
||||
}
|
||||
} catch (prefErr) {
|
||||
@@ -859,24 +933,6 @@ export const initStore = async () => {
|
||||
|
||||
// --- WINDOWED SYNC STRATEGY ---
|
||||
|
||||
// STAGE 1: Instant Focus (Top 30 Incomplete - User Only)
|
||||
// Goal: < 100ms First Paint. Uses 'idx_user_focused'.
|
||||
try {
|
||||
const focusRecords = await pb.collection(TASGRID_COLLECTION).getList(1, 30, {
|
||||
filter: `user = "${currentUserId}" && completed = false && deletedAt = ""`,
|
||||
sort: '-priority,dueDate',
|
||||
requestKey: null
|
||||
});
|
||||
|
||||
// Filter out templates client-side to ensure tasks with empty/null tags aren't excluded by DB
|
||||
const focusTasks = focusRecords.items
|
||||
.map(mapRecordToTask)
|
||||
.filter(t => !t.tags?.includes("__template__"));
|
||||
|
||||
setStore("tasks", focusTasks);
|
||||
} catch (focusErr) {
|
||||
console.warn("Stage 1 load failed:", focusErr);
|
||||
}
|
||||
|
||||
// STAGE 2 & 3: Background Sync
|
||||
const backgroundSync = async () => {
|
||||
@@ -979,7 +1035,14 @@ export const initStore = async () => {
|
||||
const taskMap = new Map(currentTasks.map(t => [t.id, t]));
|
||||
allIncomplete.forEach((r: any) => {
|
||||
if (r.tags?.includes("__template__")) return;
|
||||
|
||||
const existing = taskMap.get(r.id);
|
||||
|
||||
// Prevent DOM rebuilding/flickering for tasks that haven't changed
|
||||
if (existing && existing.updated === r.updated) {
|
||||
return; // Keeps the exact same reference in the map
|
||||
}
|
||||
|
||||
// If existing has content, keep it. Otherwise use new record (which likely has no content yet)
|
||||
const newTask = mapRecordToTask(r);
|
||||
if (existing && existing.content) {
|
||||
@@ -1387,16 +1450,13 @@ export const updateTask = async (id: string, updates: Partial<Task>) => {
|
||||
delete pbUpdates.ownerId;
|
||||
}
|
||||
|
||||
if (finalUpdates.deletedAt !== undefined) {
|
||||
// PB expects a date string or null for date fields usually,
|
||||
// but if we defined deletedAt as a 'date' field in PB.
|
||||
// If we defined it as a number in PB? User didn't specify.
|
||||
// Assuming it's a 'Date' field in PB based on previous conversation
|
||||
// "deletedAt | Date | No"
|
||||
if ("deletedAt" in finalUpdates) {
|
||||
// PB expects a date string or null for date fields.
|
||||
// Using finalUpdates.deletedAt check to distinguish between soft-delete (timestamp) and restore (null/falsy)
|
||||
if (finalUpdates.deletedAt) {
|
||||
pbUpdates.deletedAt = new Date(finalUpdates.deletedAt).toISOString();
|
||||
} else {
|
||||
pbUpdates.deletedAt = null; // restore
|
||||
pbUpdates.deletedAt = null; // restore task
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1478,8 +1538,8 @@ export const removeTask = (id: string) => {
|
||||
|
||||
export const restoreTask = (id: string) => {
|
||||
// updateTask handles both optimistic update and PB sync
|
||||
// Pass undefined for deletedAt, updateTask will convert to null for PB
|
||||
updateTask(id, { deletedAt: undefined });
|
||||
// Pass null for deletedAt to explicitly clear it in PB
|
||||
updateTask(id, { deletedAt: null });
|
||||
};
|
||||
|
||||
export const deleteTaskPermanently = async (id: string) => {
|
||||
@@ -1956,7 +2016,8 @@ export const syncPreferences = async () => {
|
||||
pWeight: store.pWeight,
|
||||
uWeight: store.uWeight,
|
||||
matrixScaleDays: store.matrixScaleDays,
|
||||
tagDefinitions: store.tagDefinitions
|
||||
tagDefinitions: store.tagDefinitions,
|
||||
quickloadTasks: store.quickloadTasks
|
||||
};
|
||||
|
||||
await pb.collection('users').update(userId, {
|
||||
|
||||
+25
-12
@@ -1,15 +1,24 @@
|
||||
import { type Component, For, createMemo } from "solid-js";
|
||||
import { type Component, For, createMemo, onMount } from "solid-js";
|
||||
import { store, getCombinedScore, matchesFilter } from "@/store";
|
||||
import { TaskCard } from "@/components/TaskCard";
|
||||
import { useDelayedSort } from "@/hooks/useDelayedSort";
|
||||
import autoAnimate from "@formkit/auto-animate";
|
||||
|
||||
export const CriticalView: Component = () => {
|
||||
const sortedTasks = createMemo(() => {
|
||||
return [...store.tasks]
|
||||
.filter(t => !t.deletedAt && matchesFilter(t))
|
||||
.sort((a, b) => {
|
||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||
return getCombinedScore(b) - getCombinedScore(a);
|
||||
});
|
||||
const sourceTasks = createMemo(() => store.tasks.filter(t => !t.deletedAt && matchesFilter(t)));
|
||||
|
||||
const { displayedTasks, shakingTaskIds } = useDelayedSort(
|
||||
sourceTasks,
|
||||
(a, b) => {
|
||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||
return getCombinedScore(b) - getCombinedScore(a);
|
||||
},
|
||||
300
|
||||
);
|
||||
|
||||
let listRef: HTMLDivElement | undefined;
|
||||
onMount(() => {
|
||||
if (listRef) autoAnimate(listRef, { duration: 300, easing: 'ease-out' });
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -19,13 +28,17 @@ export const CriticalView: Component = () => {
|
||||
<p class="text-muted-foreground mt-1 text-lg">Your tasks ranked by highest urgency + priority.</p>
|
||||
</header>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<For each={sortedTasks()}>
|
||||
{(task) => <TaskCard task={task} />}
|
||||
<div class="grid gap-3" ref={listRef}>
|
||||
<For each={displayedTasks()}>
|
||||
{(task) => (
|
||||
<div class="animate-task-enter">
|
||||
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{sortedTasks().length === 0 && (
|
||||
{displayedTasks().length === 0 && (
|
||||
<div class="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div class="w-16 h-16 bg-muted rounded-full flex items-center justify-center mb-4">
|
||||
<span class="text-2xl">✨</span>
|
||||
|
||||
+30
-17
@@ -1,20 +1,29 @@
|
||||
import { type Component, For, createMemo } from "solid-js";
|
||||
import { type Component, For, createMemo, onMount } from "solid-js";
|
||||
import { store, getCombinedScore, matchesFilter } from "@/store";
|
||||
import { TaskCard } from "@/components/TaskCard";
|
||||
import { useDelayedSort } from "@/hooks/useDelayedSort";
|
||||
import autoAnimate from "@formkit/auto-animate";
|
||||
|
||||
export const DigInView: Component = () => {
|
||||
const sortedTasks = createMemo(() => {
|
||||
return [...store.tasks]
|
||||
.filter(t => !t.deletedAt && matchesFilter(t))
|
||||
.sort((a, b) => {
|
||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||
// Primary: Size descending (largest first)
|
||||
const sizeA = a.size ?? 5;
|
||||
const sizeB = b.size ?? 5;
|
||||
if (sizeA !== sizeB) return sizeB - sizeA;
|
||||
// Secondary: Combined score descending (higher focus first)
|
||||
return getCombinedScore(b) - getCombinedScore(a);
|
||||
});
|
||||
const sourceTasks = createMemo(() => store.tasks.filter(t => !t.deletedAt && matchesFilter(t)));
|
||||
|
||||
const { displayedTasks, shakingTaskIds } = useDelayedSort(
|
||||
sourceTasks,
|
||||
(a, b) => {
|
||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||
// Primary: Size descending (largest first)
|
||||
const sizeA = a.size ?? 5;
|
||||
const sizeB = b.size ?? 5;
|
||||
if (sizeA !== sizeB) return sizeB - sizeA;
|
||||
// Secondary: Combined score descending (higher focus first)
|
||||
return getCombinedScore(b) - getCombinedScore(a);
|
||||
},
|
||||
300
|
||||
);
|
||||
|
||||
let listRef: HTMLDivElement | undefined;
|
||||
onMount(() => {
|
||||
if (listRef) autoAnimate(listRef, { duration: 300, easing: 'ease-out' });
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -24,13 +33,17 @@ export const DigInView: Component = () => {
|
||||
<p class="text-muted-foreground mt-1 text-lg">Largest tasks first. Tackle big projects head-on.</p>
|
||||
</header>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<For each={sortedTasks()}>
|
||||
{(task) => <TaskCard task={task} />}
|
||||
<div class="grid gap-3" ref={listRef}>
|
||||
<For each={displayedTasks()}>
|
||||
{(task) => (
|
||||
<div class="animate-task-enter">
|
||||
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{sortedTasks().length === 0 && (
|
||||
{displayedTasks().length === 0 && (
|
||||
<div class="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div class="w-16 h-16 bg-muted rounded-full flex items-center justify-center mb-4">
|
||||
<span class="text-2xl">⛏️</span>
|
||||
|
||||
+123
-69
@@ -2,7 +2,7 @@ import { type Component, createSignal, createMemo, For, Show } from "solid-js";
|
||||
import { store, renameTagDefinition } from "@/store";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { type Note } from "@/store";
|
||||
import { Trash2, Lock, Unlock, Search, Link, X } from "lucide-solid";
|
||||
import { Trash2, Lock, Unlock, Search, Link, X, ChevronRight } from "lucide-solid";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TaskEditor } from "@/components/TaskEditor";
|
||||
@@ -17,6 +17,7 @@ export const NotepadView: Component<{
|
||||
}> = (props) => {
|
||||
const [isLinking, setIsLinking] = createSignal(false);
|
||||
const [linkSearchQuery, setLinkSearchQuery] = createSignal("");
|
||||
const [isLinkedTasksOpen, setIsLinkedTasksOpen] = createSignal(true);
|
||||
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
|
||||
@@ -150,11 +151,19 @@ export const NotepadView: Component<{
|
||||
!props.selectedNoteId ? "opacity-0 pointer-events-none md:pointer-events-auto md:opacity-100 translate-x-8 md:translate-x-0 md:flex items-center justify-center bg-muted/5" : "opacity-100 translate-x-0 flex flex-col md:flex-row"
|
||||
)}>
|
||||
<Show when={activeNote()} fallback={
|
||||
<div class="text-center space-y-4 opacity-40 select-none">
|
||||
<div class="text-center space-y-6 opacity-40 select-none flex flex-col items-center justify-center h-full">
|
||||
<div class="h-16 w-16 rounded-3xl bg-muted flex items-center justify-center mb-2 animate-pulse">
|
||||
<Search size={32} class="text-muted-foreground/50" />
|
||||
</div>
|
||||
<Show when={props.selectedNoteId && store.notes.length === 0} fallback={
|
||||
<p class="text-sm font-medium tracking-wide">Select a note or create a new one</p>
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-bold tracking-widest uppercase">No Note Selected</p>
|
||||
<p class="text-[0.8rem] text-muted-foreground max-w-[200px] leading-relaxed">
|
||||
Select an existing note from the sidebar or create a new one to get started.
|
||||
</p>
|
||||
</div>
|
||||
}>
|
||||
<p class="text-sm font-medium tracking-wide">Loading note...</p>
|
||||
<p class="text-sm font-medium tracking-wide italic">Loading note...</p>
|
||||
</Show>
|
||||
</div>
|
||||
}>
|
||||
@@ -165,15 +174,7 @@ export const NotepadView: Component<{
|
||||
return (
|
||||
<>
|
||||
{/* Editor Area */}
|
||||
<div class="flex-none md:flex-1 flex flex-col min-w-0 md:h-full overflow-visible md:overflow-y-auto relative p-4 md:p-6 space-y-4 md:space-y-6 scroll-smooth">
|
||||
{/* Mobile Back Button */}
|
||||
<Show when={!props.hideNavigation}>
|
||||
<div class="md:hidden mt-2 md:mt-0 mb-2 md:mb-0">
|
||||
<Button variant="ghost" size="sm" class="h-8 px-2 text-xs" onClick={() => props.setSelectedNoteId(null)}>
|
||||
← Back to Notes
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex-1 flex flex-col min-w-0 md:h-full overflow-visible md:overflow-y-auto relative p-4 md:p-6 space-y-4 md:space-y-6 scroll-smooth">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<input
|
||||
@@ -216,7 +217,7 @@ export const NotepadView: Component<{
|
||||
</div>
|
||||
|
||||
{/* Editor Instance */}
|
||||
<div class="flex-1 min-h-[300px] border border-border/50 rounded-xl p-4 bg-background shadow-sm">
|
||||
<div class="flex-1 min-h-[300px] border border-border/50 rounded-xl p-4 bg-background shadow-sm mb-4">
|
||||
<TaskEditor
|
||||
content={note().content}
|
||||
onUpdate={(html) => handleUpdateContent(note().id, html)}
|
||||
@@ -226,36 +227,74 @@ export const NotepadView: Component<{
|
||||
</div>
|
||||
|
||||
{/* Linked Tasks Sidebar */}
|
||||
<div class="w-full md:w-80 lg:w-96 border-t md:border-t-0 md:border-l border-border bg-muted/5 flex flex-col h-auto md:h-full shrink-0">
|
||||
<div class="p-4 border-b border-border bg-card/50 flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground flex items-center gap-2">
|
||||
<Link size={12} />
|
||||
Linked Tasks
|
||||
</h3>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
class="h-7 w-7 rounded-lg text-muted-foreground hover:text-primary hover:bg-primary/10"
|
||||
onClick={() => setIsLinking(!isLinking())}
|
||||
title="Link Existing Task"
|
||||
>
|
||||
<Link size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="h-7 px-2 text-[0.625rem] font-bold uppercase tracking-wider text-primary hover:bg-primary/10 rounded-lg"
|
||||
onClick={openQuickEntry}
|
||||
title="Create Task via Quick Entry"
|
||||
>
|
||||
+ Task
|
||||
</Button>
|
||||
<div class={cn(
|
||||
"border-t md:border-t-0 md:border-l border-border bg-muted/5 flex flex-col shrink-0 transition-all duration-300 ease-in-out overflow-hidden",
|
||||
isLinkedTasksOpen() ? "w-full md:w-80 lg:w-96 h-auto md:h-full" : "w-full md:w-12 h-12 md:h-full cursor-pointer hover:bg-muted/10"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!isLinkedTasksOpen()) {
|
||||
setIsLinkedTasksOpen(true);
|
||||
}
|
||||
}}>
|
||||
<div class={cn("p-4 border-b border-border bg-card/50 flex flex-col gap-3", !isLinkedTasksOpen() && "items-center justify-center p-0 h-full md:py-4")}>
|
||||
<div
|
||||
class="flex items-center justify-between cursor-pointer group"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsLinkedTasksOpen(!isLinkedTasksOpen());
|
||||
}}
|
||||
>
|
||||
<div class={cn("flex items-center gap-2", !isLinkedTasksOpen() && "md:flex-col")}>
|
||||
<ChevronRight size={14} class={cn("text-muted-foreground transition-all duration-300 group-hover:text-foreground", !isLinkedTasksOpen() ? "rotate-90 md:rotate-0" : "rotate-90 md:rotate-180")} />
|
||||
<Show when={isLinkedTasksOpen()} fallback={
|
||||
<div class="hidden md:flex items-center justify-center -rotate-90 origin-center whitespace-nowrap mt-8 text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground/50">
|
||||
<Link size={10} class="mr-2 rotate-90" />
|
||||
Linked Tasks
|
||||
</div>
|
||||
}>
|
||||
<h3 class="text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground flex items-center gap-2">
|
||||
<Link size={12} />
|
||||
Linked Tasks
|
||||
</h3>
|
||||
</Show>
|
||||
<Show when={!isLinkedTasksOpen()}>
|
||||
<div class="md:hidden flex items-center justify-center text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
<Link size={12} class="mr-2" />
|
||||
Linked Tasks
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={isLinkedTasksOpen()}>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
class="h-7 w-7 rounded-lg text-muted-foreground hover:text-primary hover:bg-primary/10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsLinking(!isLinking());
|
||||
}}
|
||||
title="Link Existing Task"
|
||||
>
|
||||
<Link size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="h-7 px-2 text-[0.625rem] font-bold uppercase tracking-wider text-primary hover:bg-primary/10 rounded-lg"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openQuickEntry();
|
||||
}}
|
||||
title="Create Task via Quick Entry"
|
||||
>
|
||||
+ Task
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={isLinking()}>
|
||||
<Show when={isLinkedTasksOpen() && isLinking()}>
|
||||
<div class="animate-in fade-in slide-in-from-top-2 space-y-2">
|
||||
<div class="relative">
|
||||
<Search size={14} class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50" />
|
||||
@@ -284,39 +323,54 @@ export const NotepadView: Component<{
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
<For each={linkedTasks()} fallback={
|
||||
<div class="text-center text-xs text-muted-foreground p-6 bg-muted/10 border border-dashed border-border/50 rounded-xl flex flex-col items-center gap-2">
|
||||
<Link size={24} class="opacity-20" />
|
||||
<p>No tasks linked yet.</p>
|
||||
<p class="text-[0.6rem] opacity-70 max-w-[200px]">Link an existing task, create a new one, or add the tag #{note().title} to a task.</p>
|
||||
</div>
|
||||
}>
|
||||
{(task) => (
|
||||
<div class="relative group">
|
||||
<TaskCard task={task} />
|
||||
{/* If explicitly linked, allow unlinking. If linked by tag, they should remove the tag from the task, which we can't easily do here unless we implement it. */}
|
||||
<Show when={note().tasks && note().tasks.includes(task.id)}>
|
||||
<button
|
||||
class="absolute -top-2 -right-2 bg-destructive text-destructive-foreground rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity shadow-sm hover:scale-110"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleUnlinkTask(task.id);
|
||||
}}
|
||||
title="Unlink Task"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={isLinkedTasksOpen()}>
|
||||
<div class="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
<For each={linkedTasks()} fallback={
|
||||
<div class="text-center text-xs text-muted-foreground p-6 bg-muted/10 border border-dashed border-border/50 rounded-xl flex flex-col items-center gap-2">
|
||||
<Link size={24} class="opacity-20" />
|
||||
<p>No tasks linked yet.</p>
|
||||
<p class="text-[0.6rem] opacity-70 max-w-[200px]">Link an existing task, create a new one, or add the tag #{note().title} to a task.</p>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
}>
|
||||
{(task) => (
|
||||
<div class="relative group">
|
||||
<TaskCard task={task} />
|
||||
{/* If explicitly linked, allow unlinking. If linked by tag, they should remove the tag from the task, which we can't easily do here unless we implement it. */}
|
||||
<Show when={note().tasks && note().tasks.includes(task.id)}>
|
||||
<button
|
||||
class="absolute -top-2 -right-2 bg-destructive text-destructive-foreground rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity shadow-sm hover:scale-110"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleUnlinkTask(task.id);
|
||||
}}
|
||||
title="Unlink Task"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={!props.hideNavigation}>
|
||||
{/* Sticky Full-Width Mobile Close Footer */}
|
||||
<div class="md:hidden sticky bottom-0 z-[60] p-4 bg-background/95 backdrop-blur-sm border-t border-border flex justify-end shrink-0 w-full shadow-[0_-10px_40px_-5px_hsl(var(--background))]">
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="w-full h-11 rounded-xl text-sm font-bold shadow-sm"
|
||||
onClick={() => props.setSelectedNoteId(null)}
|
||||
>
|
||||
<X size={16} class="mr-2" />
|
||||
Close Note
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Show>
|
||||
</Show >
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
|
||||
+25
-13
@@ -1,16 +1,24 @@
|
||||
import { type Component, For, createMemo } from "solid-js";
|
||||
import { type Component, For, createMemo, onMount } from "solid-js";
|
||||
import { store, getCombinedScore, matchesFilter } from "@/store";
|
||||
import { TaskCard } from "@/components/TaskCard";
|
||||
import { useDelayedSort } from "@/hooks/useDelayedSort";
|
||||
import autoAnimate from "@formkit/auto-animate";
|
||||
|
||||
export const PriorityView: Component = () => {
|
||||
const sortedTasks = createMemo(() => {
|
||||
return [...store.tasks]
|
||||
.filter(t => !t.deletedAt && matchesFilter(t))
|
||||
.sort((a, b) => {
|
||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||
// Use combined score to account for priority, urgency, and tags
|
||||
return getCombinedScore(b) - getCombinedScore(a);
|
||||
});
|
||||
const sourceTasks = createMemo(() => store.tasks.filter(t => !t.deletedAt && matchesFilter(t)));
|
||||
|
||||
const { displayedTasks, shakingTaskIds } = useDelayedSort(
|
||||
sourceTasks,
|
||||
(a, b) => {
|
||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||
return getCombinedScore(b) - getCombinedScore(a);
|
||||
},
|
||||
300
|
||||
);
|
||||
|
||||
let listRef: HTMLDivElement | undefined;
|
||||
onMount(() => {
|
||||
if (listRef) autoAnimate(listRef, { duration: 300, easing: 'ease-out' });
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -20,13 +28,17 @@ export const PriorityView: Component = () => {
|
||||
<p class="text-muted-foreground mt-1 text-lg">Your tasks ordered by priority.</p>
|
||||
</header>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<For each={sortedTasks()}>
|
||||
{(task) => <TaskCard task={task} />}
|
||||
<div class="grid gap-3" ref={listRef}>
|
||||
<For each={displayedTasks()}>
|
||||
{(task) => (
|
||||
<div class="animate-task-enter">
|
||||
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{sortedTasks().length === 0 && (
|
||||
{displayedTasks().length === 0 && (
|
||||
<div class="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div class="w-16 h-16 bg-muted rounded-full flex items-center justify-center mb-4">
|
||||
<span class="text-2xl">🎯</span>
|
||||
|
||||
+30
-17
@@ -1,22 +1,31 @@
|
||||
import { type Component, For, createMemo } from "solid-js";
|
||||
import { type Component, For, createMemo, onMount } from "solid-js";
|
||||
import { store, getCombinedScore, matchesFilter } from "@/store";
|
||||
import { TaskCard } from "@/components/TaskCard";
|
||||
import { useDelayedSort } from "@/hooks/useDelayedSort";
|
||||
import autoAnimate from "@formkit/auto-animate";
|
||||
|
||||
export const ProgressView: Component = () => {
|
||||
const sortedTasks = createMemo(() => {
|
||||
return [...store.tasks]
|
||||
.filter(t => !t.deletedAt && matchesFilter(t))
|
||||
.sort((a, b) => {
|
||||
// 1. Completed tasks at the bottom
|
||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||
const sourceTasks = createMemo(() => store.tasks.filter(t => !t.deletedAt && matchesFilter(t)));
|
||||
|
||||
// 2. Sort by Progress (Status) Descending (Highest progress first)
|
||||
// Assuming status 0-9 are active states, 10 is completed (handled above)
|
||||
if (a.status !== b.status) return b.status - a.status;
|
||||
const { displayedTasks, shakingTaskIds } = useDelayedSort(
|
||||
sourceTasks,
|
||||
(a, b) => {
|
||||
// 1. Completed tasks at the bottom
|
||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||
|
||||
// 3. Then by Focus Score (Combined Score)
|
||||
return getCombinedScore(b) - getCombinedScore(a);
|
||||
});
|
||||
// 2. Sort by Progress (Status) Descending (Highest progress first)
|
||||
// Assuming status 0-9 are active states, 10 is completed (handled above)
|
||||
if (a.status !== b.status) return b.status - a.status;
|
||||
|
||||
// 3. Then by Focus Score (Combined Score)
|
||||
return getCombinedScore(b) - getCombinedScore(a);
|
||||
},
|
||||
300
|
||||
);
|
||||
|
||||
let listRef: HTMLDivElement | undefined;
|
||||
onMount(() => {
|
||||
if (listRef) autoAnimate(listRef, { duration: 300, easing: 'ease-out' });
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -26,13 +35,17 @@ export const ProgressView: Component = () => {
|
||||
<p class="text-muted-foreground mt-1 text-lg">Tasks sorted by progress status, then by focus score.</p>
|
||||
</header>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<For each={sortedTasks()}>
|
||||
{(task) => <TaskCard task={task} />}
|
||||
<div class="grid gap-3" ref={listRef}>
|
||||
<For each={displayedTasks()}>
|
||||
{(task) => (
|
||||
<div class="animate-task-enter">
|
||||
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{sortedTasks().length === 0 && (
|
||||
{displayedTasks().length === 0 && (
|
||||
<div class="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div class="w-16 h-16 bg-muted rounded-full flex items-center justify-center mb-4">
|
||||
<span class="text-2xl">📈</span>
|
||||
|
||||
+30
-17
@@ -1,20 +1,29 @@
|
||||
import { type Component, For, createMemo } from "solid-js";
|
||||
import { type Component, For, createMemo, onMount } from "solid-js";
|
||||
import { store, getCombinedScore, matchesFilter } from "@/store";
|
||||
import { TaskCard } from "@/components/TaskCard";
|
||||
import { useDelayedSort } from "@/hooks/useDelayedSort";
|
||||
import autoAnimate from "@formkit/auto-animate";
|
||||
|
||||
export const SnowballView: Component = () => {
|
||||
const sortedTasks = createMemo(() => {
|
||||
return [...store.tasks]
|
||||
.filter(t => !t.deletedAt && matchesFilter(t))
|
||||
.sort((a, b) => {
|
||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||
// Primary: Size ascending (smallest first)
|
||||
const sizeA = a.size ?? 5;
|
||||
const sizeB = b.size ?? 5;
|
||||
if (sizeA !== sizeB) return sizeA - sizeB;
|
||||
// Secondary: Combined score descending (higher focus first)
|
||||
return getCombinedScore(b) - getCombinedScore(a);
|
||||
});
|
||||
const sourceTasks = createMemo(() => store.tasks.filter(t => !t.deletedAt && matchesFilter(t)));
|
||||
|
||||
const { displayedTasks, shakingTaskIds } = useDelayedSort(
|
||||
sourceTasks,
|
||||
(a, b) => {
|
||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||
// Primary: Size ascending (smallest first)
|
||||
const sizeA = a.size ?? 5;
|
||||
const sizeB = b.size ?? 5;
|
||||
if (sizeA !== sizeB) return sizeA - sizeB;
|
||||
// Secondary: Combined score descending (higher focus first)
|
||||
return getCombinedScore(b) - getCombinedScore(a);
|
||||
},
|
||||
300
|
||||
);
|
||||
|
||||
let listRef: HTMLDivElement | undefined;
|
||||
onMount(() => {
|
||||
if (listRef) autoAnimate(listRef, { duration: 300, easing: 'ease-out' });
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -24,13 +33,17 @@ export const SnowballView: Component = () => {
|
||||
<p class="text-muted-foreground mt-1 text-lg">Smallest tasks first. Build momentum with quick wins.</p>
|
||||
</header>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<For each={sortedTasks()}>
|
||||
{(task) => <TaskCard task={task} />}
|
||||
<div class="grid gap-3" ref={listRef}>
|
||||
<For each={displayedTasks()}>
|
||||
{(task) => (
|
||||
<div class="animate-task-enter">
|
||||
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{sortedTasks().length === 0 && (
|
||||
{displayedTasks().length === 0 && (
|
||||
<div class="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div class="w-16 h-16 bg-muted rounded-full flex items-center justify-center mb-4">
|
||||
<span class="text-2xl">❄️</span>
|
||||
|
||||
+29
-16
@@ -1,19 +1,28 @@
|
||||
import { type Component, For, createMemo } from "solid-js";
|
||||
import { type Component, For, createMemo, onMount } from "solid-js";
|
||||
import { store, calculateUrgencyScore, getCombinedScore, matchesFilter } from "@/store";
|
||||
import { TaskCard } from "@/components/TaskCard";
|
||||
import { useDelayedSort } from "@/hooks/useDelayedSort";
|
||||
import autoAnimate from "@formkit/auto-animate";
|
||||
|
||||
export const UrgencyView: Component = () => {
|
||||
const sortedTasks = createMemo(() => {
|
||||
return [...store.tasks]
|
||||
.filter(t => !t.deletedAt && matchesFilter(t))
|
||||
.sort((a, b) => {
|
||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||
const uA = calculateUrgencyScore(a.dueDate);
|
||||
const uB = calculateUrgencyScore(b.dueDate);
|
||||
if (uA !== uB) return uB - uA;
|
||||
// Tie-break with combined score (Priority + Tags)
|
||||
return getCombinedScore(b) - getCombinedScore(a);
|
||||
});
|
||||
const sourceTasks = createMemo(() => store.tasks.filter(t => !t.deletedAt && matchesFilter(t)));
|
||||
|
||||
const { displayedTasks, shakingTaskIds } = useDelayedSort(
|
||||
sourceTasks,
|
||||
(a, b) => {
|
||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||
const uA = calculateUrgencyScore(a.dueDate);
|
||||
const uB = calculateUrgencyScore(b.dueDate);
|
||||
if (uA !== uB) return uB - uA;
|
||||
// Tie-break with combined score (Priority + Tags)
|
||||
return getCombinedScore(b) - getCombinedScore(a);
|
||||
},
|
||||
300
|
||||
);
|
||||
|
||||
let listRef: HTMLDivElement | undefined;
|
||||
onMount(() => {
|
||||
if (listRef) autoAnimate(listRef, { duration: 300, easing: 'ease-out' });
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -23,13 +32,17 @@ export const UrgencyView: Component = () => {
|
||||
<p class="text-muted-foreground mt-1 text-lg">Your tasks ordered by deadline.</p>
|
||||
</header>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<For each={sortedTasks()}>
|
||||
{(task) => <TaskCard task={task} />}
|
||||
<div class="grid gap-3" ref={listRef}>
|
||||
<For each={displayedTasks()}>
|
||||
{(task) => (
|
||||
<div class="animate-task-enter">
|
||||
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{sortedTasks().length === 0 && (
|
||||
{displayedTasks().length === 0 && (
|
||||
<div class="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div class="w-16 h-16 bg-muted rounded-full flex items-center justify-center mb-4">
|
||||
<span class="text-2xl">⏳</span>
|
||||
|
||||
Reference in New Issue
Block a user