fix: CSS class slide animation, mobile dot, flex task indicators

This commit is contained in:
mARTin 2026-03-06 12:09:13 +01:00
parent 5e235b65ad
commit 499b012d82
25 changed files with 3793 additions and 786 deletions

819
package-lock.json generated
View File

@ -1,17 +1,22 @@
{ {
"name": "my-weekly-todo-list", "name": "my-weekly-todo-list",
"version": "1.11.2", "version": "1.13.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "my-weekly-todo-list", "name": "my-weekly-todo-list",
"version": "1.11.1", "version": "1.13.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^2.11.1", "@auth/prisma-adapter": "^2.11.1",
"@next-auth/prisma-adapter": "^1.0.7", "@next-auth/prisma-adapter": "^1.0.7",
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"@tiptap/extension-link": "^3.20.0",
"@tiptap/extension-placeholder": "^3.20.0",
"@tiptap/extension-underline": "^3.20.0",
"@tiptap/react": "^3.20.0",
"@tiptap/starter-kit": "^3.20.0",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/nodemailer": "^7.0.11", "@types/nodemailer": "^7.0.11",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
@ -840,6 +845,34 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
} }
}, },
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT",
"optional": true
},
"node_modules/@humanwhocodes/config-array": { "node_modules/@humanwhocodes/config-array": {
"version": "0.13.0", "version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
@ -2290,6 +2323,12 @@
"integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==", "integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@remirror/core-constants": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
"integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
"license": "MIT"
},
"node_modules/@rtsao/scc": { "node_modules/@rtsao/scc": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@ -2347,6 +2386,453 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@tiptap/core": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.20.0.tgz",
"integrity": "sha512-aC9aROgia/SpJqhsXFiX9TsligL8d+oeoI8W3u00WI45s0VfsqjgeKQLDLF7Tu7hC+7F02teC84SAHuup003VQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "^3.20.0"
}
},
"node_modules/@tiptap/extension-blockquote": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.20.0.tgz",
"integrity": "sha512-LQzn6aGtL4WXz2+rYshl/7/VnP2qJTpD7fWL96GXAzhqviPEY1bJES7poqJb3MU/gzl8VJUVzVzU1VoVfUKlbA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.0"
}
},
"node_modules/@tiptap/extension-bold": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.20.0.tgz",
"integrity": "sha512-sQklEWiyf58yDjiHtm5vmkVjfIc/cBuSusmCsQ0q9vGYnEF1iOHKhGpvnCeEXNeqF3fiJQRlquzt/6ymle3Iwg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.0"
}
},
"node_modules/@tiptap/extension-bubble-menu": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.20.0.tgz",
"integrity": "sha512-MDosUfs8Tj+nwg8RC+wTMWGkLJORXmbR6YZgbiX4hrc7G90Gopdd6kj6ht5/T8t7dLLaX7N0+DEHdUEPGED7dw==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/dom": "^1.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.0",
"@tiptap/pm": "^3.20.0"
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.20.0.tgz",
"integrity": "sha512-OcKMeopBbqWzhSi6o8nNz0aayogg1sfOAhto3NxJu3Ya32dwBFqmHXSYM6uW4jOphNvVPyjiq9aNRh3qTdd1dw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "^3.20.0"
}
},
"node_modules/@tiptap/extension-code": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.20.0.tgz",
"integrity": "sha512-TYDWFeSQ9umiyrqsT6VecbuhL8XIHkUhO+gEk0sVvH67ZLwjFDhAIIgWIr1/dbIGPcvMZM19E7xUUhAdIaXaOQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.0"
}
},
"node_modules/@tiptap/extension-code-block": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.20.0.tgz",
"integrity": "sha512-lBbmNek14aCjrHcBcq3PRqWfNLvC6bcRa2Osc6e/LtmXlcpype4f6n+Yx+WZ+f2uUh0UmDRCz7BEyUETEsDmlQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.0",
"@tiptap/pm": "^3.20.0"
}
},
"node_modules/@tiptap/extension-document": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.20.0.tgz",
"integrity": "sha512-oJfLIG3vAtZo/wg29WiBcyWt22KUgddpP8wqtCE+kY5Dw8znLR9ehNmVWlSWJA5OJUMO0ntAHx4bBT+I2MBd5w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.0"
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.20.0.tgz",
"integrity": "sha512-d+cxplRlktVgZPwatnc34IArlppM0IFKS1J5wLk+ba1jidizsbMVh45tP/BTK2flhyfRqcNoB5R0TArhUpbkNQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "^3.20.0"
}
},
"node_modules/@tiptap/extension-floating-menu": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.20.0.tgz",
"integrity": "sha512-rYs4Bv5pVjqZ/2vvR6oe7ammZapkAwN51As/WDbemvYDjfOGRqK58qGauUjYZiDzPOEIzI2mxGwsZ4eJhPW4Ig==",
"license": "MIT",
"optional": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@floating-ui/dom": "^1.0.0",
"@tiptap/core": "^3.20.0",
"@tiptap/pm": "^3.20.0"
}
},
"node_modules/@tiptap/extension-gapcursor": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.20.0.tgz",
"integrity": "sha512-P/LasfvG9/qFq43ZAlNbAnPnXC+/RJf49buTrhtFvI9Zg0+Lbpjx1oh6oMHB19T88Y28KtrckfFZ8aTSUWDq6w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "^3.20.0"
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.20.0.tgz",
"integrity": "sha512-rqvhMOw4f+XQmEthncbvDjgLH6fz8L9splnKZC7OeS0eX8b0qd7+xI1u5kyxF3KA2Z0BnigES++jjWuecqV6mA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.0"
}
},
"node_modules/@tiptap/extension-heading": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.20.0.tgz",
"integrity": "sha512-JgJhurnCe3eN6a0lEsNQM/46R1bcwzwWWZEFDSb1P9dR8+t1/5v7cMZWsSInpD7R4/74iJn0+M5hcXLwCmBmYA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.0"
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.20.0.tgz",
"integrity": "sha512-6uvcutFMv+9wPZgptDkbRDjAm3YVxlibmkhWD5GuaWwS9L/yUtobpI3GycujRSUZ8D3q6Q9J7LqpmQtQRTalWA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.0",
"@tiptap/pm": "^3.20.0"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.20.0.tgz",
"integrity": "sha512-/DhnKQF8yN8RxtuL8abZ28wd5281EaGoE2Oha35zXSOF1vNYnbyt8Ymkv/7u1BcWEWTvRPgaju0YCGXisPRLYw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.0"
}
},
"node_modules/@tiptap/extension-link": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.20.0.tgz",
"integrity": "sha512-qI/5A+R0ZWBxo/8HxSn1uOyr7odr3xHBZ/gzOR1GUJaZqjlJxkWFX0RtXMbLKEGEvT25o345cF7b0wFznEh8qA==",
"license": "MIT",
"dependencies": {
"linkifyjs": "^4.3.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.0",
"@tiptap/pm": "^3.20.0"
}
},
"node_modules/@tiptap/extension-list": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.20.0.tgz",
"integrity": "sha512-+V0/gsVWAv+7vcY0MAe6D52LYTIicMSHw00wz3ISZgprSb2yQhJ4+4gurOnUrQ4Du3AnRQvxPROaofwxIQ66WQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.0",
"@tiptap/pm": "^3.20.0"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.20.0.tgz",
"integrity": "sha512-qEtjaaGPuqaFB4VpLrGDoIe9RHnckxPfu6d3rc22ap6TAHCDyRv05CEyJogqccnFceG/v5WN4znUBER8RWnWHA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "^3.20.0"
}
},
"node_modules/@tiptap/extension-list-keymap": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.20.0.tgz",
"integrity": "sha512-Z4GvKy04Ms4cLFN+CY6wXswd36xYsT2p/YL0V89LYFMZTerOeTjFYlndzn6svqL8NV1PRT5Diw4WTTxJSmcJPA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "^3.20.0"
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.20.0.tgz",
"integrity": "sha512-jVKnJvrizLk7etwBMfyoj6H2GE4M+PD4k7Bwp6Bh1ohBWtfIA1TlngdS842Mx5i1VB2e3UWIwr8ZH46gl6cwMA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "^3.20.0"
}
},
"node_modules/@tiptap/extension-paragraph": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.20.0.tgz",
"integrity": "sha512-mM99zK4+RnEXIMCv6akfNATAs0Iija6FgyFA9J9NZ6N4o8y9QiNLLa6HjLpAC+W+VoCgQIekyoF/Q9ftxmAYDQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.0"
}
},
"node_modules/@tiptap/extension-placeholder": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.20.0.tgz",
"integrity": "sha512-ZhYD3L5m16ydSe2z8vqz+RdtAG/iOQaFHHedFct70tKRoLqi2ajF5kgpemu8DwpaRTcyiCN4G99J/+MqehKNjQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "^3.20.0"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.20.0.tgz",
"integrity": "sha512-0vcTZRRAiDfon3VM1mHBr9EFmTkkUXMhm0Xtdtn0bGe+sIqufyi+hUYTEw93EQOD9XNsPkrud6jzQNYpX2H3AQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.0"
}
},
"node_modules/@tiptap/extension-text": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.20.0.tgz",
"integrity": "sha512-tf8bE8tSaOEWabCzPm71xwiUhyMFKqY9jkP5af3Kr1/F45jzZFIQAYZooHI/+zCHRrgJ99MQHKHe1ZNvODrKHQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.0"
}
},
"node_modules/@tiptap/extension-underline": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.20.0.tgz",
"integrity": "sha512-LzNXuy2jwR/y+ymoUqC72TiGzbOCjioIjsDu0MNYpHuHqTWPK5aV9Mh0nbZcYFy/7fPlV1q0W139EbJeYBZEAQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.0"
}
},
"node_modules/@tiptap/extensions": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.20.0.tgz",
"integrity": "sha512-HIsXX942w3nbxEQBlMAAR/aa6qiMBEP7CsSMxaxmTIVAmW35p6yUASw6GdV1u0o3lCZjXq2OSRMTskzIqi5uLg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.0",
"@tiptap/pm": "^3.20.0"
}
},
"node_modules/@tiptap/pm": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.20.0.tgz",
"integrity": "sha512-jn+2KnQZn+b+VXr8EFOJKsnjVNaA4diAEr6FOazupMt8W8ro1hfpYtZ25JL87Kao/WbMze55sd8M8BDXLUKu1A==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-collab": "^1.3.1",
"prosemirror-commands": "^1.6.2",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
"prosemirror-inputrules": "^1.4.0",
"prosemirror-keymap": "^1.2.2",
"prosemirror-markdown": "^1.13.1",
"prosemirror-menu": "^1.2.4",
"prosemirror-model": "^1.24.1",
"prosemirror-schema-basic": "^1.2.3",
"prosemirror-schema-list": "^1.5.0",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.6.4",
"prosemirror-trailing-node": "^3.0.0",
"prosemirror-transform": "^1.10.2",
"prosemirror-view": "^1.38.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/react": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.20.0.tgz",
"integrity": "sha512-jFLNzkmn18zqefJwPje0PPd9VhZ7Oy28YHiSvSc7YpBnQIbuN/HIxZ2lrOsKyEHta0WjRZjfU5X1pGxlbcGwOA==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"fast-equals": "^5.3.3",
"use-sync-external-store": "^1.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"optionalDependencies": {
"@tiptap/extension-bubble-menu": "^3.20.0",
"@tiptap/extension-floating-menu": "^3.20.0"
},
"peerDependencies": {
"@tiptap/core": "^3.20.0",
"@tiptap/pm": "^3.20.0",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tiptap/starter-kit": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.20.0.tgz",
"integrity": "sha512-W4+1re35pDNY/7rpXVg+OKo/Fa4Gfrn08Bq3E3fzlJw6gjE3tYU8dY9x9vC2rK9pd9NOp7Af11qCFDaWpohXkw==",
"license": "MIT",
"dependencies": {
"@tiptap/core": "^3.20.0",
"@tiptap/extension-blockquote": "^3.20.0",
"@tiptap/extension-bold": "^3.20.0",
"@tiptap/extension-bullet-list": "^3.20.0",
"@tiptap/extension-code": "^3.20.0",
"@tiptap/extension-code-block": "^3.20.0",
"@tiptap/extension-document": "^3.20.0",
"@tiptap/extension-dropcursor": "^3.20.0",
"@tiptap/extension-gapcursor": "^3.20.0",
"@tiptap/extension-hard-break": "^3.20.0",
"@tiptap/extension-heading": "^3.20.0",
"@tiptap/extension-horizontal-rule": "^3.20.0",
"@tiptap/extension-italic": "^3.20.0",
"@tiptap/extension-link": "^3.20.0",
"@tiptap/extension-list": "^3.20.0",
"@tiptap/extension-list-item": "^3.20.0",
"@tiptap/extension-list-keymap": "^3.20.0",
"@tiptap/extension-ordered-list": "^3.20.0",
"@tiptap/extension-paragraph": "^3.20.0",
"@tiptap/extension-strike": "^3.20.0",
"@tiptap/extension-text": "^3.20.0",
"@tiptap/extension-underline": "^3.20.0",
"@tiptap/extensions": "^3.20.0",
"@tiptap/pm": "^3.20.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tybys/wasm-util": { "node_modules/@tybys/wasm-util": {
"version": "0.10.1", "version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@ -2472,6 +2958,28 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
"license": "MIT"
},
"node_modules/@types/markdown-it": {
"version": "14.1.2",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
"license": "MIT",
"dependencies": {
"@types/linkify-it": "^5",
"@types/mdurl": "^2"
}
},
"node_modules/@types/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
"license": "MIT"
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "20.19.34", "version": "20.19.34",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.34.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.34.tgz",
@ -2494,14 +3002,12 @@
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "18.3.28", "version": "18.3.28",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
@ -2512,7 +3018,6 @@
"version": "18.3.7", "version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "^18.0.0" "@types/react": "^18.0.0"
@ -2525,6 +3030,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/yargs": { "node_modules/@types/yargs": {
"version": "17.0.35", "version": "17.0.35",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
@ -3233,7 +3744,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/aria-query": { "node_modules/aria-query": {
@ -4152,6 +4662,12 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0" "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
} }
}, },
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/cross-fetch": { "node_modules/cross-fetch": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz",
@ -4212,7 +4728,6 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/damerau-levenshtein": { "node_modules/damerau-levenshtein": {
@ -4508,6 +5023,18 @@
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/error-ex": { "node_modules/error-ex": {
"version": "1.3.4", "version": "1.3.4",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
@ -4705,7 +5232,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10" "node": ">=10"
@ -5252,6 +5778,15 @@
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-glob": { "node_modules/fast-glob": {
"version": "3.3.3", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@ -8393,6 +8928,21 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/linkifyjs": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz",
"integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
"license": "MIT"
},
"node_modules/locate-path": { "node_modules/locate-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@ -8487,6 +9037,23 @@
"tmpl": "1.0.5" "tmpl": "1.0.5"
} }
}, },
"node_modules/markdown-it": {
"version": "14.1.1",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
"integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.4.0",
"linkify-it": "^5.0.0",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -8496,6 +9063,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
"license": "MIT"
},
"node_modules/merge-stream": { "node_modules/merge-stream": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@ -9091,6 +9664,12 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/own-keys": { "node_modules/own-keys": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
@ -9775,6 +10354,201 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/prosemirror-changeset": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz",
"integrity": "sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng==",
"license": "MIT",
"dependencies": {
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-collab": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz",
"integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0"
}
},
"node_modules/prosemirror-commands": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.10.2"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"node_modules/prosemirror-gapcursor": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.0.tgz",
"integrity": "sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"node_modules/prosemirror-history": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.31.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-inputrules": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-markdown": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.4.tgz",
"integrity": "sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==",
"license": "MIT",
"dependencies": {
"@types/markdown-it": "^14.0.0",
"markdown-it": "^14.0.0",
"prosemirror-model": "^1.25.0"
}
},
"node_modules/prosemirror-menu": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.0.tgz",
"integrity": "sha512-TImyPXCHPcDsSka2/lwJ6WjTASr4re/qWq1yoTTuLOqfXucwF6VcRa2LWCkM/EyTD1UO3CUwiH8qURJoWJRxwg==",
"license": "MIT",
"dependencies": {
"crelt": "^1.0.0",
"prosemirror-commands": "^1.0.0",
"prosemirror-history": "^1.0.0",
"prosemirror-state": "^1.0.0"
}
},
"node_modules/prosemirror-model": {
"version": "1.25.4",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"license": "MIT",
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-schema-basic": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz",
"integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.25.0"
}
},
"node_modules/prosemirror-schema-list": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.7.3"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-tables": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.4",
"prosemirror-state": "^1.4.4",
"prosemirror-transform": "^1.10.5",
"prosemirror-view": "^1.41.4"
}
},
"node_modules/prosemirror-trailing-node": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz",
"integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==",
"license": "MIT",
"dependencies": {
"@remirror/core-constants": "3.0.0",
"escape-string-regexp": "^4.0.0"
},
"peerDependencies": {
"prosemirror-model": "^1.22.1",
"prosemirror-state": "^1.4.2",
"prosemirror-view": "^1.33.8"
}
},
"node_modules/prosemirror-transform": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.11.0.tgz",
"integrity": "sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.41.6",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.6.tgz",
"integrity": "sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@ -9785,6 +10559,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/pure-rand": { "node_modules/pure-rand": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
@ -10116,6 +10899,12 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/rope-sequence": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT"
},
"node_modules/run-parallel": { "node_modules/run-parallel": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@ -11290,6 +12079,12 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
"node_modules/uglify-js": { "node_modules/uglify-js": {
"version": "3.19.3", "version": "3.19.3",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
@ -11469,6 +12264,12 @@
"node": ">=10.12.0" "node": ">=10.12.0"
} }
}, },
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/walker": { "node_modules/walker": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
@ -11879,4 +12680,4 @@
} }
} }
} }
} }

