Compare commits

..

1 Commits

Author SHA1 Message Date
eewing 82a75411f2 Updated README to clarify branch usage and environment information for the Dev branch.
CI / build (pull_request) Successful in 41s
CI / deploy (pull_request) Has been skipped
2026-03-04 11:00:41 -06:00
71 changed files with 2341 additions and 21658 deletions
-1
View File
@@ -1 +0,0 @@
VITE_TASGRID_DATA_MODE=dev
-2
View File
@@ -1,2 +0,0 @@
VITE_TASGRID_ENABLE_PROD_MIGRATION=false
VITE_TASGRID_MIGRATION_ADMIN_EMAILS=tcardoza@cardoza.construction
+2 -9
View File
@@ -64,13 +64,6 @@ jobs:
ls -la
test -d dist || (echo "Missing dist/ (Vite output). Check Build step." && exit 1)
- name: Prepare deploy artifact
run: |
rm -rf deploy
mkdir -p deploy
cp -r dist package.json deploy/
(test -f bun.lock && cp bun.lock deploy/) || (test -f bun.lockb && cp bun.lockb deploy/) || true
- name: Setup SSH known hosts
run: |
mkdir -p ~/.ssh
@@ -82,7 +75,7 @@ jobs:
run: |
sshpass -p "$SSH_PASSWORD" rsync -avz --delete \
-e "ssh -o StrictHostKeyChecking=accept-new -p ${{ secrets.DEPLOY_SSH_PORT || '22' }}" \
deploy/ \
dist/ \
${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST || env.DEPLOY_HOST }}:${{ secrets.DEPLOY_PATH || env.DEPLOY_PATH }}/
- name: Restart app on server
@@ -91,4 +84,4 @@ jobs:
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 }} && bun install --frozen-lockfile && export PORT=${{ env.APP_PORT }} && (pm2 restart tasgrid 2>/dev/null || pm2 start \"bun run serve\" --name tasgrid --update-env)"'
'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)"'
+4
View File
@@ -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.
-6
View File
@@ -8,7 +8,6 @@
"@formkit/auto-animate": "^0.9.0",
"@kobalte/core": "^0.13.11",
"@tailwindcss/vite": "^4.1.18",
"@thisbeyond/solid-dnd": "^0.7.5",
"@tiptap/core": "^3.18.0",
"@tiptap/extension-bubble-menu": "^3.20.0",
"@tiptap/extension-dropcursor": "^3.20.0",
@@ -32,7 +31,6 @@
"autoprefixer": "^10.4.23",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"heic2any": "^0.0.4",
"lucide-solid": "^0.563.0",
"pm2": "^6.0.14",
"pocketbase": "^0.26.8",
@@ -462,8 +460,6 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
"@thisbeyond/solid-dnd": ["@thisbeyond/solid-dnd@0.7.5", "", { "peerDependencies": { "solid-js": "^1.5" } }, "sha512-DfI5ff+yYGpK9M21LhYwIPlbP2msKxN2ARwuu6GF8tT1GgNVDTI8VCQvH4TJFoVApP9d44izmAcTh/iTCH2UUw=="],
"@tiptap/core": ["@tiptap/core@3.18.0", "", { "peerDependencies": { "@tiptap/pm": "^3.18.0" } }, "sha512-Gczd4GbK1DNgy/QUPElMVozoa0GW9mW8E31VIi7Q4a9PHHz8PcrxPmuWwtJ2q0PF8MWpOSLuBXoQTWaXZRPRnQ=="],
"@tiptap/extension-blockquote": ["@tiptap/extension-blockquote@3.18.0", "", { "peerDependencies": { "@tiptap/core": "^3.18.0" } }, "sha512-1HjEoM5vZDfFnq2OodNpW13s56a9pbl7jolUv1V9FrE3X5s7n0HCfDzIVpT7z1HgTdPtlN5oSt5uVyBwuwSUfA=="],
@@ -826,8 +822,6 @@
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"heic2any": ["heic2any@0.0.4", "", {}, "sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA=="],
"html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="],
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
-15
View File
@@ -1,15 +0,0 @@
import PocketBase from 'pocketbase';
const pb = new PocketBase('https://pocketbase.ccllc.pro');
async function check() {
try {
await pb.admins.authWithPassword("timothy@ccllc.pro", 'pass');
const res = await pb.collection('Job_Info_Prod').getList(1, 1);
console.log("Got item:", res.items[0]);
} catch (e) {
console.error("Auth / Fetch Error", e);
}
}
check();
@@ -1,200 +0,0 @@
[
{
"id": "agjvjaleo6zfqe3",
"key": "user-oz8sxsyg8hznqa6",
"displayName": "IT",
"kind": "user",
"policy": "collaborative",
"targetUserId": "oz8sxsyg8hznqa6",
"deletedAt": null
},
{
"id": "u3julc5juv1c5pq",
"key": "user-2lmwfhtdwuexmiv",
"displayName": "Ezekiel Ewing",
"kind": "user",
"policy": "collaborative",
"targetUserId": "2lmwfhtdwuexmiv",
"deletedAt": null
},
{
"id": "73z3dp9b6alhbl4",
"key": "user-1hbif9dbqng86h9",
"displayName": "Alfonso Ewing",
"kind": "user",
"policy": "collaborative",
"targetUserId": "1hbif9dbqng86h9",
"deletedAt": null
},
{
"id": "p65sesgbxah4chu",
"key": "user-fdve3i2gop4huym",
"displayName": "Heidi Mahan",
"kind": "user",
"policy": "collaborative",
"targetUserId": "fdve3i2gop4huym",
"deletedAt": null
},
{
"id": "vo9x5s7x9jjq6zw",
"key": "user-ee4c6xmjrhypv4f",
"displayName": "Anita Pence",
"kind": "user",
"policy": "collaborative",
"targetUserId": null,
"deletedAt": null
},
{
"id": "ez800j2vp4tp0tk",
"key": "user-h9uihmnkwngdu8y",
"displayName": "Beth Cardoza",
"kind": "user",
"policy": "collaborative",
"targetUserId": "h9uihmnkwngdu8y",
"deletedAt": null
},
{
"id": "u9517tx1x3hxrs5",
"key": "user-e9vck6v3pa951wn",
"displayName": "Rick Cardoza",
"kind": "user",
"policy": "collaborative",
"targetUserId": "e9vck6v3pa951wn",
"deletedAt": null
},
{
"id": "2nlrw1wumhtbc98",
"key": "user-c1uzqhpacn65qt1",
"displayName": "Steve Brewer",
"kind": "user",
"policy": "collaborative",
"targetUserId": null,
"deletedAt": null
},
{
"id": "2ttnn1vcdnniqci",
"key": "user-rrjoxd2wewmlrwy",
"displayName": "David Cardoza",
"kind": "user",
"policy": "collaborative",
"targetUserId": "rrjoxd2wewmlrwy",
"deletedAt": null
},
{
"id": "a3nd0f8tpipjc3e",
"key": "user-sivbh2pwzyew5lb",
"displayName": "Timothy Cardoza",
"kind": "user",
"policy": "collaborative",
"targetUserId": "sivbh2pwzyew5lb",
"deletedAt": null
},
{
"id": "ucj2hrr4htezrzo",
"key": "user-gk3u91dx0r79fo2",
"displayName": "Employee",
"kind": "user",
"policy": "handoff",
"targetUserId": "gk3u91dx0r79fo2",
"deletedAt": null
},
{
"id": "lazcnjz2tsdoi0t",
"key": "user-mchpsyvh0wrcxv6",
"displayName": "Cortney Abrams",
"kind": "user",
"policy": "collaborative",
"targetUserId": "mchpsyvh0wrcxv6",
"deletedAt": null
},
{
"id": "p66mi0tscgw3sri",
"key": "user-ewdxrba73q68kqj",
"displayName": "Joel Snyder",
"kind": "user",
"policy": "collaborative",
"targetUserId": "ewdxrba73q68kqj",
"deletedAt": null
},
{
"id": "asgpvdgthuoyhgx",
"key": "user-s1gwhknxaww38i3",
"displayName": "David Rader",
"kind": "user",
"policy": "collaborative",
"targetUserId": "s1gwhknxaww38i3",
"deletedAt": null
},
{
"id": "5jdlsa7ebt1jd3i",
"key": "user-nc23vrshrg0qtdi",
"displayName": "Ben Poland",
"kind": "user",
"policy": "collaborative",
"targetUserId": "nc23vrshrg0qtdi",
"deletedAt": null
},
{
"id": "r27bfvcvig2n90m",
"key": "user-naztc2sg4b4fgfc",
"displayName": "Mark Huber",
"kind": "user",
"policy": "collaborative",
"targetUserId": "naztc2sg4b4fgfc",
"deletedAt": null
},
{
"id": "il3piz7xfklzw3o",
"key": "testing-bucket",
"displayName": "Testing Bucket",
"kind": "bucket",
"policy": "handoff",
"targetUserId": null,
"deletedAt": null
},
{
"id": "w9zx3zqsi5r8zht",
"key": "vehicle-maintenance",
"displayName": "Vehicle Maintenance",
"kind": "bucket",
"policy": "handoff",
"targetUserId": null,
"deletedAt": null
},
{
"id": "o6n72jlh3s6c42e",
"key": "shop",
"displayName": "Shop",
"kind": "bucket",
"policy": "handoff",
"targetUserId": null,
"deletedAt": null
},
{
"id": "cu4b1epkpwf15zs",
"key": "equipment-maintenance",
"displayName": "Equipment Maintenance",
"kind": "bucket",
"policy": "handoff",
"targetUserId": null,
"deletedAt": null
},
{
"id": "zdqmiqyfjx3z68d",
"key": "development",
"displayName": "Development",
"kind": "bucket",
"policy": "handoff",
"targetUserId": null,
"deletedAt": null
},
{
"id": "pjwskg0eo91n5y6",
"key": "user-i73wk8r7f2evbew",
"displayName": "Eddie",
"kind": "user",
"policy": "collaborative",
"targetUserId": "i73wk8r7f2evbew",
"deletedAt": null
}
]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,82 +0,0 @@
[
{
"email": "it@cardoza.construction",
"id": "oz8sxsyg8hznqa6",
"name": "IT"
},
{
"email": "pbserviceupdate@cardoza.construction",
"id": "x6hu9mgawxzfvk6",
"name": "PBServiceWorker"
},
{
"email": "eewing@cardoza.construction",
"id": "2lmwfhtdwuexmiv",
"name": "Ezekiel Ewing"
},
{
"email": "aewing@cardoza.construction",
"id": "1hbif9dbqng86h9",
"name": "Alfonso Ewing"
},
{
"email": "hmahan@cardoza.construction",
"id": "fdve3i2gop4huym",
"name": "Heidi Mahan"
},
{
"email": "bcardoza@cardoza.construction",
"id": "h9uihmnkwngdu8y",
"name": "Beth Cardoza"
},
{
"email": "rcardoza@cardoza.construction",
"id": "e9vck6v3pa951wn",
"name": "Rick Cardoza"
},
{
"email": "dcardoza@cardoza.construction",
"id": "rrjoxd2wewmlrwy",
"name": "David Cardoza"
},
{
"email": "tcardoza@cardoza.construction",
"id": "sivbh2pwzyew5lb",
"name": "Timothy Cardoza"
},
{
"email": "employee@cardoza.construction",
"id": "gk3u91dx0r79fo2",
"name": "Employee"
},
{
"email": "cabrams@cardoza.construction",
"id": "mchpsyvh0wrcxv6",
"name": "Cortney Abrams"
},
{
"email": "jsnyder@cardoza.construction",
"id": "ewdxrba73q68kqj",
"name": "Joel Snyder"
},
{
"email": "drader@cardoza.construction",
"id": "s1gwhknxaww38i3",
"name": "David Rader"
},
{
"email": "mreds01@aol.com",
"id": "i73wk8r7f2evbew",
"name": "Eddie"
},
{
"email": "bpoland@cardoza.construction",
"id": "nc23vrshrg0qtdi",
"name": "Ben Poland"
},
{
"email": "markhuber66@yahoo.com",
"id": "naztc2sg4b4fgfc",
"name": "Mark Huber"
}
]
@@ -1,266 +0,0 @@
{
"summary": {
"touched": 28,
"personalRefsAdded": 7,
"personalRefsRemoved": 0,
"ambiguousMultiPersonal": 44,
"failures": 0
},
"tasks": [
{
"id": "z6s8kfpek0wowmp",
"title": "4150 Performing Arts - Material Lengths to Bill Mabary - see email",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"sivbh2pwzyew5lb",
"nc23vrshrg0qtdi"
]
},
{
"id": "hnn0ruj5kvcj7mi",
"title": "4150 Performing Arts - Acoustical",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"sivbh2pwzyew5lb",
"nc23vrshrg0qtdi"
]
},
{
"id": "ij2h8laauwxffyu",
"title": "4118 - Estes new wall CO Awaiting Conf and Sign",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"sivbh2pwzyew5lb",
"nc23vrshrg0qtdi"
]
},
{
"id": "yz34jlzc29tq3ve",
"title": "4150 Performing Arts - Framing Shop Drawings",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"sivbh2pwzyew5lb",
"nc23vrshrg0qtdi"
]
},
{
"id": "9wu0icmgy8z0dpn",
"title": "4150 Acoustic CO pending response - Performing Arts Center",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"sivbh2pwzyew5lb",
"nc23vrshrg0qtdi"
]
},
{
"id": "lq7rzmxv7wtxdx0",
"title": "4118 Estes CO - Windows and Server Room Plywood",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"sivbh2pwzyew5lb"
]
},
{
"id": "5cs9kcfqkavgy5y",
"title": "Check in with Dad about further contact Kickapoo Ceilings",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"sivbh2pwzyew5lb",
"h9uihmnkwngdu8y"
]
},
{
"id": "59apl48j7se00cw",
"title": "reorganize metal & label",
"ownerIdBefore": "s1gwhknxaww38i3",
"ownerIdAfter": "s1gwhknxaww38i3",
"personalUserIds": [
"s1gwhknxaww38i3"
]
},
{
"id": "5vn3zkn7wzaldsk",
"title": "test",
"ownerIdBefore": "gk3u91dx0r79fo2",
"ownerIdAfter": "gk3u91dx0r79fo2",
"personalUserIds": [
"gk3u91dx0r79fo2"
]
},
{
"id": "p2ut35nntf8s5on",
"title": "4841 - Andys HQ DD- Estimate",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"sivbh2pwzyew5lb"
]
},
{
"id": "kn1nxbmwgz7pyz9",
"title": "Equipment lists",
"ownerIdBefore": "nc23vrshrg0qtdi",
"ownerIdAfter": "nc23vrshrg0qtdi",
"personalUserIds": [
"nc23vrshrg0qtdi"
]
},
{
"id": "yqffg2agv88ggjo",
"title": "4118 - Estes - Lead Time Material Orders",
"ownerIdBefore": "nc23vrshrg0qtdi",
"ownerIdAfter": "nc23vrshrg0qtdi",
"personalUserIds": [
"nc23vrshrg0qtdi"
]
},
{
"id": "n9tb1zd2qovqycz",
"title": "4850 - Intercounty Electric Rolla - Estimate",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"sivbh2pwzyew5lb"
]
},
{
"id": "5rsy930ttk14kdm",
"title": "4850 - Intercounty Electric Rolla - Estimate (Copy)",
"ownerIdBefore": "e9vck6v3pa951wn",
"ownerIdAfter": "e9vck6v3pa951wn",
"personalUserIds": [
"e9vck6v3pa951wn"
]
},
{
"id": "zfmf0nil83svwan",
"title": "4150 Veregy - Joist lengths",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"nc23vrshrg0qtdi",
"sivbh2pwzyew5lb"
]
},
{
"id": "h2al3o03sfkn40z",
"title": "fix hilit gx-120",
"ownerIdBefore": null,
"ownerIdAfter": null,
"personalUserIds": []
},
{
"id": "m3jk7vi6jhnai3y",
"title": "TEST Tim to Bucket",
"ownerIdBefore": null,
"ownerIdAfter": null,
"personalUserIds": []
},
{
"id": "aq1trlil8wnqgms",
"title": "buffer task",
"ownerIdBefore": "gk3u91dx0r79fo2",
"ownerIdAfter": "gk3u91dx0r79fo2",
"personalUserIds": [
"gk3u91dx0r79fo2"
]
},
{
"id": "zbj1uyj0mvusqnb",
"title": "Door strike",
"ownerIdBefore": "2lmwfhtdwuexmiv",
"ownerIdAfter": "2lmwfhtdwuexmiv",
"personalUserIds": [
"2lmwfhtdwuexmiv"
]
},
{
"id": "9347spthvpgrpsn",
"title": "4845 - Pythian St Ceilings Colin - Estimate",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"sivbh2pwzyew5lb"
]
},
{
"id": "8xvb21a21k83gd1",
"title": "4150 Veregy Material Order",
"ownerIdBefore": "nc23vrshrg0qtdi",
"ownerIdAfter": "nc23vrshrg0qtdi",
"personalUserIds": [
"nc23vrshrg0qtdi",
"sivbh2pwzyew5lb"
]
},
{
"id": "fzjbasn9r86merg",
"title": "test employee to tim handoff",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"sivbh2pwzyew5lb"
]
},
{
"id": "w1ce533o8voja8w",
"title": "Task to note via handoff",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"sivbh2pwzyew5lb"
]
},
{
"id": "wk754kcblrx1pxt",
"title": "Test task employee to note via handoff",
"ownerIdBefore": "gk3u91dx0r79fo2",
"ownerIdAfter": "gk3u91dx0r79fo2",
"personalUserIds": []
},
{
"id": "napobxne0ak37gn",
"title": "Test task employee to note via collab",
"ownerIdBefore": "gk3u91dx0r79fo2",
"ownerIdAfter": "gk3u91dx0r79fo2",
"personalUserIds": [
"gk3u91dx0r79fo2"
]
},
{
"id": "1w3bbufjhoqq3sg",
"title": "Test task employee to tim via collab",
"ownerIdBefore": "gk3u91dx0r79fo2",
"ownerIdAfter": "gk3u91dx0r79fo2",
"personalUserIds": [
"gk3u91dx0r79fo2",
"sivbh2pwzyew5lb"
]
},
{
"id": "6o855v9n1r3147h",
"title": "Test task employee to bucket via collab",
"ownerIdBefore": "gk3u91dx0r79fo2",
"ownerIdAfter": "gk3u91dx0r79fo2",
"personalUserIds": [
"gk3u91dx0r79fo2"
]
},
{
"id": "r9303qb1ejgfoaw",
"title": "4150 Veregy Redo Full Takeoff",
"ownerIdBefore": "sivbh2pwzyew5lb",
"ownerIdAfter": "sivbh2pwzyew5lb",
"personalUserIds": [
"sivbh2pwzyew5lb"
]
}
]
}
+2 -10
View File
@@ -1,18 +1,12 @@
{
"name": "tasgrid",
"private": true,
"version": "1.1.1",
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 4001",
"dev:devdata": "vite --host 0.0.0.0 --port 4001 --mode devdata",
"build": "tsc -b && vite build",
"build:devdata": "tsc -b && vite build --mode devdata",
"migration:context-ownership:backup": "bun run scripts/context-ownership-migration.ts backup",
"migration:context-ownership:report": "bun run scripts/context-ownership-migration.ts report",
"migration:context-ownership:migrate": "bun run scripts/context-ownership-migration.ts migrate",
"serve": "vite preview --host 0.0.0.0 --port 4000",
"serve:devdata": "vite preview --host 0.0.0.0 --port 4002 --mode devdata",
"---PM2 COMMANDS---": "------------------",
"status": "pm2 status",
"logs": "pm2 logs tasgrid",
@@ -24,7 +18,6 @@
"@formkit/auto-animate": "^0.9.0",
"@kobalte/core": "^0.13.11",
"@tailwindcss/vite": "^4.1.18",
"@thisbeyond/solid-dnd": "^0.7.5",
"@tiptap/core": "^3.18.0",
"@tiptap/extension-bubble-menu": "^3.20.0",
"@tiptap/extension-dropcursor": "^3.20.0",
@@ -48,7 +41,6 @@
"autoprefixer": "^10.4.23",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"heic2any": "^0.0.4",
"lucide-solid": "^0.563.0",
"pm2": "^6.0.14",
"pocketbase": "^0.26.8",
@@ -69,4 +61,4 @@
"vite": "^7.2.4",
"vite-plugin-solid": "^2.11.10"
}
}
}
-44
View File
@@ -1,44 +0,0 @@
# Accessing Job Files from Sister App
This plan outlines how a sister application can access job data and physical files managed by Prism.
## Proposed Strategy
### 1. Accessing Job Metadata (Database)
The source of truth for all job information (names, numbers, addresses, and file paths) is the PocketBase instance.
**Recommendation: Direct PocketBase SDK Integration**
- The sister app should use the [PocketBase JavaScript/TypeScript SDK](https://github.com/pocketbase/js-sdk).
- Point the SDK to the same URL as Prism (e.g., `http://127.0.0.1:8090` or your production domain).
- **Authentication**: The sister app will need its own authentication or a way to share the `pb_auth` token.
### 2. Accessing Physical Files (NAS/Local Folders)
Prism uses two types of file links:
- **`Job_Folder_Link`**: URLs (SharePoint, OneDrive, etc.). These can be opened directly as `<a>` tags or via `window.open()`.
- **`Folder_Local`**: UNC paths (e.g., `\\server\share\folder`).
**Recommendation: Shared Local RPC Tool**
- Prism currently uses a local helper running on `http://localhost:8080` to open folders in File Explorer.
- The sister app can use the exact same logic found in [nasOpener.ts](file:///c:/Users/TimothyCardoza/Documents/AI-Apps/Prism/PRISM/src/lib/nasOpener.ts) to communicate with this tool.
- This allows both apps to provide a seamless "Open Folder" experience on the same machine.
### 3. Proposed API Enhancements (Optional)
If the sister app cannot talk to PocketBase directly (e.g., security restrictions), we can add a "Service Account" or "API Key" mechanism to Prism to proxy requests.
## Proposed Changes
### [Component Name]
No changes are strictly required to Prism's code unless you want a dedicated API for the sister app.
#### [MODIFY] [nasOpener.ts](file:///c:/Users/TimothyCardoza/Documents/AI-Apps/Prism/PRISM/src/lib/nasOpener.ts)
- Potentially export the RPC URL as a shared constant if both apps are in the same monorepo or share a library.
## Verification Plan
### Automated Tests
- Test that the sister app can fetch job data from PocketBase using the same collection names (`Job_Info_Prod`).
- Verify that `http://localhost:8080/health` is reachable from the sister app's environment.
### Manual Verification
- Attempt to open a job folder from the sister app and confirm it opens in the local system's File Explorer.
- Confirm that the sister app correctly resolves job IDs to their respective `Job_Number` or metadata.
-114
View File
@@ -1,114 +0,0 @@
/**
* TasGrid SDK
*
* A headless utility for creating tasks in the TasGrid PocketBase database
* from sister applications, ensuring all TasGrid defaults and date calculations
* are applied consistently.
*/
const URGENCY_HOURS = {
10: 12,
9: 24,
8: 36,
7: 72,
6: 120,
5: 240,
4: 504,
3: 1080,
2: 2160,
1: 4320
};
/**
* Calculates a future ISO date string based on the TasGrid urgency decay algorithm.
* @param {number} level Urgency level (1-10)
* @returns {string} ISO Date string
*/
export const calculateDateFromUrgency = (level) => {
const roundedLevel = Math.max(1, Math.min(10, Math.round(level)));
const hours = URGENCY_HOURS[roundedLevel] || 24;
return new Date(Date.now() + hours * 60 * 60 * 1000).toISOString();
};
/**
* Creates a new task in the TasGrid database using the provided PocketBase instance.
*
* @param {object} pb - An authenticated PocketBase JS SDK instance.
* @param {object} payload - The task payload.
* @param {string} [payload.title="New Task"] - The task title.
* @param {string} [payload.owner] - PocketBase User ID. Defaults to pb.authStore.model.id.
* @param {number} [payload.priority=5] - Priority (1-10).
* @param {number} [payload.urgency=5] - Urgency (1-10). Used to calculate dueDate if omitted.
* @param {number} [payload.size=3] - Size (0-10).
* @param {number} [payload.status=0] - Status (0-10).
* @param {boolean} [payload.completed=false] - Completion status.
* @param {string[]} [payload.tags=[]] - Array of tag strings.
* @param {string} [payload.content=""] - HTML string for task description/notes.
* @param {string} [payload.dueDate] - Strict ISO Date string. Overrides urgency calculation.
* @param {string} [payload.startDate=""] - ISO Date string.
* @returns {Promise<object>} The created PocketBase task record.
*/
export const createTasgridTask = async (pb, payload = {}) => {
if (!pb || !pb.authStore || !pb.authStore.isValid || !pb.authStore.model) {
throw new Error("Invalid or unauthenticated PocketBase instance provided.");
}
const urgencyVal = payload.urgency ?? 5;
const taskData = {
title: payload.title || "New Task",
priority: payload.priority ?? 5,
size: payload.size ?? 3,
status: payload.status ?? 0,
completed: payload.completed ?? false,
tags: payload.tags || [],
content: payload.content || "",
user: payload.owner || pb.authStore.model.id,
dueDate: payload.dueDate || calculateDateFromUrgency(urgencyVal),
startDate: payload.startDate || "",
};
try {
const record = await pb.collection('TasGrid').create(taskData);
return record;
} catch (err) {
console.error("TasGrid SDK: Failed to create task", err);
throw err;
}
};
/**
* Creates a new note in the TasGrid database using the provided PocketBase instance.
*
* @param {object} pb - An authenticated PocketBase JS SDK instance.
* @param {object} payload - The note payload.
* @param {string} [payload.title="New Note"] - The note title.
* @param {string} [payload.content=""] - HTML string for note content.
* @param {string[]} [payload.tags=[]] - Array of tag strings.
* @param {boolean} [payload.isPrivate=false] - Whether the note is private.
* @param {string[]} [payload.tasks=[]] - Array of task IDs to link.
* @param {string} [payload.owner] - PocketBase User ID. Defaults to pb.authStore.model.id.
* @returns {Promise<object>} The created PocketBase note record.
*/
export const createTasgridNote = async (pb, payload = {}) => {
if (!pb || !pb.authStore || !pb.authStore.isValid || !pb.authStore.model) {
throw new Error("Invalid or unauthenticated PocketBase instance provided.");
}
const noteData = {
title: payload.title || "New Note",
content: payload.content || "",
tags: payload.tags || [],
isPrivate: payload.isPrivate || false,
tasks: payload.tasks || [],
user: payload.owner || pb.authStore.model.id,
};
try {
const record = await pb.collection('TasGrid_Notes').create(noteData);
return record;
} catch (err) {
console.error("TasGrid SDK: Failed to create note", err);
throw err;
}
};
-596
View File
@@ -1,596 +0,0 @@
import fs from "node:fs/promises";
import path from "node:path";
type SharePolicy = "collaborative" | "handoff";
type ShareKind = "user" | "bucket" | "note";
type ShareContext = {
id: string;
key: string;
displayName: string;
kind: ShareKind;
policy: SharePolicy;
targetUserId?: string | null;
deletedAt?: string | null;
};
type NoteRecord = {
id: string;
title: string;
key: string;
tags: string[];
user: string;
tasks: string[];
};
type TaskShareRef = {
contextId: string;
key: string;
kind: ShareKind;
policy?: SharePolicy;
};
type NoteRef = {
noteId: string;
key: string;
};
type ParsedTag =
| { kind: "share"; raw: string; key: string; display: string }
| { kind: "note"; raw: string; key: string; display: string }
| { kind: "label"; raw: string; key: string; display: string };
type TaskRecord = {
id: string;
title: string;
tags?: string[];
labelTags?: string[];
shareRefs?: TaskShareRef[];
noteRefs?: NoteRef[];
createdBy?: string | null;
user?: string | string[] | null;
};
type Summary = {
touched: number;
personalRefsAdded: number;
personalRefsRemoved: number;
ambiguousMultiPersonal: number;
failures: number;
};
const mode = (process.argv[2] || "report").toLowerCase();
if (!["backup", "report", "migrate"].includes(mode)) {
throw new Error("Usage: bun run scripts/context-ownership-migration.ts [backup|report|migrate]");
}
const dataMode = (process.env.TASGRID_DATA_MODE || "prod").toLowerCase() === "dev" ? "dev" : "prod";
const withDataSuffix = (base: string) => dataMode === "dev" ? `${base}_Dev` : base;
const TASGRID_COLLECTION = withDataSuffix("TasGrid");
const CONTEXTS_COLLECTION = withDataSuffix("TasGrid_Contexts");
const NOTES_COLLECTION = withDataSuffix("TasGrid_Notes");
const NOTE_HANDOFF_POLICY_TAG = "__note_policy_handoff__";
const POCKETBASE_URL = process.env.POCKETBASE_URL || "https://pocketbase.ccllc.pro";
let authToken = "";
const normalizeWhitespace = (value: string) => value.trim().replace(/\s+/g, " ");
const normalizeKey = (value: string) =>
normalizeWhitespace(value)
.toLowerCase()
.replace(/^[@#]+/, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
const buildShareTag = (displayName: string) => `@${normalizeWhitespace(displayName)}`;
const buildNoteTag = (value: string) => `#${normalizeWhitespace(value)}`;
const buildUserContextKey = (userId: string) => `user-${userId}`;
const unwrapRelationId = (value: unknown): string | null => {
if (typeof value === "string") return value || null;
if (Array.isArray(value)) {
const first = value[0];
return typeof first === "string" ? first : null;
}
return null;
};
const parseTag = (rawTag: string): ParsedTag => {
const raw = normalizeWhitespace(rawTag);
const prefix = raw[0];
const display = prefix === "@" || prefix === "#" ? raw.slice(1).trim() : raw;
const key = normalizeKey(display);
if (prefix === "@") return { kind: "share", raw, key, display };
if (prefix === "#") return { kind: "note", raw, key, display };
return { kind: "label", raw, key, display };
};
const parseTags = (rawTags: string[] | undefined | null) =>
(rawTags || []).map(parseTag).filter(tag => !!tag.display);
const dedupeShareRefs = (refs: TaskShareRef[]) => {
const seen = new Set<string>();
return refs.filter(ref => {
const key = `${ref.kind}:${ref.contextId}:${ref.key}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
};
const dedupeNoteRefs = (refs: NoteRef[]) => {
const seen = new Set<string>();
return refs.filter(ref => {
const key = `${ref.noteId}:${ref.key}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
};
const DEFAULT_SHARE_POLICY_BY_KIND: Record<ShareKind, SharePolicy> = {
user: "collaborative",
bucket: "handoff",
note: "handoff"
};
const getNoteSharePolicy = (note: Pick<NoteRecord, "tags"> | undefined): SharePolicy =>
note?.tags?.includes(NOTE_HANDOFF_POLICY_TAG) ? "handoff" : "collaborative";
const relationId = (record: any, field: string) => unwrapRelationId(record?.[field]);
const createOutputDir = async () => {
const outputDir = path.resolve(process.cwd(), "migration-output", `context-ownership-${new Date().toISOString().replace(/[:.]/g, "-")}`);
await fs.mkdir(outputDir, { recursive: true });
return outputDir;
};
const tryAuthEndpoint = async (pathname: string, email: string, password: string) => {
const response = await fetch(`${POCKETBASE_URL}${pathname}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity: email, password })
});
if (!response.ok) {
return {
ok: false as const,
status: response.status,
body: await response.text(),
pathname
};
}
return {
ok: true as const,
payload: await response.json(),
pathname
};
};
const auth = async () => {
const email = process.env.PB_ADMIN_EMAIL || process.env.PB_EMAIL;
const password = process.env.PB_ADMIN_PASSWORD || process.env.PB_PASSWORD;
if (!email || !password) {
throw new Error("Set PB_ADMIN_EMAIL/PB_ADMIN_PASSWORD (or PB_EMAIL/PB_PASSWORD) before running this script.");
}
const authAttempts = [
await tryAuthEndpoint("/api/collections/_superusers/auth-with-password", email, password),
await tryAuthEndpoint("/api/admins/auth-with-password", email, password)
];
const success = authAttempts.find(attempt => attempt.ok);
if (!success) {
const details = authAttempts
.map(attempt => `${attempt.pathname}: ${attempt.status} ${attempt.body}`)
.join(" | ");
throw new Error(`PocketBase superuser/admin auth failed. ${details}`);
}
const payload = success.payload;
authToken = payload.token;
};
const request = async (pathname: string, init: RequestInit = {}) => {
const response = await fetch(`${POCKETBASE_URL}${pathname}`, {
...init,
headers: {
"Content-Type": "application/json",
...(authToken ? { Authorization: authToken } : {}),
...(init.headers || {})
}
});
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}: ${await response.text()}`);
}
if (response.status === 204) {
return null;
}
return response.json();
};
const getFullList = async (collection: string, query: Record<string, string> = {}) => {
const items: any[] = [];
let page = 1;
while (true) {
const search = new URLSearchParams({ perPage: "500", page: String(page), ...query });
const payload = await request(`/api/collections/${collection}/records?${search.toString()}`);
items.push(...(payload.items || []));
if (!payload.totalPages || page >= payload.totalPages) {
break;
}
page += 1;
}
return items;
};
const createRecord = (collection: string, body: Record<string, unknown>) =>
request(`/api/collections/${collection}/records`, {
method: "POST",
body: JSON.stringify(body)
});
const updateRecord = (collection: string, id: string, body: Record<string, unknown>) =>
request(`/api/collections/${collection}/records/${id}`, {
method: "PATCH",
body: JSON.stringify(body)
});
const syncUserContexts = async (users: any[], contexts: ShareContext[]) => {
const nextContexts = [...contexts];
for (const user of users) {
const displayName = user.name || user.email;
const existing = nextContexts.find(context =>
context.kind === "user" &&
context.targetUserId === user.id &&
!context.deletedAt
);
if (!displayName || existing) continue;
const created = await createRecord(CONTEXTS_COLLECTION, {
key: buildUserContextKey(user.id),
displayName,
kind: "user",
policy: "collaborative",
targetUserId: user.id
});
nextContexts.push({
id: created.id,
key: created.key,
displayName: created.displayName,
kind: created.kind,
policy: "collaborative",
targetUserId: relationId(created, "targetUserId"),
deletedAt: created.deletedAt || null
});
}
return nextContexts;
};
const buildNoteLinkMap = (notes: NoteRecord[]) => {
const links = new Map<string, NoteRef[]>();
for (const note of notes) {
for (const taskId of note.tasks || []) {
const existing = links.get(taskId) || [];
existing.push({ noteId: note.id, key: note.key });
links.set(taskId, existing);
}
}
return links;
};
const main = async () => {
await auth();
const [users, rawContexts, rawNotes, rawTasks] = await Promise.all([
getFullList("users", { fields: "id,name,email" }),
getFullList(CONTEXTS_COLLECTION),
getFullList(NOTES_COLLECTION),
getFullList(TASGRID_COLLECTION)
]);
const contexts = mode === "migrate"
? await syncUserContexts(users, rawContexts.map((record: any) => ({
id: record.id,
key: record.key,
displayName: record.displayName,
kind: record.kind || "bucket",
policy: (record.policy || (record.kind === "user" ? "collaborative" : "handoff")) as SharePolicy,
targetUserId: relationId(record, "targetUserId"),
deletedAt: record.deletedAt || null
})))
: rawContexts.map((record: any) => ({
id: record.id,
key: record.key,
displayName: record.displayName,
kind: record.kind || "bucket",
policy: (record.policy || (record.kind === "user" ? "collaborative" : "handoff")) as SharePolicy,
targetUserId: relationId(record, "targetUserId"),
deletedAt: record.deletedAt || null
}));
const notes = rawNotes.map((record: any) => ({
id: record.id,
title: record.title,
key: record.key || normalizeKey(record.title || record.id),
tags: Array.isArray(record.tags) ? record.tags.filter(Boolean) : [],
user: relationId(record, "user") || "",
tasks: Array.isArray(record.tasks) ? record.tasks.filter(Boolean) : []
})) satisfies NoteRecord[];
const contextById = new Map(contexts.map(context => [context.id, context]));
const ownerContextByUserId = new Map(
contexts
.filter(context => context.kind === "user" && !!context.targetUserId && !context.deletedAt)
.map(context => [context.targetUserId as string, context] as const)
);
const noteById = new Map(notes.map(note => [note.id, note]));
const noteByKey = new Map(notes.map(note => [note.key, note]));
const noteLinksByTaskId = buildNoteLinkMap(notes);
const getContextByRef = (ref: TaskShareRef) => {
if (ref.kind === "note") {
const note = noteById.get(ref.contextId) || noteByKey.get(ref.key);
return note
? {
id: note.id,
key: note.key,
displayName: note.key || note.title,
kind: "note" as const,
policy: getNoteSharePolicy(note),
targetUserId: note.user
}
: null;
}
return contextById.get(ref.contextId)
|| contexts.find(context => !context.deletedAt && context.key === ref.key && context.kind === ref.kind)
|| contexts.find(context => !context.deletedAt && normalizeKey(context.displayName) === ref.key)
|| null;
};
const getTaskSharePolicy = (ref: TaskShareRef): SharePolicy => {
if (ref.kind === "note") {
if (ref.policy) return ref.policy;
const note = noteById.get(ref.contextId) || noteByKey.get(ref.key);
return getNoteSharePolicy(note);
}
const context = getContextByRef(ref);
if (ref.policy) return ref.policy;
return context?.policy || DEFAULT_SHARE_POLICY_BY_KIND[ref.kind];
};
const getPersonalContextUserIdFromRef = (ref: TaskShareRef) => {
if (ref.kind !== "user") return null;
const context = getContextByRef(ref);
if (context?.targetUserId) return context.targetUserId;
if (ref.key.startsWith("user-")) return ref.key.replace(/^user-/, "") || null;
return null;
};
const getTaskPersonalRefs = (refs: TaskShareRef[]) =>
refs.filter(ref => ref.kind === "user" && !!getPersonalContextUserIdFromRef(ref));
const resolveMirroredOwnerId = (currentOwnerId: string | null, refs: TaskShareRef[], preferredOwnerId?: string | null) => {
const personalUserIds = [...new Set(
refs
.map(ref => getPersonalContextUserIdFromRef(ref))
.filter((value): value is string => !!value)
)];
if (personalUserIds.length === 1) return personalUserIds[0];
if (personalUserIds.length > 1 && preferredOwnerId && personalUserIds.includes(preferredOwnerId)) return preferredOwnerId;
return currentOwnerId || null;
};
const buildTaskTags = (labelTags: string[], shareRefs: TaskShareRef[], noteRefs: NoteRef[], favoriteTag?: string) => {
const tags = [
...labelTags,
...shareRefs.map(ref => {
const context = getContextByRef(ref);
if (ref.kind === "note") return buildNoteTag(context?.key || ref.key);
if (context?.displayName) return buildShareTag(context.displayName);
if (ref.kind === "user" && ref.key.startsWith("user-")) return null;
return buildShareTag(ref.key);
}),
...noteRefs.map(ref => {
const note = noteById.get(ref.noteId) || noteByKey.get(ref.key);
return buildNoteTag(note?.key || ref.key);
})
];
if (favoriteTag && !tags.includes(favoriteTag)) tags.push(favoriteTag);
return [...new Set(tags.filter((value): value is string => !!value))];
};
const summary: Summary = {
touched: 0,
personalRefsAdded: 0,
personalRefsRemoved: 0,
ambiguousMultiPersonal: 0,
failures: 0
};
const reportRows: any[] = [];
for (const record of rawTasks as TaskRecord[]) {
if ((record.tags || []).includes("__template__")) continue;
try {
const legacyTags = record.tags || [];
const parsedTags = parseTags(legacyTags).filter(tag => tag.raw !== "__template__");
const shareRefs = Array.isArray(record.shareRefs)
? record.shareRefs.map(ref => ({
contextId: ref.contextId,
key: ref.key,
kind: ref.kind,
policy: ref.policy
}))
: parsedTags
.filter((tag): tag is Extract<(typeof parsedTags)[number], { kind: "share" | "note" }> => tag.kind === "share" || tag.kind === "note")
.map(tag => {
if (tag.kind === "note") {
const note = noteByKey.get(tag.key);
return {
contextId: note?.id || tag.key,
key: tag.key,
kind: "note" as const
};
}
const context = contexts.find(existing =>
!existing.deletedAt && (
existing.key === tag.key ||
normalizeKey(existing.displayName) === tag.key ||
buildShareTag(existing.displayName).toLowerCase() === tag.raw.toLowerCase()
)
);
return {
contextId: context?.id || tag.key,
key: tag.key,
kind: (context?.kind || "bucket") as "user" | "bucket",
policy: context?.policy
};
});
const noteRefs = dedupeNoteRefs([
...(Array.isArray(record.noteRefs)
? record.noteRefs.map(ref => ({ noteId: ref.noteId, key: ref.key }))
: parsedTags
.filter(tag => tag.kind === "note")
.map(tag => {
const note = noteByKey.get(tag.key);
return {
noteId: note?.id || tag.key,
key: tag.key
};
})),
...(noteLinksByTaskId.get(record.id) || [])
]);
let nextShareRefs = dedupeShareRefs([
...shareRefs,
...noteRefs.map(ref => ({
contextId: ref.noteId,
key: ref.key,
kind: "note" as const,
policy: shareRefs.find(existing => existing.kind === "note" && (existing.contextId === ref.noteId || existing.key === ref.key))?.policy
}))
]);
const ownerId = unwrapRelationId(record.user);
const ownerContext = ownerId ? ownerContextByUserId.get(ownerId) : undefined;
const shouldRemoveOwnerPersonalRef = !!ownerId &&
getTaskPersonalRefs(nextShareRefs).length === 0 &&
nextShareRefs.some(ref => ref.kind !== "user" && getTaskSharePolicy(ref) === "handoff");
if (ownerContext) {
const matchesOwnerContext = (ref: TaskShareRef) =>
ref.kind === "user" && (
ref.contextId === ownerContext.id ||
ref.key === ownerContext.key ||
normalizeKey(ownerContext.displayName) === ref.key
);
if (shouldRemoveOwnerPersonalRef) {
const before = nextShareRefs.length;
nextShareRefs = nextShareRefs.filter(ref => !matchesOwnerContext(ref));
if (before !== nextShareRefs.length) {
summary.personalRefsRemoved += before - nextShareRefs.length;
}
} else if (!nextShareRefs.some(matchesOwnerContext)) {
nextShareRefs = [
{
contextId: ownerContext.id,
key: ownerContext.key,
kind: "user" as const,
policy: ownerContext.policy
},
...nextShareRefs
];
summary.personalRefsAdded += 1;
}
}
nextShareRefs = dedupeShareRefs(nextShareRefs);
const labelTags = Array.isArray(record.labelTags)
? record.labelTags
: parsedTags.filter(tag => tag.kind === "label").map(tag => tag.display);
const favoriteTag = legacyTags.find(tag => tag.endsWith("_favorite__"));
const nextTags = buildTaskTags(labelTags, nextShareRefs, noteRefs, favoriteTag);
const nextOwnerId = resolveMirroredOwnerId(ownerId, nextShareRefs, ownerId);
const personalUserIds = [...new Set(nextShareRefs.map(ref => getPersonalContextUserIdFromRef(ref)).filter((value): value is string => !!value))];
if (personalUserIds.length > 1) {
summary.ambiguousMultiPersonal += 1;
}
const changed =
JSON.stringify(nextShareRefs) !== JSON.stringify(record.shareRefs || []) ||
JSON.stringify(noteRefs) !== JSON.stringify(record.noteRefs || []) ||
JSON.stringify(nextTags) !== JSON.stringify(record.tags || []) ||
nextOwnerId !== ownerId;
if (changed) {
summary.touched += 1;
reportRows.push({
id: record.id,
title: record.title,
ownerIdBefore: ownerId,
ownerIdAfter: nextOwnerId,
personalUserIds
});
if (mode === "migrate") {
await updateRecord(TASGRID_COLLECTION, record.id, {
user: nextOwnerId,
tags: nextTags,
labelTags,
shareRefs: nextShareRefs,
noteRefs
});
}
}
} catch (error) {
summary.failures += 1;
reportRows.push({
id: record.id,
title: record.title,
error: error instanceof Error ? error.message : String(error)
});
}
}
if (mode === "backup") {
const outputDir = await createOutputDir();
await Promise.all([
fs.writeFile(path.join(outputDir, "users.json"), JSON.stringify(users, null, 2)),
fs.writeFile(path.join(outputDir, "contexts.json"), JSON.stringify(contexts, null, 2)),
fs.writeFile(path.join(outputDir, "notes.json"), JSON.stringify(rawNotes, null, 2)),
fs.writeFile(path.join(outputDir, "tasks.json"), JSON.stringify(rawTasks, null, 2))
]);
console.log(JSON.stringify({ mode, dataMode, outputDir, counts: { users: users.length, contexts: contexts.length, notes: rawNotes.length, tasks: rawTasks.length } }, null, 2));
return;
}
if (mode === "report") {
const outputDir = await createOutputDir();
await fs.writeFile(path.join(outputDir, "context-ownership-report.json"), JSON.stringify({ summary, tasks: reportRows }, null, 2));
console.log(JSON.stringify({ mode, dataMode, outputDir, summary }, null, 2));
return;
}
console.log(JSON.stringify({ mode, dataMode, summary }, null, 2));
};
main().catch((error) => {
console.error(error);
process.exit(1);
});
+47 -56
View File
@@ -32,6 +32,7 @@ const App: Component = () => {
let isFirstRun = true;
const removeListener = pb.authStore.onChange((token) => {
console.log('[DEBUG] pb.authStore.onChange fired, token present:', !!token);
const wasAuthenticated = isAuthenticated();
setIsAuthenticated(!!token);
@@ -91,63 +92,53 @@ const App: Component = () => {
return view;
};
return (
<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>
}
>
{(() => {
const isPublicNote = () => {
const view = getEmbedView();
const urlObj = new URL(location());
const noteId = new URLSearchParams(urlObj.search).get('noteId') ||
(urlObj.hash.includes('?') ? new URLSearchParams(urlObj.hash.substring(urlObj.hash.indexOf('?'))).get('noteId') : null);
return view === 'embed_notes' && !!noteId;
};
return (
<EmbedAuthWrapper skipAuth={isPublicNote()}>
<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');
if (!noteId && urlObj.hash.includes('?')) {
const hashQueryString = urlObj.hash.substring(urlObj.hash.indexOf('?'));
noteId = new URLSearchParams(hashQueryString).get('noteId');
}
return noteId;
});
return <NotepadView selectedNoteId={noteIdFromUrl()} setSelectedNoteId={() => { }} hideNavigation={!!noteIdFromUrl()} />;
})()}
</Show>
<Show when={getEmbedView() === 'embed_quick_add'}>
<QuickEntryForm autoFocus={true} />
</Show>
</Suspense>
</EmbedLayout>
</EmbedAuthWrapper>
);
})()}
return (
<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>
);
};
+3 -5
View File
@@ -3,14 +3,12 @@ import { pb } from "@/lib/pocketbase";
interface EmbedAuthWrapperProps {
children: JSX.Element;
skipAuth?: boolean;
}
export const EmbedAuthWrapper: Component<EmbedAuthWrapperProps> = (props) => {
const [isHydrated, setIsHydrated] = createSignal(pb.authStore.isValid || !!props.skipAuth);
const [isHydrated, setIsHydrated] = createSignal(pb.authStore.isValid);
const handleMessage = (event: MessageEvent) => {
if (props.skipAuth) return; // Don't process auth messages in skip mode
const { data } = event;
console.log('[DEBUG] EmbedAuthWrapper received message:', data?.type);
if (data?.type === "TASGRID_AUTH" && data.token) {
@@ -22,7 +20,7 @@ export const EmbedAuthWrapper: Component<EmbedAuthWrapperProps> = (props) => {
};
onMount(() => {
console.log('[DEBUG] EmbedAuthWrapper mounted. Initial isHydrated:', isHydrated(), 'skipAuth:', props.skipAuth);
console.log('[DEBUG] EmbedAuthWrapper mounted. Initial isHydrated:', isHydrated());
window.addEventListener("message", handleMessage);
});
@@ -32,7 +30,7 @@ export const EmbedAuthWrapper: Component<EmbedAuthWrapperProps> = (props) => {
return (
<Show
when={isHydrated() || props.skipAuth}
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>
-6
View File
@@ -11,12 +11,6 @@ export const EmbedLayout: Component<EmbedLayoutProps> = (props) => {
<div class="w-full h-full mx-auto p-4">
{props.children}
</div>
<div
class="md:hidden shrink-0"
style={{
height: "calc(8px + env(safe-area-inset-bottom, 0px))"
}}
/>
</main>
</div>
);
+140 -411
View File
@@ -1,70 +1,29 @@
import { type Component, createSignal, For, Show } from "solid-js";
import { Search, X, Tag, ArrowUpCircle, Clock, Minus, Save, Trash2, Bookmark, User, Star, SlidersHorizontal } from "lucide-solid";
import { store, setFilter, clearFilter, saveFilterTemplate, applyFilterTemplate, removeFilterTemplate, loadAllHistory, setNoteFilter, clearNoteFilter, saveNoteFilterTemplate, applyNoteFilterTemplate, removeNoteFilterTemplate, type Filter } 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";
import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select";
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "./ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { useTheme } from "./ThemeProvider";
import { getThemeAdjustedColor } from "@/lib/colors";
export const FilterBar: Component<{ mode?: 'tasks' | 'notes', class?: string, triggerClass?: string }> = (props) => {
export const FilterBar: Component = () => {
const { resolvedTheme } = useTheme();
const [isOpen, setIsOpen] = createSignal(false);
const [isTagOpen, setIsTagOpen] = createSignal(false);
const [tagSearch, setTagSearch] = createSignal("");
const [newTemplateName, setNewTemplateName] = createSignal("");
const [isSaving, setIsSaving] = createSignal(false);
const [isJobStatusOpen, setIsJobStatusOpen] = createSignal(false);
const [isJobDivisionOpen, setIsJobDivisionOpen] = createSignal(false);
const mode = () => props.mode || 'tasks';
const currentFilter = () => mode() === 'tasks' ? store.filter : store.noteFilter;
const currentSetFilter = (val: Partial<Filter>) => mode() === 'tasks' ? setFilter(val) : setNoteFilter(val);
const currentClearFilter = () => mode() === 'tasks' ? clearFilter() : clearNoteFilter();
const currentSaveTemplate = (name: string) => mode() === 'tasks' ? saveFilterTemplate(name) : saveNoteFilterTemplate(name);
const currentApplyTemplate = (id: string) => mode() === 'tasks' ? applyFilterTemplate(id) : applyNoteFilterTemplate(id);
const currentRemoveTemplate = (id: string) => mode() === 'tasks' ? removeFilterTemplate(id) : removeNoteFilterTemplate(id);
const hasActiveFilters = () => {
const f = currentFilter();
const baseActive = (f.query || "") !== "" || (f.tags || []).length > 0 || f.editedToday || f.ownedByMe || f.starred;
const jobActive = (f.jobStatus?.length || 0) > 0 || (f.jobDivision?.length || 0) > 0;
if (mode() === 'tasks') {
return baseActive || f.priorityMin !== 1 || f.priorityMax !== 10 || f.urgencyMin !== 1 || f.urgencyMax !== 10;
}
return baseActive || jobActive;
const f = store.filter;
return f.query !== "" || f.tags.length > 0 || f.priorityMin !== 1 || f.priorityMax !== 10 || f.urgencyMin !== 1 || f.urgencyMax !== 10 || f.editedToday;
};
const allTags = () => store.tagDefinitions.map(d => d.name).sort();
const JOB_STATUSES = [
"Estimating",
"Est Sent",
"Est Hold",
"Follow Up",
"Awarded",
"In Progress",
"On Hold",
"Billed (In Progress)",
"Billed (Closed)",
"Archive",
"Not Awarded",
"Not Bidding"
];
const JOB_DIVISIONS = ["C#", "R#", "S#"];
const normalizeFilterTags = (items: Array<string | { name: string; excluded: boolean }> | undefined) => {
if (!items) return [] as { name: string; excluded: boolean }[];
return items.map((i: any) => {
if (typeof i === "string") return { name: i, excluded: false };
return { name: i.name, excluded: !!i.excluded };
});
};
return (
<div class={cn(mode() === 'tasks' ? "fixed top-4 right-5 sm:right-6 z-[60]" : "relative", props.class)}>
<div class="fixed top-4 right-5 sm:right-6 z-[60]">
<Popover open={isOpen()} onOpenChange={setIsOpen}>
<PopoverTrigger
as={Button}
@@ -73,34 +32,18 @@ export const FilterBar: Component<{ mode?: 'tasks' | 'notes', class?: string, tr
class={cn(
"bg-background/80 backdrop-blur-sm border border-border hover:bg-muted shadow-sm transition-all duration-300",
hasActiveFilters() && "border-primary/50 ring-1 ring-primary/20",
isOpen() && "bg-muted ring-1 ring-border",
props.triggerClass
isOpen() && "bg-muted ring-1 ring-border"
)}
title={mode() === 'tasks' ? "Filters & Search" : "Filters"}
title="Filters & Search"
>
<div class="relative">
{mode() === 'tasks' ? (
<Search
size={18}
class={cn(
"transition-transform duration-300",
isOpen() && "scale-110",
hasActiveFilters() && "text-primary"
)}
/>
) : (
<SlidersHorizontal
size={18}
class={cn(
"transition-transform duration-300",
isOpen() && "scale-110",
hasActiveFilters() && "text-primary"
)}
/>
<Search size={18} class={cn("transition-transform duration-300", isOpen() && "scale-110")} />
{hasActiveFilters() && !isOpen() && (
<div class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-primary animate-pulse border border-background" />
)}
</div>
</PopoverTrigger>
<PopoverContent class="w-[calc(100vw-2rem)] sm:w-[480px] p-0 overflow-hidden bg-card border-border shadow-2xl rounded-2xl animate-in fade-in zoom-in-95 duration-200 z-[100]">
<PopoverContent class="w-[calc(100vw-2rem)] sm:w-[480px] p-0 overflow-hidden bg-card border-border shadow-2xl rounded-2xl animate-in fade-in zoom-in-95 duration-200">
{/* Header */}
<div class="px-4 py-3 border-b border-border/50 bg-muted/20 flex items-center justify-between">
<div class="flex items-center gap-4">
@@ -110,63 +53,37 @@ export const FilterBar: Component<{ mode?: 'tasks' | 'notes', class?: string, tr
</h3>
<div class="h-4 w-px bg-border/50" />
<button
onClick={() => currentSetFilter({ editedToday: !currentFilter().editedToday })}
onClick={() => setFilter({ editedToday: !store.filter.editedToday })}
class={cn(
"flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[0.5625rem] font-black uppercase tracking-tight transition-all border",
currentFilter().editedToday
store.filter.editedToday
? "bg-primary/10 text-primary border-primary/20"
: "bg-muted/50 text-muted-foreground border-transparent hover:bg-muted"
)}
>
<div class={cn("w-1.5 h-1.5 rounded-full", currentFilter().editedToday ? "bg-primary" : "bg-muted-foreground/30")} />
<div class={cn("w-1.5 h-1.5 rounded-full", store.filter.editedToday ? "bg-primary" : "bg-muted-foreground/30")} />
Edited Today
</button>
<button
onClick={() => currentSetFilter({ ownedByMe: !currentFilter().ownedByMe })}
class={cn(
"flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[0.5625rem] font-black uppercase tracking-tight transition-all border",
currentFilter().ownedByMe
? "bg-primary/10 text-primary border-primary/20"
: "bg-muted/50 text-muted-foreground border-transparent hover:bg-muted"
)}
>
<User size={10} class={cn(currentFilter().ownedByMe ? "text-primary" : "text-muted-foreground/50")} />
Mine
</button>
<button
onClick={() => currentSetFilter({ starred: !currentFilter().starred })}
class={cn(
"flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[0.5625rem] font-black uppercase tracking-tight transition-all border",
currentFilter().starred
? "bg-amber-500/10 text-amber-500 border-amber-500/20"
: "bg-muted/50 text-muted-foreground border-transparent hover:bg-muted"
)}
>
<Star size={10} class={cn(currentFilter().starred ? "fill-amber-500 text-amber-500" : "text-muted-foreground/50")} />
Starred
</button>
</div>
<div class="flex items-center gap-2">
<Show when={mode() === 'tasks'}>
<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>
<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-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground hover:text-destructive"
onClick={currentClearFilter}
onClick={clearFilter}
>
Clear
</Button>
@@ -184,257 +101,100 @@ export const FilterBar: Component<{ mode?: 'tasks' | 'notes', class?: string, tr
{/* Controls */}
<div class="p-4 space-y-6">
<Show when={mode() === 'tasks'}>
<div class="space-y-2">
<div class="relative group">
<Search size={16} class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground/50 group-focus-within:text-primary transition-colors" />
<input
ref={(el) => {
if (el) setTimeout(() => el.focus(), 50);
}}
type="text"
placeholder="Search tasks, descriptions, tags..."
value={currentFilter().query}
onInput={(e) => currentSetFilter({ 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>
{/* Search Input */}
<div class="space-y-2">
<div class="relative group">
<Search size={16} class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground/50 group-focus-within:text-primary transition-colors" />
<input
ref={(el) => {
if (el) setTimeout(() => el.focus(), 50);
}}
type="text"
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>
</Show>
</div>
<Show when={mode() === 'tasks'}>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
{/* Priority Range */}
<div class="space-y-2.5">
<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()}
onChange={(val) => val && setFilter({ priorityMin: parseInt(val) })}
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
<div class="flex items-center gap-2 justify-center">
<ArrowUpCircle size={14} class="text-muted-foreground/70" />
<span>Min: {store.filter.priorityMin}</span>
</div>
</SelectTrigger>
<SelectContent />
</Select>
<div class="w-px h-4 bg-border/50" />
<Select
value={store.filter.priorityMax.toString()}
onChange={(val) => val && setFilter({ priorityMax: parseInt(val) })}
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
<div class="flex items-center gap-2 justify-center">
<span>Max: {store.filter.priorityMax}</span>
</div>
</SelectTrigger>
<SelectContent />
</Select>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
{/* Priority Range */}
<div class="space-y-2.5">
<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()}
onChange={(val) => val && setFilter({ priorityMin: parseInt(val) })}
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
<div class="flex items-center gap-2 justify-center">
<ArrowUpCircle size={14} class="text-muted-foreground/70" />
<span>Min: {store.filter.priorityMin}</span>
</div>
</SelectTrigger>
<SelectContent />
</Select>
<div class="w-px h-4 bg-border/50" />
<Select
value={store.filter.priorityMax.toString()}
onChange={(val) => val && setFilter({ priorityMax: parseInt(val) })}
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
<div class="flex items-center gap-2 justify-center">
<span>Max: {store.filter.priorityMax}</span>
</div>
</SelectTrigger>
<SelectContent />
</Select>
</div>
</div>
{/* Urgency Range */}
<div class="space-y-2.5">
<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()}
onChange={(val) => val && setFilter({ urgencyMin: parseInt(val) })}
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
<div class="flex items-center gap-2 justify-center">
<Clock size={14} class="text-muted-foreground/70" />
<span>Min: {store.filter.urgencyMin}</span>
</div>
</SelectTrigger>
<SelectContent />
</Select>
<div class="w-px h-4 bg-border/50" />
<Select
value={store.filter.urgencyMax.toString()}
onChange={(val) => val && setFilter({ urgencyMax: parseInt(val) })}
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
<div class="flex items-center gap-2 justify-center">
<span>Max: {store.filter.urgencyMax}</span>
</div>
</SelectTrigger>
<SelectContent />
</Select>
</div>
{/* Urgency Range */}
<div class="space-y-2.5">
<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()}
onChange={(val) => val && setFilter({ urgencyMin: parseInt(val) })}
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
<div class="flex items-center gap-2 justify-center">
<Clock size={14} class="text-muted-foreground/70" />
<span>Min: {store.filter.urgencyMin}</span>
</div>
</SelectTrigger>
<SelectContent />
</Select>
<div class="w-px h-4 bg-border/50" />
<Select
value={store.filter.urgencyMax.toString()}
onChange={(val) => val && setFilter({ urgencyMax: parseInt(val) })}
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
<div class="flex items-center gap-2 justify-center">
<span>Max: {store.filter.urgencyMax}</span>
</div>
</SelectTrigger>
<SelectContent />
</Select>
</div>
</div>
</Show>
<Show when={mode() === 'notes'}>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div class="space-y-2.5">
<label class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground ml-1">Job Status</label>
<div class="flex flex-wrap gap-2 min-h-[44px] p-2 bg-muted/10 border border-dashed border-border/60 rounded-xl">
<For each={normalizeFilterTags(currentFilter().jobStatus as any)}>
{(status) => (
<Badge
variant="secondary"
class={cn(
"h-7 px-2.5 rounded-lg text-xs gap-2 transition-all border shadow-sm",
status.excluded
? "bg-red-500/10 text-red-600 border-red-500/20 hover:bg-red-500/20"
: "bg-blue-500/10 text-blue-600 border-blue-500/20 hover:bg-blue-500/20"
)}
>
<div
class="flex items-center gap-2 cursor-pointer h-full"
onClick={() => {
const current = normalizeFilterTags(currentFilter().jobStatus as any);
const next = current.map(s =>
s.name === status.name ? { ...s, excluded: !s.excluded } : s
);
currentSetFilter({ jobStatus: next as any });
}}
>
<Show when={status.excluded} fallback={<Tag size={12} class="opacity-70" />}>
<Minus size={12} class="opacity-100" />
</Show>
<span class={cn(status.excluded && "line-through opacity-70")}>{status.name}</span>
</div>
<button
class="p-0.5 hover:bg-foreground/10 rounded transition-colors"
onClick={(e) => {
e.stopPropagation();
const next = normalizeFilterTags(currentFilter().jobStatus as any).filter(s => s.name !== status.name);
currentSetFilter({ jobStatus: next as any });
}}
>
<X size={10} />
</button>
</Badge>
)}
</For>
<Popover open={isJobStatusOpen()} onOpenChange={setIsJobStatusOpen}>
<PopoverTrigger 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>+ Status</span>
</PopoverTrigger>
<PopoverContent class="w-[220px] p-0" align="start">
<Command>
<CommandInput placeholder="Search status..." />
<CommandList>
<CommandEmpty>No status found.</CommandEmpty>
<CommandGroup>
<For each={JOB_STATUSES}>
{(status) => (
<CommandItem
value={status}
onSelect={() => {
const current = normalizeFilterTags(currentFilter().jobStatus as any);
if (!current.some(s => s.name === status)) {
currentSetFilter({ jobStatus: [...current, { name: status, excluded: false }] as any });
}
setIsJobStatusOpen(false);
}}
>
{status}
</CommandItem>
)}
</For>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
<div class="space-y-2.5">
<label class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground ml-1">Job Division</label>
<div class="flex flex-wrap gap-2 min-h-[44px] p-2 bg-muted/10 border border-dashed border-border/60 rounded-xl">
<For each={normalizeFilterTags(currentFilter().jobDivision as any)}>
{(division) => (
<Badge
variant="secondary"
class={cn(
"h-7 px-2.5 rounded-lg text-xs gap-2 transition-all border shadow-sm",
division.excluded
? "bg-red-500/10 text-red-600 border-red-500/20 hover:bg-red-500/20"
: "bg-emerald-500/10 text-emerald-600 border-emerald-500/20 hover:bg-emerald-500/20"
)}
>
<div
class="flex items-center gap-2 cursor-pointer h-full"
onClick={() => {
const current = normalizeFilterTags(currentFilter().jobDivision as any);
const next = current.map(d =>
d.name === division.name ? { ...d, excluded: !d.excluded } : d
);
currentSetFilter({ jobDivision: next as any });
}}
>
<Show when={division.excluded} fallback={<Tag size={12} class="opacity-70" />}>
<Minus size={12} class="opacity-100" />
</Show>
<span class={cn(division.excluded && "line-through opacity-70")}>{division.name}</span>
</div>
<button
class="p-0.5 hover:bg-foreground/10 rounded transition-colors"
onClick={(e) => {
e.stopPropagation();
const next = normalizeFilterTags(currentFilter().jobDivision as any).filter(d => d.name !== division.name);
currentSetFilter({ jobDivision: next as any });
}}
>
<X size={10} />
</button>
</Badge>
)}
</For>
<Popover open={isJobDivisionOpen()} onOpenChange={setIsJobDivisionOpen}>
<PopoverTrigger 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>+ Division</span>
</PopoverTrigger>
<PopoverContent class="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder="Search division..." />
<CommandList>
<CommandEmpty>No division found.</CommandEmpty>
<CommandGroup>
<For each={JOB_DIVISIONS}>
{(division) => (
<CommandItem
value={division}
onSelect={() => {
const current = normalizeFilterTags(currentFilter().jobDivision as any);
if (!current.some(d => d.name === division)) {
currentSetFilter({ jobDivision: [...current, { name: division, excluded: false }] as any });
}
setIsJobDivisionOpen(false);
}}
>
{division}
</CommandItem>
)}
</For>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
</div>
</Show>
</div>
{/* Tags Multi-select */}
<div class="space-y-2.5">
@@ -446,7 +206,7 @@ export const FilterBar: Component<{ mode?: 'tasks' | 'notes', class?: string, tr
</span>
</label>
<div class="flex flex-wrap gap-2 min-h-[44px] p-2 bg-muted/10 border border-dashed border-border/60 rounded-xl">
<For each={currentFilter().tags}>
<For each={store.filter.tags}>
{(tag) => (
<Badge
variant="secondary"
@@ -469,10 +229,10 @@ export const FilterBar: Component<{ mode?: 'tasks' | 'notes', class?: string, tr
<div
class="flex items-center gap-2 cursor-pointer h-full"
onClick={() => {
const newTags = currentFilter().tags.map(t =>
const newTags = store.filter.tags.map(t =>
t.name === tag.name ? { ...t, excluded: !t.excluded } : t
);
currentSetFilter({ tags: newTags });
setFilter({ tags: newTags });
}}
>
<Show when={tag.excluded} fallback={<Tag size={12} class="opacity-70" />}>
@@ -484,7 +244,7 @@ export const FilterBar: Component<{ mode?: 'tasks' | 'notes', class?: string, tr
class="p-0.5 hover:bg-foreground/10 rounded transition-colors"
onClick={(e) => {
e.stopPropagation();
currentSetFilter({ tags: currentFilter().tags.filter(t => t.name !== tag.name) });
setFilter({ tags: store.filter.tags.filter(t => t.name !== tag.name) });
}}
>
<X size={10} />
@@ -492,53 +252,22 @@ export const FilterBar: Component<{ mode?: 'tasks' | 'notes', class?: string, tr
</Badge>
)}
</For>
<Popover open={isTagOpen()} onOpenChange={setIsTagOpen}>
<PopoverTrigger 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">
<Select
value=""
onChange={(val) => {
if (val && !store.filter.tags.some(t => t.name === val)) {
setFilter({ tags: [...store.filter.tags, { name: val, excluded: false }] });
}
}}
options={allTags()}
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-[0.625rem] font-bold uppercase tracking-wider rounded-lg hover:bg-muted/60 transition-colors">
<span>+ Tag</span>
</PopoverTrigger>
<PopoverContent class="w-[200px] p-0" align="start">
<Command>
<CommandInput
placeholder="Search tags..."
value={tagSearch()}
onValueChange={setTagSearch}
/>
<CommandList>
<Show when={allTags().filter(t => {
if (t.startsWith('_')) return false;
const search = tagSearch().toLowerCase();
if (!search && (t.startsWith('@') || t.startsWith('#'))) return false;
return t.toLowerCase().includes(search);
}).length === 0}>
<CommandEmpty>No tags found.</CommandEmpty>
</Show>
<CommandGroup>
<For each={allTags().filter(t => {
if (t.startsWith('_')) return false;
const search = tagSearch().toLowerCase();
if (!search && (t.startsWith('@') || t.startsWith('#'))) return false;
return t.toLowerCase().includes(search);
})}>
{(tag) => (
<CommandItem
value={tag}
onSelect={() => {
if (!currentFilter().tags.some(t => t.name === tag)) {
currentSetFilter({ tags: [...currentFilter().tags, { name: tag, excluded: false }] });
}
setIsTagOpen(false);
setTagSearch("");
}}
>
{tag}
</CommandItem>
)}
</For>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</SelectTrigger>
<SelectContent />
</Select>
</div>
</div>
</div>
@@ -572,7 +301,7 @@ export const FilterBar: Component<{ mode?: 'tasks' | 'notes', class?: string, tr
onKeyDown={(e) => {
if (e.key === 'Enter') {
if (newTemplateName().trim()) {
currentSaveTemplate(newTemplateName().trim());
saveFilterTemplate(newTemplateName().trim());
setNewTemplateName("");
setIsSaving(false);
}
@@ -586,7 +315,7 @@ export const FilterBar: Component<{ mode?: 'tasks' | 'notes', class?: string, tr
class="h-8 px-3"
disabled={!newTemplateName().trim()}
onClick={() => {
currentSaveTemplate(newTemplateName().trim());
saveFilterTemplate(newTemplateName().trim());
setNewTemplateName("");
setIsSaving(false);
}}
@@ -614,7 +343,7 @@ export const FilterBar: Component<{ mode?: 'tasks' | 'notes', class?: string, tr
<div class="group flex items-center justify-between p-2 rounded-xl bg-muted/20 border border-border/30 hover:bg-muted/40 transition-all">
<button
class="flex-1 text-left px-1 min-w-0"
onClick={() => currentApplyTemplate(template.id)}
onClick={() => applyFilterTemplate(template.id)}
>
<div class="flex items-center gap-2">
<p class="text-[0.6875rem] font-bold text-foreground truncate">{template.name}</p>
@@ -625,7 +354,7 @@ export const FilterBar: Component<{ mode?: 'tasks' | 'notes', class?: string, tr
<Show when={template.filter.tags.length > 0}>
<div class="w-1 h-1 rounded-full bg-indigo-500" />
</Show>
<Show when={mode() === 'tasks' && (template.filter.priorityMin > 1 || template.filter.priorityMax < 10)}>
<Show when={template.filter.priorityMin > 1 || template.filter.priorityMax < 10}>
<div class="w-1 h-1 rounded-full bg-amber-500" />
</Show>
</div>
@@ -635,7 +364,7 @@ export const FilterBar: Component<{ mode?: 'tasks' | 'notes', class?: string, tr
class="opacity-0 group-hover:opacity-100 p-1.5 hover:bg-destructive/10 hover:text-destructive rounded-lg transition-all"
onClick={(e) => {
e.stopPropagation();
currentRemoveTemplate(template.id);
removeFilterTemplate(template.id);
}}
>
<Trash2 size={12} />
+1 -1
View File
@@ -223,7 +223,7 @@ export const ImportTool: Component = () => {
<div class="space-y-1">
<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={(tags) => setBulkTags(tags)} />
<TagPicker selectedTags={bulkTags()} onTagsChange={setBulkTags} />
<For each={bulkTags()}>
{(tag) => (
<Badge
-198
View File
@@ -1,198 +0,0 @@
import { createSignal, createResource, Show, For, onMount } from "solid-js";
import { pb } from "@/lib/pocketbase";
import { store, activeNoteId } from "@/store";
import { Search, File as FileIcon, Loader2, FolderOpen } from "lucide-solid";
interface JobFileSelectorProps {
onSelectFile: (jobId: string, filename: string) => void;
}
export const JobFileSelector = (props: JobFileSelectorProps) => {
const [query, setQuery] = createSignal("");
const [selectedJobId, setSelectedJobId] = createSignal<string | null>(null);
// Auto-detect job ID from active note tags (traversing up hierarchy)
onMount(() => {
const currentNoteId = activeNoteId();
if (currentNoteId) {
let currentId = currentNoteId;
let currentNote = store.notes.find(n => n.id === currentId);
let depth = 0;
// Traverse up "child-of-" tags to find the root or any ancestor with a job_id
while (currentNote && depth < 10) {
const jobIdTag = currentNote.tags?.find(t => t.startsWith("job_id:"));
if (jobIdTag) {
const id = jobIdTag.split(":")[1];
if (id) {
setSelectedJobId(id);
return; // Found it!
}
}
const parentTag = currentNote.tags?.find(t => t.startsWith("child-of-"));
if (!parentTag) break;
const parentId = parentTag.replace("child-of-", "");
const parent = store.notes.find(n => n.id === parentId);
if (!parent) break;
currentNote = parent;
depth++;
}
}
});
// Query Jobs
const [jobs] = createResource(query, async (q) => {
if (!q.trim()) return [];
try {
const res = await pb.collection('Job_Info_Prod').getList(1, 10, {
filter: `Job_Number ~ "${q}" || Job_Name ~ "${q}"`,
sort: '-created',
requestKey: null // Allow concurrent overlapping requests without auto-cancelling if needed, or leave it
});
return res.items;
} catch (e) {
return [];
}
});
// Fetch details for the selected job to list files
const [selectedJob] = createResource(selectedJobId, async (id) => {
if (!id) return null;
try {
const data = await pb.collection('Job_Info_Prod').getOne(id);
console.log("Job File Selector Selected Job Data:", data);
return data;
} catch (e) {
console.error("Job File Selector Error Fetching Job:", e);
return null;
}
});
const categories = ["plans", "EST_and_CO", "Addendums", "files"] as const;
return (
<div class="w-full flex flex-col items-center justify-center p-8 gap-6 bg-muted/10 rounded-xl relative">
<Show when={!selectedJobId()}>
<div class="text-center w-full max-w-lg space-y-4 relative z-10">
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-primary/10 text-primary mx-auto mb-4">
<FolderOpen size={24} />
</div>
<div class="space-y-1">
<h3 class="font-semibold text-lg">Embed a Job File</h3>
<p class="text-sm text-muted-foreground">Search for a Prism job to browse its files.</p>
</div>
<div class="relative w-full">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground w-4 h-4" />
<input
type="text"
placeholder="Search by Job Name or Number..."
class="w-full pl-9 pr-4 py-2 bg-background border border-border rounded-lg outline-none focus:ring-2 focus:ring-primary/50 transition-all text-sm"
value={query()}
onInput={(e) => setQuery(e.currentTarget.value)}
/>
</div>
<Show when={jobs.loading}>
<div class="flex items-center justify-center py-4 text-muted-foreground">
<Loader2 class="w-5 h-5 animate-spin" />
</div>
</Show>
<Show when={jobs() && jobs()!.length > 0}>
<div class="mt-4 border border-border bg-background rounded-lg shadow-sm overflow-hidden text-left flex flex-col">
<For each={jobs()}>
{(job) => (
<button
class="px-4 py-3 hover:bg-muted text-left flex flex-col gap-1 border-b border-border last:border-0 transition-colors cursor-pointer"
onClick={() => setSelectedJobId(job.id)}
>
<span class="font-medium text-sm text-foreground">{job.Job_Name || "Unnamed Job"}</span>
<span class="text-xs text-muted-foreground">Job #{job.Job_Number || job.id}</span>
</button>
)}
</For>
</div>
</Show>
</div>
</Show>
<Show when={selectedJobId()}>
<Show when={selectedJob.loading}>
<div class="flex flex-col items-center justify-center gap-4 py-12 relative z-10">
<Loader2 class="w-8 h-8 animate-spin text-primary" />
<span class="text-sm text-muted-foreground font-medium">Loading Job Files...</span>
</div>
</Show>
<Show when={selectedJob()}>
{(jobRecord) => (
<div class="w-full max-w-2xl bg-background rounded-xl border border-border shadow-sm overflow-hidden relative z-10">
<div class="p-4 border-b border-border bg-muted/30 flex items-center justify-between">
<div class="flex flex-col">
<h3 class="font-semibold">{jobRecord().Job_Name || "Unnamed Job"}</h3>
<span class="text-xs text-muted-foreground">Job #{jobRecord().Job_Number || jobRecord().id}</span>
</div>
<button
class="text-xs text-blue-500 hover:text-blue-600 font-medium px-2 py-1 hover:bg-blue-50 rounded transition-colors cursor-pointer"
onClick={() => setSelectedJobId(null)}
>
Change Job
</button>
</div>
<div class="p-4 flex flex-col gap-6 max-h-[400px] overflow-y-auto">
<For each={categories}>
{(category) => {
let files = jobRecord()[category];
if (files === null || files === undefined || files === "") return null;
if (!Array.isArray(files)) files = [files];
files = files.filter(Boolean);
if (files.length === 0) return null;
return (
<div class="space-y-2">
<h4 class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">{category.replace(/_/g, " ")}</h4>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<For each={files as string[]}>
{(filename) => (
<button
class="flex items-center gap-3 p-2 rounded-md border border-border hover:bg-muted hover:border-primary/50 transition-colors text-left cursor-pointer group"
onClick={() => props.onSelectFile(jobRecord().id, filename)}
>
<div class="p-2 rounded bg-muted text-muted-foreground group-hover:text-primary transition-colors shrink-0">
<FileIcon class="w-4 h-4" />
</div>
<span class="text-sm truncate font-medium text-foreground min-w-0" title={filename}>
{filename}
</span>
</button>
)}
</For>
</div>
</div>
);
}}
</For>
{/* Empty state if no files at all */}
<Show when={categories.every(c => {
const val = jobRecord()[c];
return !val || (Array.isArray(val) && val.length === 0) || (typeof val === 'string' && val.trim() === '');
})}>
<div class="text-center py-8 text-muted-foreground text-sm">
No files found for this job.
</div>
</Show>
</div>
</div>
)}
</Show>
</Show>
</div>
);
};
+6 -28
View File
@@ -1,6 +1,6 @@
import { type Component, type JSX, createSignal, createEffect, Show, lazy, Suspense, onMount, onCleanup } from "solid-js";
import { Sidebar, BottomNav, ContextSwitcher } from "./Navigation";
import { store, setStore, activeTaskId, setActiveTaskId, requestImmediateNotesMetadataLoad } from "@/store";
import { store, setStore, activeTaskId, setActiveTaskId } from "@/store";
import { PanelLeftOpen, PanelLeftClose } from "lucide-solid";
const TaskDetail = lazy(() => import("./TaskDetail").then(m => ({ default: m.TaskDetail })));
@@ -9,7 +9,7 @@ const NotepadView = lazy(() => import("../views/NotepadView").then(m => ({ defau
import { NotesSidebar } from "./NotesSidebar";
import { Button } from "./ui/button";
import { cn } from "@/lib/utils";
import { TaskSearchPopover } from "./TaskSearchPopover";
import { FilterBar } from "./FilterBar";
interface LayoutProps {
children: JSX.Element;
@@ -54,21 +54,6 @@ export const Layout: Component<LayoutProps> = (props) => {
// Derived active task for the detail view
const activeTask = () => store.tasks.find(t => t.id === activeTaskId());
createEffect(() => {
if (store.isNotepadMode) {
void requestImmediateNotesMetadataLoad();
}
});
createEffect(() => {
const noteId = selectedNoteId();
if (!noteId) return;
const noteExists = store.notes.some(note => note.id === noteId);
if (!noteExists) {
setSelectedNoteId(null);
}
});
return (
<div class="flex h-screen w-full min-w-0 bg-background overflow-hidden relative font-sans antialiased text-foreground">
{/* Desktop UI Container */}
@@ -203,25 +188,18 @@ export const Layout: Component<LayoutProps> = (props) => {
</div>
</div>
{/* Sync Progress Bar Overlay */}
<Show when={store.isInitializing || (store.isNotepadMode && store.isNotesLoading)}>
<div class="fixed top-0 left-0 w-full h-0.5 z-[100] overflow-hidden bg-primary/10">
<div class="h-full bg-primary animate-progress-fast w-1/3" />
</div>
</Show>
{/* Main Content Area */}
<main class={cn(
"flex-1 min-w-0 overflow-y-auto overflow-x-hidden relative scroll-smooth",
!store.isNotepadMode ? "sm:pb-6" : ""
!store.isNotepadMode ? "pb-32 sm:pb-6" : ""
)}>
<div class={cn(
"w-full min-h-full mx-auto",
!store.isNotepadMode ? "max-w-screen-2xl px-4 sm:px-8 lg:px-12 pt-16 sm:pt-20 mobile-footer-clearance 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"
"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"}>
<TaskSearchPopover />
<FilterBar />
</Show>
{props.children}
</div>
-256
View File
@@ -1,256 +0,0 @@
import { Node, mergeAttributes } from '@tiptap/core'
const MIN_CHECKBOXES = 1
const normalizeCheckStates = (value: unknown): boolean[] => {
if (Array.isArray(value)) {
const states = value.map(Boolean)
return states.length >= MIN_CHECKBOXES
? states
: [...states, ...Array.from({ length: MIN_CHECKBOXES - states.length }, () => false)]
}
return Array.from({ length: MIN_CHECKBOXES }, () => false)
}
const parseCheckStates = (rawValue: string | null): boolean[] => {
if (!rawValue) {
return normalizeCheckStates(null)
}
try {
return normalizeCheckStates(JSON.parse(rawValue))
} catch {
return normalizeCheckStates(
rawValue
.split(',')
.map(value => value.trim())
.filter(Boolean)
.map(value => value === 'true' || value === '1')
)
}
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
multiTaskItem: {
addCheckboxToMultiTaskItem: () => ReturnType
removeCheckboxFromMultiTaskItem: () => ReturnType
}
}
}
export const MultiTaskItem = Node.create({
name: 'multiTaskItem',
addOptions() {
return {
nested: true,
HTMLAttributes: {},
}
},
content: 'paragraph block*',
defining: true,
addAttributes() {
return {
checkStates: {
default: normalizeCheckStates(null),
keepOnSplit: true,
parseHTML: element => parseCheckStates(element.getAttribute('data-check-states')),
renderHTML: attributes => ({
'data-check-states': JSON.stringify(normalizeCheckStates(attributes.checkStates)),
}),
},
}
},
parseHTML() {
return [
{
tag: 'li[data-type="multiTaskItem"]',
priority: 51,
},
]
},
renderHTML({ node, HTMLAttributes }) {
const states = normalizeCheckStates(node.attrs.checkStates)
const controlGroup = [
'div',
{ 'data-multi-task-controls': 'true' },
...states.map(state => [
'label',
[
'input',
{
type: 'checkbox',
checked: state ? 'checked' : null,
},
],
['span', ''],
]),
]
return [
'li',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
'data-type': 'multiTaskItem',
}),
controlGroup,
['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),
}
},
addCommands() {
const updateCheckStates = (transform: (states: boolean[]) => boolean[]) => {
return () => ({ state, commands }: any) => {
const { $from } = state.selection
for (let depth = $from.depth; depth > 0; depth -= 1) {
const node = $from.node(depth)
if (node.type.name !== this.name) {
continue
}
const position = $from.before(depth)
return commands.command(({ tr }: any) => {
tr.setNodeMarkup(position, undefined, {
...node.attrs,
checkStates: normalizeCheckStates(transform(normalizeCheckStates(node.attrs.checkStates))),
})
return true
})
}
return false
}
}
return {
addCheckboxToMultiTaskItem: updateCheckStates(states => [...states, false]),
removeCheckboxFromMultiTaskItem: updateCheckStates(states => states.length > MIN_CHECKBOXES ? states.slice(0, -1) : states),
}
},
addNodeView() {
return ({ node, getPos, editor }) => {
let currentNode = node
const dom = document.createElement('li')
dom.classList.add('flex', 'items-start', 'group', 'relative', 'my-1')
dom.dataset.type = 'multiTaskItem'
const controls = document.createElement('div')
controls.contentEditable = 'false'
controls.classList.add('flex', 'items-center', 'mr-3', 'select-none', 'shrink-0')
const checkboxRow = document.createElement('div')
checkboxRow.classList.add('flex', 'items-center', 'gap-2')
const content = document.createElement('div')
content.classList.add('flex-1', 'min-w-0', 'min-h-[1.5rem]', 'tiptap-task-content')
const updateNode = (nextStates: boolean[]) => {
if (typeof getPos !== 'function') {
return
}
editor.commands.command(({ tr }) => {
const position = getPos()
if (typeof position !== 'number') {
return false
}
tr.setNodeMarkup(position, undefined, {
...currentNode.attrs,
checkStates: normalizeCheckStates(nextStates),
})
return true
})
}
const createCheckbox = (checked: boolean, index: number) => {
const wrapper = document.createElement('div')
wrapper.classList.add('w-5', 'flex', 'justify-center')
const button = document.createElement('button')
button.type = 'button'
button.classList.add(
'w-5', 'h-5',
'flex', 'items-center', 'justify-center',
'rounded-md', 'border-[1.5px]',
'transition-colors', 'duration-300', 'cursor-pointer',
'shrink-0'
)
button.setAttribute('aria-label', `Toggle checkbox ${index + 1}`)
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')
button.appendChild(checkIcon)
const updateCheckboxVisuals = (isChecked: boolean) => {
if (isChecked) {
button.classList.add('bg-primary', 'border-primary', 'text-primary-foreground')
button.classList.remove('border-muted-foreground/30', 'bg-transparent', 'text-transparent')
checkIcon.style.opacity = '1'
} else {
button.classList.remove('bg-primary', 'border-primary', 'text-primary-foreground')
button.classList.add('border-muted-foreground/30', 'bg-transparent', 'text-transparent')
checkIcon.style.opacity = '0'
}
}
updateCheckboxVisuals(checked)
button.addEventListener('click', (event) => {
event.preventDefault()
event.stopPropagation()
const states = normalizeCheckStates(currentNode.attrs.checkStates)
states[index] = !states[index]
updateNode(states)
})
wrapper.appendChild(button)
return wrapper
}
const renderCheckboxes = (states: boolean[]) => {
checkboxRow.replaceChildren(...states.map((state, index) => createCheckbox(state, index)))
}
renderCheckboxes(normalizeCheckStates(currentNode.attrs.checkStates))
controls.appendChild(checkboxRow)
dom.appendChild(controls)
dom.appendChild(content)
return {
dom,
contentDOM: content,
update: updatedNode => {
if (updatedNode.type.name !== this.name) {
return false
}
currentNode = updatedNode
renderCheckboxes(normalizeCheckStates(updatedNode.attrs.checkStates))
return true
},
}
}
},
})
-484
View File
@@ -1,484 +0,0 @@
import { Node, mergeAttributes } from '@tiptap/core'
import type { Component } from 'solid-js'
import { render } from 'solid-js/web'
import { Plus, Minus, MoreHorizontal } from 'lucide-solid'
const normalizeCheckStates = (value: unknown): boolean[] => {
if (Array.isArray(value)) {
return value.map(Boolean)
}
return [false]
}
const getColumnCountFromListNode = (listNode: any): number => {
let columnCount = 1
listNode?.descendants((descendant: any) => {
if (descendant.type?.name === 'multiTaskItem') {
columnCount = Math.max(columnCount, normalizeCheckStates(descendant.attrs?.checkStates).length)
}
})
return columnCount
}
const normalizeColumnLabels = (value: unknown, columnCount: number): string[] => {
const labels = Array.isArray(value)
? value.map(label => typeof label === 'string' ? label : '')
: []
if (labels.length >= columnCount) {
return labels.slice(0, columnCount)
}
return [...labels, ...Array.from({ length: columnCount - labels.length }, () => '')]
}
const parseColumnLabels = (rawValue: string | null, columnCount: number): string[] => {
if (!rawValue) {
return normalizeColumnLabels([], columnCount)
}
try {
return normalizeColumnLabels(JSON.parse(rawValue), columnCount)
} catch {
return normalizeColumnLabels(
rawValue.split(',').map(value => value.trim()),
columnCount,
)
}
}
const findTopmostListDepth = ($from: any, listName: string): number => {
let listDepth = -1
for (let depth = $from.depth; depth > 0; depth -= 1) {
if ($from.node(depth).type.name === listName) {
listDepth = depth
}
}
return listDepth
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
multiTaskList: {
toggleMultiTaskList: () => ReturnType
setMultiTaskList: () => ReturnType
unsetMultiTaskList: () => ReturnType
addCheckboxColumnToMultiTaskList: () => ReturnType
removeCheckboxColumnFromMultiTaskList: () => ReturnType
toggleMultiTaskListColumnLabels: () => ReturnType
setMultiTaskListColumnLabel: (index: number, label: string) => ReturnType
}
}
}
export const MultiTaskList = Node.create({
name: 'multiTaskList',
addOptions() {
return {
HTMLAttributes: {},
itemTypeName: 'multiTaskItem',
}
},
group: 'block list',
content: 'multiTaskItem+',
addAttributes() {
return {
columnLabels: {
default: [''],
parseHTML: element => parseColumnLabels(element.getAttribute('data-column-labels'), 1),
renderHTML: attributes => ({
'data-column-labels': JSON.stringify(attributes.columnLabels ?? ['']),
}),
},
showColumnLabels: {
default: false,
parseHTML: element => element.getAttribute('data-show-column-labels') === 'true',
renderHTML: attributes => ({
'data-show-column-labels': attributes.showColumnLabels ? 'true' : 'false',
}),
},
}
},
parseHTML() {
return [
{
tag: 'ul[data-type="multiTaskList"]',
priority: 51,
},
]
},
renderHTML({ HTMLAttributes }) {
return [
'ul',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
'data-type': 'multiTaskList',
}),
0,
]
},
addCommands() {
const updateDescendantItems = (tr: any, listNode: any, listPos: number, transformStates: (states: boolean[]) => boolean[]) => {
listNode.descendants((descendant: any, descendantPos: number) => {
if (descendant.type.name !== this.options.itemTypeName) {
return
}
tr.setNodeMarkup(listPos + 1 + descendantPos, undefined, {
...descendant.attrs,
checkStates: transformStates(normalizeCheckStates(descendant.attrs.checkStates)),
})
})
}
const updateCurrentList = (transformStates: (states: boolean[]) => boolean[], transformLabels?: (labels: string[], columnCount: number, showColumnLabels: boolean) => { labels: string[]; showColumnLabels?: boolean }) => {
return () => ({ state, tr, dispatch }: any) => {
const { $from } = state.selection
const listDepth = findTopmostListDepth($from, this.name)
if (listDepth === -1) {
return false
}
const listPos = $from.before(listDepth)
const listNode = state.doc.nodeAt(listPos)
if (!listNode) {
return false
}
const columnCount = getColumnCountFromListNode(listNode)
const nextColumnCount = transformStates(Array.from({ length: columnCount }, () => false)).length
const currentLabels = normalizeColumnLabels(listNode.attrs?.columnLabels, columnCount)
const currentShowColumnLabels = Boolean(listNode.attrs?.showColumnLabels)
const transformed = transformLabels
? transformLabels(currentLabels, nextColumnCount, currentShowColumnLabels)
: { labels: currentLabels, showColumnLabels: currentShowColumnLabels }
const nextLabels = normalizeColumnLabels(transformed.labels, nextColumnCount)
const nextShowColumnLabels = transformed.showColumnLabels ?? currentShowColumnLabels
tr.setNodeMarkup(listPos, undefined, {
...listNode.attrs,
columnLabels: nextLabels,
showColumnLabels: nextShowColumnLabels,
})
updateDescendantItems(tr, listNode, listPos, transformStates)
if (dispatch) {
dispatch(tr)
}
return true
}
}
return {
toggleMultiTaskList: () => ({ commands }) => {
return commands.toggleList(this.name, this.options.itemTypeName)
},
setMultiTaskList: () => ({ commands }) => {
return commands.wrapInList(this.name)
},
unsetMultiTaskList: () => ({ commands }) => {
return commands.liftListItem(this.options.itemTypeName)
},
addCheckboxColumnToMultiTaskList: updateCurrentList(
states => [...states, false],
(labels, _columnCount, showColumnLabels) => ({ labels: [...labels, ''], showColumnLabels }),
),
removeCheckboxColumnFromMultiTaskList: updateCurrentList(
states => (states.length > 1 ? states.slice(0, -1) : states),
(labels, columnCount, showColumnLabels) => ({ labels: labels.slice(0, columnCount), showColumnLabels }),
),
toggleMultiTaskListColumnLabels: () => ({ state, tr, dispatch }: any) => {
const { $from } = state.selection
const listDepth = findTopmostListDepth($from, this.name)
if (listDepth === -1) {
return false
}
const listPos = $from.before(listDepth)
const listNode = state.doc.nodeAt(listPos)
if (!listNode) {
return false
}
const columnCount = getColumnCountFromListNode(listNode)
const currentLabels = normalizeColumnLabels(listNode.attrs?.columnLabels, columnCount)
tr.setNodeMarkup(listPos, undefined, {
...listNode.attrs,
columnLabels: currentLabels,
showColumnLabels: !listNode.attrs?.showColumnLabels,
})
if (dispatch) {
dispatch(tr)
}
return true
},
setMultiTaskListColumnLabel: (index: number, label: string) => ({ state, tr, dispatch }: any) => {
const { $from } = state.selection
const listDepth = findTopmostListDepth($from, this.name)
if (listDepth === -1) {
return false
}
const listPos = $from.before(listDepth)
const listNode = state.doc.nodeAt(listPos)
if (!listNode) {
return false
}
const columnCount = getColumnCountFromListNode(listNode)
const nextLabels = normalizeColumnLabels(listNode.attrs?.columnLabels, columnCount)
nextLabels[index] = label
tr.setNodeMarkup(listPos, undefined, {
...listNode.attrs,
columnLabels: nextLabels,
showColumnLabels: true,
})
if (dispatch) {
dispatch(tr)
}
return true
},
}
},
addNodeView() {
return ({ editor, node, getPos }) => {
let currentNode = node
const getCurrentListPosition = () => {
if (typeof getPos === 'function') {
const position = getPos()
return typeof position === 'number' ? position : null
}
return null
}
const isRootList = () => {
const position = getCurrentListPosition()
if (position === null) {
return true
}
const resolvedPos = editor.state.doc.resolve(position + 1)
let seenCurrentList = false
for (let depth = resolvedPos.depth; depth > 0; depth -= 1) {
if (resolvedPos.node(depth).type.name !== this.name) {
continue
}
if (!seenCurrentList) {
seenCurrentList = true
continue
}
return false
}
if (!seenCurrentList) {
return true
}
return true
}
const dom = document.createElement('div')
dom.classList.add('relative', 'group/multi-task-list')
const header = document.createElement('div')
header.classList.add('mb-1')
const controls = document.createElement('div')
controls.contentEditable = 'false'
controls.classList.add(
'absolute', 'right-0', 'top-0', 'z-[220]',
'opacity-70', 'transition-opacity',
'hover:opacity-100', 'group-hover/multi-task-list:opacity-100',
'pointer-events-auto'
)
const ControlsMenu: Component = () => (
<div class="flex items-center gap-1">
<button
type="button"
class="h-7 w-7 p-0 rounded-xl border border-border/60 bg-background/95 hover:bg-muted text-muted-foreground hover:text-foreground shadow-md inline-flex items-center justify-center"
onMouseDown={(event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={() => {
editor.chain().focus().toggleMultiTaskListColumnLabels().run()
}}
>
<MoreHorizontal size={14} />
</button>
<div class="flex flex-col gap-1">
<button
type="button"
class="h-7 w-7 p-0 rounded-xl border border-border/60 bg-background/95 hover:bg-muted text-muted-foreground hover:text-foreground shadow-md inline-flex items-center justify-center"
onMouseDown={(event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={() => {
editor.chain().focus().addCheckboxColumnToMultiTaskList().run()
}}
>
<Plus size={14} />
</button>
<button
type="button"
class="h-7 w-7 p-0 rounded-xl border border-border/60 bg-background/95 hover:bg-muted text-muted-foreground hover:text-foreground shadow-md inline-flex items-center justify-center"
onMouseDown={(event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={() => {
editor.chain().focus().removeCheckboxColumnFromMultiTaskList().run()
}}
>
<Minus size={14} />
</button>
</div>
</div>
)
const disposeControls = render(() => <ControlsMenu />, controls)
const contentDOM = document.createElement('ul')
contentDOM.dataset.type = 'multiTaskList'
contentDOM.classList.add('not-prose', 'pl-2')
const renderHeader = () => {
const columnCount = getColumnCountFromListNode(currentNode)
const labels = normalizeColumnLabels(currentNode.attrs?.columnLabels, columnCount)
const showColumnLabels = Boolean(currentNode.attrs?.showColumnLabels)
const rootList = isRootList()
contentDOM.dataset.showColumnLabels = rootList && showColumnLabels ? 'true' : 'false'
if (!rootList || !showColumnLabels) {
header.replaceChildren()
header.classList.add('hidden')
return
}
header.classList.remove('hidden')
const row = document.createElement('div')
row.classList.add('flex', 'items-end', 'pr-10')
const labelsRow = document.createElement('div')
labelsRow.classList.add('flex', 'items-center', 'gap-2', 'mr-3', 'select-none', 'shrink-0')
labels.forEach((label, index) => {
const cell = document.createElement('input')
cell.type = 'text'
cell.value = label
cell.placeholder = `Col ${index + 1}`
cell.classList.add(
'w-5', 'bg-transparent', 'border-b', 'border-border/60',
'text-center', 'text-[9px]', 'font-semibold', 'uppercase',
'tracking-wide', 'text-muted-foreground', 'outline-none',
'focus:border-primary', 'placeholder:text-muted-foreground/40'
)
const commitLabel = () => {
const position = getCurrentListPosition()
if (position === null) {
return
}
editor.commands.command(({ tr }) => {
const listNode = editor.state.doc.nodeAt(position)
if (!listNode) {
return false
}
const columnCount = getColumnCountFromListNode(listNode)
const nextLabels = normalizeColumnLabels(listNode.attrs?.columnLabels, columnCount)
nextLabels[index] = cell.value
tr.setNodeMarkup(position, undefined, {
...listNode.attrs,
columnLabels: nextLabels,
showColumnLabels: true,
})
return true
})
}
cell.addEventListener('mousedown', event => {
event.stopPropagation()
})
cell.addEventListener('keydown', event => {
if (event.key === 'Enter') {
event.preventDefault()
cell.blur()
}
})
cell.addEventListener('blur', () => {
commitLabel()
})
labelsRow.appendChild(cell)
})
const spacer = document.createElement('div')
spacer.classList.add('flex-1', 'min-w-0')
row.appendChild(labelsRow)
row.appendChild(spacer)
header.replaceChildren(row)
}
renderHeader()
controls.style.display = isRootList() ? 'block' : 'none'
dom.appendChild(header)
dom.appendChild(controls)
dom.appendChild(contentDOM)
return {
dom,
contentDOM,
stopEvent: event =>
controls.contains(event.target as globalThis.Node | null) ||
header.contains(event.target as globalThis.Node | null),
update: updatedNode => {
if (updatedNode.type.name !== this.name) {
return false
}
currentNode = updatedNode
controls.style.display = isRootList() ? 'block' : 'none'
renderHeader()
return true
},
destroy: () => {
disposeControls()
},
}
}
},
})
+42 -49
View File
@@ -3,9 +3,9 @@ import { LayoutDashboard, ListTodo, Settings, Clock, ArrowUpCircle, PanelLeftClo
import { cn } from "@/lib/utils";
import { Button } from "./ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { store, currentTaskContext, setCurrentTaskContext, loadTasksForOwner } from "@/store";
import { store, currentTaskContext, setCurrentTaskContext } from "@/store";
import { pb } from "@/lib/pocketbase";
import { createSignal, createEffect } from "solid-js";
import { createResource, createSignal, createEffect } from "solid-js";
interface NavItem {
icon: any;
@@ -58,13 +58,7 @@ export const BottomNav: Component<{ currentView: string; setView: (v: string) =>
};
return (
<nav
class="fixed bottom-0 left-0 right-0 bg-background border-t border-border flex justify-around items-center md:hidden z-50"
style={{
"padding-bottom": "env(safe-area-inset-bottom, 0px)",
height: "calc(4rem + env(safe-area-inset-bottom, 0px))"
}}
>
<nav class="fixed bottom-0 left-0 right-0 bg-background border-t border-border flex justify-around items-center h-16 md:hidden z-50">
<For each={mobileNavGroups}>
{(group) => (
<Show when={group.items} fallback={
@@ -127,6 +121,7 @@ export const ContextSwitcher: Component<{
onOpenChange?: (open: boolean) => void;
isMobile?: boolean;
}> = (props) => {
const currentUserId = pb.authStore.model?.id;
const [open, setOpen] = createSignal(false);
// Sync with parent if needed
@@ -141,42 +136,41 @@ export const ContextSwitcher: Component<{
}
});
const [oversightUsers] = createResource(
() => [...new Set(store.shareRules.filter(r => r.targetUserId === currentUserId && r.type === 'all').map(r => r.ownerId))],
async (ownerIds) => {
if (ownerIds.length === 0) return [];
try {
// Fetch user names for these IDs in a single query
const filter = ownerIds.map(id => `id="${id}"`).join(' || ');
const users = await pb.collection('users').getFullList({
filter,
fields: 'id,name,email',
requestKey: null // Disable auto-cancellation
});
return users.map(u => ({ id: u.id, name: u.name || u.email }));
} catch (e: any) {
if (e.isAbort) return []; // Ignore explicit aborts
console.error("Failed to load oversight users", e);
return [];
}
}
);
const ctx = currentTaskContext; // Accessor
const personalContext = () =>
store.contexts.find(context =>
!context.deletedAt &&
context.kind === "user" &&
context.targetUserId === pb.authStore.model?.id
) || null;
const isPersonalContext = () => {
const current = ctx();
const personal = personalContext();
return current === 'mine' || (
typeof current === "object" &&
"bucketId" in current &&
!!personal &&
current.bucketId === personal.id
);
};
const label = () => {
const c = ctx();
return isPersonalContext() ? "My Bucket" : (c === 'mine' ? "My Bucket" : c.name);
return c === 'mine' ? "My Bucket" : c.name;
};
const handleSwitch = (id: string, name: string, type: 'mine' | 'user' | 'bucket' = 'mine') => {
if (type === 'mine') {
const personal = personalContext();
if (personal) {
setCurrentTaskContext({ bucketId: personal.id, name: "My Bucket", isPersonal: true });
} else {
setCurrentTaskContext('mine');
}
setCurrentTaskContext('mine');
} else if (type === 'bucket') {
setCurrentTaskContext({ bucketId: id, name });
} else {
setCurrentTaskContext({ userId: id, name });
void loadTasksForOwner(id);
}
// Close popover
@@ -188,7 +182,7 @@ export const ContextSwitcher: Component<{
}
};
const hasContexts = () => !!personalContext() || store.subscribedBuckets.length > 0 || store.supervisedContexts.length > 0;
const hasContexts = () => (oversightUsers() && oversightUsers()!.length > 0) || store.subscribedBuckets.length > 0;
return (
<Show when={hasContexts()}>
@@ -203,9 +197,9 @@ export const ContextSwitcher: Component<{
<Show when={props.isMobile} fallback={
<>
<div class="flex items-center gap-3 min-w-0">
<div class={cn(
"w-4 h-4 rounded-full flex items-center justify-center shrink-0",
isPersonalContext() ? "bg-primary/20 text-primary" : "bg-orange-500/20 text-orange-500"
<div class={cn(
"w-4 h-4 rounded-full flex items-center justify-center shrink-0",
ctx() === 'mine' ? "bg-primary/20 text-primary" : "bg-orange-500/20 text-orange-500"
)}>
<div class="w-2 h-2 rounded-full bg-current" />
</div>
@@ -215,8 +209,8 @@ export const ContextSwitcher: Component<{
</>
}>
<div class="relative">
<Users size={18} class={cn("transition-transform duration-300", open() && "scale-110", !isPersonalContext() && "text-orange-500")} />
{!isPersonalContext() && (
<Users size={18} class={cn("transition-transform duration-300", open() && "scale-110", ctx() !== 'mine' && "text-orange-500")} />
{ctx() !== 'mine' && (
<div class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-orange-500 border border-background" />
)}
</div>
@@ -229,7 +223,7 @@ export const ContextSwitcher: Component<{
onClick={() => handleSwitch('mine', '', 'mine')}
class={cn(
"w-full flex items-center gap-2 p-2 rounded-md text-sm transition-colors",
isPersonalContext() ? "bg-primary/10 text-primary font-semibold" : "hover:bg-muted text-foreground"
ctx() === 'mine' ? "bg-primary/10 text-primary font-semibold" : "hover:bg-muted text-foreground"
)}
>
<div class="w-4 h-4 rounded-full bg-primary/20 flex items-center justify-center">
@@ -266,32 +260,31 @@ export const ContextSwitcher: Component<{
</For>
</Show>
<Show when={store.supervisedContexts.length > 0}>
<Show when={oversightUsers() && oversightUsers()!.length > 0}>
<div class="py-1">
<div class="h-px bg-border/50" />
</div>
<div class="px-2 py-1 text-[0.625rem] font-bold uppercase text-muted-foreground tracking-wider flex items-center gap-2">
<span>Supervised Contexts</span>
<span class="ml-auto bg-muted px-1.5 rounded text-[0.5625rem]">{store.supervisedContexts.length}</span>
<span>Shared User Buckets</span>
<span class="ml-auto bg-muted px-1.5 rounded text-[0.5625rem]">{oversightUsers()?.length}</span>
</div>
<For each={store.supervisedContexts}>
{(supervised) => (
<For each={oversightUsers()}>
{(user) => (
<button
onClick={() => handleSwitch(supervised.ownerUserId, supervised.ownerName, 'user')}
onClick={() => handleSwitch(user.id, user.name, 'user')}
class={cn(
"w-full flex items-center gap-2 p-2 rounded-md text-sm transition-colors",
(ctx() as any).userId === supervised.ownerUserId ? "bg-orange-500/10 text-orange-600 font-semibold" : "hover:bg-muted text-foreground"
(ctx() as any).userId === user.id ? "bg-orange-500/10 text-orange-600 font-semibold" : "hover:bg-muted text-foreground"
)}
>
<div class="w-4 h-4 rounded-full bg-orange-500/20 flex items-center justify-center">
<div class="w-2 h-2 rounded-full bg-orange-500" />
</div>
<span class="truncate">{supervised.ownerName}</span>
<span class="truncate">{user.name}</span>
</button>
)}
</For>
</Show>
</div>
</PopoverContent>
</Popover>
+41 -187
View File
@@ -1,185 +1,36 @@
import { type Component, createMemo, createResource, For, Show } from "solid-js";
import { store, setNoteFilter, getFavoriteTag } from "@/store";
import { type Component, createSignal, createMemo, For, Show } from "solid-js";
import { store } from "@/store";
import { pb } from "@/lib/pocketbase";
import { Plus, Lock, Star } from "lucide-solid";
import { Plus, Lock, Search } from "lucide-solid";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { NOTES_COLLECTION } from "@/lib/constants";
import { FilterBar } from "./FilterBar";
import { normalizeNoteKey } from "@/lib/tags";
export const NotesSidebar: Component<{
selectedNoteId: string | null;
setSelectedNoteId: (id: string | null) => void;
}> = (props) => {
const [searchQuery, setSearchQuery] = createSignal("");
const currentUserId = pb.authStore.model?.id;
const [accessibleJobs] = createResource(currentUserId, async (userId) => {
if (!userId) return new Set<string>();
try {
const items = await pb.collection('Job_Info_Prod').getFullList({
filter: [
`EstimatorUser = "${userId}"`,
`OfficeRepUser = "${userId}"`,
`ProjectManagerUser = "${userId}"`,
`Estimator = "${userId}"`,
`Office_Rep = "${userId}"`,
`Project_Manager = "${userId}"`
].join(" || "),
fields: "id",
requestKey: null
});
return new Set(items.map((r: any) => String(r.id)));
} catch (e) {
console.warn("Failed to load job access list for notes filter:", e);
return new Set<string>();
}
});
const [jobFilterIds] = createResource(
() => {
const f = store.noteFilter;
const normalize = (items: Array<string | { name: string; excluded: boolean }> | undefined) => {
if (!items) return [] as { name: string; excluded: boolean }[];
return items.map((i: any) => (typeof i === "string" ? { name: i, excluded: false } : { name: i.name, excluded: !!i.excluded }));
};
const status = normalize(f.jobStatus as any).sort((a, b) => a.name.localeCompare(b.name));
const division = normalize(f.jobDivision as any).sort((a, b) => a.name.localeCompare(b.name));
const key = JSON.stringify({ status, division });
return key;
},
async (key) => {
if (!key) return null;
const parsed = JSON.parse(key) as { status: Array<{ name: string; excluded: boolean }>; division: Array<{ name: string; excluded: boolean }> };
const status = parsed.status || [];
const division = parsed.division || [];
if (status.length === 0 && division.length === 0) return null;
const parts: string[] = [];
const statusInclude = status.filter(s => !s.excluded).map(s => s.name);
const statusExclude = status.filter(s => s.excluded).map(s => s.name);
const divisionInclude = division.filter(d => !d.excluded).map(d => d.name);
const divisionExclude = division.filter(d => d.excluded).map(d => d.name);
if (statusInclude.length > 0) {
parts.push(`(${statusInclude.map(s => `Job_Status = "${s}"`).join(" || ")})`);
}
if (statusExclude.length > 0) {
parts.push(`(${statusExclude.map(s => `Job_Status != "${s}"`).join(" && ")})`);
}
if (divisionInclude.length > 0) {
parts.push(`(${divisionInclude.map(d => `Job_Division = "${d}"`).join(" || ")})`);
}
if (divisionExclude.length > 0) {
parts.push(`(${divisionExclude.map(d => `Job_Division != "${d}"`).join(" && ")})`);
}
const filter = parts.join(" && ");
try {
const items = await pb.collection('Job_Info_Prod').getFullList({
filter,
fields: "id",
requestKey: null
});
return new Set(items.map((r: any) => String(r.id)));
} catch (e) {
console.warn("Failed to load job filter list for notes:", e);
return new Set<string>();
}
}
);
const filteredNotes = createMemo(() => {
const filter = store.noteFilter;
let n = store.notes.filter(note => {
if (note.deletedAt) return false;
// Hide child notes from the main list
if (note.tags && note.tags.some(t => t.startsWith("child-of-"))) return false;
return true;
});
// Query Filter
if (filter.query) {
const q = filter.query.toLowerCase();
n = n.filter(note =>
note.title.toLowerCase().includes(q) ||
note.tags.some(t => t.toLowerCase().includes(q))
);
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)));
}
// Tags Filter
if (filter.tags.length > 0) {
const required = filter.tags.filter(t => !t.excluded).map(t => t.name);
const excluded = filter.tags.filter(t => t.excluded).map(t => t.name);
if (required.length > 0) {
n = n.filter(note => required.some(r => note.tags.includes(r)));
}
if (excluded.length > 0) {
n = n.filter(note => !excluded.some(e => note.tags.includes(e)));
}
}
// Edited Today Filter
if (filter.editedToday) {
const today = new Date();
today.setHours(0, 0, 0, 0);
n = n.filter(note => {
const updated = new Date(note.updated);
return updated >= today;
});
}
// Owned By Me Filter
if (filter.ownedByMe) {
const accessible = accessibleJobs();
n = n.filter(note => {
if (note.tags?.includes("prism_job")) {
if (!accessible) return false;
const jobIdTag = note.tags.find(t => t.startsWith("job_id:"));
const jobId = jobIdTag?.split(":")[1]?.trim();
if (!jobId) return false;
return accessible.has(jobId);
}
return note.user === currentUserId;
});
}
// Job Status / Division Filter (notes only)
if ((filter.jobStatus?.length || 0) > 0 || (filter.jobDivision?.length || 0) > 0) {
const allowedJobIds = jobFilterIds();
n = n.filter(note => {
if (!note.tags?.includes("prism_job")) return false;
if (!allowedJobIds) return false;
const jobIdTag = note.tags.find(t => t.startsWith("job_id:"));
const jobId = jobIdTag?.split(":")[1]?.trim();
if (!jobId) return false;
return allowedJobIds.has(jobId);
});
}
// Starred Filter
if (filter.starred) {
n = n.filter(note => note.tags?.includes(getFavoriteTag()));
}
return n.sort((a, b) => {
const titleA = a.title || "Untitled";
const titleB = b.title || "Untitled";
return titleB.localeCompare(titleA);
});
return n;
});
const handleCreateNote = async () => {
if (!currentUserId) return;
try {
const keyBase = normalizeNoteKey("new-note");
const result = await pb.collection(NOTES_COLLECTION).create({
title: "New Note",
key: `${keyBase}-${Date.now()}`,
content: "",
tags: [],
isPrivate: false,
user: currentUserId,
tasks: []
});
props.setSelectedNoteId(result.id);
} catch (e) {
@@ -189,34 +40,26 @@ export const NotesSidebar: Component<{
return (
<div class="flex flex-col h-full bg-card">
<div class="z-20">
{/* Header */}
<div class="p-4 border-b border-border bg-card/50 backdrop-blur flex justify-between items-center gap-2">
<div class="flex-1">
<input
type="text"
placeholder="Search notes..."
value={store.noteFilter.query}
onInput={(e) => setNoteFilter({ query: e.currentTarget.value })}
class="w-full bg-muted/30 border border-border/50 rounded-xl px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/40 transition-all placeholder:text-muted-foreground/30"
/>
</div>
<div class="flex items-center gap-2 shrink-0">
<FilterBar
mode="notes"
class="relative top-0 right-0 z-0"
triggerClass="h-9 w-9 p-0 bg-muted/40 hover:bg-muted/60 border-border/40"
/>
<Button onClick={handleCreateNote} size="sm" variant="default" class="h-9 w-9 p-0 shrink-0 shadow-sm rounded-xl">
<Plus size={18} />
</Button>
</div>
{/* 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">{store.isNotesLoading ? "Loading notes..." : "No notes found."}</div>}>
<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)}
@@ -228,16 +71,27 @@ export const NotesSidebar: Component<{
)}
>
<div class="font-semibold text-sm truncate flex items-center justify-between">
<div class="flex items-center gap-1.5 truncate">
<Show when={note.tags?.includes(getFavoriteTag())}>
<Star size={12} class="fill-amber-500 text-amber-500 shrink-0" />
</Show>
<span class="truncate">{note.title || "Untitled"}</span>
</div>
<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>
-148
View File
@@ -1,148 +0,0 @@
import { type Component, createEffect, onCleanup } from 'solid-js';
import { useRegisterSW } from 'virtual:pwa-register/solid';
import { toast } from 'solid-sonner';
// We export a function so other parts of the app can manually trigger an update
let manualUpdateFn: (() => void) | undefined;
let updateAndReloadFn: (() => void) | undefined;
export const forceServiceWorkerUpdate = () => {
if (manualUpdateFn) {
manualUpdateFn();
} else {
console.warn("PWA Updater not initialized yet");
}
};
export const updateAndReloadApp = () => {
if (updateAndReloadFn) {
updateAndReloadFn();
} else {
console.warn("PWA Updater not initialized yet; reloading directly.");
window.location.reload();
}
};
export const PwaUpdater: Component = () => {
let registration: ServiceWorkerRegistration | undefined;
const {
needRefresh: [needRefresh],
updateServiceWorker,
} = useRegisterSW({
onRegistered(r: ServiceWorkerRegistration | undefined) {
console.log('SW Registered:', r);
registration = r;
// Periodically check for updates (every 1 hour)
if (r) {
setInterval(() => {
console.log('Periodic SW update check...');
r.update();
}, 60 * 60 * 1000);
}
},
onRegisterError(error: Error | any) {
console.error('SW registration error', error);
},
});
createEffect(() => {
// Expose function globally
manualUpdateFn = () => {
if (needRefresh()) {
console.log('User manually forced an update. Reloading now...');
updateServiceWorker(true);
} else if (registration) {
console.log('User manually asked to check for updates...');
registration.update();
}
};
updateAndReloadFn = () => {
if (needRefresh()) {
console.log('Update ready. Reloading now...');
updateServiceWorker(true);
return;
}
if (registration) {
console.log('Checking for updates before reload...');
registration.update();
}
const start = Date.now();
const timeoutMs = 5000;
const poll = window.setInterval(() => {
if (needRefresh()) {
console.log('Update found. Reloading with new SW...');
window.clearInterval(poll);
updateServiceWorker(true);
} else if (Date.now() - start > timeoutMs) {
console.log('No update detected quickly. Reloading now...');
window.clearInterval(poll);
toast.info("No update found");
}
}, 250);
};
});
createEffect(() => {
// Check for updates when the app regains focus/visibility
const handleVisibilityFocus = () => {
if (document.visibilityState === 'visible' && registration) {
console.log('App became visible, checking for SW updates...');
registration.update();
}
};
document.addEventListener('visibilitychange', handleVisibilityFocus);
window.addEventListener('focus', handleVisibilityFocus);
onCleanup(() => {
document.removeEventListener('visibilitychange', handleVisibilityFocus);
window.removeEventListener('focus', handleVisibilityFocus);
});
});
createEffect(() => {
if (needRefresh()) {
console.log('New PWA version available. Monitoring for idle/hidden state to update...');
let idleTimeout: number | undefined;
const triggerUpdate = () => {
console.log('Triggering PWA update...');
updateServiceWorker(true);
};
const resetIdle = () => {
if (idleTimeout) window.clearTimeout(idleTimeout);
// 10 minutes idle
idleTimeout = window.setTimeout(triggerUpdate, 10 * 60 * 1000);
};
const handleVisibility = () => {
if (document.visibilityState === 'hidden') {
triggerUpdate();
}
};
// Start idle timer
resetIdle();
// Listen for user activity
const events = ['mousedown', 'mousemove', 'keydown', 'scroll', 'touchstart'];
events.forEach(e => window.addEventListener(e, resetIdle, { passive: true }));
document.addEventListener('visibilitychange', handleVisibility);
onCleanup(() => {
if (idleTimeout) window.clearTimeout(idleTimeout);
events.forEach(e => window.removeEventListener(e, resetIdle));
document.removeEventListener('visibilitychange', handleVisibility);
});
}
});
return null;
};
+36 -4
View File
@@ -1,5 +1,5 @@
import { type Component, createSignal, onCleanup, onMount } from "solid-js";
import { store } from "@/store";
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 { cn } from "@/lib/utils";
@@ -7,6 +7,37 @@ import { QuickEntryForm } from "./QuickEntryForm";
export const QuickEntry: Component = () => {
const [isOpen, setIsOpen] = createSignal(false);
// Autofill tags based on context when opening
const initialTags = createMemo(() => {
if (!isOpen()) return [];
const tags: string[] = [];
const ctx = currentTaskContext();
if (store.isNotepadMode) {
// Not in a specific context, but in Notes mode.
// We use global window variable set by Layout to get the active note id
const activeNoteId = (window as any)._activeNoteId;
if (activeNoteId) {
const note = store.notes.find(n => n.id === activeNoteId);
if (note && note.title) {
const noteTagName = note.title.trim();
if (noteTagName) tags.push(noteTagName);
}
}
} else if (typeof ctx === 'object') {
if ('bucketId' in ctx) {
// Bucket View: Autofill bucket name
const bucketName = ctx.name;
if (bucketName) tags.push(`@${bucketName}`);
} else if ('userId' in ctx) {
// Shared User View: Autofill user name (assuming name IS the tag for sharing)
const userName = ctx.name;
if (userName) tags.push(`@${userName}`);
}
}
return tags;
});
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
@@ -35,10 +66,10 @@ export const QuickEntry: Component = () => {
</Button>
{isOpen() && (
<div class="fixed inset-0 z-[100] flex items-start justify-center md:pt-[15vh] px-0 md:px-4 pb-0 md:pb-4">
<div class="fixed inset-0 z-[100] flex items-end md:items-start justify-center md:pt-[15vh] px-0 md:px-4 pb-0 md:pb-4">
<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-b-2xl md:rounded-2xl shadow-2xl overflow-hidden animate-in fade-in slide-in-from-top-4 md:slide-in-from-bottom-0 md:zoom-in-95 duration-200 flex flex-col">
<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">
<div class="absolute top-3 right-4 z-[101]">
<Button
size="icon"
@@ -54,6 +85,7 @@ export const QuickEntry: Component = () => {
<QuickEntryForm
onSuccess={() => setIsOpen(false)}
autoFocus={true}
initialTags={initialTags()}
/>
</div>
</div>
+45 -147
View File
@@ -1,9 +1,8 @@
import { type Component, createSignal, createEffect, onMount, For, Show, Suspense, lazy } from "solid-js";
import { addTask, calculateDateFromUrgency, calculateUrgencyFromDate, store, upsertTagDefinition, type TaskTemplate, currentTaskContext, isCurrentUserContextTag } from "@/store";
import { Search, Command, Clock, ArrowUpCircle, Copy, ChevronDown, Gauge, RotateCcw } from "lucide-solid";
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 { cn } from "@/lib/utils";
import { TextField, TextFieldInput } from "@/components/ui/textfield";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
import { TagPicker } from "@/components/TagPicker";
@@ -12,7 +11,6 @@ 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";
import { buildNoteTag } from "@/lib/tags";
const TaskEditor = lazy(() => import("./TaskEditor").then(m => ({ default: m.TaskEditor })));
@@ -23,36 +21,19 @@ interface QuickEntryFormProps {
initialTags?: string[];
}
// Global state for QuickEntry persistence
const [input, setInput] = createSignal("");
const [priority, setPriority] = createSignal<string>("");
const [urgency, setUrgency] = createSignal<string>("");
const [tags, setTags] = createSignal<string[]>([]);
const [tagShareModes, setTagShareModes] = createSignal<Record<string, "handoff" | "collaborative">>({});
const [description, setDescription] = createSignal("");
const [size, setSize] = createSignal<string>("3");
const [dateString, setDateString] = createSignal<string>("");
const [showValidation, setShowValidation] = createSignal(false);
let lastParsedTags: string[] = [];
const resetQuickEntryState = () => {
setInput("");
setPriority("");
setUrgency("");
setTags([]);
setTagShareModes({});
setDescription("");
setSize("3");
setDateString("");
setShowValidation(false);
lastParsedTags = [];
};
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;
@@ -60,9 +41,6 @@ export const QuickEntryForm: Component<QuickEntryFormProps> = (props) => {
if (props.autoFocus) {
setTimeout(() => inputRef?.focus(), 100);
}
if (props.initialTitle && !input()) {
setInput(props.initialTitle);
}
});
// Reactive sync from props (handles context changes while open)
@@ -146,17 +124,11 @@ export const QuickEntryForm: Component<QuickEntryFormProps> = (props) => {
const parseAndAdd = async () => {
let text = input();
if (!text || !priority() || !urgency()) {
setShowValidation(true);
toast.error("Please fill in all required fields (Title, Priority, Urgency)");
if (!text) inputRef?.focus();
return;
}
if (!text || !priority() || !urgency()) return;
let finalPriority = parseInt(priority());
let finalUrgency = parseInt(urgency());
const finalTags = [...tags()];
const finalTagShareModes = { ...tagShareModes() };
const pMatch = text.match(/\/p\s*(\d+)/);
if (pMatch) {
@@ -215,43 +187,22 @@ export const QuickEntryForm: Component<QuickEntryFormProps> = (props) => {
finalDueDate = new Date(dateStr);
}
// Apply context tags at the time of creation
const ctx = currentTaskContext();
if (store.isNotepadMode) {
const activeNoteId = (window as any)._activeNoteId;
if (activeNoteId) {
const note = store.notes.find(n => n.id === activeNoteId);
if (note) {
const noteTagName = buildNoteTag(note.key || note.title);
if (noteTagName && !finalTags.includes(noteTagName)) finalTags.push(noteTagName);
}
}
} else if (typeof ctx === 'object') {
if ('bucketId' in ctx) {
const bucketName = store.contexts.find(context => context.id === ctx.bucketId)?.displayName || ctx.name;
if (bucketName) {
const tag = `@${bucketName}`;
if (!finalTags.includes(tag)) finalTags.push(tag);
}
} else if ('userId' in ctx) {
const userName = ctx.name;
if (userName) {
const tag = `@${userName}`;
if (!finalTags.includes(tag)) finalTags.push(tag);
}
}
}
addTask(title.replace(/\s+/g, " ").trim(), {
priority: finalPriority,
dueDate: finalDueDate || undefined,
tags: finalTags,
content: description(),
size: parseInt(size()),
shareModeByTag: finalTagShareModes
size: parseInt(size())
});
resetQuickEntryState();
setInput("");
setPriority("");
setUrgency("");
setTags([]);
setDescription("");
setSize("3");
lastParsedTags = [];
setDateString("");
const instance = editorInstance();
if (instance) instance.commands.setContent("");
@@ -281,33 +232,20 @@ export const QuickEntryForm: Component<QuickEntryFormProps> = (props) => {
return (
<div class="flex flex-col h-full bg-card">
<div class={cn("flex flex-col border-b border-border sticky top-0 bg-card z-10 transition-colors", showValidation() && !input() && "bg-destructive/5")}>
<div class="flex items-center px-4 py-3">
<Search class={cn("text-muted-foreground mr-3 transition-colors", showValidation() && !input() && "text-destructive")} size={20} />
<TextField
class="flex-1"
validationState={showValidation() && !input() ? "invalid" : "valid"}
>
<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 p-0"
/>
</TextField>
</div>
<Show when={showValidation() && !input()}>
<div class="px-4 pb-2 -mt-1 flex items-center gap-1.5 animate-in fade-in slide-in-from-top-1 duration-200">
<div class="w-1.5 h-1.5 rounded-full bg-destructive" />
<span class="text-[0.625rem] font-bold uppercase tracking-widest text-destructive/80">
Title is required to create a task
</span>
</div>
</Show>
<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">
@@ -331,17 +269,11 @@ export const QuickEntryForm: Component<QuickEntryFormProps> = (props) => {
{props.item.rawValue.label}
</SelectItem>
)}
validationState={showValidation() && !priority() ? "invalid" : "valid"}
>
<SelectTrigger
class={cn(
"w-full bg-background border shadow-sm h-10 px-3 overflow-hidden transition-colors border-transparent",
showValidation() && !priority() && "border-destructive/50 bg-destructive/5"
)}
>
<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={cn("text-primary shrink-0", showValidation() && !priority() && "text-destructive")} />
<span class={cn("text-sm font-medium truncate", showValidation() && !priority() && "text-destructive")}>
<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>
@@ -396,17 +328,11 @@ export const QuickEntryForm: Component<QuickEntryFormProps> = (props) => {
{props.item.rawValue.label}
</SelectItem>
)}
validationState={showValidation() && !urgency() ? "invalid" : "valid"}
>
<SelectTrigger
class={cn(
"w-full bg-background border shadow-sm h-10 px-3 overflow-hidden transition-colors border-transparent",
showValidation() && !urgency() && "border-destructive/50 bg-destructive/5"
)}
>
<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={cn("text-primary shrink-0", showValidation() && !urgency() && "text-destructive")} />
<span class={cn("text-sm font-medium truncate", showValidation() && !urgency() && "text-destructive")}>
<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>
@@ -418,17 +344,8 @@ export const QuickEntryForm: Component<QuickEntryFormProps> = (props) => {
<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()}
getShareModeForTag={(tag) => tagShareModes()[tag.toLowerCase()]}
onTagsChange={(nextTags, shareModeOverrides) => {
setTags(nextTags);
if (shareModeOverrides) {
setTagShareModes(prev => ({ ...prev, ...shareModeOverrides }));
}
}}
/>
<For each={tags().filter(t => !t.endsWith("_favorite__") && !isCurrentUserContextTag(t))}>
<TagPicker selectedTags={tags()} onTagsChange={setTags} />
<For each={tags()}>
{(tag) => (
<Badge
variant="secondary"
@@ -449,14 +366,7 @@ export const QuickEntryForm: Component<QuickEntryFormProps> = (props) => {
return getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme()) || "inherit";
})()
}}
onClick={() => {
setTags(tags().filter(t => t !== tag));
setTagShareModes(prev => {
const next = { ...prev };
delete next[tag.toLowerCase()];
return next;
});
}}
onClick={() => setTags(tags().filter(t => t !== tag))}
>
<div
class="w-1.5 h-1.5 rounded-full transition-colors group-hover/tag:bg-destructive"
@@ -510,18 +420,6 @@ export const QuickEntryForm: Component<QuickEntryFormProps> = (props) => {
</div>
<div class="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
class="h-9 w-9 text-muted-foreground hover:text-destructive"
onClick={() => {
resetQuickEntryState();
editorInstance()?.commands.setContent("");
}}
title="Restart Quick Entry"
>
<RotateCcw size={16} />
</Button>
<Show when={(store.templates || []).length > 0}>
<Popover>
<PopoverTrigger
@@ -559,7 +457,7 @@ export const QuickEntryForm: Component<QuickEntryFormProps> = (props) => {
</Popover>
</Show>
<Button size="sm" onClick={parseAndAdd}>
<Button size="sm" onClick={parseAndAdd} disabled={!input() || !priority() || !urgency()}>
Create Task
</Button>
</div>
+165 -26
View File
@@ -1,6 +1,17 @@
import { type Component, createSignal, For, createEffect } from "solid-js";
import { Type, Code, Quote, Heading1, Heading2, Heading3, List, ListTodo, FileText, AppWindow, Minus, Grid3X3, Trash2 } from "lucide-solid";
import { cn } from "@/lib/utils";
import {
Heading1, Heading2, Heading3,
List, ListTodo, Type,
Code, Quote,
Image as ImageIcon, Minus,
Bold, Italic, Underline,
Grid3X3, Highlighter,
AlignLeft, AlignCenter, AlignRight,
Trash2
} from "lucide-solid";
import { cn, convertImageToWebpFile } from "@/lib/utils";
import { activeTaskId, uploadTaskAttachment } from "@/store";
import { toast } from "solid-sonner";
export interface CommandItem {
title: string;
@@ -20,6 +31,33 @@ 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.",
@@ -63,15 +101,6 @@ export const getSuggestionItems = ({ query }: { query: string }): CommandItem[]
editor.chain().focus().deleteRange(range).toggleTaskList().run();
},
},
{
title: "Multi Checklist",
description: "Insert a row with multiple checkboxes.",
aliases: ["multi", "checkboxes", "multi-checklist"],
icon: ListTodo,
command: ({ editor, range }: { editor: any, range: any }) => {
editor.chain().focus().deleteRange(range).toggleMultiTaskList().run();
},
},
{
title: "Code Block",
description: "Capture a code snippet.",
@@ -89,21 +118,51 @@ export const getSuggestionItems = ({ query }: { query: string }): CommandItem[]
},
},
{
title: "Upload",
description: "Upload an image, video, or document.",
aliases: ["image", "video", "file", "upload", "pdf", "movie"],
icon: FileText,
title: "Image",
description: "Upload an image.",
icon: ImageIcon,
command: ({ editor, range }: { editor: any, range: any }) => {
editor.chain().focus().deleteRange(range).uploadMedia().run();
},
},
{
title: "Job File",
description: "Embed a job file from Prism.",
aliases: ["job", "jobfile", "prism"],
icon: AppWindow,
command: ({ editor, range }: { editor: any, range: any }) => {
editor.chain().focus().deleteRange(range).insertFileViewer().run();
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();
},
},
{
@@ -123,7 +182,87 @@ export const getSuggestionItems = ({ query }: { query: string }): CommandItem[]
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.",
+3 -34
View File
@@ -1,4 +1,4 @@
import { type Component, For } from "solid-js";
import { type Component } from "solid-js";
import { CheckCircle2 } from "lucide-solid";
import { cn } from "@/lib/utils";
@@ -6,8 +6,6 @@ interface StatusCircleProps {
status: number;
size?: number;
className?: string;
recurrenceProgress?: number | null;
completed?: boolean;
}
export const StatusCircle: Component<StatusCircleProps> = (props) => {
@@ -27,39 +25,10 @@ export const StatusCircle: Component<StatusCircleProps> = (props) => {
return circumference() * (1 - percentage);
};
const dotCount = () => 12;
const dotRadius = () => Math.max(1.4, size() * 0.06);
const dotRingRadius = () => radius() - 1;
const filledDots = () => {
const progress = props.recurrenceProgress ?? 0;
return Math.floor(Math.max(0, Math.min(1, progress)) * dotCount());
};
return (
<div class={cn("relative flex items-center justify-center", props.className)} style={{ width: `${size()}px`, height: `${size()}px` }}>
{props.completed || props.status >= 10 ? (
props.recurrenceProgress != null ? (
<svg width={size()} height={size()}>
<For each={Array.from({ length: dotCount() })}>
{(_, index) => {
const angle = (index() / dotCount()) * Math.PI * 2 - Math.PI / 2;
const cx = size() / 2 + Math.cos(angle) * dotRingRadius();
const cy = size() / 2 + Math.sin(angle) * dotRingRadius();
const isFilled = index() < filledDots();
return (
<circle
cx={cx}
cy={cy}
r={dotRadius()}
class={isFilled ? "fill-green-500" : "fill-muted-foreground/30"}
/>
);
}}
</For>
</svg>
) : (
<CheckCircle2 size={size()} class="text-green-500" />
)
{props.status >= 10 ? (
<CheckCircle2 size={size()} class="text-green-500" />
) : (
// Progress Circle
<div class="relative group">
-183
View File
@@ -1,183 +0,0 @@
import { type Component, createSignal, createEffect, For } from "solid-js";
import {
Trash2,
Type
} from "lucide-solid";
import { cn } from "@/lib/utils";
import type { Editor } from "@tiptap/core";
// Custom Icons
const AddRowAbove = () => (
<svg width="20" height="20" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="8" width="12" height="7" rx="1.5" stroke="currentColor" stroke-width="1.5" />
<path d="M9 2V6M7 4H11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
);
const AddRowBelow = () => (
<svg width="20" height="20" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="3" width="12" height="7" rx="1.5" stroke="currentColor" stroke-width="1.5" />
<path d="M9 12V16M7 14H11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
);
const AddColLeft = () => (
<svg width="20" height="20" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="8" y="3" width="7" height="12" rx="1.5" stroke="currentColor" stroke-width="1.5" />
<path d="M2 9H6M4 7V11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
);
const AddColRight = () => (
<svg width="20" height="20" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="3" width="7" height="12" rx="1.5" stroke="currentColor" stroke-width="1.5" />
<path d="M12 9H16M14 7V11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
);
const DeleteRow = () => (
<svg width="20" height="20" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="7" width="14" height="4" rx="0.5" stroke="currentColor" stroke-width="1.5" />
<path d="M4 3L14 15M14 3L4 15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
);
const DeleteCol = () => (
<svg width="20" height="20" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="7" y="2" width="4" height="14" rx="0.5" stroke="currentColor" stroke-width="1.5" />
<path d="M3 4L15 14M15 4L3 14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
);
const MergeCells = () => (
<svg width="20" height="20" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="4" width="14" height="10" rx="1" stroke="currentColor" stroke-width="1.5" />
<path d="M6 9H12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
);
const SplitCell = () => (
<svg width="20" height="20" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="4" width="14" height="10" rx="1" stroke="currentColor" stroke-width="1.5" />
<path d="M9 7V11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
);
interface TableAction {
label: string;
icon: Component;
command: () => void;
iconClass?: string;
}
interface TableGroup {
name: string;
actions: TableAction[];
}
interface TableBubbleMenuProps {
editor: Editor;
}
export const TableBubbleMenu: Component<TableBubbleMenuProps> = (props) => {
const [isVisible, setIsVisible] = createSignal(false);
createEffect(() => {
const updateVisibility = () => {
const editor = props.editor;
if (!editor) return;
setIsVisible(editor.isActive("table"));
};
props.editor.on("selectionUpdate", updateVisibility);
props.editor.on("transaction", updateVisibility);
return () => {
props.editor.off("selectionUpdate", updateVisibility);
props.editor.off("transaction", updateVisibility);
};
});
const groups: TableGroup[] = [
{
name: "Rows",
actions: [
{ label: "Insert Row Above", icon: AddRowAbove, command: () => props.editor.chain().focus().addRowBefore().run() },
{ label: "Insert Row Below", icon: AddRowBelow, command: () => props.editor.chain().focus().addRowAfter().run() },
{ label: "Delete Row", icon: DeleteRow, iconClass: "text-destructive", command: () => props.editor.chain().focus().deleteRow().run() },
]
},
{
name: "Cols",
actions: [
{ label: "Insert Column Left", icon: AddColLeft, command: () => props.editor.chain().focus().addColumnBefore().run() },
{ label: "Insert Column Right", icon: AddColRight, command: () => props.editor.chain().focus().addColumnAfter().run() },
{ label: "Delete Column", icon: DeleteCol, iconClass: "text-destructive", command: () => props.editor.chain().focus().deleteColumn().run() },
]
},
{
name: "Cells",
actions: [
{ label: "Merge Cells", icon: MergeCells, command: () => props.editor.chain().focus().mergeCells().run() },
{ label: "Split Cell", icon: SplitCell, command: () => props.editor.chain().focus().splitCell().run() },
{ label: "Header Row", icon: () => <Type size={18} />, command: () => props.editor.chain().focus().toggleHeaderRow().run() },
]
},
{
name: "Table",
actions: [
{ label: "Delete Table", icon: () => <Trash2 size={18} />, iconClass: "text-destructive", command: () => props.editor.chain().focus().deleteTable().run() },
]
}
];
return (
<div
class={cn(
"relative z-[150] w-full bg-background/80 backdrop-blur-md border-b border-border transition-all duration-300 ease-in-out overflow-hidden flex flex-col",
isVisible() ? "h-9 opacity-100" : "h-0 opacity-0 pointer-events-none"
)}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<div class="flex items-center justify-center h-full px-2 max-w-4xl mx-auto w-full">
<div class="flex items-center gap-1">
<For each={groups}>
{(group, groupIdx) => (
<>
<div class="flex items-center gap-0 px-0.5">
<For each={group.actions}>
{(action) => (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
action.command();
}}
class={cn(
"p-0.5 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground shrink-0 group relative",
action.iconClass
)}
>
<action.icon />
<span class="absolute top-full left-1/2 -translate-x-1/2 mt-2 px-2 py-1 text-[10px] font-bold bg-foreground text-background rounded opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap z-[200] shadow-lg">
{action.label}
</span>
</button>
)}
</For>
</div>
{groupIdx() < groups.length - 1 && (
<div class="w-[1px] h-4 bg-border/50 mx-1" />
)}
</>
)}
</For>
</div>
</div>
</div>
);
};
+12 -123
View File
@@ -1,17 +1,13 @@
import { type Component, createSignal, For, createEffect } from "solid-js";
import { store, upsertTagDefinition, isCurrentUserContextTag, getNoteSharePolicy } from "@/store";
import { store, upsertTagDefinition } from "@/store";
import { useTheme } from "./ThemeProvider";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Tag, Plus } from "lucide-solid";
import { toast } from "solid-sonner";
import { buildNoteTag, buildShareTag, normalizeContextKey, normalizeNoteKey } from "@/lib/tags";
import { cn } from "@/lib/utils";
interface TagPickerProps {
selectedTags: string[];
onTagsChange: (tags: string[], shareModeOverrides?: Record<string, "handoff" | "collaborative">) => void;
getShareModeForTag?: (tag: string) => "handoff" | "collaborative" | undefined;
onTagsChange: (tags: string[]) => void;
}
export const TagPicker: Component<TagPickerProps> = (props) => {
@@ -19,21 +15,18 @@ export const TagPicker: Component<TagPickerProps> = (props) => {
const [open, setOpen] = createSignal(false);
const [selectedTagName, setSelectedTagName] = createSignal("");
const [valueScore, setValueScore] = createSignal(5);
const [shareMode, setShareMode] = createSignal<"handoff" | "collaborative">("handoff");
// Filter tags for the "Name" part
const localTagNames = () => {
const localDef = store.tagDefinitions.filter(d => !d.isUser && !d.isBucket).map(d => d.name).filter(tag => !isCurrentUserContextTag(tag));
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.filter(d => !d.name.endsWith("_favorite__")).map(d => d.name);
const contextTags = store.contexts.filter(c => !c.deletedAt).map(c => buildShareTag(c.displayName));
const noteTags = store.notes
.filter(n => !(n.tags || []).some(t => t.startsWith("child-of-")))
.map(n => buildNoteTag(n.key || n.title));
return [...new Set([...definedTags, ...contextTags, ...noteTags])].filter(tag => !isCurrentUserContextTag(tag)).sort();
const definedTags = store.tagDefinitions.map(d => d.name);
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 = () => {
@@ -43,27 +36,6 @@ export const TagPicker: Component<TagPickerProps> = (props) => {
return allTagNames();
};
const selectedShareContext = () => {
const name = selectedTagName().trim();
if (!name.startsWith("@")) return null;
const normalized = normalizeContextKey(name);
return store.contexts.find(context =>
!context.deletedAt && (
context.key === normalized ||
normalizeContextKey(context.displayName) === normalized
)
) || null;
};
const selectedNoteContext = () => {
const name = selectedTagName().trim();
if (!name.startsWith("#")) return null;
const key = normalizeNoteKey(name);
return store.notes.find(note => !note.deletedAt && note.key === key) || null;
};
// When a tag name is selected, update the value score to match its current definition
createEffect(() => {
const name = selectedTagName().trim();
@@ -75,69 +47,20 @@ export const TagPicker: Component<TagPickerProps> = (props) => {
}
});
createEffect(() => {
const explicitMode = props.getShareModeForTag?.(selectedTagName().trim());
if (explicitMode) {
setShareMode(explicitMode);
return;
}
const context = selectedShareContext();
if (context) {
setShareMode(context.policy);
return;
}
const note = selectedNoteContext();
if (note) {
setShareMode(getNoteSharePolicy(note));
return;
}
if (selectedTagName().trim().startsWith("@") || selectedTagName().trim().startsWith("#")) {
setShareMode("handoff");
}
});
const handleApply = async () => {
const handleApply = () => {
const name = selectedTagName().trim();
if (!name) return;
if (name.startsWith("@")) {
const normalized = normalizeContextKey(name);
const exists = store.contexts.some(context =>
!context.deletedAt && (
context.key === normalized ||
normalizeContextKey(context.displayName) === normalized
)
);
if (!exists) {
toast.error("Create the shared context first before tagging tasks with it.");
return;
}
}
if (name.startsWith("#")) {
const exists = store.notes.some(note => note.key === normalizeNoteKey(name));
if (!exists) {
toast.error("Create the canonical note first before linking it with a #tag.");
return;
}
}
// update global definition
const bucket = store.buckets.find(b => b.name === name || `@${b.name}` === name);
const color = bucket ? bucket.color : undefined;
if (!name.startsWith("@") && !name.startsWith("#")) {
upsertTagDefinition(name, valueScore(), color, resolvedTheme());
}
upsertTagDefinition(name, valueScore(), color, resolvedTheme());
// Toggle in current task selection if not already there
const currentSelected = props.selectedTags;
const nextTags = currentSelected.includes(name) ? currentSelected : [...currentSelected, name];
props.onTagsChange(
nextTags,
name.startsWith("@") || name.startsWith("#")
? { [name.toLowerCase()]: shareMode() }
: undefined
);
if (!currentSelected.includes(name)) {
props.onTagsChange([...currentSelected, name]);
}
// Cleanup
setSelectedTagName("");
@@ -183,40 +106,6 @@ export const TagPicker: Component<TagPickerProps> = (props) => {
</select>
</div>
{(selectedTagName().trim().startsWith("@") || selectedTagName().trim().startsWith("#")) && (
<div class="space-y-1.5">
<label class="text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground">Share Mode</label>
<div class="bg-background border border-border p-1 rounded-xl flex items-center shadow-sm relative min-w-[160px] h-10">
<div
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",
shareMode() === "handoff" ? "translate-x-0" : "translate-x-[calc(100%+0px)]"
)}
/>
<button
type="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",
shareMode() === "handoff" ? "text-primary-foreground" : "text-muted-foreground hover:text-foreground"
)}
onClick={() => setShareMode("handoff")}
>
Handoff
</button>
<button
type="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",
shareMode() === "collaborative" ? "text-primary-foreground" : "text-muted-foreground hover:text-foreground"
)}
onClick={() => setShareMode("collaborative")}
>
Collaborate
</button>
</div>
</div>
)}
<Button size="sm" class="w-full mt-1" onClick={handleApply}>
<Plus size={14} class="mr-2" />
{store.tagDefinitions.find(d => d.name === selectedTagName().trim()) ? "Update & Add" : "Create & Add"}
+62 -136
View File
@@ -1,13 +1,12 @@
import { type Component, createMemo, For, Show } from "solid-js";
import { type Task, calculateUrgencyFromDate, setActiveTaskId, store, copyTask, updateTask, currentTaskContext, now, getFavoriteTag, dismissTaskUpdateIndicator, isTaskUpdatedByAnotherUser, collapseTaskUntilUpdated, expandCollapsedTask, isTaskCollapsedUntilUpdated, isTaskCollaborativelyShared, getVisibleTaskTags } from "@/store";
import { type Task, calculateUrgencyFromDate, setActiveTaskId, store, copyTask, updateTask, currentTaskContext, now } from "@/store";
import { cn } from "@/lib/utils";
import { Clock, ArrowUpCircle, Copy, Users2, Star, ChevronRight } from "lucide-solid";
import { Clock, ArrowUpCircle, Copy, Users2 } from "lucide-solid";
import { Badge } from "@/components/ui/badge";
import { useTheme } from "./ThemeProvider";
import { getThemeAdjustedColor } from "@/lib/colors";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
import { getStatusOptionsForTask } from "@/lib/constants";
import { getRecurrenceProgress } from "@/lib/recurrence";
import { StatusCircle } from "./StatusCircle";
export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props) => {
@@ -27,8 +26,19 @@ export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (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(() => {
return getVisibleTaskTags(props.task, currentTaskContext());
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(() => {
@@ -43,105 +53,53 @@ export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props)
}
});
const canCollapseUntilUpdated = createMemo(() => isTaskCollaborativelyShared(props.task));
const statusOptions = createMemo(() => {
const baseOptions = getStatusOptionsForTask(props.task.recurrence);
if (!canCollapseUntilUpdated()) return baseOptions;
return [
...baseOptions,
{ value: "__collapse_until_updated__", label: "Collapse until updated" }
];
});
const recurrenceProgress = createMemo(() => {
if (!props.task.completed && props.task.status !== 10) return null;
return getRecurrenceProgress(props.task.recurrence, props.task.updated, now());
});
const shouldQuickloadFade = createMemo(() => store.quickloadFadeTaskIds.includes(props.task.id));
const showUpdateIndicator = createMemo(() => isTaskUpdatedByAnotherUser(props.task));
const isCollapsed = createMemo(() => isTaskCollapsedUntilUpdated(props.task));
const statusOptions = createMemo(() => getStatusOptionsForTask(props.task.recurrence));
return (
<div
class={cn(
"group relative flex items-center 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",
isCollapsed()
? "py-1.5 pl-5 pr-5 sm:py-2 sm:pl-6 sm:pr-6"
: "py-3 pl-6 pr-6 sm:py-4 sm:pl-7 sm:pr-7",
"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",
shouldQuickloadFade() && "animate-task-quickload-fade"
props.isShaking && "animate-task-shake bg-accent/20"
)}
onClick={() => setActiveTaskId(props.task.id)}
>
{/* Today indicator */}
<Show when={isDueWithin12Hours() && !props.task.completed}>
<div class="absolute left-0 top-3 bottom-3 w-3 sm:w-[0.875rem] rounded-r-md bg-primary/65 text-primary-foreground/90">
<span
class="absolute inset-0 flex items-center justify-center text-[0.4375rem] sm:text-[0.5rem] font-semibold uppercase tracking-[0.08em] [writing-mode:vertical-rl] [text-orientation:mixed]"
>
{!isCollapsed() && "Today"}
</span>
</div>
</Show>
<Show when={showUpdateIndicator()}>
<button
type="button"
class="absolute right-0 top-3 bottom-3 w-3 sm:w-[0.875rem] rounded-l-md bg-primary/65 text-primary-foreground/90 transition-colors hover:bg-primary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
title="Hide updates until the next change from another user"
onClick={(e) => {
e.stopPropagation();
dismissTaskUpdateIndicator(props.task.id, props.task.updated);
}}
>
<span
class="absolute inset-0 flex items-center justify-center text-[0.4375rem] sm:text-[0.5rem] font-semibold uppercase tracking-[0.08em] [writing-mode:vertical-rl] [text-orientation:mixed]"
>
{!isCollapsed() && "Updates"}
</span>
</button>
<div class="absolute left-0 top-3 bottom-3 w-1 bg-primary rounded-r-md" />
</Show>
{/* Status Dropdown */}
<Show when={!isCollapsed()}>
<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) return;
if (value === "__collapse_until_updated__") {
collapseTaskUntilUpdated(props.task.id, props.task.updated);
return;
}
<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 });
}}
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} completed={props.task.completed} size={26} recurrenceProgress={recurrenceProgress()} />
</SelectTrigger>
<SelectContent />
</Select>
</div>
</Show>
<StatusCircle status={props.task.status ?? 0} size={26} />
</SelectTrigger>
<SelectContent />
</Select>
</div>
<div class={cn("flex-1 min-w-0", isCollapsed() ? "flex items-center justify-between gap-2" : "flex flex-col")}>
<Show
when={isCollapsed()}
fallback={
<>
<div class="flex-1 min-w-0 flex flex-col">
<h3 class={cn(
"text-sm sm:text-base font-semibold truncate transition-all",
props.task.completed && "line-through"
@@ -176,13 +134,23 @@ export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props)
<span class="text-[0.625rem] font-medium">P{props.task.priority}</span>
</div>
{/* Shared Indicator - driven by task context tags */}
{/* Shared Indicator - shows for individual shares OR tag-based ShareRules */}
{(() => {
if (props.task.shareRefs.length === 0) return null;
const hasIndividualShares = props.task.sharedWith && props.task.sharedWith.length > 0;
// Check if any tag-based ShareRules match this task's tags (exclude 'all' type)
const hasTagRuleShares = store.shareRules.some(rule =>
rule.type === 'tag' &&
rule.tagName &&
props.task.tags?.includes(rule.tagName)
);
const isShared = hasIndividualShares || hasTagRuleShares;
const tooltip = props.task.shareRefs.length === 1
? 'Shared via 1 context'
: `Shared via ${props.task.shareRefs.length} contexts`;
if (!isShared) return null;
const shareCount = (props.task.sharedWith?.length || 0);
const tooltip = hasIndividualShares
? `Shared with ${shareCount} user${shareCount > 1 ? 's' : ''}`
: 'Shared via tag rule';
return (
<div class="flex items-center gap-1 shrink-0" title={tooltip}>
@@ -248,33 +216,11 @@ export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props)
</div>
</div>
</div>
</>
}
>
<h3 class={cn(
"text-[0.6875rem] sm:text-xs font-semibold truncate text-muted-foreground/90",
props.task.completed && "line-through"
)}>
{props.task.title}
</h3>
<button
type="button"
class="shrink-0 rounded-full p-1 text-muted-foreground/70 transition-colors hover:bg-muted hover:text-foreground"
title="Expand task"
onClick={(e) => {
e.stopPropagation();
expandCollapsedTask(props.task.id);
}}
>
<ChevronRight size={14} />
</button>
</Show>
</div>
<Show when={!isCollapsed()}>
<div class="ml-2 sm:ml-4 shrink-0 flex items-center gap-1">
<div class="ml-2 sm:ml-4 opacity-100 sm:opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button
class="p-1.5 hover:bg-muted rounded-full transition-all text-muted-foreground hover:text-primary opacity-0 group-hover:opacity-100"
class="p-1.5 hover:bg-muted rounded-full transition-colors text-muted-foreground hover:text-primary"
onClick={(e) => {
e.stopPropagation();
copyTask(props.task.id);
@@ -283,27 +229,7 @@ export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props)
>
<Copy size={16} />
</button>
<button
class={cn(
"p-1.5 rounded-full transition-all",
props.task.tags?.includes(getFavoriteTag())
? "text-amber-500 bg-amber-500/10 hover:bg-amber-500/20 opacity-100"
: "text-muted-foreground hover:text-amber-500 hover:bg-muted opacity-0 group-hover:opacity-100"
)}
onClick={(e) => {
e.stopPropagation();
const favTag = getFavoriteTag();
const currentTags = props.task.tags || [];
const isFav = currentTags.includes(favTag);
const newTags = isFav ? currentTags.filter(t => t !== favTag) : [...currentTags, favTag];
updateTask(props.task.id, { tags: newTags });
}}
title="Toggle Favorite"
>
<Star size={16} class={cn(props.task.tags?.includes(getFavoriteTag()) && "fill-amber-500")} />
</button>
</div>
</Show>
</div>
</div>
);
};
+165 -166
View File
@@ -1,9 +1,9 @@
import { type Component, createEffect, createSignal, For, createMemo, onCleanup, Show } 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, loadTaskContent, currentTaskContext, cleanupTaskAttachments, getFavoriteTag, now, isCurrentUserContextTag, getVisibleTaskTags, isTaskLockedNoteTag, deriveTaskRelationsFromTags, getTaskShareModeForTag, getTaskSharePolicy, canHandoffCollaborativelySharedTask, handoffCollaborativelySharedTask } 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, Star, FileText } from "lucide-solid";
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";
@@ -16,10 +16,7 @@ import { toast } from "solid-sonner";
import { lazy, Suspense } from "solid-js";
import { useTheme } from "./ThemeProvider";
import { getThemeAdjustedColor } from "@/lib/colors";
import { getRecurrenceProgress } from "@/lib/recurrence";
import { buildNoteTag, buildShareTag } from "@/lib/tags";
import { TextBubbleMenu } from "@/components/TextBubbleMenu";
import { TableBubbleMenu } from "@/components/TableBubbleMenu";
import { pb } from "@/lib/pocketbase";
const TaskEditor = lazy(() => import("./TaskEditor").then(m => ({ default: m.TaskEditor })));
@@ -113,31 +110,9 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
}, 1000);
};
// Sync activeTaskId for slash commands and run cleanup on unmount/change
createEffect(() => {
const currentTaskId = props.task.id;
// Removed onCleanup to avoid lockup
onCleanup(() => {
// 1. Immediately flush any pending debounce updates for the outgoing task
if (debounceTimer) {
clearTimeout(debounceTimer);
if (pendingContent !== null) {
updateTask(currentTaskId, { content: pendingContent });
}
}
// 2. Clear pending state
debounceTimer = undefined;
pendingContent = null;
// 3. Trigger cleanup after a tiny delay so the updateTask above processes first
if (currentTaskId) {
setTimeout(() => {
cleanupTaskAttachments(currentTaskId).catch(console.error);
}, 100);
}
});
});
const updateTitle = (val: string) => {
setTitle(val);
updateTask(props.task.id, { title: val });
@@ -168,39 +143,20 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
}
};
const updateTags = (tags: string[], shareModeOverrides?: Record<string, "handoff" | "collaborative">) => {
const updateTags = (tags: string[]) => {
const ctx = currentTaskContext();
const currentTags = props.task.tags || [];
let newTags = [...tags];
currentTags
.filter(tag => isCurrentUserContextTag(tag))
.forEach(tag => {
if (!newTags.some(existing => existing.toLowerCase() === tag.toLowerCase())) {
newTags.push(tag);
}
});
// 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 bucketContext = store.contexts.find(context => context.id === ctx.bucketId);
const bucketTagName = buildShareTag(bucketContext?.displayName || ctx.name);
const bucketTagName = `@${(ctx as { name: string }).name}`;
// Check case-insensitive existence
if (!newTags.some(t => t.toLowerCase() === bucketTagName.toLowerCase())) {
newTags.push(bucketTagName);
}
}
props.task.tags
.filter(tag => isTaskLockedNoteTag(props.task, tag))
.forEach(tag => {
if (!newTags.some(existing => existing.toLowerCase() === tag.toLowerCase())) {
newTags.push(tag);
}
});
const { labelTags, shareRefs, noteRefs } = deriveTaskRelationsFromTags(newTags, props.task.shareRefs, shareModeOverrides);
updateTask(props.task.id, { tags: newTags, labelTags, shareRefs, noteRefs });
updateTask(props.task.id, { tags: newTags });
};
const onDateInputChange = (e: Event) => {
@@ -216,14 +172,20 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
// Filter out bucket tags if we are currently INSIDE that bucket view
const visibleTags = createMemo(() => {
return getVisibleTaskTags(props.task, currentTaskContext());
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));
const recurrenceProgress = createMemo(() => {
if (!props.task.completed && props.task.status !== 10) return null;
return getRecurrenceProgress(props.task.recurrence, props.task.updated, now());
});
return (
<Sheet open={props.isOpen} onOpenChange={(open) => {
@@ -244,10 +206,10 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
e.preventDefault();
}
}}
class="w-full sm:max-w-2xl p-0 gap-0 flex flex-col h-full bg-background border-l border-border shadow-2xl"
class="w-full sm:max-w-2xl px-0 sm:px-0 flex flex-col h-full bg-background border-l border-border shadow-2xl"
>
{/* Header Area */}
<div class="px-6 pt-6 pb-0 shrink-0">
<div class="px-6 pt-6 pb-2 shrink-0">
<div class="flex items-start gap-4">
<div class="shrink-0 mt-0.5">
<Select<any>
@@ -267,7 +229,7 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
updateStatus("10");
}}
>
<StatusCircle status={props.task.status ?? 0} completed={props.task.completed} size={28} recurrenceProgress={recurrenceProgress()} />
<StatusCircle status={props.task.status ?? 0} size={28} />
</SelectTrigger>
<SelectContent />
</Select>
@@ -377,26 +339,6 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
</Select>
</div>
{/* Favorite */}
<div class="flex items-center gap-1 group shrink-0">
<span class="text-[0.625rem] uppercase font-bold tracking-wider text-muted-foreground/70 hidden sm:inline"></span>
<Button
variant="ghost"
size="sm"
class={cn("h-8 px-1 sm:px-2 text-[0.625rem] font-bold uppercase tracking-wider transition-colors shrink-0", props.task.tags?.includes(getFavoriteTag()) ? "text-amber-500 hover:bg-amber-500/10" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground")}
onClick={() => {
const favTag = getFavoriteTag();
const currentTags = props.task.tags || [];
const isFav = currentTags.includes(favTag);
const newTags = isFav ? currentTags.filter(t => t !== favTag) : [...currentTags, favTag];
updateTags(newTags);
}}
>
<Star size={14} class={cn("opacity-70 group-hover:opacity-100 transition-opacity shrink-0", props.task.tags?.includes(getFavoriteTag()) && "fill-amber-500")} />
<span class="hidden sm:inline sm:ml-1.5">{props.task.tags?.includes(getFavoriteTag()) ? "Starred" : "Star"}</span>
</Button>
</div>
{/* Commands Shortcut */}
<div class="flex items-center gap-1 shrink-0">
<Button
@@ -414,24 +356,6 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
<span class="hidden sm:inline">Commands</span>
</Button>
</div>
{/* Unified Media Upload Shortcut */}
<div class="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="sm"
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) {
instance.chain().focus().uploadMedia().run();
}
}}
>
<FileText size={14} class="opacity-70" />
<span class="hidden sm:inline">Upload</span>
</Button>
</div>
</div>
{/* Metadata Row (Dates/Timestamps) */}
@@ -468,19 +392,10 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
</div>
<div class="h-px w-full bg-border mt-5" />
<Show when={editorInstance()}>
{(editor: any) => (
<div class="flex flex-col pt-1 pb-1 relative z-30">
<TextBubbleMenu editor={editor()} />
<TableBubbleMenu editor={editor()} />
</div>
)}
</Show>
</div>
{/* Content Area */}
<div class="flex-1 overflow-y-auto px-6 pb-20 pt-4 flex flex-col">
<div class="flex-1 overflow-y-auto px-6 py-4 pb-20 flex flex-col gap-6">
<Suspense fallback={<div class="h-32 animate-pulse bg-muted/20 rounded-lg" />}>
<TaskEditor
content={props.task.content}
@@ -511,7 +426,6 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
<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()}
getShareModeForTag={(tag) => getTaskShareModeForTag(props.task, tag)}
onTagsChange={updateTags}
/>
<div class="flex flex-nowrap items-center gap-1.5 shrink-0 pr-2">
@@ -692,68 +606,17 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
{/* Sharing Section */}
<div class="p-2 border-t border-border/50">
<div class="text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground/70 px-2 py-1 mb-1">Sharing</div>
<div class="px-2 py-1.5 rounded-lg bg-muted/20 border border-border/30 space-y-2">
<p class="text-[0.6875rem] text-muted-foreground leading-relaxed">
Sharing is driven by the task&apos;s <span class="font-semibold text-foreground">@tags</span>. Add or remove
<span class="font-semibold text-foreground"> @people</span>, <span class="font-semibold text-foreground">@buckets</span>, and <span class="font-semibold text-foreground">#notes</span> in the tag list above.
</p>
<Show
when={props.task.shareRefs.length > 0}
fallback={<p class="text-[0.6875rem] text-muted-foreground/70">No shared contexts on this task.</p>}
>
<div class="flex flex-wrap gap-1.5">
<For each={props.task.shareRefs.filter(ref => {
if (ref.kind === "note") {
const label = buildNoteTag(store.notes.find(note => note.id === ref.contextId || note.key === ref.key)?.key || ref.key);
return !isTaskLockedNoteTag(props.task, label);
}
const context = store.contexts.find(existing => existing.id === ref.contextId) || store.contexts.find(existing => existing.key === ref.key);
return !isCurrentUserContextTag(`@${context?.displayName || ref.key}`);
})}>
{(ref) => {
const note = ref.kind === "note"
? store.notes.find(existing => existing.id === ref.contextId || existing.key === ref.key)
: null;
const context = ref.kind === "note"
? null
: store.contexts.find(existing => existing.id === ref.contextId) || store.contexts.find(existing => existing.key === ref.key);
const label = ref.kind === "note"
? buildNoteTag(note?.key || ref.key)
: `@${context?.displayName || ref.key}`;
const policy = getTaskSharePolicy(ref);
return (
<Badge variant="secondary" class="h-6 px-2 text-[0.625rem] gap-1 bg-muted/40 border-border/40">
<span>{label}</span>
<span class="uppercase text-[0.5rem] tracking-wider text-muted-foreground">{policy}</span>
</Badge>
);
}}
</For>
</div>
</Show>
<Show when={canHandoffCollaborativelySharedTask(props.task)}>
<Button
variant="outline"
size="sm"
class="w-full h-8 text-[0.6875rem] justify-center"
onClick={async () => {
const didHandoff = await handoffCollaborativelySharedTask(props.task.id);
if (didHandoff) {
props.onClose();
}
}}
>
Handoff to collaborators
</Button>
</Show>
</div>
<UserSharePicker
taskId={props.task.id}
sharedWith={props.task.sharedWith || []}
tags={props.task.tags || []}
/>
</div>
</div>
</PopoverContent>
</Popover>
{/* Close button (Mobile only) */}
<Button
variant="ghost"
size="sm"
@@ -818,3 +681,139 @@ const MetadataItem: Component<{
);
};
// User Share Picker Component
const UserSharePicker: Component<{
taskId: string;
sharedWith: Array<{ userId: string; access: 'view' | 'edit' }>;
tags: string[];
}> = (props) => {
const [users, setUsers] = createSignal<Array<{ id: string; name: string }>>([]);
const [selectedUserId, setSelectedUserId] = createSignal<string>("");
const [loading, setLoading] = createSignal(true);
// Get matching ShareRules for this task
const matchingRules = () => {
const currentUserId = pb.authStore.model?.id;
return store.shareRules.filter(rule => {
if (rule.ownerId !== currentUserId) return false;
if (rule.type === 'all') return true;
if (rule.type === 'tag' && props.tags.includes(rule.tagName!)) return true;
return false;
});
};
const getUserName = (userId: string) => {
return users().find(u => u.id === userId)?.name || userId;
};
// Fetch users on mount
createEffect(async () => {
try {
const records = await pb.collection('users').getFullList({
fields: 'id,name'
});
setUsers(records.map(r => ({ id: r.id, name: r.name || r.email || r.id })));
} catch (err) {
console.error("Failed to load users:", err);
} finally {
setLoading(false);
}
});
const availableUsers = () => {
const sharedUserIds = props.sharedWith.map(s => s.userId);
const currentUserId = pb.authStore.model?.id;
return users().filter(u => !sharedUserIds.includes(u.id) && u.id !== currentUserId);
};
return (
<>
<div class="flex gap-1 mb-2">
<Select<{ id: string; name: string }>
value={users().find(u => u.id === selectedUserId()) || null}
onChange={(user) => setSelectedUserId(user?.id || "")}
options={availableUsers()}
optionValue="id"
optionTextValue="name"
placeholder={loading() ? "Loading..." : "Select user..."}
itemComponent={(itemProps: any) => (
<SelectItem item={itemProps.item}>{itemProps.item.rawValue.name}</SelectItem>
)}
>
<SelectTrigger class="flex-1 h-8 text-xs">
<SelectValue<{ id: string; name: string }>>
{(state) => state.selectedOption()?.name || "Select user..."}
</SelectValue>
</SelectTrigger>
<SelectContent />
</Select>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
disabled={!selectedUserId()}
onClick={async () => {
if (selectedUserId()) {
await shareTask(props.taskId, selectedUserId(), 'edit');
setSelectedUserId("");
}
}}
>
<Share2 size={14} />
</Button>
</div>
<For each={props.sharedWith}>
{(share) => (
<div class="flex items-center justify-between py-1 px-2 rounded-lg bg-muted/30 mb-1">
<span class="text-xs truncate flex-1">{getUserName(share.userId)}</span>
<div class="flex gap-1">
<Button
variant="ghost"
size="icon"
class="h-6 w-6 hover:bg-amber-500/10 hover:text-amber-600"
title="Split - Give them an independent copy"
onClick={() => splitTask(props.taskId, share.userId)}
>
<GitBranch size={12} />
</Button>
<Button
variant="ghost"
size="icon"
class="h-6 w-6 hover:bg-destructive/10 hover:text-destructive"
title="Revoke access"
onClick={() => revokeShare(props.taskId, share.userId)}
>
<UserMinus size={12} />
</Button>
</div>
</div>
)}
</For>
{/* ShareRule-based sharing */}
{matchingRules().length > 0 && (
<div class="mt-3 pt-3 border-t border-border/30">
<div class="text-[9px] font-bold uppercase tracking-widest text-muted-foreground/40 mb-2 px-1">Automatic Rule Shares</div>
<For each={matchingRules()}>
{(rule) => (
<div class="flex items-center gap-2 py-1.5 px-2 rounded-lg bg-muted/10 mb-1 border border-border/5">
<div class="shrink-0 text-muted-foreground/40">
{rule.type === 'tag' ? <Tag size={10} /> : <Settings size={10} />}
</div>
<div class="flex flex-col min-w-0">
<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">
{rule.type === 'all' ? 'All Tasks Rule' : `Matched Tag: ${rule.tagName}`}
</span>
</div>
</div>
)}
</For>
</div>
)}
</>
);
};
+28 -123
View File
@@ -4,16 +4,11 @@ import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import TaskList from "@tiptap/extension-task-list";
import { CustomTaskItem } from "./CustomTaskItem";
import { MultiTaskList } from "./MultiTaskList.tsx";
import { MultiTaskItem } from "./MultiTaskItem";
import { CustomImage as Image } from "@/lib/extensions/image";
import Image from "@tiptap/extension-image";
import Underline from "@tiptap/extension-underline";
import { X } from "lucide-solid";
import { cn } from "@/lib/utils";
import { store, activeTaskId, activeNoteId, uploadTaskAttachment, uploadNoteAttachment } from "@/store";
import { convertImageToWebpFile } from "@/lib/utils";
import { toast } from "solid-sonner";
import type { Editor } from "@tiptap/core";
import { store } from "@/store";
import { SlashCommands } from "@/lib/slash-command";
import { suggestion } from "@/lib/slash-renderer";
@@ -31,10 +26,6 @@ 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";
import { FileViewer } from "@/lib/extensions/file-viewer";
import { Video } from "@/lib/extensions/video";
import { FileAttachment } from "@/lib/extensions/file-attachment";
import { MediaUpload } from "@/lib/extensions/media-upload";
interface TaskEditorProps {
content?: string;
@@ -60,51 +51,6 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
setZoomOffset({ x: 0, y: 0 });
};
const handleFileUploads = async (editor: Editor, files: File[]) => {
const taskId = activeTaskId();
const noteId = activeNoteId();
if (!taskId && !noteId) {
toast.error("Cannot upload files outside of a task or note.");
return;
}
for (const file of files) {
const isImage = file.type.startsWith('image/');
const isVideo = file.type.startsWith('video/');
toast.promise(
(async () => {
let fileToUpload = file;
if (isImage) {
fileToUpload = await convertImageToWebpFile(file);
}
if (taskId) {
return uploadTaskAttachment(taskId, fileToUpload);
} else {
return uploadNoteAttachment(noteId!, fileToUpload);
}
})(),
{
loading: `Uploading ${file.name}...`,
success: ({ url, filename }) => {
if (isImage) {
// @ts-ignore
editor.chain().focus().setImage({ src: url, filename }).run();
} else if (isVideo) {
editor.chain().focus().setVideo({ src: url, filename }).run();
} else {
editor.chain().focus().insertFileAttachment({ src: url, filename, filesize: file.size.toString() }).run();
}
return `${file.name} uploaded.`;
},
error: `Failed to upload ${file.name}.`
}
);
}
};
const editor = createTiptapEditor(() => ({
element: editorRef!,
extensions: [
@@ -134,17 +80,6 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
class: "flex gap-2 items-start my-1",
},
}),
MultiTaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2",
},
}),
MultiTaskItem.configure({
nested: true,
HTMLAttributes: {
class: "flex gap-2 items-start my-1",
},
}),
SlashCommands.configure({
suggestion,
}),
@@ -224,10 +159,6 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
color: "var(--primary)",
width: 2,
}),
FileViewer,
Video,
FileAttachment,
MediaUpload,
],
// Use untrack so the editor doesn't re-initialize when props.content changes
content: untrack(() => props.content) || "",
@@ -236,27 +167,6 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
props.onUpdate(editor.getHTML());
},
editorProps: {
handlePaste: (_view, event) => {
const items = Array.from(event.clipboardData?.items || []);
const files = items
.map(item => item.getAsFile())
.filter((file): file is File => file !== null);
if (files.length > 0) {
event.preventDefault();
handleFileUploads(editor()!, files);
return true;
}
return false;
},
handleDrop: (_view, event, _slice, moved) => {
if (!moved && event.dataTransfer?.files?.length) {
event.preventDefault();
handleFileUploads(editor()!, Array.from(event.dataTransfer.files));
return true;
}
return false;
},
attributes: {
class: cn(
"prose prose-sm dark:prose-invert max-w-none focus:outline-none min-h-[150px]",
@@ -275,13 +185,10 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
// 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",
"[&_ul[data-type='multiTaskList']]:list-none [&_ul[data-type='multiTaskList']]:p-0",
"[&_li[data-type='multiTaskItem']]:flex [&_li[data-type='multiTaskItem']]:items-start",
"[&_.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
"[&>.file-viewer-wrapper:first-child]:mt-0 [&>.file-viewer-wrapper:first-child]:border-t-0 [&>.file-viewer-wrapper:first-child]:border-x-0 [&>.file-viewer-wrapper:first-child]:rounded-none [&>.file-viewer-wrapper:first-child]:bg-transparent [&>.file-viewer-wrapper:first-child]:h-[80vh]",
props.class
),
},
@@ -332,35 +239,33 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
// Handle full screen images as before
return (
<div class="w-full h-full flex flex-col">
<div class="w-full flex-1 flex flex-col">
<div
class="w-full h-8 cursor-pointer group flex items-center justify-center shrink-0 transition-all"
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">
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();
}
}}
/>
<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)}>
-512
View File
@@ -1,512 +0,0 @@
import { type Component, createEffect, createSignal, For, onCleanup, Show } from "solid-js";
import { Search, X, Tag, ArrowUpCircle, Clock, Minus, Save, Trash2, Bookmark, User, Star, SlidersHorizontal } 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";
import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select";
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "./ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { useTheme } from "./ThemeProvider";
import { getThemeAdjustedColor } from "@/lib/colors";
export const TaskSearchPopover: Component = () => {
const { resolvedTheme } = useTheme();
const [isOpen, setIsOpen] = createSignal(false);
const [isExpanded, setIsExpanded] = createSignal(false);
const [isTagOpen, setIsTagOpen] = createSignal(false);
const [tagSearch, setTagSearch] = createSignal("");
const [newTemplateName, setNewTemplateName] = createSignal("");
const [isSaving, setIsSaving] = createSignal(false);
let triggerRef: HTMLDivElement | undefined;
let panelRef: HTMLDivElement | undefined;
let searchInputRef: HTMLInputElement | undefined;
const hasActiveFilters = () => {
const f = store.filter;
return (f.query || "") !== ""
|| (f.tags || []).length > 0
|| f.editedToday
|| f.ownedByMe
|| f.starred
|| f.priorityMin !== 1
|| f.priorityMax !== 10
|| f.urgencyMin !== 1
|| f.urgencyMax !== 10;
};
const hasExpandedFilters = () => {
const f = store.filter;
return (f.tags || []).length > 0
|| f.editedToday
|| f.ownedByMe
|| f.starred
|| f.priorityMin !== 1
|| f.priorityMax !== 10
|| f.urgencyMin !== 1
|| f.urgencyMax !== 10;
};
const allTags = () => store.tagDefinitions.map(d => d.name).sort();
const closePanel = () => {
setIsOpen(false);
setIsExpanded(false);
setIsSaving(false);
};
createEffect(() => {
if (!isOpen()) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node | null;
if (!target) return;
if (triggerRef?.contains(target)) return;
if (panelRef?.contains(target)) return;
closePanel();
};
document.addEventListener("pointerdown", handlePointerDown);
onCleanup(() => document.removeEventListener("pointerdown", handlePointerDown));
});
return (
<>
<div ref={triggerRef} class="fixed top-4 right-5 sm:right-6 z-[60]">
<Button
variant="ghost"
size="icon"
class={cn(
"bg-background/80 backdrop-blur-sm border border-border hover:bg-muted shadow-sm transition-all duration-300",
hasActiveFilters() && "border-primary/50 ring-1 ring-primary/20",
isOpen() && "bg-muted ring-1 ring-border"
)}
title="Search & Filter Tasks"
onClick={() => {
const next = !isOpen();
setIsOpen(next);
if (!next) {
setIsExpanded(false);
setIsSaving(false);
}
}}
>
<Search
size={18}
class={cn(
"transition-transform duration-300",
isOpen() && "scale-110",
hasActiveFilters() && "text-primary"
)}
/>
</Button>
</div>
<Show when={isOpen()}>
<div ref={panelRef} class="fixed top-[4.25rem] left-1/2 -translate-x-1/2 z-[95] w-[min(calc(100vw-2rem),32rem)]">
<div class="overflow-hidden rounded-2xl border border-border/70 bg-background/95 shadow-2xl backdrop-blur-md">
<div class="flex items-center gap-2 p-2">
<div class="relative flex-1 group">
<Search size={16} class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground/50 group-focus-within:text-primary transition-colors" />
<input
ref={(el) => {
searchInputRef = el;
if (el) setTimeout(() => el.focus(), 50);
}}
type="text"
placeholder="Search tasks, descriptions, tags..."
value={store.filter.query}
onInput={(e) => setFilter({ query: e.currentTarget.value })}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
closePanel();
}
}}
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>
<Button
variant="ghost"
size="icon"
class={cn(
"h-10 w-10 rounded-xl border border-border/50 bg-muted/30 hover:bg-muted/60 shrink-0",
(isExpanded() || hasExpandedFilters()) && "border-primary/30 bg-primary/5 text-primary"
)}
title="More filters"
onClick={() => setIsExpanded(!isExpanded())}
>
<SlidersHorizontal size={16} />
</Button>
<Button
variant="ghost"
size="sm"
class="h-10 rounded-xl shrink-0 px-3 text-xs font-bold uppercase tracking-wide"
title="Clear search"
disabled={!store.filter.query}
onClick={() => {
setFilter({ query: "" });
searchInputRef?.focus();
}}
>
Clear
</Button>
</div>
<Show when={isExpanded()}>
<>
<div class="px-4 py-3 border-t border-border/50 bg-muted/20 flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-xs font-bold uppercase tracking-widest text-muted-foreground flex items-center gap-2 mr-1">
<SlidersHorizontal size={12} />
Filters
</h3>
<button
onClick={() => setFilter({ editedToday: !store.filter.editedToday })}
class={cn(
"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"
)}
>
<div class={cn("w-1.5 h-1.5 rounded-full", store.filter.editedToday ? "bg-primary" : "bg-muted-foreground/30")} />
Edited Today
</button>
<button
onClick={() => setFilter({ ownedByMe: !store.filter.ownedByMe })}
class={cn(
"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.ownedByMe
? "bg-primary/10 text-primary border-primary/20"
: "bg-muted/50 text-muted-foreground border-transparent hover:bg-muted"
)}
>
<User size={10} class={cn(store.filter.ownedByMe ? "text-primary" : "text-muted-foreground/50")} />
Mine
</button>
<button
onClick={() => setFilter({ starred: !store.filter.starred })}
class={cn(
"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.starred
? "bg-amber-500/10 text-amber-500 border-amber-500/20"
: "bg-muted/50 text-muted-foreground border-transparent hover:bg-muted"
)}
>
<Star size={10} class={cn(store.filter.starred ? "fill-amber-500 text-amber-500" : "text-muted-foreground/50")} />
Starred
</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();
closePanel();
}}
title="Load all completed tasks"
>
Load All
</Button>
<Show when={hasActiveFilters()}>
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground hover:text-destructive"
onClick={clearFilter}
>
Clear
</Button>
</Show>
</div>
</div>
<div class="max-h-[min(70vh,40rem)] overflow-y-auto">
<div class="p-4 space-y-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div class="space-y-2.5">
<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()}
onChange={(val) => val && setFilter({ priorityMin: parseInt(val) })}
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
<div class="flex items-center gap-2 justify-center">
<ArrowUpCircle size={14} class="text-muted-foreground/70" />
<span>Min: {store.filter.priorityMin}</span>
</div>
</SelectTrigger>
<SelectContent />
</Select>
<div class="w-px h-4 bg-border/50" />
<Select
value={store.filter.priorityMax.toString()}
onChange={(val) => val && setFilter({ priorityMax: parseInt(val) })}
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
<div class="flex items-center gap-2 justify-center">
<span>Max: {store.filter.priorityMax}</span>
</div>
</SelectTrigger>
<SelectContent />
</Select>
</div>
</div>
<div class="space-y-2.5">
<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()}
onChange={(val) => val && setFilter({ urgencyMin: parseInt(val) })}
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
<div class="flex items-center gap-2 justify-center">
<Clock size={14} class="text-muted-foreground/70" />
<span>Min: {store.filter.urgencyMin}</span>
</div>
</SelectTrigger>
<SelectContent />
</Select>
<div class="w-px h-4 bg-border/50" />
<Select
value={store.filter.urgencyMax.toString()}
onChange={(val) => val && setFilter({ urgencyMax: parseInt(val) })}
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
<div class="flex items-center gap-2 justify-center">
<span>Max: {store.filter.urgencyMax}</span>
</div>
</SelectTrigger>
<SelectContent />
</Select>
</div>
</div>
</div>
<div class="space-y-2.5">
<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-[0.5625rem] lowercase font-medium opacity-50 tracking-normal text-right">
Click tag name to toggle exclude<br />
Click X to remove
</span>
</label>
<div class="flex flex-wrap gap-2 min-h-[44px] p-2 bg-muted/10 border border-dashed border-border/60 rounded-xl">
<For each={store.filter.tags}>
{(tag) => (
<Badge
variant="secondary"
class={cn(
"h-7 px-2.5 rounded-lg text-xs gap-2 transition-all border shadow-sm",
tag.excluded
? "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20 hover:bg-red-500/20"
: "bg-indigo-500/10 text-indigo-600 dark:text-indigo-300 border-indigo-500/20 hover:bg-indigo-500/20"
)}
style={!tag.excluded ? (() => {
const def = store.tagDefinitions.find(d => d.name === tag.name);
const color = getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme());
return color ? {
"background-color": `${color}15`,
"color": color,
"border-color": `${color}30`
} : {};
})() : {}}
>
<div
class="flex items-center gap-2 cursor-pointer h-full"
onClick={() => {
setFilter({
tags: store.filter.tags.map(t =>
t.name === tag.name ? { ...t, excluded: !t.excluded } : t
)
});
}}
>
<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>
</div>
<button
class="p-0.5 hover:bg-foreground/10 rounded transition-colors"
onClick={(e) => {
e.stopPropagation();
setFilter({ tags: store.filter.tags.filter(t => t.name !== tag.name) });
}}
>
<X size={10} />
</button>
</Badge>
)}
</For>
<Popover open={isTagOpen()} onOpenChange={setIsTagOpen}>
<PopoverTrigger 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>
</PopoverTrigger>
<PopoverContent class="w-[200px] p-0" align="start">
<Command>
<CommandInput
placeholder="Search tags..."
value={tagSearch()}
onValueChange={setTagSearch}
/>
<CommandList>
<Show when={allTags().filter(t => {
if (t.startsWith("_")) return false;
const search = tagSearch().toLowerCase();
if (!search && (t.startsWith("@") || t.startsWith("#"))) return false;
return t.toLowerCase().includes(search);
}).length === 0}>
<CommandEmpty>No tags found.</CommandEmpty>
</Show>
<CommandGroup>
<For each={allTags().filter(t => {
if (t.startsWith("_")) return false;
const search = tagSearch().toLowerCase();
if (!search && (t.startsWith("@") || t.startsWith("#"))) return false;
return t.toLowerCase().includes(search);
})}>
{(tag) => (
<CommandItem
value={tag}
onSelect={() => {
if (!store.filter.tags.some(t => t.name === tag)) {
setFilter({ tags: [...store.filter.tags, { name: tag, excluded: false }] });
}
setIsTagOpen(false);
setTagSearch("");
}}
>
{tag}
</CommandItem>
)}
</For>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
</div>
<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-[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-[0.5625rem] font-bold uppercase tracking-wider text-primary hover:underline flex items-center gap-1"
>
<Save size={10} />
Save Current
</button>
</Show>
</div>
<Show when={isSaving()}>
<div class="flex items-center gap-2 animate-in fade-in slide-in-from-top-1">
<input
type="text"
placeholder="Template name..."
value={newTemplateName()}
onInput={(e) => setNewTemplateName(e.currentTarget.value)}
class="flex-1 bg-muted/30 border border-primary/20 rounded-lg px-3 py-1.5 text-xs outline-none focus:ring-1 focus:ring-primary/30"
onKeyDown={(e) => {
if (e.key === "Enter" && newTemplateName().trim()) {
saveFilterTemplate(newTemplateName().trim());
setNewTemplateName("");
setIsSaving(false);
} else if (e.key === "Escape") {
setIsSaving(false);
}
}}
/>
<Button
size="sm"
class="h-8 px-3"
disabled={!newTemplateName().trim()}
onClick={() => {
saveFilterTemplate(newTemplateName().trim());
setNewTemplateName("");
setIsSaving(false);
}}
>
Save
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
onClick={() => setIsSaving(false)}
>
<X size={14} />
</Button>
</div>
</Show>
<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-[0.625rem] text-muted-foreground uppercase tracking-widest">No saved filters yet</p>
</div>
}>
{(template) => (
<div class="group flex items-center justify-between p-2 rounded-xl bg-muted/20 border border-border/30 hover:bg-muted/40 transition-all">
<button
class="flex-1 text-left px-1 min-w-0"
onClick={() => applyFilterTemplate(template.id)}
>
<div class="flex items-center gap-2">
<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" />
</Show>
<Show when={template.filter.tags.length > 0}>
<div class="w-1 h-1 rounded-full bg-indigo-500" />
</Show>
<Show when={template.filter.priorityMin > 1 || template.filter.priorityMax < 10}>
<div class="w-1 h-1 rounded-full bg-amber-500" />
</Show>
</div>
</div>
</button>
<button
class="opacity-0 group-hover:opacity-100 p-1.5 hover:bg-destructive/10 hover:text-destructive rounded-lg transition-all"
onClick={(e) => {
e.stopPropagation();
removeFilterTemplate(template.id);
}}
>
<Trash2 size={12} />
</button>
</div>
)}
</For>
</div>
</div>
</div>
</>
</Show>
</div>
</div>
</Show>
</>
);
};
-208
View File
@@ -1,208 +0,0 @@
import { type Component, createSignal, createEffect, For } from "solid-js";
import {
Bold,
Italic,
Underline,
Highlighter,
AlignLeft,
AlignCenter,
AlignRight,
Heading1,
Heading2,
Heading3,
Heading,
Eraser,
ListTodo,
Grid3X3,
AlignJustify
} from "lucide-solid";
import { cn } from "@/lib/utils";
import type { Editor } from "@tiptap/core";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
interface GenericAction {
label: string;
icon: Component;
isActive?: () => boolean;
command: () => void;
iconClass?: string;
}
interface FormatGroup {
name: string;
actions: GenericAction[];
collapseOnMobile?: boolean;
mobileIcon?: Component;
}
export const TextBubbleMenu: Component<{ editor: Editor }> = (props) => {
// Track state of active styles so we can highlight the buttons
const [activeFormats, setActiveFormats] = createSignal<Record<string, boolean>>({});
createEffect(() => {
const updateState = () => {
const editor = props.editor;
if (!editor) return;
setActiveFormats({
bold: editor.isActive('bold'),
italic: editor.isActive('italic'),
underline: editor.isActive('underline'),
highlight: editor.isActive('highlight'),
alignLeft: editor.isActive({ textAlign: 'left' }),
alignCenter: editor.isActive({ textAlign: 'center' }),
alignRight: editor.isActive({ textAlign: 'right' }),
h1: editor.isActive('heading', { level: 1 }),
h2: editor.isActive('heading', { level: 2 }),
h3: editor.isActive('heading', { level: 3 }),
checklist: editor.isActive('taskList'),
multiChecklist: editor.isActive('multiTaskList'),
table: editor.isActive('table'),
});
};
props.editor.on("selectionUpdate", updateState);
props.editor.on("transaction", updateState);
return () => {
props.editor.off("selectionUpdate", updateState);
props.editor.off("transaction", updateState);
};
});
const groups: FormatGroup[] = [
{
name: "Style",
actions: [
{ label: "Bold", icon: () => <Bold size={16} />, isActive: () => activeFormats().bold, command: () => props.editor.chain().focus().toggleBold().run() },
{ label: "Italic", icon: () => <Italic size={16} />, isActive: () => activeFormats().italic, command: () => props.editor.chain().focus().toggleItalic().run() },
{ label: "Underline", icon: () => <Underline size={16} />, isActive: () => activeFormats().underline, command: () => props.editor.chain().focus().toggleUnderline().run() },
{ label: "Highlight", icon: () => <Highlighter size={16} />, isActive: () => activeFormats().highlight, command: () => props.editor.chain().focus().toggleHighlight().run() },
]
},
{
name: "Headings",
collapseOnMobile: true,
mobileIcon: () => <Heading size={16} />,
actions: [
{ label: "Heading 1", icon: () => <Heading1 size={16} />, isActive: () => activeFormats().h1, command: () => props.editor.chain().focus().toggleHeading({ level: 1 }).run() },
{ label: "Heading 2", icon: () => <Heading2 size={16} />, isActive: () => activeFormats().h2, command: () => props.editor.chain().focus().toggleHeading({ level: 2 }).run() },
{ label: "Heading 3", icon: () => <Heading3 size={16} />, isActive: () => activeFormats().h3, command: () => props.editor.chain().focus().toggleHeading({ level: 3 }).run() },
]
},
{
name: "Alignment",
collapseOnMobile: true,
mobileIcon: () => <AlignJustify size={16} />,
actions: [
{ label: "Align Left", icon: () => <AlignLeft size={16} />, isActive: () => activeFormats().alignLeft, command: () => props.editor.chain().focus().setTextAlign('left').run() },
{ label: "Align Center", icon: () => <AlignCenter size={16} />, isActive: () => activeFormats().alignCenter, command: () => props.editor.chain().focus().setTextAlign('center').run() },
{ label: "Align Right", icon: () => <AlignRight size={16} />, isActive: () => activeFormats().alignRight, command: () => props.editor.chain().focus().setTextAlign('right').run() },
]
},
{
name: "Blocks",
actions: [
{ label: "Checklist", icon: () => <ListTodo size={16} />, isActive: () => activeFormats().checklist, command: () => props.editor.chain().focus().toggleTaskList().run() },
{ label: "Multi Checklist", icon: () => <ListTodo size={16} />, isActive: () => activeFormats().multiChecklist, command: () => props.editor.chain().focus().toggleMultiTaskList().run() },
{ label: "Table", icon: () => <Grid3X3 size={16} />, isActive: () => activeFormats().table, command: () => props.editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
]
},
{
name: "Clear",
actions: [
{ label: "Clear Formatting", icon: () => <Eraser size={16} />, command: () => props.editor.chain().focus().clearNodes().unsetAllMarks().run() },
]
}
];
return (
<div
class={cn(
"relative z-[140] w-full bg-background/80 backdrop-blur-md border-b border-border transition-all duration-300 ease-in-out overflow-hidden flex flex-col h-9 opacity-100"
)}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<div class="flex items-center justify-center h-full px-2 max-w-4xl mx-auto w-full">
<div class="flex items-center gap-1">
<For each={groups}>
{(group, groupIdx) => (
<>
<div class={cn("flex items-center gap-0 px-0.5", group.collapseOnMobile ? "hidden sm:flex" : "")}>
<For each={group.actions}>
{(action) => (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
action.command();
}}
class={cn(
"p-1.5 rounded-md transition-colors group relative shrink-0",
action.isActive?.()
? "bg-primary/20 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
action.iconClass
)}
title={action.label}
>
<action.icon />
<span class="absolute top-full left-1/2 -translate-x-1/2 mt-2 px-2 py-1 text-[10px] font-bold bg-foreground text-background rounded opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap z-[200] shadow-lg">
{action.label}
</span>
</button>
)}
</For>
</div>
{group.collapseOnMobile && group.mobileIcon && (
<div class="flex sm:hidden items-center gap-0 px-0.5">
<Popover>
<PopoverTrigger class={cn(
"p-1.5 rounded-md transition-colors group relative shrink-0 text-muted-foreground hover:bg-muted hover:text-foreground",
group.actions.some(a => a.isActive?.()) && "bg-primary/20 text-primary"
)}>
<group.mobileIcon />
</PopoverTrigger>
<PopoverContent class="w-auto p-1 flex items-center gap-1 shadow-xl" align="center">
<For each={group.actions}>
{(action) => (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
action.command();
}}
class={cn(
"p-1.5 rounded-md transition-colors relative shrink-0",
action.isActive?.()
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
action.iconClass
)}
title={action.label}
>
<action.icon />
</button>
)}
</For>
</PopoverContent>
</Popover>
</div>
)}
{groupIdx() < groups.length - 1 && (
<div class="w-[1px] h-4 bg-border/50 mx-1" />
)}
</>
)}
</For>
</div>
</div>
</div>
);
};
+1 -15
View File
@@ -1,5 +1,5 @@
import { createSignal, createEffect, onCleanup, untrack } from "solid-js";
import { store, type Task } from "@/store";
import type { Task } from "@/store";
export function useDelayedSort(
sourceTasks: () => Task[],
@@ -16,20 +16,6 @@ export function useDelayedSort(
const sorted = [...sourceTasks()].sort(sortFn);
const currentDisplayed = untrack(() => displayedTasks());
// Startup hydration loads tasks in stages; animating each reorder causes
// the entire visible list to flash even when only later-stage tasks changed.
if (store.isInitializing) {
if (timer) {
clearTimeout(timer);
timer = undefined;
}
if (shakingTaskIds().length > 0) {
setShakingTaskIds([]);
}
setDisplayedTasks(sorted);
return;
}
// Initial load (though now handled by initial state, kept for safety if it resets)
if (currentDisplayed.length === 0 && sorted.length > 0) {
setDisplayedTasks(sorted);
-17
View File
@@ -1,17 +0,0 @@
import autoAnimate from "@formkit/auto-animate";
import { createEffect } from "solid-js";
import { store } from "@/store";
export function useTaskListAutoAnimate(getElement: () => HTMLElement | undefined) {
let hasInitialized = false;
createEffect(() => {
if (hasInitialized || store.isInitializing) return;
const element = getElement();
if (!element) return;
autoAnimate(element, { duration: 300, easing: "ease-out" });
hasInitialized = true;
});
}
+6 -44
View File
@@ -57,7 +57,6 @@
}
--animate-task-shake: task-shake 0.3s ease-in-out;
--animate-task-quickload-fade: task-quickload-fade 0.2s ease-out forwards;
@keyframes task-shake {
@@ -79,25 +78,15 @@
}
}
@keyframes task-quickload-fade {
from {
--animate-task-enter: task-enter 0.25s ease-out backwards;
@keyframes task-enter {
0% {
opacity: 0;
}
to {
opacity: 1;
}
}
--animate-progress-fast: loading-progress 2s infinite linear;
@keyframes loading-progress {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(300%);
opacity: 1;
}
}
}
@@ -195,31 +184,4 @@
.fade-enter,
.fade-exit-to {
opacity: 0;
}
.mobile-footer-clearance {
padding-bottom: calc(5rem + env(safe-area-inset-bottom, 0px));
}
@media (min-width: 640px) {
.mobile-footer-clearance {
padding-bottom: 2rem;
}
}
/* --- Tiptap Table Selection --- */
.ProseMirror td.selectedCell,
.ProseMirror th.selectedCell {
background: color-mix(in srgb, var(--primary), transparent 90%);
position: relative;
}
.ProseMirror td.selectedCell::after,
.ProseMirror th.selectedCell::after {
content: "";
position: absolute;
inset: -1px;
border: 2px solid var(--primary);
pointer-events: none;
z-index: 10;
}
}
-2
View File
@@ -3,14 +3,12 @@ import './index.css'
import App from './App.tsx'
import { ThemeProvider } from './components/ThemeProvider'
import { Toaster } from './components/ui/sonner'
import { PwaUpdater } from './components/PwaUpdater'
const root = document.getElementById('root')
render(() => {
return (
<ThemeProvider>
<PwaUpdater />
<App />
<Toaster />
</ThemeProvider>
-18
View File
@@ -1,18 +0,0 @@
const rawMode = (import.meta.env.VITE_TASGRID_DATA_MODE || "prod").toLowerCase();
export const TASGRID_DATA_MODE = rawMode === "dev" ? "dev" : "prod";
export const TASGRID_IS_DEV_DATA = TASGRID_DATA_MODE === "dev";
const rawMigrationAdmins = import.meta.env.VITE_TASGRID_MIGRATION_ADMIN_EMAILS || "";
const rawMigrationEnabled = (import.meta.env.VITE_TASGRID_ENABLE_PROD_MIGRATION || "").toLowerCase();
export const POCKETBASE_URL = import.meta.env.VITE_POCKETBASE_URL || "https://pocketbase.ccllc.pro";
export const AUTH_STORE_KEY = TASGRID_IS_DEV_DATA ? "tasgrid_dev_auth" : "tasgrid_auth";
export const STORAGE_KEY_PREFIX = TASGRID_IS_DEV_DATA ? "tasgrid_dev" : "tasgrid";
export const PWA_CACHE_SUFFIX = TASGRID_IS_DEV_DATA ? "dev" : "prod";
export const PROD_MIGRATION_ENABLED = rawMigrationEnabled === "true";
export const PROD_MIGRATION_ADMIN_EMAILS = rawMigrationAdmins
.split(",")
.map((email: string) => email.trim().toLowerCase())
.filter(Boolean);
export const withDataSuffix = (base: string) => TASGRID_IS_DEV_DATA ? `${base}_Dev` : base;
+5 -8
View File
@@ -1,5 +1,3 @@
import { withDataSuffix } from "@/lib/app-config";
export const PRIORITY_OPTIONS = [
{ value: "10", label: "10 - Necessary" },
{ value: "9", label: "9" },
@@ -38,12 +36,11 @@ export const URGENCY_HOURS: Record<number, number> = {
1: 4320
};
export const TASGRID_COLLECTION = withDataSuffix('TasGrid');
export const CONTEXTS_COLLECTION = withDataSuffix('TasGrid_Contexts');
export const NOTES_COLLECTION = withDataSuffix('TasGrid_Notes');
export const TAGS_COLLECTION = CONTEXTS_COLLECTION;
export const SHARE_RULES_COLLECTION = CONTEXTS_COLLECTION;
export const BUCKETS_COLLECTION = CONTEXTS_COLLECTION;
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 - Two weeks" },
-237
View File
@@ -1,237 +0,0 @@
import { Node, mergeAttributes } from '@tiptap/core';
export interface FileAttachmentOptions {
HTMLAttributes: Record<string, any>;
}
export const FileAttachment = Node.create<FileAttachmentOptions>({
name: 'fileAttachment',
group: 'block',
selectable: true,
draggable: true,
addOptions() {
return {
HTMLAttributes: {
class: 'file-attachment-node w-full max-w-sm rounded-lg border border-border bg-muted/30 hover:bg-muted/50 transition-colors my-4 overflow-hidden relative group cursor-pointer shadow-sm',
},
};
},
addAttributes() {
return {
src: {
default: null,
parseHTML: element => element.getAttribute('data-src'),
renderHTML: attributes => {
if (!attributes.src) return {};
return { 'data-src': attributes.src };
},
},
filename: {
default: null,
parseHTML: element => element.getAttribute('data-filename'),
renderHTML: attributes => {
if (!attributes.filename) return {};
return { 'data-filename': attributes.filename };
},
},
filesize: {
default: null,
parseHTML: element => element.getAttribute('data-filesize'),
renderHTML: attributes => {
if (!attributes.filesize) return {};
return { 'data-filesize': attributes.filesize };
},
}
};
},
parseHTML() {
return [
{
tag: 'div[data-type="file-attachment"]',
},
{
tag: 'a.file-attachment-link',
}
];
},
renderHTML({ HTMLAttributes }) {
const filename = HTMLAttributes.filename || '';
const isPdf = filename.toLowerCase().endsWith('.pdf');
if (isPdf) {
return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
'data-type': 'file-attachment',
class: 'file-attachment-wrapper relative w-full my-4 rounded-xl overflow-hidden border border-border flex min-h-[500px] bg-muted/20 group'
}),
['iframe', { src: HTMLAttributes.src, class: 'w-full h-full border-0 absolute inset-0' }]
];
}
return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
'data-type': 'file-attachment',
}),
['a', { href: HTMLAttributes.src, target: '_blank', rel: 'noopener noreferrer', class: 'file-attachment-link flex items-center p-3 gap-3 w-full h-full' },
['div', { class: 'flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-md bg-primary/10 text-primary' },
// SVG for FileText
['svg', { xmlns: 'http://www.w3.org/2000/svg', width: '20', height: '20', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' },
['path', { d: 'M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' }],
['path', { d: 'M14 2v4a2 2 0 0 0 2 2h4' }],
['path', { d: 'M10 9H8' }],
['path', { d: 'M16 13H8' }],
['path', { d: 'M16 17H8' }]
]
],
['div', { class: 'flex flex-col overflow-hidden w-full text-left' },
['span', { class: 'text-sm font-medium truncate w-full text-foreground' }, HTMLAttributes.filename || 'Attachment'],
['span', { class: 'text-xs text-muted-foreground mt-0.5' }, 'View File']
]
]
];
},
addKeyboardShortcuts() {
return {
Backspace: () => {
const { selection } = this.editor.state;
if (!selection.empty) {
const selectedNode = this.editor.state.doc.nodeAt(selection.from);
if (selectedNode?.type.name === this.name) {
return true; // Prevent default deletion
}
}
return false;
},
Delete: () => {
const { selection } = this.editor.state;
if (!selection.empty) {
const selectedNode = this.editor.state.doc.nodeAt(selection.from);
if (selectedNode?.type.name === this.name) {
return true; // Prevent default deletion
}
}
return false;
},
};
},
addNodeView() {
return ({ node, getPos, editor }) => {
const filename = node.attrs.filename || '';
const isPdf = filename.toLowerCase().endsWith('.pdf');
const isTxt = filename.toLowerCase().endsWith('.txt');
const isFramable = isPdf || isTxt;
const wrapper = document.createElement('div');
wrapper.setAttribute('data-type', 'file-attachment');
wrapper.setAttribute('data-src', node.attrs.src);
wrapper.setAttribute('data-filename', node.attrs.filename);
wrapper.setAttribute('data-filesize', node.attrs.filesize);
if (isFramable) {
// Framed mode matching FileViewer
wrapper.classList.add('file-viewer-wrapper', 'relative', 'w-full', 'my-4', 'rounded-xl', 'overflow-hidden', 'border', 'border-border', 'flex', 'bg-muted/10', 'group');
wrapper.style.minHeight = '500px';
const iframe = document.createElement('iframe');
iframe.src = node.attrs.src;
iframe.classList.add('w-full', 'h-full', 'border-0', 'absolute', 'inset-0');
wrapper.appendChild(iframe);
} else {
// Card mode
wrapper.classList.add('file-attachment-node', 'w-full', 'max-w-sm', 'rounded-lg', 'border', 'border-border', 'bg-muted/30', 'hover:bg-muted/50', 'transition-colors', 'my-4', 'overflow-hidden', 'relative', 'group', 'cursor-pointer', 'shadow-sm');
const anchor = document.createElement('a');
anchor.href = node.attrs.src || '#';
anchor.target = '_blank';
anchor.rel = 'noopener noreferrer';
anchor.classList.add('flex', 'items-center', 'p-3', 'gap-3', 'w-full', 'h-full', 'no-underline');
const iconContainer = document.createElement('div');
iconContainer.classList.add('flex-shrink-0', 'flex', 'items-center', 'justify-center', 'w-10', 'h-10', 'rounded-md', 'bg-primary/10', 'text-primary');
iconContainer.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"></path><path d="M14 2v4a2 2 0 0 0 2 2h4"></path><path d="M10 9H8"></path><path d="M16 13H8"></path><path d="M16 17H8"></path></svg>';
const textContainer = document.createElement('div');
textContainer.classList.add('flex', 'flex-col', 'overflow-hidden', 'w-full', 'text-left');
const filenameSpan = document.createElement('span');
filenameSpan.classList.add('text-sm', 'font-medium', 'truncate', 'w-full', 'text-foreground');
filenameSpan.innerText = node.attrs.filename || 'Attachment';
const subTextSpan = document.createElement('span');
subTextSpan.classList.add('text-xs', 'text-muted-foreground', 'mt-0.5');
if (node.attrs.filesize) {
const mb = (parseInt(node.attrs.filesize) / (1024 * 1024)).toFixed(2);
subTextSpan.innerText = `${mb} MB • View File`;
} else {
subTextSpan.innerText = `View File`;
}
textContainer.appendChild(filenameSpan);
textContainer.appendChild(subTextSpan);
anchor.appendChild(iconContainer);
anchor.appendChild(textContainer);
wrapper.appendChild(anchor);
}
// Delete button overlay
const overlay = document.createElement('div');
overlay.classList.add('absolute', 'top-1.5', 'left-1.5', 'flex', '[@media(hover:hover)]:hidden', '[@media(hover:hover)]:group-hover:flex', 'bg-background/90', 'backdrop-blur-sm', 'p-1', 'rounded-lg', 'border', 'border-border', 'shadow-sm', 'z-10');
const deleteBtn = document.createElement('button');
deleteBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground hover:text-destructive transition-colors"><path d="M3 6h18"></path><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path></svg>';
deleteBtn.classList.add('p-1', 'cursor-pointer');
deleteBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
if (typeof getPos === 'function') {
const pos = getPos();
if (typeof pos === 'number') {
editor.commands.deleteRange({ from: pos, to: pos + node.nodeSize });
}
}
};
overlay.appendChild(deleteBtn);
wrapper.appendChild(overlay);
return {
dom: wrapper,
stopEvent: (e) => {
// Stop event propagation if click is exactly on delete button, to prevent anchor following
if (overlay.contains(e.target as unknown as globalThis.Node)) {
return true;
}
return false;
},
ignoreMutation: () => true,
};
};
},
addCommands() {
return {
insertFileAttachment: (options: { src: string, filename: string, filesize?: string }) => ({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: options,
});
},
};
},
});
declare module '@tiptap/core' {
interface Commands<ReturnType> {
fileAttachment: {
insertFileAttachment: (options: { src: string, filename: string, filesize?: string }) => ReturnType;
};
}
}
-73
View File
@@ -1,73 +0,0 @@
import { Extension } from '@tiptap/core';
import { activeTaskId, uploadTaskAttachment, activeNoteId, uploadNoteAttachment } from "@/store";
import { toast } from "solid-sonner";
declare module '@tiptap/core' {
interface Commands<ReturnType> {
fileUpload: {
uploadFile: () => ReturnType;
};
}
}
export const FileUpload = Extension.create({
name: 'fileUpload',
addCommands() {
return {
uploadFile: () => ({ editor }) => {
const taskId = activeTaskId();
const noteId = activeNoteId();
if (!taskId && !noteId) {
toast.error("Cannot upload a file outside of a task or note.");
return false;
}
const input = document.createElement("input");
input.type = "file";
// Accept any file type for generic attachments
input.accept = "*/*";
input.onchange = async () => {
if (input.files?.length) {
const file = input.files[0];
const filesize = file.size.toString();
toast.promise(
(async () => {
if (taskId) {
return uploadTaskAttachment(taskId, file);
} else {
return uploadNoteAttachment(noteId!, file);
}
})(),
{
loading: "Uploading file...",
success: ({ url, filename: pbFilename }) => {
// Insert file attchment UI card
editor.chain()
.focus()
.insertFileAttachment({ src: url, filename: pbFilename, filesize })
.run();
// Manually force a new paragraph at the end so the user can keep typing
// Add paragraph after insertion
editor.chain()
.focus('end')
.insertContent('<p></p>')
.run();
return "File uploaded successfully.";
},
error: "Failed to upload file."
}
);
}
};
input.click();
return true;
},
};
},
});
-157
View File
@@ -1,157 +0,0 @@
import { Node, mergeAttributes } from '@tiptap/core';
import { render } from 'solid-js/web';
import { JobFileSelector } from '@/components/JobFileSelector';
export interface FileViewerOptions {
HTMLAttributes: Record<string, any>;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
fileViewer: {
insertFileViewer: (options?: { src?: string }) => ReturnType;
};
}
}
export const FileViewer = Node.create<FileViewerOptions>({
name: 'fileViewer',
group: 'block',
selectable: true,
draggable: true,
addOptions() {
return {
HTMLAttributes: {
class: 'file-viewer-node group relative',
},
};
},
addAttributes() {
return {
jobId: {
default: null,
parseHTML: element => element.getAttribute('data-job-id'),
renderHTML: attributes => {
if (!attributes.jobId) return {};
return { 'data-job-id': attributes.jobId };
},
},
collectionId: {
default: 'Job_Info_Prod',
parseHTML: element => element.getAttribute('data-collection-id') || 'Job_Info_Prod',
renderHTML: attributes => {
return { 'data-collection-id': attributes.collectionId || 'Job_Info_Prod' };
},
},
filename: {
default: null,
parseHTML: element => element.getAttribute('data-filename'),
renderHTML: attributes => {
if (!attributes.filename) return {};
return { 'data-filename': attributes.filename };
},
},
};
},
parseHTML() {
return [
{
tag: 'div[data-type="file-viewer-wrapper"]',
}
];
},
renderHTML({ HTMLAttributes }) {
const { jobId, collectionId, filename, ...rest } = HTMLAttributes;
let src = '';
if (jobId && filename) {
src = `https://pocketbase.ccllc.pro/api/files/${collectionId}/${jobId}/${filename}`;
}
return ['div', mergeAttributes(this.options.HTMLAttributes, rest, { 'data-type': 'file-viewer-wrapper', class: 'file-viewer-wrapper relative w-full my-4 rounded-xl overflow-hidden border border-border flex min-h-[500px] bg-muted/20' }),
['iframe', { 'data-type': 'file-viewer', src: src, class: 'w-full h-full border-0 absolute inset-0' }]
];
},
addNodeView() {
return ({ node, getPos, editor }) => {
const wrapper = document.createElement('div');
wrapper.classList.add('file-viewer-wrapper', 'relative', 'w-full', 'my-4', 'rounded-xl', 'overflow-hidden', 'border', 'border-border', 'flex', 'bg-muted/10', 'group');
const hasFile = node.attrs.jobId && node.attrs.filename;
if (hasFile) {
// Viewer mode
wrapper.style.minHeight = '500px';
const iframe = document.createElement('iframe');
iframe.src = `https://pocketbase.ccllc.pro/api/files/${node.attrs.collectionId}/${node.attrs.jobId}/${node.attrs.filename}`;
iframe.classList.add('w-full', 'h-full', 'border-0', 'absolute', 'inset-0');
// Delete button overlay
const overlay = document.createElement('div');
overlay.classList.add('absolute', 'top-2', 'left-2', 'flex', '[@media(hover:hover)]:hidden', '[@media(hover:hover)]:group-hover:flex', 'bg-background/80', 'backdrop-blur-sm', 'p-1', 'rounded-lg', 'border', 'border-border', 'shadow-sm', 'z-10');
const deleteBtn = document.createElement('button');
deleteBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground hover:text-destructive transition-colors"><path d="M3 6h18"></path><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path></svg>';
deleteBtn.classList.add('p-1', 'cursor-pointer');
deleteBtn.onclick = (e) => {
e.preventDefault();
if (typeof getPos === 'function') {
const pos = getPos();
if (typeof pos === 'number') {
editor.commands.deleteRange({ from: pos, to: pos + node.nodeSize });
}
}
};
overlay.appendChild(deleteBtn);
wrapper.appendChild(iframe);
wrapper.appendChild(overlay);
return {
dom: wrapper,
stopEvent: () => true,
ignoreMutation: () => true,
};
} else {
render(() => JobFileSelector({
onSelectFile: (jobId: string, filename: string) => {
if (typeof getPos === 'function') {
editor.commands.command(({ tr }) => {
const pos = getPos();
if (typeof pos === 'number') {
tr.setNodeMarkup(pos, undefined, { jobId, collectionId: 'Job_Info_Prod', filename });
return true;
}
return false;
});
}
}
}), wrapper);
return {
dom: wrapper,
stopEvent: () => true,
ignoreMutation: () => true,
};
}
};
},
addCommands() {
return {
insertFileViewer: (options?: { src?: string }) => ({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: options || {},
});
},
};
},
});
-71
View File
@@ -1,71 +0,0 @@
import { Extension } from '@tiptap/core';
import { activeTaskId, uploadTaskAttachment, activeNoteId, uploadNoteAttachment } from "@/store";
import { convertImageToWebpFile } from "@/lib/utils";
import { toast } from "solid-sonner";
declare module '@tiptap/core' {
interface Commands<ReturnType> {
imageUpload: {
uploadImage: () => ReturnType;
};
}
}
export const ImageUpload = Extension.create({
name: 'imageUpload',
addCommands() {
return {
uploadImage: () => ({ editor }) => {
const taskId = activeTaskId();
const noteId = activeNoteId();
if (!taskId && !noteId) {
toast.error("Cannot upload image outside of a task or note.");
return false;
}
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*,.heic,.heif";
input.onchange = async () => {
if (input.files?.length) {
const originalFile = input.files[0];
toast.promise(
(async () => {
const webpFile = await convertImageToWebpFile(originalFile);
if (taskId) {
return uploadTaskAttachment(taskId, webpFile);
} else {
return uploadNoteAttachment(noteId!, webpFile);
}
})(),
{
loading: "Processing & uploading image...",
success: ({ url, filename }) => {
// Insert image
editor.chain()
.focus()
// @ts-ignore
.setImage({ src: url, filename })
.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();
return true;
},
};
},
});
-201
View File
@@ -1,201 +0,0 @@
import Image from "@tiptap/extension-image";
export const CustomImage = Image.extend({
name: "image",
group: "block",
selectable: true,
draggable: true,
addAttributes() {
return {
...this.parent?.(),
src: {
default: null,
},
width: {
default: null,
parseHTML: element => element.getAttribute('width'),
renderHTML: attributes => {
if (!attributes.width) return {};
return { width: attributes.width };
},
},
filename: {
default: null,
parseHTML: element => element.getAttribute('data-filename'),
renderHTML: attributes => {
if (!attributes.filename) return {};
return { 'data-filename': attributes.filename };
},
},
};
},
addCommands() {
return {
setImage: (options: { src: string, alt?: string, title?: string, filename?: string }) => ({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: options,
});
},
};
},
addNodeView() {
return ({ node, getPos, editor }) => {
const wrapper = document.createElement('div');
// Give it position: relative and flex/inline-block to wrap the image tightly
wrapper.classList.add('image-wrapper', 'relative', 'inline-block', 'my-4', 'group', 'max-w-full');
wrapper.style.display = 'block'; // Block level to not mess up flow
wrapper.style.marginInline = 'auto'; // Center if desired, or let it flow
if (node.attrs.width) {
const w = typeof node.attrs.width === 'number' ? `${node.attrs.width}px` : node.attrs.width;
wrapper.style.width = w;
} else {
wrapper.style.width = 'fit-content';
}
const img = document.createElement('img');
Object.entries(node.attrs).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
if (key === 'filename') {
img.setAttribute('data-filename', value);
} else {
img.setAttribute(key, value);
}
}
});
img.classList.add('rounded-lg', 'border', 'border-border', 'max-w-full', 'h-auto', 'cursor-default');
img.style.width = '100%';
// Resize handle
const resizer = document.createElement('div');
// Adding style.touchAction = 'none' to prevent mobile browser scrolling when dragging
resizer.style.touchAction = 'none';
resizer.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-white"><path d="m21 21-6-6m6 6v-4.8m0 4.8h-4.8M3 16.2V21m0 0h4.8M3 21l6-6M21 7.8V3m0 0h-4.8M21 3l-6 6M3 7.8V3m0 0h4.8M3 3l6 6"/></svg>';
resizer.classList.add('absolute', 'bottom-2', 'right-2', 'w-6', 'h-6', 'bg-black/50', 'hover:bg-black/80', 'backdrop-blur-sm', 'cursor-nwse-resize', 'transition-opacity', 'z-20', 'rounded-md', 'flex', 'items-center', 'justify-center', 'shadow-sm');
resizer.contentEditable = 'false';
let isResizing = false;
let startX = 0;
let startWidth = 0;
const initResize = (clientX: number) => {
isResizing = true;
startX = clientX;
startWidth = wrapper.offsetWidth;
};
const doResize = (clientX: number, minW: number) => {
if (!isResizing) return;
const dx = clientX - startX;
const newWidth = Math.max(minW, startWidth + dx);
wrapper.style.width = `${newWidth}px`;
};
const stopResize = () => {
if (!isResizing) return;
isResizing = false;
if (typeof getPos === 'function') {
const pos = getPos();
if (typeof pos === 'number') {
editor.commands.command(({ tr }) => {
tr.setNodeMarkup(pos, undefined, {
...node.attrs,
width: wrapper.offsetWidth,
});
return true;
});
}
}
};
resizer.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
initResize(e.clientX);
const onMouseMove = (ev: MouseEvent) => {
ev.preventDefault();
doResize(ev.clientX, 100);
};
const onMouseUp = (ev: MouseEvent) => {
ev.preventDefault();
stopResize();
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
resizer.addEventListener('touchstart', (e) => {
e.preventDefault();
e.stopPropagation();
initResize(e.touches[0].clientX);
const onTouchMove = (ev: TouchEvent) => {
ev.preventDefault();
doResize(ev.touches[0].clientX, 100);
};
const onTouchEnd = () => {
// touchend doesn't have touches[0] usually
stopResize();
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
document.removeEventListener('touchcancel', onTouchEnd);
};
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', onTouchEnd);
document.addEventListener('touchcancel', onTouchEnd);
}, { passive: false });
// Delete button overlay
const overlay = document.createElement('div');
overlay.classList.add('absolute', 'top-2', 'left-2', 'flex', '[@media(hover:hover)]:hidden', '[@media(hover:hover)]:group-hover:flex', 'bg-background/80', 'backdrop-blur-sm', 'p-1', 'rounded-lg', 'border', 'border-border', 'shadow-sm', 'z-10');
const deleteBtn = document.createElement('button');
deleteBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground hover:text-destructive transition-colors"><path d="M3 6h18"></path><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path></svg>';
deleteBtn.classList.add('p-1', 'cursor-pointer');
deleteBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
if (typeof getPos === 'function') {
const pos = getPos();
if (typeof pos === 'number') {
editor.commands.deleteRange({ from: pos, to: pos + node.nodeSize });
}
}
};
overlay.appendChild(deleteBtn);
wrapper.appendChild(img);
wrapper.appendChild(resizer);
wrapper.appendChild(overlay);
return {
dom: wrapper,
stopEvent: () => true,
ignoreMutation: () => true,
};
};
},
});
// @ts-ignore
declare module '@tiptap/core' {
interface Commands<ReturnType> {
// @ts-ignore
image: {
setImage: (options: { src: string, alt?: string, title?: string, filename?: string }) => ReturnType;
};
}
}
-79
View File
@@ -1,79 +0,0 @@
import { Extension } from '@tiptap/core';
import { activeTaskId, uploadTaskAttachment, activeNoteId, uploadNoteAttachment } from "@/store";
import { convertImageToWebpFile } from "@/lib/utils";
import { toast } from "solid-sonner";
declare module '@tiptap/core' {
interface Commands<ReturnType> {
mediaUpload: {
uploadMedia: () => ReturnType;
};
}
}
export const MediaUpload = Extension.create({
name: 'mediaUpload',
addCommands() {
return {
uploadMedia: () => ({ editor }) => {
const taskId = activeTaskId();
const noteId = activeNoteId();
if (!taskId && !noteId) {
toast.error("Cannot upload media outside of a task or note.");
return false;
}
const input = document.createElement("input");
input.type = "file";
input.onchange = async () => {
if (input.files?.length) {
const originalFile = input.files[0];
const isImage = originalFile.type.startsWith('image/');
const isVideo = originalFile.type.startsWith('video/');
toast.promise(
(async () => {
let fileToUpload = originalFile;
if (isImage && !originalFile.type.includes('svg') && !originalFile.type.includes('gif')) {
fileToUpload = await convertImageToWebpFile(originalFile);
}
if (taskId) {
return uploadTaskAttachment(taskId, fileToUpload);
} else {
return uploadNoteAttachment(noteId!, fileToUpload);
}
})(),
{
loading: "Processing & uploading media...",
success: ({ url, filename }) => {
if (isImage) {
// @ts-ignore
editor.chain().focus().setImage({ src: url, filename }).run();
} else if (isVideo) {
editor.chain().focus().setVideo({ src: url, filename }).run();
} else {
editor.chain().focus().insertFileAttachment({ src: url, filename, filesize: originalFile.size.toString() }).run();
}
// Manually force a new paragraph at the end so the user can keep typing
editor.chain()
.focus('end')
.insertContent('<p></p>')
.run();
return "Media uploaded.";
},
error: "Failed to upload media."
}
);
}
};
input.click();
return true;
},
};
},
});
-69
View File
@@ -1,69 +0,0 @@
import { Extension } from '@tiptap/core';
import { activeTaskId, uploadTaskAttachment, activeNoteId, uploadNoteAttachment } from "@/store";
import { toast } from "solid-sonner";
declare module '@tiptap/core' {
interface Commands<ReturnType> {
videoUpload: {
uploadVideo: () => ReturnType;
};
}
}
export const VideoUpload = Extension.create({
name: 'videoUpload',
addCommands() {
return {
uploadVideo: () => ({ editor }) => {
const taskId = activeTaskId();
const noteId = activeNoteId();
if (!taskId && !noteId) {
toast.error("Cannot upload video outside of a task or note.");
return false;
}
const input = document.createElement("input");
input.type = "file";
input.accept = "video/*";
input.onchange = async () => {
if (input.files?.length) {
const originalFile = input.files[0];
toast.promise(
(async () => {
// For video, we don't necessarily convert to webp. We just upload it directly.
if (taskId) {
return uploadTaskAttachment(taskId, originalFile);
} else {
return uploadNoteAttachment(noteId!, originalFile);
}
})(),
{
loading: "Uploading video...",
success: ({ url, filename }) => {
// Insert video
editor.chain()
.focus()
.setVideo({ src: url, filename })
.run();
// Manually force a new paragraph at the end so the user can keep typing
editor.chain()
.focus('end')
.insertContent('<p></p>')
.run();
return "Video uploaded.";
},
error: "Failed to upload video."
}
);
}
};
input.click();
return true;
},
};
},
});
-253
View File
@@ -1,253 +0,0 @@
import { Node, mergeAttributes } from '@tiptap/core';
export interface VideoOptions {
HTMLAttributes: Record<string, any>;
}
export const Video = Node.create<VideoOptions>({
name: 'video',
group: 'block',
// Allows it to be selected and manipulated as a single block
selectable: true,
draggable: true,
addOptions() {
return {
HTMLAttributes: {
class: 'w-full rounded-lg border border-border/50 shadow-sm overflow-hidden my-4',
controls: true,
},
};
},
addAttributes() {
return {
src: {
default: null,
},
width: {
default: null,
parseHTML: element => element.getAttribute('width'),
renderHTML: attributes => {
if (!attributes.width) return {};
return { width: attributes.width };
},
},
filename: {
default: null,
parseHTML: element => element.getAttribute('data-filename'),
renderHTML: attributes => {
if (!attributes.filename) return {};
return { 'data-filename': attributes.filename };
},
},
};
},
addCommands() {
return {
setVideo: (options: { src: string, filename?: string }) => ({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: options,
});
},
};
},
parseHTML() {
return [
{
tag: 'video',
},
];
},
renderHTML({ HTMLAttributes }) {
return ['video', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)];
},
addKeyboardShortcuts() {
return {
Backspace: () => {
const { selection } = this.editor.state;
if (!selection.empty) {
const selectedNode = this.editor.state.doc.nodeAt(selection.from);
if (selectedNode?.type.name === this.name) {
return true; // Prevent default deletion
}
}
return false;
},
Delete: () => {
const { selection } = this.editor.state;
if (!selection.empty) {
const selectedNode = this.editor.state.doc.nodeAt(selection.from);
if (selectedNode?.type.name === this.name) {
return true; // Prevent default deletion
}
}
return false;
},
};
},
addNodeView() {
return ({ node, getPos, editor }) => {
const wrapper = document.createElement('div');
wrapper.classList.add('video-wrapper', 'relative', 'inline-block', 'my-4', 'group', 'max-w-full');
wrapper.style.display = 'block';
wrapper.style.marginInline = 'auto'; // Center if desired
if (node.attrs.width) {
const w = typeof node.attrs.width === 'number' ? `${node.attrs.width}px` : node.attrs.width;
wrapper.style.width = w;
} else {
wrapper.style.width = '100%';
}
const video = document.createElement('video');
Object.entries(node.attrs).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
if (key === 'filename') {
video.setAttribute('data-filename', value);
} else {
video.setAttribute(key, value);
}
}
});
// Apply default attributes if not present
if (!video.hasAttribute('controls')) {
video.setAttribute('controls', 'true');
}
video.classList.add('w-full', 'rounded-lg', 'border', 'border-border/50', 'shadow-sm', 'overflow-hidden');
video.style.width = '100%';
// Resize handle
const resizer = document.createElement('div');
// Adding style.touchAction = 'none' to prevent mobile browser scrolling when dragging
resizer.style.touchAction = 'none';
resizer.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-white"><path d="m21 21-6-6m6 6v-4.8m0 4.8h-4.8M3 16.2V21m0 0h4.8M3 21l6-6M21 7.8V3m0 0h-4.8M21 3l-6 6M3 7.8V3m0 0h4.8M3 3l6 6"/></svg>';
resizer.classList.add('absolute', 'bottom-2', 'right-2', 'w-6', 'h-6', 'bg-black/50', 'hover:bg-black/80', 'backdrop-blur-sm', 'cursor-nwse-resize', 'transition-opacity', 'z-20', 'rounded-md', 'flex', 'items-center', 'justify-center', 'shadow-sm');
resizer.contentEditable = 'false';
let isResizing = false;
let startX = 0;
let startWidth = 0;
const initResize = (clientX: number) => {
isResizing = true;
startX = clientX;
startWidth = wrapper.offsetWidth;
};
const doResize = (clientX: number, minW: number) => {
if (!isResizing) return;
const dx = clientX - startX;
const newWidth = Math.max(minW, startWidth + dx);
wrapper.style.width = `${newWidth}px`;
};
const stopResize = () => {
if (!isResizing) return;
isResizing = false;
if (typeof getPos === 'function') {
const pos = getPos();
if (typeof pos === 'number') {
editor.commands.command(({ tr }) => {
tr.setNodeMarkup(pos, undefined, {
...node.attrs,
width: wrapper.offsetWidth,
});
return true;
});
}
}
};
resizer.addEventListener('mousedown', (e) => {
// Prevent event from dragging the whole block
e.preventDefault();
e.stopPropagation();
initResize(e.clientX);
const onMouseMove = (ev: MouseEvent) => {
ev.preventDefault();
doResize(ev.clientX, 200);
};
const onMouseUp = (ev: MouseEvent) => {
ev.preventDefault();
stopResize();
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
resizer.addEventListener('touchstart', (e) => {
e.preventDefault();
e.stopPropagation();
initResize(e.touches[0].clientX);
const onTouchMove = (ev: TouchEvent) => {
ev.preventDefault();
doResize(ev.touches[0].clientX, 200);
};
const onTouchEnd = () => {
stopResize();
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
document.removeEventListener('touchcancel', onTouchEnd);
};
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', onTouchEnd);
document.addEventListener('touchcancel', onTouchEnd);
}, { passive: false });
// Delete button overlay
const overlay = document.createElement('div');
overlay.classList.add('absolute', 'top-2', 'left-2', 'flex', '[@media(hover:hover)]:hidden', '[@media(hover:hover)]:group-hover:flex', 'bg-background/80', 'backdrop-blur-sm', 'p-1', 'rounded-lg', 'border', 'border-border', 'shadow-sm', 'z-10');
const deleteBtn = document.createElement('button');
deleteBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground hover:text-destructive transition-colors"><path d="M3 6h18"></path><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path></svg>';
deleteBtn.classList.add('p-1', 'cursor-pointer');
deleteBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
if (typeof getPos === 'function') {
const pos = getPos();
if (typeof pos === 'number') {
editor.commands.deleteRange({ from: pos, to: pos + node.nodeSize });
}
}
};
overlay.appendChild(deleteBtn);
wrapper.appendChild(video);
wrapper.appendChild(resizer);
wrapper.appendChild(overlay);
return {
dom: wrapper,
stopEvent: () => true, // Stops events from propagating to editor (makes the input usable)
ignoreMutation: () => true,
};
};
},
});
declare module '@tiptap/core' {
interface Commands<ReturnType> {
video: {
setVideo: (options: { src: string, filename?: string }) => ReturnType;
};
}
}
+8 -12
View File
@@ -1,18 +1,15 @@
import { store, isCurrentUserContextTag } from "@/store";
import { store } from "@/store";
import { render } from "solid-js/web";
import tippy, { type Instance as TippyInstance } from "tippy.js";
import { MentionList } from "@/components/MentionList";
import { buildShareTag } from "@/lib/tags";
export const userSuggestion = {
items: ({ query }: { query: string }) => {
return store.contexts
.filter(context => context.kind === "user" && !context.deletedAt)
.filter(context => !isCurrentUserContextTag(buildShareTag(context.displayName)))
.filter(context => context.displayName.toLowerCase().startsWith(query.toLowerCase()))
.map(context => ({
id: buildShareTag(context.displayName),
label: context.displayName,
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
}));
},
@@ -92,11 +89,10 @@ export const noteSuggestion = {
char: '#',
items: ({ query }: { query: string }) => {
return store.notes
.filter(note => !(note.tags || []).some(t => t.startsWith("child-of-")))
.filter(note => (note.key || note.title).toLowerCase().includes(query.toLowerCase()))
.filter(note => note.title.toLowerCase().includes(query.toLowerCase()))
.map(note => ({
id: note.id,
label: note.key || note.title,
label: note.title,
sublabel: note.content?.substring(0, 50).replace(/<[^>]*>?/gm, ''),
type: 'note' as const
}));
+4 -6
View File
@@ -34,13 +34,12 @@ export const MobileIndent = Extension.create({
if (!parentNode) return false;
const parentType = parentNode.type.name;
const isListItem = parentType === 'taskItem' || parentType === 'multiTaskItem' || parentType === 'listItem';
const isListItem = parentType === 'taskItem' || parentType === 'listItem';
if (!isListItem) return false;
// Sink the list item (indent)
const sunk = editor.chain().sinkListItem('taskItem').run() ||
editor.chain().sinkListItem('multiTaskItem').run() ||
editor.chain().sinkListItem('listItem').run();
// Return true to prevent the space from being inserted
@@ -65,7 +64,7 @@ export const MobileIndent = Extension.create({
if (!parentNode) return false;
const parentType = parentNode.type.name;
const isListItem = parentType === 'taskItem' || parentType === 'multiTaskItem' || parentType === 'listItem';
const isListItem = parentType === 'taskItem' || parentType === 'listItem';
if (!isListItem) return false;
@@ -75,14 +74,13 @@ export const MobileIndent = Extension.create({
const greatGrandparent = $from.node(-3);
// If grandparent is a list and greatGrandparent is also a list item, we're nested
const grandparentIsList = grandparent?.type.name === 'taskList' || grandparent?.type.name === 'multiTaskList' || grandparent?.type.name === 'bulletList' || grandparent?.type.name === 'orderedList';
const greatGrandparentIsListItem = greatGrandparent?.type.name === 'taskItem' || greatGrandparent?.type.name === 'multiTaskItem' || greatGrandparent?.type.name === 'listItem';
const grandparentIsList = grandparent?.type.name === 'taskList' || grandparent?.type.name === 'bulletList' || grandparent?.type.name === 'orderedList';
const greatGrandparentIsListItem = greatGrandparent?.type.name === 'taskItem' || greatGrandparent?.type.name === 'listItem';
if (!grandparentIsList || !greatGrandparentIsListItem) return false;
// Lift the list item (outdent)
const lifted = editor.chain().liftListItem('taskItem').run() ||
editor.chain().liftListItem('multiTaskItem').run() ||
editor.chain().liftListItem('listItem').run();
if (lifted) {
+5 -5
View File
@@ -1,6 +1,6 @@
import PocketBase, { LocalAuthStore } from 'pocketbase';
import { AUTH_STORE_KEY, POCKETBASE_URL } from "@/lib/app-config";
import { CONTEXTS_COLLECTION, NOTES_COLLECTION, TASGRID_COLLECTION } from "@/lib/constants";
import PocketBase from 'pocketbase';
export const pb = new PocketBase(POCKETBASE_URL, new LocalAuthStore(AUTH_STORE_KEY));
export { TASGRID_COLLECTION, CONTEXTS_COLLECTION, NOTES_COLLECTION };
export const pb = new PocketBase('https://pocketbase.ccllc.pro');
export const TASGRID_COLLECTION = 'TasGrid';
export const TAGS_COLLECTION = 'TasGrid_Tags';
-67
View File
@@ -1,67 +0,0 @@
export type Recurrence = {
type: 'daily' | 'weekly' | 'monthly';
days?: number[];
dayOfMonth?: number;
};
const startOfDay = (date: Date) => new Date(date.getFullYear(), date.getMonth(), date.getDate());
const addDays = (date: Date, days: number) => {
const d = new Date(date);
d.setDate(d.getDate() + days);
return d;
};
const getNextDaily = (completedAt: Date) => addDays(startOfDay(completedAt), 1);
const getNextWeekly = (completedAt: Date, days: number[]) => {
if (!days || days.length === 0) return null;
const base = startOfDay(completedAt);
for (let offset = 1; offset <= 7; offset++) {
const candidate = addDays(base, offset);
if (days.includes(candidate.getDay())) return candidate;
}
return null;
};
const getNextMonthly = (completedAt: Date, dayOfMonth?: number) => {
if (!dayOfMonth || dayOfMonth < 1 || dayOfMonth > 31) return null;
const base = startOfDay(completedAt);
const baseDay = base.getDate();
const startOffset = dayOfMonth > baseDay ? 0 : 1;
for (let offset = startOffset; offset <= 12; offset++) {
const year = base.getFullYear();
const monthIndex = base.getMonth() + offset;
const candidate = new Date(year, monthIndex, dayOfMonth);
if (candidate.getMonth() === (monthIndex % 12 + 12) % 12) {
if (candidate.getTime() > completedAt.getTime()) {
return startOfDay(candidate);
}
}
}
return null;
};
export const getNextRecurrenceDate = (recurrence: Recurrence, completedAt: Date) => {
if (recurrence.type === 'daily') return getNextDaily(completedAt);
if (recurrence.type === 'weekly') return getNextWeekly(completedAt, recurrence.days || []);
if (recurrence.type === 'monthly') return getNextMonthly(completedAt, recurrence.dayOfMonth);
return null;
};
export const getRecurrenceProgress = (
recurrence: Recurrence | null | undefined,
completedAtIso: string | null | undefined,
nowMs: number
) => {
if (!recurrence || !completedAtIso) return null;
const completedAt = new Date(completedAtIso);
if (isNaN(completedAt.getTime())) return null;
const next = getNextRecurrenceDate(recurrence, completedAt);
if (!next) return null;
const total = next.getTime() - completedAt.getTime();
if (total <= 0) return null;
const progress = (nowMs - completedAt.getTime()) / total;
return Math.max(0, Math.min(1, progress));
};
-41
View File
@@ -1,41 +0,0 @@
export type ParsedTag =
| { kind: "share"; raw: string; key: string; display: string }
| { kind: "note"; raw: string; key: string; display: string }
| { kind: "label"; raw: string; key: string; display: string };
const normalizeWhitespace = (value: string) => value.trim().replace(/\s+/g, " ");
export const normalizeKey = (value: string) =>
normalizeWhitespace(value)
.toLowerCase()
.replace(/^[@#]+/, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
export const normalizeContextKey = (value: string) => normalizeKey(value);
export const normalizeNoteKey = (value: string) => normalizeKey(value);
export const parseTag = (rawTag: string): ParsedTag => {
const raw = normalizeWhitespace(rawTag);
const prefix = raw[0];
const display = prefix === "@" || prefix === "#" ? raw.slice(1).trim() : raw;
const key = normalizeKey(display);
if (prefix === "@") {
return { kind: "share", raw, key, display };
}
if (prefix === "#") {
return { kind: "note", raw, key, display };
}
return { kind: "label", raw, key, display };
};
export const parseTags = (rawTags: string[] | undefined | null) =>
(rawTags || [])
.map(parseTag)
.filter(tag => !!tag.display);
export const buildShareTag = (displayName: string) => `@${normalizeWhitespace(displayName)}`;
export const buildNoteTag = (keyOrTitle: string) => `#${normalizeWhitespace(keyOrTitle)}`;
+2 -24
View File
@@ -1,6 +1,5 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
import heic2any from "heic2any"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
@@ -39,28 +38,7 @@ export const compressImageBase64 = (base64Str: string, maxWidth = 800, quality =
};
export const convertImageToWebpFile = async (file: File): Promise<File> => {
return new Promise(async (resolve) => {
let processableFile: File | Blob = file;
// Convert HEIC/HEIF to JPEG first so the browser can read it
const isHeic = file.name.toLowerCase().endsWith('.heic') || file.name.toLowerCase().endsWith('.heif');
if (isHeic) {
try {
const converted = await heic2any({
blob: file,
toType: "image/jpeg",
quality: 0.8 // high quality intermediate step
});
// heic2any can return an array of blobs if it's an image sequence, just take the first
processableFile = Array.isArray(converted) ? converted[0] : converted;
} catch (err) {
console.error("Failed to convert HEIC image:", err);
// If conversion fails, just fall back to returning the original file
return resolve(file);
}
}
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
@@ -114,6 +92,6 @@ export const convertImageToWebpFile = async (file: File): Promise<File> => {
img.src = e.target?.result as string;
};
reader.onerror = () => resolve(file); // Fallback
reader.readAsDataURL(processableFile);
reader.readAsDataURL(file);
});
};
+833 -2478
View File
File diff suppressed because it is too large Load Diff
+7 -5
View File
@@ -1,8 +1,8 @@
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 { useTaskListAutoAnimate } from "@/hooks/useTaskListAutoAnimate";
import autoAnimate from "@formkit/auto-animate";
export const CriticalView: Component = () => {
const sourceTasks = createMemo(() => store.tasks.filter(t => !t.deletedAt && matchesFilter(t)));
@@ -17,7 +17,9 @@ export const CriticalView: Component = () => {
);
let listRef: HTMLDivElement | undefined;
useTaskListAutoAnimate(() => listRef);
onMount(() => {
if (listRef) autoAnimate(listRef, { duration: 300, easing: 'ease-out' });
});
return (
<div class="space-y-6">
@@ -26,10 +28,10 @@ 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 grid-cols-1 gap-3 w-full" ref={listRef}>
<div class="grid gap-3" ref={listRef}>
<For each={displayedTasks()}>
{(task) => (
<div class="w-full min-w-0">
<div class="animate-task-enter">
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
</div>
)}
+7 -5
View File
@@ -1,8 +1,8 @@
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 { useTaskListAutoAnimate } from "@/hooks/useTaskListAutoAnimate";
import autoAnimate from "@formkit/auto-animate";
export const DigInView: Component = () => {
const sourceTasks = createMemo(() => store.tasks.filter(t => !t.deletedAt && matchesFilter(t)));
@@ -22,7 +22,9 @@ export const DigInView: Component = () => {
);
let listRef: HTMLDivElement | undefined;
useTaskListAutoAnimate(() => listRef);
onMount(() => {
if (listRef) autoAnimate(listRef, { duration: 300, easing: 'ease-out' });
});
return (
<div class="space-y-6">
@@ -31,10 +33,10 @@ 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 grid-cols-1 gap-3 w-full" ref={listRef}>
<div class="grid gap-3" ref={listRef}>
<For each={displayedTasks()}>
{(task) => (
<div class="w-full min-w-0">
<div class="animate-task-enter">
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
</div>
)}
+215 -850
View File
File diff suppressed because it is too large Load Diff
+7 -5
View File
@@ -1,8 +1,8 @@
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 { useTaskListAutoAnimate } from "@/hooks/useTaskListAutoAnimate";
import autoAnimate from "@formkit/auto-animate";
export const PriorityView: Component = () => {
const sourceTasks = createMemo(() => store.tasks.filter(t => !t.deletedAt && matchesFilter(t)));
@@ -17,7 +17,9 @@ export const PriorityView: Component = () => {
);
let listRef: HTMLDivElement | undefined;
useTaskListAutoAnimate(() => listRef);
onMount(() => {
if (listRef) autoAnimate(listRef, { duration: 300, easing: 'ease-out' });
});
return (
<div class="space-y-6">
@@ -26,10 +28,10 @@ export const PriorityView: Component = () => {
<p class="text-muted-foreground mt-1 text-lg">Your tasks ordered by priority.</p>
</header>
<div class="grid grid-cols-1 gap-3 w-full" ref={listRef}>
<div class="grid gap-3" ref={listRef}>
<For each={displayedTasks()}>
{(task) => (
<div class="w-full min-w-0">
<div class="animate-task-enter">
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
</div>
)}
+7 -5
View File
@@ -1,8 +1,8 @@
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 { useTaskListAutoAnimate } from "@/hooks/useTaskListAutoAnimate";
import autoAnimate from "@formkit/auto-animate";
export const ProgressView: Component = () => {
const sourceTasks = createMemo(() => store.tasks.filter(t => !t.deletedAt && matchesFilter(t)));
@@ -24,7 +24,9 @@ export const ProgressView: Component = () => {
);
let listRef: HTMLDivElement | undefined;
useTaskListAutoAnimate(() => listRef);
onMount(() => {
if (listRef) autoAnimate(listRef, { duration: 300, easing: 'ease-out' });
});
return (
<div class="space-y-6">
@@ -33,10 +35,10 @@ export const ProgressView: Component = () => {
<p class="text-muted-foreground mt-1 text-lg">Tasks sorted by progress status, then by focus score.</p>
</header>
<div class="grid grid-cols-1 gap-3 w-full" ref={listRef}>
<div class="grid gap-3" ref={listRef}>
<For each={displayedTasks()}>
{(task) => (
<div class="w-full min-w-0">
<div class="animate-task-enter">
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
</div>
)}
+284 -608
View File
File diff suppressed because it is too large Load Diff
+7 -5
View File
@@ -1,8 +1,8 @@
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 { useTaskListAutoAnimate } from "@/hooks/useTaskListAutoAnimate";
import autoAnimate from "@formkit/auto-animate";
export const SnowballView: Component = () => {
const sourceTasks = createMemo(() => store.tasks.filter(t => !t.deletedAt && matchesFilter(t)));
@@ -22,7 +22,9 @@ export const SnowballView: Component = () => {
);
let listRef: HTMLDivElement | undefined;
useTaskListAutoAnimate(() => listRef);
onMount(() => {
if (listRef) autoAnimate(listRef, { duration: 300, easing: 'ease-out' });
});
return (
<div class="space-y-6">
@@ -31,10 +33,10 @@ 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 grid-cols-1 gap-3 w-full" ref={listRef}>
<div class="grid gap-3" ref={listRef}>
<For each={displayedTasks()}>
{(task) => (
<div class="w-full min-w-0">
<div class="animate-task-enter">
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
</div>
)}
+7 -5
View File
@@ -1,8 +1,8 @@
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 { useTaskListAutoAnimate } from "@/hooks/useTaskListAutoAnimate";
import autoAnimate from "@formkit/auto-animate";
export const UrgencyView: Component = () => {
const sourceTasks = createMemo(() => store.tasks.filter(t => !t.deletedAt && matchesFilter(t)));
@@ -21,7 +21,9 @@ export const UrgencyView: Component = () => {
);
let listRef: HTMLDivElement | undefined;
useTaskListAutoAnimate(() => listRef);
onMount(() => {
if (listRef) autoAnimate(listRef, { duration: 300, easing: 'ease-out' });
});
return (
<div class="space-y-6">
@@ -30,10 +32,10 @@ export const UrgencyView: Component = () => {
<p class="text-muted-foreground mt-1 text-lg">Your tasks ordered by deadline.</p>
</header>
<div class="grid grid-cols-1 gap-3 w-full" ref={listRef}>
<div class="grid gap-3" ref={listRef}>
<For each={displayedTasks()}>
{(task) => (
<div class="w-full min-w-0">
<div class="animate-task-enter">
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
</div>
)}
-3
View File
@@ -1,3 +0,0 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string;
+65 -138
View File
@@ -113,22 +113,6 @@
<h1>TasGrid Parent App Simulator</h1>
<div class="controls">
<div id="env-settings" style="margin-bottom: 20px; padding-bottom: 20px; border-bottom: 1px solid #eee;">
<p style="font-size: 13px; color: #666; margin-top: 0;"><strong>Target Environment:</strong></p>
<label style="margin-right: 15px; cursor: pointer;">
<input type="radio" name="envToggle" value="https://tasgrid.ccllc.pro" checked onchange="updateEnv()">
Production (tasgrid.ccllc.pro)
</label>
<label style="margin-right: 15px; cursor: pointer;">
<input type="radio" name="envToggle" value="http://localhost:4000" onchange="updateEnv()">
Localhost:4000
</label>
<label style="cursor: pointer;">
<input type="radio" name="envToggle" value="http://localhost:4001" onchange="updateEnv()">
Localhost:4001
</label>
</div>
<div id="login-section">
<h3>Login to Parent Application</h3>
<p>This simulates the parent application authenticating with the shared backend.</p>
@@ -148,23 +132,12 @@
<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 Headless SDK (Centralized
Logic):</strong></p>
<div style="display: flex; flex-direction: column; gap: 10px; margin-bottom: 10px;">
<div style="display: flex; gap: 10px;">
<input type="text" id="sdk-task-title" style="flex: 1; padding: 8px;"
placeholder="New Task Title...">
<button onclick="createTaskViaSDK()"
style="padding: 8px 16px; cursor: pointer; background: #6f42c1; color: white; border: none; border-radius: 4px; font-weight: bold;">Create
Task via SDK</button>
</div>
<div style="display: flex; gap: 10px;">
<input type="text" id="sdk-note-title" style="flex: 1; padding: 8px;"
placeholder="New Note Title...">
<button onclick="createNoteViaSDK()"
style="padding: 8px 16px; cursor: pointer; background: #fd7e14; color: white; border: none; border-radius: 4px; font-weight: bold;">Create
Note via SDK</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>
@@ -184,46 +157,44 @@
<li><code>/embed/quick-add</code>: Renders a standalone task creation form.</li>
</ul>
<p><strong>2. Programmatic Creation (TasGrid SDK):</strong></p>
<p>The recommended way to create entries is using the <code>tasgrid-sdk.js</code> module. This
ensures all TasGrid-specific defaults and date logic (like urgency decay) are handled correctly.
<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>Setup:</em></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;">
import { createTasgridTask, createTasgridNote } from 'https://tasgrid.ccllc.pro/tasgrid-sdk.js';</pre>
<div style="border-left: 4px solid #6f42c1; padding-left: 15px; margin: 20px 0;">
<p><strong>A. Creating a TASK:</strong></p>
<p style="font-size: 12px; color: #666; margin-top: -10px;">Use this for items that need status,
priority, and automated due date calculation.</p>
<pre
style="background: #f8f9fa; padding: 15px; border-radius: 4px; border: 1px solid #e9ecef; overflow-x: auto; font-size: 12px;">
const task = await createTasgridTask(pb, {
title: "Urgent Task",
urgency: 10, // Automated: 10=Today, 5=Next Week, 1=Six Months
priority: 8, // 1-10
size: 3, // Default: 3 (1 hour). Range 0-10.
tags: ["external"],
owner: "USER_ID" // Optional
});</pre>
</div>
<div style="border-left: 4px solid #fd7e14; padding-left: 15px; margin: 20px 0;">
<p><strong>B. Creating a NOTE:</strong></p>
<p style="font-size: 12px; color: #666; margin-top: -10px;">Use this for long-form content or
documentation without a deadline.</p>
<pre
style="background: #f8f9fa; padding: 15px; border-radius: 4px; border: 1px solid #e9ecef; overflow-x: auto; font-size: 12px;">
const note = await createTasgridNote(pb, {
title: "Project Brief",
content: "&lt;p&gt;HTML content here...&lt;/p&gt;",
tags: ["documentation"],
const record = await pb.collection('TasGrid_Notes').create({
title: "Sister App Payload",
content: "&lt;p&gt;Initial content here...&lt;/p&gt;",
tags: ["sister_app_export"],
isPrivate: false,
tasks: [] // Optional: Array of Task IDs to link
});</pre>
</div>
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
@@ -261,7 +232,7 @@ function syncAuth(iframe) {
<span>Notes Embed</span>
<code style="font-size: 10px;">/embed/notes</code>
</div>
<iframe id="notes-iframe" onload="syncAuthOnLoad(this)"></iframe>
<iframe id="notes-iframe" src="http://localhost:4000/embed/notes" onload="syncAuthOnLoad(this)"></iframe>
</div>
<div class="iframe-container">
@@ -270,36 +241,14 @@ function syncAuth(iframe) {
<span>Quick Add Embed</span>
<code style="font-size: 10px;">/embed/quick-add</code>
</div>
<iframe id="quick-add-iframe" onload="syncAuthOnLoad(this)"></iframe>
<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');
let ENV_URL = 'https://tasgrid.ccllc.pro';
function updateEnv() {
const selected = document.querySelector('input[name="envToggle"]:checked').value;
ENV_URL = selected;
// Reload iframes with new base URL
const notesIframe = document.getElementById('notes-iframe');
const noteId = document.getElementById('note-id-input')?.value.trim();
if (noteId) {
notesIframe.src = `${ENV_URL}/embed/notes?noteId=${noteId}`;
} else {
notesIframe.src = `${ENV_URL}/embed/notes`;
}
document.getElementById('quick-add-iframe').src = `${ENV_URL}/embed/quick-add`;
console.log(`Switched target environment to: ${ENV_URL}`);
}
// Initialize iframes on load
window.addEventListener('DOMContentLoaded', () => {
updateEnv();
});
function syncAuthOnLoad(iframe) {
console.log(`Iframe ${iframe.id} loaded, checking auth sync...`);
@@ -313,68 +262,46 @@ function syncAuth(iframe) {
const id = document.getElementById('note-id-input').value.trim();
const iframe = document.getElementById('notes-iframe');
if (id) {
iframe.src = `${ENV_URL}/embed/notes?noteId=${id}`;
iframe.src = `http://localhost:4000/embed/notes?noteId=${id}`;
} else {
iframe.src = `${ENV_URL}/embed/notes`;
iframe.src = `http://localhost:4000/embed/notes`;
}
}
async function createTaskViaSDK() {
const title = document.getElementById('sdk-task-title').value.trim() || 'SDK Generated Task';
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 task via SDK.");
alert("Please log in first before creating a note via API.");
return;
}
statusEl.className = 'status';
statusEl.textContent = 'Importing SDK and creating task...';
statusEl.textContent = 'Creating note via API...';
statusEl.style.display = 'block';
try {
const { createTasgridTask } = await import(`${ENV_URL}/tasgrid-sdk.js`);
const record = await createTasgridTask(pb, {
// Using PocketBase SDK to mimic a REST API call from sister app
const record = await pb.collection('TasGrid_Notes').create({
title: title,
priority: 8,
urgency: 4,
tags: ["sdk_test"],
content: "This task was created by a sister app using the imported Headless SDK!"
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 = `Task created successfully via SDK (ID: ${record.id})!`;
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 task via SDK: ' + err.message;
}
}
async function createNoteViaSDK() {
const title = document.getElementById('sdk-note-title').value.trim() || 'SDK Generated Note';
if (!pb.authStore.isValid) {
alert("Please log in first before creating a note via SDK.");
return;
}
statusEl.className = 'status';
statusEl.textContent = 'Importing SDK and creating note...';
statusEl.style.display = 'block';
try {
const { createTasgridNote } = await import(`${ENV_URL}/tasgrid-sdk.js`);
const record = await createTasgridNote(pb, {
title: title,
content: "<p>This note was created headlessly via the SDK!</p>",
tags: ["sdk_test"],
isPrivate: false
});
statusEl.className = 'status success';
statusEl.textContent = `Note created successfully via SDK (ID: ${record.id})!`;
} catch (err) {
statusEl.className = 'status error';
statusEl.textContent = 'Failed to create note via SDK: ' + err.message;
statusEl.textContent = 'Failed to create note: ' + err.message;
}
}
+1 -3
View File
@@ -10,9 +10,7 @@
"DOM.Iterable"
],
"types": [
"vite/client",
"vite-plugin-pwa/client",
"vite-plugin-pwa/solid"
"vite/client"
],
"skipLibCheck": true,
/* Bundler mode */
+66 -70
View File
@@ -1,79 +1,75 @@
import { defineConfig, loadEnv } from 'vite'
import { defineConfig } from 'vite'
import solid from 'vite-plugin-solid'
import { VitePWA } from 'vite-plugin-pwa'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
import { readFileSync } from 'fs'
const packageJson = JSON.parse(
readFileSync(new URL('./package.json', import.meta.url), 'utf-8')
);
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const dataMode = (env.VITE_TASGRID_DATA_MODE || 'prod').toLowerCase()
const isDevData = dataMode === 'dev'
return {
define: {
__APP_VERSION__: JSON.stringify(packageJson.version),
},
plugins: [
solid(),
tailwindcss(),
VitePWA({
registerType: 'prompt',
includeAssets: ['icon.webp', 'vite.svg'],
manifest: {
name: isDevData ? 'Tasgrid Dev' : 'Tasgrid',
short_name: isDevData ? 'TasgridDev' : 'Tasgrid',
description: isDevData ? 'Tasgrid development data lane' : 'Precision Productivity Task Manager',
theme_color: '#0a0a0a',
background_color: '#0a0a0a',
display: 'standalone',
icons: [
{
src: 'icon.webp',
sizes: '1024x1024',
type: 'image/webp',
purpose: 'any'
},
{
src: 'icon.webp',
sizes: '192x192',
type: 'image/webp',
purpose: 'maskable'
},
{
src: 'icon.webp',
sizes: '512x512',
type: 'image/webp',
purpose: 'any'
}
]
},
workbox: {
cleanupOutdatedCaches: true,
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
runtimeCaching: []
}
})
],
server: {
host: '0.0.0.0',
port: 4000,
cors: { origin: '*' },
headers: {
'Access-Control-Allow-Origin': '*'
export default defineConfig({
plugins: [
solid(),
tailwindcss(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['icon.webp', 'vite.svg'],
manifest: {
name: 'Tasgrid',
short_name: 'Tasgrid',
description: 'Precision Productivity Task Manager',
theme_color: '#0a0a0a',
background_color: '#0a0a0a',
display: 'standalone',
icons: [
{
src: 'icon.webp',
sizes: '1024x1024',
type: 'image/webp',
purpose: 'any'
},
{
src: 'icon.webp',
sizes: '192x192',
type: 'image/webp',
purpose: 'maskable'
},
{
src: 'icon.webp',
sizes: '512x512',
type: 'image/webp',
purpose: 'any'
}
]
},
allowedHosts: [
'tasgrid.ccllc.pro'
]
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src")
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/pocketbase\.ccllc\.pro\/api\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24 * 7 // 1 week
},
cacheableResponse: {
statuses: [0, 200]
}
}
}
]
}
})
],
server: {
host: '0.0.0.0',
port: 4000,
allowedHosts: [
'tasgrid.ccllc.pro'
]
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src")
}
}
})
})