Compare commits
55 Commits
c80758178c
...
Dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 82a75411f2 | |||
| 214c050c8f | |||
| e9c380c35c | |||
| 738b6aad34 | |||
| f1200a2c78 | |||
| 15f518c58f | |||
| e5d579051d | |||
| 42531b37a5 | |||
| 7ee6b795f7 | |||
| d9a2b05bb1 | |||
| 8020e16ca1 | |||
| 7096f32ea5 | |||
| 6de09411a1 | |||
| 5bf00de940 | |||
| 14ebb21049 | |||
| 870ecd9052 | |||
| f574fdc2d9 | |||
| 18401dd07c | |||
| f9de2cfd2b | |||
| bde9cc7047 | |||
| 17d0aaa2ff | |||
| 299eb03b05 | |||
| b420533214 | |||
| b9840f1d32 | |||
| d825be0ee4 | |||
| 89b5063786 | |||
| 20a19006d5 | |||
| 1a497a98e8 | |||
| b59e2c2187 | |||
| ef935ac37b | |||
| b507f87d23 | |||
| d8bc5e7561 | |||
| 820395b901 | |||
| b91eb2b639 | |||
| 18c07725fc | |||
| 504eaf6abb | |||
| b1d2048736 | |||
| 41c9e2f3a0 | |||
| 2abf4ae5a9 | |||
| 3826a2cbe2 | |||
| 3907de048b | |||
| e5c8e42709 | |||
| 476823394b | |||
| 81b572422b | |||
| 063bb7d1f9 | |||
| 759c873b63 | |||
| 6356a7f653 | |||
| 98ff4d0a4f | |||
| e0cf9aa122 | |||
| 9d8e80a2bf | |||
| 96ee1168b1 | |||
| 1618b3bf92 | |||
| ce7398b660 | |||
| a1ebb8f898 | |||
| 82d545d754 |
@@ -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,12 +5,27 @@
|
||||
"": {
|
||||
"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",
|
||||
"autoprefixer": "^10.4.23",
|
||||
@@ -283,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=="],
|
||||
@@ -449,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=="],
|
||||
@@ -457,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=="],
|
||||
|
||||
@@ -465,11 +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=="],
|
||||
|
||||
@@ -477,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=="],
|
||||
@@ -485,13 +512,25 @@
|
||||
|
||||
"@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-underline": ["@tiptap/extension-underline@3.18.0", "", { "peerDependencies": { "@tiptap/core": "^3.18.0" } }, "sha512-009IeXURNJ/sm1pBqbj+2YQgjQaBtNlJR3dbl6xu49C+qExqCmI7klhKQuwsVVGLR7ahsYlp7d9RlftnhCXIcQ=="],
|
||||
"@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=="],
|
||||
|
||||
@@ -1389,6 +1428,12 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
"ast-types/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
# TaskGrid User Guide
|
||||
|
||||
## Welcome & Access
|
||||
|
||||
- **Website**: [tasgrid.ccllc.pro](https://tasgrid.ccllc.pro)
|
||||
- **Login**: Use your **Cardoza Construction email** (the same one you use for Outlook). Your password should match your email password as well.
|
||||
|
||||
---
|
||||
|
||||
## Basic Features (Getting Started)
|
||||
|
||||
### Creating Your First Task
|
||||
|
||||
1. Click the **large plus (+) sign** in the bottom right corner (or type control \+ k).
|
||||
2. **Title**: Enter a clear name for what needs to be done.
|
||||
3. **Priority**: Choose from 1 (trivial) to 10 (absolutely necessary).
|
||||
4. **Urgency & Due Date**: These are linked\!
|
||||
- Pick an **Urgency level** (10 is today, 1 is 6 months from now), and the due date will be calculated for you.
|
||||
- Alternatively, pick a **Due Date** using the calendar, and the urgency will adjust automatically.
|
||||
5. **Size**: This defaults to **5**. Adjust it if the task is very small (15 mins) or very large (multiple days).
|
||||
6. **Description**: Add notes or details. Use the **forward slash (/)** to reveal formatting options like bold text, checklists, and even adding images.
|
||||
7. Click **Create Task**.
|
||||
|
||||
### Viewing Your Tasks
|
||||
|
||||
TaskGrid sorts your tasks so you don't have to. The primary views are:
|
||||
|
||||
- **✨ Focus (Default)**: Your most important tasks, ranked by a combination of Urgency and Priority.
|
||||
- **📊 Strategy Matrix**: A 4-quadrant grid mapping Urgency vs. Priority.
|
||||
- **Do First**: Urgent & Important.
|
||||
- **Schedule**: Important but not urgent yet.
|
||||
- **Delegate**: Urgent but not important.
|
||||
- **Eliminate**: Neither urgent nor important.
|
||||
- **❄️ Snowball**: Shows your **smallest** tasks first to help you build momentum.
|
||||
- **⛏️ Dig In**: Shows your **largest** tasks first for when you have time to focus.
|
||||
- **🕒 Urgency**: Sorts tasks purely by when they are due.
|
||||
- **⭐ Priority**: Sorts tasks purely by how important they are.
|
||||
|
||||
### Other Info
|
||||
|
||||
- **Search & Filters**: Use the bar at the top right to find specific tasks or filter by priority, urgency, or tasks edited today.
|
||||
- **Themes**: Switch between **Light Mode** and **Dark Mode** in the Settings.
|
||||
- **Trash**: Deleted tasks are kept for **7 days** before being permanently removed. You can recover them in Settings.
|
||||
|
||||
### Mobile Installation
|
||||
|
||||
TaskGrid works like a dedicated app on your phone:
|
||||
|
||||
- **Android**: Tap the three dots in Chrome, select "Add to Home Screen," then "Install."
|
||||
- **iPhone**: Tap the Share icon in Safari and select "Add to Home Screen."
|
||||
|
||||
---
|
||||
|
||||
## Advanced Features (Teams & Automation)
|
||||
|
||||
### Sharing & Collaboration
|
||||
|
||||
Collaboration in TaskGrid is flexible and transparent:
|
||||
|
||||
1. **Manual Sharing**: Open a task and use the **Share** menu to add a specific person.
|
||||
- You can also **unshare** **and split** tasks here. Unshare just removes it from their list and split gives each of you a unique copy of the task.
|
||||
2. **Tag-Based User Sharing**: Add a tag that matches a coworker's name (e.g., "John Smith").
|
||||
- This automatically shares the task with them.
|
||||
- Both of you can edit the task together (Collaborative mode).
|
||||
3. **Collaboration vs. Handoff**:
|
||||
- **Collaborate**: Both users can see and edit.
|
||||
- **Handoff**: The task is transferred to the other person, and they become the new owner.
|
||||
|
||||
### Team Spaces (Buckets)
|
||||
|
||||
"Buckets" are shared areas for teams (like "Shop" or "Maintenance").
|
||||
|
||||
- **Move to Bucket**: Simply add a tag that matches the bucket name (e.g., tagging a task with "Shop").
|
||||
- **Visibility**: By default, bucketed tasks are hidden from your main view to keep your list clean. You can view them by selecting the bucket in the sidebar but they will only appear if you have pinned them.
|
||||
- **Pinning**: In order to work in a bucket, you must "Pin" it in Settings so it always shows up in your sidebar.
|
||||
|
||||
### Oversight (Share All)
|
||||
|
||||
Managers and team leads can ask their teams to create a "share all" sharing rule in setting. This will add that user's bucket as an option in the sidebar just like shared buckets. Clicking a user's name in your sidebar allows you to view their entire task list, making it easy to track progress across the team.
|
||||
|
||||
### Automatic Tasks (Recurrence)
|
||||
|
||||
For recurring work, you can set a task to repeat **Daily**, **Weekly** (on any specified days of the week), or **Monthly** (on a specified day of the month).
|
||||
|
||||
- When you mark it complete, TaskGrid automatically "un-completes" it for you when the next cycle begins.
|
||||
- You'll see status labels like "Complete until Monday" to let you know exactly when it will reappear.
|
||||
|
||||
### Bulk Import (Filling a Bucket)
|
||||
|
||||
Need to add a long list of tasks at once?
|
||||
|
||||
1. Go to **Settings** \> **Import Tasks**.
|
||||
2. Paste your list (one task per line).
|
||||
3. Click "Parse and Configure."
|
||||
4. Use the **Bulk Update** tool at the top to set the Priority, Urgency, and Tag (e.g., "Shop") for all tasks at once.
|
||||
5. Update tasks that need individual urgency and priority. (Usually all tasks need individual urgency and priority because if everything is set the same TasGrid can't sort them)
|
||||
6. Click **Import Tasks**.
|
||||
|
||||
---
|
||||
|
||||
## Power User Tips
|
||||
|
||||
- **Quick Entry Shorthands**: Type `/p5` (priority), `/u8` (urgency), or `/t Wood` (tags) directly into the title box when creating a task to set properties instantly without touching your mouse!
|
||||
- **Task Templates**: If you have recurring projects with the same notes and tags, create a **Template** in Settings. You can apply it with a single click in the creation menu.
|
||||
- **Matrix Color Pro-tip**: The dots on your Strategy Matrix change color based on **Size**—**Blue** dots are small wins (easy tasks), and **Red** dots are major projects (time-consuming tasks).
|
||||
- **Smart Tagging**: If you are currently viewing a Bucket (e.g., "Shop"), when you click the Plus button to add a task, TaskGrid will automatically add the "Shop" tag for you.
|
||||
|
||||
---
|
||||
|
||||
### Need help?
|
||||
|
||||
If the app feels out of sync, a quick [**refresh**](#) usually fixes it. Most updates happen automatically in real-time\!
|
||||
|
||||
If you have any other questions contact Timothy Cardoza or IT.
|
||||
+16
-1
@@ -12,15 +12,30 @@
|
||||
"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"
|
||||
"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",
|
||||
"autoprefixer": "^10.4.23",
|
||||
|
||||
+90
-16
@@ -1,5 +1,7 @@
|
||||
import { type Component, createSignal, Show, onMount, onCleanup, lazy, Suspense } from 'solid-js';
|
||||
import { type Component, createSignal, onMount, onCleanup, createMemo, lazy, Suspense, Show } from 'solid-js';
|
||||
import { Layout } from './components/Layout';
|
||||
import { EmbedLayout } from './components/EmbedLayout';
|
||||
import { EmbedAuthWrapper } from './components/EmbedAuthWrapper';
|
||||
import { CriticalView } from './views/CriticalView';
|
||||
import { AuthCallback } from './components/Auth';
|
||||
import { pb } from './lib/pocketbase';
|
||||
@@ -13,21 +15,38 @@ const SettingsView = lazy(() => import('./views/SettingsView').then(m => ({ defa
|
||||
const SnowballView = lazy(() => import('./views/SnowballView').then(m => ({ default: m.SnowballView })));
|
||||
const DigInView = lazy(() => import('./views/DigInView').then(m => ({ default: m.DigInView })));
|
||||
const ProgressView = lazy(() => import('./views/ProgressView').then(m => ({ default: m.ProgressView })));
|
||||
const HelpView = lazy(() => import('./views/HelpView').then(m => ({ default: m.HelpView })));
|
||||
const NotepadView = lazy(() => import('./views/NotepadView').then(m => ({ default: m.NotepadView })));
|
||||
const QuickEntryForm = lazy(() => import('./components/QuickEntryForm').then(m => ({ default: m.QuickEntryForm })));
|
||||
|
||||
const App: Component = () => {
|
||||
// Basic routing state
|
||||
const [currentView, setCurrentView] = createSignal("critical");
|
||||
const [isAuthenticated, setIsAuthenticated] = createSignal(pb.authStore.isValid);
|
||||
const [location, setLocation] = createSignal(window.location.href);
|
||||
|
||||
onMount(() => {
|
||||
const handleLocChange = () => setLocation(window.location.href);
|
||||
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(() => removeListener());
|
||||
onCleanup(() => {
|
||||
removeListener();
|
||||
window.removeEventListener('popstate', handleLocChange);
|
||||
window.removeEventListener('hashchange', handleLocChange);
|
||||
});
|
||||
|
||||
// Background preload of other views when idle
|
||||
const idleCallback = (window as any).requestIdleCallback || ((cb: any) => setTimeout(cb, 1000));
|
||||
@@ -40,6 +59,7 @@ const App: Component = () => {
|
||||
import('./views/SnowballView');
|
||||
import('./views/DigInView');
|
||||
import('./views/ProgressView');
|
||||
import('./views/HelpView');
|
||||
|
||||
// Remove splash screen after high priority work is done
|
||||
const splash = document.getElementById('splash');
|
||||
@@ -50,20 +70,74 @@ const App: Component = () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Determine if we are in an embed route (check both path and hash for compatibility)
|
||||
const isEmbed = () => {
|
||||
const loc = location(); // Reactive dependency
|
||||
const url = new URL(loc);
|
||||
const path = url.pathname;
|
||||
const hash = url.hash;
|
||||
const res = path.startsWith('/embed/') || hash.startsWith('#/embed/');
|
||||
console.log('[DEBUG] isEmbed check:', { res, path, hash, href: loc });
|
||||
return res;
|
||||
};
|
||||
|
||||
const getEmbedView = () => {
|
||||
const loc = location(); // Reactive dependency
|
||||
const url = new URL(loc);
|
||||
const fullPath = url.pathname + url.hash;
|
||||
let view = null;
|
||||
if (fullPath.includes('/notes')) view = 'embed_notes';
|
||||
else if (fullPath.includes('/quick-add')) view = 'embed_quick_add';
|
||||
console.log('[DEBUG] getEmbedView detected:', view, 'Full URL context:', fullPath);
|
||||
return view;
|
||||
};
|
||||
|
||||
return (
|
||||
<Show when={isAuthenticated()} fallback={<AuthCallback onSuccess={() => { }} />}>
|
||||
<Layout currentView={currentView()} setView={setCurrentView}>
|
||||
<Suspense fallback={<div class="flex items-center justify-center h-full"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div></div>}>
|
||||
<Show when={currentView() === "critical"}><CriticalView /></Show>
|
||||
<Show when={currentView() === "urgency"}><UrgencyView /></Show>
|
||||
<Show when={currentView() === "priority"}><PriorityView /></Show>
|
||||
<Show when={currentView() === "matrix"}><MatrixView /></Show>
|
||||
<Show when={currentView() === "snowball"}><SnowballView /></Show>
|
||||
<Show when={currentView() === "dig_in"}><DigInView /></Show>
|
||||
<Show when={currentView() === "progress"}><ProgressView /></Show>
|
||||
<Show when={currentView() === "settings"}><SettingsView /></Show>
|
||||
</Suspense>
|
||||
</Layout>
|
||||
<Show
|
||||
when={isEmbed()}
|
||||
fallback={
|
||||
<Show when={isAuthenticated()} fallback={<AuthCallback onSuccess={() => { }} />}>
|
||||
<Layout currentView={currentView()} setView={setCurrentView}>
|
||||
<Suspense fallback={<div class="flex items-center justify-center h-full"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div></div>}>
|
||||
<Show when={currentView() === "critical"}><CriticalView /></Show>
|
||||
<Show when={currentView() === "urgency"}><UrgencyView /></Show>
|
||||
<Show when={currentView() === "priority"}><PriorityView /></Show>
|
||||
<Show when={currentView() === "matrix"}><MatrixView /></Show>
|
||||
<Show when={currentView() === "snowball"}><SnowballView /></Show>
|
||||
<Show when={currentView() === "dig_in"}><DigInView /></Show>
|
||||
<Show when={currentView() === "progress"}><ProgressView /></Show>
|
||||
<Show when={currentView() === "settings"}><SettingsView setView={setCurrentView} /></Show>
|
||||
<Show when={currentView() === "help"}><HelpView onBack={() => setCurrentView("settings")} /></Show>
|
||||
</Suspense>
|
||||
</Layout>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<EmbedAuthWrapper>
|
||||
<EmbedLayout>
|
||||
<Suspense fallback={<div class="flex items-center justify-center h-full"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div></div>}>
|
||||
<Show when={getEmbedView() === 'embed_notes'}>
|
||||
{(() => {
|
||||
const noteIdFromUrl = createMemo(() => {
|
||||
const urlObj = new URL(location());
|
||||
let noteId = new URLSearchParams(urlObj.search).get('noteId');
|
||||
// Production likely uses hash routing, so check hash for query params too
|
||||
if (!noteId && urlObj.hash.includes('?')) {
|
||||
const hashQueryString = urlObj.hash.substring(urlObj.hash.indexOf('?'));
|
||||
noteId = new URLSearchParams(hashQueryString).get('noteId');
|
||||
}
|
||||
return noteId;
|
||||
});
|
||||
console.log('[DEBUG App] Rendering embed/notes context. noteId trace:', noteIdFromUrl());
|
||||
return <NotepadView selectedNoteId={noteIdFromUrl()} setSelectedNoteId={() => { }} hideNavigation={!!noteIdFromUrl()} />;
|
||||
})()}
|
||||
</Show>
|
||||
<Show when={getEmbedView() === 'embed_quick_add'}>
|
||||
<QuickEntryForm autoFocus={true} />
|
||||
</Show>
|
||||
</Suspense>
|
||||
</EmbedLayout>
|
||||
</EmbedAuthWrapper>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { type Component, type JSX, onMount, onCleanup, createSignal, Show } from "solid-js";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
|
||||
interface EmbedAuthWrapperProps {
|
||||
children: JSX.Element;
|
||||
}
|
||||
|
||||
export const EmbedAuthWrapper: Component<EmbedAuthWrapperProps> = (props) => {
|
||||
const [isHydrated, setIsHydrated] = createSignal(pb.authStore.isValid);
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
const { data } = event;
|
||||
console.log('[DEBUG] EmbedAuthWrapper received message:', data?.type);
|
||||
if (data?.type === "TASGRID_AUTH" && data.token) {
|
||||
console.log("[DEBUG] Received TasGrid auth payload, hydrating...");
|
||||
// Save triggers pb.authStore.onChange, which App.tsx is already listening to and calling initStore()
|
||||
pb.authStore.save(data.token, data.user || null);
|
||||
setIsHydrated(true);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
console.log('[DEBUG] EmbedAuthWrapper mounted. Initial isHydrated:', isHydrated());
|
||||
window.addEventListener("message", handleMessage);
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("message", handleMessage);
|
||||
});
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={isHydrated()}
|
||||
fallback={
|
||||
<div class="flex flex-col items-center justify-center h-full space-y-4">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
<p class="text-sm text-muted-foreground">Waiting for authentication...</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { type Component, type JSX } from "solid-js";
|
||||
|
||||
interface EmbedLayoutProps {
|
||||
children: JSX.Element;
|
||||
}
|
||||
|
||||
export const EmbedLayout: Component<EmbedLayoutProps> = (props) => {
|
||||
return (
|
||||
<div class="flex h-screen w-full min-w-0 bg-background overflow-hidden relative font-sans antialiased text-foreground">
|
||||
<main class="flex-1 min-w-0 overflow-y-auto overflow-x-hidden relative scroll-smooth bg-background">
|
||||
<div class="w-full h-full mx-auto p-4">
|
||||
{props.children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type Component, createSignal, For, Show } from "solid-js";
|
||||
import { Search, X, Hash, ArrowUpCircle, Clock, Minus, Save, Trash2, Bookmark } from "lucide-solid";
|
||||
import { Search, X, Tag, ArrowUpCircle, Clock, Minus, Save, Trash2, Bookmark } from "lucide-solid";
|
||||
import { store, setFilter, clearFilter, saveFilterTemplate, applyFilterTemplate, removeFilterTemplate, loadAllHistory } from "@/store";
|
||||
import { Button } from "./ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -113,6 +113,12 @@ export const FilterBar: Component = () => {
|
||||
placeholder="Search tasks, descriptions, tags..."
|
||||
value={store.filter.query}
|
||||
onInput={(e) => setFilter({ query: e.currentTarget.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
setIsOpen(false);
|
||||
}
|
||||
}}
|
||||
class="w-full bg-muted/30 border border-border/50 rounded-xl pl-10 pr-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/40 transition-all placeholder:text-muted-foreground/30"
|
||||
/>
|
||||
</div>
|
||||
@@ -229,7 +235,7 @@ export const FilterBar: Component = () => {
|
||||
setFilter({ tags: newTags });
|
||||
}}
|
||||
>
|
||||
<Show when={tag.excluded} fallback={<Hash size={12} class="opacity-70" />}>
|
||||
<Show when={tag.excluded} fallback={<Tag size={12} class="opacity-70" />}>
|
||||
<Minus size={12} class="opacity-100" />
|
||||
</Show>
|
||||
<span class={cn(tag.excluded && "line-through opacity-70")}>{tag.name}</span>
|
||||
|
||||
+159
-24
@@ -1,10 +1,12 @@
|
||||
import { type Component, type JSX, createSignal, 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, activeTaskId, setActiveTaskId } from "@/store";
|
||||
import { PanelLeftOpen } from "lucide-solid";
|
||||
import { store, setStore, activeTaskId, setActiveTaskId } from "@/store";
|
||||
import { PanelLeftOpen, PanelLeftClose } from "lucide-solid";
|
||||
|
||||
const TaskDetail = lazy(() => import("./TaskDetail").then(m => ({ default: m.TaskDetail })));
|
||||
const QuickEntry = lazy(() => import("./QuickEntry").then(m => ({ default: m.QuickEntry })));
|
||||
const NotepadView = lazy(() => import("../views/NotepadView").then(m => ({ default: m.NotepadView })));
|
||||
import { NotesSidebar } from "./NotesSidebar";
|
||||
import { Button } from "./ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FilterBar } from "./FilterBar";
|
||||
@@ -18,14 +20,34 @@ 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);
|
||||
|
||||
// Make it available globally for QuickEntry auto-tagging without circular deps
|
||||
createEffect(() => {
|
||||
(window as any)._activeNoteId = selectedNoteId();
|
||||
});
|
||||
|
||||
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(() => {
|
||||
import("./TaskDetail");
|
||||
import("./QuickEntry");
|
||||
import("./TaskEditor");
|
||||
import("../views/NotepadView");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,19 +56,86 @@ export const Layout: Component<LayoutProps> = (props) => {
|
||||
|
||||
return (
|
||||
<div class="flex h-screen w-full min-w-0 bg-background overflow-hidden relative font-sans antialiased text-foreground">
|
||||
<Sidebar
|
||||
currentView={props.currentView}
|
||||
setView={props.setView}
|
||||
isLocked={isSidebarLocked()}
|
||||
setIsLocked={setIsSidebarLocked}
|
||||
isPeeking={isSidebarPeeking()}
|
||||
setIsPeeking={setIsSidebarPeeking}
|
||||
/>
|
||||
{/* Desktop UI Container */}
|
||||
<div
|
||||
style={{ "will-change": "width, transform" }}
|
||||
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() || (store.isNotepadMode && !selectedNoteId())) && "translate-x-0 shadow-2xl ring-1 ring-border"
|
||||
)}
|
||||
onMouseEnter={() => !isSidebarLocked() && setIsSidebarPeeking(true)}
|
||||
onMouseLeave={() => {
|
||||
if (!isSidebarLocked() && !isDropdownOpen()) {
|
||||
setIsSidebarPeeking(false);
|
||||
}
|
||||
}}>
|
||||
|
||||
<div class={cn(
|
||||
"flex-1 flex flex-col h-full transition-all duration-300 ease-in-out relative",
|
||||
isSidebarLocked() ? "md:ml-48" : "ml-0"
|
||||
)}>
|
||||
<div class="relative flex-1 flex flex-col min-h-0">
|
||||
{/* Task Sidebar Wrapper */}
|
||||
<div
|
||||
style={{ "will-change": "opacity, transform" }}
|
||||
class={cn(
|
||||
"absolute inset-0 flex flex-col transition-all duration-200 ease-in-out",
|
||||
store.isNotepadMode ? "opacity-0 pointer-events-none -translate-x-8" : "opacity-100 translate-x-0"
|
||||
)}>
|
||||
<Sidebar
|
||||
currentView={props.currentView}
|
||||
setView={props.setView}
|
||||
isLocked={isSidebarLocked()}
|
||||
setIsLocked={setIsSidebarLocked}
|
||||
isPeeking={isSidebarPeeking()}
|
||||
setIsPeeking={setIsSidebarPeeking}
|
||||
isEmbed={true}
|
||||
onDropdownOpenChange={setIsDropdownOpen}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Notes Sidebar Wrapper */}
|
||||
<div
|
||||
style={{ "will-change": "opacity, transform" }}
|
||||
class={cn(
|
||||
"absolute inset-0 flex flex-col transition-all duration-200 ease-in-out bg-card",
|
||||
!store.isNotepadMode ? "opacity-0 pointer-events-none translate-x-8" : "opacity-100 translate-x-0"
|
||||
)}>
|
||||
<div class={cn("p-4 flex items-center justify-between transition-all duration-200", !isSidebarLocked() && "pl-14")}>
|
||||
<h1 class={cn(
|
||||
"text-xl font-bold tracking-tighter text-foreground transition-all duration-200",
|
||||
(isSidebarLocked() || isSidebarPeeking())
|
||||
? "opacity-100 translate-x-0"
|
||||
: "opacity-0 -translate-x-4"
|
||||
)}>
|
||||
Tasgrid
|
||||
</h1>
|
||||
<Show when={isSidebarLocked()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setIsSidebarLocked(false);
|
||||
setIsSidebarPeeking(false);
|
||||
}}
|
||||
>
|
||||
<PanelLeftClose size={16} />
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{ "will-change": "padding-left" }}
|
||||
class={cn(
|
||||
"flex-1 flex flex-col min-w-0 transition-all duration-200 ease-in-out h-screen",
|
||||
// Only apply md padding if locked
|
||||
isSidebarLocked() ? (store.isNotepadMode ? "md:pl-80" : "md:pl-48") : "md:pl-0"
|
||||
)}>
|
||||
{/* Expand Trigger Button (visible when not locked) */}
|
||||
{!isSidebarLocked() && (
|
||||
<div class="fixed top-4 left-4 z-[60] md:flex hidden">
|
||||
@@ -67,18 +156,64 @@ export const Layout: Component<LayoutProps> = (props) => {
|
||||
<ContextSwitcher isMobile={true} />
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main class="flex-1 min-w-0 overflow-y-auto overflow-x-hidden pb-32 sm:pb-6 relative scroll-smooth">
|
||||
<div class="w-full max-w-screen-2xl mx-auto px-4 sm:px-8 lg:px-12 pt-16 sm:pt-20 pb-8 space-y-6 sm:space-y-8">
|
||||
<Show when={props.currentView.toLowerCase() !== "settings"}>
|
||||
<FilterBar />
|
||||
</Show>
|
||||
{/* Mode Switcher */}
|
||||
<div class="fixed top-4 left-1/2 -translate-x-1/2 z-[60]">
|
||||
<div class="bg-background/80 backdrop-blur-sm border border-border p-1 rounded-xl flex items-center shadow-lg relative min-w-[160px] h-10">
|
||||
{/* Sliding Indicator */}
|
||||
<div
|
||||
style={{ "will-change": "transform" }}
|
||||
class={cn(
|
||||
"absolute top-1 bottom-1 w-[calc(50%-4px)] bg-primary rounded-lg shadow-sm transition-transform duration-200 ease-out z-0",
|
||||
store.isNotepadMode ? "translate-x-[calc(100%+0px)]" : "translate-x-0"
|
||||
)} />
|
||||
|
||||
{props.children}
|
||||
<button
|
||||
class={cn(
|
||||
"flex-1 h-8 text-[0.7rem] font-bold uppercase tracking-widest z-10 transition-colors duration-200 flex items-center justify-center rounded-lg",
|
||||
!store.isNotepadMode ? "text-primary-foreground" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setStore('isNotepadMode', false)}
|
||||
>
|
||||
Tasks
|
||||
</button>
|
||||
<button
|
||||
class={cn(
|
||||
"flex-1 h-8 text-[0.7rem] font-bold uppercase tracking-widest z-10 transition-colors duration-200 flex items-center justify-center rounded-lg",
|
||||
store.isNotepadMode ? "text-primary-foreground" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setStore('isNotepadMode', true)}
|
||||
>
|
||||
Notes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main class={cn(
|
||||
"flex-1 min-w-0 overflow-y-auto overflow-x-hidden relative scroll-smooth",
|
||||
!store.isNotepadMode ? "pb-32 sm:pb-6" : ""
|
||||
)}>
|
||||
<div class={cn(
|
||||
"w-full h-full mx-auto",
|
||||
!store.isNotepadMode ? "max-w-screen-2xl px-4 sm:px-8 lg:px-12 pt-16 sm:pt-20 pb-8 space-y-6 sm:space-y-8" : "max-w-screen-2xl md:px-8 lg:px-12 md:pt-20 md:pb-8 pt-14"
|
||||
)}>
|
||||
<div class={cn(store.isNotepadMode ? "hidden" : "block")}>
|
||||
<Show when={props.currentView.toLowerCase() !== "settings"}>
|
||||
<FilterBar />
|
||||
</Show>
|
||||
{props.children}
|
||||
</div>
|
||||
<div class={cn(!store.isNotepadMode ? "hidden" : "block", "h-[calc(100vh-3.5rem)] md:h-[calc(100vh-8rem)] w-full")}>
|
||||
<Suspense>
|
||||
<NotepadView selectedNoteId={selectedNoteId()} setSelectedNoteId={setSelectedNoteId} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
{/* Mobile Bottom Nav */}
|
||||
<BottomNav currentView={props.currentView} setView={props.setView} />
|
||||
<div class={cn("md:hidden", store.isNotepadMode ? "hidden" : "block")}>
|
||||
<BottomNav currentView={props.currentView} setView={props.setView} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Suspense>
|
||||
@@ -94,6 +229,6 @@ export const Layout: Component<LayoutProps> = (props) => {
|
||||
/>
|
||||
</Suspense>
|
||||
</Show>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type Component, For, Show } from "solid-js";
|
||||
import { LayoutDashboard, ListTodo, Settings, Clock, ArrowUpCircle, PanelLeftClose, Snowflake, Pickaxe, ChevronDown, Gauge, TrendingUp, Users, Box, BarChart3 } from "lucide-solid";
|
||||
import { LayoutDashboard, ListTodo, Settings, Clock, ArrowUpCircle, PanelLeftClose, Snowflake, Pickaxe, ChevronDown, Gauge, TrendingUp, Users, Box, BarChart3, HelpCircle } from "lucide-solid";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "./ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
@@ -161,7 +161,7 @@ export const ContextSwitcher: Component<{
|
||||
|
||||
const label = () => {
|
||||
const c = ctx();
|
||||
return c === 'mine' ? "My Workspace" : c.name;
|
||||
return c === 'mine' ? "My Bucket" : c.name;
|
||||
};
|
||||
|
||||
const handleSwitch = (id: string, name: string, type: 'mine' | 'user' | 'bucket' = 'mine') => {
|
||||
@@ -229,15 +229,15 @@ export const ContextSwitcher: Component<{
|
||||
<div class="w-4 h-4 rounded-full bg-primary/20 flex items-center justify-center">
|
||||
<div class="w-2 h-2 rounded-full bg-primary" />
|
||||
</div>
|
||||
My Workspace
|
||||
My Bucket
|
||||
</button>
|
||||
<Show when={store.subscribedBuckets.length > 0}>
|
||||
<div class="py-1">
|
||||
<div class="h-px bg-border/50" />
|
||||
</div>
|
||||
<div class="px-2 py-1 text-[0.625rem] font-bold uppercase text-muted-foreground tracking-wider flex items-center gap-2">
|
||||
<span>Buckets</span>
|
||||
<span class="ml-auto bg-muted px-1.5 rounded text-[0.5625rem]">{store.subscribedBuckets.length}</span>
|
||||
<span>Pinned Global Buckets</span>
|
||||
<span class="ml-auto bg-muted px-1.5 rounded text-[0.5625rem]">{store.buckets.filter(b => store.subscribedBuckets.includes(b.id)).length}</span>
|
||||
</div>
|
||||
<For each={store.buckets.filter(b => store.subscribedBuckets.includes(b.id))}>
|
||||
{(bucket) => (
|
||||
@@ -265,7 +265,7 @@ export const ContextSwitcher: Component<{
|
||||
<div class="h-px bg-border/50" />
|
||||
</div>
|
||||
<div class="px-2 py-1 text-[0.625rem] font-bold uppercase text-muted-foreground tracking-wider flex items-center gap-2">
|
||||
<span>Oversight</span>
|
||||
<span>Shared User Buckets</span>
|
||||
<span class="ml-auto bg-muted px-1.5 rounded text-[0.5625rem]">{oversightUsers()?.length}</span>
|
||||
</div>
|
||||
<For each={oversightUsers()}>
|
||||
@@ -299,6 +299,8 @@ export const Sidebar: Component<{
|
||||
setIsLocked: (v: boolean) => void;
|
||||
isPeeking: boolean;
|
||||
setIsPeeking: (v: boolean) => void;
|
||||
isEmbed?: boolean;
|
||||
onDropdownOpenChange?: (open: boolean) => void;
|
||||
}> = (props) => {
|
||||
const [switcherOpen, setSwitcherOpen] = createSignal(false);
|
||||
|
||||
@@ -311,9 +313,11 @@ export const Sidebar: Component<{
|
||||
}
|
||||
}}
|
||||
class={cn(
|
||||
"hidden md:flex flex-col border-r border-border bg-card h-screen fixed left-0 top-0 transition-all duration-300 ease-in-out z-[50]",
|
||||
props.isLocked ? "w-48 translate-x-0" : "w-48 -translate-x-full",
|
||||
!props.isLocked && props.isPeeking && "translate-x-0 shadow-2xl ring-1 ring-border"
|
||||
"hidden md:flex flex-col border-r border-border bg-card h-screen z-[50]",
|
||||
!props.isEmbed && "fixed left-0 top-0 transition-all duration-150 ease-in-out",
|
||||
!props.isEmbed && (props.isLocked ? "w-48 translate-x-0" : "w-48 -translate-x-full"),
|
||||
!props.isEmbed && !props.isLocked && props.isPeeking && "translate-x-0 shadow-2xl ring-1 ring-border",
|
||||
props.isEmbed && "w-full h-full border-r-0 bg-transparent"
|
||||
)}
|
||||
>
|
||||
<div class={cn("p-4 flex items-center justify-between", !props.isLocked && "pl-14")}>
|
||||
@@ -371,11 +375,26 @@ export const Sidebar: Component<{
|
||||
isLocked={props.isLocked}
|
||||
isPeeking={props.isPeeking}
|
||||
setIsPeeking={props.setIsPeeking}
|
||||
onOpenChange={setSwitcherOpen}
|
||||
onOpenChange={(open) => {
|
||||
setSwitcherOpen(open);
|
||||
props.onDropdownOpenChange?.(open);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="p-3 border-t border-border mt-auto">
|
||||
<div class="px-3 border-t border-border mt-auto pt-3 space-y-1 pb-3">
|
||||
<button
|
||||
onClick={() => props.setView("help")}
|
||||
class={cn(
|
||||
"flex items-center space-x-3 w-full px-3 py-2.5 rounded-lg transition-all duration-200 group text-sm",
|
||||
props.currentView === "help"
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<HelpCircle size={16} class={cn("transition-transform group-hover:scale-110")} />
|
||||
<span class="font-medium">Help Guide</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => props.setView("settings")}
|
||||
class={cn(
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { type Component, createSignal, createMemo, For, Show } from "solid-js";
|
||||
import { store } from "@/store";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { Plus, Lock, Search } from "lucide-solid";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { NOTES_COLLECTION } from "@/lib/constants";
|
||||
|
||||
export const NotesSidebar: Component<{
|
||||
selectedNoteId: string | null;
|
||||
setSelectedNoteId: (id: string | null) => void;
|
||||
}> = (props) => {
|
||||
const [searchQuery, setSearchQuery] = createSignal("");
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
|
||||
const filteredNotes = createMemo(() => {
|
||||
let n = store.notes;
|
||||
if (searchQuery()) {
|
||||
const q = searchQuery().toLowerCase();
|
||||
n = n.filter(note => note.title.toLowerCase().includes(q) || note.tags.some(t => t.toLowerCase().includes(q)));
|
||||
}
|
||||
return n;
|
||||
});
|
||||
|
||||
const handleCreateNote = async () => {
|
||||
if (!currentUserId) return;
|
||||
try {
|
||||
const result = await pb.collection(NOTES_COLLECTION).create({
|
||||
title: "New Note",
|
||||
content: "",
|
||||
tags: [],
|
||||
isPrivate: false,
|
||||
user: currentUserId,
|
||||
});
|
||||
props.setSelectedNoteId(result.id);
|
||||
} catch (e) {
|
||||
console.error("Failed to create note", e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full bg-card">
|
||||
{/* Header */}
|
||||
<div class="p-4 border-b border-border bg-card/50 backdrop-blur flex justify-between items-center gap-2">
|
||||
<div class="relative flex-1">
|
||||
<Search size={14} class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search notes..."
|
||||
value={searchQuery()}
|
||||
onInput={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
class="w-full bg-muted/50 border border-border/50 rounded-lg pl-8 pr-3 py-1.5 text-xs outline-none focus:ring-1 focus:ring-primary/30"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleCreateNote} size="sm" variant="default" class="h-8 w-8 p-0 shrink-0 shadow-sm">
|
||||
<Plus size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div class="flex-1 overflow-y-auto p-2 space-y-1 pb-20 md:pb-2">
|
||||
<For each={filteredNotes()} fallback={<div class="p-4 text-center text-xs text-muted-foreground">No notes found.</div>}>
|
||||
{(note) => (
|
||||
<button
|
||||
onClick={() => props.setSelectedNoteId(note.id)}
|
||||
class={cn(
|
||||
"w-full text-left p-3 rounded-xl transition-all border",
|
||||
props.selectedNoteId === note.id
|
||||
? "bg-primary/5 border-primary/20 shadow-sm"
|
||||
: "bg-card border-transparent hover:border-border hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<div class="font-semibold text-sm truncate flex items-center justify-between">
|
||||
<span>{note.title || "Untitled"}</span>
|
||||
<Show when={note.isPrivate}>
|
||||
<Lock size={12} class="text-orange-500 shrink-0 ml-2" />
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={note.tags && note.tags.length > 0}>
|
||||
<div class="flex gap-1 mt-2 flex-wrap">
|
||||
<For each={note.tags.slice(0, 3)}>
|
||||
{(t) => (
|
||||
<span class="text-[0.6rem] px-1.5 py-0.5 bg-muted rounded-md text-muted-foreground whitespace-nowrap border border-border/50">
|
||||
{t}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
<Show when={note.tags.length > 3}>
|
||||
<span class="text-[0.6rem] px-1.5 py-0.5 text-muted-foreground whitespace-nowrap shrink-0">
|
||||
+{note.tags.length - 3}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+50
-486
@@ -1,126 +1,48 @@
|
||||
import { type Component, createSignal, createEffect, onCleanup, onMount, For, Show } from "solid-js";
|
||||
import { addTask, calculateDateFromUrgency, calculateUrgencyFromDate, store, upsertTagDefinition, currentTaskContext, type TaskTemplate } from "@/store";
|
||||
import { Search, Command, Clock, ArrowUpCircle, Plus, Copy, ChevronDown, Gauge, X } from "lucide-solid";
|
||||
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";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { TextField, TextFieldInput } from "@/components/ui/textfield";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||
import { TagPicker } from "@/components/TagPicker";
|
||||
import { useTheme } from "./ThemeProvider";
|
||||
import { getThemeAdjustedColor } from "@/lib/colors";
|
||||
import { lazy, Suspense } from "solid-js";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { toast } from "solid-sonner";
|
||||
import { PRIORITY_OPTIONS, URGENCY_OPTIONS, SIZE_OPTIONS } from "@/lib/constants";
|
||||
|
||||
const TaskEditor = lazy(() => import("./TaskEditor").then(m => ({ default: m.TaskEditor })));
|
||||
import { cn } from "@/lib/utils";
|
||||
import { QuickEntryForm } from "./QuickEntryForm";
|
||||
|
||||
export const QuickEntry: Component = () => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
// Diff tracking for reactive tags
|
||||
let lastParsedTags: string[] = [];
|
||||
|
||||
const [isOpen, setIsOpen] = createSignal(false);
|
||||
const [input, setInput] = createSignal("");
|
||||
|
||||
const [priority, setPriority] = createSignal<string>("");
|
||||
const [urgency, setUrgency] = createSignal<string>("");
|
||||
const [tags, setTags] = createSignal<string[]>([]);
|
||||
const [description, setDescription] = createSignal("");
|
||||
const [editorInstance, setEditorInstance] = createSignal<any>(null);
|
||||
const [size, setSize] = createSignal<string>("5");
|
||||
|
||||
// Autofill tags based on context when opening
|
||||
createEffect(() => {
|
||||
if (isOpen()) {
|
||||
const ctx = currentTaskContext();
|
||||
if (typeof ctx === 'object') {
|
||||
if ('bucketId' in ctx) {
|
||||
// Bucket View: Autofill bucket name
|
||||
const bucketName = ctx.name;
|
||||
if (bucketName && !tags().includes(bucketName)) {
|
||||
setTags(prev => [...prev, 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().includes(userName)) {
|
||||
setTags(prev => [...prev, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Reactive shorthand parsing
|
||||
createEffect(() => {
|
||||
const text = input();
|
||||
|
||||
const pMatch = text.match(/\/p(\d+)/);
|
||||
if (pMatch) {
|
||||
const val = Math.min(10, Math.max(1, parseInt(pMatch[1]))).toString();
|
||||
setPriority(val);
|
||||
}
|
||||
|
||||
const uMatch = text.match(/\/u(\d+)/);
|
||||
if (uMatch) {
|
||||
const val = Math.min(10, Math.max(1, parseInt(uMatch[1]))).toString();
|
||||
if (val !== urgency()) {
|
||||
onUrgencySimpleChange(val);
|
||||
} 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Tag Shortcut Parsing: /t [tag name]:[optional value]
|
||||
// This is reactive. To avoid "prefix storm", only add if terminated by : or /
|
||||
// Tag Shortcut Parsing: /t [tag name]:[optional value]
|
||||
// Regex: /t, name (no : or /), lookahead for terminator (: or /), optional value group
|
||||
const tagRegex = /\/t\s*([^:/]+)(?=(?:[:/]))(?:[:](\d+))?/g;
|
||||
|
||||
const newParsedTags: string[] = [];
|
||||
let match;
|
||||
|
||||
while ((match = tagRegex.exec(text)) !== null) {
|
||||
const tagName = match[1].trim();
|
||||
if (tagName) {
|
||||
newParsedTags.push(tagName);
|
||||
}
|
||||
}
|
||||
|
||||
const currentTags = [...tags()];
|
||||
// Determine changes
|
||||
const tagsToRemove = lastParsedTags.filter(t => !newParsedTags.includes(t));
|
||||
const tagsToAdd = newParsedTags.filter(t => !currentTags.includes(t));
|
||||
|
||||
// Apply changes
|
||||
let nextTags = currentTags.filter(t => !tagsToRemove.includes(t));
|
||||
nextTags = [...nextTags, ...tagsToAdd];
|
||||
|
||||
if (JSON.stringify(nextTags) !== JSON.stringify(currentTags)) {
|
||||
setTags(nextTags);
|
||||
}
|
||||
|
||||
lastParsedTags = newParsedTags;
|
||||
});
|
||||
|
||||
// Explicit date string for the input (YYYY-MM-DDTHH:mm)
|
||||
const [dateString, setDateString] = createSignal<string>("");
|
||||
let inputRef: HTMLInputElement | undefined;
|
||||
|
||||
createEffect(() => {
|
||||
if (isOpen()) {
|
||||
// Slight delay to ensure DOM is ready and animations don't interfere
|
||||
setTimeout(() => inputRef?.focus(), 50);
|
||||
}
|
||||
return tags;
|
||||
});
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
e.preventDefault();
|
||||
if (isOpen()) {
|
||||
inputRef?.focus();
|
||||
} else {
|
||||
setIsOpen(true);
|
||||
}
|
||||
setIsOpen(prev => !prev);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setIsOpen(false);
|
||||
@@ -130,151 +52,15 @@ export const QuickEntry: Component = () => {
|
||||
onMount(() => window.addEventListener("keydown", handleKeyDown));
|
||||
onCleanup(() => window.removeEventListener("keydown", handleKeyDown));
|
||||
|
||||
// When urgency changes via dropdown, update the date
|
||||
const onUrgencySimpleChange = (val: any) => {
|
||||
const value = typeof val === 'object' ? val.value : val;
|
||||
if (!value) return;
|
||||
|
||||
setUrgency(value);
|
||||
const level = parseInt(value);
|
||||
if (!isNaN(level)) {
|
||||
const newDateIso = calculateDateFromUrgency(level);
|
||||
// Format for datetime-local input: YYYY-MM-DDTHH:mm
|
||||
const dateObj = new Date(newDateIso);
|
||||
const localIso = new Date(dateObj.getTime() - (dateObj.getTimezoneOffset() * 60000)).toISOString().slice(0, 16);
|
||||
setDateString(localIso);
|
||||
}
|
||||
};
|
||||
|
||||
// When date changes via input, update urgency level
|
||||
const onDateInputChange = (e: Event) => {
|
||||
const val = (e.currentTarget as HTMLInputElement).value;
|
||||
setDateString(val);
|
||||
if (val) {
|
||||
const level = calculateUrgencyFromDate(new Date(val).toISOString());
|
||||
setUrgency(level.toString());
|
||||
}
|
||||
};
|
||||
|
||||
const parseAndAdd = async () => {
|
||||
let text = input();
|
||||
if (!text || !priority() || !urgency()) return;
|
||||
|
||||
let finalPriority = parseInt(priority());
|
||||
let finalUrgency = parseInt(urgency());
|
||||
const finalTags = [...tags()];
|
||||
|
||||
// 1. Handle Priority / Urgency and clean them from text
|
||||
const pMatch = text.match(/\/p\s*(\d+)/);
|
||||
if (pMatch) {
|
||||
finalPriority = Math.min(10, Math.max(1, parseInt(pMatch[1])));
|
||||
text = text.replace(pMatch[0], "");
|
||||
}
|
||||
|
||||
const uMatch = text.match(/\/u\s*(\d+)/);
|
||||
if (uMatch) {
|
||||
finalUrgency = Math.min(10, Math.max(1, parseInt(uMatch[1])));
|
||||
text = text.replace(uMatch[0], "");
|
||||
}
|
||||
|
||||
// 2. Handle Tags: /t [name]:[value]
|
||||
const segments = text.split(/\/t\s*/);
|
||||
let title = segments[0];
|
||||
|
||||
for (let i = 1; i < segments.length; i++) {
|
||||
const seg = segments[i];
|
||||
const firstColon = seg.indexOf(':');
|
||||
let tagName = "";
|
||||
let tagValue: number | undefined;
|
||||
let remainingText = "";
|
||||
|
||||
if (firstColon === -1) {
|
||||
tagName = seg.trim();
|
||||
} else {
|
||||
tagName = seg.slice(0, firstColon).trim();
|
||||
const afterColon = seg.slice(firstColon + 1);
|
||||
const valueMatch = afterColon.match(/^\s*(\d+)/);
|
||||
|
||||
if (valueMatch) {
|
||||
tagValue = Math.min(10, Math.max(0, parseInt(valueMatch[1])));
|
||||
remainingText = afterColon.slice(valueMatch[0].length);
|
||||
} else {
|
||||
remainingText = afterColon;
|
||||
}
|
||||
}
|
||||
|
||||
if (tagName) {
|
||||
if (!finalTags.includes(tagName)) {
|
||||
finalTags.push(tagName);
|
||||
}
|
||||
if (tagValue !== undefined) {
|
||||
// update global definition
|
||||
await upsertTagDefinition(tagName, tagValue, undefined, resolvedTheme());
|
||||
}
|
||||
}
|
||||
title += " " + remainingText;
|
||||
}
|
||||
|
||||
let finalDueDate: Date | null = null;
|
||||
if (dateString()) {
|
||||
finalDueDate = new Date(dateString());
|
||||
} else {
|
||||
const u = finalUrgency;
|
||||
const dateStr = calculateDateFromUrgency(u);
|
||||
finalDueDate = new Date(dateStr);
|
||||
}
|
||||
|
||||
addTask(title.replace(/\s+/g, " ").trim(), {
|
||||
priority: finalPriority,
|
||||
dueDate: finalDueDate || undefined,
|
||||
tags: finalTags,
|
||||
content: description(),
|
||||
size: parseInt(size())
|
||||
});
|
||||
|
||||
setInput("");
|
||||
setPriority("");
|
||||
setUrgency("");
|
||||
setTags([]);
|
||||
setDescription("");
|
||||
setSize("5");
|
||||
lastParsedTags = [];
|
||||
setDateString("");
|
||||
setIsOpen(false);
|
||||
// Reset editor content
|
||||
const instance = editorInstance();
|
||||
if (instance) instance.commands.setContent("");
|
||||
};
|
||||
|
||||
const applyTemplate = (template: TaskTemplate) => {
|
||||
setInput(template.title);
|
||||
setPriority(template.priority.toString());
|
||||
setUrgency(template.urgency.toString());
|
||||
setTags([...(template.tags || [])]);
|
||||
setDescription(template.content || "");
|
||||
|
||||
// Sync urgency to date
|
||||
const level = template.urgency;
|
||||
const newDateIso = calculateDateFromUrgency(level);
|
||||
const dateObj = new Date(newDateIso);
|
||||
const localIso = new Date(dateObj.getTime() - (dateObj.getTimezoneOffset() * 60000)).toISOString().slice(0, 16);
|
||||
setDateString(localIso);
|
||||
|
||||
// Update editor if ready
|
||||
const instance = editorInstance();
|
||||
if (instance) {
|
||||
instance.commands.setContent(template.content || "");
|
||||
}
|
||||
|
||||
toast.info(`Applied template: ${template.name}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={() => setIsOpen(true)}
|
||||
class="fixed bottom-20 left-[80%] -translate-x-1/2 md:bottom-10 md:right-10 md:left-auto md:translate-x-0 w-14 h-14 rounded-full shadow-2xl z-50 group hover:scale-110 transition-all duration-300"
|
||||
class={cn(
|
||||
"fixed bottom-20 left-[80%] -translate-x-1/2 md:bottom-10 md:right-10 md:left-auto md:translate-x-0 w-14 h-14 rounded-full shadow-2xl z-50 group hover:scale-110 transition-all duration-300",
|
||||
store.isNotepadMode ? "hidden md:flex" : "flex"
|
||||
)}
|
||||
>
|
||||
<Plus size={28} class="group-hover:rotate-90 transition-transform duration-300" />
|
||||
</Button>
|
||||
@@ -284,245 +70,23 @@ export const QuickEntry: Component = () => {
|
||||
<div class="absolute inset-0 bg-background/80 backdrop-blur-sm" onClick={() => setIsOpen(false)} />
|
||||
|
||||
<div class="relative w-full max-w-xl max-h-[100vh] md:max-h-[80vh] bg-card border border-border rounded-t-2xl md:rounded-2xl shadow-2xl overflow-hidden animate-in fade-in slide-in-from-bottom-4 md:zoom-in-95 duration-200 flex flex-col">
|
||||
{/* Scrollable content area - includes header so entire card can scroll on mobile */}
|
||||
<div class="flex-1 overflow-y-auto overscroll-contain">
|
||||
<div class="flex items-center px-4 py-3 border-b border-border sticky top-0 bg-card z-10">
|
||||
<Search class="text-muted-foreground mr-3" size={20} />
|
||||
<TextField class="flex-1">
|
||||
<TextFieldInput
|
||||
id="quick-entry-input"
|
||||
name="task-title"
|
||||
ref={inputRef}
|
||||
value={input()}
|
||||
onInput={(e) => setInput(e.currentTarget.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && parseAndAdd()}
|
||||
placeholder="Task title... type /p1-10 for priority and /u1-10 for urgency"
|
||||
class="border-none shadow-none focus-visible:ring-0 text-lg"
|
||||
/>
|
||||
</TextField>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => setIsOpen(false)}
|
||||
class="ml-2 md:hidden h-9 w-9 rounded-full text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
>
|
||||
<X size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
{/* Row 1: Priority, Size, Urgency */}
|
||||
<div class="flex flex-col md:flex-row flex-wrap items-stretch md:items-center p-4 gap-4 border-b border-border bg-muted/10">
|
||||
<div class="flex-1 min-w-0 md:min-w-[140px] space-y-1.5">
|
||||
<label class="text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground ml-1">
|
||||
Priority <span class="text-destructive">*</span>
|
||||
</label>
|
||||
<Select<any>
|
||||
value={priority()}
|
||||
onChange={(val) => {
|
||||
const v = typeof val === 'object' ? val.value : val;
|
||||
if (v) setPriority(v);
|
||||
}}
|
||||
options={PRIORITY_OPTIONS}
|
||||
optionValue="value"
|
||||
optionTextValue="label"
|
||||
placeholder="Priority"
|
||||
itemComponent={(props) => (
|
||||
<SelectItem item={props.item}>
|
||||
{props.item.rawValue.label}
|
||||
</SelectItem>
|
||||
)}
|
||||
>
|
||||
<SelectTrigger class="w-full bg-background border-none shadow-sm h-10 px-3 overflow-hidden">
|
||||
<div class="flex items-center gap-2 truncate">
|
||||
<ArrowUpCircle size={14} class="text-primary shrink-0" />
|
||||
<span class="text-sm font-medium truncate">
|
||||
{PRIORITY_OPTIONS.find(o => o.value === priority())?.label || (priority() === "" ? "Pick Priority" : priority())}
|
||||
</span>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0 md:min-w-[140px] space-y-1.5">
|
||||
<label class="text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground ml-1">Size</label>
|
||||
<Select<any>
|
||||
value={size()}
|
||||
onChange={(val) => {
|
||||
const v = typeof val === 'object' ? val.value : val;
|
||||
if (v) setSize(v);
|
||||
}}
|
||||
options={SIZE_OPTIONS}
|
||||
optionValue="value"
|
||||
optionTextValue="label"
|
||||
placeholder="Size"
|
||||
itemComponent={(props) => (
|
||||
<SelectItem item={props.item}>
|
||||
{props.item.rawValue.label}
|
||||
</SelectItem>
|
||||
)}
|
||||
>
|
||||
<SelectTrigger class="w-full bg-background border-none shadow-sm h-10 px-3 overflow-hidden">
|
||||
<div class="flex items-center gap-2 truncate">
|
||||
<Gauge size={14} class="text-primary shrink-0" />
|
||||
<span class="text-sm font-medium truncate">
|
||||
{SIZE_OPTIONS.find(o => o.value === size())?.label || size()}
|
||||
</span>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0 md:min-w-[140px] space-y-1.5">
|
||||
<label class="text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground ml-1">
|
||||
Urgency <span class="text-destructive">*</span>
|
||||
</label>
|
||||
<Select<any>
|
||||
value={urgency()}
|
||||
onChange={(val) => onUrgencySimpleChange(val)}
|
||||
options={URGENCY_OPTIONS}
|
||||
optionValue="value"
|
||||
optionTextValue="label"
|
||||
placeholder="Urgency"
|
||||
itemComponent={(props) => (
|
||||
<SelectItem item={props.item}>
|
||||
{props.item.rawValue.label}
|
||||
</SelectItem>
|
||||
)}
|
||||
>
|
||||
<SelectTrigger class="w-full bg-background border-none shadow-sm h-10 px-3 overflow-hidden">
|
||||
<div class="flex items-center gap-2 truncate">
|
||||
<Clock size={14} class="text-primary shrink-0" />
|
||||
<span class="text-sm font-medium truncate">
|
||||
{URGENCY_OPTIONS.find(o => o.value === urgency())?.label || (urgency() === "" ? "Pick Urgency" : urgency())}
|
||||
</span>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Tags + Due Date */}
|
||||
<div class="flex flex-col md:flex-row items-stretch md:items-center px-4 py-3 gap-4 bg-muted/10 border-b border-border">
|
||||
<div class="flex-1 flex flex-wrap gap-2 items-center">
|
||||
<TagPicker selectedTags={tags()} onTagsChange={setTags} />
|
||||
<For each={tags()}>
|
||||
{(tag) => (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="h-7 px-2 text-xs gap-1 bg-background border border-border/50 cursor-pointer hover:bg-destructive/10 hover:border-destructive/30 group/tag transition-all"
|
||||
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={() => setTags(tags().filter(t => t !== tag))}
|
||||
>
|
||||
<div
|
||||
class="w-1.5 h-1.5 rounded-full transition-colors group-hover/tag:bg-destructive"
|
||||
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}
|
||||
</Badge>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div class="w-full md:w-auto md:min-w-[200px] space-y-1.5">
|
||||
<label class="text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground ml-1">Due Date</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={dateString()}
|
||||
onInput={onDateInputChange}
|
||||
onClick={(e) => (e.target as HTMLInputElement).showPicker?.()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Tab" && e.key !== "Escape") {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
step="900"
|
||||
class="w-full h-10 px-3 bg-background border-none rounded-md text-sm shadow-sm focus:outline-none focus:ring-1 focus:ring-ring z-[102] relative cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-3 bg-card min-h-[80px]">
|
||||
<Suspense fallback={<div class="h-20 animate-pulse bg-muted/20 rounded-lg" />}>
|
||||
<TaskEditor
|
||||
content={description()}
|
||||
onUpdate={setDescription}
|
||||
onEditorReady={setEditorInstance}
|
||||
class="min-h-[60px] prose-sm"
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
<div class="absolute top-3 right-4 z-[101]">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => setIsOpen(false)}
|
||||
class="md:hidden h-9 w-9 rounded-full text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
>
|
||||
<X size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-muted/30 flex justify-between items-center shrink-0 border-t border-border">
|
||||
<div class="flex items-center space-x-2 text-[0.625rem] font-bold text-muted-foreground uppercase tracking-widest">
|
||||
<Command size={10} />
|
||||
<span class="hidden sm:inline">K to focus • Enter to Add</span>
|
||||
<span class="sm:hidden">Enter to Add</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={(store.templates || []).length > 0}>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
as={Button}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-9 px-3 gap-2 hover:bg-primary/10 hover:text-primary text-muted-foreground transition-all rounded-xl border border-border/40"
|
||||
>
|
||||
<Copy size={14} />
|
||||
<span class="text-[0.625rem] font-bold uppercase tracking-wider hidden sm:inline">Use Template</span>
|
||||
<ChevronDown size={12} class="opacity-50" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-64 p-2 bg-card border-border shadow-xl">
|
||||
<div class="space-y-1">
|
||||
<For each={store.templates}>
|
||||
{(template) => (
|
||||
<button
|
||||
class="w-full flex items-center gap-3 p-2 rounded-lg hover:bg-muted transition-colors text-left group"
|
||||
onClick={() => applyTemplate(template)}
|
||||
>
|
||||
<div class="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center text-primary shrink-0 group-hover:bg-primary group-hover:text-primary-foreground transition-all">
|
||||
<Copy size={14} />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-bold truncate tracking-tight">{template.name}</p>
|
||||
<p class="text-[0.5625rem] uppercase font-black text-muted-foreground opacity-60 tracking-widest">
|
||||
P:{template.priority} / U:{template.urgency}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Show>
|
||||
|
||||
<Button size="sm" onClick={parseAndAdd} disabled={!input() || !priority() || !urgency()}>
|
||||
Create Task
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<QuickEntryForm
|
||||
onSuccess={() => setIsOpen(false)}
|
||||
autoFocus={true}
|
||||
initialTags={initialTags()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
import { type Component, createSignal, createEffect, onMount, For, Show, Suspense, lazy } from "solid-js";
|
||||
import { addTask, calculateDateFromUrgency, calculateUrgencyFromDate, store, upsertTagDefinition, type TaskTemplate } from "@/store";
|
||||
import { Search, Command, Clock, ArrowUpCircle, Copy, ChevronDown, Gauge } from "lucide-solid";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { TextField, TextFieldInput } from "@/components/ui/textfield";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||
import { TagPicker } from "@/components/TagPicker";
|
||||
import { useTheme } from "./ThemeProvider";
|
||||
import { getThemeAdjustedColor } from "@/lib/colors";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { toast } from "solid-sonner";
|
||||
import { PRIORITY_OPTIONS, URGENCY_OPTIONS, SIZE_OPTIONS } from "@/lib/constants";
|
||||
|
||||
const TaskEditor = lazy(() => import("./TaskEditor").then(m => ({ default: m.TaskEditor })));
|
||||
|
||||
interface QuickEntryFormProps {
|
||||
onSuccess?: () => void;
|
||||
autoFocus?: boolean;
|
||||
initialTitle?: string;
|
||||
initialTags?: string[];
|
||||
}
|
||||
|
||||
export const QuickEntryForm: Component<QuickEntryFormProps> = (props) => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
// Diff tracking for reactive tags
|
||||
let lastParsedTags: string[] = [];
|
||||
|
||||
const [input, setInput] = createSignal(props.initialTitle || "");
|
||||
const [priority, setPriority] = createSignal<string>("");
|
||||
const [urgency, setUrgency] = createSignal<string>("");
|
||||
const [tags, setTags] = createSignal<string[]>(props.initialTags || []);
|
||||
const [description, setDescription] = createSignal("");
|
||||
const [editorInstance, setEditorInstance] = createSignal<any>(null);
|
||||
const [size, setSize] = createSignal<string>("3");
|
||||
const [dateString, setDateString] = createSignal<string>("");
|
||||
|
||||
let inputRef: HTMLInputElement | undefined;
|
||||
|
||||
onMount(() => {
|
||||
if (props.autoFocus) {
|
||||
setTimeout(() => inputRef?.focus(), 100);
|
||||
}
|
||||
});
|
||||
|
||||
// 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();
|
||||
|
||||
const pMatch = text.match(/\/p(\d+)/);
|
||||
if (pMatch) {
|
||||
const val = Math.min(10, Math.max(1, parseInt(pMatch[1]))).toString();
|
||||
setPriority(val);
|
||||
}
|
||||
|
||||
const uMatch = text.match(/\/u(\d+)/);
|
||||
if (uMatch) {
|
||||
const val = Math.min(10, Math.max(1, parseInt(uMatch[1]))).toString();
|
||||
if (val !== urgency()) {
|
||||
onUrgencySimpleChange(val);
|
||||
}
|
||||
}
|
||||
|
||||
const tagRegex = /\/t\s*([^:/]+)(?=(?:[:/]))(?:[:](\d+))?/g;
|
||||
const newParsedTags: string[] = [];
|
||||
let match;
|
||||
while ((match = tagRegex.exec(text)) !== null) {
|
||||
const tagName = match[1].trim();
|
||||
if (tagName) {
|
||||
newParsedTags.push(tagName);
|
||||
}
|
||||
}
|
||||
|
||||
const currentTags = [...tags()];
|
||||
const tagsToRemove = lastParsedTags.filter(t => !newParsedTags.includes(t));
|
||||
const tagsToAdd = newParsedTags.filter(t => !currentTags.includes(t));
|
||||
|
||||
let nextTags = currentTags.filter(t => !tagsToRemove.includes(t));
|
||||
nextTags = [...nextTags, ...tagsToAdd];
|
||||
|
||||
if (JSON.stringify(nextTags) !== JSON.stringify(currentTags)) {
|
||||
setTags(nextTags);
|
||||
}
|
||||
lastParsedTags = newParsedTags;
|
||||
});
|
||||
|
||||
const onUrgencySimpleChange = (val: any) => {
|
||||
const value = typeof val === 'object' ? val.value : val;
|
||||
if (!value) return;
|
||||
|
||||
setUrgency(value);
|
||||
const level = parseInt(value);
|
||||
if (!isNaN(level)) {
|
||||
const newDateIso = calculateDateFromUrgency(level);
|
||||
const dateObj = new Date(newDateIso);
|
||||
const localIso = new Date(dateObj.getTime() - (dateObj.getTimezoneOffset() * 60000)).toISOString().slice(0, 16);
|
||||
setDateString(localIso);
|
||||
}
|
||||
};
|
||||
|
||||
const onDateInputChange = (e: Event) => {
|
||||
const val = (e.currentTarget as HTMLInputElement).value;
|
||||
setDateString(val);
|
||||
if (val) {
|
||||
const level = calculateUrgencyFromDate(new Date(val).toISOString());
|
||||
setUrgency(level.toString());
|
||||
}
|
||||
};
|
||||
|
||||
const parseAndAdd = async () => {
|
||||
let text = input();
|
||||
if (!text || !priority() || !urgency()) return;
|
||||
|
||||
let finalPriority = parseInt(priority());
|
||||
let finalUrgency = parseInt(urgency());
|
||||
const finalTags = [...tags()];
|
||||
|
||||
const pMatch = text.match(/\/p\s*(\d+)/);
|
||||
if (pMatch) {
|
||||
finalPriority = Math.min(10, Math.max(1, parseInt(pMatch[1])));
|
||||
text = text.replace(pMatch[0], "");
|
||||
}
|
||||
|
||||
const uMatch = text.match(/\/u\s*(\d+)/);
|
||||
if (uMatch) {
|
||||
finalUrgency = Math.min(10, Math.max(1, parseInt(uMatch[1])));
|
||||
text = text.replace(uMatch[0], "");
|
||||
}
|
||||
|
||||
const segments = text.split(/\/t\s*/);
|
||||
let title = segments[0];
|
||||
|
||||
for (let i = 1; i < segments.length; i++) {
|
||||
const seg = segments[i];
|
||||
const firstColon = seg.indexOf(':');
|
||||
let tagName = "";
|
||||
let tagValue: number | undefined;
|
||||
let remainingText = "";
|
||||
|
||||
if (firstColon === -1) {
|
||||
tagName = seg.trim();
|
||||
} else {
|
||||
tagName = seg.slice(0, firstColon).trim();
|
||||
const afterColon = seg.slice(firstColon + 1);
|
||||
const valueMatch = afterColon.match(/^\s*(\d+)/);
|
||||
|
||||
if (valueMatch) {
|
||||
tagValue = Math.min(10, Math.max(0, parseInt(valueMatch[1])));
|
||||
remainingText = afterColon.slice(valueMatch[0].length);
|
||||
} else {
|
||||
remainingText = afterColon;
|
||||
}
|
||||
}
|
||||
|
||||
if (tagName) {
|
||||
if (!finalTags.includes(tagName)) {
|
||||
finalTags.push(tagName);
|
||||
}
|
||||
if (tagValue !== undefined) {
|
||||
await upsertTagDefinition(tagName, tagValue, undefined, resolvedTheme());
|
||||
}
|
||||
}
|
||||
title += " " + remainingText;
|
||||
}
|
||||
|
||||
let finalDueDate: Date | null = null;
|
||||
if (dateString()) {
|
||||
finalDueDate = new Date(dateString());
|
||||
} else {
|
||||
const u = finalUrgency;
|
||||
const dateStr = calculateDateFromUrgency(u);
|
||||
finalDueDate = new Date(dateStr);
|
||||
}
|
||||
|
||||
addTask(title.replace(/\s+/g, " ").trim(), {
|
||||
priority: finalPriority,
|
||||
dueDate: finalDueDate || undefined,
|
||||
tags: finalTags,
|
||||
content: description(),
|
||||
size: parseInt(size())
|
||||
});
|
||||
|
||||
setInput("");
|
||||
setPriority("");
|
||||
setUrgency("");
|
||||
setTags([]);
|
||||
setDescription("");
|
||||
setSize("3");
|
||||
lastParsedTags = [];
|
||||
setDateString("");
|
||||
|
||||
const instance = editorInstance();
|
||||
if (instance) instance.commands.setContent("");
|
||||
|
||||
if (props.onSuccess) props.onSuccess();
|
||||
};
|
||||
|
||||
const applyTemplate = (template: TaskTemplate) => {
|
||||
setInput(template.title);
|
||||
setPriority(template.priority.toString());
|
||||
setUrgency(template.urgency.toString());
|
||||
setTags([...(template.tags || [])]);
|
||||
setDescription(template.content || "");
|
||||
|
||||
const level = template.urgency;
|
||||
const newDateIso = calculateDateFromUrgency(level);
|
||||
const dateObj = new Date(newDateIso);
|
||||
const localIso = new Date(dateObj.getTime() - (dateObj.getTimezoneOffset() * 60000)).toISOString().slice(0, 16);
|
||||
setDateString(localIso);
|
||||
|
||||
const instance = editorInstance();
|
||||
if (instance) {
|
||||
instance.commands.setContent(template.content || "");
|
||||
}
|
||||
toast.info(`Applied template: ${template.name}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full bg-card">
|
||||
<div class="flex items-center px-4 py-3 border-b border-border sticky top-0 bg-card z-10">
|
||||
<Search class="text-muted-foreground mr-3" size={20} />
|
||||
<TextField class="flex-1">
|
||||
<TextFieldInput
|
||||
id="quick-entry-input"
|
||||
name="task-title"
|
||||
ref={inputRef}
|
||||
value={input()}
|
||||
onInput={(e) => setInput(e.currentTarget.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && parseAndAdd()}
|
||||
placeholder="Task title... type /p1-10 for priority and /u1-10 for urgency"
|
||||
class="border-none shadow-none focus-visible:ring-0 text-lg"
|
||||
/>
|
||||
</TextField>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto overscroll-contain">
|
||||
<div class="flex flex-col md:flex-row flex-wrap items-stretch md:items-center p-4 gap-4 border-b border-border bg-muted/10">
|
||||
<div class="flex-1 min-w-0 md:min-w-[140px] space-y-1.5">
|
||||
<label class="text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground ml-1">
|
||||
Priority <span class="text-destructive">*</span>
|
||||
</label>
|
||||
<Select<any>
|
||||
value={priority()}
|
||||
onChange={(val) => {
|
||||
const v = typeof val === 'object' ? val.value : val;
|
||||
if (v) setPriority(v);
|
||||
}}
|
||||
options={PRIORITY_OPTIONS}
|
||||
optionValue="value"
|
||||
optionTextValue="label"
|
||||
placeholder="Priority"
|
||||
itemComponent={(props) => (
|
||||
<SelectItem item={props.item}>
|
||||
{props.item.rawValue.label}
|
||||
</SelectItem>
|
||||
)}
|
||||
>
|
||||
<SelectTrigger class="w-full bg-background border-none shadow-sm h-10 px-3 overflow-hidden">
|
||||
<div class="flex items-center gap-2 truncate">
|
||||
<ArrowUpCircle size={14} class="text-primary shrink-0" />
|
||||
<span class="text-sm font-medium truncate">
|
||||
{PRIORITY_OPTIONS.find(o => o.value === priority())?.label || (priority() === "" ? "Pick Priority" : priority())}
|
||||
</span>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0 md:min-w-[140px] space-y-1.5">
|
||||
<label class="text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground ml-1">Size</label>
|
||||
<Select<any>
|
||||
value={size()}
|
||||
onChange={(val) => {
|
||||
const v = typeof val === 'object' ? val.value : val;
|
||||
if (v) setSize(v);
|
||||
}}
|
||||
options={SIZE_OPTIONS}
|
||||
optionValue="value"
|
||||
optionTextValue="label"
|
||||
placeholder="Size"
|
||||
itemComponent={(props) => (
|
||||
<SelectItem item={props.item}>
|
||||
{props.item.rawValue.label}
|
||||
</SelectItem>
|
||||
)}
|
||||
>
|
||||
<SelectTrigger class="w-full bg-background border-none shadow-sm h-10 px-3 overflow-hidden">
|
||||
<div class="flex items-center gap-2 truncate">
|
||||
<Gauge size={14} class="text-primary shrink-0" />
|
||||
<span class="text-sm font-medium truncate">
|
||||
{SIZE_OPTIONS.find(o => o.value === size())?.label || size()}
|
||||
</span>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0 md:min-w-[140px] space-y-1.5">
|
||||
<label class="text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground ml-1">
|
||||
Urgency <span class="text-destructive">*</span>
|
||||
</label>
|
||||
<Select<any>
|
||||
value={urgency()}
|
||||
onChange={(val) => onUrgencySimpleChange(val)}
|
||||
options={URGENCY_OPTIONS}
|
||||
optionValue="value"
|
||||
optionTextValue="label"
|
||||
placeholder="Urgency"
|
||||
itemComponent={(props) => (
|
||||
<SelectItem item={props.item}>
|
||||
{props.item.rawValue.label}
|
||||
</SelectItem>
|
||||
)}
|
||||
>
|
||||
<SelectTrigger class="w-full bg-background border-none shadow-sm h-10 px-3 overflow-hidden">
|
||||
<div class="flex items-center gap-2 truncate">
|
||||
<Clock size={14} class="text-primary shrink-0" />
|
||||
<span class="text-sm font-medium truncate">
|
||||
{URGENCY_OPTIONS.find(o => o.value === urgency())?.label || (urgency() === "" ? "Pick Urgency" : urgency())}
|
||||
</span>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row items-stretch md:items-center px-4 py-3 gap-4 bg-muted/10 border-b border-border">
|
||||
<div class="flex-1 flex flex-wrap gap-2 items-center">
|
||||
<TagPicker selectedTags={tags()} onTagsChange={setTags} />
|
||||
<For each={tags()}>
|
||||
{(tag) => (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="h-7 px-2 text-xs gap-1 bg-background border border-border/50 cursor-pointer hover:bg-destructive/10 hover:border-destructive/30 group/tag transition-all"
|
||||
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={() => setTags(tags().filter(t => t !== tag))}
|
||||
>
|
||||
<div
|
||||
class="w-1.5 h-1.5 rounded-full transition-colors group-hover/tag:bg-destructive"
|
||||
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}
|
||||
</Badge>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div class="w-full md:w-auto md:min-w-[200px] space-y-1.5">
|
||||
<label class="text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground ml-1">Due Date</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={dateString()}
|
||||
onInput={onDateInputChange}
|
||||
onClick={(e) => (e.target as HTMLInputElement).showPicker?.()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Tab" && e.key !== "Escape") {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
step="900"
|
||||
class="w-full h-10 px-3 bg-background border-none rounded-md text-sm shadow-sm focus:outline-none focus:ring-1 focus:ring-ring relative cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-3 bg-card min-h-[80px]">
|
||||
<Suspense fallback={<div class="h-20 animate-pulse bg-muted/20 rounded-lg" />}>
|
||||
<TaskEditor
|
||||
content={description()}
|
||||
onUpdate={setDescription}
|
||||
onEditorReady={setEditorInstance}
|
||||
class="min-h-[60px] prose-sm"
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-muted/30 flex justify-between items-center shrink-0 border-t border-border">
|
||||
<div class="flex items-center space-x-2 text-[0.625rem] font-bold text-muted-foreground uppercase tracking-widest">
|
||||
<Command size={10} />
|
||||
<span class="hidden sm:inline">K to focus • Enter to Add</span>
|
||||
<span class="sm:hidden">Enter to Add</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={(store.templates || []).length > 0}>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
as={Button}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-9 px-3 gap-2 hover:bg-primary/10 hover:text-primary text-muted-foreground transition-all rounded-xl border border-border/40"
|
||||
>
|
||||
<Copy size={14} />
|
||||
<span class="text-[0.625rem] font-bold uppercase tracking-wider hidden sm:inline">Use Template</span>
|
||||
<ChevronDown size={12} class="opacity-50" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-64 p-2 bg-card border-border shadow-xl">
|
||||
<div class="space-y-1">
|
||||
<For each={store.templates}>
|
||||
{(template) => (
|
||||
<button
|
||||
class="w-full flex items-center gap-3 p-2 rounded-lg hover:bg-muted transition-colors text-left group"
|
||||
onClick={() => applyTemplate(template)}
|
||||
>
|
||||
<div class="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center text-primary shrink-0 group-hover:bg-primary group-hover:text-primary-foreground transition-all">
|
||||
<Copy size={14} />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-bold truncate tracking-tight">{template.name}</p>
|
||||
<p class="text-[0.5625rem] uppercase font-black text-muted-foreground opacity-60 tracking-widest">
|
||||
P:{template.priority} / U:{template.urgency}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Show>
|
||||
|
||||
<Button size="sm" onClick={parseAndAdd} disabled={!input() || !priority() || !urgency()}>
|
||||
Create Task
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,13 +2,21 @@ import { type Component, createSignal, For, createEffect } from "solid-js";
|
||||
import {
|
||||
Heading1, Heading2, Heading3,
|
||||
List, ListTodo, Type,
|
||||
Code, Quote
|
||||
Code, Quote,
|
||||
Image as ImageIcon, Minus,
|
||||
Bold, Italic, Underline,
|
||||
Grid3X3, Highlighter,
|
||||
AlignLeft, AlignCenter, AlignRight,
|
||||
Trash2
|
||||
} from "lucide-solid";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn, convertImageToWebpFile } from "@/lib/utils";
|
||||
import { activeTaskId, uploadTaskAttachment } from "@/store";
|
||||
import { toast } from "solid-sonner";
|
||||
|
||||
export interface CommandItem {
|
||||
title: string;
|
||||
description: string;
|
||||
aliases?: string[];
|
||||
icon: any;
|
||||
command: (props: { editor: any; range: any }) => void;
|
||||
}
|
||||
@@ -23,9 +31,37 @@ export const getSuggestionItems = ({ query }: { query: string }): CommandItem[]
|
||||
editor.chain().focus().deleteRange(range).setNode("paragraph").run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Bold",
|
||||
description: "Strong text. (**text**)",
|
||||
aliases: ["b", "strong"],
|
||||
icon: Bold,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).toggleBold().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Italic",
|
||||
description: "Emphasized text. (*text*)",
|
||||
aliases: ["i", "emphasis"],
|
||||
icon: Italic,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).toggleItalic().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Underline",
|
||||
description: "Underlined text.",
|
||||
aliases: ["u"],
|
||||
icon: Underline,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).toggleUnderline().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Heading 1",
|
||||
description: "Big section heading.",
|
||||
aliases: ["h1"],
|
||||
icon: Heading1,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
|
||||
@@ -34,6 +70,7 @@ export const getSuggestionItems = ({ query }: { query: string }): CommandItem[]
|
||||
{
|
||||
title: "Heading 2",
|
||||
description: "Medium section heading.",
|
||||
aliases: ["h2"],
|
||||
icon: Heading2,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run();
|
||||
@@ -42,6 +79,7 @@ export const getSuggestionItems = ({ query }: { query: string }): CommandItem[]
|
||||
{
|
||||
title: "Heading 3",
|
||||
description: "Small section heading.",
|
||||
aliases: ["h3"],
|
||||
icon: Heading3,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run();
|
||||
@@ -56,8 +94,8 @@ export const getSuggestionItems = ({ query }: { query: string }): CommandItem[]
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Task List",
|
||||
description: "Track tasks with a checklist.",
|
||||
title: "Checklist",
|
||||
description: "Insert a checklist.",
|
||||
icon: ListTodo,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).toggleTaskList().run();
|
||||
@@ -79,7 +117,165 @@ export const getSuggestionItems = ({ query }: { query: string }): CommandItem[]
|
||||
editor.chain().focus().deleteRange(range).toggleBlockquote().run();
|
||||
},
|
||||
},
|
||||
].filter(item => item.title.toLowerCase().startsWith(query.toLowerCase()));
|
||||
{
|
||||
title: "Image",
|
||||
description: "Upload an image.",
|
||||
icon: ImageIcon,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).run();
|
||||
const taskId = activeTaskId();
|
||||
if (!taskId) {
|
||||
toast.error("Cannot upload image outside of a task.");
|
||||
return;
|
||||
}
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "image/*";
|
||||
input.onchange = async () => {
|
||||
if (input.files?.length) {
|
||||
const originalFile = input.files[0];
|
||||
toast.promise(
|
||||
(async () => {
|
||||
const webpFile = await convertImageToWebpFile(originalFile);
|
||||
return uploadTaskAttachment(taskId, webpFile);
|
||||
})(),
|
||||
{
|
||||
loading: "Processing & uploading image...",
|
||||
success: (url) => {
|
||||
// Insert image, then run enter to add a newline (paragraph) below it.
|
||||
// Without this, the image stays selected and the next keypress deletes it.
|
||||
editor.chain()
|
||||
.focus()
|
||||
.setImage({ src: url })
|
||||
.run();
|
||||
|
||||
// Manually force a new paragraph at the end so the user can keep typing
|
||||
editor.chain()
|
||||
.focus('end')
|
||||
.insertContent('<p></p>')
|
||||
.run();
|
||||
return "Image uploaded.";
|
||||
},
|
||||
error: "Failed to upload image."
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Divider",
|
||||
description: "Insert a horizontal rule.",
|
||||
icon: Minus,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
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) ||
|
||||
item.aliases?.some(alias => alias.toLowerCase().startsWith(lowerQuery));
|
||||
});
|
||||
};
|
||||
|
||||
export const SlashMenu: Component<{
|
||||
|
||||
@@ -3,7 +3,7 @@ import { store, upsertTagDefinition } from "@/store";
|
||||
import { useTheme } from "./ThemeProvider";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Hash, Plus } from "lucide-solid";
|
||||
import { Tag, Plus } from "lucide-solid";
|
||||
|
||||
interface TagPickerProps {
|
||||
selectedTags: string[];
|
||||
@@ -17,10 +17,23 @@ export const TagPicker: Component<TagPickerProps> = (props) => {
|
||||
const [valueScore, setValueScore] = createSignal(5);
|
||||
|
||||
// Filter tags for the "Name" part
|
||||
const localTagNames = () => {
|
||||
const localDef = store.tagDefinitions.filter(d => !d.isUser && !d.isBucket).map(d => d.name);
|
||||
return [...new Set(localDef)].sort();
|
||||
};
|
||||
|
||||
const allTagNames = () => {
|
||||
const definedTags = store.tagDefinitions.map(d => d.name);
|
||||
const bucketTags = store.buckets.map(b => b.name);
|
||||
return [...new Set([...definedTags, ...bucketTags])].sort();
|
||||
const bucketTags = store.buckets.map(b => `@${b.name}`);
|
||||
const noteTags = store.notes.map(n => `#${n.title}`);
|
||||
return [...new Set([...definedTags, ...bucketTags, ...noteTags])].sort();
|
||||
};
|
||||
|
||||
const suggestedTags = () => {
|
||||
if (selectedTagName().trim().length === 0) {
|
||||
return localTagNames();
|
||||
}
|
||||
return allTagNames();
|
||||
};
|
||||
|
||||
// When a tag name is selected, update the value score to match its current definition
|
||||
@@ -39,7 +52,7 @@ export const TagPicker: Component<TagPickerProps> = (props) => {
|
||||
if (!name) return;
|
||||
|
||||
// update global definition
|
||||
const bucket = store.buckets.find(b => b.name === name);
|
||||
const bucket = store.buckets.find(b => b.name === name || `@${b.name}` === name);
|
||||
const color = bucket ? bucket.color : undefined;
|
||||
upsertTagDefinition(name, valueScore(), color, resolvedTheme());
|
||||
|
||||
@@ -58,7 +71,7 @@ export const TagPicker: Component<TagPickerProps> = (props) => {
|
||||
return (
|
||||
<Popover open={open()} onOpenChange={setOpen}>
|
||||
<PopoverTrigger as={Button} variant="ghost" size="sm" class="h-8 border-dashed flex gap-2 items-center text-muted-foreground hover:text-foreground">
|
||||
<Hash size={14} />
|
||||
<Tag size={14} />
|
||||
<span>Add Tag</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="p-3 w-[min(calc(100vw-2rem),280px)] flex flex-col gap-3" align="start">
|
||||
@@ -73,7 +86,7 @@ export const TagPicker: Component<TagPickerProps> = (props) => {
|
||||
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<datalist id="existing-tags">
|
||||
<For each={allTagNames()}>
|
||||
<For each={suggestedTags()}>
|
||||
{(tag) => <option value={tag} />}
|
||||
</For>
|
||||
</datalist>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { type Component, createMemo, For } from "solid-js";
|
||||
import { type Task, calculateUrgencyFromDate, setActiveTaskId, store, copyTask, updateTask } from "@/store";
|
||||
import { type Component, createMemo, For, Show } from "solid-js";
|
||||
import { type Task, calculateUrgencyFromDate, setActiveTaskId, store, copyTask, updateTask, currentTaskContext, now } from "@/store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Clock, ArrowUpCircle, Copy, Users2 } from "lucide-solid";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useTheme } from "./ThemeProvider";
|
||||
import { getThemeAdjustedColor } from "@/lib/colors";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||
import { STATUS_OPTIONS } from "@/lib/constants";
|
||||
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));
|
||||
|
||||
@@ -26,14 +26,48 @@ export const TaskCard: Component<{ task: Task }> = (props) => {
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
// Filter out bucket tags if we are currently INSIDE that bucket view
|
||||
const visibleTags = createMemo(() => {
|
||||
const tags = props.task.tags || [];
|
||||
const ctx = currentTaskContext();
|
||||
|
||||
if (typeof ctx === 'object' && 'bucketId' in ctx) {
|
||||
// We are in a bucket view. Hide the tag that matches this bucket's name.
|
||||
// Buckets are now tagged with '@' + name.
|
||||
const bucketTagName = `@${(ctx as { name: string }).name.toLowerCase()}`;
|
||||
return tags.filter(t => t.toLowerCase() !== bucketTagName);
|
||||
}
|
||||
|
||||
return tags;
|
||||
});
|
||||
|
||||
const isDueWithin12Hours = createMemo(() => {
|
||||
if (!props.task.dueDate) return false;
|
||||
try {
|
||||
const dueMs = new Date(props.task.dueDate).getTime();
|
||||
const diffMs = dueMs - now();
|
||||
const twelveHoursMs = 12 * 60 * 60 * 1000;
|
||||
return Math.abs(diffMs) <= twelveHoursMs;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const statusOptions = createMemo(() => getStatusOptionsForTask(props.task.recurrence));
|
||||
|
||||
return (
|
||||
<div
|
||||
class={cn(
|
||||
"group 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)}
|
||||
>
|
||||
{/* Today indicator */}
|
||||
<Show when={isDueWithin12Hours() && !props.task.completed}>
|
||||
<div class="absolute left-0 top-3 bottom-3 w-1 bg-primary rounded-r-md" />
|
||||
</Show>
|
||||
{/* Status Dropdown */}
|
||||
<div class="mr-2 sm:mr-3 shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<Select<any>
|
||||
@@ -45,7 +79,7 @@ export const TaskCard: Component<{ task: Task }> = (props) => {
|
||||
if (!isNaN(s)) updateTask(props.task.id, { status: s });
|
||||
}
|
||||
}}
|
||||
options={STATUS_OPTIONS}
|
||||
options={statusOptions()}
|
||||
optionValue="value"
|
||||
optionTextValue="label"
|
||||
placeholder="Status"
|
||||
@@ -126,9 +160,9 @@ export const TaskCard: Component<{ task: Task }> = (props) => {
|
||||
})()}
|
||||
|
||||
{/* Tags */}
|
||||
{props.task.tags && props.task.tags.length > 0 && (
|
||||
{visibleTags().length > 0 && (
|
||||
<div class="flex flex-wrap items-center gap-1 min-w-0">
|
||||
<For each={props.task.tags}>
|
||||
<For each={visibleTags()}>
|
||||
{(tag) => (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
|
||||
+142
-51
@@ -1,6 +1,6 @@
|
||||
import { type Component, createEffect, createSignal, For } from "solid-js";
|
||||
import { type Component, createEffect, createSignal, For, createMemo } from "solid-js";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
import { type Task, removeTask, restoreTask, updateTask, saveTaskAsTemplate, shareTask, revokeShare, splitTask, loadTaskContent } from "@/store";
|
||||
import { type Task, removeTask, restoreTask, updateTask, saveTaskAsTemplate, shareTask, revokeShare, splitTask, loadTaskContent, currentTaskContext } from "@/store";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { ArrowUpCircle, Clock, Calendar, Type, Trash2, X, Copy, MoreHorizontal, Gauge, Share2, UserMinus, GitBranch, Tag, Settings } from "lucide-solid";
|
||||
@@ -9,7 +9,7 @@ import { calculateDateFromUrgency, calculateUrgencyFromDate } from "@/store";
|
||||
import { Button } from "./ui/button";
|
||||
import { TagPicker } from "./TagPicker";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { PRIORITY_OPTIONS, URGENCY_OPTIONS, SIZE_OPTIONS, STATUS_OPTIONS } from "@/lib/constants";
|
||||
import { PRIORITY_OPTIONS, URGENCY_OPTIONS, SIZE_OPTIONS, getStatusOptionsForTask } from "@/lib/constants";
|
||||
import { store } from "@/store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "solid-sonner";
|
||||
@@ -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);
|
||||
@@ -96,7 +144,19 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
||||
};
|
||||
|
||||
const updateTags = (tags: string[]) => {
|
||||
updateTask(props.task.id, { tags });
|
||||
const ctx = currentTaskContext();
|
||||
let newTags = [...tags];
|
||||
|
||||
// If in a bucket view, ensure the bucket tag is preserved (it was hidden from the UI so it's missing from 'tags')
|
||||
if (typeof ctx === 'object' && 'bucketId' in ctx) {
|
||||
const bucketTagName = `@${(ctx as { name: string }).name}`;
|
||||
// Check case-insensitive existence
|
||||
if (!newTags.some(t => t.toLowerCase() === bucketTagName.toLowerCase())) {
|
||||
newTags.push(bucketTagName);
|
||||
}
|
||||
}
|
||||
|
||||
updateTask(props.task.id, { tags: newTags });
|
||||
};
|
||||
|
||||
const onDateInputChange = (e: Event) => {
|
||||
@@ -110,6 +170,23 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
||||
// Calculate current urgency level for display
|
||||
const currentUrgency = () => calculateUrgencyFromDate(props.task.dueDate).toString();
|
||||
|
||||
// Filter out bucket tags if we are currently INSIDE that bucket view
|
||||
const visibleTags = createMemo(() => {
|
||||
const tags = props.task.tags || [];
|
||||
const ctx = currentTaskContext();
|
||||
|
||||
if (typeof ctx === 'object' && 'bucketId' in ctx) {
|
||||
// We are in a bucket view. Hide the tag that matches this bucket's name.
|
||||
// Buckets are now tagged with '@' + name.
|
||||
const bucketTagName = `@${(ctx as { name: string }).name.toLowerCase()}`;
|
||||
return tags.filter(t => t.toLowerCase() !== bucketTagName);
|
||||
}
|
||||
|
||||
return tags;
|
||||
});
|
||||
|
||||
const statusOptions = createMemo(() => getStatusOptionsForTask(props.task.recurrence));
|
||||
|
||||
return (
|
||||
<Sheet open={props.isOpen} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
@@ -138,7 +215,7 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
||||
<Select<any>
|
||||
value={(props.task.status ?? 0).toString()}
|
||||
onChange={(val: string | null) => val && updateStatus(val)}
|
||||
options={STATUS_OPTIONS}
|
||||
options={statusOptions()}
|
||||
optionValue="value"
|
||||
optionTextValue="label"
|
||||
placeholder="Status"
|
||||
@@ -236,7 +313,7 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
||||
<div class="flex items-center gap-1 group shrink-0">
|
||||
<span class="text-[0.625rem] uppercase font-bold tracking-wider text-muted-foreground/70 hidden sm:inline">Size</span>
|
||||
<Select<any>
|
||||
value={(props.task.size ?? 5).toString()}
|
||||
value={(props.task.size ?? 3).toString()}
|
||||
onChange={(val: any) => {
|
||||
const value = typeof val === 'object' ? val?.value : val;
|
||||
if (value) {
|
||||
@@ -254,7 +331,7 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
||||
<div class="flex items-center gap-1 sm:gap-1.5 text-foreground font-medium truncate">
|
||||
<Gauge size={14} class="text-primary opacity-70 group-hover:opacity-100 transition-opacity shrink-0" />
|
||||
<span class="text-xs sm:text-sm truncate">
|
||||
{SIZE_OPTIONS.find(o => o.value === (props.task.size ?? 5).toString())?.label || props.task.size}
|
||||
{SIZE_OPTIONS.find(o => o.value === (props.task.size ?? 3).toString())?.label || props.task.size}
|
||||
</span>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
@@ -324,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={props.task.tags || []}
|
||||
onTagsChange={updateTags}
|
||||
/>
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<For each={props.task.tags || []}>
|
||||
{(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((props.task.tags || []).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"
|
||||
|
||||
+295
-12
@@ -1,25 +1,55 @@
|
||||
import { type Component, createEffect, untrack } from "solid-js";
|
||||
import { type Component, createEffect, untrack, createSignal, Show } from "solid-js";
|
||||
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;
|
||||
}
|
||||
|
||||
export const TaskEditor: Component<TaskEditorProps> = (props) => {
|
||||
let editorRef: HTMLDivElement | undefined;
|
||||
const [fullscreenImage, setFullscreenImage] = createSignal<string | null>(null);
|
||||
const [zoomScale, setZoomScale] = createSignal(1);
|
||||
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);
|
||||
setZoomOffset({ x: 0, y: 0 });
|
||||
};
|
||||
|
||||
const editor = createTiptapEditor(() => ({
|
||||
element: editorRef!,
|
||||
@@ -44,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",
|
||||
@@ -53,7 +83,82 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
|
||||
SlashCommands.configure({
|
||||
suggestion,
|
||||
}),
|
||||
MobileIndent,
|
||||
Image.configure({
|
||||
HTMLAttributes: {
|
||||
class: "rounded-lg border border-border max-w-full h-auto",
|
||||
},
|
||||
}),
|
||||
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) || "",
|
||||
@@ -68,12 +173,22 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
|
||||
"prose-headings:font-semibold prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg",
|
||||
"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: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-2 [&_li[data-type='taskItem']>label]:mt-0.5 [&_li[data-type='taskItem']>label]:select-none",
|
||||
"[&_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
|
||||
),
|
||||
},
|
||||
@@ -97,11 +212,179 @@ 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
|
||||
ref={editorRef}
|
||||
class="w-full cursor-text"
|
||||
onClick={() => editor()?.chain().focus().run()}
|
||||
/>
|
||||
<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 flex-1 cursor-text"
|
||||
onClick={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName.toLowerCase() === 'img') {
|
||||
setFullscreenImage((target as HTMLImageElement).src);
|
||||
} 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"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setFullscreenImage(null);
|
||||
resetZoom();
|
||||
}
|
||||
}}
|
||||
onWheel={(e) => {
|
||||
e.preventDefault();
|
||||
const scaleSpeed = 0.05;
|
||||
const delta = e.deltaY < 0 ? 1 : -1;
|
||||
setZoomScale(s => Math.min(Math.max(1, s + delta * scaleSpeed), 5));
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (zoomScale() > 1 && e.target.tagName.toLowerCase() === 'img') {
|
||||
setIsDragging(true);
|
||||
setDragStart({ x: e.clientX - zoomOffset().x, y: e.clientY - zoomOffset().y });
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (isDragging()) {
|
||||
setZoomOffset({
|
||||
x: e.clientX - dragStart().x,
|
||||
y: e.clientY - dragStart().y
|
||||
});
|
||||
}
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
setIsDragging(false);
|
||||
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
}}
|
||||
onPointerCancel={(e) => {
|
||||
setIsDragging(false);
|
||||
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
class="absolute top-4 right-4 p-2 bg-background/50 hover:bg-background/80 rounded-full transition-colors z-[10000]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setFullscreenImage(null);
|
||||
resetZoom();
|
||||
}}
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
<div
|
||||
class="relative flex items-center justify-center w-full h-full pointer-events-none"
|
||||
>
|
||||
<img
|
||||
src={fullscreenImage()!}
|
||||
class={cn(
|
||||
"max-w-full max-h-[90vh] object-contain rounded-md shadow-2xl pointer-events-auto transition-transform duration-75",
|
||||
isDragging() ? "cursor-grabbing" : (zoomScale() > 1 ? "cursor-grab" : "cursor-zoom-in")
|
||||
)}
|
||||
style={{
|
||||
transform: `translate(${zoomOffset().x}px, ${zoomOffset().y}px) scale(${zoomScale()})`,
|
||||
}}
|
||||
draggable={false}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (zoomScale() === 1) {
|
||||
setZoomScale(2);
|
||||
} else if (!isDragging()) {
|
||||
// Optional: Click to single-zoom-out or do nothing during pan
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</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 {
|
||||
|
||||
+67
-11
@@ -40,21 +40,36 @@ export const TASGRID_COLLECTION = 'TasGrid';
|
||||
export const TAGS_COLLECTION = 'TasGrid_Tags';
|
||||
export const SHARE_RULES_COLLECTION = 'TasGrid_ShareRules';
|
||||
export const BUCKETS_COLLECTION = 'buckets';
|
||||
export const NOTES_COLLECTION = 'TasGrid_Notes';
|
||||
|
||||
export const SIZE_OPTIONS = [
|
||||
{ value: "10", label: "10 - Multiple Days" },
|
||||
{ value: "9", label: "9 - More than a day" },
|
||||
{ value: "8", label: "8 - One day" },
|
||||
{ value: "7", label: "7 - Half a day" },
|
||||
{ value: "6", label: "6 - Two hours" },
|
||||
{ value: "5", label: "5 - One hour" },
|
||||
{ value: "4", label: "4 - Forty-Five minutes" },
|
||||
{ value: "3", label: "3 - Thirty minutes" },
|
||||
{ value: "2", label: "2 - Fifteen minutes" },
|
||||
{ value: "1", label: "1 - Five minutes" },
|
||||
{ value: "0", label: "0 - Less than a minute" }
|
||||
{ value: "10", label: "10 - Two weeks" },
|
||||
{ value: "9", label: "9 - One week" },
|
||||
{ value: "8", label: "8 - Three days" },
|
||||
{ value: "7", label: "7 - Two days" },
|
||||
{ value: "6", label: "6 - One day" },
|
||||
{ value: "5", label: "5 - Half a day" },
|
||||
{ value: "4", label: "4 - Two hours" },
|
||||
{ value: "3", label: "3 - One hour" },
|
||||
{ value: "2", label: "2 - Thirty minutes" },
|
||||
{ value: "1", label: "1 - Fifteen minutes" },
|
||||
{ value: "0", label: "0 - Five minutes" }
|
||||
];
|
||||
|
||||
export const SIZE_HOURS: Record<number, number> = {
|
||||
10: 336, // Two weeks
|
||||
9: 168, // One week
|
||||
8: 72, // Three days
|
||||
7: 48, // Two days
|
||||
6: 24, // One day
|
||||
5: 12, // Half a day
|
||||
4: 2, // Two hours
|
||||
3: 1, // One hour
|
||||
2: 0.5, // Thirty minutes
|
||||
1: 0.25, // Fifteen minutes
|
||||
0: 0.083 // Five minutes
|
||||
};
|
||||
|
||||
export const STATUS_OPTIONS = [
|
||||
{ value: "10", label: "10 - Complete" },
|
||||
{ value: "9", label: "9" },
|
||||
@@ -68,3 +83,44 @@ export const STATUS_OPTIONS = [
|
||||
{ value: "1", label: "1" },
|
||||
{ value: "0", label: "0 - Not Yet Started" }
|
||||
];
|
||||
|
||||
const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
|
||||
type Recurrence = {
|
||||
type: 'daily' | 'weekly' | 'monthly';
|
||||
days?: number[];
|
||||
dayOfMonth?: number;
|
||||
};
|
||||
|
||||
export const getRecurrenceCompleteLabel = (recurrence?: Recurrence | null): string => {
|
||||
if (!recurrence) return "10 - Complete";
|
||||
if (recurrence.type === 'daily') return "10 - Complete until tomorrow";
|
||||
if (recurrence.type === 'weekly') {
|
||||
if (recurrence.days && recurrence.days.length > 0) {
|
||||
const today = new Date().getDay();
|
||||
const sorted = [...recurrence.days].sort((a, b) => {
|
||||
const da = (a - today + 7) % 7 || 7;
|
||||
const db = (b - today + 7) % 7 || 7;
|
||||
return da - db;
|
||||
});
|
||||
return `10 - Complete until ${DAY_NAMES[sorted[0]]}`;
|
||||
}
|
||||
return "10 - Complete (weekly)";
|
||||
}
|
||||
if (recurrence.type === 'monthly') {
|
||||
const dom = recurrence.dayOfMonth;
|
||||
if (dom != null) {
|
||||
const suffix = dom === 1 || dom === 21 || dom === 31 ? 'st'
|
||||
: dom === 2 || dom === 22 ? 'nd'
|
||||
: dom === 3 || dom === 23 ? 'rd' : 'th';
|
||||
return `10 - Complete until the ${dom}${suffix}`;
|
||||
}
|
||||
}
|
||||
return "10 - Complete";
|
||||
};
|
||||
|
||||
export const getStatusOptionsForTask = (recurrence?: Recurrence | null) =>
|
||||
STATUS_OPTIONS.map(opt =>
|
||||
opt.value === "10" ? { ...opt, label: getRecurrenceCompleteLabel(recurrence) } : opt
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -4,3 +4,94 @@ import { twMerge } from "tailwind-merge"
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export const compressImageBase64 = (base64Str: string, maxWidth = 800, quality = 0.7): Promise<string> => {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.src = base64Str;
|
||||
img.onload = () => {
|
||||
let { width, height } = img;
|
||||
if (width > maxWidth) {
|
||||
height = Math.round((height * maxWidth) / width);
|
||||
width = maxWidth;
|
||||
}
|
||||
if (height > maxWidth) {
|
||||
width = Math.round((width * maxWidth) / height);
|
||||
height = maxWidth;
|
||||
}
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
resolve(base64Str);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
resolve(canvas.toDataURL("image/jpeg", quality));
|
||||
};
|
||||
img.onerror = () => resolve(base64Str);
|
||||
});
|
||||
};
|
||||
|
||||
export const convertImageToWebpFile = async (file: File): Promise<File> => {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
let { width, height } = img;
|
||||
const maxWidth = 2560; // 2K resolution max for task images
|
||||
|
||||
if (width > maxWidth) {
|
||||
height = Math.round((height * maxWidth) / width);
|
||||
width = maxWidth;
|
||||
}
|
||||
if (height > maxWidth) {
|
||||
width = Math.round((width * maxWidth) / height);
|
||||
height = maxWidth;
|
||||
}
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
resolve(file); // Fallback to original
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) {
|
||||
resolve(file); // Fallback to original
|
||||
return;
|
||||
}
|
||||
|
||||
// Replace original extension with .webp
|
||||
const originalName = file.name;
|
||||
const finalName = originalName.replace(/\.[^/.]+$/, "") + ".webp";
|
||||
|
||||
const newFile = new File([blob], finalName, {
|
||||
type: "image/webp",
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
resolve(newFile);
|
||||
},
|
||||
"image/webp",
|
||||
0.7 // 70% Quality
|
||||
);
|
||||
};
|
||||
img.onerror = () => resolve(file); // Fallback
|
||||
img.src = e.target?.result as string;
|
||||
};
|
||||
reader.onerror = () => resolve(file); // Fallback
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
+478
-126
@@ -2,7 +2,7 @@ import { createStore, reconcile } from "solid-js/store";
|
||||
import { createSignal, createEffect, createRoot } from "solid-js";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { toast } from "solid-sonner";
|
||||
import { TASGRID_COLLECTION, TAGS_COLLECTION, SHARE_RULES_COLLECTION, BUCKETS_COLLECTION, URGENCY_HOURS } from "@/lib/constants";
|
||||
import { TASGRID_COLLECTION, TAGS_COLLECTION, SHARE_RULES_COLLECTION, BUCKETS_COLLECTION, NOTES_COLLECTION, URGENCY_HOURS, SIZE_HOURS } from "@/lib/constants";
|
||||
|
||||
const LIST_FIELDS = 'id,collectionId,collectionName,created,updated,title,startDate,dueDate,priority,status,completed,tags,user,size,recurrence,sharedWith,deletedAt';
|
||||
|
||||
@@ -12,7 +12,7 @@ const getStorageKey = () => {
|
||||
};
|
||||
|
||||
export const [now, setNow] = createSignal(Date.now());
|
||||
// Context: 'mine' = My Workspace (default), { userId: string } = Oversight View for that user, { bucketId: string, name: string } = Bucket View
|
||||
// Context: 'mine' = My Bucket (default), { userId: string } = Oversight View for that user, { bucketId: string, name: string } = Bucket View
|
||||
type TaskContext = 'mine' | { userId: string, name: string } | { bucketId: string, name: string };
|
||||
export const [activeTaskId, setActiveTaskId] = createSignal<string | null>(null);
|
||||
export const [currentTaskContext, setCurrentTaskContext] = createSignal<TaskContext>('mine');
|
||||
@@ -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?: {
|
||||
@@ -41,6 +41,7 @@ export interface Task {
|
||||
};
|
||||
size?: number; // 0-10, task complexity/size
|
||||
sharedWith?: Array<{ userId: string; access: 'view' | 'edit' }>; // Users this task is shared with
|
||||
attachments?: string[]; // Array of filenames from PocketBase
|
||||
}
|
||||
|
||||
export const checkRecurringTasks = () => {
|
||||
@@ -135,6 +136,18 @@ export interface TagDefinition {
|
||||
isBucket?: boolean; // New flag to identify bucket-based tags
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
id: string;
|
||||
title: string;
|
||||
content?: string;
|
||||
tags: string[];
|
||||
isPrivate: boolean;
|
||||
user: string;
|
||||
tasks: string[]; // List of related task IDs
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface FilterTag {
|
||||
name: string;
|
||||
excluded: boolean;
|
||||
@@ -175,6 +188,7 @@ export interface Bucket {
|
||||
|
||||
interface TaskStore {
|
||||
tasks: Task[];
|
||||
isInitializing: boolean;
|
||||
pWeight: number;
|
||||
uWeight: number;
|
||||
matrixScaleDays: number;
|
||||
@@ -186,11 +200,15 @@ interface TaskStore {
|
||||
filterTemplates: FilterTemplate[];
|
||||
buckets: Bucket[];
|
||||
subscribedBuckets: string[]; // IDs of buckets I'm subscribed to
|
||||
notes: Note[];
|
||||
isNotepadMode: boolean;
|
||||
quickloadTasks: string[];
|
||||
}
|
||||
|
||||
// Initial empty state
|
||||
export const [store, setStore] = createStore<TaskStore>({
|
||||
tasks: [],
|
||||
isInitializing: false,
|
||||
pWeight: 1.0,
|
||||
uWeight: 1.0,
|
||||
matrixScaleDays: 30,
|
||||
@@ -208,7 +226,10 @@ export const [store, setStore] = createStore<TaskStore>({
|
||||
shareRules: [],
|
||||
filterTemplates: [],
|
||||
buckets: [],
|
||||
subscribedBuckets: []
|
||||
subscribedBuckets: [],
|
||||
notes: [],
|
||||
isNotepadMode: false,
|
||||
quickloadTasks: []
|
||||
});
|
||||
|
||||
export const matchesFilter = (task: Task) => {
|
||||
@@ -221,35 +242,38 @@ export const matchesFilter = (task: Task) => {
|
||||
const isExplicitlyShared = task.sharedWith?.some(s => s.userId === currentUserId);
|
||||
|
||||
// Tag-based share rules targeting me
|
||||
const isTagRuleShared = store.shareRules.some(r =>
|
||||
r.targetUserId === currentUserId &&
|
||||
r.ownerId === task.ownerId &&
|
||||
r.type === 'tag' &&
|
||||
task.tags?.includes(r.tagName || "")
|
||||
);
|
||||
const isTagRuleShared = store.shareRules.some(r => {
|
||||
if (r.targetUserId !== currentUserId) return false;
|
||||
|
||||
// Bucket visibility logic for "My Workspace":
|
||||
// Users requested: "Any user who adds that bucket to their workspaces list can view the workspace and see tasks in that bucket from all users."
|
||||
// AND "In settings a user should be able to see and edit all buckets... They should be able to add any bucket to their workspaces list."
|
||||
// When in 'mine', do we show bucket tasks?
|
||||
// Typically 'My Workspace' is just MY stuff + stuff shared explicitly with me.
|
||||
// Buckets seem to be separate "Contexts" in the sidebar.
|
||||
// However, if the user wants them mixed in, they didn't explicitly say "mix into my main list",
|
||||
// they said "add that bucket to their workspaces list". This usually implies a separate view.
|
||||
// BUT, if I create a task and tag it "Shop", I expect to see it.
|
||||
// Global Rule / Self-Rule: Match purely on tag, ignore owner check
|
||||
// logic: If I have a rule for tag "T" assigned to myself, show ANY task with tag "T"
|
||||
if (r.ownerId === r.targetUserId && r.type === 'tag') {
|
||||
const tagName = r.tagName || "";
|
||||
|
||||
// If I own the task, I see it.
|
||||
// If it's shared with me, I see it.
|
||||
// EXCLUDE Buckets from the main "Mine" view
|
||||
// The user wants to see "Direct Shares" (User Tags), not "Bucket Subscriptions" here.
|
||||
const isBucket = store.buckets.some(b => b.name.toLowerCase() === tagName.toLowerCase());
|
||||
if (isBucket) return false;
|
||||
|
||||
return task.tags?.some(t => t.toLowerCase() === tagName.toLowerCase());
|
||||
}
|
||||
|
||||
// Standard Rule: Match owner AND tag
|
||||
return r.ownerId === task.ownerId &&
|
||||
r.type === 'tag' &&
|
||||
task.tags?.includes(r.tagName || "");
|
||||
});
|
||||
|
||||
// Bucket tasks are now tagged with '@' + name.
|
||||
if (!isOwner && !isExplicitlyShared && !isTagRuleShared) return false;
|
||||
} else if (typeof ctx === 'object' && 'bucketId' in ctx) {
|
||||
// Bucket View
|
||||
const bucketProxy = store.buckets.find(b => b.id === (ctx as { bucketId: string }).bucketId);
|
||||
if (!bucketProxy) return false;
|
||||
|
||||
// Show tasks that have the bucket name as a tag
|
||||
// The requirement: "if any user adds a tag that is the name of a bucket to a task that task is then made public and moved to the bucket."
|
||||
if (!task.tags?.includes(bucketProxy.name)) return false;
|
||||
// Show tasks that have the bucket name (prefixed with @) as a tag
|
||||
const bucketTagName = `@${bucketProxy.name}`;
|
||||
if (!task.tags?.includes(bucketTagName)) return false;
|
||||
} else {
|
||||
// Context is Oversight for specific user
|
||||
// Show ONLY tasks owned by that user
|
||||
@@ -314,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 --
|
||||
@@ -354,7 +410,17 @@ export const calculateUrgencyFromDate = (dateStr: string): number => {
|
||||
};
|
||||
|
||||
export const getCombinedScore = (task: Task): number => {
|
||||
const urgencyScore = calculateUrgencyScore(task.dueDate);
|
||||
// 1. Calculate an effective due date by subtracting size hours
|
||||
const size = task.size ?? 3; // Default size 3
|
||||
const sizeHours = SIZE_HOURS[size] ?? 12;
|
||||
const dueTime = new Date(task.dueDate).getTime();
|
||||
if (isNaN(dueTime)) return 0;
|
||||
|
||||
// effective due date = due date - size task time
|
||||
const effectiveDueTime = dueTime - (sizeHours * 60 * 60 * 1000);
|
||||
const effectiveDueDateStr = new Date(effectiveDueTime).toISOString();
|
||||
|
||||
const urgencyScore = calculateUrgencyScore(effectiveDueDateStr);
|
||||
let baseScore = (task.priority * store.pWeight) + (urgencyScore * store.uWeight);
|
||||
|
||||
// Tag adjustments
|
||||
@@ -369,9 +435,8 @@ export const getCombinedScore = (task: Task): number => {
|
||||
});
|
||||
}
|
||||
|
||||
// Size adjustment: (size - 5) * -0.4
|
||||
// Small tasks (0-4) get a slight boost, large tasks (6-10) get a slight penalty
|
||||
const sizeScore = ((task.size ?? 5) - 5) * -0.4;
|
||||
// Size adjustment: linear scale from +1.9 (for size 0) to 0 (for size 10)
|
||||
const sizeScore = (10 - size) * 0.19;
|
||||
baseScore += sizeScore;
|
||||
|
||||
// Status adjustment: 0-9 -> 0.0-2.0 weight
|
||||
@@ -408,10 +473,34 @@ const mapRecordToTask = (r: any): Task => {
|
||||
updated: r.updated,
|
||||
recurrence: r.recurrence,
|
||||
size: (r.size === null || r.size === undefined) ? undefined : r.size,
|
||||
sharedWith: r.sharedWith
|
||||
sharedWith: r.sharedWith,
|
||||
attachments: r.attachments || []
|
||||
};
|
||||
};
|
||||
|
||||
export const uploadTaskAttachment = async (taskId: string, file: File): Promise<string> => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('attachments', file);
|
||||
|
||||
const record = await pb.collection(TASGRID_COLLECTION).update(taskId, formData);
|
||||
|
||||
// Return the URL for the newly uploaded file. PocketBase appends new files to the array.
|
||||
// We need to find the specific filename that was just uploaded to get its URL.
|
||||
// Since we don't know the exact generated filename, we can return the URL of the last file in the array.
|
||||
if (record.attachments && record.attachments.length > 0) {
|
||||
const latestFilename = record.attachments[record.attachments.length - 1];
|
||||
return pb.files.getURL(record, latestFilename);
|
||||
}
|
||||
|
||||
throw new Error("File uploaded but no attachment returned");
|
||||
} catch (err: any) {
|
||||
console.error("Error uploading attachment:", err);
|
||||
toast.error("Failed to upload image.");
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const mapRecordToShareRule = (r: any): ShareRule => {
|
||||
return {
|
||||
id: r.id,
|
||||
@@ -423,6 +512,20 @@ const mapRecordToShareRule = (r: any): ShareRule => {
|
||||
};
|
||||
};
|
||||
|
||||
const mapRecordToNote = (r: any): Note => {
|
||||
return {
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
content: r.content,
|
||||
tags: r.tags || [],
|
||||
isPrivate: r.isPrivate || false,
|
||||
user: r.user,
|
||||
tasks: r.tasks || [],
|
||||
created: r.created,
|
||||
updated: r.updated
|
||||
};
|
||||
};
|
||||
|
||||
// Helper to check if a task should be visible based on ownership, shares, or rules
|
||||
const shouldTaskBeVisible = (task: any, currentUserId: string): boolean => {
|
||||
// Own task
|
||||
@@ -435,6 +538,16 @@ const shouldTaskBeVisible = (task: any, currentUserId: string): boolean => {
|
||||
// ShareRule match
|
||||
const matchingRule = store.shareRules.find(rule => {
|
||||
if (rule.targetUserId !== currentUserId) return false;
|
||||
|
||||
// Global Rule Logic: If owner == target, it's a "Global Subscription" to that tag.
|
||||
// We ignore the rule.ownerId check (which would match the task.user)
|
||||
// and instead match purely on the tag.
|
||||
if (rule.ownerId === rule.targetUserId && rule.type === 'tag') {
|
||||
const tags: string[] = task.tags || [];
|
||||
return tags.some(t => t.toLowerCase() === (rule.tagName || "").toLowerCase());
|
||||
}
|
||||
|
||||
// Standard Rule Logic: Match specific owner
|
||||
if (task.user !== rule.ownerId) return false;
|
||||
if (rule.type === 'all') return true;
|
||||
if (rule.type === 'tag') {
|
||||
@@ -456,13 +569,9 @@ const isTaskInSubscribedBucket = (task: any): boolean => {
|
||||
if (taskTags.length === 0) return false;
|
||||
|
||||
// Check if any of the task's tags match a bucket we are subscribed to
|
||||
// We need to look up bucket names from our subscribed bucket IDs
|
||||
// This is slightly expensive if we iterate.
|
||||
// Optimization: Pre-calculate subscribed bucket names?
|
||||
// For now, simple find.
|
||||
return store.subscribedBuckets.some(subId => {
|
||||
const bucket = store.buckets.find(b => b.id === subId);
|
||||
return bucket && taskTags.includes(bucket.name);
|
||||
return bucket && taskTags.includes(`@${bucket.name}`);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -568,6 +677,33 @@ export const subscribeToRealtime = async () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to Notes
|
||||
await pb.collection(NOTES_COLLECTION).unsubscribe('*');
|
||||
pb.collection(NOTES_COLLECTION).subscribe('*', (e) => {
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
|
||||
// Only process if public or owned by current user
|
||||
const isVisible = !e.record.isPrivate || e.record.user === currentUserId;
|
||||
|
||||
if (e.action === 'create' || e.action === 'update') {
|
||||
if (isVisible) {
|
||||
const updatedNote = mapRecordToNote(e.record);
|
||||
setStore("notes", prev => {
|
||||
const exists = prev.find(n => n.id === updatedNote.id);
|
||||
if (exists) return prev.map(n => n.id === updatedNote.id ? updatedNote : n);
|
||||
return [updatedNote, ...prev];
|
||||
});
|
||||
} else {
|
||||
// If it became private and we're not the owner, remove it
|
||||
setStore("notes", prev => prev.filter(n => n.id !== e.record.id));
|
||||
}
|
||||
}
|
||||
|
||||
if (e.action === 'delete') {
|
||||
setStore("notes", prev => prev.filter(n => n.id !== e.record.id));
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to Buckets
|
||||
// We need to know if buckets are added/removed/renamed
|
||||
await pb.collection(BUCKETS_COLLECTION).unsubscribe('*');
|
||||
@@ -657,7 +793,47 @@ export const subscribeToRealtime = async () => {
|
||||
};
|
||||
|
||||
export const initStore = async () => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
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();
|
||||
@@ -692,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) {
|
||||
@@ -701,7 +878,7 @@ export const initStore = async () => {
|
||||
|
||||
// 1.2 Fetch Buckets
|
||||
try {
|
||||
const buckets = await pb.collection(BUCKETS_COLLECTION).getFullList({ sort: 'name' });
|
||||
const buckets = await pb.collection(BUCKETS_COLLECTION).getFullList({ sort: 'name', requestKey: null });
|
||||
setStore("buckets", buckets.map((b: any) => ({
|
||||
id: b.id,
|
||||
name: b.name,
|
||||
@@ -712,10 +889,24 @@ export const initStore = async () => {
|
||||
console.warn("Failed to load buckets (collection might not exist yet):", bucketErr);
|
||||
}
|
||||
|
||||
// 1.3 Fetch Notes
|
||||
try {
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
const notesRecords = await pb.collection(NOTES_COLLECTION).getFullList({
|
||||
filter: `isPrivate = false || user = "${currentUserId}"`,
|
||||
sort: '-created',
|
||||
requestKey: null
|
||||
});
|
||||
setStore("notes", notesRecords.map(mapRecordToNote));
|
||||
} catch (notesErr) {
|
||||
console.warn("Failed to load notes (collection might not exist yet):", notesErr);
|
||||
}
|
||||
|
||||
// 1.5. Fetch Tag Definitions
|
||||
try {
|
||||
const tagRecords = await pb.collection(TAGS_COLLECTION).getFullList({
|
||||
filter: `user = "${pb.authStore.model?.id}"`,
|
||||
requestKey: null
|
||||
});
|
||||
const loadedTags: TagDefinition[] = tagRecords.map(r => ({
|
||||
id: r.id,
|
||||
@@ -742,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: 'initial_focus'
|
||||
});
|
||||
|
||||
// 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 () => {
|
||||
@@ -768,6 +941,9 @@ export const initStore = async () => {
|
||||
.map(id => store.buckets.find(b => b.id === id)?.name)
|
||||
.filter(Boolean);
|
||||
|
||||
// Sync System Tags & Share Rules FIRST to ensure Self-Rules exist for query construction
|
||||
await syncSystemTagsAndRules();
|
||||
|
||||
// Add rules from shareRules store (which was just synced)
|
||||
const incomingRules = store.shareRules.filter(r => r.targetUserId === currentUserId);
|
||||
|
||||
@@ -799,18 +975,49 @@ export const initStore = async () => {
|
||||
|
||||
// Share Rules Incomplete
|
||||
if (incomingRules.length > 0) {
|
||||
const ownerRules = new Map<string, { type: 'all' | 'tag', tagName?: string }[]>();
|
||||
// Separate "Global Rules" (Self-Rules) from "Specific User Rules"
|
||||
const globalTagRules: string[] = [];
|
||||
const specificUserRules = new Map<string, { type: 'all' | 'tag', tagName?: string }[]>();
|
||||
|
||||
incomingRules.forEach((rule: any) => {
|
||||
const existing = ownerRules.get(rule.ownerId) || [];
|
||||
existing.push({ type: rule.type, tagName: rule.tagName });
|
||||
ownerRules.set(rule.ownerId, existing);
|
||||
if (rule.ownerId === rule.targetUserId && rule.type === 'tag' && rule.tagName) {
|
||||
globalTagRules.push(rule.tagName);
|
||||
} else {
|
||||
const existing = specificUserRules.get(rule.ownerId) || [];
|
||||
existing.push({ type: rule.type, tagName: rule.tagName });
|
||||
specificUserRules.set(rule.ownerId, existing);
|
||||
}
|
||||
});
|
||||
|
||||
Array.from(ownerRules.entries()).forEach(([ownerId, rules]) => {
|
||||
// 1. Fetch Global Rule Tasks (from ANY user)
|
||||
// 1. Fetch Global Rule Tasks (from ANY user)
|
||||
if (globalTagRules.length > 0) {
|
||||
// We want tasks that have ANY of these tags, AND are not completed
|
||||
// filter: (tags ~ "A" || tags ~ "B") && completed = false
|
||||
const tagFilter = globalTagRules.map(t => `tags ~ "${t}"`).join(' || ');
|
||||
incompletePromises.push(pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: `(${tagFilter}) && completed = false`,
|
||||
sort: '-created',
|
||||
fields: LIST_FIELDS,
|
||||
requestKey: null
|
||||
}).catch(() => []));
|
||||
}
|
||||
|
||||
// 2. Fetch Specific User Rule Tasks (from specific owner)
|
||||
Array.from(specificUserRules.entries()).forEach(([ownerId, rules]) => {
|
||||
const hasAllRule = rules.some(r => r.type === 'all');
|
||||
const tagRules = rules.filter(r => r.type === 'tag').map(r => r.tagName).filter(Boolean);
|
||||
|
||||
let filter = `user = "${ownerId}" && completed = false`;
|
||||
if (!hasAllRule && tagRules.length > 0) filter += ` && (${tagRules.map(t => `tags ~ "${t}"`).join(' || ')})`;
|
||||
|
||||
if (hasAllRule) {
|
||||
// Get everything from this user
|
||||
} else if (tagRules.length > 0) {
|
||||
// Get specific tags from this user
|
||||
filter += ` && (${tagRules.map(t => `tags ~ "${t}"`).join(' || ')})`;
|
||||
} else {
|
||||
return; // No valid rules for this user
|
||||
}
|
||||
|
||||
incompletePromises.push(pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter,
|
||||
@@ -828,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) {
|
||||
@@ -903,18 +1117,18 @@ export const initStore = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Sync System Tags & Share Rules (Heavy maintenance task)
|
||||
await syncSystemTagsAndRules();
|
||||
// Sync System Tags & Share Rules (Moved to top of backgroundSync to ensure rules exist before use)
|
||||
// await syncSystemTagsAndRules();
|
||||
};
|
||||
|
||||
// Fire off background sync after a short delay
|
||||
setTimeout(backgroundSync, 100);
|
||||
// Fire off background sync
|
||||
await backgroundSync();
|
||||
|
||||
// Separate Templates load (less critical)
|
||||
try {
|
||||
const templates = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: `user = "${currentUserId}" && tags ~ "__template__"`,
|
||||
requestKey: 'templates_load'
|
||||
requestKey: null
|
||||
});
|
||||
const loadedTemplates = templates.map(r => {
|
||||
let meta: any = {};
|
||||
@@ -942,6 +1156,8 @@ export const initStore = async () => {
|
||||
console.error("Failed to load data:", err);
|
||||
toast.error("Failed to sync with server.");
|
||||
}
|
||||
} finally {
|
||||
setStore("isInitializing", false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1050,7 +1266,7 @@ export const addTask = async (title: string, options?: { dueDate?: Date | string
|
||||
const priority = options?.priority ?? 5;
|
||||
const tags = options?.tags ?? ["work"];
|
||||
const content = options?.content ?? "";
|
||||
const size = options?.size ?? 5;
|
||||
const size = options?.size ?? 3;
|
||||
|
||||
const tempId = "temp-" + Date.now();
|
||||
const initialTask: Task = {
|
||||
@@ -1153,8 +1369,9 @@ const checkForHandoffs = (task: Task, updates: Partial<Task>): Partial<Task> =>
|
||||
|
||||
// Check for any tag that triggers a SEND_TASK rule
|
||||
for (const tag of updates.tags) {
|
||||
// Handle @ prefix in rule matching
|
||||
const rule = store.shareRules.find(r =>
|
||||
r.ownerId === currentUserId &&
|
||||
r.ownerId === pb.authStore.model?.id &&
|
||||
r.type === 'tag' &&
|
||||
r.tagName === tag &&
|
||||
r.share_mode === 'SEND_TASK'
|
||||
@@ -1163,7 +1380,7 @@ const checkForHandoffs = (task: Task, updates: Partial<Task>): Partial<Task> =>
|
||||
if (rule) {
|
||||
console.log(`Handoff triggered by tag "${tag}" -> Transferring to ${rule.targetUserId}`);
|
||||
|
||||
// Check if it's a bucket tag (target is self, but tag is a bucket)
|
||||
// Check if it's a bucket tag
|
||||
const isBucketRule = rule.targetUserId === currentUserId && store.tagDefinitions.find(t => t.name === tag)?.isBucket;
|
||||
|
||||
if (isBucketRule) {
|
||||
@@ -1233,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1324,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) => {
|
||||
@@ -1363,13 +1577,38 @@ export const upsertTagDefinition = async (name: string, value: number, color?: s
|
||||
theme: theme !== undefined ? theme : existing.theme
|
||||
});
|
||||
} else {
|
||||
|
||||
// Check for case-insensitive duplicate
|
||||
const nameLower = name.toLowerCase();
|
||||
const duplicate = store.tagDefinitions.find(d => d.name.toLowerCase() === nameLower);
|
||||
|
||||
if (duplicate) {
|
||||
if (duplicate.name !== name) {
|
||||
// Case difference only -> Update to new casing?
|
||||
// Or just warn? "Tag already exists with different casing".
|
||||
// For now, we'll just return the existing one to be safe, or update it if needed.
|
||||
// A strict interpretation of "There should never be two tags with the same name"
|
||||
// usually implies normalization.
|
||||
// But if the user typed "Work" and "work" exists, maybe they mean "work".
|
||||
toast.error(`Tag "${duplicate.name}" already exists.`);
|
||||
return;
|
||||
}
|
||||
// Exact match logic handled by 'existing' check above usually, but 'existing' uses strict equality?
|
||||
// The existing check at top of function is strict: `existing = store.tagDefinitions.find(d => d.name === name);`
|
||||
// So this block handles case-insensitive duplicates that weren't caught by strict check.
|
||||
// We should update the existing one if we want to change casing, or just reject.
|
||||
// Rejection is safer for unique constraint.
|
||||
toast.error(`Tag "${duplicate.name}" already exists.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const record = await pb.collection(TAGS_COLLECTION).create({
|
||||
user: pb.authStore.model?.id,
|
||||
name,
|
||||
value,
|
||||
color: color || "#6366f1", // Default indigo
|
||||
theme: finalTheme
|
||||
});
|
||||
}, { requestKey: null });
|
||||
|
||||
const newDef: TagDefinition = {
|
||||
id: record.id,
|
||||
@@ -1446,17 +1685,18 @@ export const syncSystemTagsAndRules = async () => {
|
||||
|
||||
// --- Sync Bucket Tags ---
|
||||
for (const bucket of store.buckets) {
|
||||
const tagExists = tagDefinitions.find(t => t.name === bucket.name);
|
||||
if (!tagExists) {
|
||||
const bucketTagName = `@${bucket.name}`;
|
||||
const tagExistsExact = tagDefinitions.find(t => t.name.toLowerCase() === bucketTagName.toLowerCase());
|
||||
if (!tagExistsExact) {
|
||||
try {
|
||||
const record = await pb.collection(TAGS_COLLECTION).create({
|
||||
user: currentUserId,
|
||||
name: bucket.name,
|
||||
name: bucketTagName,
|
||||
value: 5,
|
||||
color: bucket.color || "#64748b",
|
||||
theme: "dark",
|
||||
isBucket: true
|
||||
});
|
||||
}, { requestKey: null });
|
||||
setStore("tagDefinitions", prev => [...prev, {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
@@ -1466,47 +1706,62 @@ export const syncSystemTagsAndRules = async () => {
|
||||
isBucket: true
|
||||
}]);
|
||||
} catch (e) {
|
||||
console.error(`Failed to create bucket tag ${bucket.name}`, e);
|
||||
console.error(`Failed to create bucket tag ${bucketTagName}`, e);
|
||||
}
|
||||
} else if (!tagExists.isBucket) {
|
||||
} else if (!tagExistsExact.isBucket) {
|
||||
try {
|
||||
await pb.collection(TAGS_COLLECTION).update(tagExists.id, { isBucket: true });
|
||||
setStore("tagDefinitions", d => d.id === tagExists.id, { isBucket: true });
|
||||
await pb.collection(TAGS_COLLECTION).update(tagExistsExact.id, { isBucket: true }, { requestKey: null });
|
||||
setStore("tagDefinitions", d => d.id === tagExistsExact.id, { isBucket: true });
|
||||
} catch (e) {
|
||||
console.error(`Failed to flag tag ${bucket.name} as bucket`, e);
|
||||
console.error(`Failed to flag tag ${bucketTagName} as bucket`, e);
|
||||
}
|
||||
}
|
||||
// --- Sync Bucket System Rule ---
|
||||
// Ensure a ShareRule exists for this bucket so it appears in "System Rules"
|
||||
const bucketRuleExists = normalizedRules.some(r =>
|
||||
const bucketRuleExact = normalizedRules.find(r =>
|
||||
r.ownerId === currentUserId &&
|
||||
r.targetUserId === currentUserId &&
|
||||
r.type === 'tag' &&
|
||||
r.tagName === bucketTagName
|
||||
);
|
||||
const bucketRuleLegacy = normalizedRules.find(r =>
|
||||
r.ownerId === currentUserId &&
|
||||
r.targetUserId === currentUserId &&
|
||||
r.type === 'tag' &&
|
||||
r.tagName === bucket.name
|
||||
);
|
||||
|
||||
if (!bucketRuleExists) {
|
||||
if (bucketRuleLegacy && !bucketRuleExact) {
|
||||
// Rename legacy rule
|
||||
try {
|
||||
await pb.collection(SHARE_RULES_COLLECTION).update(bucketRuleLegacy.id, { tagName: bucketTagName }, { requestKey: null });
|
||||
setStore("shareRules", r => r.id === bucketRuleLegacy.id, { tagName: bucketTagName });
|
||||
console.log(`Renamed legacy bucket rule: ${bucket.name} -> ${bucketTagName}`);
|
||||
} catch (e) {
|
||||
console.error(`Failed to rename legacy bucket rule ${bucket.name}`, e);
|
||||
}
|
||||
} else if (!bucketRuleExact) {
|
||||
try {
|
||||
await pb.collection(SHARE_RULES_COLLECTION).create({
|
||||
ownerId: currentUserId,
|
||||
targetUserId: currentUserId,
|
||||
type: 'tag',
|
||||
tagName: bucket.name,
|
||||
tagName: bucketTagName,
|
||||
share_mode: 'SEND_TASK'
|
||||
});
|
||||
}, { requestKey: null });
|
||||
} catch (e) {
|
||||
console.error(`Failed to ensure system rule for bucket ${bucket.name}`, e);
|
||||
console.error(`Failed to ensure system rule for bucket ${bucketTagName}`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Sync User Tags ---
|
||||
for (const user of users) {
|
||||
const userName = user.name || user.email;
|
||||
if (!userName) continue;
|
||||
const originalName = user.name || user.email;
|
||||
if (!originalName) continue;
|
||||
const userName = `@${originalName}`;
|
||||
|
||||
const tagExists = tagDefinitions.find(t => t.name === userName);
|
||||
if (!tagExists) {
|
||||
const tagExistsExact = tagDefinitions.find(t => t.name.toLowerCase() === userName.toLowerCase());
|
||||
if (!tagExistsExact) {
|
||||
try {
|
||||
const record = await pb.collection(TAGS_COLLECTION).create({
|
||||
user: currentUserId,
|
||||
@@ -1515,7 +1770,7 @@ export const syncSystemTagsAndRules = async () => {
|
||||
color: "#64748b",
|
||||
theme: "dark",
|
||||
isUser: true
|
||||
});
|
||||
}, { requestKey: null });
|
||||
setStore("tagDefinitions", prev => [...prev, {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
@@ -1527,34 +1782,62 @@ export const syncSystemTagsAndRules = async () => {
|
||||
} catch (e) {
|
||||
// console.error(`Failed to create user tag ${userName}`, e);
|
||||
}
|
||||
} else if (!tagExists.isUser) {
|
||||
} else if (!tagExistsExact.isUser) {
|
||||
try {
|
||||
await pb.collection(TAGS_COLLECTION).update(tagExists.id, { isUser: true });
|
||||
setStore("tagDefinitions", d => d.id === tagExists.id, { isUser: true });
|
||||
await pb.collection(TAGS_COLLECTION).update(tagExistsExact.id, { isUser: true }, { requestKey: null });
|
||||
setStore("tagDefinitions", d => d.id === tagExistsExact.id, { isUser: true });
|
||||
} catch (e) {
|
||||
console.error(`Failed to flag tag ${userName} as user`, e);
|
||||
}
|
||||
}
|
||||
|
||||
// --- User Share Rule Sync ---
|
||||
if (user.id !== currentUserId) {
|
||||
const matchingRules = normalizedRules.filter(r =>
|
||||
r.targetUserId === user.id &&
|
||||
// --- Ensure Self-Rule for Current User (Global Subscription) ---
|
||||
if (user.id === currentUserId) {
|
||||
const selfRuleExact = normalizedRules.find(r =>
|
||||
r.ownerId === currentUserId &&
|
||||
r.targetUserId === currentUserId &&
|
||||
r.type === 'tag' &&
|
||||
r.tagName === userName
|
||||
);
|
||||
const selfRuleLegacy = normalizedRules.find(r =>
|
||||
r.ownerId === currentUserId &&
|
||||
r.targetUserId === currentUserId &&
|
||||
r.type === 'tag' &&
|
||||
r.tagName === originalName
|
||||
);
|
||||
|
||||
if (matchingRules.length === 0) {
|
||||
await addShareRule('tag', user.id, userName, 'ADD_USER');
|
||||
} else if (matchingRules.length > 1) {
|
||||
const [, ...remove] = matchingRules;
|
||||
for (const rule of remove) {
|
||||
try {
|
||||
await pb.collection(SHARE_RULES_COLLECTION).delete(rule.id);
|
||||
setStore("shareRules", list => list.filter(r => r.id !== rule.id));
|
||||
} catch (e) {
|
||||
console.error("Failed to delete duplicate rule:", e);
|
||||
}
|
||||
if (selfRuleLegacy && !selfRuleExact) {
|
||||
// Rename legacy self-rule
|
||||
try {
|
||||
await pb.collection(SHARE_RULES_COLLECTION).update(selfRuleLegacy.id, { tagName: userName }, { requestKey: null });
|
||||
setStore("shareRules", r => r.id === selfRuleLegacy.id, { tagName: userName });
|
||||
console.log(`Renamed legacy self-rule: ${originalName} -> ${userName}`);
|
||||
} catch (e) {
|
||||
console.error(`Failed to rename legacy self-rule ${originalName}`, e);
|
||||
}
|
||||
} else if (!selfRuleExact) {
|
||||
try {
|
||||
const record = await pb.collection(SHARE_RULES_COLLECTION).create({
|
||||
ownerId: currentUserId,
|
||||
targetUserId: currentUserId,
|
||||
type: 'tag',
|
||||
tagName: userName,
|
||||
share_mode: 'ADD_USER'
|
||||
}, { requestKey: null });
|
||||
|
||||
const newRule: ShareRule = {
|
||||
id: record.id,
|
||||
ownerId: currentUserId,
|
||||
targetUserId: currentUserId,
|
||||
type: 'tag',
|
||||
tagName: userName,
|
||||
share_mode: 'ADD_USER'
|
||||
};
|
||||
setStore("shareRules", prev => [...prev, newRule]);
|
||||
|
||||
console.log(`Created self-rule for global sharing: ${userName}`);
|
||||
} catch (e) {
|
||||
console.error(`Failed to create self-rule for ${userName}`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1573,9 +1856,17 @@ export const renameTagDefinition = async (oldName: string, newName: string) => {
|
||||
if (!def) return;
|
||||
|
||||
const finalName = newName.trim();
|
||||
const finalNameLower = finalName.toLowerCase();
|
||||
|
||||
// Check availability (case-insensitive)
|
||||
const duplicate = store.tagDefinitions.find(d => d.name.toLowerCase() === finalNameLower && d.id !== def.id);
|
||||
if (duplicate) {
|
||||
toast.error(`Tag "${duplicate.name}" already exists.`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await pb.collection(TAGS_COLLECTION).update(def.id, { name: finalName });
|
||||
await pb.collection(TAGS_COLLECTION).update(def.id, { name: finalName }, { requestKey: null });
|
||||
setStore("tagDefinitions", (d) => d.id === def.id, { name: finalName });
|
||||
|
||||
const tasksWithTag = store.tasks.filter(t => t.tags?.includes(oldName));
|
||||
@@ -1584,6 +1875,18 @@ export const renameTagDefinition = async (oldName: string, newName: string) => {
|
||||
const uniqueTags = [...new Set(newTags)];
|
||||
updateTask(task.id, { tags: uniqueTags });
|
||||
}
|
||||
|
||||
// Sync Share Rules
|
||||
const rulesWithTag = store.shareRules.filter(r => r.tagName === oldName);
|
||||
for (const rule of rulesWithTag) {
|
||||
try {
|
||||
await pb.collection(SHARE_RULES_COLLECTION).update(rule.id, { tagName: finalName }, { requestKey: null });
|
||||
setStore("shareRules", (r) => r.id === rule.id, { tagName: finalName });
|
||||
console.log(`Synced ShareRule ${rule.id}: ${oldName} -> ${finalName}`);
|
||||
} catch (ruleErr) {
|
||||
console.error(`Failed to sync ShareRule ${rule.id} during tag rename:`, ruleErr);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to rename tag:", err);
|
||||
toast.error("Failed to rename tag.");
|
||||
@@ -1621,13 +1924,43 @@ export const loadAllHistory = async () => {
|
||||
}).catch(() => []));
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Add Share Rules
|
||||
const incomingRules = store.shareRules.filter(r => r.targetUserId === userId);
|
||||
console.log("[DEBUG] loadAllHistory - Incoming Rules for User:", userId, incomingRules);
|
||||
|
||||
if (incomingRules.length > 0) {
|
||||
const rulesToFetch: string[] = [];
|
||||
|
||||
incomingRules.forEach((rule: any) => {
|
||||
let filter = `user = "${rule.ownerId}"`;
|
||||
if (rule.type === 'tag' && rule.tagName) filter += ` && tags ~ "${rule.tagName}"`;
|
||||
promises.push(pb.collection(TASGRID_COLLECTION).getFullList({ filter, sort: '-created', fields: LIST_FIELDS, requestKey: null }).catch(() => []));
|
||||
// Global Rule (Self-Targeted)
|
||||
if (rule.ownerId === rule.targetUserId && rule.type === 'tag' && rule.tagName) {
|
||||
console.log("[DEBUG] Found Self-Rule:", rule.tagName);
|
||||
rulesToFetch.push(`tags ~ "${rule.tagName}"`);
|
||||
} else {
|
||||
// Standard Rule
|
||||
let f = `user = "${rule.ownerId}"`;
|
||||
if (rule.type === 'tag' && rule.tagName) f += ` && tags ~ "${rule.tagName}"`;
|
||||
rulesToFetch.push(f);
|
||||
}
|
||||
});
|
||||
|
||||
// Optimization: Group Global Rules?
|
||||
// For now, simpler to just push them all.
|
||||
// But if we have many global rules (e.g. buckets), we might want to group them.
|
||||
// The Global Rules usually behave like buckets.
|
||||
// Let's just push them as individual queries for safety.
|
||||
|
||||
rulesToFetch.forEach(filter => {
|
||||
console.log("[DEBUG] Fetching with filter:", filter);
|
||||
promises.push(pb.collection(TASGRID_COLLECTION).getFullList({ filter, sort: '-created', fields: LIST_FIELDS, requestKey: null }).then(res => {
|
||||
console.log(`[DEBUG] Filter "${filter}" returned ${res.length} items`);
|
||||
return res;
|
||||
}).catch(err => {
|
||||
console.error(`[DEBUG] Filter "${filter}" FAILED:`, err);
|
||||
return [];
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1683,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, {
|
||||
@@ -1716,7 +2050,7 @@ export const addTemplate = async (template: Omit<TaskTemplate, "id">) => {
|
||||
completed: false,
|
||||
startDate: new Date().toISOString(),
|
||||
dueDate: new Date().toISOString()
|
||||
});
|
||||
}, { requestKey: null });
|
||||
|
||||
const newTemplate = { ...template, id: record.id };
|
||||
// Check if already added by realtime subscription
|
||||
@@ -1875,7 +2209,8 @@ export const loadShareRules = async () => {
|
||||
try {
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
const records = await pb.collection(SHARE_RULES_COLLECTION).getFullList({
|
||||
filter: `ownerId = "${currentUserId}" || targetUserId = "${currentUserId}"`
|
||||
filter: `ownerId = "${currentUserId}" || targetUserId = "${currentUserId}"`,
|
||||
requestKey: null
|
||||
});
|
||||
|
||||
const rules: ShareRule[] = records.map(r => ({
|
||||
@@ -1896,6 +2231,23 @@ export const loadShareRules = async () => {
|
||||
export const addShareRule = async (type: 'tag' | 'all', targetUserId: string, tagName?: string, share_mode: 'ADD_USER' | 'SEND_TASK' = 'ADD_USER') => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
|
||||
// Check for duplicates
|
||||
// We check against the local store to avoid network round-trip delay issues
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
const exists = store.shareRules.some(r =>
|
||||
r.ownerId === currentUserId &&
|
||||
r.targetUserId === targetUserId &&
|
||||
r.type === type &&
|
||||
(type === 'all' || r.tagName === tagName)
|
||||
// We don't strictly check share_mode because having two rules for same tag/person with different modes is confusing.
|
||||
// Usually we update the existing one if mode changes.
|
||||
);
|
||||
|
||||
if (exists) {
|
||||
// toast.info("Share rule already exists.");
|
||||
return; // Silent return or update?
|
||||
}
|
||||
|
||||
try {
|
||||
await pb.collection(SHARE_RULES_COLLECTION).create({
|
||||
ownerId: pb.authStore.model?.id,
|
||||
@@ -1903,7 +2255,7 @@ export const addShareRule = async (type: 'tag' | 'all', targetUserId: string, ta
|
||||
type,
|
||||
tagName: type === 'tag' ? tagName : null,
|
||||
share_mode: share_mode || 'ADD_USER'
|
||||
});
|
||||
}, { requestKey: null });
|
||||
// Realtime subscription will handle adding to store
|
||||
toast.success(`Share rule created`);
|
||||
} catch (err) {
|
||||
@@ -1916,7 +2268,7 @@ export const updateShareRule = async (ruleId: string, updates: Partial<ShareRule
|
||||
if (!pb.authStore.isValid) return;
|
||||
|
||||
try {
|
||||
await pb.collection(SHARE_RULES_COLLECTION).update(ruleId, updates);
|
||||
await pb.collection(SHARE_RULES_COLLECTION).update(ruleId, updates, { requestKey: null });
|
||||
setStore("shareRules", (rules) =>
|
||||
rules.map(r => r.id === ruleId ? { ...r, ...updates } : r)
|
||||
);
|
||||
|
||||
+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>
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import { type Component, Show } from "solid-js";
|
||||
import { ArrowLeft, ExternalLink, HelpCircle, Zap, ShieldCheck, ListTodo, LayoutDashboard, Snowflake, Pickaxe, Clock, ArrowUpCircle, Search, Palette, Trash2, Smartphone, BarChart3 } from "lucide-solid";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface HelpViewProps {
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
export const HelpView: Component<HelpViewProps> = (props) => {
|
||||
return (
|
||||
<div class="w-full max-w-3xl mx-auto py-4 sm:py-10 px-4 sm:px-0 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||
<header class="flex items-center gap-4 mb-8">
|
||||
<Show when={props.onBack}>
|
||||
<Button variant="ghost" size="icon" onClick={() => props.onBack?.()} class="h-10 w-10 rounded-xl">
|
||||
<ArrowLeft size={20} />
|
||||
</Button>
|
||||
</Show>
|
||||
<div>
|
||||
<h1 class="text-2xl sm:text-4xl font-black tracking-tighter flex items-center gap-3">
|
||||
<HelpCircle size={32} class="text-primary hidden sm:block" />
|
||||
User Guide
|
||||
</h1>
|
||||
<p class="text-[0.625rem] sm:text-xs text-muted-foreground font-mono uppercase tracking-widest opacity-70">
|
||||
Everything you need to know about TaskGrid
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="space-y-12 pb-20">
|
||||
{/* Welcome & Access */}
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-xl font-bold border-b border-border pb-2 flex items-center gap-2">
|
||||
<ShieldCheck size={20} class="text-primary" />
|
||||
Welcome & Access
|
||||
</h2>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="p-4 rounded-2xl bg-muted/30 border border-border/50">
|
||||
<p class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground mb-1">Website</p>
|
||||
<a href="https://tasgrid.ccllc.pro" target="_blank" class="text-sm font-bold text-primary flex items-center gap-1 hover:underline">
|
||||
tasgrid.ccllc.pro
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
</div>
|
||||
<div class="p-4 rounded-2xl bg-muted/30 border border-border/50">
|
||||
<p class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground mb-1">Login</p>
|
||||
<p class="text-sm font-medium">Use your <strong>Cardoza Construction email</strong> (the same one you use for Outlook). Your password should match your email password as well.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Basic Features */}
|
||||
<section class="space-y-6">
|
||||
<h2 class="text-xl font-bold border-b border-border pb-2 flex items-center gap-2">
|
||||
<Zap size={20} class="text-yellow-500" />
|
||||
Basic Features (Getting Started)
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-bold text-lg">Creating Your First Task</h3>
|
||||
<ol class="space-y-3 pl-1">
|
||||
{[
|
||||
<>Click the <strong>large plus (+) sign</strong> in the bottom right corner (or type <code>control + k</code>).</>,
|
||||
<><strong>Title</strong>: Enter a clear name for what needs to be done.</>,
|
||||
<><strong>Priority</strong>: Choose from 1 (trivial) to 10 (absolutely necessary).</>,
|
||||
<><strong>Urgency & Due Date</strong>: These are linked!
|
||||
<ul class="mt-2 space-y-1 ml-4 list-disc text-xs text-muted-foreground">
|
||||
<li>Pick an <strong>Urgency level</strong> (10 is today, 1 is 6 months from now), and the due date will be calculated for you.</li>
|
||||
<li>Alternatively, pick a <strong>Due Date</strong> using the calendar, and the urgency will adjust automatically.</li>
|
||||
</ul>
|
||||
</>,
|
||||
<><strong>Size</strong>: This defaults to <strong>5</strong>. Adjust it if the task is very small (15 mins) or very large (multiple days).</>,
|
||||
<><strong>Description</strong>: Add notes or details. Use the <strong>forward slash (/)</strong> to reveal formatting options like bold text, checklists, and even adding images.</>,
|
||||
<>Click <strong>Create Task</strong>.</>
|
||||
].map((item, i) => (
|
||||
<li class="flex gap-4 items-start">
|
||||
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-bold">{i + 1}</span>
|
||||
<div class="text-sm leading-relaxed">{item}</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 pt-4">
|
||||
<h3 class="font-bold text-lg">Viewing Your Tasks</h3>
|
||||
<p class="text-sm text-muted-foreground mb-4">TaskGrid sorts your tasks so you don't have to. The primary views are:</p>
|
||||
<div class="grid gap-4">
|
||||
{[
|
||||
{ icon: ListTodo, label: "Focus (Default)", desc: "Your most important tasks, ranked by a combination of Urgency and Priority." },
|
||||
{
|
||||
icon: LayoutDashboard,
|
||||
label: "Strategy Matrix",
|
||||
desc: "A 4-quadrant grid mapping Urgency vs. Priority.",
|
||||
sub: [
|
||||
{ l: "Do First", d: "Urgent & Important" },
|
||||
{ l: "Schedule", d: "Important but not urgent yet" },
|
||||
{ l: "Delegate", d: "Urgent but not important" },
|
||||
{ l: "Eliminate", d: "Neither urgent nor important" }
|
||||
]
|
||||
},
|
||||
{ icon: Snowflake, label: "Snowball", desc: "Shows your smallest tasks first to help you build momentum." },
|
||||
{ icon: Pickaxe, label: "Dig In", desc: "Shows your largest tasks first for when you have time to focus." },
|
||||
{ icon: Clock, label: "Urgency", desc: "Sorts tasks purely by when they are due." },
|
||||
{ icon: ArrowUpCircle, label: "Priority", desc: "Sorts tasks purely by how important they are." }
|
||||
].map(view => (
|
||||
<div class="p-4 rounded-xl border border-border/50 bg-card/50 flex gap-4 items-start">
|
||||
<div class="mt-1 text-primary p-2 rounded-lg bg-primary/5">
|
||||
<view.icon size={18} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-bold">{view.label}</p>
|
||||
<p class="text-sm text-muted-foreground leading-relaxed">{view.desc}</p>
|
||||
{view.sub && (
|
||||
<div class="grid grid-cols-2 gap-2 pt-2 border-t border-border/20">
|
||||
{view.sub.map(s => (
|
||||
<div>
|
||||
<p class="text-[0.625rem] font-black uppercase text-primary/70">{s.l}</p>
|
||||
<p class="text-[0.625rem] text-muted-foreground">{s.d}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 pt-4">
|
||||
<h3 class="font-bold text-lg">Other Info</h3>
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
{[
|
||||
{ icon: Search, label: "Search & Filters", desc: "Use the bar at the top right to find specific tasks or filter by priority, urgency, or tasks edited today." },
|
||||
{ icon: Palette, label: "Themes", desc: "Switch between Light Mode and Dark Mode in the Settings." },
|
||||
{ icon: Trash2, label: "Trash", desc: "Deleted tasks are kept for 7 days before being permanently removed. You can recover them in Settings." }
|
||||
].map(info => (
|
||||
<div class="p-3 rounded-xl border border-border/50 bg-card/10">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<info.icon size={14} class="text-primary" />
|
||||
<p class="text-xs font-bold">{info.label}</p>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground leading-relaxed">{info.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 pt-4">
|
||||
<h3 class="font-bold text-lg">Mobile Installation</h3>
|
||||
<p class="text-sm text-muted-foreground mb-3">TaskGrid works like a dedicated app on your phone:</p>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="p-4 rounded-xl bg-primary/5 border border-primary/10">
|
||||
<p class="text-xs font-bold flex items-center gap-2 mb-2"><Smartphone size={14} /> Android</p>
|
||||
<p class="text-xs text-muted-foreground">Tap the three dots in Chrome, select "Add to Home Screen," then "Install."</p>
|
||||
</div>
|
||||
<div class="p-4 rounded-xl bg-primary/5 border border-primary/10">
|
||||
<p class="text-xs font-bold flex items-center gap-2 mb-2"><Smartphone size={14} /> iPhone</p>
|
||||
<p class="text-xs text-muted-foreground">Tap the Share icon in Safari and select "Add to Home Screen."</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Advanced Features */}
|
||||
<section class="space-y-6">
|
||||
<h2 class="text-xl font-bold border-b border-border pb-2 flex items-center gap-2">
|
||||
<Zap size={20} class="text-primary" />
|
||||
Advanced Features (Teams & Automation)
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-bold text-lg">Sharing & Collaboration</h3>
|
||||
<p class="text-sm text-muted-foreground mb-4">Collaboration in TaskGrid is flexible and transparent:</p>
|
||||
<ol class="space-y-4 text-sm leading-relaxed">
|
||||
<li>
|
||||
<strong>1. Manual Sharing</strong>: Open a task and use the <strong>Share</strong> menu to add a specific person.
|
||||
<ul class="ml-4 mt-1 list-disc text-muted-foreground">
|
||||
<li>You can also <strong>unshare and split</strong> tasks here. Unshare just removes it from their list and split gives each of you a unique copy of the task.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>2. Tag-Based User Sharing</strong>: Add a tag that matches a coworker's name (e.g., "John Smith").
|
||||
<ul class="ml-4 mt-1 list-disc text-muted-foreground">
|
||||
<li>This automatically shares the task with them.</li>
|
||||
<li>Both of you can edit the task together (Collaborative mode).</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>3. Collaboration vs. Handoff</strong>:
|
||||
<ul class="ml-4 mt-1 list-disc text-muted-foreground">
|
||||
<li><strong>Collaborate</strong>: Both users can see and edit.</li>
|
||||
<li><strong>Handoff</strong>: The task is transferred to the other person, and they become the new owner.</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 pt-4">
|
||||
<h3 class="font-bold text-lg">Team Spaces (Buckets)</h3>
|
||||
<p class="text-sm text-muted-foreground leading-relaxed italic mb-2">"Buckets" are shared areas for teams (like "Shop" or "Maintenance").</p>
|
||||
<ul class="space-y-2 text-sm leading-relaxed">
|
||||
<li><strong>Move to Bucket</strong>: Simply add a tag that matches the bucket name (e.g., tagging a task with "Shop").</li>
|
||||
<li><strong>Visibility</strong>: By default, bucketed tasks are hidden from your main view to keep your list clean. You can view them by selecting the bucket in the sidebar but they will only appear if you have pinned them.</li>
|
||||
<li><strong>Pinning</strong>: In order to work in a bucket, you must "Pin" it in Settings so it always shows up in your sidebar.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 pt-4">
|
||||
<h3 class="font-bold text-lg flex items-center gap-2"><BarChart3 size={20} class="text-primary" /> Oversight (Share All)</h3>
|
||||
<p class="text-sm text-muted-foreground leading-relaxed">
|
||||
Managers and team leads can ask their teams to create a "share all" sharing rule in setting. This will add that user's bucket as an option in the sidebar just like shared buckets. Clicking a user's name in your sidebar allows you to view their entire task list, making it easy to track progress across the team.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 pt-4">
|
||||
<h3 class="font-bold text-lg">Automatic Tasks (Recurrence)</h3>
|
||||
<p class="text-sm text-muted-foreground leading-relaxed">
|
||||
For recurring work, you can set a task to repeat <strong>Daily</strong>, <strong>Weekly</strong> (on any specified days of the week), or <strong>Monthly</strong> (on a specified day of the month).
|
||||
</p>
|
||||
<ul class="space-y-1 ml-4 list-disc text-sm text-muted-foreground">
|
||||
<li>When you mark it complete, TaskGrid automatically "un-completes" it for you when the next cycle begins.</li>
|
||||
<li>You'll see status labels like "Complete until Monday" to let you know exactly when it will reappear.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 pt-4">
|
||||
<h3 class="font-bold text-lg">Bulk Import (Filling a Bucket)</h3>
|
||||
<p class="text-sm text-muted-foreground leading-relaxed italic mb-2">Need to add a long list of tasks at once?</p>
|
||||
<ol class="space-y-2 text-sm leading-relaxed list-decimal ml-4">
|
||||
<li>Go to <strong>Settings > Import Tasks</strong>.</li>
|
||||
<li>Paste your list (one task per line).</li>
|
||||
<li>Click "Parse and Configure."</li>
|
||||
<li>Use the <strong>Bulk Update</strong> tool at the top to set the Priority, Urgency, and Tag (e.g., "Shop") for all tasks at once.</li>
|
||||
<li>Update tasks that need individual urgency and priority. (Usually all tasks need individual urgency and priority because if everything is set the same TasGrid can't sort them)</li>
|
||||
<li>Click <strong>Import Tasks</strong>.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Power User Tips */}
|
||||
<section class="p-6 rounded-2xl bg-primary/5 border border-primary/20 space-y-4">
|
||||
<h2 class="text-xl font-bold flex items-center gap-2">
|
||||
<Zap size={20} class="text-primary" />
|
||||
Power User Tips
|
||||
</h2>
|
||||
<ul class="space-y-4">
|
||||
<li class="text-sm flex gap-3">
|
||||
<span class="text-primary font-bold">/</span>
|
||||
<span><strong>Quick Entry Shorthands</strong>: Type <code>/p5</code> (priority), <code>/u8</code> (urgency), or <code>/t Wood</code> (tags) directly into the title box when creating a task to set properties instantly without touching your mouse!</span>
|
||||
</li>
|
||||
<li class="text-sm flex gap-3">
|
||||
<span class="text-primary font-bold">/</span>
|
||||
<span><strong>Task Templates</strong>: If you have recurring projects with the same notes and tags, create a <strong>Template</strong> in Settings. You can apply it with a single click in the creation menu.</span>
|
||||
</li>
|
||||
<li class="text-sm flex gap-3">
|
||||
<span class="text-primary font-bold">/</span>
|
||||
<span><strong>Matrix Color Pro-tip</strong>: The dots on your Strategy Matrix change color based on <strong>Size</strong>—**Blue** dots are small wins (easy tasks), and **Red** dots are major projects (time-consuming tasks).</span>
|
||||
</li>
|
||||
<li class="text-sm flex gap-3">
|
||||
<span class="text-primary font-bold">/</span>
|
||||
<span><strong>Smart Tagging</strong>: If you are currently viewing a Bucket (e.g., "Shop"), when you click the Plus button to add a task, TaskGrid will automatically add the "Shop" tag for you.</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Help/Contact */}
|
||||
<footer class="pt-10 border-t border-border flex flex-col items-center text-center space-y-4">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
If the app feels out of sync, a quick <button onClick={() => location.reload()} class="text-primary font-bold hover:underline cursor-pointer bg-transparent border-none p-0 inline">refresh</button> usually fixes it. Most updates happen automatically in real-time!
|
||||
</p>
|
||||
<p class="text-sm font-medium">
|
||||
If you have any other questions contact Timothy Cardoza or IT.
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,377 @@
|
||||
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, ChevronRight } from "lucide-solid";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TaskEditor } from "@/components/TaskEditor";
|
||||
import { NOTES_COLLECTION } from "@/lib/constants";
|
||||
import { TaskCard } from "@/components/TaskCard";
|
||||
import { NotesSidebar } from "@/components/NotesSidebar";
|
||||
|
||||
export const NotepadView: Component<{
|
||||
selectedNoteId: string | null;
|
||||
setSelectedNoteId: (id: string | null) => void;
|
||||
hideNavigation?: boolean;
|
||||
}> = (props) => {
|
||||
const [isLinking, setIsLinking] = createSignal(false);
|
||||
const [linkSearchQuery, setLinkSearchQuery] = createSignal("");
|
||||
const [isLinkedTasksOpen, setIsLinkedTasksOpen] = createSignal(true);
|
||||
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
|
||||
// Derived states
|
||||
const activeNote = createMemo(() => {
|
||||
const id = props.selectedNoteId?.trim();
|
||||
return store.notes.find(n => n.id === id) || null;
|
||||
});
|
||||
|
||||
const handleUpdateNote = async (id: string, data: Partial<Note>) => {
|
||||
try {
|
||||
await pb.collection(NOTES_COLLECTION).update(id, data);
|
||||
} catch (e: any) {
|
||||
if (!e.isAbort) {
|
||||
console.error("Failed to update note", e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let contentUpdateTimeout: number | undefined;
|
||||
const handleUpdateContent = (id: string, html: string) => {
|
||||
clearTimeout(contentUpdateTimeout);
|
||||
contentUpdateTimeout = window.setTimeout(async () => {
|
||||
try {
|
||||
await pb.collection(NOTES_COLLECTION).update(id, { content: html });
|
||||
} catch (e: any) {
|
||||
if (!e.isAbort) {
|
||||
console.error("Failed to update note content", e);
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleRenameNote = async (id: string, oldTitle: string, newTitle: string) => {
|
||||
// 1. Update the note title
|
||||
await handleUpdateNote(id, { title: newTitle });
|
||||
|
||||
// 2. Rename the TagDefinition (preserves color) and propagate to all tagged tasks
|
||||
// Use '#' prefix for note tags
|
||||
await renameTagDefinition(`#${oldTitle}`, `#${newTitle}`);
|
||||
};
|
||||
const handleDeleteNote = async (id: string) => {
|
||||
try {
|
||||
await pb.collection(NOTES_COLLECTION).delete(id);
|
||||
if (props.selectedNoteId === id) {
|
||||
props.setSelectedNoteId(null);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to delete note", e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLinkTask = async (taskId: string) => {
|
||||
const note = activeNote();
|
||||
if (!note) return;
|
||||
const newTasks = [...(note.tasks || []), taskId];
|
||||
await handleUpdateNote(note.id, { tasks: newTasks });
|
||||
setIsLinking(false);
|
||||
setLinkSearchQuery("");
|
||||
};
|
||||
|
||||
const handleUnlinkTask = async (taskId: string) => {
|
||||
const note = activeNote();
|
||||
if (!note) return;
|
||||
const newTasks = (note.tasks || []).filter(id => id !== taskId);
|
||||
await handleUpdateNote(note.id, { tasks: newTasks });
|
||||
};
|
||||
|
||||
// Linked tasks calculation
|
||||
const linkedTasks = createMemo(() => {
|
||||
const note = activeNote();
|
||||
if (!note) return [];
|
||||
const noteTagName = `#${note.title}`;
|
||||
return store.tasks.filter(t => {
|
||||
// Explicit relation
|
||||
if (note.tasks && note.tasks.includes(t.id)) return true;
|
||||
// Tag relation (case-insensitive title match with # prefix)
|
||||
if (t.tags && t.tags.some(tag => tag.toLowerCase() === noteTagName.toLowerCase())) return true;
|
||||
return false;
|
||||
});
|
||||
});
|
||||
|
||||
const unlinkedTasks = createMemo(() => {
|
||||
const note = activeNote();
|
||||
if (!note) return [];
|
||||
const q = linkSearchQuery().toLowerCase();
|
||||
|
||||
// Get names of pinned/subscribed buckets
|
||||
const pinnedBucketNames = store.buckets
|
||||
.filter(b => store.subscribedBuckets.includes(b.id))
|
||||
.map(b => b.name.toLowerCase());
|
||||
|
||||
const allBucketNames = store.buckets.map(b => b.name.toLowerCase());
|
||||
|
||||
return store.tasks.filter(t => {
|
||||
if (t.deletedAt) return false; // Don't show trashed tasks
|
||||
|
||||
// Filter out tasks from unpinned buckets
|
||||
const taskBucketTags = (t.tags || []).filter(tag => allBucketNames.includes(tag.toLowerCase()));
|
||||
if (taskBucketTags.length > 0) {
|
||||
// If it belongs to a bucket, at least one must be pinned
|
||||
const hasPinnedBucket = taskBucketTags.some(tag => pinnedBucketNames.includes(tag.toLowerCase()));
|
||||
if (!hasPinnedBucket) return false;
|
||||
}
|
||||
|
||||
if (note.tasks && note.tasks.includes(t.id)) return false; // Already linked
|
||||
if (!q) return false; // Don't show all by default
|
||||
return t.title.toLowerCase().includes(q);
|
||||
}).slice(0, 10);
|
||||
});
|
||||
|
||||
const openQuickEntry = () => {
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { metaKey: true, key: 'k' }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="flex flex-col md:flex-row h-full bg-background md:bg-card md:border md:border-border md:rounded-xl md:shadow-sm overflow-hidden relative">
|
||||
{/* Mobile Notes List */}
|
||||
<Show when={!props.hideNavigation}>
|
||||
<div class={cn(
|
||||
"md:hidden absolute inset-0 z-10 bg-card overflow-hidden flex flex-col h-full transition-all duration-200 ease-out",
|
||||
props.selectedNoteId ? "opacity-0 pointer-events-none -translate-x-8" : "opacity-100 translate-x-0"
|
||||
)}>
|
||||
<NotesSidebar selectedNoteId={props.selectedNoteId} setSelectedNoteId={props.setSelectedNoteId} />
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Note Editor (Main Panel) */}
|
||||
<div class={cn(
|
||||
"flex-1 bg-card min-w-0 transition-all duration-200 ease-out h-full overflow-y-auto md:overflow-hidden absolute md:relative inset-0 z-20 md:z-0",
|
||||
!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-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={
|
||||
<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 italic">Loading note...</p>
|
||||
</Show>
|
||||
</div>
|
||||
}>
|
||||
{(note) => {
|
||||
const isOwner = note().user === currentUserId;
|
||||
const canEdit = isOwner || !note().isPrivate;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Editor Area */}
|
||||
<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
|
||||
class="text-3xl sm:text-4xl font-black tracking-tight bg-transparent border-none outline-none focus:ring-0 flex-1 placeholder:text-muted-foreground/30 px-0 min-w-0"
|
||||
value={note().title}
|
||||
placeholder="Note Title"
|
||||
readOnly={!canEdit}
|
||||
onBlur={(e) => {
|
||||
const val = e.currentTarget.value.trim() || 'Untitled';
|
||||
if (val !== note().title) handleRenameNote(note().id, note().title, val);
|
||||
}}
|
||||
/>
|
||||
<div class="flex items-center gap-2 shrink-0 pt-1">
|
||||
<Show when={isOwner}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn("h-8 text-[0.625rem] font-bold uppercase tracking-widest", note().isPrivate ? "text-orange-500 hover:bg-orange-500/10" : "text-muted-foreground")}
|
||||
onClick={() => handleUpdateNote(note().id, { isPrivate: !note().isPrivate })}
|
||||
>
|
||||
<Show when={note().isPrivate} fallback={<><Unlock size={12} class="mr-1.5" /> Public</>}>
|
||||
<><Lock size={12} class="mr-1.5" /> Private</>
|
||||
</Show>
|
||||
</Button>
|
||||
</Show>
|
||||
|
||||
<Show when={isOwner}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-lg"
|
||||
onClick={() => handleDeleteNote(note().id)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Editor Instance */}
|
||||
<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)}
|
||||
editable={canEdit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Linked Tasks Sidebar */}
|
||||
<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={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" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search tasks to link..."
|
||||
value={linkSearchQuery()}
|
||||
onInput={(e) => setLinkSearchQuery(e.currentTarget.value)}
|
||||
class="w-full bg-background border border-border/50 rounded-lg pl-8 pr-3 py-1.5 text-xs outline-none focus:ring-1 focus:ring-primary/30 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
<Show when={linkSearchQuery() && unlinkedTasks().length > 0}>
|
||||
<div class="bg-background border border-border rounded-lg shadow-xl overflow-hidden max-h-48 overflow-y-auto">
|
||||
<For each={unlinkedTasks()}>
|
||||
{(t) => (
|
||||
<button
|
||||
class="w-full text-left p-2 text-xs hover:bg-muted border-b border-border last:border-0 truncate"
|
||||
onClick={() => handleLinkTask(t.id)}
|
||||
>
|
||||
{t.title}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<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>
|
||||
}>
|
||||
{(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 >
|
||||
</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>
|
||||
|
||||
+912
-902
File diff suppressed because it is too large
Load Diff
+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>
|
||||
|
||||
+366
@@ -0,0 +1,366 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TasGrid Parent App Simulator</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui;
|
||||
padding: 20px;
|
||||
background: #f4f4f5;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.iframe-container {
|
||||
flex: 1;
|
||||
border: 1px solid #ddd;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 600px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ddd;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.login-form input {
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.login-form button {
|
||||
padding: 10px;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.login-form button:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.status.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.status.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
display: block;
|
||||
}
|
||||
|
||||
iframe {
|
||||
border: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
button.send-manual {
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
input.manual-token {
|
||||
padding: 8px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
}
|
||||
</style>
|
||||
<script src="https://cdn.jsdelivr.net/npm/pocketbase/dist/pocketbase.umd.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>TasGrid Parent App Simulator</h1>
|
||||
|
||||
<div class="controls">
|
||||
<div id="login-section">
|
||||
<h3>Login to Parent Application</h3>
|
||||
<p>This simulates the parent application authenticating with the shared backend.</p>
|
||||
<div class="login-form">
|
||||
<input type="email" id="email" placeholder="Email" value="">
|
||||
<input type="password" id="password" placeholder="Password" value="">
|
||||
<button onclick="login()">Login & Sync Iframes</button>
|
||||
</div>
|
||||
<div id="status" class="status"></div>
|
||||
</div>
|
||||
|
||||
<div id="manual-section" style="margin-top: 30px; border-top: 1px solid #eee; padding-top: 20px;">
|
||||
<p style="font-size: 13px; color: #666;"><strong>Test Deep Linking:</strong></p>
|
||||
<div style="display: flex; gap: 10px; margin-bottom: 10px;">
|
||||
<input type="text" id="note-id-input" style="flex: 1; padding: 8px;"
|
||||
placeholder="Paste Note ID here...">
|
||||
<button onclick="loadSpecificNote()" style="padding: 8px 16px; cursor: pointer;">Load Note</button>
|
||||
</div>
|
||||
|
||||
<p style="font-size: 13px; color: #666; margin-top: 20px;"><strong>Test API Creation:</strong></p>
|
||||
<div style="display: flex; gap: 10px; margin-bottom: 10px;">
|
||||
<input type="text" id="new-note-title" style="flex: 1; padding: 8px;" placeholder="New Note Title...">
|
||||
<button onclick="createNoteViaAPI()"
|
||||
style="padding: 8px 16px; cursor: pointer; background: #28a745; color: white; border: none; border-radius: 4px; font-weight: bold;">Create
|
||||
& Open Note</button>
|
||||
</div>
|
||||
|
||||
<p style="font-size: 13px; color: #666; margin-top: 20px;"><strong>Manual Auth:</strong></p>
|
||||
<input type="text" id="token" class="manual-token" placeholder="Paste PB Token here...">
|
||||
<button class="send-manual" onclick="sendAuthFromInput()">Send Manual Token</button>
|
||||
</div>
|
||||
|
||||
<div id="docs-section" style="margin-top: 30px; border-top: 1px solid #eee; padding-top: 20px;">
|
||||
<details>
|
||||
<summary style="cursor: pointer; font-weight: bold; color: #007bff;">Developer Integration Guide
|
||||
</summary>
|
||||
<div style="font-size: 14px; line-height: 1.6; margin-top: 15px;">
|
||||
<p><strong>1. Available Routes:</strong></p>
|
||||
<ul>
|
||||
<li><code>/embed/notes</code>: Renders the full notepad/editor.</li>
|
||||
<li><code>/embed/notes?noteId=XXX</code>: Renders a specific note directly.</li>
|
||||
<li><code>/embed/quick-add</code>: Renders a standalone task creation form.</li>
|
||||
</ul>
|
||||
|
||||
<p><strong>2. Creating Notes Programmatically (API):</strong></p>
|
||||
<p>Before embedding, a sister app can create a Note via PocketBase API to retrieve a new
|
||||
<code>noteId</code>. By default, notes should have an array of <code>tags</code> and a
|
||||
<code>title</code>.
|
||||
</p>
|
||||
<p><em>Using the PocketBase JS SDK:</em></p>
|
||||
<pre
|
||||
style="background: #f8f9fa; padding: 15px; border-radius: 4px; border: 1px solid #e9ecef; overflow-x: auto; font-size: 12px;">
|
||||
const record = await pb.collection('TasGrid_Notes').create({
|
||||
title: "Sister App Payload",
|
||||
content: "<p>Initial content here...</p>",
|
||||
tags: ["sister_app_export"],
|
||||
isPrivate: false,
|
||||
user: pb.authStore.model.id, // Must be authenticated user ID
|
||||
tasks: [] // Optional array of linked task IDs
|
||||
});
|
||||
const newNoteId = record.id;
|
||||
// Then iframe to /embed/notes?noteId=${newNoteId}
|
||||
</pre>
|
||||
<p><em>Using standard REST API (fetch):</em></p>
|
||||
<pre
|
||||
style="background: #f8f9fa; padding: 15px; border-radius: 4px; border: 1px solid #e9ecef; overflow-x: auto; font-size: 12px;">
|
||||
const res = await fetch('https://pocketbase.ccllc.pro/api/collections/TasGrid_Notes/records', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'YOUR_PB_AUTH_TOKEN'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: "REST Custom Note",
|
||||
user: "YOUR_USER_ID",
|
||||
tags: [],
|
||||
tasks: [],
|
||||
isPrivate: false
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
const newNoteId = data.id;</pre>
|
||||
|
||||
<p><strong>3. Authentication Protocol:</strong></p>
|
||||
<p>The parent application must send a <code>postMessage</code> to the iframe immediately after the
|
||||
iframe loads (or whenever auth state changes). Since iframes reset their internal state on
|
||||
navigation,
|
||||
you should always sync auth on the <code>onload</code> event.</p>
|
||||
<p><em>Example using vanilla JS:</em></p>
|
||||
<pre
|
||||
style="background: #f8f9fa; padding: 15px; border-radius: 4px; border: 1px solid #e9ecef; overflow-x: auto; font-size: 12px;">
|
||||
// 1. Define sync function
|
||||
function syncAuth(iframe) {
|
||||
iframe.contentWindow.postMessage({
|
||||
type: "TASGRID_AUTH",
|
||||
token: pb.authStore.token,
|
||||
user: pb.authStore.model
|
||||
}, "*");
|
||||
}
|
||||
|
||||
// 2. Attach to iframe (either in HTML or via JS)
|
||||
// <iframe onload="syncAuth(this)" src="..."></iframe>
|
||||
</pre>
|
||||
|
||||
<p><strong>4. Responsiveness:</strong></p>
|
||||
<p>Both routes are designed to be fluid. Ensure your iframe container has defined dimensions; the
|
||||
content will expand to fill it.</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="iframe-container">
|
||||
<div
|
||||
style="padding: 10px; background: #eee; border-bottom: 1px solid #ddd; font-weight: bold; font-size: 12px; display: flex; justify-content: space-between;">
|
||||
<span>Notes Embed</span>
|
||||
<code style="font-size: 10px;">/embed/notes</code>
|
||||
</div>
|
||||
<iframe id="notes-iframe" src="http://localhost:4000/embed/notes" onload="syncAuthOnLoad(this)"></iframe>
|
||||
</div>
|
||||
|
||||
<div class="iframe-container">
|
||||
<div
|
||||
style="padding: 10px; background: #eee; border-bottom: 1px solid #ddd; font-weight: bold; font-size: 12px; display: flex; justify-content: space-between;">
|
||||
<span>Quick Add Embed</span>
|
||||
<code style="font-size: 10px;">/embed/quick-add</code>
|
||||
</div>
|
||||
<iframe id="quick-add-iframe" src="http://localhost:4000/embed/quick-add"
|
||||
onload="syncAuthOnLoad(this)"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const pb = new PocketBase('https://pocketbase.ccllc.pro');
|
||||
const statusEl = document.getElementById('status');
|
||||
|
||||
function syncAuthOnLoad(iframe) {
|
||||
console.log(`Iframe ${iframe.id} loaded, checking auth sync...`);
|
||||
if (pb.authStore.isValid) {
|
||||
console.log(`Syncing existing auth to ${iframe.id}`);
|
||||
sendAuthToIframes();
|
||||
}
|
||||
}
|
||||
|
||||
function loadSpecificNote() {
|
||||
const id = document.getElementById('note-id-input').value.trim();
|
||||
const iframe = document.getElementById('notes-iframe');
|
||||
if (id) {
|
||||
iframe.src = `http://localhost:4000/embed/notes?noteId=${id}`;
|
||||
} else {
|
||||
iframe.src = `http://localhost:4000/embed/notes`;
|
||||
}
|
||||
}
|
||||
|
||||
async function createNoteViaAPI() {
|
||||
const title = document.getElementById('new-note-title').value.trim() || 'API Generated Note';
|
||||
if (!pb.authStore.isValid) {
|
||||
alert("Please log in first before creating a note via API.");
|
||||
return;
|
||||
}
|
||||
|
||||
statusEl.className = 'status';
|
||||
statusEl.textContent = 'Creating note via API...';
|
||||
statusEl.style.display = 'block';
|
||||
|
||||
try {
|
||||
// Using PocketBase SDK to mimic a REST API call from sister app
|
||||
const record = await pb.collection('TasGrid_Notes').create({
|
||||
title: title,
|
||||
content: "<p>This note was created automatically via the API script!</p>",
|
||||
tags: ["api_generated"],
|
||||
isPrivate: false,
|
||||
user: pb.authStore.model.id,
|
||||
tasks: []
|
||||
});
|
||||
|
||||
statusEl.className = 'status success';
|
||||
statusEl.textContent = `Note created successfully (ID: ${record.id})! Loading iframe...`;
|
||||
|
||||
// Automatically set the new ID in the deep link input
|
||||
document.getElementById('note-id-input').value = record.id;
|
||||
|
||||
// Automatically load it
|
||||
loadSpecificNote();
|
||||
|
||||
} catch (err) {
|
||||
statusEl.className = 'status error';
|
||||
statusEl.textContent = 'Failed to create note: ' + err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function login() {
|
||||
const email = document.getElementById('email').value;
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
statusEl.className = 'status';
|
||||
statusEl.textContent = 'Logging in...';
|
||||
statusEl.style.display = 'block';
|
||||
|
||||
try {
|
||||
const authData = await pb.collection('users').authWithPassword(email, password);
|
||||
|
||||
statusEl.className = 'status success';
|
||||
statusEl.textContent = `Logged in as ${authData.record.email}! Syncing iframes...`;
|
||||
|
||||
// Automatically send auth to iframes
|
||||
sendAuth(pb.authStore.token, authData.record);
|
||||
} catch (err) {
|
||||
statusEl.className = 'status error';
|
||||
statusEl.textContent = 'Login failed: ' + err.message;
|
||||
}
|
||||
}
|
||||
|
||||
function sendAuthFromInput() {
|
||||
const token = document.getElementById('token').value;
|
||||
if (!token) {
|
||||
alert("Please enter a token first");
|
||||
return;
|
||||
}
|
||||
sendAuth(token, null);
|
||||
}
|
||||
|
||||
function sendAuth(token, user) {
|
||||
const payload = {
|
||||
type: "TASGRID_AUTH",
|
||||
token: token,
|
||||
user: user
|
||||
};
|
||||
|
||||
const notesIframe = document.getElementById('notes-iframe').contentWindow;
|
||||
const quickAddIframe = document.getElementById('quick-add-iframe').contentWindow;
|
||||
|
||||
console.log("Sending auth to iframes...", payload);
|
||||
notesIframe.postMessage(payload, "*");
|
||||
quickAddIframe.postMessage(payload, "*");
|
||||
}
|
||||
|
||||
// Handle case where user refreshed but is still logged in to the parent
|
||||
window.addEventListener('load', () => {
|
||||
if (pb.authStore.isValid && pb.authStore.model) {
|
||||
document.getElementById('email').value = pb.authStore.model.email || "";
|
||||
statusEl.className = 'status success';
|
||||
statusEl.textContent = 'Session restored. Click login or send manual token to sync.';
|
||||
statusEl.style.display = 'block';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Reference in New Issue
Block a user