View File

@ -24,6 +24,11 @@
"@auth/prisma-adapter": "^2.11.1", "@auth/prisma-adapter": "^2.11.1",
"@next-auth/prisma-adapter": "^1.0.7", "@next-auth/prisma-adapter": "^1.0.7",
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"@tiptap/extension-link": "^3.20.0",
"@tiptap/extension-placeholder": "^3.20.0",
"@tiptap/extension-underline": "^3.20.0",
"@tiptap/react": "^3.20.0",
"@tiptap/starter-kit": "^3.20.0",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/nodemailer": "^7.0.11", "@types/nodemailer": "^7.0.11",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
@ -59,4 +64,4 @@
"ts-jest": "^29.0.0", "ts-jest": "^29.0.0",
"typescript": "^5.0.0" "typescript": "^5.0.0"
} }
} }

View File

@ -73,6 +73,7 @@ model User {
pastDayColor String? @default("#a6a6a7") pastDayColor String? @default("#a6a6a7")
goalScope String @default("week") goalScope String @default("week")
dateLayout String @default("right") dateLayout String @default("right")
mobileDateLayout String @default("below")
dateAlignment String @default("center") dateAlignment String @default("center")
hourLabelFormat String @default("short") hourLabelFormat String @default("short")
showSubHourSlots Boolean @default(true) showSubHourSlots Boolean @default(true)
@ -158,6 +159,7 @@ model Task {
recurrenceUnit String? recurrenceUnit String?
recurrenceTime String? recurrenceTime String?
recurrenceEndDate DateTime? recurrenceEndDate DateTime?
recurrenceDays String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
externalId String? externalId String?

9
query.js Normal file
View File

@ -0,0 +1,9 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
const conns = await prisma.calendarConnection.findMany({ where: { provider: 'synology' } });
console.log(JSON.stringify(conns, null, 2));
}
main().catch(console.error).finally(() => prisma.$disconnect());

View File

@ -56,7 +56,7 @@ export async function POST(request: NextRequest) {
dbId: conn.id, dbId: conn.id,
userId: user.id, userId: user.id,
id: conn.id, id: conn.id,
provider: conn.provider as 'google' | 'apple' | 'outlook', provider: conn.provider as 'google' | 'apple' | 'outlook' | 'synology',
accessToken: conn.accessToken, accessToken: conn.accessToken,
refreshToken: conn.refreshToken ?? undefined, refreshToken: conn.refreshToken ?? undefined,
expiresAt: conn.expiresAt ?? undefined, expiresAt: conn.expiresAt ?? undefined,

View File

@ -0,0 +1,88 @@
import { NextResponse } from 'next/server';
import { validateCredentials } from '@/lib/synology-calendar';
import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth";
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function POST(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await req.json();
const { serverUrl, username, password } = body;
if (!serverUrl || !username || !password) {
return NextResponse.json(
{ error: 'Server URL, username, and password are required' },
{ status: 400 }
);
}
// Validate credentials and get calendars
const calendars = await validateCredentials(serverUrl, username, password);
// Find user
const user = await prisma.user.findUnique({
where: { email: session.user.email }
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Check if connection already exists and update it, otherwise create new
const existingConnection = await prisma.calendarConnection.findFirst({
where: {
userId: user.id,
provider: 'synology'
}
});
let connection;
if (existingConnection) {
connection = await prisma.calendarConnection.update({
where: { id: existingConnection.id },
data: {
accessToken: `${username}:${password}`,
refreshToken: serverUrl, // Use refreshToken field to store the server URL since Basic Auth doesn't need refresh tokens
calendars: calendars.map(cal => ({
id: cal.id,
title: cal.title,
isPrimary: cal.isPrimary,
selected: true // Default to selected
})) as any,
updatedAt: new Date()
}
});
} else {
connection = await prisma.calendarConnection.create({
data: {
userId: user.id,
provider: 'synology',
accessToken: `${username}:${password}`,
refreshToken: serverUrl,
calendars: calendars.map(cal => ({
id: cal.id,
title: cal.title,
isPrimary: cal.isPrimary,
selected: true
})) as any
}
});
}
return NextResponse.json({ success: true, connection });
} catch (error: any) {
console.error('Error connecting Synology Calendar:', error);
return NextResponse.json(
{ error: error.message || 'Failed to connect Synology Calendar' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
export async function GET() {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { email: session.user.email },
select: { id: true },
});
if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 });
const projects = await prisma.project.findMany({
where: { userId: user.id },
orderBy: { order: 'asc' },
});
return NextResponse.json({ projects });
}
export async function POST(request: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { email: session.user.email },
select: { id: true },
});
if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 });
const { name, color, icon } = await request.json();
if (!name) {
return NextResponse.json({ error: 'Name is required' }, { status: 400 });
}
const project = await prisma.project.create({
data: { name, color: color || null, icon: icon || null, userId: user.id },
});
return NextResponse.json({ project });
}
export async function PATCH(request: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { email: session.user.email },
select: { id: true },
});
if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 });
const { id, name, color, icon } = await request.json();
if (!id) return NextResponse.json({ error: 'ID required' }, { status: 400 });
const existing = await prisma.project.findFirst({ where: { id, userId: user.id } });
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 });
const project = await prisma.project.update({
where: { id },
data: {
...(name !== undefined && { name }),
...(color !== undefined && { color: color || null }),
...(icon !== undefined && { icon: icon || null }),
},
});
return NextResponse.json({ project });
}
export async function DELETE(request: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { email: session.user.email },
select: { id: true },
});
if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 });
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) return NextResponse.json({ error: 'ID required' }, { status: 400 });
const existing = await prisma.project.findFirst({ where: { id, userId: user.id } });
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 });
// Unlink tasks from this project
await prisma.task.updateMany({ where: { projectId: id }, data: { projectId: null } });
await prisma.project.delete({ where: { id } });
return NextResponse.json({ message: 'Project deleted' });
}

