Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 82a75411f2 |
@@ -1 +0,0 @@
|
||||
VITE_TASGRID_DATA_MODE=dev
|
||||
@@ -1,4 +0,0 @@
|
||||
VITE_TASGRID_ENABLE_PROD_MIGRATION=false
|
||||
VITE_TASGRID_MIGRATION_ADMIN_EMAILS=tcardoza@cardoza.construction
|
||||
VITE_FIREWORKS_API_KEY=fw_2QZReEeWXyJsuYn3JWffTh
|
||||
VITE_FIREWORKS_HELP_MODEL=accounts/fireworks/models/gpt-oss-20b
|
||||
@@ -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)"'
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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();
|
||||
+114
-905
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
]
|
||||
-266
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -1,870 +0,0 @@
|
||||
/**
|
||||
* TasGrid SDK
|
||||
*
|
||||
* Headless helpers for sister apps that need to create TasGrid tasks and notes
|
||||
* using the same normalized creation model as the current TasGrid app.
|
||||
*/
|
||||
|
||||
const DEFAULT_COLLECTIONS = {
|
||||
tasks: "TasGrid",
|
||||
notes: "TasGrid_Notes",
|
||||
contexts: "TasGrid_Contexts",
|
||||
};
|
||||
|
||||
export const TASGRID_SDK_SIGNATURE = "context-model-v2-2026-03-26";
|
||||
|
||||
const NOTE_HANDOFF_POLICY_TAG = "__note_policy_handoff__";
|
||||
const USER_CONTEXT_KEY_PREFIX = "user-";
|
||||
|
||||
const URGENCY_HOURS = {
|
||||
10: 12,
|
||||
9: 24,
|
||||
8: 36,
|
||||
7: 72,
|
||||
6: 120,
|
||||
5: 240,
|
||||
4: 504,
|
||||
3: 1080,
|
||||
2: 2160,
|
||||
1: 4320,
|
||||
};
|
||||
|
||||
const referenceCacheByPb = new WeakMap();
|
||||
|
||||
const normalizeWhitespace = (value) => String(value || "").trim().replace(/\s+/g, " ");
|
||||
|
||||
const normalizeKey = (value) =>
|
||||
normalizeWhitespace(value)
|
||||
.toLowerCase()
|
||||
.replace(/^[@#]+/, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
|
||||
export const normalizeContextKey = (value) => normalizeKey(value);
|
||||
export const normalizeNoteKey = (value) => normalizeKey(value);
|
||||
export const buildShareTag = (displayName) => `@${normalizeWhitespace(displayName)}`;
|
||||
export const buildNoteTag = (keyOrTitle) => `#${normalizeWhitespace(keyOrTitle)}`;
|
||||
|
||||
const buildUserContextKey = (userId) => `${USER_CONTEXT_KEY_PREFIX}${userId}`;
|
||||
|
||||
const parseTag = (rawTag) => {
|
||||
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) =>
|
||||
(Array.isArray(rawTags) ? rawTags : [])
|
||||
.map(parseTag)
|
||||
.filter((tag) => !!tag.display);
|
||||
|
||||
const uniqueStrings = (values) => [...new Set((Array.isArray(values) ? values : []).filter(Boolean))];
|
||||
|
||||
const dedupeShareRefs = (refs) => {
|
||||
const seen = new Set();
|
||||
return (Array.isArray(refs) ? refs : []).filter((ref) => {
|
||||
if (!ref) return false;
|
||||
const key = `${ref.kind}:${ref.contextId}:${ref.key}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const dedupeNoteRefs = (refs) => {
|
||||
const seen = new Set();
|
||||
return (Array.isArray(refs) ? refs : []).filter((ref) => {
|
||||
if (!ref) return false;
|
||||
const key = `${ref.noteId}:${ref.key}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const getSdkCache = (pb) => {
|
||||
let cache = referenceCacheByPb.get(pb);
|
||||
if (!cache) {
|
||||
cache = {
|
||||
collectionsKey: null,
|
||||
contexts: null,
|
||||
notes: null,
|
||||
updatedByFieldSupport: null,
|
||||
};
|
||||
referenceCacheByPb.set(pb, cache);
|
||||
}
|
||||
return cache;
|
||||
};
|
||||
|
||||
const getCollections = (payload = {}) => ({
|
||||
tasks: payload.collections?.tasks || DEFAULT_COLLECTIONS.tasks,
|
||||
notes: payload.collections?.notes || DEFAULT_COLLECTIONS.notes,
|
||||
contexts: payload.collections?.contexts || DEFAULT_COLLECTIONS.contexts,
|
||||
});
|
||||
|
||||
const resetCacheIfCollectionsChanged = (cache, collections) => {
|
||||
const key = JSON.stringify(collections);
|
||||
if (cache.collectionsKey !== key) {
|
||||
cache.collectionsKey = key;
|
||||
cache.contexts = null;
|
||||
cache.notes = null;
|
||||
cache.updatedByFieldSupport = null;
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrentUserId = (pb) => pb?.authStore?.model?.id || null;
|
||||
const getCurrentUserDisplayName = (pb) =>
|
||||
normalizeWhitespace(pb?.authStore?.model?.name || pb?.authStore?.model?.email || "");
|
||||
|
||||
const getRelationId = (value) => {
|
||||
if (!value) return null;
|
||||
if (typeof value === "string") return value;
|
||||
if (Array.isArray(value)) return value[0] || null;
|
||||
if (typeof value === "object" && typeof value.id === "string") return value.id;
|
||||
return null;
|
||||
};
|
||||
|
||||
const getDefaultSharePolicyByKind = (kind) => {
|
||||
if (kind === "user") return "collaborative";
|
||||
if (kind === "note") return "collaborative";
|
||||
return "handoff";
|
||||
};
|
||||
|
||||
const mapContextRecord = (record) => ({
|
||||
id: record.id,
|
||||
key: record.key || normalizeContextKey(record.displayName || record.name || ""),
|
||||
displayName: record.displayName || record.name || record.key || "",
|
||||
kind: record.kind || "bucket",
|
||||
policy: record.policy || (record.kind === "user" ? "collaborative" : "handoff"),
|
||||
targetUserId: getRelationId(record.targetUserId),
|
||||
deletedAt: record.deletedAt || null,
|
||||
});
|
||||
|
||||
const mapNoteRecord = (record) => ({
|
||||
id: record.id,
|
||||
key: record.key || normalizeNoteKey(record.title || record.id),
|
||||
title: record.title || "Untitled",
|
||||
tags: uniqueStrings(record.tags),
|
||||
isPrivate: !!record.isPrivate,
|
||||
user: getRelationId(record.user) || "",
|
||||
});
|
||||
|
||||
const findContextForShareTag = (contexts, parsedTag) =>
|
||||
contexts.find((context) => !context.deletedAt && context.key === parsedTag.key) ||
|
||||
contexts.find((context) => !context.deletedAt && context.kind === "user" && normalizeContextKey(context.displayName) === parsedTag.key) ||
|
||||
contexts.find((context) => !context.deletedAt && normalizeContextKey(context.displayName) === parsedTag.key) ||
|
||||
contexts.find((context) => !context.deletedAt && buildShareTag(context.displayName).toLowerCase() === parsedTag.raw.toLowerCase()) ||
|
||||
null;
|
||||
|
||||
const getContextByRef = (contexts, notes, ref) =>
|
||||
(ref.kind === "note"
|
||||
? (() => {
|
||||
const note = notes.find((existing) => existing.id === ref.contextId || existing.key === ref.key);
|
||||
return note
|
||||
? {
|
||||
id: note.id,
|
||||
key: note.key,
|
||||
displayName: note.key || note.title,
|
||||
kind: "note",
|
||||
policy: getNoteSharePolicy(note),
|
||||
targetUserId: note.user,
|
||||
deletedAt: null,
|
||||
}
|
||||
: null;
|
||||
})()
|
||||
: null) ||
|
||||
contexts.find((context) => !context.deletedAt && context.id === ref.contextId) ||
|
||||
contexts.find((context) => !context.deletedAt && context.key === ref.key && context.kind === ref.kind) ||
|
||||
contexts.find((context) => !context.deletedAt && context.kind === ref.kind && normalizeContextKey(context.displayName) === ref.key) ||
|
||||
contexts.find((context) => !context.deletedAt && normalizeContextKey(context.displayName) === ref.key) ||
|
||||
null;
|
||||
|
||||
const getNoteByRef = (notes, ref) =>
|
||||
notes.find((note) => note.id === ref.noteId) ||
|
||||
notes.find((note) => note.key === ref.key) ||
|
||||
null;
|
||||
|
||||
const getNoteSharePolicy = (note) =>
|
||||
note?.tags?.includes(NOTE_HANDOFF_POLICY_TAG) ? "handoff" : "collaborative";
|
||||
|
||||
const getTaskSharePolicy = (contexts, notes, ref) => {
|
||||
if (ref.kind === "note") {
|
||||
if (ref.policy) return ref.policy;
|
||||
const note = notes.find((existing) => existing.id === ref.contextId || existing.key === ref.key);
|
||||
return getNoteSharePolicy(note);
|
||||
}
|
||||
|
||||
const context = getContextByRef(contexts, notes, ref);
|
||||
if (ref.policy) return ref.policy;
|
||||
if (context?.policy) return context.policy;
|
||||
|
||||
return getDefaultSharePolicyByKind(ref.kind);
|
||||
};
|
||||
|
||||
const getPersonalContextUserIdFromRef = (contexts, notes, ref) => {
|
||||
if (ref.kind !== "user") return null;
|
||||
|
||||
const context = getContextByRef(contexts, notes, ref);
|
||||
if (context?.targetUserId) {
|
||||
return context.targetUserId;
|
||||
}
|
||||
|
||||
if (ref.key && ref.key.startsWith(USER_CONTEXT_KEY_PREFIX)) {
|
||||
return ref.key.replace(USER_CONTEXT_KEY_PREFIX, "") || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getTaskPersonalRefs = (contexts, notes, task) =>
|
||||
(task.shareRefs || []).filter((ref) => ref.kind === "user" && !!getPersonalContextUserIdFromRef(contexts, notes, ref));
|
||||
|
||||
const taskHasLegacyPersonalAccess = (contexts, notes, task, userId) =>
|
||||
task.ownerId === userId &&
|
||||
getTaskPersonalRefs(contexts, notes, task).length === 0 &&
|
||||
!(task.shareRefs || []).some((ref) => ref.kind !== "user" && getTaskSharePolicy(contexts, notes, ref) === "handoff");
|
||||
|
||||
const taskHasPersonalContextAccess = (contexts, notes, task, userId, allowLegacyFallback = true) => {
|
||||
const personalUserIds = [...new Set(
|
||||
(task.shareRefs || [])
|
||||
.filter((ref) => ref.kind === "user")
|
||||
.map((ref) => getPersonalContextUserIdFromRef(contexts, notes, ref))
|
||||
.filter(Boolean)
|
||||
)];
|
||||
|
||||
return personalUserIds.includes(userId) || (allowLegacyFallback && taskHasLegacyPersonalAccess(contexts, notes, task, userId));
|
||||
};
|
||||
|
||||
const buildTaskTags = (contexts, notes, labelTags, shareRefs, noteRefs, favoriteTag) => {
|
||||
const tags = [
|
||||
...(labelTags || []),
|
||||
...(shareRefs || []).map((ref) => {
|
||||
const context = getContextByRef(contexts, notes, ref);
|
||||
if (ref.kind === "note") {
|
||||
return buildNoteTag(context?.key || ref.key);
|
||||
}
|
||||
if (context?.displayName) {
|
||||
return buildShareTag(context.displayName);
|
||||
}
|
||||
if (ref.kind === "user" && ref.key && ref.key.startsWith(USER_CONTEXT_KEY_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
return buildShareTag(ref.key);
|
||||
}),
|
||||
...(noteRefs || []).map((ref) => {
|
||||
const note = getNoteByRef(notes, ref);
|
||||
return buildNoteTag(note?.key || ref.key);
|
||||
}),
|
||||
];
|
||||
|
||||
if (favoriteTag && !tags.includes(favoriteTag)) {
|
||||
tags.push(favoriteTag);
|
||||
}
|
||||
|
||||
return [...new Set(tags.filter(Boolean))];
|
||||
};
|
||||
|
||||
const buildTaskShareRef = (contexts, notes, parsedTag, existingRefs = [], shareModeOverrides = {}) => {
|
||||
if (parsedTag.kind === "note") {
|
||||
const note = notes.find((existing) => existing.key === parsedTag.key);
|
||||
const nextRefBase = {
|
||||
contextId: note?.id || parsedTag.key,
|
||||
key: parsedTag.key,
|
||||
kind: "note",
|
||||
};
|
||||
const existing = existingRefs.find((ref) => ref.kind === "note" && (ref.contextId === nextRefBase.contextId || ref.key === nextRefBase.key));
|
||||
return {
|
||||
...nextRefBase,
|
||||
policy: shareModeOverrides[parsedTag.raw.toLowerCase()] || existing?.policy || getTaskSharePolicy(contexts, notes, nextRefBase),
|
||||
};
|
||||
}
|
||||
|
||||
const context = findContextForShareTag(contexts, parsedTag);
|
||||
const nextRefBase = {
|
||||
contextId: context?.id || parsedTag.key,
|
||||
key: parsedTag.key,
|
||||
kind: context?.kind || "bucket",
|
||||
};
|
||||
const existing = existingRefs.find((ref) =>
|
||||
ref.kind === nextRefBase.kind &&
|
||||
(ref.contextId === nextRefBase.contextId || ref.key === nextRefBase.key)
|
||||
);
|
||||
|
||||
return {
|
||||
...nextRefBase,
|
||||
policy: shareModeOverrides[parsedTag.raw.toLowerCase()] || existing?.policy || getTaskSharePolicy(contexts, notes, nextRefBase),
|
||||
};
|
||||
};
|
||||
|
||||
const deriveTaskRelationsFromTags = (contexts, notes, tags, existingShareRefs = [], shareModeOverrides = {}) => {
|
||||
const parsedTags = parseTags(tags);
|
||||
const labelTags = parsedTags.filter((tag) => tag.kind === "label").map((tag) => tag.display);
|
||||
const shareRefs = parsedTags
|
||||
.filter((tag) => tag.kind === "share" || tag.kind === "note")
|
||||
.map((tag) => buildTaskShareRef(contexts, notes, tag, existingShareRefs, shareModeOverrides));
|
||||
const noteRefs = parsedTags
|
||||
.filter((tag) => tag.kind === "note")
|
||||
.map((tag) => {
|
||||
const note = notes.find((existing) => existing.key === tag.key);
|
||||
return {
|
||||
noteId: note?.id || tag.key,
|
||||
key: tag.key,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
labelTags,
|
||||
shareRefs: dedupeShareRefs(shareRefs),
|
||||
noteRefs: dedupeNoteRefs(noteRefs),
|
||||
};
|
||||
};
|
||||
|
||||
const resolveMirroredOwnerId = (contexts, notes, currentOwnerId, shareRefs, preferredOwnerId) => {
|
||||
const personalUserIds = [...new Set(
|
||||
(shareRefs || [])
|
||||
.filter((ref) => ref.kind === "user")
|
||||
.map((ref) => getPersonalContextUserIdFromRef(contexts, notes, ref))
|
||||
.filter(Boolean)
|
||||
)];
|
||||
|
||||
if (personalUserIds.length === 1) {
|
||||
return personalUserIds[0];
|
||||
}
|
||||
|
||||
if (personalUserIds.length > 1 && preferredOwnerId && personalUserIds.includes(preferredOwnerId)) {
|
||||
return preferredOwnerId;
|
||||
}
|
||||
|
||||
return currentOwnerId || null;
|
||||
};
|
||||
|
||||
const applyHandoffs = (contexts, notes, senderUserId, senderPersonalContext, task, updates) => {
|
||||
if (!senderUserId || !taskHasPersonalContextAccess(contexts, notes, task, senderUserId)) {
|
||||
return updates;
|
||||
}
|
||||
|
||||
const refs = updates.shareRefs || task.shareRefs || [];
|
||||
const stripSenderPersonalContext = (inputRefs) =>
|
||||
senderPersonalContext
|
||||
? inputRefs.filter((existingRef) => !(
|
||||
existingRef.kind === "user" &&
|
||||
(existingRef.contextId === senderPersonalContext.id || existingRef.key === senderPersonalContext.key)
|
||||
))
|
||||
: inputRefs;
|
||||
|
||||
for (const ref of refs) {
|
||||
const context = getContextByRef(contexts, notes, ref);
|
||||
if (senderPersonalContext && ref.kind === "user" && (
|
||||
ref.contextId === senderPersonalContext.id ||
|
||||
ref.key === senderPersonalContext.key
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
if (!context || getTaskSharePolicy(contexts, notes, ref) !== "handoff") continue;
|
||||
|
||||
const nextShareRefs = stripSenderPersonalContext(refs);
|
||||
const labelTags = updates.labelTags || task.labelTags || [];
|
||||
const noteRefs = updates.noteRefs || task.noteRefs || [];
|
||||
const favoriteTag = (updates.tags || task.tags || []).find((tag) => tag.endsWith("_favorite__"));
|
||||
|
||||
if (context.kind === "user" && context.targetUserId) {
|
||||
return {
|
||||
...updates,
|
||||
ownerId: context.targetUserId,
|
||||
shareRefs: nextShareRefs,
|
||||
tags: buildTaskTags(contexts, notes, labelTags, nextShareRefs, noteRefs, favoriteTag),
|
||||
};
|
||||
}
|
||||
|
||||
if (context.kind === "bucket" || context.kind === "note") {
|
||||
return {
|
||||
...updates,
|
||||
shareRefs: nextShareRefs,
|
||||
tags: buildTaskTags(contexts, notes, labelTags, nextShareRefs, noteRefs, favoriteTag),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return updates;
|
||||
};
|
||||
|
||||
const mapTaskRecord = (contexts, notes, record) => {
|
||||
const legacyTags = uniqueStrings(record.tags).filter((tag) => tag !== "__template__");
|
||||
const parsedTags = parseTags(legacyTags);
|
||||
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.kind === "share" || tag.kind === "note")
|
||||
.map((tag) => buildTaskShareRef(contexts, notes, tag));
|
||||
|
||||
const noteRefs = Array.isArray(record.noteRefs)
|
||||
? record.noteRefs.map((ref) => ({ noteId: ref.noteId, key: ref.key }))
|
||||
: parsedTags
|
||||
.filter((tag) => tag.kind === "note")
|
||||
.map((tag) => {
|
||||
const note = notes.find((existing) => existing.key === tag.key);
|
||||
return {
|
||||
noteId: note?.id || tag.key,
|
||||
key: tag.key,
|
||||
};
|
||||
});
|
||||
|
||||
const noteShareRefs = noteRefs.map((ref) => ({
|
||||
contextId: ref.noteId || ref.key,
|
||||
key: ref.key,
|
||||
kind: "note",
|
||||
policy: shareRefs.find((existing) => existing.kind === "note" && (existing.contextId === ref.noteId || existing.key === ref.key))?.policy,
|
||||
}));
|
||||
|
||||
const mergedShareRefs = dedupeShareRefs([...shareRefs, ...noteShareRefs]);
|
||||
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__"));
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
title: record.title || "Untitled",
|
||||
tags: buildTaskTags(contexts, notes, labelTags, mergedShareRefs, noteRefs, favoriteTag),
|
||||
labelTags,
|
||||
shareRefs: mergedShareRefs,
|
||||
noteRefs,
|
||||
ownerId: getRelationId(record.user),
|
||||
createdBy: getRelationId(record.createdBy) || getRelationId(record.user),
|
||||
};
|
||||
};
|
||||
|
||||
const loadContexts = async (pb, collections, cache) => {
|
||||
if (cache.contexts) return cache.contexts;
|
||||
|
||||
const records = await pb.collection(collections.contexts).getFullList({
|
||||
sort: "displayName,key",
|
||||
fields: "id,key,displayName,name,kind,policy,targetUserId,deletedAt",
|
||||
requestKey: null,
|
||||
}).catch(() => []);
|
||||
|
||||
cache.contexts = records.map(mapContextRecord);
|
||||
return cache.contexts;
|
||||
};
|
||||
|
||||
const loadNotes = async (pb, collections, cache) => {
|
||||
if (cache.notes) return cache.notes;
|
||||
|
||||
const records = await pb.collection(collections.notes).getFullList({
|
||||
fields: "id,key,title,tags,isPrivate,user",
|
||||
requestKey: null,
|
||||
}).catch(() => []);
|
||||
|
||||
cache.notes = records.map(mapNoteRecord);
|
||||
return cache.notes;
|
||||
};
|
||||
|
||||
const ensureOwnerPersonalContext = async (pb, ownerId, collections, cache) => {
|
||||
if (!ownerId) return null;
|
||||
|
||||
const contexts = await loadContexts(pb, collections, cache);
|
||||
const existing = contexts.find((context) =>
|
||||
!context.deletedAt &&
|
||||
context.kind === "user" &&
|
||||
context.targetUserId === ownerId
|
||||
);
|
||||
if (existing) return existing;
|
||||
|
||||
if (ownerId !== getCurrentUserId(pb)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const displayName = getCurrentUserDisplayName(pb);
|
||||
if (!displayName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await pb.collection(collections.contexts).create({
|
||||
key: buildUserContextKey(ownerId),
|
||||
displayName,
|
||||
kind: "user",
|
||||
policy: "collaborative",
|
||||
targetUserId: ownerId,
|
||||
}, { requestKey: null });
|
||||
|
||||
const mapped = mapContextRecord(created);
|
||||
cache.contexts = [...contexts, mapped];
|
||||
return mapped;
|
||||
} catch (_err) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const detectUpdatedBySupport = async (pb, collectionName, cache) => {
|
||||
if (cache.updatedByFieldSupport !== null) {
|
||||
return cache.updatedByFieldSupport;
|
||||
}
|
||||
|
||||
try {
|
||||
const sample = await pb.collection(collectionName).getList(1, 1, {
|
||||
fields: "id,updatedBy",
|
||||
requestKey: null,
|
||||
});
|
||||
const item = sample?.items?.[0];
|
||||
cache.updatedByFieldSupport = !!(item && Object.prototype.hasOwnProperty.call(item, "updatedBy"));
|
||||
} catch (_err) {
|
||||
cache.updatedByFieldSupport = false;
|
||||
}
|
||||
|
||||
return cache.updatedByFieldSupport;
|
||||
};
|
||||
|
||||
const addOwnerContextTag = (tags, ownerContext) => {
|
||||
const nextTags = uniqueStrings(tags);
|
||||
if (!ownerContext?.displayName) return nextTags;
|
||||
|
||||
const ownerTag = buildShareTag(ownerContext.displayName);
|
||||
if (!nextTags.some((tag) => tag.toLowerCase() === ownerTag.toLowerCase())) {
|
||||
nextTags.push(ownerTag);
|
||||
}
|
||||
|
||||
return nextTags;
|
||||
};
|
||||
|
||||
const buildTaskCreatePayload = async (pb, payload = {}) => {
|
||||
const collections = getCollections(payload);
|
||||
const cache = getSdkCache(pb);
|
||||
resetCacheIfCollectionsChanged(cache, collections);
|
||||
|
||||
const senderUserId = getCurrentUserId(pb);
|
||||
const ownerId = payload.owner || senderUserId;
|
||||
const ownerContext = await ensureOwnerPersonalContext(pb, ownerId, collections, cache);
|
||||
const senderPersonalContext = senderUserId
|
||||
? await ensureOwnerPersonalContext(pb, senderUserId, collections, cache)
|
||||
: null;
|
||||
const contexts = await loadContexts(pb, collections, cache);
|
||||
const notes = await loadNotes(pb, collections, cache);
|
||||
|
||||
const urgencyVal = payload.urgency ?? 5;
|
||||
const startDate = payload.startDate || new Date().toISOString();
|
||||
const dueDate = payload.dueDate || calculateDateFromUrgency(urgencyVal);
|
||||
const priority = payload.priority ?? 5;
|
||||
const size = payload.size ?? 3;
|
||||
const baseTags = Array.isArray(payload.tags) && payload.tags.length > 0 ? payload.tags : ["work"];
|
||||
const tagsWithOwnerContext = addOwnerContextTag(baseTags, ownerContext);
|
||||
const content = payload.content || "";
|
||||
const { labelTags, shareRefs, noteRefs } = deriveTaskRelationsFromTags(
|
||||
contexts,
|
||||
notes,
|
||||
tagsWithOwnerContext,
|
||||
[],
|
||||
payload.shareModeByTag || {}
|
||||
);
|
||||
|
||||
const initialTask = {
|
||||
title: payload.title || "New Task",
|
||||
tags: buildTaskTags(contexts, notes, labelTags, shareRefs, noteRefs),
|
||||
labelTags,
|
||||
shareRefs,
|
||||
noteRefs,
|
||||
ownerId: ownerId || "",
|
||||
createdBy: senderUserId || ownerId || "",
|
||||
};
|
||||
|
||||
const handoffUpdates = applyHandoffs(
|
||||
contexts,
|
||||
notes,
|
||||
senderUserId,
|
||||
senderPersonalContext,
|
||||
initialTask,
|
||||
initialTask
|
||||
);
|
||||
|
||||
const finalShareRefs = handoffUpdates.shareRefs || initialTask.shareRefs;
|
||||
const finalOwnerId = resolveMirroredOwnerId(
|
||||
contexts,
|
||||
notes,
|
||||
handoffUpdates.ownerId ?? initialTask.ownerId,
|
||||
finalShareRefs,
|
||||
handoffUpdates.ownerId ?? initialTask.ownerId
|
||||
);
|
||||
|
||||
const finalTask = {
|
||||
...initialTask,
|
||||
...handoffUpdates,
|
||||
ownerId: finalOwnerId,
|
||||
};
|
||||
|
||||
return {
|
||||
collections,
|
||||
cache,
|
||||
createPayload: {
|
||||
createdBy: finalTask.createdBy,
|
||||
title: finalTask.title,
|
||||
startDate,
|
||||
dueDate,
|
||||
priority,
|
||||
status: payload.status ?? 0,
|
||||
completed: payload.completed ?? false,
|
||||
tags: finalTask.tags,
|
||||
labelTags: finalTask.labelTags,
|
||||
shareRefs: finalTask.shareRefs,
|
||||
noteRefs: finalTask.noteRefs,
|
||||
content,
|
||||
size,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 using TasGrid's current normalized creation model.
|
||||
*
|
||||
* @param {object} pb Authenticated PocketBase instance.
|
||||
* @param {object} payload Task payload.
|
||||
* @param {string} [payload.title="New Task"] Task title.
|
||||
* @param {string} [payload.owner] PocketBase user id whose personal context should own the task.
|
||||
* @param {number} [payload.priority=5] Priority (1-10).
|
||||
* @param {number} [payload.urgency=5] Urgency (1-10). Used when dueDate is 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=["work"]] Display tags. Supports labels, @contexts, and #notes.
|
||||
* @param {Record<string,"collaborative"|"handoff">} [payload.shareModeByTag] Share mode overrides keyed by tag.
|
||||
* @param {string} [payload.content=""] HTML content.
|
||||
* @param {string} [payload.dueDate] ISO date string.
|
||||
* @param {string} [payload.startDate] ISO date string.
|
||||
* @param {{tasks?: string, notes?: string, contexts?: string}} [payload.collections] Optional collection overrides.
|
||||
* @returns {Promise<object>} Created PocketBase 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 { collections, cache, createPayload } = await buildTaskCreatePayload(pb, payload);
|
||||
const updatedBySupported = await detectUpdatedBySupport(pb, collections.tasks, cache);
|
||||
if (updatedBySupported) {
|
||||
createPayload.updatedBy = getCurrentUserId(pb);
|
||||
}
|
||||
|
||||
try {
|
||||
return await pb.collection(collections.tasks).create(createPayload, { requestKey: null });
|
||||
} catch (err) {
|
||||
if (updatedBySupported && err?.data?.data?.updatedBy) {
|
||||
delete createPayload.updatedBy;
|
||||
cache.updatedByFieldSupport = false;
|
||||
return await pb.collection(collections.tasks).create(createPayload, { requestKey: null });
|
||||
}
|
||||
console.error("TasGrid SDK: Failed to create task", err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const buildNoteCreatePayload = async (pb, payload = {}) => {
|
||||
const collections = getCollections(payload);
|
||||
const cache = getSdkCache(pb);
|
||||
resetCacheIfCollectionsChanged(cache, collections);
|
||||
|
||||
const title = payload.title || "New Note";
|
||||
const explicitKey = normalizeNoteKey(payload.key || "");
|
||||
const keyBase = explicitKey || normalizeNoteKey(title || "new-note") || "new-note";
|
||||
const key = explicitKey || `${keyBase}-${Date.now()}`;
|
||||
const baseTags = uniqueStrings(payload.tags);
|
||||
const nextTags = payload.noteShareMode === "handoff" && !baseTags.includes(NOTE_HANDOFF_POLICY_TAG)
|
||||
? [...baseTags, NOTE_HANDOFF_POLICY_TAG]
|
||||
: baseTags;
|
||||
|
||||
return {
|
||||
collections,
|
||||
cache,
|
||||
noteData: {
|
||||
title,
|
||||
key,
|
||||
content: payload.content || "",
|
||||
tags: nextTags,
|
||||
isPrivate: !!payload.isPrivate,
|
||||
tasks: uniqueStrings(payload.tasks),
|
||||
user: payload.owner || getCurrentUserId(pb),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds current note-link metadata to existing tasks so they behave like note-linked
|
||||
* tasks created inside TasGrid.
|
||||
*
|
||||
* @param {object} pb Authenticated PocketBase instance.
|
||||
* @param {string|object} noteOrId Note id or note-like object with id/key/title/tags/user.
|
||||
* @param {string[]} taskIds Task ids to link.
|
||||
* @param {{collections?: {tasks?: string, notes?: string, contexts?: string}}} [options] Optional collection overrides.
|
||||
* @returns {Promise<object[]>} Updated task records.
|
||||
*/
|
||||
export const linkTasgridTasksToNote = async (pb, noteOrId, taskIds, options = {}) => {
|
||||
if (!pb || !pb.authStore || !pb.authStore.isValid || !pb.authStore.model) {
|
||||
throw new Error("Invalid or unauthenticated PocketBase instance provided.");
|
||||
}
|
||||
|
||||
const collections = getCollections(options);
|
||||
const cache = getSdkCache(pb);
|
||||
resetCacheIfCollectionsChanged(cache, collections);
|
||||
|
||||
const senderUserId = getCurrentUserId(pb);
|
||||
const senderPersonalContext = senderUserId
|
||||
? await ensureOwnerPersonalContext(pb, senderUserId, collections, cache)
|
||||
: null;
|
||||
const contexts = await loadContexts(pb, collections, cache);
|
||||
const notes = await loadNotes(pb, collections, cache);
|
||||
const note = typeof noteOrId === "string"
|
||||
? notes.find((existing) => existing.id === noteOrId) || mapNoteRecord(await pb.collection(collections.notes).getOne(noteOrId, {
|
||||
fields: "id,key,title,tags,isPrivate,user",
|
||||
requestKey: null,
|
||||
}))
|
||||
: {
|
||||
id: noteOrId.id,
|
||||
key: noteOrId.key || normalizeNoteKey(noteOrId.title || noteOrId.id),
|
||||
title: noteOrId.title || "Untitled",
|
||||
tags: uniqueStrings(noteOrId.tags),
|
||||
isPrivate: !!noteOrId.isPrivate,
|
||||
user: noteOrId.user || "",
|
||||
};
|
||||
|
||||
if (!notes.some((existing) => existing.id === note.id)) {
|
||||
cache.notes = [...notes, note];
|
||||
}
|
||||
|
||||
const noteTag = buildNoteTag(note.key || note.title);
|
||||
const uniqueTaskIds = uniqueStrings(taskIds);
|
||||
const updatedBySupported = await detectUpdatedBySupport(pb, collections.tasks, cache);
|
||||
const updatedRecords = [];
|
||||
|
||||
for (const taskId of uniqueTaskIds) {
|
||||
const record = await pb.collection(collections.tasks).getOne(taskId, {
|
||||
fields: "id,title,tags,labelTags,shareRefs,noteRefs,user,createdBy",
|
||||
requestKey: null,
|
||||
});
|
||||
const currentTask = mapTaskRecord(contexts, cache.notes || notes, record);
|
||||
const favoriteTag = currentTask.tags.find((tag) => tag.endsWith("_favorite__"));
|
||||
const nextTags = uniqueStrings([...(currentTask.tags || []), noteTag]);
|
||||
const derived = deriveTaskRelationsFromTags(
|
||||
contexts,
|
||||
cache.notes || notes,
|
||||
nextTags,
|
||||
currentTask.shareRefs,
|
||||
{}
|
||||
);
|
||||
const baseUpdates = {
|
||||
labelTags: derived.labelTags,
|
||||
shareRefs: derived.shareRefs,
|
||||
noteRefs: derived.noteRefs,
|
||||
tags: buildTaskTags(contexts, cache.notes || notes, derived.labelTags, derived.shareRefs, derived.noteRefs, favoriteTag),
|
||||
};
|
||||
|
||||
const handoffUpdates = applyHandoffs(
|
||||
contexts,
|
||||
cache.notes || notes,
|
||||
senderUserId,
|
||||
senderPersonalContext,
|
||||
currentTask,
|
||||
baseUpdates
|
||||
);
|
||||
const finalShareRefs = handoffUpdates.shareRefs || baseUpdates.shareRefs;
|
||||
const finalOwnerId = resolveMirroredOwnerId(
|
||||
contexts,
|
||||
cache.notes || notes,
|
||||
handoffUpdates.ownerId ?? currentTask.ownerId,
|
||||
finalShareRefs,
|
||||
handoffUpdates.ownerId ?? currentTask.ownerId
|
||||
);
|
||||
|
||||
const updatePayload = {
|
||||
tags: handoffUpdates.tags || baseUpdates.tags,
|
||||
labelTags: handoffUpdates.labelTags || baseUpdates.labelTags,
|
||||
shareRefs: finalShareRefs,
|
||||
noteRefs: handoffUpdates.noteRefs || baseUpdates.noteRefs,
|
||||
};
|
||||
|
||||
if (updatedBySupported) {
|
||||
updatePayload.updatedBy = senderUserId;
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await pb.collection(collections.tasks).update(taskId, updatePayload, { requestKey: null });
|
||||
updatedRecords.push(updated);
|
||||
} catch (err) {
|
||||
if (updatedBySupported && err?.data?.data?.updatedBy) {
|
||||
delete updatePayload.updatedBy;
|
||||
cache.updatedByFieldSupport = false;
|
||||
const updated = await pb.collection(collections.tasks).update(taskId, updatePayload, { requestKey: null });
|
||||
updatedRecords.push(updated);
|
||||
continue;
|
||||
}
|
||||
console.error("TasGrid SDK: Failed to link task to note", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return updatedRecords;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a new note using TasGrid's current creation model.
|
||||
*
|
||||
* @param {object} pb Authenticated PocketBase instance.
|
||||
* @param {object} payload Note payload.
|
||||
* @param {string} [payload.title="New Note"] Note title.
|
||||
* @param {string} [payload.key] Optional canonical note key override.
|
||||
* @param {string} [payload.content=""] HTML content.
|
||||
* @param {string[]} [payload.tags=[]] Note tags.
|
||||
* @param {"collaborative"|"handoff"} [payload.noteShareMode] Note workspace share mode.
|
||||
* @param {boolean} [payload.isPrivate=false] Privacy flag.
|
||||
* @param {string[]} [payload.tasks=[]] Task ids to store and link to this note.
|
||||
* @param {boolean} [payload.linkTasks=true] When tasks are provided, also apply task-side note linkage metadata.
|
||||
* @param {string} [payload.owner] PocketBase user id. Defaults to the current auth user.
|
||||
* @param {{tasks?: string, notes?: string, contexts?: string}} [payload.collections] Optional collection overrides.
|
||||
* @returns {Promise<object>} Created PocketBase 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 { collections, cache, noteData } = await buildNoteCreatePayload(pb, payload);
|
||||
|
||||
try {
|
||||
const record = await pb.collection(collections.notes).create(noteData, { requestKey: null });
|
||||
const mappedNote = mapNoteRecord({ ...record, ...noteData });
|
||||
cache.notes = [...(cache.notes || []), mappedNote];
|
||||
|
||||
if ((noteData.tasks || []).length > 0 && payload.linkTasks !== false) {
|
||||
await linkTasgridTasksToNote(pb, mappedNote, noteData.tasks, { collections });
|
||||
}
|
||||
|
||||
return record;
|
||||
} catch (err) {
|
||||
console.error("TasGrid SDK: Failed to create note", err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
+55
-299
@@ -1,12 +1,11 @@
|
||||
import { type Component, createSignal, onMount, onCleanup, createMemo, createEffect, lazy, Suspense, Show, untrack } from 'solid-js';
|
||||
import { type Component, createSignal, onMount, onCleanup, createMemo, lazy, Suspense, Show } from 'solid-js';
|
||||
import { Layout } from './components/Layout';
|
||||
import { EmbedLayout } from './components/EmbedLayout';
|
||||
import { EmbedAuthWrapper } from './components/EmbedAuthWrapper';
|
||||
import { CriticalView } from './views/CriticalView';
|
||||
import { AuthCallback } from './components/Auth';
|
||||
import { pb } from './lib/pocketbase';
|
||||
import { initStore, setStore, store, advanceAIHelpTipSectionIndex } from './store';
|
||||
import { generateAIHelpTip, hasFireworksApiKey } from './lib/ai-help';
|
||||
import { initStore } from './store';
|
||||
|
||||
// const CriticalView = lazy(() => import('./views/CriticalView').then(m => ({ default: m.CriticalView })));
|
||||
const UrgencyView = lazy(() => import('./views/UrgencyView').then(m => ({ default: m.UrgencyView })));
|
||||
@@ -17,242 +16,25 @@ const SnowballView = lazy(() => import('./views/SnowballView').then(m => ({ defa
|
||||
const DigInView = lazy(() => import('./views/DigInView').then(m => ({ default: m.DigInView })));
|
||||
const ProgressView = lazy(() => import('./views/ProgressView').then(m => ({ default: m.ProgressView })));
|
||||
const HelpView = lazy(() => import('./views/HelpView').then(m => ({ default: m.HelpView })));
|
||||
const AIHelpView = lazy(() => import('./views/AIHelpView').then(m => ({ default: m.AIHelpView })));
|
||||
const NotepadView = lazy(() => import('./views/NotepadView').then(m => ({ default: m.NotepadView })));
|
||||
const QuickEntryForm = lazy(() => import('./components/QuickEntryForm').then(m => ({ default: m.QuickEntryForm })));
|
||||
|
||||
const DEFAULT_VIEW = "critical";
|
||||
const routeToView: Record<string, string> = {
|
||||
critical: "critical",
|
||||
focus: "critical",
|
||||
urgency: "urgency",
|
||||
priority: "priority",
|
||||
matrix: "matrix",
|
||||
snowball: "snowball",
|
||||
"dig-in": "dig_in",
|
||||
"dig_in": "dig_in",
|
||||
progress: "progress",
|
||||
settings: "settings",
|
||||
help: "help",
|
||||
ask: "help_ai",
|
||||
"help-ai": "help_ai",
|
||||
"help_ai": "help_ai",
|
||||
};
|
||||
|
||||
const viewToRoute: Record<string, string> = {
|
||||
critical: "focus",
|
||||
urgency: "urgency",
|
||||
priority: "priority",
|
||||
matrix: "matrix",
|
||||
snowball: "snowball",
|
||||
dig_in: "dig-in",
|
||||
progress: "progress",
|
||||
settings: "settings",
|
||||
help: "help",
|
||||
help_ai: "ask",
|
||||
};
|
||||
|
||||
const normalizeHash = (hash: string) => {
|
||||
if (hash === "#help" || hash.startsWith("#help/")) {
|
||||
return `#/${hash.slice(1)}`;
|
||||
}
|
||||
return hash;
|
||||
};
|
||||
|
||||
const parseHashRoute = (hash: string): { view: string; sectionId?: string } | null => {
|
||||
const normalizedHash = normalizeHash(hash);
|
||||
if (!normalizedHash.startsWith("#/")) return null;
|
||||
|
||||
const routePath = normalizedHash.slice(2);
|
||||
if (!routePath || routePath.startsWith("embed/")) return null;
|
||||
|
||||
const [viewSegment, ...rest] = routePath.split("/");
|
||||
if (decodeURIComponent(viewSegment) === "notes") {
|
||||
return {
|
||||
view: "notes",
|
||||
sectionId: rest.length > 0 ? decodeURIComponent(rest.join("/")) : undefined,
|
||||
};
|
||||
}
|
||||
const view = routeToView[decodeURIComponent(viewSegment)];
|
||||
if (!view) return null;
|
||||
|
||||
if (view === "help") {
|
||||
return {
|
||||
view,
|
||||
sectionId: rest.length > 0 ? decodeURIComponent(rest.join("/")) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return { view };
|
||||
};
|
||||
|
||||
const getHashForView = (view: string, sectionId?: string) => {
|
||||
if (view === "notes") {
|
||||
return sectionId ? `#/notes/${encodeURIComponent(sectionId)}` : "#/notes";
|
||||
}
|
||||
const route = viewToRoute[view] || viewToRoute[DEFAULT_VIEW];
|
||||
if (view === "help" && sectionId) {
|
||||
return `#/${route}/${encodeURIComponent(sectionId)}`;
|
||||
}
|
||||
return `#/${route}`;
|
||||
};
|
||||
|
||||
const App: Component = () => {
|
||||
// Basic routing state
|
||||
const [currentView, setCurrentView] = createSignal(DEFAULT_VIEW);
|
||||
const [lastNonAIView, setLastNonAIView] = createSignal(DEFAULT_VIEW);
|
||||
const [currentView, setCurrentView] = createSignal("critical");
|
||||
const [isAuthenticated, setIsAuthenticated] = createSignal(pb.authStore.isValid);
|
||||
const [authUserId, setAuthUserId] = createSignal<string | null>(pb.authStore.model?.id || null);
|
||||
const [location, setLocation] = createSignal(window.location.href);
|
||||
const [isDesktop, setIsDesktop] = createSignal(window.matchMedia("(min-width: 768px)").matches);
|
||||
const [aiHelpTip, setAiHelpTip] = createSignal<string | null>(null);
|
||||
const [tipGeneratedForUserId, setTipGeneratedForUserId] = createSignal<string | null>(null);
|
||||
const [selectedNoteId, setSelectedNoteId] = createSignal<string | null>(null);
|
||||
|
||||
createEffect(() => {
|
||||
const view = currentView();
|
||||
if (view !== "help_ai") {
|
||||
setLastNonAIView(view);
|
||||
}
|
||||
});
|
||||
|
||||
const effectiveView = createMemo(() =>
|
||||
currentView() === "help_ai" && isDesktop() ? lastNonAIView() : currentView()
|
||||
);
|
||||
|
||||
const syncNotesRoute = (noteId?: string | null, options?: { replace?: boolean }) => {
|
||||
syncUrlToView("notes", { sectionId: noteId || undefined, replace: options?.replace });
|
||||
};
|
||||
|
||||
const openNotes = (noteId?: string | null, options?: { replace?: boolean }) => {
|
||||
setStore('isNotepadMode', true);
|
||||
setSelectedNoteId(noteId || null);
|
||||
syncNotesRoute(noteId, options);
|
||||
};
|
||||
|
||||
const openTasks = (view?: string, options?: { replace?: boolean }) => {
|
||||
setStore('isNotepadMode', false);
|
||||
navigateToView(view || currentView(), options);
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
const userId = authUserId();
|
||||
const authenticated = isAuthenticated();
|
||||
const desktop = isDesktop();
|
||||
const initializing = store.isInitializing;
|
||||
const hasApiKey = hasFireworksApiKey();
|
||||
const alreadyGeneratedForUser = tipGeneratedForUserId() === userId;
|
||||
const prefsLoadedForUser = !!userId && store.prefId === userId;
|
||||
|
||||
if (!authenticated || !userId || !desktop || initializing || !prefsLoadedForUser || !hasApiKey || alreadyGeneratedForUser) return;
|
||||
|
||||
setTipGeneratedForUserId(userId);
|
||||
const sectionIndex = untrack(() => store.aiHelpTipSectionIndex);
|
||||
void (async () => {
|
||||
try {
|
||||
const nextTip = await generateAIHelpTip(sectionIndex);
|
||||
if (!nextTip) return;
|
||||
setAiHelpTip(nextTip);
|
||||
await advanceAIHelpTipSectionIndex();
|
||||
} catch (err) {
|
||||
console.warn("Tip generation skipped", err);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
const syncUrlToView = (view: string, options?: { sectionId?: string; replace?: boolean }) => {
|
||||
if (isEmbed()) return;
|
||||
|
||||
const nextHash = getHashForView(view, options?.sectionId);
|
||||
if (window.location.hash === nextHash) return;
|
||||
|
||||
if (options?.replace) {
|
||||
const nextUrl = `${window.location.pathname}${window.location.search}${nextHash}`;
|
||||
window.history.replaceState(window.history.state, "", nextUrl);
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.hash = nextHash;
|
||||
};
|
||||
|
||||
const navigateToView = (view: string, options?: { sectionId?: string; replace?: boolean }) => {
|
||||
setCurrentView(view);
|
||||
syncUrlToView(view, options);
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
const mediaQuery = window.matchMedia("(min-width: 768px)");
|
||||
const openHelpGuide = (sectionId?: string) => {
|
||||
setStore('isNotepadMode', false);
|
||||
navigateToView("help", { sectionId });
|
||||
window.dispatchEvent(new CustomEvent("taskgrid:help-target", { detail: { sectionId } }));
|
||||
};
|
||||
|
||||
const openAIHelp = () => {
|
||||
setStore('isNotepadMode', false);
|
||||
navigateToView("help_ai");
|
||||
};
|
||||
|
||||
const handleMediaChange = (event: MediaQueryListEvent) => {
|
||||
setIsDesktop(event.matches);
|
||||
};
|
||||
|
||||
const handleLocChange = () => {
|
||||
setLocation(window.location.href);
|
||||
|
||||
if (isEmbed()) return;
|
||||
|
||||
const routeState = parseHashRoute(window.location.hash);
|
||||
if (routeState) {
|
||||
if (routeState.view === "notes") {
|
||||
setStore('isNotepadMode', true);
|
||||
setSelectedNoteId(routeState.sectionId || null);
|
||||
return;
|
||||
}
|
||||
|
||||
setStore('isNotepadMode', false);
|
||||
setCurrentView(routeState.view);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedHash = normalizeHash(window.location.hash);
|
||||
if (normalizedHash !== window.location.hash) {
|
||||
const nextUrl = `${window.location.pathname}${window.location.search}${normalizedHash}`;
|
||||
window.history.replaceState(window.history.state, "", nextUrl);
|
||||
setLocation(window.location.href);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.location.hash) {
|
||||
if (store.isNotepadMode) {
|
||||
syncNotesRoute(selectedNoteId(), { replace: true });
|
||||
} else {
|
||||
syncUrlToView(currentView(), { replace: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
(window as any).setCurrentView = navigateToView;
|
||||
(window as any).openTasGridHelp = openHelpGuide;
|
||||
(window as any).openTasGridAIHelp = openAIHelp;
|
||||
|
||||
const handleLocChange = () => setLocation(window.location.href);
|
||||
window.addEventListener('popstate', handleLocChange);
|
||||
window.addEventListener('hashchange', handleLocChange);
|
||||
mediaQuery.addEventListener('change', handleMediaChange);
|
||||
|
||||
let isFirstRun = true;
|
||||
const removeListener = pb.authStore.onChange((token) => {
|
||||
console.log('[DEBUG] pb.authStore.onChange fired, token present:', !!token);
|
||||
const wasAuthenticated = isAuthenticated();
|
||||
const previousUserId = authUserId();
|
||||
const nextUserId = token ? (pb.authStore.model?.id || null) : null;
|
||||
setIsAuthenticated(!!token);
|
||||
setAuthUserId(nextUserId);
|
||||
|
||||
if (!token || nextUserId !== previousUserId) {
|
||||
setTipGeneratedForUserId(null);
|
||||
setAiHelpTip(null);
|
||||
}
|
||||
|
||||
if (token && (isFirstRun || !wasAuthenticated)) {
|
||||
initStore();
|
||||
@@ -262,12 +44,8 @@ const App: Component = () => {
|
||||
|
||||
onCleanup(() => {
|
||||
removeListener();
|
||||
delete (window as any).setCurrentView;
|
||||
delete (window as any).openTasGridHelp;
|
||||
delete (window as any).openTasGridAIHelp;
|
||||
window.removeEventListener('popstate', handleLocChange);
|
||||
window.removeEventListener('hashchange', handleLocChange);
|
||||
mediaQuery.removeEventListener('change', handleMediaChange);
|
||||
});
|
||||
|
||||
// Background preload of other views when idle
|
||||
@@ -282,7 +60,6 @@ const App: Component = () => {
|
||||
import('./views/DigInView');
|
||||
import('./views/ProgressView');
|
||||
import('./views/HelpView');
|
||||
import('./views/AIHelpView');
|
||||
|
||||
// Remove splash screen after high priority work is done
|
||||
const splash = document.getElementById('splash');
|
||||
@@ -291,8 +68,6 @@ const App: Component = () => {
|
||||
setTimeout(() => splash.remove(), 500);
|
||||
}
|
||||
});
|
||||
|
||||
handleLocChange();
|
||||
});
|
||||
|
||||
// Determine if we are in an embed route (check both path and hash for compatibility)
|
||||
@@ -301,7 +76,9 @@ const App: Component = () => {
|
||||
const url = new URL(loc);
|
||||
const path = url.pathname;
|
||||
const hash = url.hash;
|
||||
return path.startsWith('/embed/') || hash.startsWith('#/embed/');
|
||||
const res = path.startsWith('/embed/') || hash.startsWith('#/embed/');
|
||||
console.log('[DEBUG] isEmbed check:', { res, path, hash, href: loc });
|
||||
return res;
|
||||
};
|
||||
|
||||
const getEmbedView = () => {
|
||||
@@ -311,78 +88,57 @@ const App: Component = () => {
|
||||
let view = null;
|
||||
if (fullPath.includes('/notes')) view = 'embed_notes';
|
||||
else if (fullPath.includes('/quick-add')) view = 'embed_quick_add';
|
||||
console.log('[DEBUG] getEmbedView detected:', view, 'Full URL context:', fullPath);
|
||||
return view;
|
||||
};
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={isEmbed()}
|
||||
fallback={
|
||||
<Show when={isAuthenticated()} fallback={<AuthCallback onSuccess={() => { }} />}>
|
||||
<Layout
|
||||
currentView={effectiveView()}
|
||||
setView={navigateToView}
|
||||
selectedNoteId={selectedNoteId()}
|
||||
setSelectedNoteId={setSelectedNoteId}
|
||||
onOpenTasks={openTasks}
|
||||
onOpenNotes={openNotes}
|
||||
isAIHelpOpen={currentView() === "help_ai" && isDesktop()}
|
||||
onToggleAIHelp={() => navigateToView(currentView() === "help_ai" ? lastNonAIView() : "help_ai")}
|
||||
aiHelpTip={aiHelpTip()}
|
||||
hideQuickEntry={currentView() === "help_ai" && !isDesktop()}
|
||||
>
|
||||
<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={effectiveView() === "critical"}><CriticalView /></Show>
|
||||
<Show when={effectiveView() === "urgency"}><UrgencyView /></Show>
|
||||
<Show when={effectiveView() === "priority"}><PriorityView /></Show>
|
||||
<Show when={effectiveView() === "matrix"}><MatrixView /></Show>
|
||||
<Show when={effectiveView() === "snowball"}><SnowballView /></Show>
|
||||
<Show when={effectiveView() === "dig_in"}><DigInView /></Show>
|
||||
<Show when={effectiveView() === "progress"}><ProgressView /></Show>
|
||||
<Show when={effectiveView() === "settings"}><SettingsView setView={navigateToView} /></Show>
|
||||
<Show when={effectiveView() === "help_ai"}><AIHelpView /></Show>
|
||||
<Show when={effectiveView() === "help"}><HelpView onBack={() => navigateToView("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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,281 +0,0 @@
|
||||
import { type Component, For, Show, createEffect, createMemo, createSignal } from "solid-js";
|
||||
import { MessageCircle, LoaderCircle, RefreshCw, ArrowUp } from "lucide-solid";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FIREWORKS_HELP_MODEL } from "@/lib/app-config";
|
||||
import { askAIHelp, hasFireworksApiKey } from "@/lib/ai-help";
|
||||
import { MarkdownMessage } from "./MarkdownMessage";
|
||||
|
||||
type ChatMessage = {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
};
|
||||
|
||||
const SUGGESTED_PROMPTS = [
|
||||
"How does sharing work in TasGrid?",
|
||||
"What is the difference between Handoff and Collaborative?",
|
||||
"How do linked tasks and notes work together?",
|
||||
"Why might a task disappear from my view?",
|
||||
"How do recurrence and templates behave?"
|
||||
];
|
||||
|
||||
const INITIAL_MESSAGE = "Ask a question about how TasGrid works.";
|
||||
|
||||
export const AIHelpPanel: Component<{
|
||||
variant?: "page" | "sidebar";
|
||||
class?: string;
|
||||
}> = (props) => {
|
||||
const variant = () => props.variant ?? "page";
|
||||
const [messages, setMessages] = createSignal<ChatMessage[]>([
|
||||
{ role: "assistant", content: INITIAL_MESSAGE }
|
||||
]);
|
||||
const [input, setInput] = createSignal("");
|
||||
const [isLoading, setIsLoading] = createSignal(false);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
let messagesViewport: HTMLDivElement | undefined;
|
||||
|
||||
const hasApiKey = createMemo(() => hasFireworksApiKey());
|
||||
const showSuggestions = createMemo(() =>
|
||||
hasApiKey() && messages().length === 1 && messages()[0]?.role === "assistant"
|
||||
);
|
||||
|
||||
createEffect(() => {
|
||||
messages();
|
||||
isLoading();
|
||||
queueMicrotask(() => {
|
||||
messagesViewport?.scrollTo({
|
||||
top: messagesViewport.scrollHeight,
|
||||
behavior: "smooth"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const submitQuestion = async (questionOverride?: string) => {
|
||||
const question = (questionOverride ?? input()).trim();
|
||||
if (!question || isLoading()) return;
|
||||
|
||||
const priorMessages = messages();
|
||||
const userMessage: ChatMessage = { role: "user", content: question };
|
||||
const nextMessages = [...priorMessages, userMessage];
|
||||
|
||||
setMessages(nextMessages);
|
||||
setInput("");
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
if (!hasApiKey()) {
|
||||
setMessages([
|
||||
...nextMessages,
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Questions are not configured yet. Add `VITE_FIREWORKS_API_KEY` to the app environment to enable responses."
|
||||
}
|
||||
]);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const assistantText = await askAIHelp(nextMessages);
|
||||
setMessages(prev => [...prev, { role: "assistant", content: assistantText }]);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
setError(message);
|
||||
setMessages(prev => [
|
||||
...prev,
|
||||
{
|
||||
role: "assistant",
|
||||
content: "I couldn't get a response from the help model just now. Try again in a moment."
|
||||
}
|
||||
]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetChat = () => {
|
||||
setMessages([{ role: "assistant", content: INITIAL_MESSAGE }]);
|
||||
setInput("");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const isSidebar = () => variant() === "sidebar";
|
||||
|
||||
return (
|
||||
<div
|
||||
class={cn(
|
||||
"flex min-h-0 flex-col overflow-hidden",
|
||||
isSidebar()
|
||||
? "rounded-2xl border border-border bg-card"
|
||||
: "h-full rounded-2xl border border-border bg-card",
|
||||
props.class
|
||||
)}
|
||||
>
|
||||
<div class={cn(
|
||||
"flex items-center justify-between gap-3 border-b border-border",
|
||||
isSidebar() ? "px-3 py-3" : "px-4 py-4 sm:px-5"
|
||||
)}>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<MessageCircle size={16} class="text-primary shrink-0" />
|
||||
<h1 class={cn("font-semibold tracking-tight", isSidebar() ? "text-sm" : "text-lg")}>Ask Questions</h1>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="gap-2 rounded-lg shrink-0"
|
||||
onClick={resetChat}
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
New
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class={cn(
|
||||
"flex min-h-0 flex-1 flex-col",
|
||||
isSidebar() ? "p-3 gap-3" : "p-4 gap-3 sm:p-5"
|
||||
)}>
|
||||
<Show when={!hasApiKey()}>
|
||||
<div class={cn(
|
||||
"rounded-2xl border border-amber-500/30 bg-amber-500/10",
|
||||
isSidebar() ? "p-3 text-[0.75rem]" : "p-4 text-sm"
|
||||
)}>
|
||||
Add `VITE_FIREWORKS_API_KEY` to enable Ask Questions. The view is wired to Fireworks model `{FIREWORKS_HELP_MODEL}`.
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div
|
||||
ref={messagesViewport}
|
||||
class={cn(
|
||||
"min-h-0 flex-1 overflow-y-auto space-y-3",
|
||||
isSidebar() ? "px-0.5" : "px-0.5"
|
||||
)}
|
||||
>
|
||||
<For each={messages()}>
|
||||
{(message) => (
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
class={cn(
|
||||
"flex",
|
||||
message.role === "user"
|
||||
? "justify-center pt-8 pb-0"
|
||||
: "justify-start"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
class={cn(
|
||||
"rounded-2xl px-4 leading-relaxed whitespace-pre-wrap shadow-sm",
|
||||
message.role === "assistant"
|
||||
? "w-full bg-card border border-border px-4 py-3 text-foreground"
|
||||
: cn(
|
||||
"bg-card px-4 pt-3 pb-0 text-foreground",
|
||||
isSidebar() ? "max-w-[92%]" : "max-w-[90%] sm:max-w-[80%]"
|
||||
)
|
||||
)}
|
||||
>
|
||||
<Show when={message.role === "assistant"}>
|
||||
<div class="mb-1 flex items-center gap-2 text-[0.625rem] font-black uppercase tracking-[0.18em] opacity-70">
|
||||
<MessageCircle size={12} />
|
||||
<span>TasGrid Help</span>
|
||||
</div>
|
||||
</Show>
|
||||
<MarkdownMessage
|
||||
content={message.content}
|
||||
class=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={showSuggestions() && message.role === "assistant"}>
|
||||
<div class={cn("flex flex-wrap gap-2", isSidebar() ? "pl-1" : "pl-1")}>
|
||||
<For each={SUGGESTED_PROMPTS}>
|
||||
{(prompt) => (
|
||||
<button
|
||||
class={cn(
|
||||
"rounded-full border border-border bg-card text-left font-medium text-foreground transition-colors hover:bg-muted",
|
||||
isSidebar() ? "px-2.5 py-1.5 text-[0.6875rem]" : "px-3 py-2 text-xs sm:text-sm"
|
||||
)}
|
||||
onClick={() => void submitQuestion(prompt)}
|
||||
disabled={isLoading()}
|
||||
>
|
||||
{prompt}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show when={isLoading()}>
|
||||
<div class="flex justify-start">
|
||||
<div class={cn(
|
||||
"rounded-2xl px-4 py-3 leading-relaxed shadow-sm bg-card border border-border text-foreground",
|
||||
isSidebar() ? "max-w-[92%] text-sm" : "max-w-[80%] text-sm"
|
||||
)}>
|
||||
<div class="mb-1 flex items-center gap-2 text-[0.625rem] font-black uppercase tracking-[0.18em] opacity-70">
|
||||
<MessageCircle size={12} />
|
||||
<span>TasGrid Help</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-muted-foreground">
|
||||
<LoaderCircle size={14} class="animate-spin" />
|
||||
Finding an answer...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={error()}>
|
||||
{(message) => (
|
||||
<div class={cn(
|
||||
"rounded-xl border border-destructive/30 bg-destructive/10 text-destructive",
|
||||
isSidebar() ? "px-3 py-2 text-[0.75rem]" : "px-4 py-3 text-sm"
|
||||
)}>
|
||||
Request error: {message()}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<form
|
||||
class={cn(
|
||||
"space-y-2"
|
||||
)}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void submitQuestion();
|
||||
}}
|
||||
>
|
||||
<div class="relative">
|
||||
<textarea
|
||||
value={input()}
|
||||
onInput={(e) => setInput(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void submitQuestion();
|
||||
}
|
||||
}}
|
||||
placeholder="Ask how a TasGrid feature works, what a setting does, or why something may behave a certain way..."
|
||||
class={cn(
|
||||
"flex w-full rounded-xl border border-input bg-background transition-colors placeholder:text-muted-foreground/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-none leading-relaxed",
|
||||
isSidebar()
|
||||
? "min-h-[88px] px-3 py-2.5 pr-14 text-sm"
|
||||
: "min-h-[96px] px-3 py-2.5 pr-14 text-sm"
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
class="absolute bottom-3 right-3 h-8 w-8 rounded-full shadow-sm"
|
||||
disabled={isLoading() || !input().trim()}
|
||||
aria-label="Ask Question"
|
||||
title="Ask Question"
|
||||
>
|
||||
<ArrowUp size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
+25
-111
@@ -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, activeTaskId, setActiveTaskId, requestImmediateNotesMetadataLoad, fetchNoteById, updateStoreWithNote } 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,45 +9,33 @@ 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;
|
||||
currentView: string;
|
||||
setView: (v: string) => void;
|
||||
selectedNoteId: string | null;
|
||||
setSelectedNoteId: (id: string | null) => void;
|
||||
onOpenTasks: (view?: string, options?: { replace?: boolean }) => void;
|
||||
onOpenNotes: (noteId?: string | null, options?: { replace?: boolean }) => void;
|
||||
isAIHelpOpen?: boolean;
|
||||
onToggleAIHelp?: () => void;
|
||||
aiHelpTip?: string | null;
|
||||
hideQuickEntry?: boolean;
|
||||
}
|
||||
|
||||
export const Layout: Component<LayoutProps> = (props) => {
|
||||
const [isSidebarLocked, setIsSidebarLocked] = createSignal(true);
|
||||
const [isSidebarPeeking, setIsSidebarPeeking] = createSignal(false);
|
||||
const [isDropdownOpen, setIsDropdownOpen] = createSignal(false);
|
||||
const [resolvingRoutedNoteId, setResolvingRoutedNoteId] = createSignal<string | null>(null);
|
||||
|
||||
const setActiveNoteId = (id: string | null) => {
|
||||
props.setSelectedNoteId(id);
|
||||
if (store.isNotepadMode) {
|
||||
props.onOpenNotes(id);
|
||||
}
|
||||
};
|
||||
// Lift selectedNoteId up to Layout so it can be passed to NotesSidebar and NotepadView
|
||||
const [selectedNoteId, setSelectedNoteId] = createSignal<string | null>(null);
|
||||
|
||||
// Make it available globally for QuickEntry auto-tagging without circular deps
|
||||
createEffect(() => {
|
||||
(window as any)._activeNoteId = props.selectedNoteId;
|
||||
(window as any)._activeNoteId = selectedNoteId();
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const handleOpenNote = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (detail && detail.noteId) {
|
||||
props.onOpenNotes(detail.noteId);
|
||||
setStore('isNotepadMode', true);
|
||||
setSelectedNoteId(detail.noteId);
|
||||
}
|
||||
};
|
||||
window.addEventListener('open-note', handleOpenNote);
|
||||
@@ -66,70 +54,15 @@ 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 = props.selectedNoteId;
|
||||
if (!noteId) return;
|
||||
|
||||
const noteExists = store.notes.some(note => note.id === noteId);
|
||||
if (noteExists) {
|
||||
if (resolvingRoutedNoteId() === noteId) {
|
||||
setResolvingRoutedNoteId(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (store.isNotesLoading || !store.hasLoadedNotesMetadata) return;
|
||||
if (resolvingRoutedNoteId() === noteId) return;
|
||||
|
||||
setResolvingRoutedNoteId(noteId);
|
||||
void (async () => {
|
||||
try {
|
||||
const fetched = await fetchNoteById(noteId);
|
||||
if (fetched) {
|
||||
updateStoreWithNote(fetched);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to resolve routed note", error);
|
||||
}
|
||||
|
||||
if (props.selectedNoteId === noteId) {
|
||||
setActiveNoteId(null);
|
||||
props.onOpenNotes(null, { replace: true });
|
||||
}
|
||||
setResolvingRoutedNoteId(current => current === noteId ? null : current);
|
||||
})();
|
||||
});
|
||||
|
||||
return (
|
||||
(() => {
|
||||
const sidebarWidthClass = () => {
|
||||
if (store.isNotepadMode) return "w-80";
|
||||
if (props.isAIHelpOpen) return "w-[24rem]";
|
||||
return "w-48";
|
||||
};
|
||||
|
||||
const sidebarPaddingClass = () => {
|
||||
if (store.isNotepadMode) return "md:pl-80";
|
||||
if (props.isAIHelpOpen) return "md:pl-[24rem]";
|
||||
return "md:pl-48";
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="flex h-screen w-full min-w-0 bg-background overflow-hidden relative font-sans antialiased text-foreground">
|
||||
{/* Desktop UI Container */}
|
||||
<div
|
||||
style={{ "will-change": "width, transform" }}
|
||||
class={cn(
|
||||
"hidden md:flex flex-col border-r border-border bg-card h-screen fixed left-0 top-0 transition-all duration-200 ease-in-out z-[50] overflow-hidden",
|
||||
isSidebarLocked() ? `${sidebarWidthClass()} translate-x-0` : `${sidebarWidthClass()} -translate-x-full`,
|
||||
!isSidebarLocked() && (isSidebarPeeking() || (store.isNotepadMode && !props.selectedNoteId)) && "translate-x-0 shadow-2xl ring-1 ring-border"
|
||||
isSidebarLocked() ? (store.isNotepadMode ? "w-80 translate-x-0" : "w-48 translate-x-0") : (store.isNotepadMode ? "w-80 -translate-x-full" : "w-48 -translate-x-full"),
|
||||
!isSidebarLocked() && (isSidebarPeeking() || (store.isNotepadMode && !selectedNoteId())) && "translate-x-0 shadow-2xl ring-1 ring-border"
|
||||
)}
|
||||
onMouseEnter={() => !isSidebarLocked() && setIsSidebarPeeking(true)}
|
||||
onMouseLeave={() => {
|
||||
@@ -149,9 +82,6 @@ export const Layout: Component<LayoutProps> = (props) => {
|
||||
<Sidebar
|
||||
currentView={props.currentView}
|
||||
setView={props.setView}
|
||||
isAIHelpOpen={!!props.isAIHelpOpen}
|
||||
onToggleAIHelp={() => props.onToggleAIHelp?.()}
|
||||
aiHelpTip={props.aiHelpTip}
|
||||
isLocked={isSidebarLocked()}
|
||||
setIsLocked={setIsSidebarLocked}
|
||||
isPeeking={isSidebarPeeking()}
|
||||
@@ -192,8 +122,8 @@ export const Layout: Component<LayoutProps> = (props) => {
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class={cn("flex-1 overflow-hidden transition-all duration-200", (!isSidebarLocked() && !isSidebarPeeking() && !(store.isNotepadMode && !props.selectedNoteId)) ? "opacity-0 pointer-events-none scale-95" : "opacity-100 scale-100")}>
|
||||
<NotesSidebar selectedNoteId={props.selectedNoteId} setSelectedNoteId={setActiveNoteId} />
|
||||
<div class={cn("flex-1 overflow-hidden transition-all duration-200", (!isSidebarLocked() && !isSidebarPeeking() && !(store.isNotepadMode && !selectedNoteId())) ? "opacity-0 pointer-events-none scale-95" : "opacity-100 scale-100")}>
|
||||
<NotesSidebar selectedNoteId={selectedNoteId()} setSelectedNoteId={setSelectedNoteId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -204,7 +134,7 @@ export const Layout: Component<LayoutProps> = (props) => {
|
||||
class={cn(
|
||||
"flex-1 flex flex-col min-w-0 transition-all duration-200 ease-in-out h-screen",
|
||||
// Only apply md padding if locked
|
||||
isSidebarLocked() ? sidebarPaddingClass() : "md:pl-0"
|
||||
isSidebarLocked() ? (store.isNotepadMode ? "md:pl-80" : "md:pl-48") : "md:pl-0"
|
||||
)}>
|
||||
{/* Expand Trigger Button (visible when not locked) */}
|
||||
{!isSidebarLocked() && (
|
||||
@@ -242,7 +172,7 @@ export const Layout: Component<LayoutProps> = (props) => {
|
||||
"flex-1 h-8 text-[0.7rem] font-bold uppercase tracking-widest z-10 transition-colors duration-200 flex items-center justify-center rounded-lg",
|
||||
!store.isNotepadMode ? "text-primary-foreground" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => props.onOpenTasks()}
|
||||
onClick={() => setStore('isNotepadMode', false)}
|
||||
>
|
||||
Tasks
|
||||
</button>
|
||||
@@ -251,43 +181,31 @@ export const Layout: Component<LayoutProps> = (props) => {
|
||||
"flex-1 h-8 text-[0.7rem] font-bold uppercase tracking-widest z-10 transition-colors duration-200 flex items-center justify-center rounded-lg",
|
||||
store.isNotepadMode ? "text-primary-foreground" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => props.onOpenNotes(props.selectedNoteId)}
|
||||
onClick={() => setStore('isNotepadMode', true)}
|
||||
>
|
||||
Notes
|
||||
</button>
|
||||
</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-x-hidden relative scroll-smooth",
|
||||
props.currentView === "help_ai" ? "overflow-hidden" : "overflow-y-auto",
|
||||
!store.isNotepadMode ? "sm:pb-6" : ""
|
||||
"flex-1 min-w-0 overflow-y-auto overflow-x-hidden relative scroll-smooth",
|
||||
!store.isNotepadMode ? "pb-32 sm:pb-6" : ""
|
||||
)}>
|
||||
<div class={cn(
|
||||
"w-full mx-auto",
|
||||
props.currentView === "help_ai"
|
||||
? "max-w-screen-2xl px-4 sm:px-8 lg:px-12 pt-16 sm:pt-20 pb-[calc(4.5rem+env(safe-area-inset-bottom,0px))] sm:pb-8 h-full"
|
||||
: !store.isNotepadMode
|
||||
? "min-h-full 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" : props.currentView === "help_ai" ? "block h-full" : "block")}>
|
||||
<Show when={!["settings", "help", "help_ai"].includes(props.currentView.toLowerCase())}>
|
||||
<TaskSearchPopover />
|
||||
<div class={cn(store.isNotepadMode ? "hidden" : "block")}>
|
||||
<Show when={props.currentView.toLowerCase() !== "settings"}>
|
||||
<FilterBar />
|
||||
</Show>
|
||||
{props.children}
|
||||
</div>
|
||||
<div class={cn(!store.isNotepadMode ? "hidden" : "block", "h-[calc(100vh-3.5rem)] md:h-[calc(100vh-8rem)] w-full")}>
|
||||
<Suspense>
|
||||
<NotepadView selectedNoteId={props.selectedNoteId} setSelectedNoteId={setActiveNoteId} />
|
||||
<NotepadView selectedNoteId={selectedNoteId()} setSelectedNoteId={setSelectedNoteId} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
@@ -298,11 +216,9 @@ export const Layout: Component<LayoutProps> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={!props.hideQuickEntry}>
|
||||
<Suspense>
|
||||
<QuickEntry />
|
||||
</Suspense>
|
||||
</Show>
|
||||
<Suspense>
|
||||
<QuickEntry />
|
||||
</Suspense>
|
||||
|
||||
<Show when={activeTask()}>
|
||||
<Suspense>
|
||||
@@ -314,7 +230,5 @@ export const Layout: Component<LayoutProps> = (props) => {
|
||||
</Suspense>
|
||||
</Show>
|
||||
</div >
|
||||
);
|
||||
})()
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
import { type Component, createMemo } from "solid-js";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const escapeHtml = (value: string) =>
|
||||
value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
const applyInlineMarkdown = (value: string) => {
|
||||
let text = escapeHtml(value);
|
||||
|
||||
text = text.replace(/`([^`]+)`/g, "<code>$1</code>");
|
||||
text = text.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
|
||||
text = text.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||
text = text.replace(/(^|[\s(])\*([^*]+)\*(?=[\s).,!?:;]|$)/g, '$1<em>$2</em>');
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
const splitTableRow = (line: string) => {
|
||||
const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
|
||||
return trimmed.split("|").map(cell => applyInlineMarkdown(cell.trim()));
|
||||
};
|
||||
|
||||
const isTableSeparator = (line: string) => {
|
||||
const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
|
||||
if (!trimmed) return false;
|
||||
|
||||
return trimmed
|
||||
.split("|")
|
||||
.every(cell => /^:?-{3,}:?$/.test(cell.trim()));
|
||||
};
|
||||
|
||||
const isTableRow = (line: string) => {
|
||||
const trimmed = line.trim();
|
||||
return trimmed.includes("|") && splitTableRow(trimmed).length > 1;
|
||||
};
|
||||
|
||||
const renderMarkdown = (markdown: string) => {
|
||||
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
|
||||
const html: string[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("```")) {
|
||||
const codeLines: string[] = [];
|
||||
i += 1;
|
||||
while (i < lines.length && !lines[i].trim().startsWith("```")) {
|
||||
codeLines.push(lines[i]);
|
||||
i += 1;
|
||||
}
|
||||
if (i < lines.length) i += 1;
|
||||
html.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (headingMatch) {
|
||||
const level = headingMatch[1].length;
|
||||
const text = applyInlineMarkdown(headingMatch[2].trim());
|
||||
html.push(`<h${level}>${text}</h${level}>`);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
i + 1 < lines.length &&
|
||||
isTableRow(lines[i]) &&
|
||||
isTableSeparator(lines[i + 1])
|
||||
) {
|
||||
const headerCells = splitTableRow(lines[i]);
|
||||
const bodyRows: string[] = [];
|
||||
i += 2;
|
||||
|
||||
while (i < lines.length && lines[i].trim() && isTableRow(lines[i])) {
|
||||
const cells = splitTableRow(lines[i]).map(cell => `<td>${cell}</td>`);
|
||||
bodyRows.push(`<tr>${cells.join("")}</tr>`);
|
||||
i += 1;
|
||||
}
|
||||
|
||||
html.push(
|
||||
`<div class="table-wrap"><table><thead><tr>${headerCells.map(cell => `<th>${cell}</th>`).join("")}</tr></thead>${bodyRows.length > 0 ? `<tbody>${bodyRows.join("")}</tbody>` : ""}</table></div>`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^[-*]\s+/.test(trimmed)) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && /^[-*]\s+/.test(lines[i].trim())) {
|
||||
items.push(`<li>${applyInlineMarkdown(lines[i].trim().replace(/^[-*]\s+/, ""))}</li>`);
|
||||
i += 1;
|
||||
}
|
||||
html.push(`<ul>${items.join("")}</ul>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\d+\.\s+/.test(trimmed)) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) {
|
||||
items.push(`<li>${applyInlineMarkdown(lines[i].trim().replace(/^\d+\.\s+/, ""))}</li>`);
|
||||
i += 1;
|
||||
}
|
||||
html.push(`<ol>${items.join("")}</ol>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^>\s?/.test(trimmed)) {
|
||||
const quoteLines: string[] = [];
|
||||
while (i < lines.length && /^>\s?/.test(lines[i].trim())) {
|
||||
quoteLines.push(applyInlineMarkdown(lines[i].trim().replace(/^>\s?/, "")));
|
||||
i += 1;
|
||||
}
|
||||
html.push(`<blockquote><p>${quoteLines.join("<br />")}</p></blockquote>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const paragraphLines: string[] = [];
|
||||
while (i < lines.length && lines[i].trim() && !lines[i].trim().startsWith("```") && !/^[-*]\s+/.test(lines[i].trim()) && !/^\d+\.\s+/.test(lines[i].trim()) && !/^>\s?/.test(lines[i].trim())) {
|
||||
paragraphLines.push(applyInlineMarkdown(lines[i].trim()));
|
||||
i += 1;
|
||||
}
|
||||
html.push(`<p>${paragraphLines.join("<br />")}</p>`);
|
||||
}
|
||||
|
||||
return html.join("");
|
||||
};
|
||||
|
||||
export const MarkdownMessage: Component<{
|
||||
content: string;
|
||||
class?: string;
|
||||
}> = (props) => {
|
||||
const html = createMemo(() => renderMarkdown(props.content));
|
||||
|
||||
return (
|
||||
<div
|
||||
class={cn(
|
||||
"prose prose-sm dark:prose-invert max-w-none",
|
||||
"prose-headings:mt-4 prose-headings:mb-2 prose-headings:font-semibold prose-headings:tracking-tight",
|
||||
"prose-h1:text-xl prose-h2:text-lg prose-h3:text-base prose-h4:text-sm",
|
||||
"prose-p:my-2 prose-ul:my-2 prose-ol:my-2 prose-li:my-0",
|
||||
"prose-pre:my-3 prose-pre:rounded-lg prose-pre:bg-muted prose-pre:p-3",
|
||||
"prose-code:rounded prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:text-[0.9em]",
|
||||
"prose-code:before:content-none prose-code:after:content-none",
|
||||
"prose-a:text-primary prose-a:underline prose-a:underline-offset-4",
|
||||
"prose-blockquote:border-l-4 prose-blockquote:border-primary prose-blockquote:pl-4 prose-blockquote:italic",
|
||||
"prose-table:my-3 prose-table:w-full prose-table:border-collapse prose-th:border prose-th:border-border prose-th:bg-muted prose-th:px-2 prose-th:py-1 prose-th:text-left prose-th:font-semibold",
|
||||
"prose-td:border prose-td:border-border prose-td:px-2 prose-td:py-1 align-top",
|
||||
"[&_div.table-wrap]:my-3 [&_div.table-wrap]:overflow-x-auto",
|
||||
props.class
|
||||
)}
|
||||
innerHTML={html()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
+63
-102
@@ -1,12 +1,11 @@
|
||||
import { type Component, For, Show } from "solid-js";
|
||||
import { LayoutDashboard, ListTodo, Settings, Clock, ArrowUpCircle, PanelLeftClose, Snowflake, Pickaxe, ChevronDown, Gauge, TrendingUp, Users, Box, BarChart3, HelpCircle, MessageCircle } from "lucide-solid";
|
||||
import { LayoutDashboard, ListTodo, Settings, Clock, ArrowUpCircle, PanelLeftClose, Snowflake, Pickaxe, ChevronDown, Gauge, TrendingUp, Users, Box, BarChart3, HelpCircle } from "lucide-solid";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "./ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
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 { AIHelpPanel } from "./AIHelpPanel";
|
||||
import { createResource, createSignal, createEffect } from "solid-js";
|
||||
|
||||
interface NavItem {
|
||||
icon: any;
|
||||
@@ -35,11 +34,11 @@ interface MobileNavGroup {
|
||||
|
||||
const mobileNavGroups: MobileNavGroup[] = [
|
||||
{ icon: ListTodo, label: "Focus", view: "critical" },
|
||||
{ icon: LayoutDashboard, label: "Matrix", view: "matrix" },
|
||||
{
|
||||
icon: TrendingUp, label: "Value", items: [
|
||||
{ icon: ArrowUpCircle, label: "Priority", view: "priority" },
|
||||
{ icon: Clock, label: "Urgency", view: "urgency" },
|
||||
{ icon: LayoutDashboard, label: "Matrix", view: "matrix" },
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -49,7 +48,6 @@ const mobileNavGroups: MobileNavGroup[] = [
|
||||
{ icon: BarChart3, label: "Progress", view: "progress" },
|
||||
]
|
||||
},
|
||||
{ icon: MessageCircle, label: "Ask", view: "help_ai" },
|
||||
{ icon: Settings, label: "Settings", view: "settings" },
|
||||
];
|
||||
|
||||
@@ -60,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={
|
||||
@@ -129,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
|
||||
@@ -143,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
|
||||
@@ -190,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()}>
|
||||
@@ -205,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>
|
||||
@@ -217,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>
|
||||
@@ -231,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">
|
||||
@@ -268,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>
|
||||
@@ -304,9 +295,6 @@ export const ContextSwitcher: Component<{
|
||||
export const Sidebar: Component<{
|
||||
currentView: string;
|
||||
setView: (v: string) => void;
|
||||
isAIHelpOpen: boolean;
|
||||
onToggleAIHelp: () => void;
|
||||
aiHelpTip?: string | null;
|
||||
isLocked: boolean;
|
||||
setIsLocked: (v: boolean) => void;
|
||||
isPeeking: boolean;
|
||||
@@ -357,19 +345,9 @@ export const Sidebar: Component<{
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="px-3 pb-2">
|
||||
<ContextSwitcher
|
||||
isLocked={props.isLocked}
|
||||
isPeeking={props.isPeeking}
|
||||
setIsPeeking={props.setIsPeeking}
|
||||
onOpenChange={(open) => {
|
||||
setSwitcherOpen(open);
|
||||
props.onDropdownOpenChange?.(open);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<nav class="flex-1 px-3 space-y-1 mt-2 min-h-0">
|
||||
|
||||
<nav class="flex-1 px-3 space-y-1 mt-2">
|
||||
<For each={desktopNavItems}>
|
||||
{(item) => (
|
||||
<button
|
||||
@@ -388,39 +366,23 @@ export const Sidebar: Component<{
|
||||
</For>
|
||||
</nav>
|
||||
|
||||
<div class="px-3 mt-auto pb-3 space-y-3">
|
||||
<div
|
||||
class={cn(
|
||||
"transition-all duration-300 ease-out overflow-hidden",
|
||||
props.isAIHelpOpen ? "max-h-[44rem] opacity-100" : "max-h-0 opacity-0 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
<AIHelpPanel variant="sidebar" class="h-[42rem]" />
|
||||
</div>
|
||||
{/* Context Switcher - Only visible if there are oversight contexts */}
|
||||
<div class={cn(
|
||||
"px-3 mb-2 transition-opacity duration-200",
|
||||
(!props.isLocked && !props.isPeeking) ? "opacity-0 pointer-events-none" : "opacity-100 delay-100"
|
||||
)}>
|
||||
<ContextSwitcher
|
||||
isLocked={props.isLocked}
|
||||
isPeeking={props.isPeeking}
|
||||
setIsPeeking={props.setIsPeeking}
|
||||
onOpenChange={(open) => {
|
||||
setSwitcherOpen(open);
|
||||
props.onDropdownOpenChange?.(open);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-border pt-3 space-y-1">
|
||||
<button
|
||||
onClick={props.onToggleAIHelp}
|
||||
class={cn(
|
||||
"flex w-full items-start gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 group text-sm text-left",
|
||||
props.isAIHelpOpen
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<MessageCircle size={16} class={cn("transition-transform group-hover:scale-110 mt-0.5 shrink-0")} />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="font-medium">Ask Questions</div>
|
||||
<div
|
||||
class={cn(
|
||||
"text-[0.625rem] leading-snug transition-opacity duration-700",
|
||||
props.aiHelpTip ? "opacity-50" : "opacity-0"
|
||||
)}
|
||||
>
|
||||
{props.aiHelpTip || " "}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div class="px-3 border-t border-border mt-auto pt-3 space-y-1 pb-3">
|
||||
<button
|
||||
onClick={() => props.setView("help")}
|
||||
class={cn(
|
||||
@@ -431,7 +393,7 @@ export const Sidebar: Component<{
|
||||
)}
|
||||
>
|
||||
<HelpCircle size={16} class={cn("transition-transform group-hover:scale-110")} />
|
||||
<span class="font-medium">Help</span>
|
||||
<span class="font-medium">Help Guide</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => props.setView("settings")}
|
||||
@@ -445,7 +407,6 @@ export const Sidebar: Component<{
|
||||
<Settings size={16} class={cn("transition-transform group-hover:scale-110")} />
|
||||
<span class="font-medium">Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
+41
-187
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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.",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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'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
@@ -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)}>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -1,324 +0,0 @@
|
||||
import { FIREWORKS_API_KEY, FIREWORKS_API_URL, FIREWORKS_HELP_MODEL } from "@/lib/app-config";
|
||||
import helpGuide from "../../docs/HELP_GUIDE.md?raw";
|
||||
|
||||
type ChatRequestMessage = {
|
||||
role: "system" | "user" | "assistant";
|
||||
content: string;
|
||||
};
|
||||
|
||||
const HELP_SYSTEM_PROMPT = `You answer questions about TasGrid using the reference guide below.
|
||||
|
||||
You must follow this instruction exactly: "use only this guide for all information and never assume you know something that isn't mentioned in the guide"
|
||||
|
||||
If the guide does not contain the answer, say that the guide does not mention it.
|
||||
Do not use outside product knowledge.
|
||||
Do not invent buttons, workflows, settings, or behaviors.
|
||||
Answer in the most direct and concise way possible initially.
|
||||
After the main answer, end with a short follow-up line like "If you would like, I can explain the intricacies in more detail."
|
||||
When helpful, mention the relevant section name from the guide.
|
||||
|
||||
TASGRID REFERENCE GUIDE
|
||||
${helpGuide}`;
|
||||
|
||||
const buildSectionPrompt = (sectionTitle: string, sectionContent: string) => `You answer questions about TasGrid using only the guide section below.
|
||||
|
||||
You must follow this instruction exactly: "use only this guide for all information and never assume you know something that isn't mentioned in the guide"
|
||||
|
||||
If the section does not contain the answer, say that the guide section does not mention it.
|
||||
Do not use outside product knowledge.
|
||||
Do not invent buttons, workflows, settings, or behaviors.
|
||||
Prefer concise, practical answers.
|
||||
|
||||
TASGRID GUIDE SECTION
|
||||
## ${sectionTitle}
|
||||
${sectionContent}`;
|
||||
|
||||
const normalizeLine = (value: string) =>
|
||||
value
|
||||
.trim()
|
||||
.replace(/^tip:\s*/i, "")
|
||||
.replace(/^["'\s]+|["'\s]+$/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.toLowerCase();
|
||||
|
||||
const keepSingleSentence = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
const sentenceMatch = trimmed.match(/.+?[.!?](?=\s|$)/);
|
||||
return (sentenceMatch?.[0] || trimmed).trim();
|
||||
};
|
||||
|
||||
const isReasoningLikeType = (value: unknown) =>
|
||||
typeof value === "string" && /reasoning|thought/i.test(value);
|
||||
|
||||
export const hasFireworksApiKey = () => FIREWORKS_API_KEY.trim().length > 0;
|
||||
|
||||
const extractGuideSections = (markdown: string) => {
|
||||
const lines = markdown.split(/\r?\n/);
|
||||
const sections: Array<{ title: string; content: string }> = [];
|
||||
const excludedTopLevelTitles = new Set([
|
||||
"Overview",
|
||||
"Reference Notes",
|
||||
"Troubleshooting Checklist"
|
||||
]);
|
||||
const excludedSubsectionTitles = new Set([
|
||||
"Explicit Exclusions",
|
||||
"Summary for Future Help-Agent Use",
|
||||
"Help Access",
|
||||
"Main App Modes",
|
||||
"Account Header"
|
||||
]);
|
||||
let currentTopLevelTitle: string | null = null;
|
||||
let currentSection: { title: string; lines: string[] } | null = null;
|
||||
|
||||
const pushCurrentSection = () => {
|
||||
if (!currentSection) return;
|
||||
const content = currentSection.lines.join("\n").trim();
|
||||
if (!content) return;
|
||||
if (excludedSubsectionTitles.has(currentSection.title)) return;
|
||||
sections.push({
|
||||
title: currentSection.title,
|
||||
content
|
||||
});
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const topLevelHeadingMatch = line.match(/^##\s+(.+?)\s*$/);
|
||||
if (topLevelHeadingMatch) {
|
||||
pushCurrentSection();
|
||||
currentTopLevelTitle = topLevelHeadingMatch[1].trim();
|
||||
currentSection = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const subsectionHeadingMatch = line.match(/^###\s+(.+?)\s*$/);
|
||||
if (subsectionHeadingMatch) {
|
||||
pushCurrentSection();
|
||||
if (!currentTopLevelTitle || excludedTopLevelTitles.has(currentTopLevelTitle)) {
|
||||
currentSection = null;
|
||||
continue;
|
||||
}
|
||||
currentSection = {
|
||||
title: `${currentTopLevelTitle}: ${subsectionHeadingMatch[1].trim()}`,
|
||||
lines: []
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentSection) {
|
||||
currentSection.lines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
pushCurrentSection();
|
||||
|
||||
return sections;
|
||||
};
|
||||
|
||||
const HELP_GUIDE_SECTIONS = extractGuideSections(helpGuide);
|
||||
|
||||
export const getAIHelpTipSectionDebug = (sectionIndex: number) => {
|
||||
if (HELP_GUIDE_SECTIONS.length === 0) {
|
||||
return { requestedIndex: sectionIndex, resolvedIndex: -1, title: null as string | null };
|
||||
}
|
||||
|
||||
const resolvedIndex = ((sectionIndex % HELP_GUIDE_SECTIONS.length) + HELP_GUIDE_SECTIONS.length) % HELP_GUIDE_SECTIONS.length;
|
||||
return {
|
||||
requestedIndex: sectionIndex,
|
||||
resolvedIndex,
|
||||
title: HELP_GUIDE_SECTIONS[resolvedIndex]?.title || null
|
||||
};
|
||||
};
|
||||
|
||||
const cleanRawCompletion = (value: string) =>
|
||||
value
|
||||
.replace(/<think>[\s\S]*?<\/think>/gi, " ")
|
||||
.replace(/<reasoning>[\s\S]*?<\/reasoning>/gi, " ")
|
||||
.replace(/<\|[^>]+?\|>/g, " ")
|
||||
.replace(/<\/?s>/gi, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
const extractTextFromContent = (content: unknown): string => {
|
||||
if (typeof content === "string") {
|
||||
return content.trim();
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map(part => {
|
||||
if (typeof part === "string") return part;
|
||||
if (part && typeof part === "object") {
|
||||
if (isReasoningLikeType((part as any).type)) {
|
||||
return "";
|
||||
}
|
||||
if ("text" in part && typeof (part as any).text === "string") {
|
||||
return (part as any).text;
|
||||
}
|
||||
if ("content" in part && typeof (part as any).content === "string") {
|
||||
return (part as any).content;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
if (content && typeof content === "object") {
|
||||
if (isReasoningLikeType((content as any).type)) {
|
||||
return "";
|
||||
}
|
||||
if ("text" in content && typeof (content as any).text === "string") {
|
||||
return (content as any).text.trim();
|
||||
}
|
||||
if ("content" in content) {
|
||||
return extractTextFromContent((content as any).content);
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
const extractTextFromOutputArray = (output: unknown): string => {
|
||||
if (!Array.isArray(output)) return "";
|
||||
|
||||
return output
|
||||
.map(item => {
|
||||
if (!item || typeof item !== "object") return "";
|
||||
if ("content" in item) {
|
||||
return extractTextFromContent((item as any).content);
|
||||
}
|
||||
if ("text" in item && typeof (item as any).text === "string") {
|
||||
return (item as any).text;
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim();
|
||||
};
|
||||
|
||||
const requestFireworks = async (messages: ChatRequestMessage[], options?: { maxTokens?: number; temperature?: number; reasoningEffort?: "none" | "low" | "medium" | "high" }) => {
|
||||
const response = await fetch(FIREWORKS_API_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${FIREWORKS_API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: FIREWORKS_HELP_MODEL,
|
||||
reasoning_effort: options?.reasoningEffort ?? "low",
|
||||
temperature: options?.temperature ?? 0.2,
|
||||
max_tokens: options?.maxTokens ?? 700,
|
||||
raw_output: true,
|
||||
messages
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const bodyText = await response.text();
|
||||
throw new Error(bodyText || `Request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const choice = data?.choices?.[0];
|
||||
const content = extractTextFromContent(choice?.message?.content)
|
||||
|| extractTextFromContent(choice?.delta?.content)
|
||||
|| extractTextFromContent(choice?.text)
|
||||
|| extractTextFromContent(data?.output_text);
|
||||
const rawCompletion = cleanRawCompletion(
|
||||
extractTextFromContent(choice?.raw_output?.completion)
|
||||
|| extractTextFromContent(data?.raw_output?.completion)
|
||||
);
|
||||
const outputArrayText = extractTextFromOutputArray(data?.output);
|
||||
const answer = content || outputArrayText || rawCompletion;
|
||||
|
||||
if (!answer) {
|
||||
const finishReason = choice?.finish_reason ? ` finish_reason=${String(choice.finish_reason)}` : "";
|
||||
throw new Error(`The help assistant did not return any text.${finishReason}`);
|
||||
}
|
||||
|
||||
return answer;
|
||||
};
|
||||
|
||||
const looksLikePromptEcho = (value: string) => {
|
||||
const normalized = normalizeLine(value);
|
||||
return [
|
||||
"we need to produce",
|
||||
"create one short tasgrid desktop tip",
|
||||
"maximum one sentence",
|
||||
"return plain text only",
|
||||
"do not repeat or closely paraphrase",
|
||||
"previous tips to avoid",
|
||||
"use only this guide",
|
||||
"the guide does not mention",
|
||||
"guide section does not mention",
|
||||
"section does not mention",
|
||||
"specific desktop tip",
|
||||
"tasgrid desktop tip",
|
||||
"based only on the guide"
|
||||
].some(fragment => normalized.includes(fragment));
|
||||
};
|
||||
|
||||
export const askAIHelp = async (messages: Array<{ role: "user" | "assistant"; content: string }>) =>
|
||||
requestFireworks(
|
||||
[
|
||||
{ role: "system", content: HELP_SYSTEM_PROMPT },
|
||||
...messages
|
||||
],
|
||||
{ maxTokens: 700, temperature: 0.2, reasoningEffort: "low" }
|
||||
);
|
||||
|
||||
export const generateAIHelpTip = async (sectionIndex: number) => {
|
||||
const sectionDebug = getAIHelpTipSectionDebug(sectionIndex);
|
||||
const section = sectionDebug.resolvedIndex >= 0
|
||||
? HELP_GUIDE_SECTIONS[sectionDebug.resolvedIndex]
|
||||
: null;
|
||||
|
||||
const sectionLabel = section?.title || "TasGrid";
|
||||
const tipPrompt = `Write one short practical TasGrid tip for a person already using the app.
|
||||
|
||||
Rules:
|
||||
- Maximum one sentence.
|
||||
- Return plain text only.
|
||||
- No title, no bullet, no quotes.
|
||||
- Keep it subtle and practical.
|
||||
- State a real TasGrid behavior or workflow the user can use.
|
||||
- Do not talk about the guide, the section, the prompt, or what is not mentioned.
|
||||
- Do not explain your limitations.
|
||||
- Use only this guide for all information and never assume you know something that isn't mentioned in the guide.
|
||||
- Start the sentence with "Tip: ".
|
||||
- Base the tip on this topic: ${sectionLabel}.
|
||||
`;
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const response = await requestFireworks(
|
||||
[
|
||||
{
|
||||
role: "system",
|
||||
content: section
|
||||
? buildSectionPrompt(section.title, section.content)
|
||||
: HELP_SYSTEM_PROMPT
|
||||
},
|
||||
{ role: "user", content: tipPrompt }
|
||||
],
|
||||
{ maxTokens: 192, temperature: 0.5, reasoningEffort: "low" }
|
||||
);
|
||||
|
||||
const firstLine = response.split("\n")[0]?.trim() || "";
|
||||
const cleaned = keepSingleSentence(
|
||||
firstLine
|
||||
.replace(/^[\-\*\u2022\d.\s]+/, "")
|
||||
.trim()
|
||||
);
|
||||
const normalized = normalizeLine(cleaned);
|
||||
|
||||
if (!cleaned || !normalized) continue;
|
||||
if (looksLikePromptEcho(cleaned)) continue;
|
||||
|
||||
return cleaned.startsWith("Tip:") ? cleaned : `Tip: ${cleaned.replace(/^tip:\s*/i, "")}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -1,21 +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 FIREWORKS_API_URL = import.meta.env.VITE_FIREWORKS_API_URL || "https://api.fireworks.ai/inference/v1/chat/completions";
|
||||
export const FIREWORKS_API_KEY = import.meta.env.VITE_FIREWORKS_API_KEY || "";
|
||||
export const FIREWORKS_HELP_MODEL = import.meta.env.VITE_FIREWORKS_HELP_MODEL || "accounts/fireworks/models/gpt-oss-20b";
|
||||
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;
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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 || {},
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,929 +0,0 @@
|
||||
import { createEffect, createRoot } from "solid-js";
|
||||
import { toast } from "solid-sonner";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { CONTEXTS_COLLECTION, NOTES_COLLECTION, TASGRID_COLLECTION } from "@/lib/constants";
|
||||
import { PROD_MIGRATION_ADMIN_EMAILS, PROD_MIGRATION_ENABLED, TASGRID_IS_DEV_DATA } from "@/lib/app-config";
|
||||
import { buildNoteTag, buildShareTag, normalizeNoteKey } from "@/lib/tags";
|
||||
import {
|
||||
activeNoteId,
|
||||
applyScopedPrefsToStore,
|
||||
currentTaskContext,
|
||||
getPersistedStoreSnapshot,
|
||||
getStorageKey,
|
||||
type Note,
|
||||
readScopedPrefs,
|
||||
setCurrentTaskContext,
|
||||
setNow,
|
||||
setStore,
|
||||
store,
|
||||
type Task,
|
||||
unwrapRelationId,
|
||||
writeScopedPrefs
|
||||
} from "./state";
|
||||
import {
|
||||
buildPersonalAccessFilter,
|
||||
getNoteByRef,
|
||||
getPersonalContext,
|
||||
getSubscribedBucketContexts,
|
||||
loadContextsIntoStore,
|
||||
mapContextToShareRule,
|
||||
mapRecordToContext,
|
||||
relationFilter,
|
||||
setContexts,
|
||||
shouldTaskBeVisible,
|
||||
syncSupervisedContexts,
|
||||
syncSystemTagsAndRules,
|
||||
syncUserContexts,
|
||||
taskHasPersonalContextAccess
|
||||
} from "./contexts";
|
||||
import { deleteNotePermanently, loadedNoteDetailIds, mapRecordToNote, scheduleDeferredNotesMetadataLoad } from "./notes";
|
||||
import { loadFilterTemplates, syncPreferences } from "./preferences";
|
||||
import {
|
||||
buildNoteLinkMap,
|
||||
buildTaskMigrationPayload,
|
||||
checkRecurringTasks,
|
||||
deleteTaskPermanently,
|
||||
ensureUpdatedByFieldSupport,
|
||||
getCombinedScore,
|
||||
getTaskListFields,
|
||||
mapRecordToTask,
|
||||
upsertTagDefinition
|
||||
} from "./tasks";
|
||||
|
||||
let realtimeRecoveryAttached = false;
|
||||
let realtimeRecoveryTimer: number | undefined;
|
||||
let lastRealtimeRecoveryAt = 0;
|
||||
let contextSyncInterval: number | undefined;
|
||||
let heartbeatInterval: number | undefined;
|
||||
let quickloadFadeTimer: number | undefined;
|
||||
|
||||
const scheduleRealtimeRecovery = (reason: string, delayMs = 400) => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!pb.authStore.isValid) return;
|
||||
|
||||
const nowTs = Date.now();
|
||||
if (nowTs - lastRealtimeRecoveryAt < 10000) return;
|
||||
|
||||
if (realtimeRecoveryTimer) {
|
||||
window.clearTimeout(realtimeRecoveryTimer);
|
||||
}
|
||||
|
||||
realtimeRecoveryTimer = window.setTimeout(async () => {
|
||||
realtimeRecoveryTimer = undefined;
|
||||
if (!pb.authStore.isValid || store.isInitializing) return;
|
||||
lastRealtimeRecoveryAt = Date.now();
|
||||
console.log(`[REALTIME] Recovering subscriptions after ${reason}`);
|
||||
try {
|
||||
await initStore();
|
||||
} catch (err) {
|
||||
console.warn("[REALTIME] Recovery failed:", err);
|
||||
}
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
const ensureRealtimeRecovery = () => {
|
||||
if (realtimeRecoveryAttached || typeof window === "undefined") return;
|
||||
realtimeRecoveryAttached = true;
|
||||
|
||||
const recoverIfDisconnected = (reason: string) => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
if (pb.realtime.isConnected) return;
|
||||
scheduleRealtimeRecovery(reason);
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
recoverIfDisconnected("visibilitychange");
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("focus", () => recoverIfDisconnected("focus"));
|
||||
window.addEventListener("online", () => recoverIfDisconnected("online"));
|
||||
|
||||
pb.realtime.onDisconnect = () => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
};
|
||||
};
|
||||
|
||||
export const startContextPolling = () => {
|
||||
if (contextSyncInterval) {
|
||||
clearInterval(contextSyncInterval);
|
||||
contextSyncInterval = undefined;
|
||||
}
|
||||
|
||||
const ctx = currentTaskContext();
|
||||
const personalOrUserId =
|
||||
typeof ctx === "object" && "userId" in ctx
|
||||
? ctx.userId
|
||||
: typeof ctx === "object" && "bucketId" in ctx
|
||||
? store.contexts.find(context => context.id === ctx.bucketId && context.kind === "user")?.targetUserId
|
||||
: null;
|
||||
|
||||
if (personalOrUserId) {
|
||||
contextSyncInterval = window.setInterval(async () => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
|
||||
try {
|
||||
const timeStr = new Date(Date.now() - 30000).toISOString();
|
||||
const contextFilter = buildPersonalAccessFilter([personalOrUserId]);
|
||||
const records = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: `${contextFilter} && updated >= "${timeStr}"`,
|
||||
sort: "-updated",
|
||||
fields: getTaskListFields(),
|
||||
requestKey: "context-poll"
|
||||
});
|
||||
|
||||
if (records.length === 0) return;
|
||||
|
||||
setStore("tasks", currentTasks => {
|
||||
const taskMap = new Map(currentTasks.map(t => [t.id, t]));
|
||||
let changed = false;
|
||||
|
||||
records.forEach((r: any) => {
|
||||
if (r.tags?.includes("__template__")) return;
|
||||
|
||||
const existing = taskMap.get(r.id);
|
||||
if (existing && existing.updated === r.updated) return;
|
||||
|
||||
const newTask = mapRecordToTask(r);
|
||||
if (existing && existing.content) {
|
||||
newTask.content = existing.content;
|
||||
}
|
||||
taskMap.set(r.id, newTask);
|
||||
changed = true;
|
||||
});
|
||||
|
||||
if (changed) return Array.from(taskMap.values());
|
||||
return currentTasks;
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (!err.isAbort) {
|
||||
console.warn("Context poll failed:", err);
|
||||
}
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
createRoot(() => {
|
||||
createEffect(() => {
|
||||
currentTaskContext();
|
||||
startContextPolling();
|
||||
});
|
||||
});
|
||||
|
||||
const runDailyCleanupIfNeeded = () => {
|
||||
const today = new Date().toLocaleDateString("en-CA");
|
||||
if (store.lastCompletedTaskCleanupDate === today) return;
|
||||
setStore("lastCompletedTaskCleanupDate", today);
|
||||
return true;
|
||||
};
|
||||
|
||||
const STALE_COMPLETED_TASK_DAYS = 90;
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
const getStaleCompletedTaskDeletedAt = (task: Pick<Task, "completed" | "updated" | "deletedAt">) => {
|
||||
if (task.deletedAt || !task.completed) return null;
|
||||
|
||||
const updatedAt = new Date(task.updated).getTime();
|
||||
if (Number.isNaN(updatedAt)) return null;
|
||||
|
||||
const deletedAt = updatedAt + (STALE_COMPLETED_TASK_DAYS * MS_PER_DAY);
|
||||
return deletedAt <= Date.now() ? deletedAt : null;
|
||||
};
|
||||
|
||||
const taskHasAnyActiveContextAssignment = (task: Pick<Task, "ownerId" | "shareRefs" | "noteRefs">) => {
|
||||
const currentUserId = pb.authStore.model?.id || "";
|
||||
if (currentUserId && taskHasPersonalContextAccess(task, currentUserId, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasActiveShareContext = task.shareRefs.some(ref => !!store.contexts.find(context => context.id === ref.contextId && !context.deletedAt));
|
||||
if (hasActiveShareContext) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return task.noteRefs.some(ref => !!getNoteByRef(ref));
|
||||
};
|
||||
|
||||
const runFinalInitTaskAutoTrash = async () => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
if (!currentUserId) return;
|
||||
|
||||
const visibleTasks = store.tasks.filter(task =>
|
||||
!task.deletedAt &&
|
||||
!task.tags?.includes("__template__") &&
|
||||
shouldTaskBeVisible(task, currentUserId)
|
||||
);
|
||||
|
||||
for (const task of visibleTasks) {
|
||||
if (task.deletedAt) continue;
|
||||
|
||||
const staleCompletedDeletedAt = getStaleCompletedTaskDeletedAt(task);
|
||||
if (staleCompletedDeletedAt) {
|
||||
const { updateTask } = await import("./tasks");
|
||||
await updateTask(task.id, { deletedAt: staleCompletedDeletedAt });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!taskHasAnyActiveContextAssignment(task)) {
|
||||
const { updateTask } = await import("./tasks");
|
||||
await updateTask(task.id, { deletedAt: Date.now() });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const purgeExpiredTrashIfNeeded = async () => {
|
||||
if (!runDailyCleanupIfNeeded()) return;
|
||||
|
||||
const expiryMs = 7 * 24 * 60 * 60 * 1000;
|
||||
const cutoff = Date.now() - expiryMs;
|
||||
const cutoffIso = new Date(cutoff).toISOString();
|
||||
const expiredTaskRecords = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: `deletedAt != "" && deletedAt <= "${cutoffIso}"`,
|
||||
fields: "id,tags",
|
||||
requestKey: null
|
||||
}).catch(() => []);
|
||||
const expiredTaskIds = expiredTaskRecords
|
||||
.filter((record: any) => !record.tags?.includes("__template__"))
|
||||
.map((record: any) => record.id as string);
|
||||
const expiredNoteRecords = await pb.collection(NOTES_COLLECTION).getFullList({
|
||||
filter: `deletedAt != "" && deletedAt <= "${cutoffIso}"`,
|
||||
fields: "id,title,key,content,tags,isPrivate,user,tasks,created,updated,deletedAt",
|
||||
requestKey: null
|
||||
}).catch(() => []);
|
||||
const expiredNotes = expiredNoteRecords.map((record: any) => mapRecordToNote(record));
|
||||
|
||||
for (const taskId of expiredTaskIds) {
|
||||
await deleteTaskPermanently(taskId);
|
||||
}
|
||||
|
||||
for (const note of expiredNotes) {
|
||||
await deleteNotePermanently(note.id);
|
||||
}
|
||||
};
|
||||
|
||||
export const subscribeToRealtime = async () => {
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
|
||||
await pb.collection(TASGRID_COLLECTION).unsubscribe("*");
|
||||
|
||||
pb.collection(TASGRID_COLLECTION).subscribe("*", e => {
|
||||
console.log("Realtime event:", e.action, e.record.id);
|
||||
|
||||
if (e.action === "create") {
|
||||
const isTemplate = e.record.tags?.includes("__template__");
|
||||
if (isTemplate) {
|
||||
const templateExists = store.templates?.some(t => t.id === e.record.id);
|
||||
if (templateExists) return;
|
||||
|
||||
let meta: any = {};
|
||||
try { meta = JSON.parse(e.record.content || "{}"); } catch { }
|
||||
const newTemplate = {
|
||||
id: e.record.id,
|
||||
name: e.record.title,
|
||||
title: meta.title || "",
|
||||
priority: e.record.priority,
|
||||
urgency: meta.urgency || 5,
|
||||
tags: meta.tags || [],
|
||||
content: meta.content || ""
|
||||
};
|
||||
setStore("templates", t => [...(t || []), newTemplate]);
|
||||
return;
|
||||
}
|
||||
|
||||
const exists = store.tasks.find(t => t.id === e.record.id);
|
||||
if (!exists) {
|
||||
const authUserId = pb.authStore.model?.id;
|
||||
const isOwnTask = unwrapRelationId(e.record.user) === authUserId;
|
||||
const newTask = mapRecordToTask(e.record);
|
||||
|
||||
if (isOwnTask || (authUserId && shouldTaskBeVisible(newTask, authUserId))) {
|
||||
setStore("tasks", t => [newTask, ...t]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (e.action === "update") {
|
||||
const isTemplate = e.record.tags?.includes("__template__");
|
||||
if (isTemplate) {
|
||||
let meta: any = {};
|
||||
try { meta = JSON.parse(e.record.content || "{}"); } catch { }
|
||||
setStore("templates", t => t.id === e.record.id, {
|
||||
name: e.record.title,
|
||||
title: meta.title || "",
|
||||
priority: e.record.priority,
|
||||
urgency: meta.urgency || 5,
|
||||
tags: meta.tags || [],
|
||||
content: meta.content || ""
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const authUserId = pb.authStore.model?.id;
|
||||
const existingTask = store.tasks.find(t => t.id === e.record.id);
|
||||
const isOwnTask = unwrapRelationId(e.record.user) === authUserId;
|
||||
const incomingUpdated = new Date(e.record.updated).getTime();
|
||||
const updatedTask = mapRecordToTask(e.record);
|
||||
|
||||
if (existingTask) {
|
||||
const localUpdated = new Date(existingTask.updated).getTime();
|
||||
|
||||
if (!existingTask.updated || incomingUpdated >= localUpdated) {
|
||||
if (!isOwnTask && authUserId && !shouldTaskBeVisible(updatedTask, authUserId)) {
|
||||
setStore("tasks", t => t.filter(x => x.id !== e.record.id));
|
||||
return;
|
||||
}
|
||||
|
||||
setStore("tasks", t => t.id === e.record.id, updatedTask);
|
||||
} else {
|
||||
console.log("Skipping stale realtime update for task:", e.record.id);
|
||||
}
|
||||
} else if (authUserId && shouldTaskBeVisible(updatedTask, authUserId)) {
|
||||
setStore("tasks", t => [updatedTask, ...t]);
|
||||
}
|
||||
}
|
||||
|
||||
if (e.action === "delete") {
|
||||
setStore("tasks", t => t.filter(x => x.id !== e.record.id));
|
||||
setStore("templates", t => (t || []).filter(x => x.id !== e.record.id));
|
||||
}
|
||||
});
|
||||
|
||||
await pb.collection(NOTES_COLLECTION).unsubscribe("*");
|
||||
pb.collection(NOTES_COLLECTION).subscribe("*", e => {
|
||||
const authUserId = pb.authStore.model?.id;
|
||||
const isVisible = !e.record.isPrivate || unwrapRelationId(e.record.user) === authUserId;
|
||||
|
||||
if (e.action === "create" || e.action === "update") {
|
||||
if (isVisible) {
|
||||
const shouldSyncContent = loadedNoteDetailIds.has(e.record.id) || activeNoteId() === e.record.id;
|
||||
const updatedNote = mapRecordToNote(e.record, shouldSyncContent);
|
||||
setStore("notes", prev => {
|
||||
const exists = prev.find(n => n.id === updatedNote.id);
|
||||
if (exists) {
|
||||
const mergedNote = shouldSyncContent || exists.content === undefined
|
||||
? updatedNote
|
||||
: { ...updatedNote, content: exists.content };
|
||||
return prev.map(n => n.id === updatedNote.id ? mergedNote : n);
|
||||
}
|
||||
return [updatedNote, ...prev];
|
||||
});
|
||||
} else {
|
||||
setStore("notes", prev => prev.filter(n => n.id !== e.record.id));
|
||||
}
|
||||
}
|
||||
|
||||
if (e.action === "delete") {
|
||||
setStore("notes", prev => prev.filter(n => n.id !== e.record.id));
|
||||
}
|
||||
});
|
||||
|
||||
await pb.collection(CONTEXTS_COLLECTION).unsubscribe("*");
|
||||
pb.collection(CONTEXTS_COLLECTION).subscribe("*", e => {
|
||||
if (e.action === "delete") {
|
||||
const remaining = store.contexts.filter(context => context.id !== e.record.id);
|
||||
setContexts(remaining);
|
||||
setStore("shareRules", remaining.map(mapContextToShareRule));
|
||||
setStore("tasks", tasks => tasks.filter(task => shouldTaskBeVisible(task, pb.authStore.model?.id || "")));
|
||||
void syncSupervisedContexts();
|
||||
return;
|
||||
}
|
||||
|
||||
const context = mapRecordToContext(e.record);
|
||||
const nextContexts = store.contexts.some(existing => existing.id === context.id)
|
||||
? store.contexts.map(existing => existing.id === context.id ? context : existing)
|
||||
: [...store.contexts, context];
|
||||
setContexts(nextContexts);
|
||||
setStore("shareRules", nextContexts.map(mapContextToShareRule));
|
||||
if (context.targetUserId === pb.authStore.model?.id) {
|
||||
setStore("personalContextSupervisorIds", context.supervisorUserIds || []);
|
||||
}
|
||||
void syncSupervisedContexts();
|
||||
});
|
||||
|
||||
if (currentUserId) {
|
||||
await pb.collection("users").unsubscribe(currentUserId);
|
||||
pb.collection("users").subscribe(currentUserId, e => {
|
||||
if (e.action !== "update") return;
|
||||
|
||||
const prefs = readScopedPrefs(e.record?.Taskgrid_pref || {});
|
||||
applyScopedPrefsToStore({
|
||||
...prefs,
|
||||
subscribedBuckets: prefs.subscribedBuckets || e.record?.subscribedBuckets || []
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const runLegacyTagMigration = async (force = false) => {
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
if (!currentUserId) return;
|
||||
|
||||
try {
|
||||
const userRec = await pb.collection("users").getOne(currentUserId, { requestKey: null });
|
||||
const rawPrefs = userRec.Taskgrid_pref || {};
|
||||
const prefs = readScopedPrefs(rawPrefs);
|
||||
|
||||
if (!force && prefs.migratedStructuredTagsV2) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[MIGRATION] Starting structured tag migration...");
|
||||
|
||||
await syncUserContexts();
|
||||
const contexts = await loadContextsIntoStore();
|
||||
const ownerContextByUserId = new Map(
|
||||
contexts
|
||||
.filter(context => context.kind === "user" && !!context.targetUserId && !context.deletedAt)
|
||||
.map(context => [context.targetUserId as string, context])
|
||||
);
|
||||
|
||||
const notes = await pb.collection(NOTES_COLLECTION).getFullList({
|
||||
requestKey: null
|
||||
});
|
||||
const migratedNotes: Note[] = [];
|
||||
for (const noteRecord of notes) {
|
||||
const key = normalizeNoteKey(noteRecord.key || noteRecord.title || noteRecord.id);
|
||||
const existingTags = Array.isArray(noteRecord.tags) ? noteRecord.tags.filter(Boolean) : [];
|
||||
const nextTags = [...new Set([buildNoteTag(key), ...existingTags])];
|
||||
const nextTasks = [...new Set((Array.isArray(noteRecord.tasks) ? noteRecord.tasks : []).filter(Boolean))];
|
||||
|
||||
await pb.collection(NOTES_COLLECTION).update(noteRecord.id, {
|
||||
key,
|
||||
tags: nextTags,
|
||||
tasks: nextTasks
|
||||
}, { requestKey: null });
|
||||
|
||||
migratedNotes.push({
|
||||
...mapRecordToNote(noteRecord),
|
||||
key,
|
||||
tags: nextTags,
|
||||
tasks: nextTasks
|
||||
});
|
||||
}
|
||||
setStore("notes", migratedNotes);
|
||||
const noteLinksByTaskId = buildNoteLinkMap(migratedNotes);
|
||||
|
||||
const tasks = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
requestKey: null
|
||||
});
|
||||
for (const taskRecord of tasks) {
|
||||
if (taskRecord.tags?.includes("__template__")) continue;
|
||||
const task = mapRecordToTask(taskRecord);
|
||||
const payload = buildTaskMigrationPayload(
|
||||
task,
|
||||
ownerContextByUserId,
|
||||
noteLinksByTaskId.get(task.id) || [],
|
||||
currentUserId
|
||||
);
|
||||
await pb.collection(TASGRID_COLLECTION).update(task.id, payload, { requestKey: null });
|
||||
}
|
||||
|
||||
const nextPrefs = { ...prefs, migratedStructuredTagsV2: true };
|
||||
await pb.collection("users").update(currentUserId, {
|
||||
Taskgrid_pref: writeScopedPrefs(rawPrefs, nextPrefs)
|
||||
}, { requestKey: null });
|
||||
} catch (err) {
|
||||
console.error("[MIGRATION] Failed:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const runContextOwnershipMigration = async (force = false) => {
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
if (!currentUserId) return;
|
||||
|
||||
try {
|
||||
const userRec = await pb.collection("users").getOne(currentUserId, { requestKey: null });
|
||||
const rawPrefs = userRec.Taskgrid_pref || {};
|
||||
const prefs = readScopedPrefs(rawPrefs);
|
||||
|
||||
if (!force && prefs.migratedContextOwnershipV2) {
|
||||
return;
|
||||
}
|
||||
|
||||
await syncUserContexts();
|
||||
const contexts = store.contexts.length > 0 ? store.contexts : await loadContextsIntoStore();
|
||||
const ownerContextByUserId = new Map(
|
||||
contexts
|
||||
.filter(context => context.kind === "user" && !!context.targetUserId && !context.deletedAt)
|
||||
.map(context => [context.targetUserId as string, context])
|
||||
);
|
||||
const noteRecords = await pb.collection(NOTES_COLLECTION).getFullList({
|
||||
fields: "id,created,updated,title,key,tags,isPrivate,user,tasks,deletedAt",
|
||||
requestKey: null
|
||||
}).catch(() => []);
|
||||
const allNotes = noteRecords.map(record => mapRecordToNote(record));
|
||||
const noteLinksByTaskId = buildNoteLinkMap(allNotes);
|
||||
|
||||
const taskRecords = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
fields: getTaskListFields(),
|
||||
requestKey: null
|
||||
});
|
||||
|
||||
for (const taskRecord of taskRecords) {
|
||||
if (taskRecord.tags?.includes("__template__")) continue;
|
||||
|
||||
const task = mapRecordToTask(taskRecord);
|
||||
if (!task.ownerId) continue;
|
||||
const payload = buildTaskMigrationPayload(
|
||||
task,
|
||||
ownerContextByUserId,
|
||||
noteLinksByTaskId.get(task.id) || [],
|
||||
currentUserId
|
||||
);
|
||||
|
||||
const sameShareRefs = JSON.stringify(payload.shareRefs) === JSON.stringify(task.shareRefs);
|
||||
const sameNoteRefs = JSON.stringify(payload.noteRefs) === JSON.stringify(task.noteRefs);
|
||||
const sameTags = JSON.stringify(payload.tags) === JSON.stringify(task.tags);
|
||||
const sameOwnerId = payload.ownerId === task.ownerId;
|
||||
if (sameShareRefs && sameNoteRefs && sameTags && payload.createdBy === task.createdBy && sameOwnerId) continue;
|
||||
|
||||
const pbPayload = {
|
||||
...payload,
|
||||
user: payload.ownerId || task.ownerId || null
|
||||
} as any;
|
||||
delete pbPayload.ownerId;
|
||||
|
||||
await pb.collection(TASGRID_COLLECTION).update(task.id, pbPayload, { requestKey: null });
|
||||
}
|
||||
|
||||
await pb.collection("users").update(currentUserId, {
|
||||
Taskgrid_pref: writeScopedPrefs(rawPrefs, {
|
||||
...prefs,
|
||||
migratedPersonalContextRefsV1: true,
|
||||
migratedContextOwnershipV2: true
|
||||
})
|
||||
}, { requestKey: null });
|
||||
} catch (err) {
|
||||
console.error("[MIGRATION] Failed context ownership migration:", err);
|
||||
}
|
||||
};
|
||||
|
||||
export const runDevDataMigration = async () => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
if (!TASGRID_IS_DEV_DATA) {
|
||||
toast.error("Dev migration is only available in dev data mode.");
|
||||
return;
|
||||
}
|
||||
|
||||
await toast.promise((async () => {
|
||||
await runLegacyTagMigration(true);
|
||||
await runContextOwnershipMigration(true);
|
||||
await loadContextsIntoStore();
|
||||
await syncSupervisedContexts();
|
||||
return "Dev data migration completed.";
|
||||
})(), {
|
||||
loading: "Running dev data migration...",
|
||||
success: message => message,
|
||||
error: "Dev data migration failed."
|
||||
});
|
||||
};
|
||||
|
||||
export const canRunProdMigration = () => {
|
||||
if (TASGRID_IS_DEV_DATA || !PROD_MIGRATION_ENABLED) return false;
|
||||
const email = (pb.authStore.model?.email || "").trim().toLowerCase();
|
||||
return !!email && PROD_MIGRATION_ADMIN_EMAILS.includes(email);
|
||||
};
|
||||
|
||||
export const runProdDataMigration = async () => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
if (!canRunProdMigration()) {
|
||||
toast.error("Prod migration is not enabled for this account.");
|
||||
return;
|
||||
}
|
||||
|
||||
await toast.promise((async () => {
|
||||
await runLegacyTagMigration(true);
|
||||
await runContextOwnershipMigration(true);
|
||||
await loadContextsIntoStore();
|
||||
await syncSupervisedContexts();
|
||||
return "Production data migration completed.";
|
||||
})(), {
|
||||
loading: "Running production data migration...",
|
||||
success: message => message,
|
||||
error: "Production data migration failed."
|
||||
});
|
||||
};
|
||||
|
||||
export const initStore = async () => {
|
||||
ensureRealtimeRecovery();
|
||||
if (!pb.authStore.isValid || store.isInitializing) return;
|
||||
setStore("isInitializing", true);
|
||||
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
const refreshedTaskIds = new Set<string>();
|
||||
|
||||
if (currentUserId) {
|
||||
try {
|
||||
const prefs = readScopedPrefs(pb.authStore.model?.Taskgrid_pref || {});
|
||||
const cachedQuickload = prefs.quickloadTasks || [];
|
||||
|
||||
let focusRecords;
|
||||
if (cachedQuickload.length > 0) {
|
||||
const idFilter = cachedQuickload.map((id: string) => `id = "${id}"`).join(" || ");
|
||||
const res = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: `(${idFilter}) && deletedAt = ""`,
|
||||
requestKey: null
|
||||
});
|
||||
|
||||
res.sort((a, b) => cachedQuickload.indexOf(a.id) - cachedQuickload.indexOf(b.id));
|
||||
focusRecords = { items: res };
|
||||
} else {
|
||||
const personalAccessFilter = buildPersonalAccessFilter([currentUserId]);
|
||||
focusRecords = await pb.collection(TASGRID_COLLECTION).getList(1, 30, {
|
||||
filter: `${personalAccessFilter} && completed = false && deletedAt = ""`,
|
||||
sort: "-priority,dueDate",
|
||||
requestKey: null
|
||||
});
|
||||
}
|
||||
|
||||
const focusTasks = focusRecords.items
|
||||
.map(mapRecordToTask)
|
||||
.filter(t => !t.tags?.includes("__template__"));
|
||||
|
||||
setStore("tasks", focusTasks);
|
||||
setStore("quickloadFadeTaskIds", focusTasks.map(task => task.id));
|
||||
focusTasks.forEach(task => refreshedTaskIds.add(task.id));
|
||||
if (quickloadFadeTimer) clearTimeout(quickloadFadeTimer);
|
||||
quickloadFadeTimer = window.setTimeout(() => {
|
||||
setStore("quickloadFadeTaskIds", []);
|
||||
quickloadFadeTimer = undefined;
|
||||
}, 200);
|
||||
} catch (focusErr) {
|
||||
console.warn("Stage 1 load failed:", focusErr);
|
||||
}
|
||||
}
|
||||
|
||||
const key = getStorageKey();
|
||||
if (key) {
|
||||
const saved = localStorage.getItem(key);
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
const cachedTasks = Array.isArray(parsed?.tasks) ? parsed.tasks : [];
|
||||
const mergedCachedTasks = [
|
||||
...store.tasks,
|
||||
...cachedTasks.filter((task: Task) => !refreshedTaskIds.has(task.id))
|
||||
];
|
||||
setStore({
|
||||
...parsed,
|
||||
tasks: mergedCachedTasks,
|
||||
isInitializing: true
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to parse cached data", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (heartbeatInterval) clearInterval(heartbeatInterval);
|
||||
heartbeatInterval = setInterval(() => {
|
||||
setNow(Date.now());
|
||||
checkRecurringTasks();
|
||||
}, 60000);
|
||||
|
||||
try {
|
||||
try {
|
||||
const userId = pb.authStore.model?.id;
|
||||
if (userId) {
|
||||
const user = await pb.collection("users").getOne(userId, { requestKey: null });
|
||||
const prefs = readScopedPrefs(user.Taskgrid_pref || {});
|
||||
applyScopedPrefsToStore({
|
||||
...prefs,
|
||||
subscribedBuckets: prefs.subscribedBuckets || user.subscribedBuckets || []
|
||||
});
|
||||
}
|
||||
} catch (prefErr) {
|
||||
console.warn("Failed to load preferences:", prefErr);
|
||||
}
|
||||
|
||||
try {
|
||||
const contextRecords = await pb.collection(CONTEXTS_COLLECTION).getFullList({
|
||||
sort: "displayName,key",
|
||||
requestKey: null
|
||||
});
|
||||
const contexts = contextRecords.map(mapRecordToContext);
|
||||
setContexts(contexts);
|
||||
setStore("shareRules", contexts.map(mapContextToShareRule));
|
||||
await syncUserContexts();
|
||||
await syncSupervisedContexts();
|
||||
const personalContext = getPersonalContext(pb.authStore.model?.id);
|
||||
if (personalContext) {
|
||||
setStore("personalContextSupervisorIds", personalContext.supervisorUserIds || []);
|
||||
}
|
||||
if (personalContext && currentTaskContext() === "mine") {
|
||||
setCurrentTaskContext({ bucketId: personalContext.id, name: "My Bucket", isPersonal: true });
|
||||
}
|
||||
} catch (contextErr) {
|
||||
console.warn("Failed to load contexts (collection might not exist yet):", contextErr);
|
||||
}
|
||||
|
||||
await syncSystemTagsAndRules();
|
||||
|
||||
const authUserId = pb.authStore.model?.id;
|
||||
if (!authUserId) return;
|
||||
|
||||
const backgroundSync = async () => {
|
||||
const subscribedBucketTags = getSubscribedBucketContexts()
|
||||
.map(context => buildShareTag(context.displayName));
|
||||
const supervisedUserIds = store.supervisedContexts.map(context => context.ownerUserId);
|
||||
const currentUserAccessFilter = buildPersonalAccessFilter(authUserId ? [authUserId] : []);
|
||||
const supervisedAccessFilter = buildPersonalAccessFilter(supervisedUserIds);
|
||||
|
||||
const incompletePromises = [
|
||||
pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: `${currentUserAccessFilter} && completed = false`,
|
||||
sort: "-created",
|
||||
fields: getTaskListFields(),
|
||||
requestKey: null
|
||||
}),
|
||||
subscribedBucketTags.length > 0 ? pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: `(${subscribedBucketTags.map(tag => `tags ~ "${tag}"`).join(" || ")}) && completed = false`,
|
||||
sort: "-created",
|
||||
fields: getTaskListFields(),
|
||||
requestKey: null
|
||||
}).catch(() => []) : Promise.resolve([]),
|
||||
supervisedUserIds.length > 0 ? pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: `${supervisedAccessFilter} && completed = false`,
|
||||
sort: "-created",
|
||||
fields: getTaskListFields(),
|
||||
requestKey: null
|
||||
}).catch(() => []) : Promise.resolve([])
|
||||
];
|
||||
|
||||
const stage2Results = await Promise.all(incompletePromises);
|
||||
const allIncomplete = stage2Results.flat();
|
||||
|
||||
setStore("tasks", currentTasks => {
|
||||
const taskMap = new Map(currentTasks.map(t => [t.id, t]));
|
||||
allIncomplete.forEach((r: any) => {
|
||||
if (r.tags?.includes("__template__")) return;
|
||||
if (refreshedTaskIds.has(r.id)) return;
|
||||
|
||||
const existing = taskMap.get(r.id);
|
||||
if (existing && existing.updated === r.updated) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newTask = mapRecordToTask(r);
|
||||
if (existing && existing.content) {
|
||||
newTask.content = existing.content;
|
||||
}
|
||||
taskMap.set(r.id, newTask);
|
||||
refreshedTaskIds.add(r.id);
|
||||
});
|
||||
return Array.from(taskMap.values());
|
||||
});
|
||||
|
||||
const tasksNeedingContent = store.tasks
|
||||
.filter(t => !t.completed && t.content === undefined && !refreshedTaskIds.has(t.id))
|
||||
.map(t => t.id);
|
||||
if (tasksNeedingContent.length > 0) {
|
||||
const chunkSize = 20;
|
||||
for (let i = 0; i < tasksNeedingContent.length; i += chunkSize) {
|
||||
const ids = tasksNeedingContent.slice(i, i + chunkSize);
|
||||
try {
|
||||
const contentRecords = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: ids.map(id => `id="${id}"`).join(" || "),
|
||||
fields: "id,content",
|
||||
requestKey: null
|
||||
});
|
||||
|
||||
setStore("tasks", tasks => tasks.map(t => {
|
||||
const match = contentRecords.find((r: any) => r.id === t.id);
|
||||
if (match) return { ...t, content: match.content };
|
||||
return t;
|
||||
}));
|
||||
} catch (e) {
|
||||
console.warn("Backfill chunk failed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const currentCount = store.tasks.length;
|
||||
const remainingSlots = 150 - currentCount;
|
||||
|
||||
if (remainingSlots > 0) {
|
||||
try {
|
||||
const completedAccessFilter = buildPersonalAccessFilter(authUserId ? [authUserId] : []);
|
||||
const recentCompleted = await pb.collection(TASGRID_COLLECTION).getList(1, remainingSlots, {
|
||||
filter: `${completedAccessFilter} && completed = true`,
|
||||
sort: "-updated",
|
||||
fields: getTaskListFields(),
|
||||
requestKey: null
|
||||
});
|
||||
|
||||
setStore("tasks", prev => {
|
||||
const newTasks = [...prev];
|
||||
recentCompleted.items.forEach((r: any) => {
|
||||
if (!newTasks.some(t => t.id === r.id)) {
|
||||
newTasks.push(mapRecordToTask(r));
|
||||
}
|
||||
});
|
||||
return newTasks;
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("Stage 3 load failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
checkRecurringTasks();
|
||||
|
||||
const foundTagNames = new Set<string>();
|
||||
store.tasks.forEach(t => t.tags?.forEach(tag => foundTagNames.add(tag)));
|
||||
for (const tagName of foundTagNames) {
|
||||
if (tagName === "__template__" || tagName.startsWith("@") || tagName.startsWith("#")) continue;
|
||||
if (!store.tagDefinitions.find(d => d.name === tagName)) {
|
||||
await upsertTagDefinition(tagName, 5, undefined, document.documentElement.classList.contains("dark") ? "dark" : "light");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await backgroundSync();
|
||||
await ensureUpdatedByFieldSupport();
|
||||
|
||||
if (TASGRID_IS_DEV_DATA) {
|
||||
await runLegacyTagMigration();
|
||||
await runContextOwnershipMigration();
|
||||
}
|
||||
|
||||
try {
|
||||
const templates = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: `${relationFilter("user", authUserId)} && tags ~ "__template__"`,
|
||||
requestKey: null
|
||||
});
|
||||
const loadedTemplates = templates.map(r => {
|
||||
let meta: any = {};
|
||||
try { meta = JSON.parse(r.content || "{}"); } catch { }
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.title,
|
||||
title: meta.title || "",
|
||||
priority: r.priority,
|
||||
urgency: meta.urgency || 5,
|
||||
tags: meta.tags || [],
|
||||
content: meta.content || ""
|
||||
};
|
||||
});
|
||||
setStore("templates", loadedTemplates);
|
||||
} catch { }
|
||||
|
||||
await subscribeToRealtime();
|
||||
await loadFilterTemplates();
|
||||
scheduleDeferredNotesMetadataLoad();
|
||||
|
||||
await purgeExpiredTrashIfNeeded();
|
||||
await runFinalInitTaskAutoTrash();
|
||||
} catch (err) {
|
||||
if (store.tasks.length === 0) {
|
||||
console.error("Failed to load data:", err);
|
||||
toast.error("Failed to sync with server.");
|
||||
}
|
||||
} finally {
|
||||
setStore("isInitializing", false);
|
||||
}
|
||||
};
|
||||
|
||||
export const setupPersistence = () => {
|
||||
initStore();
|
||||
};
|
||||
|
||||
createRoot(() => {
|
||||
createEffect(() => {
|
||||
const key = getStorageKey();
|
||||
if (key) {
|
||||
localStorage.setItem(key, JSON.stringify(getPersistedStoreSnapshot()));
|
||||
}
|
||||
});
|
||||
|
||||
let debouncedSync: number | undefined;
|
||||
createEffect(() => {
|
||||
if (store.isInitializing || !pb.authStore.model?.id) return;
|
||||
|
||||
const incompleteTasks = store.tasks.filter(t =>
|
||||
!t.completed &&
|
||||
taskHasPersonalContextAccess(t, pb.authStore.model?.id || "", true) &&
|
||||
!t.tags?.includes("__template__") &&
|
||||
!t.deletedAt
|
||||
);
|
||||
|
||||
const scoredTasks = incompleteTasks.map(t => ({ id: t.id, score: getCombinedScore(t) }));
|
||||
scoredTasks.sort((a, b) => b.score - a.score);
|
||||
|
||||
const top20Ids = scoredTasks.slice(0, 20).map(t => t.id);
|
||||
const currentIds = store.quickloadTasks || [];
|
||||
const isDifferent = top20Ids.length !== currentIds.length || top20Ids.some((id, i) => id !== currentIds[i]);
|
||||
|
||||
if (isDifferent) {
|
||||
setStore("quickloadTasks", top20Ids);
|
||||
|
||||
if (debouncedSync) window.clearTimeout(debouncedSync);
|
||||
debouncedSync = window.setTimeout(() => {
|
||||
syncPreferences();
|
||||
}, 10000);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,864 +0,0 @@
|
||||
import { reconcile } from "solid-js/store";
|
||||
import { toast } from "solid-sonner";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { CONTEXTS_COLLECTION, NOTES_COLLECTION, TASGRID_COLLECTION } from "@/lib/constants";
|
||||
import { buildNoteTag, buildShareTag, normalizeContextKey, normalizeNoteKey, parseTags } from "@/lib/tags";
|
||||
import {
|
||||
activeNoteId,
|
||||
currentTaskContext,
|
||||
type Bucket,
|
||||
type Note,
|
||||
type NoteRef,
|
||||
readScopedPrefs,
|
||||
type ShareContext,
|
||||
type ShareRule,
|
||||
setStore,
|
||||
store,
|
||||
type TagDefinition,
|
||||
type Task,
|
||||
type TaskContext,
|
||||
type TaskShareRef,
|
||||
unwrapRelationId,
|
||||
unwrapRelationIds,
|
||||
writeScopedPrefs
|
||||
} from "./state";
|
||||
|
||||
export const relationFilter = (field: string, id: string | undefined | null) =>
|
||||
id ? `(${field} = "${id}" || ${field} ?= "${id}")` : "false";
|
||||
|
||||
export const buildUserContextKey = (userId: string) => `user-${userId}`;
|
||||
|
||||
const DEFAULT_LABEL_THEME: "light" | "dark" = "dark";
|
||||
|
||||
const mapContextToBucket = (context: ShareContext): Bucket => ({
|
||||
id: context.id,
|
||||
name: context.displayName,
|
||||
color: context.color || "#64748b",
|
||||
description: context.description,
|
||||
policy: context.policy,
|
||||
key: context.key,
|
||||
deletedAt: context.deletedAt ?? null
|
||||
});
|
||||
|
||||
const deriveBuckets = (contexts: ShareContext[]) =>
|
||||
contexts
|
||||
.filter(context => context.kind === "bucket" && !context.deletedAt)
|
||||
.sort((a, b) => a.displayName.localeCompare(b.displayName))
|
||||
.map(mapContextToBucket);
|
||||
|
||||
const deriveSystemTagDefinitions = (contexts: ShareContext[]): TagDefinition[] =>
|
||||
contexts
|
||||
.filter(context => !context.deletedAt)
|
||||
.map(context => ({
|
||||
id: context.id,
|
||||
name: buildShareTag(context.displayName),
|
||||
value: 5,
|
||||
color: context.color,
|
||||
theme: DEFAULT_LABEL_THEME,
|
||||
isUser: context.kind === "user",
|
||||
isBucket: context.kind === "bucket"
|
||||
}));
|
||||
|
||||
const findContextForShareTag = (tag: { key: string; raw: string }) =>
|
||||
store.contexts.find(context => !context.deletedAt && context.key === tag.key) ||
|
||||
store.contexts.find(context => !context.deletedAt && context.kind === "user" && normalizeContextKey(context.displayName) === tag.key) ||
|
||||
store.contexts.find(context => !context.deletedAt && normalizeContextKey(context.displayName) === tag.key) ||
|
||||
store.contexts.find(context => !context.deletedAt && buildShareTag(context.displayName).toLowerCase() === tag.raw.toLowerCase()) ||
|
||||
null;
|
||||
|
||||
const buildNoteContext = (note: Note): ShareContext => ({
|
||||
id: note.id,
|
||||
key: note.key,
|
||||
displayName: note.key || note.title,
|
||||
kind: "note",
|
||||
policy: getNoteSharePolicy(note),
|
||||
targetUserId: note.user,
|
||||
deletedAt: note.deletedAt ?? null
|
||||
});
|
||||
|
||||
export const getContextByRef = (ref: TaskShareRef) =>
|
||||
(ref.kind === "note"
|
||||
? (() => {
|
||||
const note = store.notes.find(existing => !existing.deletedAt && (existing.id === ref.contextId || existing.key === ref.key));
|
||||
return note ? buildNoteContext(note) : null;
|
||||
})()
|
||||
: null) ||
|
||||
store.contexts.find(context => !context.deletedAt && context.id === ref.contextId) ||
|
||||
store.contexts.find(context => !context.deletedAt && context.key === ref.key && context.kind === ref.kind) ||
|
||||
store.contexts.find(context => !context.deletedAt && context.kind === ref.kind && normalizeContextKey(context.displayName) === ref.key) ||
|
||||
store.contexts.find(context => !context.deletedAt && normalizeContextKey(context.displayName) === ref.key) ||
|
||||
null;
|
||||
|
||||
export const getNoteByRef = (ref: NoteRef) =>
|
||||
store.notes.find(note => !note.deletedAt && note.id === ref.noteId) ||
|
||||
store.notes.find(note => !note.deletedAt && note.key === ref.key);
|
||||
|
||||
const DEFAULT_SHARE_POLICY_BY_KIND: Record<TaskShareRef["kind"], "collaborative" | "handoff"> = {
|
||||
user: "collaborative",
|
||||
bucket: "handoff",
|
||||
note: "handoff"
|
||||
};
|
||||
|
||||
const NOTE_HANDOFF_POLICY_TAG = "__note_policy_handoff__";
|
||||
|
||||
export const getNoteSharePolicy = (note: Pick<Note, "tags"> | null | undefined): "collaborative" | "handoff" =>
|
||||
note?.tags?.includes(NOTE_HANDOFF_POLICY_TAG) ? "handoff" : "collaborative";
|
||||
|
||||
export const getTaskSharePolicy = (ref: Pick<TaskShareRef, "kind" | "key" | "policy" | "contextId">): "collaborative" | "handoff" => {
|
||||
if (ref.kind === "note") {
|
||||
if (ref.policy) return ref.policy;
|
||||
const note = store.notes.find(existing => !existing.deletedAt && (existing.id === ref.contextId || existing.key === ref.key));
|
||||
return getNoteSharePolicy(note);
|
||||
}
|
||||
|
||||
const context = getContextByRef(ref as TaskShareRef);
|
||||
if (ref.policy) return ref.policy;
|
||||
if (context?.policy) return context.policy;
|
||||
|
||||
return DEFAULT_SHARE_POLICY_BY_KIND[ref.kind];
|
||||
};
|
||||
|
||||
export 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 = getNoteByRef(ref);
|
||||
return buildNoteTag(note?.key || ref.key);
|
||||
})
|
||||
];
|
||||
|
||||
if (favoriteTag && !tags.includes(favoriteTag)) {
|
||||
tags.push(favoriteTag);
|
||||
}
|
||||
|
||||
return [...new Set(tags.filter((tag): tag is string => !!tag))];
|
||||
};
|
||||
|
||||
export 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;
|
||||
});
|
||||
};
|
||||
|
||||
export const buildTaskShareRef = (
|
||||
parsedTag: Extract<ReturnType<typeof parseTags>[number], { kind: "share" | "note" }>,
|
||||
existingRefs: TaskShareRef[] = [],
|
||||
shareModeOverrides: Record<string, "collaborative" | "handoff"> = {}
|
||||
): TaskShareRef => {
|
||||
if (parsedTag.kind === "note") {
|
||||
const note = store.notes.find(existing => !existing.deletedAt && existing.key === parsedTag.key);
|
||||
const nextRefBase = {
|
||||
contextId: note?.id || parsedTag.key,
|
||||
key: parsedTag.key,
|
||||
kind: "note" as const
|
||||
};
|
||||
const existing = existingRefs.find(ref => ref.kind === "note" && (ref.contextId === nextRefBase.contextId || ref.key === nextRefBase.key));
|
||||
return {
|
||||
...nextRefBase,
|
||||
policy: shareModeOverrides[parsedTag.raw.toLowerCase()] || existing?.policy || getTaskSharePolicy(nextRefBase)
|
||||
};
|
||||
}
|
||||
|
||||
const context = findContextForShareTag(parsedTag);
|
||||
const nextRefBase = {
|
||||
contextId: context?.id || parsedTag.key,
|
||||
key: parsedTag.key,
|
||||
kind: (context?.kind || "bucket") as "user" | "bucket"
|
||||
};
|
||||
const existing = existingRefs.find(ref =>
|
||||
ref.kind === nextRefBase.kind &&
|
||||
(ref.contextId === nextRefBase.contextId || ref.key === nextRefBase.key)
|
||||
);
|
||||
return {
|
||||
...nextRefBase,
|
||||
policy: shareModeOverrides[parsedTag.raw.toLowerCase()] || existing?.policy || getTaskSharePolicy(nextRefBase)
|
||||
};
|
||||
};
|
||||
|
||||
export const getTaskShareModeForTag = (task: Pick<Task, "shareRefs">, tag: string): "collaborative" | "handoff" | undefined => {
|
||||
const parsed = parseTags([tag])[0];
|
||||
if (!parsed || (parsed.kind !== "share" && parsed.kind !== "note")) return undefined;
|
||||
|
||||
const ref = task.shareRefs.find(existing =>
|
||||
existing.key === parsed.key && (
|
||||
(parsed.kind === "note" && existing.kind === "note") ||
|
||||
(parsed.kind === "share" && existing.kind !== "note")
|
||||
)
|
||||
);
|
||||
|
||||
return ref ? getTaskSharePolicy(ref) : undefined;
|
||||
};
|
||||
|
||||
export const deriveTaskRelationsFromTags = (
|
||||
tags: string[],
|
||||
existingShareRefs: TaskShareRef[] = [],
|
||||
shareModeOverrides: Record<string, "collaborative" | "handoff"> = {}
|
||||
) => {
|
||||
const parsedTags = parseTags(tags);
|
||||
const labelTags = parsedTags.filter(tag => tag.kind === "label").map(tag => tag.display);
|
||||
const shareRefs = parsedTags
|
||||
.filter((tag): tag is Extract<typeof tag, { kind: "share" | "note" }> => tag.kind === "share" || tag.kind === "note")
|
||||
.map(tag => buildTaskShareRef(tag, existingShareRefs, shareModeOverrides));
|
||||
const noteRefs = parsedTags
|
||||
.filter(tag => tag.kind === "note")
|
||||
.map(tag => {
|
||||
const note = store.notes.find(existing => !existing.deletedAt && existing.key === tag.key);
|
||||
return {
|
||||
noteId: note?.id || tag.key,
|
||||
key: tag.key
|
||||
} satisfies NoteRef;
|
||||
});
|
||||
|
||||
return {
|
||||
labelTags,
|
||||
shareRefs: dedupeShareRefs(shareRefs),
|
||||
noteRefs: dedupeNoteRefs(noteRefs)
|
||||
};
|
||||
};
|
||||
|
||||
export 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;
|
||||
});
|
||||
};
|
||||
|
||||
export const setContexts = (contexts: ShareContext[]) => {
|
||||
setStore("contexts", reconcile(contexts));
|
||||
setStore("buckets", reconcile(deriveBuckets(contexts)));
|
||||
setStore("tasks", tasks => tasks.map(task => {
|
||||
const favoriteTag = task.tags.find(tag => tag.endsWith("_favorite__"));
|
||||
return {
|
||||
...task,
|
||||
tags: buildTaskTags(task.labelTags, task.shareRefs, task.noteRefs, favoriteTag)
|
||||
};
|
||||
}));
|
||||
|
||||
const labelDefinitions = store.tagDefinitions.filter(def => !def.name.startsWith("@"));
|
||||
const mergedDefs = [...labelDefinitions, ...deriveSystemTagDefinitions(contexts)];
|
||||
setStore("tagDefinitions", reconcile(mergedDefs));
|
||||
};
|
||||
|
||||
export const mapContextToShareRule = (context: ShareContext): ShareRule => ({
|
||||
id: context.id,
|
||||
ownerId: pb.authStore.model?.id || "",
|
||||
targetUserId: context.targetUserId || pb.authStore.model?.id || "",
|
||||
type: "tag",
|
||||
tagName: buildShareTag(context.displayName),
|
||||
share_mode: context.policy === "handoff" ? "SEND_TASK" : "ADD_USER"
|
||||
});
|
||||
|
||||
export const mapRecordToContext = (r: any): ShareContext => ({
|
||||
id: r.id,
|
||||
key: r.key || normalizeContextKey(r.displayName || r.name || ""),
|
||||
displayName: r.displayName || r.name || r.key,
|
||||
kind: (r.kind || "bucket") as "user" | "bucket",
|
||||
policy: (r.policy || (r.kind === "user" ? "collaborative" : "handoff")) as "collaborative" | "handoff",
|
||||
targetUserId: unwrapRelationId(r.targetUserId),
|
||||
supervisorUserIds: unwrapRelationIds(r.supervisorUserIds),
|
||||
color: r.color,
|
||||
description: r.description,
|
||||
deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : null,
|
||||
deletedBy: unwrapRelationId(r.deletedBy)
|
||||
});
|
||||
|
||||
export const loadContextsIntoStore = async () => {
|
||||
const contextRecords = await pb.collection(CONTEXTS_COLLECTION).getFullList({
|
||||
sort: "displayName,key",
|
||||
requestKey: null
|
||||
}).catch(() => []);
|
||||
const nextContexts = contextRecords.map(mapRecordToContext);
|
||||
setContexts(nextContexts);
|
||||
setStore("shareRules", nextContexts.map(mapContextToShareRule));
|
||||
return nextContexts;
|
||||
};
|
||||
|
||||
export const getPersonalContext = (userId = pb.authStore.model?.id) =>
|
||||
userId
|
||||
? store.contexts.find(context =>
|
||||
!context.deletedAt &&
|
||||
context.kind === "user" &&
|
||||
context.targetUserId === userId
|
||||
) || null
|
||||
: null;
|
||||
|
||||
const getCurrentUserDisplayName = () =>
|
||||
(pb.authStore.model?.name || pb.authStore.model?.email || "").trim() || null;
|
||||
|
||||
const getPersonalContextTag = (userId = pb.authStore.model?.id) => {
|
||||
const context = getPersonalContext(userId);
|
||||
if (context?.displayName) {
|
||||
return buildShareTag(context.displayName);
|
||||
}
|
||||
|
||||
if (userId && userId === pb.authStore.model?.id) {
|
||||
const displayName = getCurrentUserDisplayName();
|
||||
if (displayName) {
|
||||
return buildShareTag(displayName);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const escapeFilterValue = (value: string) => value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
|
||||
export const buildPersonalAccessFilter = (userIds: string[], includeLegacyUser = true) => {
|
||||
const clauses = userIds.flatMap(userId => {
|
||||
const nextClauses: string[] = [];
|
||||
const personalTag = getPersonalContextTag(userId);
|
||||
if (personalTag) {
|
||||
nextClauses.push(`tags ~ "${escapeFilterValue(personalTag)}"`);
|
||||
}
|
||||
if (includeLegacyUser) {
|
||||
nextClauses.push(relationFilter("user", userId));
|
||||
}
|
||||
return nextClauses;
|
||||
}).filter(Boolean);
|
||||
|
||||
return clauses.length > 0 ? `(${clauses.join(" || ")})` : "false";
|
||||
};
|
||||
|
||||
export 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;
|
||||
};
|
||||
|
||||
export const getTaskPersonalRefs = (task: Pick<Task, "shareRefs">) =>
|
||||
task.shareRefs.filter(ref => ref.kind === "user" && !!getPersonalContextUserIdFromRef(ref));
|
||||
|
||||
const getTaskPersonalUserIds = (task: Pick<Task, "shareRefs">) =>
|
||||
[...new Set(getTaskPersonalRefs(task).map(ref => getPersonalContextUserIdFromRef(ref)).filter((value): value is string => !!value))];
|
||||
|
||||
const taskHasLegacyPersonalAccess = (task: Pick<Task, "ownerId" | "shareRefs">, userId: string) =>
|
||||
task.ownerId === userId &&
|
||||
getTaskPersonalRefs(task).length === 0 &&
|
||||
!task.shareRefs.some(ref => ref.kind !== "user" && getTaskSharePolicy(ref) === "handoff");
|
||||
|
||||
export const taskHasPersonalContextAccess = (task: Pick<Task, "ownerId" | "shareRefs">, userId: string, allowLegacyFallback = true) =>
|
||||
getTaskPersonalUserIds(task).includes(userId) ||
|
||||
(allowLegacyFallback && taskHasLegacyPersonalAccess(task, userId));
|
||||
|
||||
export const resolveMirroredOwnerId = (
|
||||
currentOwnerId: string | null | undefined,
|
||||
shareRefs: TaskShareRef[],
|
||||
preferredOwnerId?: string | null
|
||||
) => {
|
||||
const personalUserIds = [...new Set(
|
||||
shareRefs
|
||||
.filter(ref => ref.kind === "user")
|
||||
.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;
|
||||
};
|
||||
|
||||
export const withMirroredOwnerId = (
|
||||
currentOwnerId: string | null | undefined,
|
||||
shareRefs: TaskShareRef[],
|
||||
updates: Partial<Task>,
|
||||
preferredOwnerId?: string | null
|
||||
) => ({
|
||||
...updates,
|
||||
ownerId: resolveMirroredOwnerId(currentOwnerId, shareRefs, preferredOwnerId)
|
||||
});
|
||||
|
||||
export const withActiveContextTags = (rawTags: string[]) => {
|
||||
const nextTags = [...rawTags];
|
||||
const ctx = currentTaskContext();
|
||||
|
||||
if (ctx === "mine") {
|
||||
const personalContext = getPersonalContext();
|
||||
if (personalContext) {
|
||||
const personalTag = buildShareTag(personalContext.displayName);
|
||||
if (!nextTags.some(tag => tag.toLowerCase() === personalTag.toLowerCase())) {
|
||||
nextTags.push(personalTag);
|
||||
}
|
||||
}
|
||||
} else if (typeof ctx === "object") {
|
||||
if ("bucketId" in ctx && ctx.name) {
|
||||
const bucketContext = store.contexts.find(context => context.id === ctx.bucketId);
|
||||
const bucketTag = buildShareTag(bucketContext?.displayName || ctx.name);
|
||||
if (!nextTags.some(tag => tag.toLowerCase() === bucketTag.toLowerCase())) {
|
||||
nextTags.push(bucketTag);
|
||||
}
|
||||
} else if ("userId" in ctx && ctx.name) {
|
||||
const userTag = buildShareTag(ctx.name);
|
||||
if (!nextTags.some(tag => tag.toLowerCase() === userTag.toLowerCase())) {
|
||||
nextTags.push(userTag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (store.isNotepadMode) {
|
||||
const noteId = activeNoteId();
|
||||
const note = noteId ? store.notes.find(existing => existing.id === noteId) : null;
|
||||
if (note) {
|
||||
const noteTag = buildNoteTag(note.key || note.title);
|
||||
if (!nextTags.some(tag => tag.toLowerCase() === noteTag.toLowerCase())) {
|
||||
nextTags.push(noteTag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(nextTags)];
|
||||
};
|
||||
|
||||
export const isCurrentUserContextTag = (tag: string) => {
|
||||
const normalizedTag = tag.trim().toLowerCase();
|
||||
if (!normalizedTag.startsWith("@")) return false;
|
||||
|
||||
const personalContext = getPersonalContext();
|
||||
const currentUserDisplayName = (pb.authStore.model?.name || pb.authStore.model?.email || "").trim();
|
||||
const hiddenTags = new Set<string>();
|
||||
|
||||
if (personalContext?.displayName) {
|
||||
hiddenTags.add(buildShareTag(personalContext.displayName).toLowerCase());
|
||||
}
|
||||
|
||||
if (currentUserDisplayName) {
|
||||
hiddenTags.add(buildShareTag(currentUserDisplayName).toLowerCase());
|
||||
}
|
||||
|
||||
return hiddenTags.has(normalizedTag);
|
||||
};
|
||||
|
||||
export const isTaskLockedNoteTag = (task: Pick<Task, "tags" | "shareRefs" | "noteRefs">, tag: string) => {
|
||||
const normalizedTag = tag.trim();
|
||||
if (!normalizedTag.startsWith("#")) return false;
|
||||
|
||||
const noteKey = normalizeNoteKey(normalizedTag);
|
||||
const matchingNoteRefs = task.noteRefs.filter(ref => ref.key === noteKey || ref.noteId === noteKey);
|
||||
if (matchingNoteRefs.length === 0) return false;
|
||||
|
||||
const nonNoteContexts = task.shareRefs.filter(ref => ref.kind !== "note");
|
||||
const uniqueNoteRefs = dedupeNoteRefs(task.noteRefs);
|
||||
return uniqueNoteRefs.length === matchingNoteRefs.length && nonNoteContexts.length === 0;
|
||||
};
|
||||
|
||||
export const getVisibleTaskTags = (task: Pick<Task, "tags" | "shareRefs" | "noteRefs">, context: TaskContext = currentTaskContext()) => {
|
||||
const tags = task.tags || [];
|
||||
|
||||
return tags.filter(tag => {
|
||||
if (tag.endsWith("_favorite__") || isCurrentUserContextTag(tag)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isTaskLockedNoteTag(task, tag)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof context === "object" && "bucketId" in context) {
|
||||
const bucketContext = store.contexts.find(existing => existing.id === context.bucketId);
|
||||
const bucketTagName = buildShareTag(bucketContext?.displayName || context.name).toLowerCase();
|
||||
if (tag.toLowerCase() === bucketTagName) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
export const getSubscribedBucketContexts = () =>
|
||||
store.subscribedBuckets
|
||||
.map(id => store.contexts.find(context => context.id === id))
|
||||
.filter((context): context is ShareContext => !!context && !context.deletedAt && context.kind === "bucket");
|
||||
|
||||
export const shouldTaskBeVisible = (task: Task, currentUserId: string): boolean => {
|
||||
if (taskHasPersonalContextAccess(task, currentUserId)) return true;
|
||||
if (store.supervisedContexts.some(context => taskHasPersonalContextAccess(task, context.ownerUserId))) return true;
|
||||
|
||||
const hasSubscribedBucket = task.shareRefs.some(ref =>
|
||||
ref.kind === "bucket" && (
|
||||
store.subscribedBuckets.includes(ref.contextId) ||
|
||||
store.subscribedBuckets.some(bucketId => store.contexts.find(context => context.id === bucketId)?.key === ref.key)
|
||||
)
|
||||
);
|
||||
if (hasSubscribedBucket) return true;
|
||||
|
||||
const hasAccessibleNote = task.noteRefs.some(ref => {
|
||||
const note = getNoteByRef(ref);
|
||||
return !!note && (!note.isPrivate || note.user === currentUserId);
|
||||
});
|
||||
if (hasAccessibleNote) return true;
|
||||
|
||||
return task.shareRefs.some(ref => {
|
||||
if (ref.kind !== "user" || getTaskSharePolicy(ref) !== "collaborative") return false;
|
||||
const context = getContextByRef(ref);
|
||||
return !!context && !context.deletedAt && context.targetUserId === currentUserId;
|
||||
});
|
||||
};
|
||||
|
||||
export const syncUserContexts = async () => {
|
||||
const allUsers = await pb.collection("users").getFullList({
|
||||
filter: "verified = true",
|
||||
fields: "id,name,email",
|
||||
requestKey: null
|
||||
});
|
||||
const existingContexts = await pb.collection(CONTEXTS_COLLECTION).getFullList({
|
||||
requestKey: null
|
||||
}).catch(() => []);
|
||||
let createdAny = false;
|
||||
|
||||
for (const user of allUsers) {
|
||||
const displayName = user.name || user.email;
|
||||
const existingUserContext = existingContexts.find((context: any) =>
|
||||
(context.kind || "bucket") === "user" &&
|
||||
unwrapRelationId(context.targetUserId) === user.id
|
||||
);
|
||||
if (!displayName || existingUserContext) continue;
|
||||
|
||||
const created = await pb.collection(CONTEXTS_COLLECTION).create({
|
||||
key: buildUserContextKey(user.id),
|
||||
displayName,
|
||||
kind: "user",
|
||||
policy: "collaborative",
|
||||
targetUserId: user.id
|
||||
}, { requestKey: null }).catch(() => null);
|
||||
if (created) {
|
||||
createdAny = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (createdAny || store.contexts.length === 0) {
|
||||
await loadContextsIntoStore();
|
||||
}
|
||||
};
|
||||
|
||||
export const syncSupervisedContexts = async () => {
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
if (!currentUserId) {
|
||||
setStore("supervisedContexts", []);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const contextRecords = await pb.collection(CONTEXTS_COLLECTION).getFullList({
|
||||
sort: "displayName,key",
|
||||
requestKey: null
|
||||
}).catch(() => []);
|
||||
const contexts = contextRecords.length > 0 ? contextRecords.map(mapRecordToContext) : store.contexts;
|
||||
|
||||
if (contextRecords.length > 0) {
|
||||
setContexts(contexts);
|
||||
setStore("shareRules", contexts.map(mapContextToShareRule));
|
||||
}
|
||||
|
||||
const nextSupervisedContexts = contexts
|
||||
.filter(context =>
|
||||
!context.deletedAt &&
|
||||
context.kind === "user" &&
|
||||
!!context.targetUserId &&
|
||||
context.targetUserId !== currentUserId &&
|
||||
(context.supervisorUserIds || []).includes(currentUserId)
|
||||
)
|
||||
.map(context => ({
|
||||
ownerUserId: context.targetUserId as string,
|
||||
ownerName: context.displayName,
|
||||
contextId: context.id,
|
||||
contextName: context.displayName
|
||||
}))
|
||||
.sort((a, b) => a.ownerName.localeCompare(b.ownerName));
|
||||
|
||||
setStore("supervisedContexts", nextSupervisedContexts);
|
||||
} catch (err) {
|
||||
console.error("Failed to load supervised contexts:", err);
|
||||
}
|
||||
};
|
||||
|
||||
export const toggleBucketSubscription = async (bucketId: string) => {
|
||||
const currentSubscribed = store.subscribedBuckets;
|
||||
const isSubscribed = currentSubscribed.includes(bucketId);
|
||||
|
||||
const newSubscribed = isSubscribed
|
||||
? currentSubscribed.filter(id => id !== bucketId)
|
||||
: [...currentSubscribed, bucketId];
|
||||
|
||||
setStore("subscribedBuckets", newSubscribed);
|
||||
|
||||
const userId = pb.authStore.model?.id;
|
||||
if (userId) {
|
||||
try {
|
||||
const user = await pb.collection("users").getOne(userId, { requestKey: null });
|
||||
const rawPrefs = user.Taskgrid_pref || {};
|
||||
const scopedPrefs = readScopedPrefs(rawPrefs);
|
||||
await pb.collection("users").update(userId, {
|
||||
Taskgrid_pref: writeScopedPrefs(rawPrefs, {
|
||||
...scopedPrefs,
|
||||
subscribedBuckets: newSubscribed
|
||||
})
|
||||
});
|
||||
|
||||
if (!isSubscribed) {
|
||||
const bucket = store.buckets.find(b => b.id === bucketId);
|
||||
if (bucket) {
|
||||
const { getTaskListFields, mapRecordToTask } = await import("./tasks");
|
||||
const records = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: `tags ~ "${buildShareTag(bucket.name)}"`,
|
||||
sort: "-created",
|
||||
fields: getTaskListFields()
|
||||
});
|
||||
|
||||
const newTasks: Task[] = [];
|
||||
records.forEach((r: any) => {
|
||||
const exists = store.tasks.find(t => t.id === r.id);
|
||||
if (!exists && !r.tags?.includes("__template__")) {
|
||||
newTasks.push(mapRecordToTask(r));
|
||||
}
|
||||
});
|
||||
|
||||
if (newTasks.length > 0) {
|
||||
setStore("tasks", t => [...newTasks, ...t]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to update subscriptions:", err);
|
||||
setStore("subscribedBuckets", currentSubscribed);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const createBucket = async (name: string, color: string, description?: string) => {
|
||||
try {
|
||||
const key = normalizeContextKey(name);
|
||||
await pb.collection(CONTEXTS_COLLECTION).create({
|
||||
key,
|
||||
displayName: name,
|
||||
kind: "bucket",
|
||||
policy: "handoff",
|
||||
color,
|
||||
description
|
||||
});
|
||||
toast.success("Bucket created");
|
||||
} catch (err) {
|
||||
console.error("Failed to create bucket:", err);
|
||||
toast.error("Failed to create bucket");
|
||||
}
|
||||
};
|
||||
|
||||
export const updateBucket = async (id: string, data: { name?: string, color?: string, description?: string, policy?: "collaborative" | "handoff" }) => {
|
||||
try {
|
||||
const payload: any = { ...data };
|
||||
if (data.name) payload.key = normalizeContextKey(data.name);
|
||||
if (data.name) payload.displayName = data.name;
|
||||
await pb.collection(CONTEXTS_COLLECTION).update(id, payload);
|
||||
toast.success("Bucket updated");
|
||||
} catch (err) {
|
||||
console.error("Failed to update bucket:", err);
|
||||
toast.error("Failed to update bucket");
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteBucket = async (id: string) => {
|
||||
try {
|
||||
await pb.collection(CONTEXTS_COLLECTION).update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
deletedBy: pb.authStore.model?.id || null
|
||||
});
|
||||
toast.success("Bucket moved to trash");
|
||||
} catch (err) {
|
||||
console.error("Failed to delete bucket:", err);
|
||||
toast.error("Failed to delete bucket");
|
||||
}
|
||||
};
|
||||
|
||||
export const updateUserContextPolicy = async (contextId: string, policy: "collaborative" | "handoff") => {
|
||||
const success = await setContextPolicy(contextId, policy);
|
||||
if (success) {
|
||||
toast.success("User context updated");
|
||||
}
|
||||
};
|
||||
|
||||
export const setContextPolicy = async (contextId: string, policy: "collaborative" | "handoff") => {
|
||||
const previousContext = store.contexts.find(context => context.id === contextId);
|
||||
const previousPolicy = previousContext?.policy;
|
||||
|
||||
if (previousContext && previousPolicy !== policy) {
|
||||
setStore("contexts", context => context.id === contextId, "policy", policy);
|
||||
setStore("shareRules", store.contexts.map(mapContextToShareRule));
|
||||
}
|
||||
|
||||
try {
|
||||
await pb.collection(CONTEXTS_COLLECTION).update(contextId, { policy }, { requestKey: null });
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Failed to update user context:", err);
|
||||
if (previousContext && previousPolicy) {
|
||||
setStore("contexts", context => context.id === contextId, "policy", previousPolicy);
|
||||
setStore("shareRules", store.contexts.map(mapContextToShareRule));
|
||||
}
|
||||
toast.error("Failed to update user context");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const setNoteContextPolicy = async (noteId: string, policy: "collaborative" | "handoff") => {
|
||||
if (!pb.authStore.isValid || !noteId) return false;
|
||||
|
||||
const note = store.notes.find(existing => existing.id === noteId);
|
||||
if (!note) return false;
|
||||
|
||||
const baseTags = (note.tags || []).filter(tag => tag !== NOTE_HANDOFF_POLICY_TAG);
|
||||
const nextTags = policy === "handoff" ? [...baseTags, NOTE_HANDOFF_POLICY_TAG] : baseTags;
|
||||
const previousTags = note.tags || [];
|
||||
|
||||
setStore("notes", n => n.id === noteId, "tags", nextTags);
|
||||
|
||||
try {
|
||||
await pb.collection(NOTES_COLLECTION).update(noteId, { tags: nextTags }, { requestKey: null });
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Failed to update note context policy:", err);
|
||||
setStore("notes", n => n.id === noteId, "tags", previousTags);
|
||||
toast.error("Failed to update note sharing mode");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const savePersonalContextSupervisors = async (supervisorUserIds: string[]) => {
|
||||
const userId = pb.authStore.model?.id;
|
||||
if (!userId) return;
|
||||
|
||||
const uniqueSupervisorIds = [...new Set(supervisorUserIds.filter(Boolean))];
|
||||
setStore("personalContextSupervisorIds", uniqueSupervisorIds);
|
||||
|
||||
try {
|
||||
await syncUserContexts();
|
||||
const personalContext = getPersonalContext(userId);
|
||||
if (!personalContext) {
|
||||
throw new Error("Missing personal context record");
|
||||
}
|
||||
|
||||
await pb.collection(CONTEXTS_COLLECTION).update(personalContext.id, {
|
||||
supervisorUserIds: uniqueSupervisorIds
|
||||
}, { requestKey: null });
|
||||
|
||||
const user = await pb.collection("users").getOne(userId, { requestKey: null });
|
||||
const rawPrefs = user.Taskgrid_pref || {};
|
||||
const scopedPrefs = readScopedPrefs(rawPrefs);
|
||||
await pb.collection("users").update(userId, {
|
||||
Taskgrid_pref: writeScopedPrefs(rawPrefs, {
|
||||
...scopedPrefs,
|
||||
supervisorUserIds: uniqueSupervisorIds
|
||||
})
|
||||
}, { requestKey: null });
|
||||
await syncSupervisedContexts();
|
||||
toast.success("Personal supervision updated");
|
||||
} catch (err) {
|
||||
console.error("Failed to update personal supervision:", err);
|
||||
toast.error("Failed to update personal supervision");
|
||||
}
|
||||
};
|
||||
|
||||
export const loadShareRules = async () => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
setStore("shareRules", reconcile(store.contexts.map(mapContextToShareRule)));
|
||||
};
|
||||
|
||||
export const addShareRule = async (type: "tag" | "all", targetUserId: string, tagName?: string, share_mode: "ADD_USER" | "SEND_TASK" = "SEND_TASK") => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
if (type !== "tag" || !tagName?.startsWith("@")) {
|
||||
toast.error("Only @tag contexts are supported now.");
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = store.contexts.find(context => context.targetUserId === targetUserId && buildShareTag(context.displayName) === tagName);
|
||||
if (existing) return;
|
||||
|
||||
try {
|
||||
await pb.collection(CONTEXTS_COLLECTION).create({
|
||||
key: normalizeContextKey(tagName),
|
||||
displayName: tagName.replace(/^@/, ""),
|
||||
kind: "user",
|
||||
policy: share_mode === "SEND_TASK" ? "handoff" : "collaborative",
|
||||
targetUserId
|
||||
}, { requestKey: null });
|
||||
toast.success("Shared context created");
|
||||
} catch (err) {
|
||||
console.error("Failed to create shared context:", err);
|
||||
toast.error("Failed to create shared context.");
|
||||
}
|
||||
};
|
||||
|
||||
export const updateShareRule = async (ruleId: string, updates: Partial<ShareRule>) => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
|
||||
try {
|
||||
const context = store.contexts.find(existing => existing.id === ruleId);
|
||||
if (!context) return;
|
||||
await pb.collection(CONTEXTS_COLLECTION).update(ruleId, {
|
||||
policy: updates.share_mode === "SEND_TASK" ? "handoff" : "collaborative"
|
||||
}, { requestKey: null });
|
||||
toast.success("Context updated");
|
||||
} catch (err) {
|
||||
console.error("Failed to update share rule:", err);
|
||||
toast.error("Failed to update share rule.");
|
||||
}
|
||||
};
|
||||
|
||||
export const removeShareRule = async (ruleId: string) => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
|
||||
try {
|
||||
await pb.collection(CONTEXTS_COLLECTION).delete(ruleId);
|
||||
toast.success("Context removed");
|
||||
} catch (err) {
|
||||
console.error("Failed to delete share rule:", err);
|
||||
toast.error("Failed to delete share rule.");
|
||||
}
|
||||
};
|
||||
|
||||
export const syncSystemTagsAndRules = async () => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
setStore("shareRules", store.contexts.map(mapContextToShareRule));
|
||||
setStore("tagDefinitions", defs => {
|
||||
const plainDefs = defs.filter(def => !def.name.startsWith("@"));
|
||||
return [...plainDefs, ...deriveSystemTagDefinitions(store.contexts)];
|
||||
});
|
||||
};
|
||||
+2380
-6
File diff suppressed because it is too large
Load Diff
@@ -1,365 +0,0 @@
|
||||
import { toast } from "solid-sonner";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { NOTES_COLLECTION, TASGRID_COLLECTION } from "@/lib/constants";
|
||||
import { buildNoteTag, normalizeNoteKey } from "@/lib/tags";
|
||||
import { relationFilter } from "./contexts";
|
||||
import {
|
||||
activeNoteId,
|
||||
setActiveNoteId,
|
||||
type Note,
|
||||
type Task,
|
||||
setStore,
|
||||
store,
|
||||
unwrapRelationId
|
||||
} from "./state";
|
||||
import { getTaskListFields, mapRecordToTask, updateTask } from "./tasks";
|
||||
|
||||
let notesLoadPromise: Promise<void> | null = null;
|
||||
let deferredNotesLoadTimer: number | undefined;
|
||||
|
||||
export const loadedNoteDetailIds = new Set<string>();
|
||||
export const failedNoteDetailIds = new Set<string>();
|
||||
|
||||
export const mapRecordToNote = (r: any, includeContent = true): Note => {
|
||||
return {
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
key: r.key || normalizeNoteKey(r.title),
|
||||
content: includeContent ? r.content : undefined,
|
||||
tags: Array.isArray(r.tags) && r.tags.length > 0 ? r.tags : [buildNoteTag(r.key || r.title)],
|
||||
isPrivate: r.isPrivate || false,
|
||||
user: unwrapRelationId(r.user) || "",
|
||||
tasks: r.tasks || [],
|
||||
created: r.created,
|
||||
updated: r.updated,
|
||||
deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : undefined
|
||||
};
|
||||
};
|
||||
|
||||
const reconcileFetchedNotes = (records: any[], includeContent = false) => {
|
||||
const existingById = new Map(store.notes.map(note => [note.id, note]));
|
||||
const nextNotes = records.map((record: any) => {
|
||||
const mapped = mapRecordToNote(record, includeContent);
|
||||
const existing = existingById.get(mapped.id);
|
||||
|
||||
if (!includeContent && existing?.content !== undefined) {
|
||||
mapped.content = existing.content;
|
||||
}
|
||||
|
||||
return mapped;
|
||||
});
|
||||
|
||||
setStore("notes", nextNotes);
|
||||
};
|
||||
|
||||
export const fetchNoteById = async (id: string): Promise<Note | null> => {
|
||||
try {
|
||||
const record = await pb.collection(NOTES_COLLECTION).getOne(id);
|
||||
if (!record) return null;
|
||||
return mapRecordToNote(record);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch note by ID:", err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateStoreWithNote = (note: Note) => {
|
||||
failedNoteDetailIds.delete(note.id);
|
||||
if (note.content !== undefined) {
|
||||
loadedNoteDetailIds.add(note.id);
|
||||
}
|
||||
setStore("notes", currentNotes => {
|
||||
const index = currentNotes.findIndex(n => n.id === note.id);
|
||||
if (index !== -1) {
|
||||
const newNotes = [...currentNotes];
|
||||
newNotes[index] = note;
|
||||
return newNotes;
|
||||
}
|
||||
return [...currentNotes, note];
|
||||
});
|
||||
};
|
||||
|
||||
export const loadNotesMetadata = async () => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
if (store.hasLoadedNotesMetadata) return;
|
||||
if (notesLoadPromise) return notesLoadPromise;
|
||||
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
setStore("isNotesLoading", true);
|
||||
|
||||
notesLoadPromise = (async () => {
|
||||
try {
|
||||
const notesRecords = await pb.collection(NOTES_COLLECTION).getFullList({
|
||||
filter: `isPrivate = false || ${relationFilter("user", currentUserId)}`,
|
||||
sort: "-created",
|
||||
fields: "id,created,updated,title,key,tags,isPrivate,user,tasks,deletedAt",
|
||||
requestKey: null
|
||||
});
|
||||
|
||||
reconcileFetchedNotes(notesRecords, false);
|
||||
setStore("hasLoadedNotesMetadata", true);
|
||||
} catch (notesErr) {
|
||||
console.warn("Failed to load notes (collection might not exist yet):", notesErr);
|
||||
} finally {
|
||||
setStore("isNotesLoading", false);
|
||||
notesLoadPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return notesLoadPromise;
|
||||
};
|
||||
|
||||
export const scheduleDeferredNotesMetadataLoad = () => {
|
||||
if (!pb.authStore.isValid || store.hasLoadedNotesMetadata || notesLoadPromise || deferredNotesLoadTimer) return;
|
||||
|
||||
deferredNotesLoadTimer = window.setTimeout(() => {
|
||||
deferredNotesLoadTimer = undefined;
|
||||
void loadNotesMetadata();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
export const requestImmediateNotesMetadataLoad = async () => {
|
||||
if (deferredNotesLoadTimer) {
|
||||
clearTimeout(deferredNotesLoadTimer);
|
||||
deferredNotesLoadTimer = undefined;
|
||||
}
|
||||
|
||||
await loadNotesMetadata();
|
||||
};
|
||||
|
||||
export const loadNoteContent = async (id: string) => {
|
||||
if (!id) return;
|
||||
if (loadedNoteDetailIds.has(id)) return;
|
||||
if (failedNoteDetailIds.has(id)) return;
|
||||
if (store.loadingNoteContentIds.includes(id)) return;
|
||||
|
||||
setStore("loadingNoteContentIds", ids => ids.includes(id) ? ids : [...ids, id]);
|
||||
|
||||
try {
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
const visibilityFilter = `id = "${id}" && (isPrivate = false || ${relationFilter("user", currentUserId)})`;
|
||||
const record = await pb.collection(NOTES_COLLECTION).getFirstListItem(visibilityFilter, {
|
||||
requestKey: `note-content-${id}`
|
||||
});
|
||||
const note = mapRecordToNote(record, true);
|
||||
updateStoreWithNote(note);
|
||||
loadedNoteDetailIds.add(id);
|
||||
} catch (err) {
|
||||
const status = (err as any)?.status;
|
||||
if (status === 404) {
|
||||
failedNoteDetailIds.add(id);
|
||||
return;
|
||||
}
|
||||
console.error("Failed to load note content:", err);
|
||||
} finally {
|
||||
setStore("loadingNoteContentIds", ids => ids.filter(existingId => existingId !== id));
|
||||
}
|
||||
};
|
||||
|
||||
export const uploadNoteAttachment = async (noteId: string, file: File): Promise<{ url: string, filename: string }> => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("attachments+", file);
|
||||
|
||||
const record = await pb.collection(NOTES_COLLECTION).update(noteId, formData);
|
||||
|
||||
if (record.attachments && record.attachments.length > 0) {
|
||||
const latestFilename = record.attachments[record.attachments.length - 1];
|
||||
return {
|
||||
url: pb.files.getURL(record, latestFilename),
|
||||
filename: latestFilename
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("File uploaded but no attachment returned");
|
||||
} catch (err: any) {
|
||||
console.error("Error uploading note attachment. Validation details:", JSON.stringify(err.data, null, 2));
|
||||
toast.error("Failed to upload image to note.");
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
export const cleanupNoteAttachments = async (noteId: string) => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
|
||||
try {
|
||||
const record = await pb.collection(NOTES_COLLECTION).getOne(noteId);
|
||||
if (!record.attachments || record.attachments.length === 0) return;
|
||||
|
||||
const content = record.content || "";
|
||||
const filesToDelete: string[] = [];
|
||||
const decodedContent = decodeURIComponent(content);
|
||||
|
||||
for (const filename of record.attachments) {
|
||||
if (!content.includes(filename) && !decodedContent.includes(filename)) {
|
||||
filesToDelete.push(filename);
|
||||
}
|
||||
}
|
||||
|
||||
if (filesToDelete.length > 0) {
|
||||
await pb.collection(NOTES_COLLECTION).update(noteId, {
|
||||
"attachments-": filesToDelete
|
||||
});
|
||||
console.log(`[CLEANUP] Deleted ${filesToDelete.length} orphaned attachment(s) for note ${noteId}`);
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as any)?.status === 404) return;
|
||||
console.error("[CLEANUP] Failed to clean up orphaned note attachments:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const removeDeletedNoteFromStore = (id: string) => {
|
||||
if (activeNoteId() === id) {
|
||||
setActiveNoteId(null);
|
||||
}
|
||||
loadedNoteDetailIds.delete(id);
|
||||
failedNoteDetailIds.delete(id);
|
||||
setStore("loadingNoteContentIds", ids => ids.filter(existingId => existingId !== id));
|
||||
setStore("notes", notes => notes.filter(note => note.id !== id));
|
||||
};
|
||||
|
||||
const removeNoteReferencesFromTask = (task: Task, note: Note): Partial<Task> | null => {
|
||||
const noteKey = (note.key || normalizeNoteKey(note.title)).toLowerCase();
|
||||
const noteTag = buildNoteTag(note.key || note.title).toLowerCase();
|
||||
const nextNoteRefs = task.noteRefs.filter(ref =>
|
||||
ref.noteId !== note.id &&
|
||||
ref.key.toLowerCase() !== noteKey
|
||||
);
|
||||
const nextShareRefs = task.shareRefs.filter(ref =>
|
||||
!(ref.kind === "note" && (ref.contextId === note.id || ref.key.toLowerCase() === noteKey))
|
||||
);
|
||||
const nextTags = (task.tags || []).filter(tag => tag.toLowerCase() !== noteTag);
|
||||
|
||||
const noteRefsChanged = nextNoteRefs.length !== task.noteRefs.length;
|
||||
const shareRefsChanged = nextShareRefs.length !== task.shareRefs.length;
|
||||
const tagsChanged = nextTags.length !== (task.tags || []).length;
|
||||
|
||||
if (!noteRefsChanged && !shareRefsChanged && !tagsChanged) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
tags: nextTags,
|
||||
noteRefs: nextNoteRefs,
|
||||
shareRefs: nextShareRefs
|
||||
};
|
||||
};
|
||||
|
||||
const cleanupTasksForPermanentlyDeletedNote = async (note: Note) => {
|
||||
const taskRecords = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
fields: getTaskListFields(),
|
||||
requestKey: null
|
||||
});
|
||||
const affectedTasks = taskRecords
|
||||
.filter((record: any) => !record.tags?.includes("__template__"))
|
||||
.map((record: any) => mapRecordToTask(record))
|
||||
.filter(task => !!removeNoteReferencesFromTask(task, note));
|
||||
|
||||
for (const task of affectedTasks) {
|
||||
const cleanup = removeNoteReferencesFromTask(task, note);
|
||||
if (!cleanup) continue;
|
||||
await updateTask(task.id, cleanup);
|
||||
}
|
||||
};
|
||||
|
||||
export const updateNote = async (id: string, updates: Partial<Note>) => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
|
||||
if (updates.content !== undefined) {
|
||||
loadedNoteDetailIds.add(id);
|
||||
}
|
||||
|
||||
const optimisticUpdates = {
|
||||
...updates,
|
||||
key: updates.key || (updates.title ? normalizeNoteKey(updates.title) : undefined),
|
||||
updated: new Date().toISOString()
|
||||
};
|
||||
setStore("notes", n => n.id === id, optimisticUpdates);
|
||||
|
||||
try {
|
||||
const pbUpdates: any = { ...updates };
|
||||
|
||||
if ("deletedAt" in updates) {
|
||||
if (updates.deletedAt) {
|
||||
pbUpdates.deletedAt = new Date(updates.deletedAt).toISOString();
|
||||
} else {
|
||||
pbUpdates.deletedAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!pbUpdates.key && updates.title) {
|
||||
pbUpdates.key = normalizeNoteKey(updates.title);
|
||||
}
|
||||
|
||||
await pb.collection(NOTES_COLLECTION).update(id, pbUpdates, { requestKey: null });
|
||||
} catch (err) {
|
||||
console.error("Note update failed", err);
|
||||
toast.error("Failed to update note.");
|
||||
}
|
||||
};
|
||||
|
||||
export const removeNote = (id: string) => {
|
||||
updateNote(id, { deletedAt: Date.now() });
|
||||
};
|
||||
|
||||
export const restoreNote = (id: string) => {
|
||||
updateNote(id, { deletedAt: null });
|
||||
};
|
||||
|
||||
export const deleteNotePermanently = async (id: string) => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
|
||||
const note = store.notes.find(existing => existing.id === id) || await fetchNoteById(id);
|
||||
if (note) {
|
||||
await cleanupTasksForPermanentlyDeletedNote(note);
|
||||
}
|
||||
|
||||
removeDeletedNoteFromStore(id);
|
||||
|
||||
try {
|
||||
await pb.collection(NOTES_COLLECTION).delete(id);
|
||||
} catch (err) {
|
||||
console.error("Note delete failed", err);
|
||||
toast.error("Failed to delete note.");
|
||||
}
|
||||
};
|
||||
|
||||
export const loadTasksLinkedToNote = async (note: Pick<Note, "id" | "key" | "title" | "tasks">) => {
|
||||
if (!pb.authStore.isValid || !note?.id) return;
|
||||
|
||||
const noteTag = buildNoteTag(note.key || note.title);
|
||||
const escapedNoteTag = noteTag.replace(/"/g, '\\"');
|
||||
|
||||
try {
|
||||
const listRecords = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: `(tags ~ "${escapedNoteTag}") && tags !~ "__template__"`,
|
||||
sort: "-updated",
|
||||
fields: getTaskListFields(),
|
||||
requestKey: `note-linked-${note.id}`
|
||||
}).catch(() => []);
|
||||
|
||||
const recordMap = new Map<string, any>();
|
||||
listRecords.forEach(record => {
|
||||
if (!record?.id) return;
|
||||
if (record.tags?.includes("__template__")) return;
|
||||
recordMap.set(record.id, record);
|
||||
});
|
||||
|
||||
if (recordMap.size === 0) return;
|
||||
|
||||
setStore("tasks", currentTasks => {
|
||||
const taskMap = new Map(currentTasks.map(task => [task.id, task]));
|
||||
recordMap.forEach((record: any) => {
|
||||
const mapped = mapRecordToTask(record);
|
||||
const existing = taskMap.get(mapped.id);
|
||||
if (existing?.content) {
|
||||
mapped.content = existing.content;
|
||||
}
|
||||
taskMap.set(mapped.id, mapped);
|
||||
});
|
||||
return Array.from(taskMap.values());
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error("Failed to load note-linked tasks:", err?.status, err?.response || err);
|
||||
}
|
||||
};
|
||||
@@ -1,183 +0,0 @@
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { toast } from "solid-sonner";
|
||||
import {
|
||||
createDefaultFilter,
|
||||
type Filter,
|
||||
type FilterTemplate,
|
||||
readScopedPrefs,
|
||||
setStore,
|
||||
store,
|
||||
writeScopedPrefs
|
||||
} from "./state";
|
||||
|
||||
export const syncPreferences = async () => {
|
||||
const userId = pb.authStore.model?.id;
|
||||
if (!userId) return;
|
||||
|
||||
try {
|
||||
const user = await pb.collection("users").getOne(userId, { requestKey: null });
|
||||
const rawPrefs = user.Taskgrid_pref || {};
|
||||
const prefs = {
|
||||
pWeight: store.pWeight,
|
||||
uWeight: store.uWeight,
|
||||
matrixScaleDays: store.matrixScaleDays,
|
||||
tagDefinitions: store.tagDefinitions.filter(def => !def.name.startsWith("@")),
|
||||
filterTemplates: store.filterTemplates,
|
||||
quickloadTasks: store.quickloadTasks,
|
||||
dismissedTaskUpdateIndicators: store.dismissedTaskUpdateIndicators,
|
||||
collapsedUntilUpdatedTasks: store.collapsedUntilUpdatedTasks,
|
||||
subscribedBuckets: store.subscribedBuckets,
|
||||
supervisorUserIds: store.personalContextSupervisorIds,
|
||||
aiHelpTipSectionIndex: store.aiHelpTipSectionIndex,
|
||||
lastCompletedTaskCleanupDate: store.lastCompletedTaskCleanupDate
|
||||
};
|
||||
|
||||
await pb.collection("users").update(userId, {
|
||||
Taskgrid_pref: writeScopedPrefs(rawPrefs, {
|
||||
...readScopedPrefs(rawPrefs),
|
||||
...prefs
|
||||
})
|
||||
}, { requestKey: null });
|
||||
} catch (e: any) {
|
||||
if (e.isAbort) return;
|
||||
console.error("Failed to sync preferences", e);
|
||||
}
|
||||
};
|
||||
|
||||
export const setFilter = (update: Partial<Filter>) => {
|
||||
setStore("filter", f => ({ ...f, ...update }));
|
||||
};
|
||||
|
||||
export const loadFilterTemplates = async () => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
const userId = pb.authStore.model?.id;
|
||||
if (!userId) return;
|
||||
const user = await pb.collection("users").getOne(userId, { requestKey: null });
|
||||
const prefs = readScopedPrefs(user.Taskgrid_pref || {});
|
||||
setStore("filterTemplates", prefs.filterTemplates || []);
|
||||
};
|
||||
|
||||
export const saveFilterTemplate = async (name: string) => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
try {
|
||||
const newTemplate: FilterTemplate = {
|
||||
id: `filter-${Date.now()}`,
|
||||
name,
|
||||
filter: store.filter
|
||||
};
|
||||
|
||||
setStore("filterTemplates", prev => [...prev, newTemplate]);
|
||||
await syncPreferences();
|
||||
toast.success(`Filter template "${name}" saved`);
|
||||
} catch (err) {
|
||||
console.error("Failed to save filter template:", err);
|
||||
toast.error("Failed to save filter template.");
|
||||
}
|
||||
};
|
||||
|
||||
export const applyFilterTemplate = (id: string) => {
|
||||
const template = store.filterTemplates.find(t => t.id === id);
|
||||
if (template) {
|
||||
setFilter(template.filter);
|
||||
toast.success(`Applied filter: ${template.name}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const removeFilterTemplate = async (id: string) => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
try {
|
||||
setStore("filterTemplates", prev => prev.filter(t => t.id !== id));
|
||||
await syncPreferences();
|
||||
toast.success("Filter template deleted");
|
||||
} catch (err) {
|
||||
console.error("Failed to delete filter template:", err);
|
||||
toast.error("Failed to delete filter template.");
|
||||
}
|
||||
};
|
||||
|
||||
export const clearFilter = () => {
|
||||
setStore("filter", createDefaultFilter());
|
||||
};
|
||||
|
||||
export const setNoteFilter = (update: Partial<Filter>) => {
|
||||
setStore("noteFilter", f => ({ ...f, ...update }));
|
||||
};
|
||||
|
||||
export const loadNoteFilterTemplates = async () => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
const userId = pb.authStore.model?.id;
|
||||
if (!userId) return;
|
||||
const user = await pb.collection("users").getOne(userId, { requestKey: null });
|
||||
const prefs = readScopedPrefs(user.Taskgrid_pref || {});
|
||||
setStore("filterTemplates", prefs.noteFilterTemplates || []);
|
||||
};
|
||||
|
||||
export const saveNoteFilterTemplate = async (name: string) => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
try {
|
||||
const newTemplate: FilterTemplate = {
|
||||
id: `note-filter-${Date.now()}`,
|
||||
name,
|
||||
filter: store.noteFilter
|
||||
};
|
||||
|
||||
setStore("filterTemplates", prev => [...prev, newTemplate]);
|
||||
const userId = pb.authStore.model?.id;
|
||||
if (userId) {
|
||||
const user = await pb.collection("users").getOne(userId, { requestKey: null });
|
||||
const rawPrefs = user.Taskgrid_pref || {};
|
||||
const prefs = readScopedPrefs(rawPrefs);
|
||||
await pb.collection("users").update(userId, {
|
||||
Taskgrid_pref: writeScopedPrefs(rawPrefs, {
|
||||
...prefs,
|
||||
noteFilterTemplates: [...(prefs.noteFilterTemplates || []), newTemplate]
|
||||
})
|
||||
}, { requestKey: null });
|
||||
}
|
||||
toast.success(`Note filter template "${name}" saved`);
|
||||
} catch (err) {
|
||||
console.error("Failed to save note filter template:", err);
|
||||
toast.error("Failed to save note filter template.");
|
||||
}
|
||||
};
|
||||
|
||||
export const applyNoteFilterTemplate = (id: string) => {
|
||||
const template = store.filterTemplates.find(t => t.id === id);
|
||||
if (template) {
|
||||
setNoteFilter(template.filter);
|
||||
toast.success(`Applied note filter: ${template.name}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const removeNoteFilterTemplate = async (id: string) => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
try {
|
||||
setStore("filterTemplates", prev => prev.filter(t => t.id !== id));
|
||||
const userId = pb.authStore.model?.id;
|
||||
if (userId) {
|
||||
const user = await pb.collection("users").getOne(userId, { requestKey: null });
|
||||
const rawPrefs = user.Taskgrid_pref || {};
|
||||
const prefs = readScopedPrefs(rawPrefs);
|
||||
await pb.collection("users").update(userId, {
|
||||
Taskgrid_pref: writeScopedPrefs(rawPrefs, {
|
||||
...prefs,
|
||||
noteFilterTemplates: (prefs.noteFilterTemplates || []).filter((template: FilterTemplate) => template.id !== id)
|
||||
})
|
||||
}, { requestKey: null });
|
||||
}
|
||||
toast.success("Note filter template deleted");
|
||||
} catch (err) {
|
||||
console.error("Failed to delete note filter template:", err);
|
||||
toast.error("Failed to delete note filter template.");
|
||||
}
|
||||
};
|
||||
|
||||
export const clearNoteFilter = () => {
|
||||
setStore("noteFilter", createDefaultFilter());
|
||||
};
|
||||
|
||||
export const advanceAIHelpTipSectionIndex = async () => {
|
||||
setStore("aiHelpTipSectionIndex", prev => prev + 1);
|
||||
|
||||
await syncPreferences();
|
||||
};
|
||||
@@ -1,356 +0,0 @@
|
||||
import { createSignal } from "solid-js";
|
||||
import { createStore } from "solid-js/store";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { STORAGE_KEY_PREFIX, TASGRID_DATA_MODE } from "@/lib/app-config";
|
||||
|
||||
// Helper to get the current user's personalized favorite tag
|
||||
export const getFavoriteTag = () => {
|
||||
const userId = pb.authStore.model?.id;
|
||||
return userId ? `__${userId}_favorite__` : "__favorite__";
|
||||
};
|
||||
|
||||
export const [now, setNow] = createSignal(Date.now());
|
||||
|
||||
// Context: 'mine' is a temporary bootstrap alias for the current user's personal context bucket.
|
||||
export type TaskContext = "mine" | { userId: string, name: string } | { bucketId: string, name: string, isPersonal?: boolean };
|
||||
export const [activeTaskId, setActiveTaskId] = createSignal<string | null>(null);
|
||||
export const [activeNoteId, setActiveNoteId] = createSignal<string | null>(null);
|
||||
export const [currentTaskContext, setCurrentTaskContext] = createSignal<TaskContext>("mine");
|
||||
|
||||
export type UrgencyLevel = number;
|
||||
|
||||
export interface TaskShareRef {
|
||||
contextId: string;
|
||||
key: string;
|
||||
kind: "user" | "bucket" | "note";
|
||||
policy?: "collaborative" | "handoff";
|
||||
}
|
||||
|
||||
export interface NoteRef {
|
||||
noteId: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
startDate: string;
|
||||
dueDate: string;
|
||||
priority: number;
|
||||
status: number;
|
||||
completed: boolean;
|
||||
tags: string[];
|
||||
labelTags: string[];
|
||||
shareRefs: TaskShareRef[];
|
||||
noteRefs: NoteRef[];
|
||||
ownerId: string | null;
|
||||
createdBy: string | null;
|
||||
updatedBy?: string | null;
|
||||
content?: string;
|
||||
deletedAt?: number | null;
|
||||
created: string;
|
||||
updated: string;
|
||||
recurrence?: {
|
||||
type: "daily" | "weekly" | "monthly";
|
||||
days?: number[];
|
||||
dayOfMonth?: number;
|
||||
lastUncompleted?: string;
|
||||
};
|
||||
size?: number;
|
||||
sharedWith?: Array<{ userId: string; access: "view" | "edit" }>;
|
||||
attachments?: string[];
|
||||
}
|
||||
|
||||
export interface TaskTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
priority: number;
|
||||
urgency: number;
|
||||
tags: string[];
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface TagDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
value: number;
|
||||
color?: string;
|
||||
theme?: "light" | "dark";
|
||||
isUser?: boolean;
|
||||
isBucket?: boolean;
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
id: string;
|
||||
title: string;
|
||||
key: string;
|
||||
content?: string;
|
||||
tags: string[];
|
||||
isPrivate: boolean;
|
||||
user: string;
|
||||
tasks: string[];
|
||||
created: string;
|
||||
updated: string;
|
||||
deletedAt?: number | null;
|
||||
}
|
||||
|
||||
export interface FilterTag {
|
||||
name: string;
|
||||
excluded: boolean;
|
||||
}
|
||||
|
||||
export interface Filter {
|
||||
query: string;
|
||||
tags: FilterTag[];
|
||||
priorityMin: number;
|
||||
priorityMax: number;
|
||||
urgencyMin: number;
|
||||
urgencyMax: number;
|
||||
editedToday: boolean;
|
||||
ownedByMe: boolean;
|
||||
starred: boolean;
|
||||
jobStatus: FilterTag[];
|
||||
jobDivision: FilterTag[];
|
||||
}
|
||||
|
||||
export interface FilterTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
filter: Filter;
|
||||
}
|
||||
|
||||
export interface ShareContext {
|
||||
id: string;
|
||||
key: string;
|
||||
displayName: string;
|
||||
kind: "user" | "bucket" | "note";
|
||||
policy: "collaborative" | "handoff";
|
||||
targetUserId?: string | null;
|
||||
supervisorUserIds?: string[];
|
||||
color?: string;
|
||||
description?: string;
|
||||
deletedAt?: number | null;
|
||||
deletedBy?: string | null;
|
||||
}
|
||||
|
||||
export interface ShareRule {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
targetUserId: string;
|
||||
type: "tag" | "all";
|
||||
tagName?: string;
|
||||
share_mode?: "ADD_USER" | "SEND_TASK";
|
||||
}
|
||||
|
||||
export interface SupervisedContextAccess {
|
||||
ownerUserId: string;
|
||||
ownerName: string;
|
||||
contextId: string;
|
||||
contextName: string;
|
||||
}
|
||||
|
||||
export interface Bucket {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
description?: string;
|
||||
policy?: "collaborative" | "handoff";
|
||||
key?: string;
|
||||
deletedAt?: number | null;
|
||||
}
|
||||
|
||||
export interface TaskStore {
|
||||
tasks: Task[];
|
||||
isInitializing: boolean;
|
||||
quickloadFadeTaskIds: string[];
|
||||
dismissedTaskUpdateIndicators: Record<string, string>;
|
||||
collapsedUntilUpdatedTasks: Record<string, string>;
|
||||
isNotesLoading: boolean;
|
||||
hasLoadedNotesMetadata: boolean;
|
||||
loadingNoteContentIds: string[];
|
||||
pWeight: number;
|
||||
uWeight: number;
|
||||
matrixScaleDays: number;
|
||||
prefId?: string;
|
||||
tagDefinitions: TagDefinition[];
|
||||
templates: TaskTemplate[];
|
||||
filter: Filter;
|
||||
shareRules: ShareRule[];
|
||||
contexts: ShareContext[];
|
||||
supervisedContexts: SupervisedContextAccess[];
|
||||
filterTemplates: FilterTemplate[];
|
||||
buckets: Bucket[];
|
||||
subscribedBuckets: string[];
|
||||
personalContextSupervisorIds: string[];
|
||||
aiHelpTipSectionIndex: number;
|
||||
notes: Note[];
|
||||
isNotepadMode: boolean;
|
||||
quickloadTasks: string[];
|
||||
noteFilter: Filter;
|
||||
lastCompletedTaskCleanupDate: string;
|
||||
}
|
||||
|
||||
export interface ScopedTaskgridPrefs {
|
||||
pWeight?: number;
|
||||
uWeight?: number;
|
||||
matrixScaleDays?: number;
|
||||
tagDefinitions?: TagDefinition[];
|
||||
filter?: Filter;
|
||||
filterTemplates?: FilterTemplate[];
|
||||
noteFilterTemplates?: FilterTemplate[];
|
||||
quickloadTasks?: string[];
|
||||
noteFilter?: Filter;
|
||||
subscribedBuckets?: string[];
|
||||
supervisorUserIds?: string[];
|
||||
aiHelpTipSectionIndex?: number;
|
||||
dismissedTaskUpdateIndicators?: Record<string, string>;
|
||||
collapsedUntilUpdatedTasks?: Record<string, string>;
|
||||
migratedStructuredTagsV2?: boolean;
|
||||
migratedPersonalContextRefsV1?: boolean;
|
||||
migratedContextOwnershipV2?: boolean;
|
||||
lastCompletedTaskCleanupDate?: string;
|
||||
}
|
||||
|
||||
export const createDefaultFilter = (): Filter => ({
|
||||
query: "",
|
||||
tags: [],
|
||||
priorityMin: 1,
|
||||
priorityMax: 10,
|
||||
urgencyMin: 1,
|
||||
urgencyMax: 10,
|
||||
editedToday: false,
|
||||
ownedByMe: false,
|
||||
starred: false,
|
||||
jobStatus: [],
|
||||
jobDivision: []
|
||||
});
|
||||
|
||||
export const [store, setStore] = createStore<TaskStore>({
|
||||
tasks: [],
|
||||
isInitializing: false,
|
||||
quickloadFadeTaskIds: [],
|
||||
dismissedTaskUpdateIndicators: {},
|
||||
collapsedUntilUpdatedTasks: {},
|
||||
isNotesLoading: false,
|
||||
hasLoadedNotesMetadata: false,
|
||||
loadingNoteContentIds: [],
|
||||
pWeight: 1.0,
|
||||
uWeight: 1.0,
|
||||
matrixScaleDays: 30,
|
||||
tagDefinitions: [],
|
||||
templates: [],
|
||||
filter: createDefaultFilter(),
|
||||
shareRules: [],
|
||||
contexts: [],
|
||||
supervisedContexts: [],
|
||||
filterTemplates: [],
|
||||
buckets: [],
|
||||
subscribedBuckets: [],
|
||||
personalContextSupervisorIds: [],
|
||||
aiHelpTipSectionIndex: 0,
|
||||
notes: [],
|
||||
isNotepadMode: false,
|
||||
quickloadTasks: [],
|
||||
lastCompletedTaskCleanupDate: "",
|
||||
noteFilter: createDefaultFilter()
|
||||
});
|
||||
|
||||
const PREF_ENVIRONMENTS_KEY = "environments";
|
||||
|
||||
export const getStorageKey = () => {
|
||||
const userId = pb.authStore.model?.id;
|
||||
return userId ? `${STORAGE_KEY_PREFIX}_data_${userId}` : null;
|
||||
};
|
||||
|
||||
export const getPersistedStoreSnapshot = () => {
|
||||
const { isInitializing, quickloadFadeTaskIds, isNotesLoading, hasLoadedNotesMetadata, loadingNoteContentIds, ...persisted } = store;
|
||||
return persisted;
|
||||
};
|
||||
|
||||
export const readScopedPrefs = (rawPrefs: any): ScopedTaskgridPrefs => {
|
||||
const scoped = rawPrefs?.[PREF_ENVIRONMENTS_KEY]?.[TASGRID_DATA_MODE];
|
||||
return (scoped || rawPrefs || {}) as ScopedTaskgridPrefs;
|
||||
};
|
||||
|
||||
export const writeScopedPrefs = (rawPrefs: any, scopedPrefs: ScopedTaskgridPrefs) => ({
|
||||
...(rawPrefs || {}),
|
||||
[PREF_ENVIRONMENTS_KEY]: {
|
||||
...(rawPrefs?.[PREF_ENVIRONMENTS_KEY] || {}),
|
||||
[TASGRID_DATA_MODE]: scopedPrefs
|
||||
}
|
||||
});
|
||||
|
||||
export const applyScopedPrefsToStore = (prefs: ScopedTaskgridPrefs) => {
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
const personalContext = currentUserId
|
||||
? store.contexts.find(context =>
|
||||
!context.deletedAt &&
|
||||
context.kind === "user" &&
|
||||
context.targetUserId === currentUserId
|
||||
) || null
|
||||
: null;
|
||||
|
||||
setStore({
|
||||
pWeight: prefs.pWeight || 1.0,
|
||||
uWeight: prefs.uWeight || 1.0,
|
||||
matrixScaleDays: prefs.matrixScaleDays || 30,
|
||||
prefId: currentUserId,
|
||||
subscribedBuckets: prefs.subscribedBuckets || [],
|
||||
personalContextSupervisorIds: personalContext?.supervisorUserIds || prefs.supervisorUserIds || [],
|
||||
aiHelpTipSectionIndex: typeof prefs.aiHelpTipSectionIndex === "number" ? prefs.aiHelpTipSectionIndex : 0,
|
||||
tagDefinitions: (prefs.tagDefinitions || []).filter((def: TagDefinition) => !def.name.startsWith("@")),
|
||||
filterTemplates: prefs.filterTemplates || [],
|
||||
quickloadTasks: prefs.quickloadTasks || [],
|
||||
dismissedTaskUpdateIndicators: prefs.dismissedTaskUpdateIndicators || {},
|
||||
collapsedUntilUpdatedTasks: prefs.collapsedUntilUpdatedTasks || {},
|
||||
noteFilter: store.noteFilter,
|
||||
filter: store.filter
|
||||
});
|
||||
};
|
||||
|
||||
export 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;
|
||||
};
|
||||
|
||||
export const unwrapRelationIds = (value: unknown): string[] => {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map(item => {
|
||||
if (typeof item === "string") return item;
|
||||
if (item && typeof item === "object" && "id" in item && typeof (item as any).id === "string") {
|
||||
return (item as any).id;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((item): item is string => !!item);
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
return unwrapRelationIds(parsed);
|
||||
} catch {
|
||||
return trimmed.includes(",")
|
||||
? trimmed.split(",").map(item => item.trim()).filter(Boolean)
|
||||
: [trimmed];
|
||||
}
|
||||
}
|
||||
|
||||
if (value && typeof value === "object" && "id" in value && typeof (value as any).id === "string") {
|
||||
return [(value as any).id];
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
-1219
File diff suppressed because it is too large
Load Diff
@@ -1,10 +0,0 @@
|
||||
import { type Component } from "solid-js";
|
||||
import { AIHelpPanel } from "@/components/AIHelpPanel";
|
||||
|
||||
export const AIHelpView: Component = () => {
|
||||
return (
|
||||
<div class="h-full min-h-0 w-full max-w-5xl mx-auto md:py-6 md:px-0">
|
||||
<AIHelpPanel variant="page" class="h-full" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
+270
-211
@@ -1,218 +1,277 @@
|
||||
import { type Component, For, Show, onCleanup, onMount } from "solid-js";
|
||||
import { ArrowLeft, BookOpen, Boxes, ExternalLink, HelpCircle, LayoutDashboard, Link2, ListTodo, NotebookText, PencilLine, RefreshCcw, Search, Settings2, Share2, ShieldCheck, Sparkles, WandSparkles, Workflow } from "lucide-solid";
|
||||
import { type Component, Show } from "solid-js";
|
||||
import { ArrowLeft, ExternalLink, HelpCircle, Zap, ShieldCheck, ListTodo, LayoutDashboard, Snowflake, Pickaxe, Clock, ArrowUpCircle, Search, Palette, Trash2, Smartphone, BarChart3 } from "lucide-solid";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface HelpViewProps { onBack?: () => void; }
|
||||
interface AccessCard { title: string; content: string; detail: string; href?: string; }
|
||||
interface QuickAction { title: string; steps: string[]; target: string; }
|
||||
interface RelatedLink { label: string; target: string; }
|
||||
interface Section { id: string; title: string; icon: any; intro: string; body: string; steps: string[]; reminder: string; warning: string; related: RelatedLink[]; }
|
||||
interface DeepSection { id: string; title: string; icon: any; intro: string; bullets: string[]; why: string; related: RelatedLink[]; }
|
||||
|
||||
const accessCards: AccessCard[] = [
|
||||
{ title: "Website", content: "tasgrid.ccllc.pro", href: "https://tasgrid.ccllc.pro", detail: "Open TasGrid in your browser or install it as an app on mobile." },
|
||||
{ title: "Login", content: "Use your Cardoza Construction email", detail: "Your account uses the same email and password you use for Outlook." },
|
||||
{ title: "What This Guide Is For", content: "Refresh first, then go deeper", detail: "This guide supports the in-person training you already received." }
|
||||
];
|
||||
|
||||
const quickActions: QuickAction[] = [
|
||||
{ title: "Create a task", target: "create-tasks", steps: ["Open Quick Entry with + or Ctrl/Cmd+K.", "Enter Title, Priority, and Urgency.", "Create the task and let TasGrid keep the active context."] },
|
||||
{ title: "Edit a task", target: "manage-tasks", steps: ["Click the task to open it.", "Edit status, dates, tags, and notes.", "Use the status circle to update progress or completion."] },
|
||||
{ title: "Share a task", target: "share-tasks", steps: ["Open the task.", "Add an @person, @bucket, or #note tag.", "Choose Collaborative or Handoff if prompted."] },
|
||||
{ title: "Move work into a bucket", target: "buckets-supervision", steps: ["Add the bucket's @tag to the task.", "Or create the task while inside that bucket.", "Pin the bucket in Settings if you want it in the switcher."] },
|
||||
{ title: "Switch to notes", target: "notes", steps: ["Use the Tasks/Notes toggle.", "Open or create a note.", "Work inside it like a project document, not just a comment box."] },
|
||||
{ title: "Link a task to a note", target: "notes", steps: ["Open the note's Linked Tasks panel.", "Link an existing task or create a new one.", "TasGrid connects it through the note's #tag."] },
|
||||
{ title: "Find filtered work", target: "finding-work", steps: ["Use search and filters first.", "Switch views if you need a different ranking lens.", "Save the filter if you will reuse it."] },
|
||||
{ title: "Recover from trash", target: "recovery-trash", steps: ["Open Settings.", "Expand Trash.", "Recover the item before its 7-day window expires."] }
|
||||
];
|
||||
|
||||
const coreSections: Section[] = [
|
||||
{ id: "create-tasks", title: "Create Tasks", icon: ListTodo, intro: "Use this when you remember the concept but forgot the exact task flow.", body: "Quick Entry is the main capture flow. It keeps scheduling and context consistent for you.", steps: ["Open Quick Entry with the floating plus button or Ctrl/Cmd+K.", "Enter Title, Priority, and Urgency. Add Size, Due Date, tags, and rich details if needed.", "Create the task. TasGrid automatically applies the current context, like a bucket, supervised user, or active note."], reminder: "Urgency and Due Date stay linked, and new tasks inherit the place you are currently working from.", warning: "If you create work inside a bucket, supervised context, or note, TasGrid usually adds that context automatically.", related: [{ label: "How contexts and sharing work", target: "contexts-sharing" }, { label: "How to find work later", target: "finding-work" }] },
|
||||
{ id: "manage-tasks", title: "Edit and Manage Tasks", icon: PencilLine, intro: "Use this when you know the task exists but forgot where a control lives.", body: "The task detail sheet is the main editing surface for title, status, dates, tags, favorites, and task notes.", steps: ["Click a task to open the detail sheet.", "Edit title, status, priority, urgency, size, due date, tags, favorite state, and the rich text body.", "Use the status circle to move through progress states or mark the task complete."], reminder: "Task cards also support quick actions like status updates, duplicate, and favorite.", warning: "Deleting a task sends it to trash first, so check trash before assuming something is gone forever.", related: [{ label: "Recover from trash", target: "recovery-trash" }, { label: "Templates and recurrence", target: "time-savers" }] },
|
||||
{ id: "notes", title: "Notes", icon: NotebookText, intro: "Use this when you need to refresh how notes behave as workspaces.", body: "Notes are a full mode inside the app. They can hold rich content, linked tasks, favorites, and public or private visibility.", steps: ["Switch to Notes using the Tasks/Notes toggle.", "Create or open a note from the notes sidebar.", "Use Linked Tasks to connect existing work or create a task directly from the note context."], reminder: "Creating a task from a note usually links it automatically through the note's #tag.", warning: "If you rename a note, TasGrid also updates the matching #note tag definition so linked tasks stay aligned.", related: [{ label: "How notes become project workspaces", target: "notes-project-workspaces" }, { label: "How rich editing works", target: "editor-rich-content" }] },
|
||||
{ id: "share-tasks", title: "Share Tasks", icon: Share2, intro: "Use this if you remember that sharing exists, but not the current model.", body: "Task sharing is tag-driven. You share by attaching a user, bucket, or note context to the task.", steps: ["Open the task and edit its tags.", "Add an @person, @bucket, or #note context through the tag picker.", "Choose Collaborative or Handoff when TasGrid asks for a sharing mode."], reminder: "The old mental model of a separate share dialog is no longer the main workflow.", warning: "Handoff and Collaborative do very different things. Handoff moves responsibility; Collaborative keeps the task visible in both places.", related: [{ label: "How contexts and sharing work", target: "contexts-sharing" }, { label: "Buckets and supervision", target: "buckets-supervision" }] },
|
||||
{ id: "buckets-supervision", title: "Buckets and Supervision", icon: Boxes, intro: "Use this if you forgot how to move between your work, team buckets, and supervised contexts.", body: "Contexts control where work lives and who can see it. The same switcher handles My Bucket, pinned shared buckets, and supervised personal contexts.", steps: ["Use the context switcher to move between My Bucket, pinned buckets, and supervised contexts.", "Pin buckets in Settings if you want them in the switcher.", "Create tasks from the context you are already inside so TasGrid applies the right tag automatically."], reminder: "Your own personal context tag, the active bucket tag, and some note tags may stay hidden while still remaining active.", warning: "If a task seems to belong to a bucket or person even though you cannot see the tag, hidden context behavior is often the reason.", related: [{ label: "Share tasks", target: "share-tasks" }, { label: "Deep dive on context behavior", target: "contexts-sharing" }] },
|
||||
{ id: "finding-work", title: "Views and Finding Work", icon: Search, intro: "Use this when the task exists but you cannot remember how to surface it quickly.", body: "TasGrid gives you multiple sort views plus search and filters so you can find the right work without rebuilding lists by hand.", steps: ["Use search for text and the filter controls for tags, ranges, edited today, mine, or starred.", "Switch between Focus, Matrix, Snowball, Dig In, Urgency, Priority, or Progress depending on the lens you need.", "Save filters you reuse often so you do not have to rebuild them every time."], reminder: "Changing views is often faster than changing the task itself.", warning: "If a task seems missing, first check your current context and filters before assuming it was deleted or moved.", related: [{ label: "How task views think", target: "task-views-think" }, { label: "Templates and saved filters", target: "time-savers" }] },
|
||||
{ id: "recovery-trash", title: "Recovery and Trash", icon: RefreshCcw, intro: "Use this when you are trying to undo something or confirm whether an item is really gone.", body: "Settings contains separate trash sections for tasks and notes. Items stay there for 7 days before permanent deletion.", steps: ["Open Settings and expand Trash.", "Look in the Tasks or Notes list depending on what you lost.", "Recover the item or permanently delete it if you are sure."], reminder: "Deletion is usually reversible during the trash window, and the app also offers an immediate undo action.", warning: "Expired trash is permanently removed when the app loads, so do not rely on trash as long-term storage.", related: [{ label: "Manage tasks", target: "manage-tasks" }, { label: "How settings change behavior", target: "settings-behavior" }] }
|
||||
];
|
||||
|
||||
const deepSections: DeepSection[] = [
|
||||
{ id: "task-views-think", title: "How Task Views Think", icon: LayoutDashboard, intro: "Use this when you want to understand why the same task appears differently across views.", bullets: ["Focus, Urgency, Priority, Progress, Snowball, and Dig In all sort the same active task pool by different rules.", "Matrix places incomplete tasks on an urgency-versus-priority grid instead of a list.", "Matrix dots are clickable, size-aware, and show U, S, and P values.", "The Matrix time scale can be adjusted to 1, 7, 30, 60, or 90 days."], why: "If users understand the view's mental model, they stop fighting the sort order and start choosing the right view for the moment.", related: [{ label: "Find work refresher", target: "finding-work" }, { label: "Templates and saved filters", target: "time-savers" }] },
|
||||
{ id: "contexts-sharing", title: "How Contexts and Sharing Work", icon: Share2, intro: "Use this when the app's access behavior feels smarter or stranger than a normal tag list.", bullets: ["TasGrid turns @user, @bucket, and #note tags into structured relationships, not just labels.", "Each context can behave as Collaborative or Handoff.", "Per-user policies, bucket policies, and supervision access all change how work moves and who can see it.", "Some context tags stay hidden in the current view while still remaining active."], why: "Most sharing confusion comes from treating contexts like plain tags when they actually control access and ownership behavior.", related: [{ label: "Share tasks refresher", target: "share-tasks" }, { label: "Buckets and supervision", target: "buckets-supervision" }] },
|
||||
{ id: "notes-project-workspaces", title: "How Notes Become Project Workspaces", icon: NotebookText, intro: "Use this when you want to go beyond simple note editing and understand note structure.", bullets: ["Root notes can have child tabs that inherit the parent note's privacy.", "Desktop supports drag-and-drop tab ordering.", "Linked tasks live at the note workspace level, and root notes manage those relationships directly.", "Share links open note content through the embed route."], why: "Once users treat notes like a workspace with tabs and linked tasks, notes become much more useful than static text pages.", related: [{ label: "Notes refresher", target: "notes" }, { label: "How the editor handles rich content", target: "editor-rich-content" }] },
|
||||
{ id: "editor-rich-content", title: "How the Editor Handles Rich Content", icon: WandSparkles, intro: "Use this when someone remembers that the editor is powerful, but forgot what it can do.", bullets: ["Tasks, notes, and templates all use the same editor.", "Typing / opens slash commands for blocks, lists, checklists, uploads, job files, dividers, and tables.", "The editor supports underline, links, highlight, alignment, resizable tables, @user mentions, and #note mentions.", "Images, videos, and files are handled differently and can be pasted or dragged directly into the editor."], why: "Users trust the note and task body more when they know it can hold real working content, not just plain text.", related: [{ label: "Notes refresher", target: "notes" }, { label: "How notes become project workspaces", target: "notes-project-workspaces" }] },
|
||||
{ id: "time-savers", title: "How Templates, Filters, and Recurrence Save Time", icon: Workflow, intro: "Use this when the user understands the basics and wants to stop repeating setup work.", bullets: ["Task templates can store reusable titles, priority, urgency, tags, and rich descriptions.", "Task filters and note filters can be saved as reusable templates.", "Load All is useful when older history is not currently loaded into the visible list.", "Recurrence supports daily, weekly, and monthly schedules, and completed recurring tasks reopen automatically."], why: "These features remove repeated setup and reduce the amount of remembering users have to do from scratch.", related: [{ label: "Create tasks refresher", target: "create-tasks" }, { label: "Find work refresher", target: "finding-work" }] },
|
||||
{ id: "settings-behavior", title: "How Settings Change Behavior", icon: Settings2, intro: "Use this when a user needs to understand how the app can be configured, not just used.", bullets: ["Settings controls theme, tag values and colors, bucket creation and pinning, supervision access, import tools, trash recovery, app updates, and PWA reset.", "Shared Buckets defines which team spaces exist and whether they behave as Collaborative or Handoff.", "Tag Sharing defines how user contexts behave and who can supervise your personal context.", "Import Tasks helps convert pasted lists into structured work with bulk defaults."], why: "Most team-level confusion comes from not realizing that sharing, buckets, and maintenance behavior are defined in Settings.", related: [{ label: "Buckets and supervision", target: "buckets-supervision" }, { label: "How contexts and sharing work", target: "contexts-sharing" }] }
|
||||
];
|
||||
|
||||
const jumpLinks = [
|
||||
{ label: "Get Unstuck Fast", target: "quick-start" },
|
||||
{ label: "Create Tasks", target: "create-tasks" },
|
||||
{ label: "Share Tasks", target: "share-tasks" },
|
||||
{ label: "Notes", target: "notes" },
|
||||
{ label: "Find Work", target: "finding-work" },
|
||||
{ label: "Deep Dive", target: "task-views-think" }
|
||||
];
|
||||
|
||||
const RelatedTopics: Component<{ items: RelatedLink[]; onJump: (id: string) => void }> = (props) => (
|
||||
<div class="pt-3 border-t border-border/40 space-y-2">
|
||||
<p class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground">Related topics</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<For each={props.items}>{(item) => <button class="rounded-full border border-border/60 bg-background px-3 py-1.5 text-[0.6875rem] font-medium text-muted-foreground hover:text-foreground hover:border-primary/40 hover:bg-primary/5 transition-colors" onClick={() => props.onJump(item.target)}>{item.label}</button>}</For>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
interface HelpViewProps {
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
export const HelpView: Component<HelpViewProps> = (props) => {
|
||||
const scrollToSection = (id?: string, behavior: ScrollBehavior = "smooth") => {
|
||||
if (!id) return;
|
||||
document.getElementById(`help-${id}`)?.scrollIntoView({ behavior, block: "start" });
|
||||
};
|
||||
const getSectionFromHash = () => {
|
||||
if (window.location.hash.startsWith("#/help/")) {
|
||||
return decodeURIComponent(window.location.hash.slice("#/help/".length));
|
||||
}
|
||||
if (window.location.hash.startsWith("#help/")) {
|
||||
return decodeURIComponent(window.location.hash.slice("#help/".length));
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const jumpTo = (id: string) => {
|
||||
const nextHash = `#/help/${encodeURIComponent(id)}`;
|
||||
if (window.location.hash !== nextHash) window.location.hash = nextHash;
|
||||
scrollToSection(id);
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
const syncToHash = () => window.setTimeout(() => scrollToSection(getSectionFromHash(), "auto"), 50);
|
||||
const handleHelpTarget = (event: Event) => {
|
||||
const detail = (event as CustomEvent).detail as { sectionId?: string } | undefined;
|
||||
window.setTimeout(() => scrollToSection(detail?.sectionId || getSectionFromHash()), 50);
|
||||
};
|
||||
syncToHash();
|
||||
window.addEventListener("hashchange", syncToHash);
|
||||
window.addEventListener("taskgrid:help-target", handleHelpTarget);
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("hashchange", syncToHash);
|
||||
window.removeEventListener("taskgrid:help-target", handleHelpTarget);
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="w-full max-w-5xl mx-auto py-4 sm:py-10 px-4 sm:px-0 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||
<header class="flex items-center gap-4 mb-8 sm:mb-10">
|
||||
<Show when={props.onBack}><Button variant="ghost" size="icon" onClick={() => props.onBack?.()} class="h-10 w-10 rounded-xl"><ArrowLeft size={20} /></Button></Show>
|
||||
<div>
|
||||
<h1 class="text-2xl sm:text-4xl font-black tracking-tighter flex items-center gap-3"><HelpCircle size={32} class="text-primary hidden sm:block" />Help</h1>
|
||||
<p class="text-[0.625rem] sm:text-xs text-muted-foreground font-mono uppercase tracking-widest opacity-70">Refresh what you forgot, then learn the deeper system</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="space-y-12 pb-20">
|
||||
<section class="rounded-3xl border border-primary/20 bg-primary/[0.05] p-5 sm:p-6 space-y-4">
|
||||
<div class="space-y-2">
|
||||
<p class="text-[0.625rem] font-black uppercase tracking-widest text-primary/80">Jump to</p>
|
||||
<h2 class="text-xl font-black tracking-tight">Need the fast version?</h2>
|
||||
<p class="text-sm text-muted-foreground leading-relaxed">Use these shortcuts when you remember being shown something, but do not remember the exact steps anymore.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<For each={jumpLinks}>{(item) => <button class="rounded-full border border-primary/20 bg-background/80 px-3 py-1.5 text-[0.6875rem] font-semibold text-foreground hover:bg-primary/10 hover:border-primary/40 transition-colors" onClick={() => jumpTo(item.target)}>{item.label}</button>}</For>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="help-quick-start" class="space-y-5">
|
||||
<div class="space-y-2">
|
||||
<h2 class="text-xl font-bold border-b border-border pb-2 flex items-center gap-2"><Sparkles size={20} class="text-primary" />Get Unstuck Fast</h2>
|
||||
<p class="text-sm text-muted-foreground leading-relaxed">These cards answer the questions users most often have after in-person training.</p>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<For each={quickActions}>{(action) => (
|
||||
<div class="rounded-2xl border border-border/50 bg-card/70 shadow-sm p-4 space-y-3">
|
||||
<div class="space-y-1"><p class="text-[0.625rem] font-black uppercase tracking-widest text-primary/80">I need to...</p><h3 class="text-sm font-bold tracking-tight">{action.title}</h3></div>
|
||||
<ol class="space-y-2">
|
||||
<For each={action.steps}>{(step, index) => <li class="flex gap-3 text-sm text-muted-foreground leading-relaxed"><span class="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary text-[0.625rem] font-bold">{index() + 1}</span><span>{step}</span></li>}</For>
|
||||
</ol>
|
||||
<Button variant="ghost" size="sm" class="h-8 px-2 text-[0.6875rem] font-bold text-primary hover:bg-primary/10" onClick={() => jumpTo(action.target)}>Open full guide</Button>
|
||||
</div>
|
||||
)}</For>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-6 rounded-[2rem] border border-sky-200/40 bg-sky-500/[0.04] p-5 sm:p-6">
|
||||
<div class="space-y-2">
|
||||
<h2 class="text-xl font-bold border-b border-border/60 pb-2 flex items-center gap-2"><BookOpen size={20} class="text-sky-600" />Core Workflows</h2>
|
||||
<p class="text-sm text-muted-foreground leading-relaxed">This layer is for refreshing the workflows you already learned in person.</p>
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<For each={coreSections}>{(section) => {
|
||||
const Icon = section.icon;
|
||||
return <article id={`help-${section.id}`} class="rounded-3xl border border-border/50 bg-card/80 shadow-sm overflow-hidden">
|
||||
<div class="p-5 sm:p-6 border-b border-border/40 bg-gradient-to-r from-sky-500/[0.08] via-sky-500/[0.03] to-transparent">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="hidden sm:flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-sky-500/10 text-sky-700"><Icon size={20} /></div>
|
||||
<div class="space-y-2 min-w-0"><h3 class="text-lg sm:text-xl font-black tracking-tight flex items-center gap-2"><Icon size={18} class="sm:hidden text-sky-700" />{section.title}</h3><p class="text-sm text-muted-foreground leading-relaxed">{section.intro}</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-5 sm:p-6 space-y-5">
|
||||
<div class="space-y-2"><p class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground">What it is</p><p class="text-sm leading-relaxed text-foreground">{section.body}</p></div>
|
||||
<div class="space-y-3"><p class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground">How to do it</p><ol class="space-y-2"><For each={section.steps}>{(step, index) => <li class="flex gap-3 text-sm text-muted-foreground leading-relaxed"><span class="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-sky-500/10 text-sky-700 text-[0.625rem] font-bold">{index() + 1}</span><span>{step}</span></li>}</For></ol></div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="rounded-2xl border border-amber-300/40 bg-amber-500/[0.06] p-4 space-y-2"><p class="text-[0.625rem] font-black uppercase tracking-widest text-amber-700">What people usually forget</p><p class="text-sm text-muted-foreground leading-relaxed">{section.reminder}</p></div>
|
||||
<div class="rounded-2xl border border-rose-300/40 bg-rose-500/[0.06] p-4 space-y-2"><p class="text-[0.625rem] font-black uppercase tracking-widest text-rose-700">Watch for this</p><p class="text-sm text-muted-foreground leading-relaxed">{section.warning}</p></div>
|
||||
</div>
|
||||
<RelatedTopics items={section.related} onJump={jumpTo} />
|
||||
</div>
|
||||
</article>;
|
||||
}}</For>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-6 rounded-[2rem] border border-violet-200/40 bg-violet-500/[0.04] p-5 sm:p-6">
|
||||
<div class="space-y-2">
|
||||
<h2 class="text-xl font-bold border-b border-border/60 pb-2 flex items-center gap-2"><Link2 size={20} class="text-violet-700" />Deep Dive</h2>
|
||||
<p class="text-sm text-muted-foreground leading-relaxed">This layer is optional enrichment for users who want the mental model behind the app.</p>
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<For each={deepSections}>{(section) => {
|
||||
const Icon = section.icon;
|
||||
return <article id={`help-${section.id}`} class="rounded-3xl border border-border/50 bg-card/80 shadow-sm overflow-hidden">
|
||||
<div class="p-5 sm:p-6 border-b border-border/40 bg-gradient-to-r from-violet-500/[0.08] via-violet-500/[0.03] to-transparent">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="hidden sm:flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-violet-500/10 text-violet-700"><Icon size={20} /></div>
|
||||
<div class="space-y-2 min-w-0"><h3 class="text-lg sm:text-xl font-black tracking-tight flex items-center gap-2"><Icon size={18} class="sm:hidden text-violet-700" />{section.title}</h3><p class="text-sm text-muted-foreground leading-relaxed">{section.intro}</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-5 sm:p-6 space-y-5">
|
||||
<div class="space-y-3"><p class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground">Learn more</p><ul class="space-y-2"><For each={section.bullets}>{(item) => <li class="flex gap-3 text-sm text-muted-foreground leading-relaxed"><span class="mt-[0.45rem] h-1.5 w-1.5 shrink-0 rounded-full bg-violet-600/70" /><span>{item}</span></li>}</For></ul></div>
|
||||
<div class="rounded-2xl border border-violet-300/40 bg-violet-500/[0.06] p-4 space-y-2"><p class="text-[0.625rem] font-black uppercase tracking-widest text-violet-700">Why it matters</p><p class="text-sm text-muted-foreground leading-relaxed">{section.why}</p></div>
|
||||
<RelatedTopics items={section.related} onJump={jumpTo} />
|
||||
</div>
|
||||
</article>;
|
||||
}}</For>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-5">
|
||||
<h2 class="text-xl font-bold border-b border-border pb-2 flex items-center gap-2"><ShieldCheck size={20} class="text-primary" />Welcome & Access</h2>
|
||||
<p class="text-sm text-muted-foreground leading-relaxed">This section is mostly here for helping someone else get into TasGrid or for occasional reference later. It is intentionally placed near the end because most users opening Help already know how to get in.</p>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<For each={accessCards}>{(card) => (
|
||||
<div class="rounded-2xl border border-border/50 bg-card/50 p-4 space-y-2 shadow-sm">
|
||||
<p class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground">{card.title}</p>
|
||||
<Show when={card.href} fallback={<p class="text-sm font-bold tracking-tight">{card.content}</p>}>
|
||||
<a href={card.href} target="_blank" class="text-sm font-bold tracking-tight text-primary flex items-center gap-1 hover:underline">{card.content}<ExternalLink size={12} /></a>
|
||||
return (
|
||||
<div class="w-full max-w-3xl mx-auto py-4 sm:py-10 px-4 sm:px-0 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||
<header class="flex items-center gap-4 mb-8">
|
||||
<Show when={props.onBack}>
|
||||
<Button variant="ghost" size="icon" onClick={() => props.onBack?.()} class="h-10 w-10 rounded-xl">
|
||||
<ArrowLeft size={20} />
|
||||
</Button>
|
||||
</Show>
|
||||
<p class="text-sm text-muted-foreground leading-relaxed">{card.detail}</p>
|
||||
</div>
|
||||
)}</For>
|
||||
</div>
|
||||
</section>
|
||||
<div>
|
||||
<h1 class="text-2xl sm:text-4xl font-black tracking-tighter flex items-center gap-3">
|
||||
<HelpCircle size={32} class="text-primary hidden sm:block" />
|
||||
User Guide
|
||||
</h1>
|
||||
<p class="text-[0.625rem] sm:text-xs text-muted-foreground font-mono uppercase tracking-widest opacity-70">
|
||||
Everything you need to know about TaskGrid
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<footer class="pt-10 border-t border-border flex flex-col items-center text-center space-y-4">
|
||||
<p class="text-sm text-muted-foreground max-w-2xl leading-relaxed">If the app feels out of sync, a quick <button onClick={() => location.reload()} class="text-primary font-bold hover:underline cursor-pointer bg-transparent border-none p-0 inline-flex items-center gap-1">refresh<RefreshCcw size={12} /></button> is still the best first step. Most changes sync automatically in real time.</p>
|
||||
<p class="text-sm font-medium">If you still need help after that, contact Timothy Cardoza or IT.</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<div class="space-y-12 pb-20">
|
||||
{/* Welcome & Access */}
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-xl font-bold border-b border-border pb-2 flex items-center gap-2">
|
||||
<ShieldCheck size={20} class="text-primary" />
|
||||
Welcome & Access
|
||||
</h2>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="p-4 rounded-2xl bg-muted/30 border border-border/50">
|
||||
<p class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground mb-1">Website</p>
|
||||
<a href="https://tasgrid.ccllc.pro" target="_blank" class="text-sm font-bold text-primary flex items-center gap-1 hover:underline">
|
||||
tasgrid.ccllc.pro
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
</div>
|
||||
<div class="p-4 rounded-2xl bg-muted/30 border border-border/50">
|
||||
<p class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground mb-1">Login</p>
|
||||
<p class="text-sm font-medium">Use your <strong>Cardoza Construction email</strong> (the same one you use for Outlook). Your password should match your email password as well.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Basic Features */}
|
||||
<section class="space-y-6">
|
||||
<h2 class="text-xl font-bold border-b border-border pb-2 flex items-center gap-2">
|
||||
<Zap size={20} class="text-yellow-500" />
|
||||
Basic Features (Getting Started)
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-bold text-lg">Creating Your First Task</h3>
|
||||
<ol class="space-y-3 pl-1">
|
||||
{[
|
||||
<>Click the <strong>large plus (+) sign</strong> in the bottom right corner (or type <code>control + k</code>).</>,
|
||||
<><strong>Title</strong>: Enter a clear name for what needs to be done.</>,
|
||||
<><strong>Priority</strong>: Choose from 1 (trivial) to 10 (absolutely necessary).</>,
|
||||
<><strong>Urgency & Due Date</strong>: These are linked!
|
||||
<ul class="mt-2 space-y-1 ml-4 list-disc text-xs text-muted-foreground">
|
||||
<li>Pick an <strong>Urgency level</strong> (10 is today, 1 is 6 months from now), and the due date will be calculated for you.</li>
|
||||
<li>Alternatively, pick a <strong>Due Date</strong> using the calendar, and the urgency will adjust automatically.</li>
|
||||
</ul>
|
||||
</>,
|
||||
<><strong>Size</strong>: This defaults to <strong>5</strong>. Adjust it if the task is very small (15 mins) or very large (multiple days).</>,
|
||||
<><strong>Description</strong>: Add notes or details. Use the <strong>forward slash (/)</strong> to reveal formatting options like bold text, checklists, and even adding images.</>,
|
||||
<>Click <strong>Create Task</strong>.</>
|
||||
].map((item, i) => (
|
||||
<li class="flex gap-4 items-start">
|
||||
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-bold">{i + 1}</span>
|
||||
<div class="text-sm leading-relaxed">{item}</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 pt-4">
|
||||
<h3 class="font-bold text-lg">Viewing Your Tasks</h3>
|
||||
<p class="text-sm text-muted-foreground mb-4">TaskGrid sorts your tasks so you don't have to. The primary views are:</p>
|
||||
<div class="grid gap-4">
|
||||
{[
|
||||
{ icon: ListTodo, label: "Focus (Default)", desc: "Your most important tasks, ranked by a combination of Urgency and Priority." },
|
||||
{
|
||||
icon: LayoutDashboard,
|
||||
label: "Strategy Matrix",
|
||||
desc: "A 4-quadrant grid mapping Urgency vs. Priority.",
|
||||
sub: [
|
||||
{ l: "Do First", d: "Urgent & Important" },
|
||||
{ l: "Schedule", d: "Important but not urgent yet" },
|
||||
{ l: "Delegate", d: "Urgent but not important" },
|
||||
{ l: "Eliminate", d: "Neither urgent nor important" }
|
||||
]
|
||||
},
|
||||
{ icon: Snowflake, label: "Snowball", desc: "Shows your smallest tasks first to help you build momentum." },
|
||||
{ icon: Pickaxe, label: "Dig In", desc: "Shows your largest tasks first for when you have time to focus." },
|
||||
{ icon: Clock, label: "Urgency", desc: "Sorts tasks purely by when they are due." },
|
||||
{ icon: ArrowUpCircle, label: "Priority", desc: "Sorts tasks purely by how important they are." }
|
||||
].map(view => (
|
||||
<div class="p-4 rounded-xl border border-border/50 bg-card/50 flex gap-4 items-start">
|
||||
<div class="mt-1 text-primary p-2 rounded-lg bg-primary/5">
|
||||
<view.icon size={18} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-bold">{view.label}</p>
|
||||
<p class="text-sm text-muted-foreground leading-relaxed">{view.desc}</p>
|
||||
{view.sub && (
|
||||
<div class="grid grid-cols-2 gap-2 pt-2 border-t border-border/20">
|
||||
{view.sub.map(s => (
|
||||
<div>
|
||||
<p class="text-[0.625rem] font-black uppercase text-primary/70">{s.l}</p>
|
||||
<p class="text-[0.625rem] text-muted-foreground">{s.d}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 pt-4">
|
||||
<h3 class="font-bold text-lg">Other Info</h3>
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
{[
|
||||
{ icon: Search, label: "Search & Filters", desc: "Use the bar at the top right to find specific tasks or filter by priority, urgency, or tasks edited today." },
|
||||
{ icon: Palette, label: "Themes", desc: "Switch between Light Mode and Dark Mode in the Settings." },
|
||||
{ icon: Trash2, label: "Trash", desc: "Deleted tasks are kept for 7 days before being permanently removed. You can recover them in Settings." }
|
||||
].map(info => (
|
||||
<div class="p-3 rounded-xl border border-border/50 bg-card/10">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<info.icon size={14} class="text-primary" />
|
||||
<p class="text-xs font-bold">{info.label}</p>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground leading-relaxed">{info.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 pt-4">
|
||||
<h3 class="font-bold text-lg">Mobile Installation</h3>
|
||||
<p class="text-sm text-muted-foreground mb-3">TaskGrid works like a dedicated app on your phone:</p>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="p-4 rounded-xl bg-primary/5 border border-primary/10">
|
||||
<p class="text-xs font-bold flex items-center gap-2 mb-2"><Smartphone size={14} /> Android</p>
|
||||
<p class="text-xs text-muted-foreground">Tap the three dots in Chrome, select "Add to Home Screen," then "Install."</p>
|
||||
</div>
|
||||
<div class="p-4 rounded-xl bg-primary/5 border border-primary/10">
|
||||
<p class="text-xs font-bold flex items-center gap-2 mb-2"><Smartphone size={14} /> iPhone</p>
|
||||
<p class="text-xs text-muted-foreground">Tap the Share icon in Safari and select "Add to Home Screen."</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Advanced Features */}
|
||||
<section class="space-y-6">
|
||||
<h2 class="text-xl font-bold border-b border-border pb-2 flex items-center gap-2">
|
||||
<Zap size={20} class="text-primary" />
|
||||
Advanced Features (Teams & Automation)
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-bold text-lg">Sharing & Collaboration</h3>
|
||||
<p class="text-sm text-muted-foreground mb-4">Collaboration in TaskGrid is flexible and transparent:</p>
|
||||
<ol class="space-y-4 text-sm leading-relaxed">
|
||||
<li>
|
||||
<strong>1. Manual Sharing</strong>: Open a task and use the <strong>Share</strong> menu to add a specific person.
|
||||
<ul class="ml-4 mt-1 list-disc text-muted-foreground">
|
||||
<li>You can also <strong>unshare and split</strong> tasks here. Unshare just removes it from their list and split gives each of you a unique copy of the task.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>2. Tag-Based User Sharing</strong>: Add a tag that matches a coworker's name (e.g., "John Smith").
|
||||
<ul class="ml-4 mt-1 list-disc text-muted-foreground">
|
||||
<li>This automatically shares the task with them.</li>
|
||||
<li>Both of you can edit the task together (Collaborative mode).</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>3. Collaboration vs. Handoff</strong>:
|
||||
<ul class="ml-4 mt-1 list-disc text-muted-foreground">
|
||||
<li><strong>Collaborate</strong>: Both users can see and edit.</li>
|
||||
<li><strong>Handoff</strong>: The task is transferred to the other person, and they become the new owner.</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 pt-4">
|
||||
<h3 class="font-bold text-lg">Team Spaces (Buckets)</h3>
|
||||
<p class="text-sm text-muted-foreground leading-relaxed italic mb-2">"Buckets" are shared areas for teams (like "Shop" or "Maintenance").</p>
|
||||
<ul class="space-y-2 text-sm leading-relaxed">
|
||||
<li><strong>Move to Bucket</strong>: Simply add a tag that matches the bucket name (e.g., tagging a task with "Shop").</li>
|
||||
<li><strong>Visibility</strong>: By default, bucketed tasks are hidden from your main view to keep your list clean. You can view them by selecting the bucket in the sidebar but they will only appear if you have pinned them.</li>
|
||||
<li><strong>Pinning</strong>: In order to work in a bucket, you must "Pin" it in Settings so it always shows up in your sidebar.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 pt-4">
|
||||
<h3 class="font-bold text-lg flex items-center gap-2"><BarChart3 size={20} class="text-primary" /> Oversight (Share All)</h3>
|
||||
<p class="text-sm text-muted-foreground leading-relaxed">
|
||||
Managers and team leads can ask their teams to create a "share all" sharing rule in setting. This will add that user's bucket as an option in the sidebar just like shared buckets. Clicking a user's name in your sidebar allows you to view their entire task list, making it easy to track progress across the team.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 pt-4">
|
||||
<h3 class="font-bold text-lg">Automatic Tasks (Recurrence)</h3>
|
||||
<p class="text-sm text-muted-foreground leading-relaxed">
|
||||
For recurring work, you can set a task to repeat <strong>Daily</strong>, <strong>Weekly</strong> (on any specified days of the week), or <strong>Monthly</strong> (on a specified day of the month).
|
||||
</p>
|
||||
<ul class="space-y-1 ml-4 list-disc text-sm text-muted-foreground">
|
||||
<li>When you mark it complete, TaskGrid automatically "un-completes" it for you when the next cycle begins.</li>
|
||||
<li>You'll see status labels like "Complete until Monday" to let you know exactly when it will reappear.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 pt-4">
|
||||
<h3 class="font-bold text-lg">Bulk Import (Filling a Bucket)</h3>
|
||||
<p class="text-sm text-muted-foreground leading-relaxed italic mb-2">Need to add a long list of tasks at once?</p>
|
||||
<ol class="space-y-2 text-sm leading-relaxed list-decimal ml-4">
|
||||
<li>Go to <strong>Settings > Import Tasks</strong>.</li>
|
||||
<li>Paste your list (one task per line).</li>
|
||||
<li>Click "Parse and Configure."</li>
|
||||
<li>Use the <strong>Bulk Update</strong> tool at the top to set the Priority, Urgency, and Tag (e.g., "Shop") for all tasks at once.</li>
|
||||
<li>Update tasks that need individual urgency and priority. (Usually all tasks need individual urgency and priority because if everything is set the same TasGrid can't sort them)</li>
|
||||
<li>Click <strong>Import Tasks</strong>.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Power User Tips */}
|
||||
<section class="p-6 rounded-2xl bg-primary/5 border border-primary/20 space-y-4">
|
||||
<h2 class="text-xl font-bold flex items-center gap-2">
|
||||
<Zap size={20} class="text-primary" />
|
||||
Power User Tips
|
||||
</h2>
|
||||
<ul class="space-y-4">
|
||||
<li class="text-sm flex gap-3">
|
||||
<span class="text-primary font-bold">/</span>
|
||||
<span><strong>Quick Entry Shorthands</strong>: Type <code>/p5</code> (priority), <code>/u8</code> (urgency), or <code>/t Wood</code> (tags) directly into the title box when creating a task to set properties instantly without touching your mouse!</span>
|
||||
</li>
|
||||
<li class="text-sm flex gap-3">
|
||||
<span class="text-primary font-bold">/</span>
|
||||
<span><strong>Task Templates</strong>: If you have recurring projects with the same notes and tags, create a <strong>Template</strong> in Settings. You can apply it with a single click in the creation menu.</span>
|
||||
</li>
|
||||
<li class="text-sm flex gap-3">
|
||||
<span class="text-primary font-bold">/</span>
|
||||
<span><strong>Matrix Color Pro-tip</strong>: The dots on your Strategy Matrix change color based on <strong>Size</strong>—**Blue** dots are small wins (easy tasks), and **Red** dots are major projects (time-consuming tasks).</span>
|
||||
</li>
|
||||
<li class="text-sm flex gap-3">
|
||||
<span class="text-primary font-bold">/</span>
|
||||
<span><strong>Smart Tagging</strong>: If you are currently viewing a Bucket (e.g., "Shop"), when you click the Plus button to add a task, TaskGrid will automatically add the "Shop" tag for you.</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Help/Contact */}
|
||||
<footer class="pt-10 border-t border-border flex flex-col items-center text-center space-y-4">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
If the app feels out of sync, a quick <button onClick={() => location.reload()} class="text-primary font-bold hover:underline cursor-pointer bg-transparent border-none p-0 inline">refresh</button> usually fixes it. Most updates happen automatically in real-time!
|
||||
</p>
|
||||
<p class="text-sm font-medium">
|
||||
If you have any other questions contact Timothy Cardoza or IT.
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+215
-866
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
+286
-652
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Vendored
-3
@@ -1,3 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __APP_VERSION__: string;
|
||||
+79
-475
@@ -105,80 +105,6 @@
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.sdk-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.sdk-card {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.sdk-card h4 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sdk-card p {
|
||||
margin: 0 0 10px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.sdk-stack {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sdk-stack input,
|
||||
.sdk-stack textarea,
|
||||
.sdk-stack select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font: inherit;
|
||||
box-sizing: border-box;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.sdk-stack textarea {
|
||||
min-height: 74px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.sdk-inline {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sdk-inline label {
|
||||
font-size: 12px;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.sdk-meta {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: #555;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.sdk-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
<script src="https://cdn.jsdelivr.net/npm/pocketbase/dist/pocketbase.umd.js"></script>
|
||||
</head>
|
||||
@@ -187,18 +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" onchange="updateEnv()">
|
||||
Production (tasgrid.ccllc.pro)
|
||||
</label>
|
||||
<label style="cursor: pointer;">
|
||||
<input type="radio" name="envToggle" value="http://localhost:4001" checked 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>
|
||||
@@ -218,94 +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 (Current
|
||||
Creation Model):</strong></p>
|
||||
<div class="sdk-grid">
|
||||
<div class="sdk-card">
|
||||
<h4>Task Creation</h4>
|
||||
<p>Creates a task with normalized tags, structured sharing refs, and optional `#note` linkage, then verifies the task is anchored by personal-context refs instead of the legacy direct user link.</p>
|
||||
<div class="sdk-stack">
|
||||
<input type="text" id="sdk-task-title" placeholder="Task title">
|
||||
<input type="text" id="sdk-task-tags"
|
||||
placeholder="Tags CSV, e.g. sdk_test,#sdk-workspace,@Shared Bucket">
|
||||
<textarea id="sdk-task-share-modes"
|
||||
placeholder="Optional share mode overrides, one per line. Example: #sdk-workspace=collaborative @shared bucket=handoff"></textarea>
|
||||
<div class="sdk-inline">
|
||||
<input type="number" id="sdk-task-priority" min="1" max="10" value="8"
|
||||
style="max-width: 90px;" title="Priority">
|
||||
<input type="number" id="sdk-task-urgency" min="1" max="10" value="4"
|
||||
style="max-width: 90px;" title="Urgency">
|
||||
<input type="number" id="sdk-task-size" min="0" max="10" value="3"
|
||||
style="max-width: 90px;" title="Size">
|
||||
</div>
|
||||
<textarea id="sdk-task-content"
|
||||
placeholder="Task content HTML or plain text">This task was created by a sister app using the current TasGrid SDK flow.</textarea>
|
||||
</div>
|
||||
<div class="sdk-actions">
|
||||
<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 class="sdk-meta" id="sdk-task-meta">Last created task: none</div>
|
||||
</div>
|
||||
|
||||
<div class="sdk-card">
|
||||
<h4>Note Creation</h4>
|
||||
<p>Creates a canonical-key note and can fully link tasks into the note workspace the new way.</p>
|
||||
<div class="sdk-stack">
|
||||
<input type="text" id="sdk-note-title" placeholder="Note title">
|
||||
<input type="text" id="sdk-note-key" placeholder="Optional note key override">
|
||||
<input type="text" id="sdk-note-tags" placeholder="Tags CSV, e.g. sdk_test">
|
||||
<textarea id="sdk-note-content"
|
||||
placeholder="Note HTML content"><p>This note was created by the updated TasGrid SDK.</p></textarea>
|
||||
<input type="text" id="sdk-note-task-ids"
|
||||
placeholder="Task IDs CSV to fully link into this note workspace">
|
||||
<div class="sdk-inline">
|
||||
<label>Share mode
|
||||
<select id="sdk-note-share-mode">
|
||||
<option value="collaborative" selected>collaborative</option>
|
||||
<option value="handoff">handoff</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Visibility
|
||||
<select id="sdk-note-private">
|
||||
<option value="false" selected>public</option>
|
||||
<option value="true">private</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 6px;">
|
||||
<input type="checkbox" id="sdk-note-link-tasks" checked>
|
||||
Link tasks via SDK
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sdk-actions">
|
||||
<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>
|
||||
<button onclick="createLinkedWorkspaceScenario()"
|
||||
style="padding: 8px 16px; cursor: pointer; background: #0f766e; color: white; border: none; border-radius: 4px; font-weight: bold;">Create
|
||||
Task + Linked Note</button>
|
||||
</div>
|
||||
<div class="sdk-meta" id="sdk-note-meta">Last created note: none</div>
|
||||
</div>
|
||||
|
||||
<div class="sdk-card">
|
||||
<h4>Link Existing Tasks</h4>
|
||||
<p>Calls the SDK's direct note-link helper so you can test current workspace linking without creating a new note.</p>
|
||||
<div class="sdk-stack">
|
||||
<input type="text" id="sdk-link-note-id" placeholder="Target note ID">
|
||||
<input type="text" id="sdk-link-task-ids" placeholder="Task IDs CSV to link">
|
||||
</div>
|
||||
<div class="sdk-actions">
|
||||
<button onclick="linkExistingTasksViaSDK()"
|
||||
style="padding: 8px 16px; cursor: pointer; background: #2563eb; color: white; border: none; border-radius: 4px; font-weight: bold;">Link
|
||||
Tasks to Note</button>
|
||||
<button onclick="loadLastCreatedNote()" style="padding: 8px 16px; cursor: pointer;">Load Last
|
||||
Note Embed</button>
|
||||
</div>
|
||||
<div class="sdk-meta" id="sdk-link-meta">Ready to link existing records.</div>
|
||||
</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>
|
||||
@@ -325,53 +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
|
||||
now applies TasGrid's current normalized creation rules, including canonical note keys,
|
||||
structured <code>labelTags</code>/<code>shareRefs</code>/<code>noteRefs</code>, owner-context
|
||||
tagging, and note-linked task metadata.
|
||||
<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", "@Shared Bucket", "#project-brief"],
|
||||
shareModeByTag: {
|
||||
"@shared bucket": "handoff",
|
||||
"#project-brief": "collaborative"
|
||||
},
|
||||
owner: "USER_ID" // Optional: target personal-context owner, not a direct task.user link
|
||||
});</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: "<p>HTML content here...</p>",
|
||||
tags: ["documentation"],
|
||||
noteShareMode: "collaborative",
|
||||
const record = await pb.collection('TasGrid_Notes').create({
|
||||
title: "Sister App Payload",
|
||||
content: "<p>Initial content here...</p>",
|
||||
tags: ["sister_app_export"],
|
||||
isPrivate: false,
|
||||
tasks: [] // Optional: Task IDs to store and fully link into the note workspace
|
||||
});</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
|
||||
@@ -409,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">
|
||||
@@ -418,132 +241,20 @@ 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 = 'http://localhost:4001';
|
||||
let lastCreatedTaskId = '';
|
||||
let lastCreatedNoteId = '';
|
||||
|
||||
function setStatus(kind, message) {
|
||||
statusEl.className = kind ? `status ${kind}` : 'status';
|
||||
statusEl.textContent = message;
|
||||
statusEl.style.display = 'block';
|
||||
}
|
||||
|
||||
function parseCsvInput(id) {
|
||||
return (document.getElementById(id)?.value || '')
|
||||
.split(',')
|
||||
.map(value => value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseShareModeOverrides(id) {
|
||||
const lines = (document.getElementById(id)?.value || '')
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean);
|
||||
const result = {};
|
||||
|
||||
for (const line of lines) {
|
||||
const separatorIndex = line.indexOf('=');
|
||||
if (separatorIndex === -1) continue;
|
||||
const key = line.slice(0, separatorIndex).trim().toLowerCase();
|
||||
const value = line.slice(separatorIndex + 1).trim().toLowerCase();
|
||||
if (!key || (value !== 'collaborative' && value !== 'handoff')) continue;
|
||||
result[key] = value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function refreshSdkMeta() {
|
||||
document.getElementById('sdk-task-meta').textContent = lastCreatedTaskId
|
||||
? `Last created task: ${lastCreatedTaskId}`
|
||||
: 'Last created task: none';
|
||||
document.getElementById('sdk-note-meta').textContent = lastCreatedNoteId
|
||||
? `Last created note: ${lastCreatedNoteId}`
|
||||
: 'Last created note: none';
|
||||
document.getElementById('sdk-link-meta').textContent = [
|
||||
lastCreatedTaskId ? `task ${lastCreatedTaskId}` : null,
|
||||
lastCreatedNoteId ? `note ${lastCreatedNoteId}` : null
|
||||
].filter(Boolean).join(' | ') || 'Ready to link existing records.';
|
||||
}
|
||||
|
||||
async function loadSdkModule() {
|
||||
return await import(`${ENV_URL}/tasgrid-sdk.js`);
|
||||
}
|
||||
|
||||
function fillLinkingFields() {
|
||||
if (lastCreatedTaskId && !document.getElementById('sdk-note-task-ids').value.trim()) {
|
||||
document.getElementById('sdk-note-task-ids').value = lastCreatedTaskId;
|
||||
}
|
||||
if (lastCreatedTaskId && !document.getElementById('sdk-link-task-ids').value.trim()) {
|
||||
document.getElementById('sdk-link-task-ids').value = lastCreatedTaskId;
|
||||
}
|
||||
if (lastCreatedNoteId && !document.getElementById('sdk-link-note-id').value.trim()) {
|
||||
document.getElementById('sdk-link-note-id').value = lastCreatedNoteId;
|
||||
}
|
||||
}
|
||||
|
||||
function loadLastCreatedNote() {
|
||||
if (!lastCreatedNoteId) {
|
||||
alert('Create a note first.');
|
||||
return;
|
||||
}
|
||||
document.getElementById('note-id-input').value = lastCreatedNoteId;
|
||||
loadSpecificNote();
|
||||
}
|
||||
|
||||
async function inspectTaskContextState(taskId) {
|
||||
const record = await pb.collection('TasGrid').getOne(taskId, {
|
||||
fields: 'id,user,shareRefs,noteRefs,tags,labelTags',
|
||||
requestKey: null
|
||||
});
|
||||
|
||||
const legacyUser = record.user && (typeof record.user === 'string' ? record.user : record.user.id);
|
||||
const shareRefCount = Array.isArray(record.shareRefs) ? record.shareRefs.length : 0;
|
||||
const noteRefCount = Array.isArray(record.noteRefs) ? record.noteRefs.length : 0;
|
||||
|
||||
return {
|
||||
legacyUser: legacyUser || null,
|
||||
shareRefCount,
|
||||
noteRefCount,
|
||||
tags: Array.isArray(record.tags) ? record.tags : []
|
||||
};
|
||||
}
|
||||
|
||||
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...`);
|
||||
if (pb.authStore.isValid) {
|
||||
console.log(`Syncing existing auth to ${iframe.id}`);
|
||||
sendAuth(pb.authStore.token, pb.authStore.model);
|
||||
sendAuthToIframes();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,158 +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;
|
||||
}
|
||||
|
||||
setStatus('', 'Importing SDK and creating task...');
|
||||
statusEl.className = 'status';
|
||||
statusEl.textContent = 'Creating note via API...';
|
||||
statusEl.style.display = 'block';
|
||||
|
||||
try {
|
||||
const { createTasgridTask, TASGRID_SDK_SIGNATURE } = await loadSdkModule();
|
||||
const priority = parseInt(document.getElementById('sdk-task-priority').value || '8', 10);
|
||||
const urgency = parseInt(document.getElementById('sdk-task-urgency').value || '4', 10);
|
||||
const size = parseInt(document.getElementById('sdk-task-size').value || '3', 10);
|
||||
const tags = parseCsvInput('sdk-task-tags');
|
||||
const shareModeByTag = parseShareModeOverrides('sdk-task-share-modes');
|
||||
|
||||
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: Number.isFinite(priority) ? priority : 8,
|
||||
urgency: Number.isFinite(urgency) ? urgency : 4,
|
||||
size: Number.isFinite(size) ? size : 3,
|
||||
tags: tags,
|
||||
shareModeByTag,
|
||||
content: document.getElementById('sdk-task-content').value || ""
|
||||
});
|
||||
|
||||
lastCreatedTaskId = record.id;
|
||||
fillLinkingFields();
|
||||
refreshSdkMeta();
|
||||
const inspection = await inspectTaskContextState(record.id);
|
||||
const legacyText = inspection.legacyUser ? `legacy user mirror still present: ${inspection.legacyUser}` : 'no legacy task.user link';
|
||||
setStatus('success', `Task created successfully via SDK ${TASGRID_SDK_SIGNATURE || '(unknown signature)'} (ID: ${record.id}). shareRefs=${inspection.shareRefCount}, noteRefs=${inspection.noteRefCount}, ${legacyText}.`);
|
||||
} catch (err) {
|
||||
setStatus('error', '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;
|
||||
}
|
||||
|
||||
setStatus('', 'Importing SDK and creating note...');
|
||||
|
||||
try {
|
||||
const { createTasgridNote, TASGRID_SDK_SIGNATURE } = await loadSdkModule();
|
||||
const taskIds = parseCsvInput('sdk-note-task-ids');
|
||||
|
||||
const record = await createTasgridNote(pb, {
|
||||
title: title,
|
||||
key: document.getElementById('sdk-note-key').value.trim() || undefined,
|
||||
content: document.getElementById('sdk-note-content').value || "",
|
||||
tags: parseCsvInput('sdk-note-tags'),
|
||||
noteShareMode: document.getElementById('sdk-note-share-mode').value,
|
||||
isPrivate: document.getElementById('sdk-note-private').value === 'true',
|
||||
tasks: taskIds,
|
||||
linkTasks: document.getElementById('sdk-note-link-tasks').checked
|
||||
});
|
||||
|
||||
lastCreatedNoteId = record.id;
|
||||
document.getElementById('note-id-input').value = record.id;
|
||||
fillLinkingFields();
|
||||
refreshSdkMeta();
|
||||
loadSpecificNote();
|
||||
setStatus('success', `Note created successfully via SDK ${TASGRID_SDK_SIGNATURE || '(unknown signature)'} (ID: ${record.id}). The notes iframe was pointed at the new note.`);
|
||||
} catch (err) {
|
||||
setStatus('error', 'Failed to create note via SDK: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function linkExistingTasksViaSDK() {
|
||||
const noteId = document.getElementById('sdk-link-note-id').value.trim();
|
||||
const taskIds = parseCsvInput('sdk-link-task-ids');
|
||||
if (!pb.authStore.isValid) {
|
||||
alert("Please log in first before linking tasks via SDK.");
|
||||
return;
|
||||
}
|
||||
if (!noteId || taskIds.length === 0) {
|
||||
alert("Provide both a target note ID and at least one task ID.");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('', 'Importing SDK and linking tasks to note...');
|
||||
|
||||
try {
|
||||
const { linkTasgridTasksToNote, TASGRID_SDK_SIGNATURE } = await loadSdkModule();
|
||||
const records = await linkTasgridTasksToNote(pb, noteId, taskIds);
|
||||
lastCreatedNoteId = noteId;
|
||||
fillLinkingFields();
|
||||
refreshSdkMeta();
|
||||
const inspection = await inspectTaskContextState(taskIds[0]);
|
||||
setStatus('success', `Linked ${records.length} task(s) to note ${noteId} via SDK ${TASGRID_SDK_SIGNATURE || '(unknown signature)'}. First task now has shareRefs=${inspection.shareRefCount} and noteRefs=${inspection.noteRefCount}.`);
|
||||
} catch (err) {
|
||||
setStatus('error', 'Failed to link tasks via SDK: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function createLinkedWorkspaceScenario() {
|
||||
if (!pb.authStore.isValid) {
|
||||
alert("Please log in first before creating linked workspace data.");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('', 'Creating a task and linked note scenario via the SDK...');
|
||||
|
||||
try {
|
||||
const { createTasgridTask, createTasgridNote, TASGRID_SDK_SIGNATURE } = await loadSdkModule();
|
||||
const timestamp = new Date().toISOString().replace(/[^\d]/g, '').slice(0, 12);
|
||||
const workspaceTag = `#sdk-workspace-${timestamp}`;
|
||||
const taskRecord = await createTasgridTask(pb, {
|
||||
title: `SDK Workspace Task ${timestamp}`,
|
||||
priority: 8,
|
||||
urgency: 4,
|
||||
size: 3,
|
||||
tags: ['sdk_test', workspaceTag],
|
||||
shareModeByTag: {
|
||||
[workspaceTag.toLowerCase()]: 'collaborative'
|
||||
},
|
||||
content: 'Workspace task created by the test simulator.'
|
||||
});
|
||||
|
||||
const noteRecord = await createTasgridNote(pb, {
|
||||
title: `SDK Workspace Note ${timestamp}`,
|
||||
tags: ['sdk_test'],
|
||||
content: `<p>Workspace note for task <code>${taskRecord.id}</code>.</p>`,
|
||||
noteShareMode: 'collaborative',
|
||||
content: "<p>This note was created automatically via the API script!</p>",
|
||||
tags: ["api_generated"],
|
||||
isPrivate: false,
|
||||
tasks: [taskRecord.id],
|
||||
linkTasks: true
|
||||
user: pb.authStore.model.id,
|
||||
tasks: []
|
||||
});
|
||||
|
||||
lastCreatedTaskId = taskRecord.id;
|
||||
lastCreatedNoteId = noteRecord.id;
|
||||
document.getElementById('note-id-input').value = noteRecord.id;
|
||||
document.getElementById('sdk-note-task-ids').value = taskRecord.id;
|
||||
fillLinkingFields();
|
||||
refreshSdkMeta();
|
||||
statusEl.className = 'status success';
|
||||
statusEl.textContent = `Note created successfully (ID: ${record.id})! Loading iframe...`;
|
||||
|
||||
// Automatically set the new ID in the deep link input
|
||||
document.getElementById('note-id-input').value = record.id;
|
||||
|
||||
// Automatically load it
|
||||
loadSpecificNote();
|
||||
const inspection = await inspectTaskContextState(taskRecord.id);
|
||||
const legacyText = inspection.legacyUser ? `legacy user mirror still present: ${inspection.legacyUser}` : 'no legacy task.user link';
|
||||
setStatus('success', `Created linked workspace scenario via SDK ${TASGRID_SDK_SIGNATURE || '(unknown signature)'}. Task ${taskRecord.id} and note ${noteRecord.id} were created, loaded into the embed, and verified with shareRefs=${inspection.shareRefCount}, noteRefs=${inspection.noteRefCount}, ${legacyText}.`);
|
||||
|
||||
} catch (err) {
|
||||
setStatus('error', 'Failed to create linked workspace scenario: ' + err.message);
|
||||
statusEl.className = 'status error';
|
||||
statusEl.textContent = 'Failed to create note: ' + err.message;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -710,17 +309,21 @@ function syncAuth(iframe) {
|
||||
const email = document.getElementById('email').value;
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
setStatus('', 'Logging in...');
|
||||
statusEl.className = 'status';
|
||||
statusEl.textContent = 'Logging in...';
|
||||
statusEl.style.display = 'block';
|
||||
|
||||
try {
|
||||
const authData = await pb.collection('users').authWithPassword(email, password);
|
||||
|
||||
setStatus('success', `Logged in as ${authData.record.email}! Syncing iframes...`);
|
||||
statusEl.className = 'status success';
|
||||
statusEl.textContent = `Logged in as ${authData.record.email}! Syncing iframes...`;
|
||||
|
||||
// Automatically send auth to iframes
|
||||
sendAuth(pb.authStore.token, authData.record);
|
||||
} catch (err) {
|
||||
setStatus('error', 'Login failed: ' + err.message);
|
||||
statusEl.className = 'status error';
|
||||
statusEl.textContent = 'Login failed: ' + err.message;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -752,11 +355,12 @@ function syncAuth(iframe) {
|
||||
window.addEventListener('load', () => {
|
||||
if (pb.authStore.isValid && pb.authStore.model) {
|
||||
document.getElementById('email').value = pb.authStore.model.email || "";
|
||||
setStatus('success', 'Session restored. Click login or send manual token to sync.');
|
||||
statusEl.className = 'status success';
|
||||
statusEl.textContent = 'Session restored. Click login or send manual token to sync.';
|
||||
statusEl.style.display = 'block';
|
||||
}
|
||||
refreshSdkMeta();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
+1
-3
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user