Compare commits
68 Commits
01348ce229
...
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 | |||
| c80758178c | |||
| 2e2fec4a9c | |||
| 3ab306b4d2 | |||
| 6195e2875e | |||
| 07febd7dc7 | |||
| 1a24165975 | |||
| 09240e8f89 | |||
| 759083ea7d | |||
| 49cbe6abb2 | |||
| b8f5db030c | |||
| 3d6e29d2c0 | |||
| 9a3bd4cca3 | |||
| e5c7666a2d |
@@ -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",
|
||||
|
||||
+92
-15
@@ -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';
|
||||
@@ -12,21 +14,39 @@ const MatrixView = lazy(() => import('./views/MatrixView').then(m => ({ default:
|
||||
const SettingsView = lazy(() => import('./views/SettingsView').then(m => ({ default: m.SettingsView })));
|
||||
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));
|
||||
@@ -38,6 +58,8 @@ const App: Component = () => {
|
||||
import('./views/SettingsView');
|
||||
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');
|
||||
@@ -48,19 +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() === "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,6 +1,6 @@
|
||||
import { type Component, createSignal, For, Show } from "solid-js";
|
||||
import { Search, X, Hash, ArrowUpCircle, Clock, Minus, Save, Trash2, Bookmark } from "lucide-solid";
|
||||
import { store, setFilter, clearFilter, saveFilterTemplate, applyFilterTemplate, removeFilterTemplate } from "@/store";
|
||||
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";
|
||||
import { Badge } from "./ui/badge";
|
||||
@@ -55,7 +55,7 @@ export const FilterBar: Component = () => {
|
||||
<button
|
||||
onClick={() => setFilter({ editedToday: !store.filter.editedToday })}
|
||||
class={cn(
|
||||
"flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[9px] font-black uppercase tracking-tight transition-all border",
|
||||
"flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[0.5625rem] font-black uppercase tracking-tight transition-all border",
|
||||
store.filter.editedToday
|
||||
? "bg-primary/10 text-primary border-primary/20"
|
||||
: "bg-muted/50 text-muted-foreground border-transparent hover:bg-muted"
|
||||
@@ -66,11 +66,23 @@ export const FilterBar: Component = () => {
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground hover:text-primary"
|
||||
onClick={() => {
|
||||
loadAllHistory();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
title="Load all completed tasks"
|
||||
>
|
||||
Load All
|
||||
</Button>
|
||||
<Show when={hasActiveFilters()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground hover:text-destructive"
|
||||
class="h-7 px-2 text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground hover:text-destructive"
|
||||
onClick={clearFilter}
|
||||
>
|
||||
Clear
|
||||
@@ -101,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>
|
||||
@@ -109,7 +127,7 @@ export const FilterBar: Component = () => {
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
{/* Priority Range */}
|
||||
<div class="space-y-2.5">
|
||||
<label class="text-[10px] font-black uppercase tracking-widest text-muted-foreground ml-1">Priority Range</label>
|
||||
<label class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground ml-1">Priority Range</label>
|
||||
<div class="flex items-center gap-2 bg-muted/20 p-1 rounded-xl border border-border/30">
|
||||
<Select
|
||||
value={store.filter.priorityMin.toString()}
|
||||
@@ -144,7 +162,7 @@ export const FilterBar: Component = () => {
|
||||
|
||||
{/* Urgency Range */}
|
||||
<div class="space-y-2.5">
|
||||
<label class="text-[10px] font-black uppercase tracking-widest text-muted-foreground ml-1">Urgency Range</label>
|
||||
<label class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground ml-1">Urgency Range</label>
|
||||
<div class="flex items-center gap-2 bg-muted/20 p-1 rounded-xl border border-border/30">
|
||||
<Select
|
||||
value={store.filter.urgencyMin.toString()}
|
||||
@@ -180,9 +198,9 @@ export const FilterBar: Component = () => {
|
||||
|
||||
{/* Tags Multi-select */}
|
||||
<div class="space-y-2.5">
|
||||
<label class="text-[10px] font-black uppercase tracking-widest text-muted-foreground ml-1 flex items-center justify-between">
|
||||
<label class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground ml-1 flex items-center justify-between">
|
||||
Filter by Tags
|
||||
<span class="text-[9px] lowercase font-medium opacity-50 tracking-normal text-right">
|
||||
<span class="text-[0.5625rem] lowercase font-medium opacity-50 tracking-normal text-right">
|
||||
Click tag name to toggle exclude<br />
|
||||
Click X to remove
|
||||
</span>
|
||||
@@ -217,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>
|
||||
@@ -245,7 +263,7 @@ export const FilterBar: Component = () => {
|
||||
placeholder="Add tag..."
|
||||
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
|
||||
>
|
||||
<SelectTrigger class="h-7 px-3 bg-muted/40 border-none shadow-none focus:ring-0 text-[10px] font-bold uppercase tracking-wider rounded-lg hover:bg-muted/60 transition-colors">
|
||||
<SelectTrigger class="h-7 px-3 bg-muted/40 border-none shadow-none focus:ring-0 text-[0.625rem] font-bold uppercase tracking-wider rounded-lg hover:bg-muted/60 transition-colors">
|
||||
<span>+ Tag</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
@@ -257,14 +275,14 @@ export const FilterBar: Component = () => {
|
||||
{/* Filter Templates Section */}
|
||||
<div class="px-4 pb-4 pt-4 border-t border-border/50 space-y-4 bg-muted/5">
|
||||
<div class="flex items-center justify-between ml-1">
|
||||
<label class="text-[10px] font-black uppercase tracking-widest text-muted-foreground flex items-center gap-2">
|
||||
<label class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground flex items-center gap-2">
|
||||
<Bookmark size={12} class="text-primary" />
|
||||
Saved Filters
|
||||
</label>
|
||||
<Show when={!isSaving() && hasActiveFilters()}>
|
||||
<button
|
||||
onClick={() => setIsSaving(true)}
|
||||
class="text-[9px] font-bold uppercase tracking-wider text-primary hover:underline flex items-center gap-1"
|
||||
class="text-[0.5625rem] font-bold uppercase tracking-wider text-primary hover:underline flex items-center gap-1"
|
||||
>
|
||||
<Save size={10} />
|
||||
Save Current
|
||||
@@ -318,7 +336,7 @@ export const FilterBar: Component = () => {
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<For each={store.filterTemplates} fallback={
|
||||
<div class="col-span-2 py-4 text-center border border-dashed border-border/40 rounded-xl bg-muted/5">
|
||||
<p class="text-[10px] text-muted-foreground uppercase tracking-widest">No saved filters yet</p>
|
||||
<p class="text-[0.625rem] text-muted-foreground uppercase tracking-widest">No saved filters yet</p>
|
||||
</div>
|
||||
}>
|
||||
{(template) => (
|
||||
@@ -328,7 +346,7 @@ export const FilterBar: Component = () => {
|
||||
onClick={() => applyFilterTemplate(template.id)}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-[11px] font-bold text-foreground truncate">{template.name}</p>
|
||||
<p class="text-[0.6875rem] font-bold text-foreground truncate">{template.name}</p>
|
||||
<div class="flex gap-1 opacity-60 shrink-0">
|
||||
<Show when={template.filter.query}>
|
||||
<div class="w-1 h-1 rounded-full bg-primary" />
|
||||
|
||||
@@ -137,7 +137,7 @@ export const ImportTool: Component = () => {
|
||||
<Show when={step() === "paste"}>
|
||||
<div class="space-y-3 animate-in fade-in duration-300">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground ml-1">
|
||||
<label class="text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground ml-1">
|
||||
Paste your task list
|
||||
</label>
|
||||
<textarea
|
||||
@@ -154,7 +154,7 @@ export const ImportTool: Component = () => {
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
class="gap-2 rounded-xl font-bold uppercase tracking-wider text-[10px] h-9 px-4"
|
||||
class="gap-2 rounded-xl font-bold uppercase tracking-wider text-[0.625rem] h-9 px-4"
|
||||
disabled={!rawText().trim()}
|
||||
onClick={parseText}
|
||||
>
|
||||
@@ -170,13 +170,13 @@ export const ImportTool: Component = () => {
|
||||
{/* Bulk defaults */}
|
||||
<div class="p-3 rounded-xl bg-muted/30 border border-border/50 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
||||
<p class="text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground">
|
||||
Bulk Defaults
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 text-[10px] font-bold uppercase tracking-wider rounded-lg"
|
||||
class="h-7 text-[0.625rem] font-bold uppercase tracking-wider rounded-lg"
|
||||
onClick={applyBulkDefaults}
|
||||
>
|
||||
Apply to All
|
||||
@@ -185,7 +185,7 @@ export const ImportTool: Component = () => {
|
||||
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
<div class="space-y-1">
|
||||
<label class="text-[9px] font-bold uppercase tracking-wider text-muted-foreground/70 ml-0.5">Priority</label>
|
||||
<label class="text-[0.5625rem] font-bold uppercase tracking-wider text-muted-foreground/70 ml-0.5">Priority</label>
|
||||
<select
|
||||
value={bulkPriority()}
|
||||
onChange={(e) => setBulkPriority(e.currentTarget.value)}
|
||||
@@ -197,7 +197,7 @@ export const ImportTool: Component = () => {
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-[9px] font-bold uppercase tracking-wider text-muted-foreground/70 ml-0.5">Urgency</label>
|
||||
<label class="text-[0.5625rem] font-bold uppercase tracking-wider text-muted-foreground/70 ml-0.5">Urgency</label>
|
||||
<select
|
||||
value={bulkUrgency()}
|
||||
onChange={(e) => setBulkUrgency(e.currentTarget.value)}
|
||||
@@ -209,7 +209,7 @@ export const ImportTool: Component = () => {
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-[9px] font-bold uppercase tracking-wider text-muted-foreground/70 ml-0.5">Size</label>
|
||||
<label class="text-[0.5625rem] font-bold uppercase tracking-wider text-muted-foreground/70 ml-0.5">Size</label>
|
||||
<select
|
||||
value={bulkSize()}
|
||||
onChange={(e) => setBulkSize(e.currentTarget.value)}
|
||||
@@ -221,14 +221,14 @@ export const ImportTool: Component = () => {
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-[9px] font-bold uppercase tracking-wider text-muted-foreground/70 ml-0.5">Tags</label>
|
||||
<label class="text-[0.5625rem] font-bold uppercase tracking-wider text-muted-foreground/70 ml-0.5">Tags</label>
|
||||
<div class="flex items-center gap-1 flex-wrap">
|
||||
<TagPicker selectedTags={bulkTags()} onTagsChange={setBulkTags} />
|
||||
<For each={bulkTags()}>
|
||||
{(tag) => (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="h-6 px-1.5 text-[10px] gap-1 cursor-pointer hover:bg-destructive/10"
|
||||
class="h-6 px-1.5 text-[0.625rem] gap-1 cursor-pointer hover:bg-destructive/10"
|
||||
onClick={() => setBulkTags(bulkTags().filter(t => t !== tag))}
|
||||
>
|
||||
{tag}
|
||||
@@ -250,7 +250,7 @@ export const ImportTool: Component = () => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 text-[10px] font-bold uppercase tracking-wider rounded-lg text-muted-foreground"
|
||||
class="h-7 text-[0.625rem] font-bold uppercase tracking-wider rounded-lg text-muted-foreground"
|
||||
onClick={reset}
|
||||
>
|
||||
← Back
|
||||
@@ -284,7 +284,7 @@ export const ImportTool: Component = () => {
|
||||
<select
|
||||
value={task.priority}
|
||||
onChange={(e) => updateTask(task.id, "priority", e.currentTarget.value)}
|
||||
class="h-7 rounded-lg border border-input bg-background px-1.5 text-[11px] shadow-sm outline-none focus:ring-1 focus:ring-ring appearance-none"
|
||||
class="h-7 rounded-lg border border-input bg-background px-1.5 text-[0.6875rem] shadow-sm outline-none focus:ring-1 focus:ring-ring appearance-none"
|
||||
title="Priority"
|
||||
>
|
||||
<For each={PRIORITY_OPTIONS}>
|
||||
@@ -295,7 +295,7 @@ export const ImportTool: Component = () => {
|
||||
<select
|
||||
value={task.urgency}
|
||||
onChange={(e) => updateTask(task.id, "urgency", e.currentTarget.value)}
|
||||
class="h-7 rounded-lg border border-input bg-background px-1.5 text-[11px] shadow-sm outline-none focus:ring-1 focus:ring-ring appearance-none"
|
||||
class="h-7 rounded-lg border border-input bg-background px-1.5 text-[0.6875rem] shadow-sm outline-none focus:ring-1 focus:ring-ring appearance-none"
|
||||
title="Urgency"
|
||||
>
|
||||
<For each={URGENCY_OPTIONS}>
|
||||
@@ -306,7 +306,7 @@ export const ImportTool: Component = () => {
|
||||
<select
|
||||
value={task.size}
|
||||
onChange={(e) => updateTask(task.id, "size", e.currentTarget.value)}
|
||||
class="h-7 rounded-lg border border-input bg-background px-1.5 text-[11px] shadow-sm outline-none focus:ring-1 focus:ring-ring appearance-none"
|
||||
class="h-7 rounded-lg border border-input bg-background px-1.5 text-[0.6875rem] shadow-sm outline-none focus:ring-1 focus:ring-ring appearance-none"
|
||||
title="Size"
|
||||
>
|
||||
<For each={SIZE_OPTIONS}>
|
||||
@@ -323,7 +323,7 @@ export const ImportTool: Component = () => {
|
||||
{(tag) => (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="h-6 px-1.5 text-[10px] gap-0.5 cursor-pointer hover:bg-destructive/10"
|
||||
class="h-6 px-1.5 text-[0.625rem] gap-0.5 cursor-pointer hover:bg-destructive/10"
|
||||
onClick={() => updateTask(task.id, "tags", task.tags.filter(t => t !== tag))}
|
||||
>
|
||||
{tag}
|
||||
@@ -352,7 +352,7 @@ export const ImportTool: Component = () => {
|
||||
</Show>
|
||||
<Button
|
||||
size="sm"
|
||||
class="gap-2 rounded-xl font-bold uppercase tracking-wider text-[10px] h-9 px-5"
|
||||
class="gap-2 rounded-xl font-bold uppercase tracking-wider text-[0.625rem] h-9 px-5"
|
||||
disabled={tasks().length === 0 || importing()}
|
||||
onClick={handleImport}
|
||||
>
|
||||
|
||||
+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 } 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";
|
||||
@@ -16,11 +16,12 @@ interface NavItem {
|
||||
// Desktop nav items (all)
|
||||
const desktopNavItems: NavItem[] = [
|
||||
{ icon: ListTodo, label: "Focus", view: "critical" },
|
||||
{ icon: Clock, label: "Time", view: "urgency" },
|
||||
{ icon: Clock, label: "Urgency", view: "urgency" },
|
||||
{ icon: ArrowUpCircle, label: "Priority", view: "priority" },
|
||||
{ icon: LayoutDashboard, label: "Matrix", view: "matrix" },
|
||||
{ icon: Snowflake, label: "Snowball", view: "snowball" },
|
||||
{ icon: Pickaxe, label: "Dig In", view: "dig_in" },
|
||||
{ icon: BarChart3, label: "Progress", view: "progress" },
|
||||
];
|
||||
|
||||
// Mobile nav groups
|
||||
@@ -37,13 +38,14 @@ const mobileNavGroups: MobileNavGroup[] = [
|
||||
{
|
||||
icon: TrendingUp, label: "Value", items: [
|
||||
{ icon: ArrowUpCircle, label: "Priority", view: "priority" },
|
||||
{ icon: Clock, label: "Time", view: "urgency" },
|
||||
{ icon: Clock, label: "Urgency", view: "urgency" },
|
||||
]
|
||||
},
|
||||
{
|
||||
icon: Gauge, label: "Flow", items: [
|
||||
{ icon: Snowflake, label: "Snowball", view: "snowball" },
|
||||
{ icon: Pickaxe, label: "Dig In", view: "dig_in" },
|
||||
{ icon: BarChart3, label: "Progress", view: "progress" },
|
||||
]
|
||||
},
|
||||
{ icon: Settings, label: "Settings", view: "settings" },
|
||||
@@ -68,7 +70,7 @@ export const BottomNav: Component<{ currentView: string; setView: (v: string) =>
|
||||
)}
|
||||
>
|
||||
<group.icon size={20} />
|
||||
<span class="text-[10px] font-medium uppercase tracking-wider">{group.label}</span>
|
||||
<span class="text-[0.625rem] font-medium uppercase tracking-wider">{group.label}</span>
|
||||
</button>
|
||||
}>
|
||||
<Popover>
|
||||
@@ -82,7 +84,7 @@ export const BottomNav: Component<{ currentView: string; setView: (v: string) =>
|
||||
<group.icon size={20} />
|
||||
<ChevronDown size={10} class="absolute -right-2 -bottom-0.5 opacity-60" />
|
||||
</div>
|
||||
<span class="text-[10px] font-medium uppercase tracking-wider">{group.label}</span>
|
||||
<span class="text-[0.625rem] font-medium uppercase tracking-wider">{group.label}</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-40 p-1 bg-card border-border shadow-xl mb-2">
|
||||
<div class="space-y-0.5">
|
||||
@@ -159,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') => {
|
||||
@@ -227,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-[10px] 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-[9px]">{store.subscribedBuckets.length}</span>
|
||||
<div class="px-2 py-1 text-[0.625rem] font-bold uppercase text-muted-foreground tracking-wider flex items-center gap-2">
|
||||
<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) => (
|
||||
@@ -262,9 +264,9 @@ export const ContextSwitcher: Component<{
|
||||
<div class="py-1">
|
||||
<div class="h-px bg-border/50" />
|
||||
</div>
|
||||
<div class="px-2 py-1 text-[10px] font-bold uppercase text-muted-foreground tracking-wider flex items-center gap-2">
|
||||
<span>Oversight</span>
|
||||
<span class="ml-auto bg-muted px-1.5 rounded text-[9px]">{oversightUsers()?.length}</span>
|
||||
<div class="px-2 py-1 text-[0.625rem] font-bold uppercase text-muted-foreground tracking-wider flex items-center gap-2">
|
||||
<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()}>
|
||||
{(user) => (
|
||||
@@ -297,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);
|
||||
|
||||
@@ -309,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")}>
|
||||
@@ -339,15 +345,7 @@ export const Sidebar: Component<{
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Context Switcher - Only visible if there are oversight contexts */}
|
||||
<div class={cn("px-3 mb-2", !props.isLocked && !props.isPeeking && "hidden")}>
|
||||
<ContextSwitcher
|
||||
isLocked={props.isLocked}
|
||||
isPeeking={props.isPeeking}
|
||||
setIsPeeking={props.setIsPeeking}
|
||||
onOpenChange={setSwitcherOpen}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<nav class="flex-1 px-3 space-y-1 mt-2">
|
||||
<For each={desktopNavItems}>
|
||||
@@ -367,7 +365,36 @@ export const Sidebar: Component<{
|
||||
)}
|
||||
</For>
|
||||
</nav>
|
||||
<div class="p-3 border-t border-border mt-auto">
|
||||
|
||||
{/* Context Switcher - Only visible if there are oversight contexts */}
|
||||
<div class={cn(
|
||||
"px-3 mb-2 transition-opacity duration-200",
|
||||
(!props.isLocked && !props.isPeeking) ? "opacity-0 pointer-events-none" : "opacity-100 delay-100"
|
||||
)}>
|
||||
<ContextSwitcher
|
||||
isLocked={props.isLocked}
|
||||
isPeeking={props.isPeeking}
|
||||
setIsPeeking={props.setIsPeeking}
|
||||
onOpenChange={(open) => {
|
||||
setSwitcherOpen(open);
|
||||
props.onDropdownOpenChange?.(open);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
+51
-465
@@ -1,104 +1,48 @@
|
||||
import { type Component, createSignal, createEffect, onCleanup, onMount, For, Show } from "solid-js";
|
||||
import { addTask, calculateDateFromUrgency, calculateUrgencyFromDate, store, upsertTagDefinition, 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("");
|
||||
// Autofill tags based on context when opening
|
||||
const initialTags = createMemo(() => {
|
||||
if (!isOpen()) return [];
|
||||
|
||||
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");
|
||||
const tags: string[] = [];
|
||||
const ctx = currentTaskContext();
|
||||
|
||||
// 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);
|
||||
if (store.isNotepadMode) {
|
||||
// Not in a specific context, but in Notes mode.
|
||||
// We use global window variable set by Layout to get the active note id
|
||||
const activeNoteId = (window as any)._activeNoteId;
|
||||
if (activeNoteId) {
|
||||
const note = store.notes.find(n => n.id === activeNoteId);
|
||||
if (note && note.title) {
|
||||
const noteTagName = note.title.trim();
|
||||
if (noteTagName) tags.push(noteTagName);
|
||||
}
|
||||
}
|
||||
} else if (typeof ctx === 'object') {
|
||||
if ('bucketId' in ctx) {
|
||||
// Bucket View: Autofill bucket name
|
||||
const bucketName = ctx.name;
|
||||
if (bucketName) tags.push(`@${bucketName}`);
|
||||
} else if ('userId' in ctx) {
|
||||
// Shared User View: Autofill user name (assuming name IS the tag for sharing)
|
||||
const userName = ctx.name;
|
||||
if (userName) tags.push(`@${userName}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
@@ -108,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>
|
||||
@@ -262,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-[10px] 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-[10px] 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-[10px] 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-[10px] 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-[10px] 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-[10px] 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-[9px] 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<{
|
||||
@@ -150,7 +346,7 @@ export const SlashMenu: Component<{
|
||||
</div>
|
||||
<div class="flex flex-col min-w-0 overflow-hidden">
|
||||
<span class="text-sm font-medium leading-none">{item.title}</span>
|
||||
<span class="text-[11px] text-muted-foreground truncate mt-1">{item.description}</span>
|
||||
<span class="text-[0.6875rem] text-muted-foreground truncate mt-1">{item.description}</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { type Component } from "solid-js";
|
||||
import { CheckCircle2 } from "lucide-solid";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface StatusCircleProps {
|
||||
status: number;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const StatusCircle: Component<StatusCircleProps> = (props) => {
|
||||
const size = () => props.size || 24;
|
||||
const radius = () => (size() / 2) - 2; // Subtract stroke width/padding
|
||||
const circumference = () => 2 * Math.PI * radius();
|
||||
|
||||
// Status 0: Empty Circle
|
||||
// Status 10: CheckCircle2 (Green)
|
||||
// Status 1-9: Progress Ring
|
||||
|
||||
const strokeDashoffset = () => {
|
||||
// Invert to fill clockwise?
|
||||
// Progress 1-9.
|
||||
// 1 = 10%, 9 = 90%
|
||||
const percentage = Math.max(0, Math.min(10, props.status)) / 10;
|
||||
return circumference() * (1 - percentage);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class={cn("relative flex items-center justify-center", props.className)} style={{ width: `${size()}px`, height: `${size()}px` }}>
|
||||
{props.status >= 10 ? (
|
||||
<CheckCircle2 size={size()} class="text-green-500" />
|
||||
) : (
|
||||
// Progress Circle
|
||||
<div class="relative group">
|
||||
{/* Background Circle (Ghost) */}
|
||||
<svg width={size()} height={size()} class="rotate-[-90deg]">
|
||||
<circle
|
||||
cx={size() / 2}
|
||||
cy={size() / 2}
|
||||
r={radius()}
|
||||
fill="transparent"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
class="text-muted-foreground/20"
|
||||
/>
|
||||
{/* Progress Arc */}
|
||||
<circle
|
||||
cx={size() / 2}
|
||||
cy={size() / 2}
|
||||
r={radius()}
|
||||
fill="transparent"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-dasharray={circumference().toString()}
|
||||
stroke-dashoffset={strokeDashoffset()}
|
||||
stroke-linecap="round"
|
||||
class="text-primary transition-all duration-300"
|
||||
/>
|
||||
</svg>
|
||||
{/* Hover indicator (maybe show check on hover?)
|
||||
User didn't ask for generic hover, just visual update.
|
||||
*/}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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,12 +71,12 @@ 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">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Tag Name</label>
|
||||
<label class="text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground">Tag Name</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
list="existing-tags"
|
||||
@@ -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>
|
||||
@@ -81,7 +94,7 @@ export const TagPicker: Component<TagPickerProps> = (props) => {
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Value (Sorting Influence)</label>
|
||||
<label class="text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground">Value (Sorting Influence)</label>
|
||||
<select
|
||||
value={valueScore().toString()}
|
||||
onInput={(e) => setValueScore(parseInt(e.currentTarget.value))}
|
||||
|
||||
+80
-24
@@ -1,13 +1,15 @@
|
||||
import { type Component, createMemo } from "solid-js";
|
||||
import { type Task, toggleTask, calculateUrgencyFromDate, setActiveTaskId, store, copyTask } 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 { CheckCircle2, Circle, Clock, ArrowUpCircle, Copy, Users2 } from "lucide-solid";
|
||||
import { Clock, ArrowUpCircle, Copy, Users2 } from "lucide-solid";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { For } from "solid-js";
|
||||
import { useTheme } from "./ThemeProvider";
|
||||
import { getThemeAdjustedColor } from "@/lib/colors";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||
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));
|
||||
|
||||
@@ -24,24 +26,78 @@ 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)}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleTask(props.task.id); }}
|
||||
class="mr-3 sm:mr-4 text-muted-foreground hover:text-primary transition-colors shrink-0"
|
||||
>
|
||||
{props.task.completed ? (
|
||||
<CheckCircle2 class="text-green-500" size={22} />
|
||||
) : (
|
||||
<Circle size={22} />
|
||||
)}
|
||||
</button>
|
||||
{/* 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>
|
||||
value={(props.task.status ?? 0).toString()}
|
||||
onChange={(val: any) => {
|
||||
const value = typeof val === 'object' ? val?.value : val;
|
||||
if (value) {
|
||||
const s = parseInt(value);
|
||||
if (!isNaN(s)) updateTask(props.task.id, { status: s });
|
||||
}
|
||||
}}
|
||||
options={statusOptions()}
|
||||
optionValue="value"
|
||||
optionTextValue="label"
|
||||
placeholder="Status"
|
||||
itemComponent={(props: any) => <SelectItem item={props.item}>{props.item.rawValue.label}</SelectItem>}
|
||||
>
|
||||
<SelectTrigger
|
||||
class="h-9 w-9 p-0 bg-transparent border-transparent hover:bg-muted/50 data-[expanded]:bg-muted/50 shadow-none focus:ring-0 shrink-0 overflow-hidden flex items-center justify-center [&>svg]:hidden"
|
||||
onDoubleClick={(e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
updateTask(props.task.id, { status: 10 });
|
||||
}}
|
||||
>
|
||||
<StatusCircle status={props.task.status ?? 0} size={26} />
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0 flex flex-col">
|
||||
<h3 class={cn(
|
||||
@@ -51,7 +107,7 @@ export const TaskCard: Component<{ task: Task }> = (props) => {
|
||||
{props.task.title}
|
||||
</h3>
|
||||
{props.task.content && (
|
||||
<div class="text-[11px] sm:text-xs text-muted-foreground/60 line-clamp-1 mt-1 leading-tight whitespace-pre">
|
||||
<div class="text-[0.6875rem] sm:text-xs text-muted-foreground/60 line-clamp-1 mt-1 leading-tight whitespace-pre">
|
||||
{(() => {
|
||||
let html = props.task.content || "";
|
||||
// Replace block tags with newlines first to preserve structure
|
||||
@@ -75,7 +131,7 @@ export const TaskCard: Component<{ task: Task }> = (props) => {
|
||||
{/* Priority */}
|
||||
<div class="flex items-center gap-1.5 shrink-0">
|
||||
<ArrowUpCircle size={12} class="text-primary opacity-60" />
|
||||
<span class="text-[10px] font-medium">P{props.task.priority}</span>
|
||||
<span class="text-[0.625rem] font-medium">P{props.task.priority}</span>
|
||||
</div>
|
||||
|
||||
{/* Shared Indicator - shows for individual shares OR tag-based ShareRules */}
|
||||
@@ -104,13 +160,13 @@ 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"
|
||||
class="h-4 px-1.5 text-[9px] gap-1 bg-muted/50 border-border/50 shrink-0"
|
||||
class="h-4 px-1.5 text-[0.5625rem] gap-1 bg-muted/50 border-border/50 shrink-0"
|
||||
style={{
|
||||
"background-color": (() => {
|
||||
const def = store.tagDefinitions.find(d => d.name === tag);
|
||||
@@ -144,7 +200,7 @@ export const TaskCard: Component<{ task: Task }> = (props) => {
|
||||
{/* Due Date + Urgency Slide-out */}
|
||||
<div class="relative group/date flex items-center h-5 cursor-help shrink-0 min-w-0">
|
||||
{/* Due Date - Static Layer on Top */}
|
||||
<div class="flex items-center space-x-1 text-[9px] font-bold text-muted-foreground uppercase tracking-wider bg-card relative z-20 pr-1">
|
||||
<div class="flex items-center space-x-1 text-[0.5625rem] font-bold text-muted-foreground uppercase tracking-wider bg-card relative z-20 pr-1">
|
||||
<Clock size={11} class="text-primary/60" />
|
||||
<span class="truncate">{formattedDate()}</span>
|
||||
</div>
|
||||
@@ -154,7 +210,7 @@ export const TaskCard: Component<{ task: Task }> = (props) => {
|
||||
"absolute left-4 top-0 bottom-0 flex items-center transition-all duration-300 ease-out z-10 opacity-0 group-hover/date:opacity-100 group-hover/date:left-full whitespace-nowrap",
|
||||
urgencyColor()
|
||||
)}>
|
||||
<span class="text-[9px] font-bold ml-2 transition-all duration-300 transform translate-x-[-8px] group-hover/date:translate-x-0 group-hover/date:blur-none blur-[2px]">
|
||||
<span class="text-[0.5625rem] font-bold ml-2 transition-all duration-300 transform translate-x-[-8px] group-hover/date:translate-x-0 group-hover/date:blur-none blur-[2px]">
|
||||
Urgency {urgencyLevel()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
+213
-80
@@ -1,14 +1,15 @@
|
||||
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 } 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";
|
||||
import { StatusCircle } from "./StatusCircle";
|
||||
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 } 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";
|
||||
@@ -40,6 +41,26 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
||||
const dateObj = new Date(props.task.dueDate);
|
||||
const localIso = new Date(dateObj.getTime() - (dateObj.getTimezoneOffset() * 60000)).toISOString().slice(0, 16);
|
||||
setDateString(localIso);
|
||||
|
||||
// Load content if missing and panel is open
|
||||
if (props.isOpen && props.task.content === undefined) {
|
||||
loadTaskContent(props.task.id);
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -51,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);
|
||||
@@ -72,6 +126,14 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const updateStatus = (val: any) => {
|
||||
const value = typeof val === 'object' ? val.value : val;
|
||||
const s = parseInt(value);
|
||||
if (!isNaN(s)) {
|
||||
updateTask(props.task.id, { status: s });
|
||||
}
|
||||
};
|
||||
|
||||
const updateUrgency = (val: any) => {
|
||||
const value = typeof val === 'object' ? val.value : val;
|
||||
const u = parseInt(value);
|
||||
@@ -82,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) => {
|
||||
@@ -96,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) {
|
||||
@@ -119,32 +210,60 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
||||
>
|
||||
{/* Header Area */}
|
||||
<div class="px-6 pt-6 pb-2 shrink-0">
|
||||
<textarea
|
||||
id="task-detail-title"
|
||||
name="task-title"
|
||||
value={title()}
|
||||
onInput={(e) => {
|
||||
updateTitle(e.currentTarget.value);
|
||||
// Auto-resize
|
||||
e.currentTarget.style.height = 'auto';
|
||||
e.currentTarget.style.height = e.currentTarget.scrollHeight + 'px';
|
||||
}}
|
||||
ref={(el) => {
|
||||
setTimeout(() => {
|
||||
el.style.height = 'auto';
|
||||
el.style.height = el.scrollHeight + 'px';
|
||||
}, 0);
|
||||
}}
|
||||
rows={1}
|
||||
class="text-xl sm:text-2xl font-bold bg-transparent border-none focus:outline-none focus:ring-0 w-full placeholder:text-muted-foreground/50 resize-none overflow-hidden leading-tight"
|
||||
placeholder="Untitled Task"
|
||||
/>
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="shrink-0 mt-0.5">
|
||||
<Select<any>
|
||||
value={(props.task.status ?? 0).toString()}
|
||||
onChange={(val: string | null) => val && updateStatus(val)}
|
||||
options={statusOptions()}
|
||||
optionValue="value"
|
||||
optionTextValue="label"
|
||||
placeholder="Status"
|
||||
itemComponent={(props: any) => <SelectItem item={props.item}>{props.item.rawValue.label}</SelectItem>}
|
||||
>
|
||||
<SelectTrigger
|
||||
class="h-9 w-9 p-0 bg-transparent border-transparent hover:bg-muted/50 data-[expanded]:bg-muted/50 px-0 shadow-none focus:ring-0 shrink-0 overflow-hidden flex items-center justify-center [&>svg]:hidden"
|
||||
onDoubleClick={(e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
updateStatus("10");
|
||||
}}
|
||||
>
|
||||
<StatusCircle status={props.task.status ?? 0} size={28} />
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
id="task-detail-title"
|
||||
name="task-title"
|
||||
value={title()}
|
||||
onInput={(e) => {
|
||||
updateTitle(e.currentTarget.value);
|
||||
// Auto-resize
|
||||
e.currentTarget.style.height = 'auto';
|
||||
e.currentTarget.style.height = e.currentTarget.scrollHeight + 'px';
|
||||
}}
|
||||
ref={(el) => {
|
||||
setTimeout(() => {
|
||||
el.style.height = 'auto';
|
||||
el.style.height = el.scrollHeight + 'px';
|
||||
}, 0);
|
||||
}}
|
||||
rows={1}
|
||||
class="text-xl sm:text-2xl font-bold bg-transparent border-none focus:outline-none focus:ring-0 w-full placeholder:text-muted-foreground/50 resize-none overflow-hidden leading-tight flex-1 min-w-0"
|
||||
placeholder="Untitled Task"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Properties Bar */}
|
||||
<div class="flex flex-wrap items-center gap-2 sm:gap-4 mt-2 text-sm">
|
||||
|
||||
|
||||
{/* Priority */}
|
||||
<div class="flex items-center gap-1 group shrink-0">
|
||||
<span class="text-[10px] uppercase font-bold tracking-wider text-muted-foreground/70 hidden sm:inline">Priority</span>
|
||||
<span class="text-[0.625rem] uppercase font-bold tracking-wider text-muted-foreground/70 hidden sm:inline">Priority</span>
|
||||
<Select<any>
|
||||
value={props.task.priority.toString()}
|
||||
onChange={(val: string | null) => val && updatePriority(val)}
|
||||
@@ -168,7 +287,7 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
||||
|
||||
{/* Urgency */}
|
||||
<div class="flex items-center gap-1 group shrink-0">
|
||||
<span class="text-[10px] uppercase font-bold tracking-wider text-muted-foreground/70 hidden sm:inline">Time</span>
|
||||
<span class="text-[0.625rem] uppercase font-bold tracking-wider text-muted-foreground/70 hidden sm:inline">Urgency</span>
|
||||
<Select<any>
|
||||
value={currentUrgency()}
|
||||
onChange={(val: string | null) => val && updateUrgency(val)}
|
||||
@@ -192,9 +311,9 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
||||
|
||||
{/* Size */}
|
||||
<div class="flex items-center gap-1 group shrink-0">
|
||||
<span class="text-[10px] uppercase font-bold tracking-wider text-muted-foreground/70 hidden sm:inline">Size</span>
|
||||
<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) {
|
||||
@@ -212,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>
|
||||
@@ -225,7 +344,7 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 px-1 sm:px-2 text-[10px] font-bold uppercase tracking-wider hover:bg-muted/50 flex items-center gap-1 sm:gap-1.5 text-muted-foreground hover:text-foreground transition-colors"
|
||||
class="h-8 px-1 sm:px-2 text-[0.625rem] font-bold uppercase tracking-wider hover:bg-muted/50 flex items-center gap-1 sm:gap-1.5 text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => {
|
||||
const instance = editorInstance();
|
||||
if (instance) {
|
||||
@@ -282,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-[10px] 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"
|
||||
@@ -367,7 +500,7 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
||||
<PopoverContent class="w-72 p-0 overflow-hidden" align="end">
|
||||
<div class="flex flex-col">
|
||||
<div class="p-2 border-b border-border/50">
|
||||
<div class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70 px-2 py-1">Recurrence</div>
|
||||
<div class="text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground/70 px-2 py-1">Recurrence</div>
|
||||
<div class="flex flex-col gap-2 p-2 pt-0">
|
||||
<Select
|
||||
value={props.task.recurrence?.type || "none"}
|
||||
@@ -405,7 +538,7 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
||||
{(day, index) => (
|
||||
<button
|
||||
class={cn(
|
||||
"w-7 h-7 rounded-full text-[10px] font-bold flex items-center justify-center transition-colors shadow-sm",
|
||||
"w-7 h-7 rounded-full text-[0.625rem] font-bold flex items-center justify-center transition-colors shadow-sm",
|
||||
props.task.recurrence?.days?.includes(index())
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||
@@ -472,7 +605,7 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
||||
|
||||
{/* Sharing Section */}
|
||||
<div class="p-2 border-t border-border/50">
|
||||
<div class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70 px-2 py-1 mb-1">Sharing</div>
|
||||
<div class="text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground/70 px-2 py-1 mb-1">Sharing</div>
|
||||
<UserSharePicker
|
||||
taskId={props.task.id}
|
||||
sharedWith={props.task.sharedWith || []}
|
||||
@@ -532,12 +665,12 @@ const MetadataItem: Component<{
|
||||
isToggled() ? "-translate-y-4" : "translate-y-0"
|
||||
)}>
|
||||
{/* Layer 1: Date */}
|
||||
<div class="h-4 flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground/80 whitespace-nowrap">
|
||||
<div class="h-4 flex items-center gap-1.5 text-[0.6875rem] font-medium text-muted-foreground/80 whitespace-nowrap">
|
||||
{props.icon}
|
||||
{props.date}
|
||||
</div>
|
||||
{/* Layer 2: Time */}
|
||||
<div class="h-4 flex items-center gap-2 text-[11px] font-bold text-foreground whitespace-nowrap">
|
||||
<div class="h-4 flex items-center gap-2 text-[0.6875rem] font-bold text-foreground whitespace-nowrap">
|
||||
<Clock size={11} class="text-primary/60 shrink-0" />
|
||||
<span>{props.time}</span>
|
||||
</div>
|
||||
@@ -669,7 +802,7 @@ const UserSharePicker: Component<{
|
||||
{rule.type === 'tag' ? <Tag size={10} /> : <Settings size={10} />}
|
||||
</div>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span class="text-[11px] font-medium leading-none truncate">
|
||||
<span class="text-[0.6875rem] font-medium leading-none truncate">
|
||||
{getUserName(rule.targetUserId)}
|
||||
</span>
|
||||
<span class="text-[9px] text-muted-foreground/50 mt-0.5 uppercase tracking-tighter">
|
||||
|
||||
+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 {
|
||||
|
||||
+81
-11
@@ -40,17 +40,87 @@ 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" },
|
||||
{ value: "8", label: "8" },
|
||||
{ value: "7", label: "7" },
|
||||
{ value: "6", label: "6" },
|
||||
{ value: "5", label: "5" },
|
||||
{ value: "4", label: "4" },
|
||||
{ value: "3", label: "3" },
|
||||
{ value: "2", label: "2" },
|
||||
{ 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);
|
||||
});
|
||||
};
|
||||
|
||||
+940
-277
File diff suppressed because it is too large
Load Diff
+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>
|
||||
);
|
||||
};
|
||||
+11
-11
@@ -17,10 +17,10 @@ export const MatrixView: Component = () => {
|
||||
|
||||
<div class="flex-1 relative border-2 border-dashed border-muted rounded-2xl md:rounded-3xl overflow-hidden bg-muted/20">
|
||||
{/* Axis Labels */}
|
||||
<div class="absolute bottom-4 left-1/2 -translate-x-1/2 text-[9px] md:text-[10px] font-bold uppercase tracking-widest text-muted-foreground/50 flex items-center gap-1 md:gap-2 z-30 w-[calc(100%-2rem)] justify-center">
|
||||
<div class="absolute bottom-4 left-1/2 -translate-x-1/2 text-[0.5625rem] md:text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground/50 flex items-center gap-1 md:gap-2 z-30 w-[calc(100%-2rem)] justify-center">
|
||||
<span class="truncate">← Soon</span>
|
||||
<div class="flex items-center gap-1 bg-background/50 backdrop-blur-sm px-1.5 md:px-2 py-0.5 rounded-full border border-border/50 group hover:bg-background/80 transition-colors cursor-pointer shrink-0">
|
||||
<span class="text-[8px] md:text-[9px]">Time:</span>
|
||||
<span class="text-[0.5rem] md:text-[0.5625rem]">Time:</span>
|
||||
<Select
|
||||
value={store.matrixScaleDays.toString()}
|
||||
onChange={(val) => val && setStore("matrixScaleDays", parseInt(val))}
|
||||
@@ -32,7 +32,7 @@ export const MatrixView: Component = () => {
|
||||
</SelectItem>
|
||||
)}
|
||||
>
|
||||
<SelectTrigger class="h-auto p-0 min-w-[40px] md:min-w-[50px] bg-transparent border-none shadow-none focus:ring-0 text-[8px] md:text-[10px] font-black text-foreground">
|
||||
<SelectTrigger class="h-auto p-0 min-w-[40px] md:min-w-[50px] bg-transparent border-none shadow-none focus:ring-0 text-[0.5rem] md:text-[0.625rem] font-black text-foreground">
|
||||
<span>{store.matrixScaleDays} {store.matrixScaleDays === 1 ? 'day' : 'days'}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent class="z-[150]" />
|
||||
@@ -40,7 +40,7 @@ export const MatrixView: Component = () => {
|
||||
</div>
|
||||
<span class="truncate">Later →</span>
|
||||
</div>
|
||||
<div class="absolute left-2 md:left-4 top-1/2 -translate-y-1/2 -rotate-90 text-[8px] md:text-[10px] font-bold uppercase tracking-widest text-muted-foreground/30 pointer-events-none">
|
||||
<div class="absolute left-2 md:left-4 top-1/2 -translate-y-1/2 -rotate-90 text-[0.5rem] md:text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground/30 pointer-events-none">
|
||||
← Low (Priority) High →
|
||||
</div>
|
||||
|
||||
@@ -55,10 +55,10 @@ export const MatrixView: Component = () => {
|
||||
</div>
|
||||
|
||||
{/* Quadrant Labels */}
|
||||
<div class="absolute top-4 left-4 text-[10px] font-bold text-muted-foreground/30 pointer-events-none">DO FIRST</div>
|
||||
<div class="absolute top-4 right-4 text-[10px] font-bold text-muted-foreground/30 pointer-events-none">SCHEDULE</div>
|
||||
<div class="absolute bottom-4 left-4 text-[10px] font-bold text-muted-foreground/30 pointer-events-none">DELEGATE</div>
|
||||
<div class="absolute bottom-4 right-4 text-[10px] font-bold text-muted-foreground/30 pointer-events-none">ELIMINATE</div>
|
||||
<div class="absolute top-4 left-4 text-[0.625rem] font-bold text-muted-foreground/30 pointer-events-none">DO FIRST</div>
|
||||
<div class="absolute top-4 right-4 text-[0.625rem] font-bold text-muted-foreground/30 pointer-events-none">SCHEDULE</div>
|
||||
<div class="absolute bottom-4 left-4 text-[0.625rem] font-bold text-muted-foreground/30 pointer-events-none">DELEGATE</div>
|
||||
<div class="absolute bottom-4 right-4 text-[0.625rem] font-bold text-muted-foreground/30 pointer-events-none">ELIMINATE</div>
|
||||
|
||||
{/* Task Pips */}
|
||||
<For each={activeTasks()}>
|
||||
@@ -122,9 +122,9 @@ export const MatrixView: Component = () => {
|
||||
<div class="bg-popover text-popover-foreground border border-border rounded-xl p-3 shadow-2xl scale-95 group-hover:scale-100 transition-transform">
|
||||
<p class="text-xs font-bold leading-tight">{task.title}</p>
|
||||
<div class="flex items-center justify-between mt-2 pt-2 border-t border-border">
|
||||
<span class="text-[9px] text-muted-foreground">U: {urgencyScore}</span>
|
||||
<span class="text-[9px] text-muted-foreground">S: {task.size ?? 5}</span>
|
||||
<span class="text-[9px] text-muted-foreground">P: {task.priority}</span>
|
||||
<span class="text-[0.5625rem] text-muted-foreground">U: {urgencyScore}</span>
|
||||
<span class="text-[0.5625rem] text-muted-foreground">S: {task.size ?? 5}</span>
|
||||
<span class="text-[0.5625rem] text-muted-foreground">P: {task.priority}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class={cn(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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 sourceTasks = createMemo(() => store.tasks.filter(t => !t.deletedAt && matchesFilter(t)));
|
||||
|
||||
const { displayedTasks, shakingTaskIds } = useDelayedSort(
|
||||
sourceTasks,
|
||||
(a, b) => {
|
||||
// 1. Completed tasks at the bottom
|
||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||
|
||||
// 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 (
|
||||
<div class="space-y-6">
|
||||
<header>
|
||||
<h2 class="text-3xl font-bold tracking-tight">Progress</h2>
|
||||
<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" ref={listRef}>
|
||||
<For each={displayedTasks()}>
|
||||
{(task) => (
|
||||
<div class="animate-task-enter">
|
||||
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
</div>
|
||||
<p class="text-muted-foreground">No tasks found in the current filter.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+929
-844
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