View File

@ -35,7 +35,7 @@ export async function POST(req: NextRequest) {
const body = await req.json(); const body = await req.json();
const { provider, sourceLists } = body; const { provider, sourceLists } = body;
if (!provider || !['google', 'outlook'].includes(provider)) { if (!provider || !['google', 'outlook', 'synology'].includes(provider)) {
return NextResponse.json({ error: 'Invalid provider' }, { status: 400 }); return NextResponse.json({ error: 'Invalid provider' }, { status: 400 });
} }
@ -151,6 +151,47 @@ export async function POST(req: NextRequest) {
} }
} }
if (provider === 'synology') {
const connection = await prisma.calendarConnection.findFirst({
where: { userId: user.id, provider: 'synology' }
});
if (!connection || !connection.accessToken || !connection.refreshToken) {
return NextResponse.json({ error: 'Synology account not connected' }, { status: 400 });
}
const [username, password] = connection.accessToken.split(':');
const serverUrl = connection.refreshToken;
const { fetchSynologyReminderLists, fetchSynologyTasks } = await import('@/lib/synology-tasks');
if (targetLists.length === 0) {
const synoLists = await fetchSynologyReminderLists(serverUrl, username, password);
if (synoLists.length > 0) {
targetLists = [{ id: synoLists[0].id, title: synoLists[0].title }];
}
}
for (const sourceList of targetLists) {
try {
const synoTasks = await fetchSynologyTasks(serverUrl, username, password, sourceList.id);
for (const t of synoTasks) {
importedTasks.push({
title: t.title,
description: t.notes || '',
externalId: t.id,
externalListId: sourceList.id,
dueDate: t.due ? new Date(t.due) : null,
status: t.status === 'completed' ? 'completed' : 'notStarted',
sourceListTitle: sourceList.title,
});
}
} catch (e) {
console.error(`Error fetching Synology tasks for list ${sourceList.id}:`, e);
}
}
}
// Group tasks by source list title // Group tasks by source list title
const tasksByList = new Map<string, ImportedTask[]>(); const tasksByList = new Map<string, ImportedTask[]>();
// Pre-initialize with all target lists to ensure they are created even if empty // Pre-initialize with all target lists to ensure they are created even if empty

View File

@ -20,7 +20,7 @@ export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const provider = searchParams.get('provider'); const provider = searchParams.get('provider');
if (!provider || !['google', 'outlook'].includes(provider)) { if (!provider || !['google', 'outlook', 'synology'].includes(provider)) {
return NextResponse.json({ error: 'Invalid provider' }, { status: 400 }); return NextResponse.json({ error: 'Invalid provider' }, { status: 400 });
} }
@ -60,6 +60,27 @@ export async function GET(req: NextRequest) {
}); });
} }
if (provider === 'synology') {
const connection = await prisma.calendarConnection.findFirst({
where: { userId: user.id, provider: 'synology' }
});
if (!connection || !connection.accessToken || !connection.refreshToken) {
return NextResponse.json({ error: 'Synology account not connected' }, { status: 400 });
}
const [username, password] = connection.accessToken.split(':');
const serverUrl = connection.refreshToken;
// Re-export dynamically to avoid circular dependencies in route
const { fetchSynologyReminderLists } = await import('@/lib/synology-tasks');
const lists = await fetchSynologyReminderLists(serverUrl, username, password);
return NextResponse.json({
lists: lists.map(l => ({ id: l.id, title: l.title }))
});
}
return NextResponse.json({ error: 'Invalid provider' }, { status: 400 }); return NextResponse.json({ error: 'Invalid provider' }, { status: 400 });
} catch (error: unknown) { } catch (error: unknown) {

View File

@ -53,19 +53,33 @@ const projectFutureTasks = (tasks: Task[], horizonDays = 90) => {
const interval = latestTask.recurrenceInterval || 1; const interval = latestTask.recurrenceInterval || 1;
const unit = latestTask.recurrenceUnit || 'weeks'; // 'days', 'weeks', 'months', 'years' const unit = latestTask.recurrenceUnit || 'weeks'; // 'days', 'weeks', 'months', 'years'
// Advance to next slot // Parse recurrenceDays for weekly multi-day recurrence
// We only generate UP TO horizon. let recDays: number[] | null = null;
// We avoid generating duplicates if a real task already exists at that date? if (latestTask.recurrenceDays) {
// We already grouped by series, and we are projecting from the *latest* one. try { recDays = JSON.parse(latestTask.recurrenceDays); } catch { recDays = null; }
// So any future dates we generate *should* be new. }
// Safety break // Safety break
let iterations = 0; let iterations = 0;
while (currentDate < horizonDate && iterations < 100) { while (currentDate < horizonDate && iterations < 500) {
iterations++; iterations++;
// Advance date // Advance date
if (unit === 'days') { if (unit === 'weeks' && recDays && recDays.length > 0) {
// Multi-day weekly: advance day by day within each week cycle
currentDate.setDate(currentDate.getDate() + 1);
// If we've moved past the current week (interval > 1), skip ahead
const daysSinceBase = Math.floor((currentDate.getTime() - baseDate.getTime()) / 86400000);
const weeksSinceBase = Math.floor(daysSinceBase / 7);
if (weeksSinceBase % interval !== 0) {
// Skip to start of next valid week
const nextValidWeek = (Math.floor(weeksSinceBase / interval) + 1) * interval;
const daysToSkip = (nextValidWeek * 7) - daysSinceBase;
currentDate.setDate(currentDate.getDate() + daysToSkip);
}
// Only emit if current day matches one of the selected days
if (!recDays.includes(currentDate.getDay())) continue;
} else if (unit === 'days') {
currentDate.setDate(currentDate.getDate() + interval); currentDate.setDate(currentDate.getDate() + interval);
} else if (unit === 'weeks') { } else if (unit === 'weeks') {
currentDate.setDate(currentDate.getDate() + (interval * 7)); currentDate.setDate(currentDate.getDate() + (interval * 7));
@ -74,7 +88,6 @@ const projectFutureTasks = (tasks: Task[], horizonDays = 90) => {
} else if (unit === 'years') { } else if (unit === 'years') {
currentDate.setFullYear(currentDate.getFullYear() + interval); currentDate.setFullYear(currentDate.getFullYear() + interval);
} else { } else {
// Default to weekly if unknown
currentDate.setDate(currentDate.getDate() + 7); currentDate.setDate(currentDate.getDate() + 7);
} }
@ -120,14 +133,24 @@ export async function GET(request: NextRequest) {
try { try {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session?.user) { if (!session?.user?.email) {
return NextResponse.json( return NextResponse.json(
{ error: 'Unauthorized' }, { error: 'Unauthorized' },
{ status: 401 } { status: 401 }
); );
} }
const userId = (session.user as any).id; // Resolve user by email (session ID can be stale after DB changes)
const dbUser = await prisma.user.findUnique({
where: { email: session.user.email },
select: { id: true }
});
if (!dbUser) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
const userId = dbUser.id;
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const start = searchParams.get('start'); const start = searchParams.get('start');
const end = searchParams.get('end'); const end = searchParams.get('end');
@ -195,10 +218,9 @@ export async function POST(request: NextRequest) {
); );
} }
const userId = (session.user as any).id;
const body = await request.json(); const body = await request.json();
const { title, description, dayOfWeek, order, markdownContent, somedayListId, somedaySlotIndex, startTime, scheduledDate, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, parentTaskId, projectId } = body; const { title, description, dayOfWeek, order, markdownContent, somedayListId, somedaySlotIndex, startTime, scheduledDate, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, recurrenceDays, parentTaskId, projectId } = body;
let { isRolling } = body; let { isRolling } = body;
const { isRecurring } = body; const { isRecurring } = body;
@ -209,30 +231,27 @@ export async function POST(request: NextRequest) {
); );
} }
// If isRolling is not specified, check user preference // Resolve user by email (session ID can be stale after DB changes)
if (isRolling === undefined) { const user = await prisma.user.findUnique({
const user = await prisma.user.findUnique({ where: { email: session.user.email },
where: { email: session.user.email }, select: { id: true, autoRolling: true }
select: { autoRolling: true }
});
isRolling = user?.autoRolling || false;
}
console.log('API POST - Creating task for user:', userId);
// Safety check: ensure user exists before creation to provide better error
const userExists = await prisma.user.findUnique({
where: { id: userId }
}); });
if (!userExists) { if (!user) {
console.error('CRITICAL: User ID in session does not exist in database:', userId); console.error('CRITICAL: User email in session does not exist in database:', session.user.email);
return NextResponse.json( return NextResponse.json(
{ error: 'User does not exist in database. Please log out and back in.' }, { error: 'User does not exist in database. Please log out and back in.' },
{ status: 400 } { status: 400 }
); );
} }
const userId = user.id;
// If isRolling is not specified, check user preference
if (isRolling === undefined) {
isRolling = user.autoRolling || false;
}
const task = await prisma.task.create({ const task = await prisma.task.create({
data: { data: {
title, title,
@ -250,6 +269,7 @@ export async function POST(request: NextRequest) {
recurrenceUnit, recurrenceUnit,
recurrenceTime, recurrenceTime,
recurrenceEndDate: recurrenceEndDate ? new Date(recurrenceEndDate) : null, recurrenceEndDate: recurrenceEndDate ? new Date(recurrenceEndDate) : null,
recurrenceDays: recurrenceDays ? JSON.stringify(recurrenceDays) : null,
somedaySlotIndex: somedaySlotIndex !== undefined ? parseInt(somedaySlotIndex) : null, somedaySlotIndex: somedaySlotIndex !== undefined ? parseInt(somedaySlotIndex) : null,
parentTaskId: parentTaskId || null, parentTaskId: parentTaskId || null,
...(projectId !== undefined && { projectId: projectId || null }), ...(projectId !== undefined && { projectId: projectId || null }),
@ -274,18 +294,28 @@ export async function PATCH(request: NextRequest) {
try { try {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session?.user) { if (!session?.user?.email) {
return NextResponse.json( return NextResponse.json(
{ error: 'Unauthorized' }, { error: 'Unauthorized' },
{ status: 401 } { status: 401 }
); );
} }
const userId = (session.user as any).id; // Resolve user by email (session ID can be stale after DB changes)
const dbUser = await prisma.user.findUnique({
where: { email: session.user.email },
select: { id: true }
});
if (!dbUser) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
const userId = dbUser.id;
const body = await request.json(); const body = await request.json();
const { id } = body; const { id } = body;
const { title, description, completed, dayOfWeek, order, markdownContent, scheduledDate, startTime, somedayListId, somedaySlotIndex, isRecurring, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, restore, parentTaskId, projectId } = body; const { title, description, completed, dayOfWeek, order, markdownContent, scheduledDate, startTime, somedayListId, somedaySlotIndex, isRecurring, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, recurrenceDays, restore, parentTaskId, projectId } = body;
if (!id) { if (!id) {
return NextResponse.json( return NextResponse.json(
@ -325,6 +355,7 @@ export async function PATCH(request: NextRequest) {
recurrenceUnit: originalTask.recurrenceUnit, recurrenceUnit: originalTask.recurrenceUnit,
recurrenceTime: originalTask.recurrenceTime, recurrenceTime: originalTask.recurrenceTime,
recurrenceEndDate: originalTask.recurrenceEndDate, recurrenceEndDate: originalTask.recurrenceEndDate,
recurrenceDays: originalTask.recurrenceDays,
isRolling: originalTask.isRolling, isRolling: originalTask.isRolling,
// Apply overrides from the patch body immediately // Apply overrides from the patch body immediately
completed: completed !== undefined ? completed : false, completed: completed !== undefined ? completed : false,
@ -368,6 +399,7 @@ export async function PATCH(request: NextRequest) {
...(recurrenceUnit !== undefined && { recurrenceUnit }), ...(recurrenceUnit !== undefined && { recurrenceUnit }),
...(recurrenceTime !== undefined && { recurrenceTime }), ...(recurrenceTime !== undefined && { recurrenceTime }),
...(recurrenceEndDate !== undefined && { recurrenceEndDate: recurrenceEndDate ? new Date(recurrenceEndDate) : null }), ...(recurrenceEndDate !== undefined && { recurrenceEndDate: recurrenceEndDate ? new Date(recurrenceEndDate) : null }),
...(recurrenceDays !== undefined && { recurrenceDays: recurrenceDays ? JSON.stringify(recurrenceDays) : null }),
...(restore === true && { deletedAt: null }), ...(restore === true && { deletedAt: null }),
...(somedaySlotIndex !== undefined && { somedaySlotIndex: somedaySlotIndex !== null ? parseInt(somedaySlotIndex) : null }), ...(somedaySlotIndex !== undefined && { somedaySlotIndex: somedaySlotIndex !== null ? parseInt(somedaySlotIndex) : null }),
...(parentTaskId !== undefined && { parentTaskId: parentTaskId || null }), ...(parentTaskId !== undefined && { parentTaskId: parentTaskId || null }),
@ -396,14 +428,24 @@ export async function DELETE(request: NextRequest) {
try { try {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session?.user) { if (!session?.user?.email) {
return NextResponse.json( return NextResponse.json(
{ error: 'Unauthorized' }, { error: 'Unauthorized' },
{ status: 401 } { status: 401 }
); );
} }
const userId = (session.user as any).id; // Resolve user by email (session ID can be stale after DB changes)
const dbUser = await prisma.user.findUnique({
where: { email: session.user.email },
select: { id: true }
});
if (!dbUser) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
const userId = dbUser.id;
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const id = searchParams.get('id'); const id = searchParams.get('id');

View File

@ -316,6 +316,106 @@ export async function GET(req: NextRequest) {
} }
} }
// --- Synology pull-sync ---
const synologySyncedLists = syncedLists.filter(l => l.externalProvider === 'synology');
const synologyLocalTasks = allExternalTasks.filter(t => t.externalProvider === 'synology');
const hasSynologyTasks = synologyLocalTasks.length > 0 || synologySyncedLists.length > 0;
if (hasSynologyTasks) {
const synoConnection = await prisma.calendarConnection.findFirst({
where: { userId: user.id, provider: 'synology' }
});
if (synoConnection?.accessToken && synoConnection?.refreshToken) {
const [synoUsername, synoPassword] = synoConnection.accessToken.split(':');
const synoServerUrl = synoConnection.refreshToken;
if (synoUsername && synoPassword && synoServerUrl) {
const { fetchSynologyTasks } = await import('@/lib/synology-tasks');
const synoListIds = new Set<string>();
const synoListIdToSomedayList = new Map<string, { id: string; title: string }>();
for (const task of synologyLocalTasks) {
if (task.externalListId) synoListIds.add(task.externalListId);
}
for (const sl of synologySyncedLists) {
if (sl.externalId) {
synoListIds.add(sl.externalId);
synoListIdToSomedayList.set(sl.externalId, { id: sl.id, title: sl.title });
}
}
const synoByList = new Map<string, typeof synologyLocalTasks>();
for (const task of synologyLocalTasks) {
if (!task.externalListId) continue;
if (!synoByList.has(task.externalListId)) synoByList.set(task.externalListId, []);
synoByList.get(task.externalListId)!.push(task);
}
for (const listId of synoListIds) {
const localTasks = synoByList.get(listId) || [];
try {
const remoteTasks = await fetchSynologyTasks(synoServerUrl, synoUsername, synoPassword, listId);
// Remap ID: the fetchSynologyTasks prefixes ids with "synology::"
const remoteMap = new Map(remoteTasks.map(t => [t.id.replace(/^synology::/, ''), t]));
const existingExternalIds = new Set(localTasks.map(t => t.externalId?.replace(/^synology::/, '')));
for (const localTask of localTasks) {
const cleanExtId = localTask.externalId?.replace(/^synology::/, '');
const remote = cleanExtId ? remoteMap.get(cleanExtId) : null;
if (!remote) {
await prisma.task.update({ where: { id: localTask.id }, data: { deletedAt: new Date() } });
deleted++;
continue;
}
const updateData: any = { lastSyncedAt: new Date() };
const remoteCompleted = remote.status === 'completed';
if (remoteCompleted !== localTask.completed) updateData.completed = remoteCompleted;
if (remote.title && remote.title !== localTask.title) updateData.title = remote.title;
await prisma.task.update({ where: { id: localTask.id }, data: updateData });
if (Object.keys(updateData).length > 1) updated++;
}
// Create new local tasks from remote
const somedayListInfo = synoListIdToSomedayList.get(listId);
if (somedayListInfo) {
const newRemoteTasks = remoteTasks.filter(rt => {
const cleanId = rt.id.replace(/^synology::/, '');
return !existingExternalIds.has(cleanId);
});
for (const remote of newRemoteTasks) {
if (!remote.title?.trim()) continue;
await prisma.task.create({
data: {
userId: user.id,
title: remote.title,
description: remote.notes || null,
completed: remote.status === 'completed',
somedayListId: somedayListInfo.id,
externalId: remote.id,
externalProvider: 'synology',
externalListId: listId,
scheduledDate: remote.due ? new Date(remote.due) : null,
lastSyncedAt: new Date(),
}
});
existingExternalIds.add(remote.id.replace(/^synology::/, ''));
created++;
}
}
} catch (listError) {
console.error(`Error syncing Synology list ${listId}:`, listError);
}
}
}
}
}
return NextResponse.json({ success: true, updated, deleted, created }); return NextResponse.json({ success: true, updated, deleted, created });
} catch (error: unknown) { } catch (error: unknown) {

View File

@ -43,6 +43,7 @@ export async function GET(request: NextRequest) {
goalFontWeight: true, goalFontWeight: true,
goalScope: true, goalScope: true,
dateLayout: true, dateLayout: true,
mobileDateLayout: true,
headlineFont: true, headlineFont: true,
headlineFontSize: true, headlineFontSize: true,
headlineFontWeight: true, headlineFontWeight: true,
@ -120,7 +121,7 @@ export async function PATCH(request: NextRequest) {
weekdayColor, dateColor, taskColor, todayHighlightColor, weekdayColor, dateColor, taskColor, todayHighlightColor,
pastDayColor, goalFallbackType, goalDefaultSentence, pastDayColor, goalFallbackType, goalDefaultSentence,
goalFontFamily, goalFontSize, goalFontWeight, goalScope, goalFontFamily, goalFontSize, goalFontWeight, goalScope,
dateLayout, dateLayout, mobileDateLayout,
hourLabelFormat, showSubHourSlots, allDayPosition, hourLabelFormat, showSubHourSlots, allDayPosition,
cwFontFamily, cwFontSize, cwFontWeight, cwColor, cwFontFamily, cwFontSize, cwFontWeight, cwColor,
yearFontFamily, yearFontSize, yearFontWeight, yearColor, yearFontFamily, yearFontSize, yearFontWeight, yearColor,
@ -182,6 +183,7 @@ export async function PATCH(request: NextRequest) {
...(goalFontWeight !== undefined && { goalFontWeight }), ...(goalFontWeight !== undefined && { goalFontWeight }),
...(goalScope !== undefined && { goalScope }), ...(goalScope !== undefined && { goalScope }),
...(dateLayout !== undefined && { dateLayout }), ...(dateLayout !== undefined && { dateLayout }),
...(mobileDateLayout !== undefined && { mobileDateLayout }),
...(hourLabelFormat !== undefined && { hourLabelFormat }), ...(hourLabelFormat !== undefined && { hourLabelFormat }),
...(showSubHourSlots !== undefined && { showSubHourSlots }), ...(showSubHourSlots !== undefined && { showSubHourSlots }),
...(allDayPosition !== undefined && { allDayPosition }), ...(allDayPosition !== undefined && { allDayPosition }),
@ -265,6 +267,7 @@ export async function PATCH(request: NextRequest) {
goalFontWeight: true, goalFontWeight: true,
goalScope: true, goalScope: true,
dateLayout: true, dateLayout: true,
mobileDateLayout: true,
cwFontFamily: true, cwFontFamily: true,
cwFontSize: true, cwFontSize: true,
cwFontWeight: true, cwFontWeight: true,

View File

@ -1041,6 +1041,10 @@ h3 {
font-size: 0.8125rem; font-size: 0.8125rem;
color: var(--weekly-text); color: var(--weekly-text);
position: relative; position: relative;
cursor: grab;
}
.subtask-item:active {
cursor: grabbing;
} }
.subtask-item.completed .subtask-title { .subtask-item.completed .subtask-title {
@ -2750,27 +2754,27 @@ h3 {
/* Class-based animation that works on ALL browsers (no View Transitions API needed) */ /* Class-based animation that works on ALL browsers (no View Transitions API needed) */
@keyframes navSlideInFromRight { @keyframes navSlideInFromRight {
from { transform: translateX(40%); opacity: 0.3; } from { transform: translateX(30%); opacity: 0; }
to { transform: translateX(0); opacity: 1; } to { transform: translateX(0); opacity: 1; }
} }
@keyframes navSlideInFromLeft { @keyframes navSlideInFromLeft {
from { transform: translateX(-40%); opacity: 0.3; } from { transform: translateX(-30%); opacity: 0; }
to { transform: translateX(0); opacity: 1; } to { transform: translateX(0); opacity: 1; }
} }
.slide-animate-next { .slide-animate-next {
animation: navSlideInFromRight 0.35s cubic-bezier(0.25, 1, 0.5, 1) both; animation: navSlideInFromRight 0.3s cubic-bezier(0.25, 1, 0.5, 1) both;
} }
.slide-animate-prev { .slide-animate-prev {
animation: navSlideInFromLeft 0.35s cubic-bezier(0.25, 1, 0.5, 1) both; animation: navSlideInFromLeft 0.3s cubic-bezier(0.25, 1, 0.5, 1) both;
} }
/* Week navigation: 5x longer for a more dramatic slide */ /* Week navigation: slightly longer slide with more offset */
.slide-animate-week-next { .slide-animate-week-next {
animation: navSlideInFromRight 1.75s cubic-bezier(0.25, 1, 0.5, 1) both; animation: navSlideInFromRight 0.45s cubic-bezier(0.25, 1, 0.5, 1) both;
} }
.slide-animate-week-prev { .slide-animate-week-prev {
animation: navSlideInFromLeft 1.75s cubic-bezier(0.25, 1, 0.5, 1) both; animation: navSlideInFromLeft 0.45s cubic-bezier(0.25, 1, 0.5, 1) both;
} }
/* Smooth View Transitions */ /* Smooth View Transitions */

View File

@ -1,4 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, lazy, Suspense } from 'react';
const RichTextEditor = lazy(() => import('./RichTextEditor'));
interface CalendarEventModalProps { interface CalendarEventModalProps {
event?: any; // Existing event if editing event?: any; // Existing event if editing
@ -26,7 +28,8 @@ export default function CalendarEventModal({
provider: conn.provider, provider: conn.provider,
providerName: conn.provider === 'google' ? 'Google Calendar' : providerName: conn.provider === 'google' ? 'Google Calendar' :
conn.provider === 'apple' ? 'Apple Calendar' : conn.provider === 'apple' ? 'Apple Calendar' :
'Outlook Calendar' conn.provider === 'synology' ? 'Synology Calendar' :
'Outlook Calendar'
}))) })))
.filter((cal: any) => cal.editable); // Only editable calendars .filter((cal: any) => cal.editable); // Only editable calendars
@ -284,16 +287,25 @@ export default function CalendarEventModal({
); );
})()} })()}
{/* Description */} {/* Description - Rich text */}
<div> <div>
<label style={{ display: 'block', marginBottom: '5px', fontSize: '0.9rem', color: '#666' }}>Notes</label> <label style={{ display: 'block', marginBottom: '5px', fontSize: '0.9rem', color: '#666' }}>Notes</label>
<textarea <Suspense fallback={
value={description} <textarea
onChange={e => setDescription(e.target.value)} value={description}
placeholder="Notes..." onChange={e => setDescription(e.target.value)}
rows={3} placeholder="Notes..."
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px', resize: 'vertical' }} rows={3}
/> style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px', resize: 'vertical' }}
/>
}>
<RichTextEditor
value={description}
onChange={setDescription}
placeholder="Notes..."
minHeight="120px"
/>
</Suspense>
</div> </div>
</div> </div>

View File

@ -5,7 +5,7 @@ interface ImportListModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onImport: (selectedLists: { id: string, title: string }[]) => void; onImport: (selectedLists: { id: string, title: string }[]) => void;
provider: 'google' | 'apple' | 'outlook' | null; provider: 'google' | 'apple' | 'outlook' | 'synology' | null;
lists: { id: string, title: string }[]; lists: { id: string, title: string }[];
isLoading: boolean; isLoading: boolean;
} }

View File

@ -4,9 +4,13 @@ interface RecurrenceModalProps {
task: any; task: any;
onClose: () => void; onClose: () => void;
onSave: (taskId: string, recurrence: any) => Promise<void>; onSave: (taskId: string, recurrence: any) => Promise<void>;
language?: string;
} }
export default function RecurrenceModal({ task, onClose, onSave }: RecurrenceModalProps) { const WEEKDAYS_EN = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const WEEKDAYS_DE = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
export default function RecurrenceModal({ task, onClose, onSave, language = 'en' }: RecurrenceModalProps) {
const [isRecurring, setIsRecurring] = useState(task.isRecurring || false); const [isRecurring, setIsRecurring] = useState(task.isRecurring || false);
const [interval, setInterval] = useState(task.recurrenceInterval || 1); const [interval, setInterval] = useState(task.recurrenceInterval || 1);
const [unit, setUnit] = useState(task.recurrenceUnit || 'weeks'); const [unit, setUnit] = useState(task.recurrenceUnit || 'weeks');
@ -14,6 +18,13 @@ export default function RecurrenceModal({ task, onClose, onSave }: RecurrenceMod
const [endDate, setEndDate] = useState(task.recurrenceEndDate ? new Date(task.recurrenceEndDate).toISOString().split('T')[0] : ''); const [endDate, setEndDate] = useState(task.recurrenceEndDate ? new Date(task.recurrenceEndDate).toISOString().split('T')[0] : '');
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
// Day-of-week for weekly recurrence (0=Sun, 1=Mon, etc.)
const taskDow = task.scheduledDate ? new Date(task.scheduledDate).getDay() : (task.dayOfWeek ?? new Date().getDay());
const [selectedDays, setSelectedDays] = useState<number[]>(task.recurrenceDays?.length ? task.recurrenceDays : [taskDow]);
const weekdays = language === 'de' ? WEEKDAYS_DE : WEEKDAYS_EN;
const isDE = language === 'de';
// Helper to format date as DD.MM.YYYY // Helper to format date as DD.MM.YYYY
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
if (!dateStr) return ''; if (!dateStr) return '';
@ -33,6 +44,15 @@ export default function RecurrenceModal({ task, onClose, onSave }: RecurrenceMod
return input; // fallback to raw return input; // fallback to raw
}; };
const toggleDay = (day: number) => {
setSelectedDays(prev => {
if (prev.includes(day)) {
return prev.length > 1 ? prev.filter(d => d !== day) : prev; // keep at least one
}
return [...prev, day].sort();
});
};
const handleSave = async () => { const handleSave = async () => {
setIsSaving(true); setIsSaving(true);
try { try {
@ -42,7 +62,8 @@ export default function RecurrenceModal({ task, onClose, onSave }: RecurrenceMod
recurrenceInterval: isRecurring ? interval : null, recurrenceInterval: isRecurring ? interval : null,
recurrenceUnit: isRecurring ? unit : null, recurrenceUnit: isRecurring ? unit : null,
recurrenceTime: isRecurring ? time : null, recurrenceTime: isRecurring ? time : null,
recurrenceEndDate: isRecurring && parsedEndDate ? new Date(parsedEndDate) : null recurrenceEndDate: isRecurring && parsedEndDate ? new Date(parsedEndDate) : null,
recurrenceDays: isRecurring && unit === 'weeks' ? selectedDays : null,
}); });
onClose(); onClose();
} catch (error) { } catch (error) {
@ -54,7 +75,7 @@ export default function RecurrenceModal({ task, onClose, onSave }: RecurrenceMod
return ( return (
<div className="weekly-modal-overlay" onClick={onClose}> <div className="weekly-modal-overlay" onClick={onClose}>
<div className="weekly-modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: '400px' }}> <div className="weekly-modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: '400px' }}>
<h3 style={{ marginBottom: '1.5rem' }}>Recurring Task</h3> <h3 style={{ marginBottom: '1.5rem' }}>{isDE ? 'Wiederkehrende Aufgabe' : 'Recurring Task'}</h3>
<div style={{ marginBottom: '1.5rem' }}> <div style={{ marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '1rem' }}> <div style={{ display: 'flex', alignItems: 'center', marginBottom: '1rem' }}>
@ -65,13 +86,13 @@ export default function RecurrenceModal({ task, onClose, onSave }: RecurrenceMod
onChange={e => setIsRecurring(e.target.checked)} onChange={e => setIsRecurring(e.target.checked)}
style={{ width: '18px', height: '18px', marginRight: '10px' }} style={{ width: '18px', height: '18px', marginRight: '10px' }}
/> />
<label htmlFor="isRecurring" style={{ fontSize: '1rem', fontWeight: 500 }}>Enable Recurrence</label> <label htmlFor="isRecurring" style={{ fontSize: '1rem', fontWeight: 500 }}>{isDE ? 'Wiederholung aktivieren' : 'Enable Recurrence'}</label>
</div> </div>
{isRecurring && ( {isRecurring && (
<div style={{ paddingLeft: '28px', display: 'flex', flexDirection: 'column', gap: '12px' }}> <div style={{ paddingLeft: '28px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div> <div>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '0.9rem', color: '#666' }}>Repeat every</label> <label style={{ display: 'block', marginBottom: '4px', fontSize: '0.9rem', color: '#666' }}>{isDE ? 'Wiederholen alle' : 'Repeat every'}</label>
<div style={{ display: 'flex', gap: '8px' }}> <div style={{ display: 'flex', gap: '8px' }}>
<input <input
type="number" type="number"
@ -85,16 +106,46 @@ export default function RecurrenceModal({ task, onClose, onSave }: RecurrenceMod
onChange={e => setUnit(e.target.value)} onChange={e => setUnit(e.target.value)}
style={{ flex: 1, padding: '6px', borderRadius: '4px', border: '1px solid #ddd' }} style={{ flex: 1, padding: '6px', borderRadius: '4px', border: '1px solid #ddd' }}
> >
<option value="days">Days</option> <option value="days">{isDE ? 'Tage' : 'Days'}</option>
<option value="weeks">Weeks</option> <option value="weeks">{isDE ? 'Wochen' : 'Weeks'}</option>
<option value="months">Months</option> <option value="months">{isDE ? 'Monate' : 'Months'}</option>
<option value="years">Years</option> <option value="years">{isDE ? 'Jahre' : 'Years'}</option>
</select> </select>
</div> </div>
</div> </div>
{/* Weekday selector for weekly recurrence */}
{unit === 'weeks' && (
<div>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '0.9rem', color: '#666' }}>{isDE ? 'An diesen Tagen' : 'On these days'}</label>
<div style={{ display: 'flex', gap: '4px' }}>
{weekdays.map((dayName, i) => (
<button
key={i}
type="button"
onClick={() => toggleDay(i)}
style={{
flex: 1,
padding: '6px 2px',
borderRadius: '6px',
border: 'none',
cursor: 'pointer',
fontSize: '0.8rem',
fontWeight: selectedDays.includes(i) ? 700 : 400,
background: selectedDays.includes(i) ? '#3b82f6' : '#f3f4f6',
color: selectedDays.includes(i) ? '#fff' : '#666',
transition: 'all 0.15s',
}}
>
{dayName}
</button>
))}
</div>
</div>
)}
<div> <div>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '0.9rem', color: '#666' }}>Time</label> <label style={{ display: 'block', marginBottom: '4px', fontSize: '0.9rem', color: '#666' }}>{isDE ? 'Uhrzeit' : 'Time'}</label>
<input <input
type="time" type="time"
value={time} value={time}
@ -104,7 +155,7 @@ export default function RecurrenceModal({ task, onClose, onSave }: RecurrenceMod
</div> </div>
<div> <div>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '0.9rem', color: '#666' }}>End Date (Optional, DD.MM.YYYY)</label> <label style={{ display: 'block', marginBottom: '4px', fontSize: '0.9rem', color: '#666' }}>{isDE ? 'Enddatum (Optional, TT.MM.JJJJ)' : 'End Date (Optional, DD.MM.YYYY)'}</label>
<input <input
type="text" type="text"
placeholder="DD.MM.YYYY" placeholder="DD.MM.YYYY"
@ -147,14 +198,14 @@ export default function RecurrenceModal({ task, onClose, onSave }: RecurrenceMod
onClick={handleSave} onClick={handleSave}
disabled={isSaving} disabled={isSaving}
> >
{isSaving ? 'Saving...' : 'Save'} {isSaving ? (isDE ? 'Speichere...' : 'Saving...') : (isDE ? 'Speichern' : 'Save')}
</button> </button>
<button <button
className="weekly-btn weekly-btn-secondary" className="weekly-btn weekly-btn-secondary"
onClick={onClose} onClick={onClose}
disabled={isSaving} disabled={isSaving}
> >
Cancel {isDE ? 'Abbrechen' : 'Cancel'}
</button> </button>
</div> </div>
</div> </div>

View File

@ -0,0 +1,150 @@
'use client';
import React, { useEffect } from 'react';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Underline from '@tiptap/extension-underline';
import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder';
interface RichTextEditorProps {
value: string;
onChange: (html: string) => void;
placeholder?: string;
minHeight?: string;
}
export default function RichTextEditor({ value, onChange, placeholder = 'Notes...', minHeight = '120px' }: RichTextEditorProps) {
const editor = useEditor({
immediatelyRender: false,
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
}),
Underline,
Link.configure({
openOnClick: false,
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
}),
Placeholder.configure({ placeholder }),
],
content: value,
onUpdate: ({ editor }) => {
// When content is just empty paragraph, return empty string
const html = editor.isEmpty ? '' : editor.getHTML();
onChange(html);
},
editorProps: {
attributes: {
class: 'rte-editor-content',
style: `min-height: ${minHeight}; outline: none;`,
},
},
});
// Sync external value changes (e.g. when editing an existing event)
useEffect(() => {
if (editor && editor.getHTML() !== value && !editor.isFocused) {
editor.commands.setContent(value || '');
}
}, [value, editor]);
if (!editor) return null;
const btn = (active: boolean) => ({
padding: '3px 7px',
border: '1px solid var(--weekly-border, #ddd)',
borderRadius: '4px',
background: active ? 'var(--weekly-accent, #2563eb)' : 'var(--weekly-bg, white)',
color: active ? 'white' : 'var(--weekly-text, #333)',
cursor: 'pointer',
fontSize: '0.78rem',
fontWeight: active ? '700' : '500',
lineHeight: 1,
transition: 'all 0.15s',
} as React.CSSProperties);
const handleLink = () => {
const prev = editor.getAttributes('link').href || '';
const url = window.prompt('Enter URL:', prev);
if (url === null) return;
if (url === '') {
editor.chain().focus().unsetLink().run();
} else {
editor.chain().focus().setLink({ href: url }).run();
}
};
return (
<div style={{
border: '1px solid var(--weekly-border, #ddd)',
borderRadius: '6px',
overflow: 'hidden',
background: 'var(--weekly-bg, white)',
}}>
{/* Toolbar */}
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: '4px',
padding: '6px 8px',
borderBottom: '1px solid var(--weekly-border, #ddd)',
background: 'var(--weekly-settings-item-bg, #f8f8f8)',
}}>
<button type="button" style={btn(editor.isActive('bold'))} onMouseDown={e => { e.preventDefault(); editor.chain().focus().toggleBold().run(); }} title="Bold">
<b>B</b>
</button>
<button type="button" style={btn(editor.isActive('italic'))} onMouseDown={e => { e.preventDefault(); editor.chain().focus().toggleItalic().run(); }} title="Italic">
<i>I</i>
</button>
<button type="button" style={btn(editor.isActive('underline'))} onMouseDown={e => { e.preventDefault(); editor.chain().focus().toggleUnderline().run(); }} title="Underline">
<u>U</u>
</button>
<div style={{ width: 1, background: 'var(--weekly-border, #ddd)', margin: '0 2px' }} />
<button type="button" style={btn(editor.isActive('bulletList'))} onMouseDown={e => { e.preventDefault(); editor.chain().focus().toggleBulletList().run(); }} title="Bullet list">
</button>
<button type="button" style={btn(editor.isActive('orderedList'))} onMouseDown={e => { e.preventDefault(); editor.chain().focus().toggleOrderedList().run(); }} title="Numbered list">
1.
</button>
<div style={{ width: 1, background: 'var(--weekly-border, #ddd)', margin: '0 2px' }} />
<button type="button" style={btn(editor.isActive('blockquote'))} onMouseDown={e => { e.preventDefault(); editor.chain().focus().toggleBlockquote().run(); }} title="Blockquote">
"
</button>
<button type="button" style={btn(editor.isActive('link'))} onMouseDown={e => { e.preventDefault(); handleLink(); }} title="Link">
🔗
</button>
<div style={{ width: 1, background: 'var(--weekly-border, #ddd)', margin: '0 2px' }} />
<button type="button" style={btn(false)} onMouseDown={e => { e.preventDefault(); editor.chain().focus().undo().run(); }} title="Undo">
</button>
<button type="button" style={btn(false)} onMouseDown={e => { e.preventDefault(); editor.chain().focus().redo().run(); }} title="Redo">
</button>
</div>
{/* Editor content */}
<EditorContent editor={editor} style={{ padding: '10px 12px' }} />
<style>{`
.rte-editor-content p { margin: 0 0 0.5em; }
.rte-editor-content p:last-child { margin-bottom: 0; }
.rte-editor-content ul, .rte-editor-content ol { padding-left: 1.4em; margin: 0.3em 0; }
.rte-editor-content blockquote {
border-left: 3px solid var(--weekly-accent, #2563eb);
margin: 0.3em 0;
padding-left: 0.8em;
color: var(--weekly-text-muted, #666);
}
.rte-editor-content a { color: var(--weekly-accent, #2563eb); text-decoration: underline; }
.rte-editor-content .is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: var(--weekly-text-muted, #adb5bd);
pointer-events: none;
height: 0;
}
`}</style>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -113,6 +113,16 @@ export const authOptions: NextAuthOptions = {
if (user) { if (user) {
token.id = user.id; token.id = user.id;
} }
// Refresh user ID from DB to handle stale sessions after DB changes
if (token.email) {
const dbUser = await prisma.user.findUnique({
where: { email: token.email as string },
select: { id: true }
});
if (dbUser) {
token.id = dbUser.id;
}
}
return token; return token;
}, },
async session({ session, token }) { async session({ session, token }) {

View File

@ -74,7 +74,7 @@ export async function readCachedEvents(
isRecurring: row.isRecurring, isRecurring: row.isRecurring,
startTime: row.startDateTime?.toISOString() ?? row.startDate ?? '', startTime: row.startDateTime?.toISOString() ?? row.startDate ?? '',
endTime: row.endDateTime?.toISOString() ?? row.endDate ?? '', endTime: row.endDateTime?.toISOString() ?? row.endDate ?? '',
source: row.provider as 'google' | 'apple' | 'outlook', source: row.provider as 'google' | 'apple' | 'outlook' | 'synology',
calendarId: row.calendarId, calendarId: row.calendarId,
calendarTitle: row.calendarTitle, calendarTitle: row.calendarTitle,
calendarColor: row.calendarColor, calendarColor: row.calendarColor,

View File

@ -1,7 +1,7 @@
// Google Calendar OAuth wrapper
import { GoogleCalendarEvent, getUserCalendars as getGoogleCalendars, getUpcomingEvents as getGoogleEvents, initializeOAuth as initializeGoogleOAuth } from './google-calendar'; import { GoogleCalendarEvent, getUserCalendars as getGoogleCalendars, getUpcomingEvents as getGoogleEvents, initializeOAuth as initializeGoogleOAuth } from './google-calendar';
import { AppleCalendarEvent, getUserCalendars as getAppleCalendars, getUpcomingEvents as getAppleEvents } from './apple-calendar'; import { AppleCalendarEvent, getUserCalendars as getAppleCalendars, getUpcomingEvents as getAppleEvents } from './apple-calendar';
import { getUpcomingEvents as getOutlookEvents, refreshAccessToken as refreshOutlookTokenAPI, createEvent as createOutlookEvent, updateEvent as updateOutlookEvent, deleteEvent as deleteOutlookEvent } from './outlook-calendar'; import { getUpcomingEvents as getOutlookEvents, refreshAccessToken as refreshOutlookTokenAPI, createEvent as createOutlookEvent, updateEvent as updateOutlookEvent, deleteEvent as deleteOutlookEvent } from './outlook-calendar';
import { getUserCalendars as getSynologyCalendars, getUpcomingEvents as getSynologyEvents } from './synology-calendar';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@ -23,7 +23,7 @@ export interface CalendarEvent {
recurrence?: string; recurrence?: string;
recurringEventId?: string; recurringEventId?: string;
isRecurring?: boolean; isRecurring?: boolean;
source: 'google' | 'apple' | 'outlook'; source: 'google' | 'apple' | 'outlook' | 'synology';
calendarId: string; calendarId: string;
calendarTitle: string; calendarTitle: string;
backgroundColor?: string; backgroundColor?: string;
@ -90,7 +90,7 @@ function getGoogleEventColor(colorId: string): string {
export interface CalendarConnection { export interface CalendarConnection {
id: string; id: string;
provider: 'google' | 'apple' | 'outlook'; provider: 'google' | 'apple' | 'outlook' | 'synology';
accessToken: string; accessToken: string;
refreshToken?: string; refreshToken?: string;
expiresAt?: Date; expiresAt?: Date;
@ -372,6 +372,81 @@ export const getCalendarEvents = async (
}; };
})); }));
} }
} else if (connection.provider === 'synology') {
const [username, password] = connection.accessToken.split(':');
const serverUrl = connection.refreshToken;
if (!username || !password || !serverUrl) {
console.error('[CALENDAR] Invalid Synology credentials format');
continue;
}
// Use stored calendar selection (same pattern as Outlook/Google)
let calendarIds: string[] = [];
let calendars: any[] = [];
if (connection.calendars && Array.isArray(connection.calendars) && connection.calendars.length > 0) {
calendars = connection.calendars as any[];
calendarIds = calendars
.filter((c: any) => c.selected !== false)
.map((c: any) => c.id);
console.log('[CALENDAR] Synology: using stored calendars, selected:', calendarIds.length, 'of', calendars.length);
} else {
// Fallback: fetch fresh from server if no stored calendars
console.log('[CALENDAR] Synology: no stored calendars, fetching from server...');
const fresh = await getSynologyCalendars(serverUrl, username, password);
calendars = fresh;
calendarIds = fresh.map(c => c.id);
}
if (calendarIds.length === 0) {
console.log('[CALENDAR] Synology: no calendars selected, skipping');
continue;
}
for (const calendarId of calendarIds) {
try {
const calendarEvents = await getSynologyEvents(
serverUrl,
username,
password,
calendarId,
timeMin,
timeMax
);
console.log(`[CALENDAR] Synology: fetched ${calendarEvents.length} events from calendar ${calendarId}`);
const calendarData = calendars.find(c => c.id === calendarId);
events = events.concat(calendarEvents.map((event: any) => {
const isDateOnly = (s: string) => s && !s.includes('T');
const startIsAllDay = isDateOnly(event.startDate);
return {
id: event.id,
title: event.title,
description: event.description,
start: {
dateTime: startIsAllDay ? undefined : event.startDate,
date: startIsAllDay ? event.startDate : undefined,
},
end: {
dateTime: startIsAllDay ? undefined : event.endDate,
date: startIsAllDay ? event.endDate : undefined,
},
location: event.location,
url: event.url,
recurringEventId: event.recurringEventId,
isRecurring: event.isRecurring,
source: 'synology' as const,
calendarId,
calendarTitle: calendarData?.title || 'Synology Calendar',
backgroundColor: calendarData?.color || '#1b85ff'
};
}));
} catch (calError) {
console.error(`[CALENDAR] Synology: error fetching events from calendar ${calendarId}:`, calError);
}
}
} else if (connection.provider === 'outlook') { } else if (connection.provider === 'outlook') {
console.log('[CALENDAR] Processing Outlook connection:', connection.id); console.log('[CALENDAR] Processing Outlook connection:', connection.id);
@ -643,13 +718,44 @@ export const createCalendarEvent = async (
id: createdEvent.id, id: createdEvent.id,
title: createdEvent.title, title: createdEvent.title,
description: createdEvent.description, description: createdEvent.description,
start: { dateTime: createdEvent.startDate }, // Mapping back might need adjustment if date-only start: { dateTime: createdEvent.startDate },
end: { dateTime: createdEvent.endDate }, end: { dateTime: createdEvent.endDate },
location: createdEvent.location, location: createdEvent.location,
source: 'apple', source: 'apple',
calendarId, calendarId,
calendarTitle: '', // Fetch if needed calendarTitle: '', // Fetch if needed
} as CalendarEvent; } as CalendarEvent;
} else if (connection.provider === 'synology') {
const [username, password] = connection.accessToken.split(':');
const serverUrl = connection.refreshToken;
if (!username || !password || !serverUrl) throw new Error('Invalid Synology credentials');
if (!event.title) throw new Error('Event title is required');
if (!event.start || !event.end) throw new Error('Event start and end times are required');
const createdEvent = await import('./synology-calendar').then(m =>
m.createEvent(serverUrl, username, password, calendarId, {
title: event.title!,
description: event.description,
location: event.location,
url: event.url,
recurrence: event.recurrence,
start: event.start!,
end: event.end!
})
);
return {
id: createdEvent.id,
title: createdEvent.title,
description: createdEvent.description,
start: { dateTime: createdEvent.startDate },
end: { dateTime: createdEvent.endDate },
location: createdEvent.location,
source: 'synology',
calendarId,
calendarTitle: '',
} as CalendarEvent;
} }
throw new Error(`Provider ${connection.provider} does not support creating events yet.`); throw new Error(`Provider ${connection.provider} does not support creating events yet.`);
@ -768,6 +874,35 @@ export const updateCalendarEvent = async (
calendarId, calendarId,
calendarTitle: '', calendarTitle: '',
} as CalendarEvent; } as CalendarEvent;
} else if (connection.provider === 'synology') {
const [username, password] = connection.accessToken.split(':');
const serverUrl = connection.refreshToken;
if (!username || !password || !serverUrl) throw new Error('Invalid Synology credentials');
const updatedEvent = await import('./synology-calendar').then(m =>
m.updateEvent(serverUrl, username, password, calendarId, eventId, {
title: event.title,
description: event.description,
location: event.location,
url: event.url,
start: event.start,
end: event.end
})
);
return {
id: updatedEvent.id,
title: updatedEvent.title,
description: updatedEvent.description,
start: { dateTime: updatedEvent.startDate },
end: { dateTime: updatedEvent.endDate },
location: updatedEvent.location,
url: updatedEvent.url,
source: 'synology',
calendarId,
calendarTitle: '',
} as CalendarEvent;
} }
throw new Error(`Provider ${connection.provider} does not support updating events yet.`); throw new Error(`Provider ${connection.provider} does not support updating events yet.`);
@ -823,6 +958,16 @@ export const deleteCalendarEvent = async (
m.deleteEvent(email, appPassword, calendarId, eventId) m.deleteEvent(email, appPassword, calendarId, eventId)
); );
return; return;
} else if (connection.provider === 'synology') {
const [username, password] = connection.accessToken.split(':');
const serverUrl = connection.refreshToken;
if (!username || !password || !serverUrl) throw new Error('Invalid Synology credentials');
await import('./synology-calendar').then(m =>
m.deleteEvent(serverUrl, username, password, calendarId, eventId)
);
return;
} }
throw new Error(`Provider ${connection.provider} does not support deleting events yet.`); throw new Error(`Provider ${connection.provider} does not support deleting events yet.`);

View File

@ -0,0 +1,548 @@
import { DAVClient } from 'tsdav';
import ICAL from 'ical.js';
export interface SynologyCalendarEvent {
id: string;
title: string;
startDate: string;
endDate: string;
description?: string;
location?: string;
url?: string;
recurringEventId?: string;
isRecurring?: boolean;
}
export interface SynologyCalendar {
id: string;
title: string;
color?: string;
isPrimary?: boolean;
}
/**
* Create a configured DAV client for Synology Calendar
*/
const createClient = (serverUrl: string, username: string, password: string) => {
let url = serverUrl.replace(/\/$/, '');
if (!url.includes('/caldav')) {
url = `${url}/caldav/${username}`;
}
return new DAVClient({
serverUrl: url,
credentials: {
username,
password,
},
authMethod: 'Basic',
defaultAccountType: 'caldav',
});
};
/**
* Validate credentials by attempting to fetch calendars
* @returns List of found calendars if successful
*/
export const validateCredentials = async (serverUrl: string, username: string, password: string): Promise<SynologyCalendar[]> => {
try {
const client = createClient(serverUrl, username, password);
await client.login();
const calendars = await client.fetchCalendars();
// Filter to VEVENT-only calendars — exclude VTODO (Reminders) collections
const eventCalendars = calendars.filter(cal => {
const components: string[] = (cal as any).components || [];
// Keep if components include VEVENT, or if components is empty/undefined
if (components.length === 0) return true;
return components.includes('VEVENT');
});
const mappedCalendars = eventCalendars.map(cal => ({
id: cal.url, // Using URL as ID for CalDAV
title: (cal.displayName as string) || 'Untitled Calendar',
color: cal.calendarColor,
isPrimary: false,
}));
console.log('[SYNOLOGY CALENDAR] Found calendars:', mappedCalendars.length, '(filtered from', calendars.length, 'total)');
return mappedCalendars;
} catch (error) {
console.error('Synology Calendar validation failed:', error);
throw new Error('Invalid credentials or unable to connect to Synology Server.');
}
};
/**
* Get user calendars (wrapper around validateCredentials for now as they do the same)
*/
export const getUserCalendars = validateCredentials;
/**
* Get upcoming events for a specified time period from a specific calendar
*/
export const getUpcomingEvents = async (
serverUrl: string,
username: string,
password: string,
calendarUrl: string,
timeMin: string,
timeMax: string
): Promise<SynologyCalendarEvent[]> => {
try {
const client = createClient(serverUrl, username, password);
await client.login();
const calendars = await client.fetchCalendars();
const getPath = (url: string) => {
try { return new URL(url).pathname; } catch { return url; }
};
const targetPath = getPath(calendarUrl);
const targetCalendar = calendars.find(c => getPath(c.url) === targetPath);
if (!targetCalendar) {
throw new Error(`Calendar not found: ${calendarUrl}`);
}
const events = await client.fetchCalendarObjects({
calendar: targetCalendar,
timeRange: {
start: new Date(timeMin).toISOString(),
end: new Date(timeMax).toISOString(),
},
});
const parsedEvents: SynologyCalendarEvent[] = [];
const minTime = new Date(timeMin).getTime();
const maxTime = new Date(timeMax).getTime();
events.forEach(eventObj => {
const data = (eventObj as any).data;
if (!data) return;
try {
const jcalData = ICAL.parse(data);
const comp = new ICAL.Component(jcalData);
const vevents = comp.getAllSubcomponents('vevent');
// Separate base events (with RRULE) from recurrence exceptions (with RECURRENCE-ID)
const baseEvents: any[] = [];
const exceptions: Map<string, any[]> = new Map();
vevents.forEach((vevent: any) => {
const recurrenceId = vevent.getFirstPropertyValue('recurrence-id');
if (recurrenceId) {
const event = new ICAL.Event(vevent);
const uid = event.uid;
if (!exceptions.has(uid)) exceptions.set(uid, []);
exceptions.get(uid)!.push(vevent);
} else {
baseEvents.push(vevent);
}
});
baseEvents.forEach((vevent: any) => {
const event = new ICAL.Event(vevent);
const rrule = vevent.getFirstPropertyValue('rrule');
if (rrule) {
const uid = event.uid;
const exceptionVevents = exceptions.get(uid) || [];
const exceptionDates = new Set<string>();
const isAllDayRecurring = event.startDate.isDate === true;
exceptionVevents.forEach((exVevent: any) => {
const exEvent = new ICAL.Event(exVevent);
const recId = exVevent.getFirstPropertyValue('recurrence-id');
if (recId) {
exceptionDates.add(recId.toJSDate().toISOString());
}
const exStart = exEvent.startDate.toJSDate();
const exEnd = exEvent.endDate.toJSDate();
const exIsAllDay = exEvent.startDate.isDate === true;
if (exEnd.getTime() >= minTime && exStart.getTime() <= maxTime) {
parsedEvents.push({
id: `caldav::${eventObj.url}::${exEvent.uid}::${exStart.toISOString()}`,
title: exEvent.summary || 'Untitled Event',
startDate: exIsAllDay ? exStart.toISOString().slice(0, 10) : exStart.toISOString(),
endDate: exIsAllDay ? exEnd.toISOString().slice(0, 10) : exEnd.toISOString(),
description: exEvent.description,
location: exEvent.location,
url: exVevent.getFirstPropertyValue('url')?.toString() || undefined,
recurringEventId: exEvent.uid,
isRecurring: true,
});
}
});
try {
const duration = event.endDate.toJSDate().getTime() - event.startDate.toJSDate().getTime();
const iter = event.iterator();
let next;
let safetyCount = 0;
while ((next = iter.next()) && safetyCount < 500) {
safetyCount++;
const occStart = next.toJSDate();
const occEnd = new Date(occStart.getTime() + duration);
if (occStart.getTime() > maxTime) break;
if (occEnd.getTime() < minTime) continue;
if (exceptionDates.has(occStart.toISOString())) continue;
parsedEvents.push({
id: `caldav::${eventObj.url}::${event.uid}::${occStart.toISOString()}`,
title: event.summary || 'Untitled Event',
startDate: isAllDayRecurring ? occStart.toISOString().slice(0, 10) : occStart.toISOString(),
endDate: isAllDayRecurring ? occEnd.toISOString().slice(0, 10) : occEnd.toISOString(),
description: event.description,
location: event.location,
url: vevent.getFirstPropertyValue('url')?.toString() || undefined,
recurringEventId: event.uid,
isRecurring: true,
});
}
} catch (expandErr: any) {
console.error(`[SYNOLOGY CALENDAR] Error expanding recurrence for "${event.summary}":`, expandErr);
}
} else {
const start = event.startDate.toJSDate();
const end = event.endDate.toJSDate();
const isAllDay = event.startDate.isDate === true;
if (end.getTime() < minTime || start.getTime() > maxTime) return;
parsedEvents.push({
id: `caldav::${eventObj.url}::${event.uid || 'unknown'}`,
title: event.summary || 'Untitled Event',
startDate: isAllDay ? start.toISOString().slice(0, 10) : start.toISOString(),
endDate: isAllDay ? end.toISOString().slice(0, 10) : end.toISOString(),
description: event.description,
location: event.location,
url: vevent.getFirstPropertyValue('url')?.toString() || undefined,
});
}
});
} catch (parseErr: any) {
console.error(`[SYNOLOGY CALENDAR] Error parsing event data for calendar ${calendarUrl}:`, parseErr);
}
});
return parsedEvents;
} catch (error: any) {
console.error(`[SYNOLOGY CALENDAR] Error fetching events for ${calendarUrl}:`, error);
return [];
}
};
/**
* Create a new event in the specified calendar
*/
export const createEvent = async (
serverUrl: string,
username: string,
password: string,
calendarUrl: string,
eventData: {
title: string;
description?: string;
location?: string;
url?: string;
recurrence?: string;
start: { dateTime?: string; date?: string };
end: { dateTime?: string; date?: string };
}
): Promise<SynologyCalendarEvent> => {
try {
const client = createClient(serverUrl, username, password);
await client.login();
const calendars = await client.fetchCalendars();
const targetCalendar = calendars.find(c => c.url === calendarUrl);
if (!targetCalendar) {
throw new Error(`Calendar not found: ${calendarUrl}`);
}
const now = new Date();
const uid = crypto.randomUUID();
const dtStamp = now.toISOString().replace(/[-:.]/g, '').substring(0, 15) + 'Z';
let dtStart = '';
let dtEnd = '';
let dtStartParam = '';
let dtEndParam = '';
if (eventData.start.date) {
dtStart = eventData.start.date.replace(/-/g, '');
dtEnd = eventData.end.date ? eventData.end.date.replace(/-/g, '') : dtStart; // Fallback
dtStartParam = ';VALUE=DATE';
dtEndParam = ';VALUE=DATE';
} else if (eventData.start.dateTime) {
dtStart = new Date(eventData.start.dateTime).toISOString().replace(/[-:.]/g, '').substring(0, 15) + 'Z';
dtEnd = eventData.end.dateTime
? new Date(eventData.end.dateTime).toISOString().replace(/[-:.]/g, '').substring(0, 15) + 'Z'
: dtStart;
}
const description = eventData.description ? `DESCRIPTION:${eventData.description.replace(/\n/g, '\\n')}\r\n` : '';
const location = eventData.location ? `LOCATION:${eventData.location.replace(/,/g, '\\,')}\r\n` : '';
const url = eventData.url ? `URL:${eventData.url}\r\n` : '';
let rruleLine = '';
if (eventData.recurrence) {
const rruleMap: Record<string, string> = {
daily: 'RRULE:FREQ=DAILY',
weekly: 'RRULE:FREQ=WEEKLY',
biweekly: 'RRULE:FREQ=WEEKLY;INTERVAL=2',
monthly: 'RRULE:FREQ=MONTHLY',
yearly: 'RRULE:FREQ=YEARLY',
};
if (rruleMap[eventData.recurrence]) {
rruleLine = `${rruleMap[eventData.recurrence]}\r\n`;
}
}
const iCalString = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//My Weekly ToDo List//EN
BEGIN:VEVENT
UID:${uid}
DTSTAMP:${dtStamp}
DTSTART${dtStartParam}:${dtStart}
DTEND${dtEndParam}:${dtEnd}
SUMMARY:${eventData.title}
${description}${location}${url}${rruleLine}END:VEVENT
END:VCALENDAR`;
console.log('[SYNOLOGY CALENDAR] Creating event with iCal:', iCalString);
const filename = `${uid}.ics`;
await client.createCalendarObject({
calendar: targetCalendar,
filename,
iCalString
});
return {
id: `${uid}-${filename}`,
title: eventData.title,
startDate: eventData.start.dateTime || eventData.start.date || '',
endDate: eventData.end.dateTime || eventData.end.date || '',
description: eventData.description,
location: eventData.location,
url: eventData.url
};
} catch (error) {
console.error('[SYNOLOGY CALENDAR] Error creating event:', error);
throw error;
}
};
/**
* Parse the new caldav:: ID format to extract the object URL and UID.
*/
function parseCaldavId(eventId: string): { objectUrl: string; uid: string } | null {
if (!eventId.startsWith('caldav::')) return null;
const parts = eventId.split('::');
if (parts.length >= 3) {
return { objectUrl: parts[1], uid: parts[2] };
}
return null;
}
/**
* Update an existing event
*/
export const updateEvent = async (
serverUrl: string,
username: string,
password: string,
calendarUrl: string,
eventId: string,
eventData: {
title?: string;
description?: string;
location?: string;
url?: string;
start?: { dateTime?: string; date?: string };
end?: { dateTime?: string; date?: string };
}
): Promise<SynologyCalendarEvent> => {
try {
const client = createClient(serverUrl, username, password);
await client.login();
let targetObject: any = null;
const parsed = parseCaldavId(eventId);
if (parsed) {
const objects = await client.fetchCalendarObjects({
calendar: { url: calendarUrl } as any,
objectUrls: [parsed.objectUrl],
});
targetObject = objects?.[0] || null;
if (!targetObject) {
throw new Error('Event not found on server at URL: ' + parsed.objectUrl);
}
} else {
const calendars = await client.fetchCalendars();
const targetCalendar = calendars.find(c => c.url === calendarUrl);
if (!targetCalendar) {
throw new Error(`Calendar not found: ${calendarUrl}`);
}
const uid = eventId.split('-')[0];
const allObjects = await client.fetchCalendarObjects({
calendar: targetCalendar,
});
targetObject = allObjects.find(obj => {
if (obj.data) {
return obj.data.includes(`UID:${uid}`);
}
return obj.url.includes(uid);
});
if (!targetObject) {
throw new Error('Event not found on server');
}
}
const jcal = ICAL.parse(targetObject.data);
const comp = new ICAL.Component(jcal);
const vevent = comp.getFirstSubcomponent('vevent');
if (!vevent) {
throw new Error('No VEVENT found in calendar object');
}
const event = new ICAL.Event(vevent);
if (eventData.title) event.summary = eventData.title;
if (eventData.description) event.description = eventData.description;
if (eventData.location) event.location = eventData.location;
if (eventData.url !== undefined) {
if (eventData.url) {
vevent.updatePropertyWithValue('url', eventData.url);
} else {
vevent.removeProperty('url');
}
}
if (eventData.start) {
if (eventData.start.date) {
event.startDate = ICAL.Time.fromJSDate(new Date(eventData.start.date), true);
event.startDate.isDate = true;
} else if (eventData.start.dateTime) {
event.startDate = ICAL.Time.fromJSDate(new Date(eventData.start.dateTime), true);
event.startDate.isDate = false;
}
}
if (eventData.end) {
if (eventData.end.date) {
event.endDate = ICAL.Time.fromJSDate(new Date(eventData.end.date), true);
event.endDate.isDate = true;
} else if (eventData.end.dateTime) {
event.endDate = ICAL.Time.fromJSDate(new Date(eventData.end.dateTime), true);
event.endDate.isDate = false;
}
}
event.sequence = (event.sequence || 0) + 1;
if (vevent) {
vevent.updatePropertyWithValue('dtstamp', ICAL.Time.now());
}
const updatedIcalString = comp.toString();
console.log('[SYNOLOGY CALENDAR] Updating event with iCal:', updatedIcalString);
await client.updateObject({
url: targetObject.url,
data: updatedIcalString,
etag: targetObject.etag
} as any);
return {
id: eventId,
title: event.summary,
startDate: event.startDate.toString(),
endDate: event.endDate.toString(),
description: event.description,
location: event.location,
url: vevent.getFirstPropertyValue('url')?.toString() || undefined
};
} catch (error) {
console.error('[SYNOLOGY CALENDAR] Error updating event:', error);
throw error;
}
};
/**
* Delete an event
*/
export const deleteEvent = async (
serverUrl: string,
username: string,
password: string,
calendarUrl: string,
eventId: string
): Promise<void> => {
try {
const client = createClient(serverUrl, username, password);
await client.login();
const parsed = parseCaldavId(eventId);
if (parsed) {
await client.deleteObject({
url: parsed.objectUrl,
etag: undefined,
} as any);
console.log('[SYNOLOGY CALENDAR] Event deleted via direct URL (O(1))');
return;
}
const calendars = await client.fetchCalendars();
const targetCalendar = calendars.find(c => c.url === calendarUrl);
if (!targetCalendar) {
throw new Error(`Calendar not found: ${calendarUrl}`);
}
const uid = eventId.split('-')[0];
const allObjects = await client.fetchCalendarObjects({
calendar: targetCalendar,
});
const targetObject = allObjects.find(obj => {
if (obj.data) {
return obj.data.includes(`UID:${uid}`);
}
return obj.url.includes(uid);
});
if (!targetObject) {
console.warn('[SYNOLOGY CALENDAR] Event to delete not found, maybe already deleted?');
return;
}
await client.deleteObject({
url: targetObject.url,
etag: targetObject.etag
} as any);
console.log('[SYNOLOGY CALENDAR] Event deleted via legacy scan');
} catch (error) {
console.error('[SYNOLOGY CALENDAR] Error deleting event:', error);
throw error;
}
};

157
src/lib/synology-tasks.ts Normal file
View File

@ -0,0 +1,157 @@
import { DAVClient } from 'tsdav';
import ICAL from 'ical.js';
export interface SynologyReminderList {
id: string; // URL
title: string;
color?: string;
}
/**
* Create a configured DAV client for Synology Tasks
*/
const createClient = (serverUrl: string, username: string, password: string) => {
let url = serverUrl.replace(/\/$/, '');
if (!url.includes('/caldav')) {
url = `${url}/caldav/${username}`;
}
return new DAVClient({
serverUrl: url,
credentials: {
username,
password,
},
authMethod: 'Basic',
defaultAccountType: 'caldav',
});
};
/**
* Fetch all reminder lists (VTODO collections) from Synology
*/
export const fetchSynologyReminderLists = async (serverUrl: string, username: string, password: string): Promise<SynologyReminderList[]> => {
try {
const client = createClient(serverUrl, username, password);
await client.login();
const calendars = await client.fetchCalendars();
// Filter to VTODO collections (Reminders)
const reminderLists = calendars.filter(cal => {
const components: string[] = (cal as any).components || [];
return components.includes('VTODO');
});
return reminderLists.map(cal => ({
id: cal.url,
title: (cal.displayName as string) || 'Untitled List',
color: (cal as any).calendarColor,
}));
} catch (error) {
console.error('Error fetching Synology reminder lists:', error);
throw error;
}
};
/**
* Create a new reminder list (collection)
*/
export const createSynologyReminderList = async (serverUrl: string, username: string, password: string, title: string): Promise<SynologyReminderList> => {
try {
const client = createClient(serverUrl, username, password);
await client.login();
const calendars = await client.fetchCalendars();
if (calendars.length === 0) {
throw new Error('No existing Synology calendars found to derive base URL');
}
const firstCalUrl = calendars[0].url;
const urlParts = firstCalUrl.split('/');
if (firstCalUrl.endsWith('/')) urlParts.pop();
urlParts.pop();
const baseWebDavUrl = urlParts.join('/') + '/';
const newId = crypto.randomUUID();
const newCalendarUrl = `${baseWebDavUrl}${newId}/`;
await (client as any).makeCalendar({
url: newCalendarUrl,
props: {
'displayname': title,
'supported-calendar-component-set': {
'comp': { _attributes: { name: 'VTODO' } }
}
}
});
return {
id: newCalendarUrl,
title: title,
};
} catch (error) {
console.error('Error creating Synology reminder list:', error);
throw error;
}
};
/**
* Fetch tasks (VTODOs) from a specific Synology list
*/
export const fetchSynologyTasks = async (serverUrl: string, username: string, password: string, listId: string) => {
try {
const client = createClient(serverUrl, username, password);
await client.login();
const calendars = await client.fetchCalendars();
// Match by URL path only (ignoring host/port differences that can arise
// when the stored URL was saved before the port was included in the server URL)
const getPath = (url: string) => {
try { return new URL(url).pathname; } catch { return url; }
};
const targetPath = getPath(listId);
const targetCalendar = calendars.find(c => getPath(c.url) === targetPath);
if (!targetCalendar) {
throw new Error(`List not found: ${listId}`);
}
const objects = await client.fetchCalendarObjects({
calendar: targetCalendar,
});
const parsedTasks: any[] = [];
objects.forEach(obj => {
const data = (obj as any).data;
if (!data) return;
try {
const jcalData = ICAL.parse(data);
const comp = new ICAL.Component(jcalData);
const vtodos = comp.getAllSubcomponents('vtodo');
vtodos.forEach((vtodo: any) => {
const status = vtodo.getFirstPropertyValue('status') || 'NEEDS-ACTION';
const isCompleted = status === 'COMPLETED';
const dueProp = vtodo.getFirstProperty('due');
parsedTasks.push({
id: `synology::${vtodo.getFirstPropertyValue('uid')}`,
title: vtodo.getFirstPropertyValue('summary') || 'Untitled Task',
notes: vtodo.getFirstPropertyValue('description') || '',
status: isCompleted ? 'completed' : 'needsAction',
due: dueProp ? dueProp.getFirstValue().toJSDate() : null,
});
});
} catch (err) {
console.error('[SYNOLOGY TASKS] Error parsing VTODO:', err);
}
});
return parsedTasks;
} catch (error) {
console.error('[SYNOLOGY TASKS] Error fetching tasks:', error);
throw error;
}
};

25
test-syno.js Normal file
View File

@ -0,0 +1,25 @@
const { DAVClient } = require('tsdav');
async function test() {
const client = new DAVClient({
serverUrl: 'https://ds.martin-bierschenk.de:91/caldav/martin',
credentials: {
username: 'martin',
password: 'DS916-MB-ImC.2019',
},
authMethod: 'Basic',
defaultAccountType: 'caldav',
});
await client.login();
console.log("Logged in:", client.account);
const calendars = await client.fetchCalendars();
console.log('Calendars count:', calendars.length);
calendars.forEach((c, idx) => {
console.log(`\nCalendar ${idx + 1}:`, c.displayName);
console.log('url:', c.url);
console.log('components:', c.components);
});
}
test().catch(console.error);

File diff suppressed because one or more lines are too long