Compare commits
13 Commits
88d47dc4e0
...
main
Author | SHA1 | Date | |
---|---|---|---|
8bb0c56d01 | |||
a15956a960 | |||
dfa54ae883 | |||
edd1478aa5 | |||
82c88243b4 | |||
7b9a1eeb70 | |||
74d5037f09 | |||
b7e8d1c921 | |||
ddb3c682f5 | |||
7d55a1369d | |||
eaf4d6b085 | |||
3608750616 | |||
53e2b15351 |
4
.gitignore
vendored
@@ -136,3 +136,7 @@ dist
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
|
||||
|
||||
# server files
|
||||
server/database.sqlite
|
@@ -15,14 +15,22 @@
|
||||
"build:unpack": "npm run build && electron-builder --dir",
|
||||
"build:win": "npm run build && electron-builder --win",
|
||||
"build:mac": "npm run build && electron-builder --mac",
|
||||
"build:linux": "npm run build && electron-builder --linux"
|
||||
"build:linux": "npm run build && electron-builder --linux",
|
||||
"fullscreen": "KIOSK=false npm run start",
|
||||
"kiosk": "KIOSK=true npm run start",
|
||||
"electron": "electron",
|
||||
"electron:kiosk": "electron . --kiosk",
|
||||
"electron:fullscreen": "electron .",
|
||||
"test:fullscreen": "node test-fullscreen.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.1",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router-dom": "^7.7.0",
|
||||
"sass": "^1.89.2"
|
||||
"robotjs": "^0.6.0",
|
||||
"sass": "^1.89.2",
|
||||
"tesseract.js": "^6.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/eslint-config": "^2.0.0",
|
||||
|
344
dashboard/pnpm-lock.yaml
generated
@@ -20,9 +20,15 @@ importers:
|
||||
react-router-dom:
|
||||
specifier: ^7.7.0
|
||||
version: 7.7.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
robotjs:
|
||||
specifier: ^0.6.0
|
||||
version: 0.6.0
|
||||
sass:
|
||||
specifier: ^1.89.2
|
||||
version: 1.89.2
|
||||
tesseract.js:
|
||||
specifier: ^6.0.1
|
||||
version: 6.0.1(encoding@0.1.13)
|
||||
devDependencies:
|
||||
'@electron-toolkit/eslint-config':
|
||||
specifier: ^2.0.0
|
||||
@@ -782,6 +788,10 @@ packages:
|
||||
ajv@6.12.6:
|
||||
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
|
||||
|
||||
ansi-regex@2.1.1:
|
||||
resolution: {integrity: sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
ansi-regex@5.0.1:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -808,6 +818,9 @@ packages:
|
||||
dmg-builder: 25.1.8
|
||||
electron-builder-squirrel-windows: 25.1.8
|
||||
|
||||
aproba@1.2.0:
|
||||
resolution: {integrity: sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==}
|
||||
|
||||
aproba@2.1.0:
|
||||
resolution: {integrity: sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==}
|
||||
|
||||
@@ -823,6 +836,10 @@ packages:
|
||||
resolution: {integrity: sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
are-we-there-yet@1.1.7:
|
||||
resolution: {integrity: sha512-nxwy40TuMiUGqMyRHgCSWZ9FM4VAoRP4xUYSTv5ImRog+h9yISPbVH7H8fASCIzYn9wlEv4zvFL7uKDMCFQm3g==}
|
||||
deprecated: This package is no longer supported.
|
||||
|
||||
are-we-there-yet@3.0.1:
|
||||
resolution: {integrity: sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==}
|
||||
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
|
||||
@@ -904,6 +921,9 @@ packages:
|
||||
bluebird@3.7.2:
|
||||
resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==}
|
||||
|
||||
bmp-js@0.1.0:
|
||||
resolution: {integrity: sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==}
|
||||
|
||||
boolean@3.2.0:
|
||||
resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==}
|
||||
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
|
||||
@@ -982,6 +1002,9 @@ packages:
|
||||
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
|
||||
engines: {node: '>= 14.16.0'}
|
||||
|
||||
chownr@1.1.4:
|
||||
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
|
||||
|
||||
chownr@2.0.0:
|
||||
resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1020,6 +1043,10 @@ packages:
|
||||
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
code-point-at@1.1.0:
|
||||
resolution: {integrity: sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
@@ -1106,10 +1133,18 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
decompress-response@4.2.1:
|
||||
resolution: {integrity: sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
decompress-response@6.0.0:
|
||||
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
deep-extend@0.6.0:
|
||||
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
|
||||
deep-is@0.1.4:
|
||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||
|
||||
@@ -1359,6 +1394,10 @@ packages:
|
||||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
expand-template@2.0.3:
|
||||
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
exponential-backoff@3.1.2:
|
||||
resolution: {integrity: sha512-8QxYTVXUkuy7fIIoitQkPwGonB8F3Zj8eEO8Sqg9Zv/bkI7RJAzowee4gr81Hak/dUTpA2Z7VfQgoijjPNlUZA==}
|
||||
|
||||
@@ -1469,6 +1508,10 @@ packages:
|
||||
functions-have-names@1.2.3:
|
||||
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
|
||||
|
||||
gauge@2.7.4:
|
||||
resolution: {integrity: sha512-14x4kjc6lkD3ltw589k0NrPD6cCNTD6CWoVUNpB85+DrtONoZn+Rug6xZU5RvSC4+TZPxA5AnBibQYAvZn41Hg==}
|
||||
deprecated: This package is no longer supported.
|
||||
|
||||
gauge@4.0.4:
|
||||
resolution: {integrity: sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==}
|
||||
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
|
||||
@@ -1498,6 +1541,9 @@ packages:
|
||||
resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
github-from-package@0.0.0:
|
||||
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
|
||||
|
||||
glob-parent@6.0.2:
|
||||
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
@@ -1611,6 +1657,9 @@ packages:
|
||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
idb-keyval@6.2.2:
|
||||
resolution: {integrity: sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==}
|
||||
|
||||
ieee754@1.2.1:
|
||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||
|
||||
@@ -1643,6 +1692,9 @@ packages:
|
||||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
ini@1.3.8:
|
||||
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
|
||||
|
||||
internal-slot@1.1.0:
|
||||
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1695,6 +1747,10 @@ packages:
|
||||
resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
is-fullwidth-code-point@1.0.0:
|
||||
resolution: {integrity: sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
is-fullwidth-code-point@3.0.0:
|
||||
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1758,6 +1814,9 @@ packages:
|
||||
resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
is-url@1.2.4:
|
||||
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
|
||||
|
||||
is-weakmap@2.0.2:
|
||||
resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1946,6 +2005,10 @@ packages:
|
||||
resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
mimic-response@2.1.0:
|
||||
resolution: {integrity: sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
mimic-response@3.1.0:
|
||||
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2004,6 +2067,9 @@ packages:
|
||||
resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
mkdirp-classic@0.5.3:
|
||||
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
|
||||
|
||||
mkdirp@1.0.4:
|
||||
resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2012,11 +2078,17 @@ packages:
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
nan@2.23.0:
|
||||
resolution: {integrity: sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==}
|
||||
|
||||
nanoid@3.3.11:
|
||||
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
napi-build-utils@1.0.2:
|
||||
resolution: {integrity: sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==}
|
||||
|
||||
natural-compare@1.4.0:
|
||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||
|
||||
@@ -2024,6 +2096,9 @@ packages:
|
||||
resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
node-abi@2.30.1:
|
||||
resolution: {integrity: sha512-/2D0wOQPgaUWzVSVgRMx+trKJRC2UG4SUc4oCJoXx9Uxjtp0Vy3/kt7zcbxHF8+Z/pK3UloLWzBISg72brfy1w==}
|
||||
|
||||
node-abi@3.75.0:
|
||||
resolution: {integrity: sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2037,6 +2112,15 @@ packages:
|
||||
node-api-version@0.2.1:
|
||||
resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==}
|
||||
|
||||
node-fetch@2.7.0:
|
||||
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
|
||||
engines: {node: 4.x || >=6.0.0}
|
||||
peerDependencies:
|
||||
encoding: ^0.1.0
|
||||
peerDependenciesMeta:
|
||||
encoding:
|
||||
optional: true
|
||||
|
||||
node-gyp@9.4.1:
|
||||
resolution: {integrity: sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==}
|
||||
engines: {node: ^12.13 || ^14.13 || >=16}
|
||||
@@ -2045,6 +2129,9 @@ packages:
|
||||
node-releases@2.0.19:
|
||||
resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==}
|
||||
|
||||
noop-logger@0.1.1:
|
||||
resolution: {integrity: sha512-6kM8CLXvuW5crTxsAtva2YLrRrDaiTIkIePWs9moLHqbFWT94WpNFjwS/5dfLfECg5i/lkmw3aoqVidxt23TEQ==}
|
||||
|
||||
nopt@6.0.0:
|
||||
resolution: {integrity: sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==}
|
||||
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
|
||||
@@ -2058,11 +2145,19 @@ packages:
|
||||
resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
npmlog@4.1.2:
|
||||
resolution: {integrity: sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==}
|
||||
deprecated: This package is no longer supported.
|
||||
|
||||
npmlog@6.0.2:
|
||||
resolution: {integrity: sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==}
|
||||
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
|
||||
deprecated: This package is no longer supported.
|
||||
|
||||
number-is-nan@1.0.1:
|
||||
resolution: {integrity: sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -2098,6 +2193,10 @@ packages:
|
||||
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
opencollective-postinstall@2.0.3:
|
||||
resolution: {integrity: sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==}
|
||||
hasBin: true
|
||||
|
||||
optionator@0.9.4:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -2182,6 +2281,11 @@ packages:
|
||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
prebuild-install@5.3.6:
|
||||
resolution: {integrity: sha512-s8Aai8++QQGi4sSbs/M1Qku62PFK49Jm1CbgXklGz4nmHveDq0wzJkg7Na5QbnO1uNH8K7iqx2EQ/mV0MZEmOg==}
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
prelude-ls@1.2.1:
|
||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -2228,6 +2332,10 @@ packages:
|
||||
resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
rc@1.2.8:
|
||||
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
|
||||
hasBin: true
|
||||
|
||||
react-dom@19.1.0:
|
||||
resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==}
|
||||
peerDependencies:
|
||||
@@ -2288,6 +2396,9 @@ packages:
|
||||
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
regenerator-runtime@0.13.11:
|
||||
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
|
||||
|
||||
regexp.prototype.flags@1.5.4:
|
||||
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2331,6 +2442,9 @@ packages:
|
||||
resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==}
|
||||
engines: {node: '>=8.0'}
|
||||
|
||||
robotjs@0.6.0:
|
||||
resolution: {integrity: sha512-6pRWI3d+CBZqCXT/rsJfabbZoELua+jTeXilG27F8Jvix/J2BYZ0O7Tly2WCmXyqw5xYdCvOwvCeLRHEtXkt4w==}
|
||||
|
||||
rollup@4.45.1:
|
||||
resolution: {integrity: sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw==}
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
@@ -2374,6 +2488,10 @@ packages:
|
||||
semver-compare@1.0.0:
|
||||
resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==}
|
||||
|
||||
semver@5.7.2:
|
||||
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
|
||||
hasBin: true
|
||||
|
||||
semver@6.3.1:
|
||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||
hasBin: true
|
||||
@@ -2436,6 +2554,12 @@ packages:
|
||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
simple-concat@1.0.1:
|
||||
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
|
||||
|
||||
simple-get@3.1.1:
|
||||
resolution: {integrity: sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==}
|
||||
|
||||
simple-update-notifier@2.0.0:
|
||||
resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2482,6 +2606,10 @@ packages:
|
||||
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
string-width@1.0.2:
|
||||
resolution: {integrity: sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
string-width@4.2.3:
|
||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -2515,6 +2643,10 @@ packages:
|
||||
string_decoder@1.3.0:
|
||||
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||
|
||||
strip-ansi@3.0.1:
|
||||
resolution: {integrity: sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
strip-ansi@6.0.1:
|
||||
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -2523,6 +2655,10 @@ packages:
|
||||
resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
strip-json-comments@2.0.1:
|
||||
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
strip-json-comments@3.1.1:
|
||||
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -2543,6 +2679,9 @@ packages:
|
||||
resolution: {integrity: sha512-+XZ+r1XGIJGeQk3VvXhT6xx/VpbHsRzsTkGgF6E5RX9TTXD0118l87puaEBZ566FhqblC6U0d4XnubznJDm30A==}
|
||||
engines: {node: ^14.18.0 || >=16.0.0}
|
||||
|
||||
tar-fs@2.1.3:
|
||||
resolution: {integrity: sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==}
|
||||
|
||||
tar-stream@2.2.0:
|
||||
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -2554,6 +2693,12 @@ packages:
|
||||
temp-file@3.4.0:
|
||||
resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==}
|
||||
|
||||
tesseract.js-core@6.0.0:
|
||||
resolution: {integrity: sha512-1Qncm/9oKM7xgrQXZXNB+NRh19qiXGhxlrR8EwFbK5SaUbPZnS5OMtP/ghtqfd23hsr1ZvZbZjeuAGcMxd/ooA==}
|
||||
|
||||
tesseract.js@6.0.1:
|
||||
resolution: {integrity: sha512-/sPvMvrCtgxnNRCjbTYbr7BRu0yfWDsMZQ2a/T5aN/L1t8wUQN6tTWv6p6FwzpoEBA0jrN2UD2SX4QQFRdoDbA==}
|
||||
|
||||
tinyglobby@0.2.14:
|
||||
resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -2569,9 +2714,15 @@ packages:
|
||||
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||
engines: {node: '>=8.0'}
|
||||
|
||||
tr46@0.0.3:
|
||||
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
|
||||
|
||||
truncate-utf8-bytes@1.0.2:
|
||||
resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==}
|
||||
|
||||
tunnel-agent@0.6.0:
|
||||
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
|
||||
|
||||
type-check@0.4.0:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -2686,9 +2837,18 @@ packages:
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
wasm-feature-detect@1.8.0:
|
||||
resolution: {integrity: sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==}
|
||||
|
||||
wcwidth@1.0.1:
|
||||
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
|
||||
|
||||
webidl-conversions@3.0.1:
|
||||
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
||||
|
||||
whatwg-url@5.0.0:
|
||||
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
||||
|
||||
which-boxed-primitive@1.1.1:
|
||||
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2701,6 +2861,10 @@ packages:
|
||||
resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
which-pm-runs@1.1.0:
|
||||
resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
which-typed-array@1.1.19:
|
||||
resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2761,6 +2925,9 @@ packages:
|
||||
resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
zlibjs@0.3.1:
|
||||
resolution: {integrity: sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==}
|
||||
|
||||
snapshots:
|
||||
|
||||
7zip-bin@5.2.0: {}
|
||||
@@ -3435,6 +3602,8 @@ snapshots:
|
||||
json-schema-traverse: 0.4.1
|
||||
uri-js: 4.4.1
|
||||
|
||||
ansi-regex@2.1.1: {}
|
||||
|
||||
ansi-regex@5.0.1: {}
|
||||
|
||||
ansi-regex@6.1.0: {}
|
||||
@@ -3487,6 +3656,8 @@ snapshots:
|
||||
- bluebird
|
||||
- supports-color
|
||||
|
||||
aproba@1.2.0: {}
|
||||
|
||||
aproba@2.1.0: {}
|
||||
|
||||
archiver-utils@2.1.0:
|
||||
@@ -3525,6 +3696,11 @@ snapshots:
|
||||
tar-stream: 2.2.0
|
||||
zip-stream: 4.1.1
|
||||
|
||||
are-we-there-yet@1.1.7:
|
||||
dependencies:
|
||||
delegates: 1.0.0
|
||||
readable-stream: 2.3.8
|
||||
|
||||
are-we-there-yet@3.0.1:
|
||||
dependencies:
|
||||
delegates: 1.0.0
|
||||
@@ -3625,6 +3801,8 @@ snapshots:
|
||||
|
||||
bluebird@3.7.2: {}
|
||||
|
||||
bmp-js@0.1.0: {}
|
||||
|
||||
boolean@3.2.0:
|
||||
optional: true
|
||||
|
||||
@@ -3753,6 +3931,8 @@ snapshots:
|
||||
dependencies:
|
||||
readdirp: 4.1.2
|
||||
|
||||
chownr@1.1.4: {}
|
||||
|
||||
chownr@2.0.0: {}
|
||||
|
||||
chromium-pickle-js@0.2.0: {}
|
||||
@@ -3785,6 +3965,8 @@ snapshots:
|
||||
|
||||
clone@1.0.4: {}
|
||||
|
||||
code-point-at@1.1.0: {}
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
@@ -3866,10 +4048,16 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
decompress-response@4.2.1:
|
||||
dependencies:
|
||||
mimic-response: 2.1.0
|
||||
|
||||
decompress-response@6.0.0:
|
||||
dependencies:
|
||||
mimic-response: 3.1.0
|
||||
|
||||
deep-extend@0.6.0: {}
|
||||
|
||||
deep-is@0.1.4: {}
|
||||
|
||||
defaults@1.0.4:
|
||||
@@ -3894,8 +4082,7 @@ snapshots:
|
||||
|
||||
delegates@1.0.0: {}
|
||||
|
||||
detect-libc@1.0.3:
|
||||
optional: true
|
||||
detect-libc@1.0.3: {}
|
||||
|
||||
detect-libc@2.0.4: {}
|
||||
|
||||
@@ -4282,6 +4469,8 @@ snapshots:
|
||||
|
||||
esutils@2.0.3: {}
|
||||
|
||||
expand-template@2.0.3: {}
|
||||
|
||||
exponential-backoff@3.1.2: {}
|
||||
|
||||
extract-zip@2.0.1:
|
||||
@@ -4404,6 +4593,17 @@ snapshots:
|
||||
|
||||
functions-have-names@1.2.3: {}
|
||||
|
||||
gauge@2.7.4:
|
||||
dependencies:
|
||||
aproba: 1.2.0
|
||||
console-control-strings: 1.1.0
|
||||
has-unicode: 2.0.1
|
||||
object-assign: 4.1.1
|
||||
signal-exit: 3.0.7
|
||||
string-width: 1.0.2
|
||||
strip-ansi: 3.0.1
|
||||
wide-align: 1.1.5
|
||||
|
||||
gauge@4.0.4:
|
||||
dependencies:
|
||||
aproba: 2.1.0
|
||||
@@ -4447,6 +4647,8 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.3.0
|
||||
|
||||
github-from-package@0.0.0: {}
|
||||
|
||||
glob-parent@6.0.2:
|
||||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
@@ -4592,6 +4794,8 @@ snapshots:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
idb-keyval@6.2.2: {}
|
||||
|
||||
ieee754@1.2.1: {}
|
||||
|
||||
ignore@5.3.2: {}
|
||||
@@ -4616,6 +4820,8 @@ snapshots:
|
||||
|
||||
inherits@2.0.4: {}
|
||||
|
||||
ini@1.3.8: {}
|
||||
|
||||
internal-slot@1.1.0:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
@@ -4677,6 +4883,10 @@ snapshots:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
|
||||
is-fullwidth-code-point@1.0.0:
|
||||
dependencies:
|
||||
number-is-nan: 1.0.1
|
||||
|
||||
is-fullwidth-code-point@3.0.0: {}
|
||||
|
||||
is-generator-function@1.1.0:
|
||||
@@ -4736,6 +4946,8 @@ snapshots:
|
||||
|
||||
is-unicode-supported@0.1.0: {}
|
||||
|
||||
is-url@1.2.4: {}
|
||||
|
||||
is-weakmap@2.0.2: {}
|
||||
|
||||
is-weakref@1.1.1:
|
||||
@@ -4924,6 +5136,8 @@ snapshots:
|
||||
|
||||
mimic-response@1.0.1: {}
|
||||
|
||||
mimic-response@2.1.0: {}
|
||||
|
||||
mimic-response@3.1.0: {}
|
||||
|
||||
minimatch@10.0.3:
|
||||
@@ -4981,16 +5195,26 @@ snapshots:
|
||||
minipass: 3.3.6
|
||||
yallist: 4.0.0
|
||||
|
||||
mkdirp-classic@0.5.3: {}
|
||||
|
||||
mkdirp@1.0.4: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
nan@2.23.0: {}
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
napi-build-utils@1.0.2: {}
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
|
||||
negotiator@0.6.4: {}
|
||||
|
||||
node-abi@2.30.1:
|
||||
dependencies:
|
||||
semver: 5.7.2
|
||||
|
||||
node-abi@3.75.0:
|
||||
dependencies:
|
||||
semver: 7.7.2
|
||||
@@ -5005,6 +5229,12 @@ snapshots:
|
||||
dependencies:
|
||||
semver: 7.7.2
|
||||
|
||||
node-fetch@2.7.0(encoding@0.1.13):
|
||||
dependencies:
|
||||
whatwg-url: 5.0.0
|
||||
optionalDependencies:
|
||||
encoding: 0.1.13
|
||||
|
||||
node-gyp@9.4.1:
|
||||
dependencies:
|
||||
env-paths: 2.2.1
|
||||
@@ -5024,6 +5254,8 @@ snapshots:
|
||||
|
||||
node-releases@2.0.19: {}
|
||||
|
||||
noop-logger@0.1.1: {}
|
||||
|
||||
nopt@6.0.0:
|
||||
dependencies:
|
||||
abbrev: 1.1.1
|
||||
@@ -5032,6 +5264,13 @@ snapshots:
|
||||
|
||||
normalize-url@6.1.0: {}
|
||||
|
||||
npmlog@4.1.2:
|
||||
dependencies:
|
||||
are-we-there-yet: 1.1.7
|
||||
console-control-strings: 1.1.0
|
||||
gauge: 2.7.4
|
||||
set-blocking: 2.0.0
|
||||
|
||||
npmlog@6.0.2:
|
||||
dependencies:
|
||||
are-we-there-yet: 3.0.1
|
||||
@@ -5039,6 +5278,8 @@ snapshots:
|
||||
gauge: 4.0.4
|
||||
set-blocking: 2.0.0
|
||||
|
||||
number-is-nan@1.0.1: {}
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
@@ -5083,6 +5324,8 @@ snapshots:
|
||||
dependencies:
|
||||
mimic-fn: 2.1.0
|
||||
|
||||
opencollective-postinstall@2.0.3: {}
|
||||
|
||||
optionator@0.9.4:
|
||||
dependencies:
|
||||
deep-is: 0.1.4
|
||||
@@ -5168,6 +5411,24 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
prebuild-install@5.3.6:
|
||||
dependencies:
|
||||
detect-libc: 1.0.3
|
||||
expand-template: 2.0.3
|
||||
github-from-package: 0.0.0
|
||||
minimist: 1.2.8
|
||||
mkdirp-classic: 0.5.3
|
||||
napi-build-utils: 1.0.2
|
||||
node-abi: 2.30.1
|
||||
noop-logger: 0.1.1
|
||||
npmlog: 4.1.2
|
||||
pump: 3.0.3
|
||||
rc: 1.2.8
|
||||
simple-get: 3.1.1
|
||||
tar-fs: 2.1.3
|
||||
tunnel-agent: 0.6.0
|
||||
which-pm-runs: 1.1.0
|
||||
|
||||
prelude-ls@1.2.1: {}
|
||||
|
||||
prettier-linter-helpers@1.0.0:
|
||||
@@ -5202,6 +5463,13 @@ snapshots:
|
||||
|
||||
quick-lru@5.1.1: {}
|
||||
|
||||
rc@1.2.8:
|
||||
dependencies:
|
||||
deep-extend: 0.6.0
|
||||
ini: 1.3.8
|
||||
minimist: 1.2.8
|
||||
strip-json-comments: 2.0.1
|
||||
|
||||
react-dom@19.1.0(react@19.1.0):
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
@@ -5270,6 +5538,8 @@ snapshots:
|
||||
get-proto: 1.0.1
|
||||
which-builtin-type: 1.2.1
|
||||
|
||||
regenerator-runtime@0.13.11: {}
|
||||
|
||||
regexp.prototype.flags@1.5.4:
|
||||
dependencies:
|
||||
call-bind: 1.0.8
|
||||
@@ -5320,6 +5590,12 @@ snapshots:
|
||||
sprintf-js: 1.1.3
|
||||
optional: true
|
||||
|
||||
robotjs@0.6.0:
|
||||
dependencies:
|
||||
nan: 2.23.0
|
||||
node-abi: 2.30.1
|
||||
prebuild-install: 5.3.6
|
||||
|
||||
rollup@4.45.1:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
@@ -5390,6 +5666,8 @@ snapshots:
|
||||
semver-compare@1.0.0:
|
||||
optional: true
|
||||
|
||||
semver@5.7.2: {}
|
||||
|
||||
semver@6.3.1: {}
|
||||
|
||||
semver@7.7.2: {}
|
||||
@@ -5463,6 +5741,14 @@ snapshots:
|
||||
|
||||
signal-exit@4.1.0: {}
|
||||
|
||||
simple-concat@1.0.1: {}
|
||||
|
||||
simple-get@3.1.1:
|
||||
dependencies:
|
||||
decompress-response: 4.2.1
|
||||
once: 1.4.0
|
||||
simple-concat: 1.0.1
|
||||
|
||||
simple-update-notifier@2.0.0:
|
||||
dependencies:
|
||||
semver: 7.7.2
|
||||
@@ -5511,6 +5797,12 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
internal-slot: 1.1.0
|
||||
|
||||
string-width@1.0.2:
|
||||
dependencies:
|
||||
code-point-at: 1.1.0
|
||||
is-fullwidth-code-point: 1.0.0
|
||||
strip-ansi: 3.0.1
|
||||
|
||||
string-width@4.2.3:
|
||||
dependencies:
|
||||
emoji-regex: 8.0.0
|
||||
@@ -5575,6 +5867,10 @@ snapshots:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
strip-ansi@3.0.1:
|
||||
dependencies:
|
||||
ansi-regex: 2.1.1
|
||||
|
||||
strip-ansi@6.0.1:
|
||||
dependencies:
|
||||
ansi-regex: 5.0.1
|
||||
@@ -5583,6 +5879,8 @@ snapshots:
|
||||
dependencies:
|
||||
ansi-regex: 6.1.0
|
||||
|
||||
strip-json-comments@2.0.1: {}
|
||||
|
||||
strip-json-comments@3.1.1: {}
|
||||
|
||||
sumchecker@3.0.1:
|
||||
@@ -5601,6 +5899,13 @@ snapshots:
|
||||
dependencies:
|
||||
'@pkgr/core': 0.2.7
|
||||
|
||||
tar-fs@2.1.3:
|
||||
dependencies:
|
||||
chownr: 1.1.4
|
||||
mkdirp-classic: 0.5.3
|
||||
pump: 3.0.3
|
||||
tar-stream: 2.2.0
|
||||
|
||||
tar-stream@2.2.0:
|
||||
dependencies:
|
||||
bl: 4.1.0
|
||||
@@ -5623,6 +5928,22 @@ snapshots:
|
||||
async-exit-hook: 2.0.1
|
||||
fs-extra: 10.1.0
|
||||
|
||||
tesseract.js-core@6.0.0: {}
|
||||
|
||||
tesseract.js@6.0.1(encoding@0.1.13):
|
||||
dependencies:
|
||||
bmp-js: 0.1.0
|
||||
idb-keyval: 6.2.2
|
||||
is-url: 1.2.4
|
||||
node-fetch: 2.7.0(encoding@0.1.13)
|
||||
opencollective-postinstall: 2.0.3
|
||||
regenerator-runtime: 0.13.11
|
||||
tesseract.js-core: 6.0.0
|
||||
wasm-feature-detect: 1.8.0
|
||||
zlibjs: 0.3.1
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
|
||||
tinyglobby@0.2.14:
|
||||
dependencies:
|
||||
fdir: 6.4.6(picomatch@4.0.3)
|
||||
@@ -5639,10 +5960,16 @@ snapshots:
|
||||
is-number: 7.0.0
|
||||
optional: true
|
||||
|
||||
tr46@0.0.3: {}
|
||||
|
||||
truncate-utf8-bytes@1.0.2:
|
||||
dependencies:
|
||||
utf8-byte-length: 1.0.5
|
||||
|
||||
tunnel-agent@0.6.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
@@ -5742,10 +6069,19 @@ snapshots:
|
||||
fsevents: 2.3.3
|
||||
sass: 1.89.2
|
||||
|
||||
wasm-feature-detect@1.8.0: {}
|
||||
|
||||
wcwidth@1.0.1:
|
||||
dependencies:
|
||||
defaults: 1.0.4
|
||||
|
||||
webidl-conversions@3.0.1: {}
|
||||
|
||||
whatwg-url@5.0.0:
|
||||
dependencies:
|
||||
tr46: 0.0.3
|
||||
webidl-conversions: 3.0.1
|
||||
|
||||
which-boxed-primitive@1.1.1:
|
||||
dependencies:
|
||||
is-bigint: 1.1.0
|
||||
@@ -5777,6 +6113,8 @@ snapshots:
|
||||
is-weakmap: 2.0.2
|
||||
is-weakset: 2.0.4
|
||||
|
||||
which-pm-runs@1.1.0: {}
|
||||
|
||||
which-typed-array@1.1.19:
|
||||
dependencies:
|
||||
available-typed-arrays: 1.0.7
|
||||
@@ -5843,3 +6181,5 @@ snapshots:
|
||||
archiver-utils: 3.0.4
|
||||
compress-commons: 4.1.2
|
||||
readable-stream: 3.6.2
|
||||
|
||||
zlibjs@0.3.1: {}
|
||||
|
@@ -1,23 +1,84 @@
|
||||
import { app, shell, BrowserWindow, ipcMain } from 'electron'
|
||||
import { app, shell, BrowserWindow, ipcMain, screen } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||
import icon from '../../resources/icon.png?asset'
|
||||
|
||||
let mainWindow
|
||||
|
||||
const createWindow = () => {
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 900,
|
||||
height: 670,
|
||||
// Get the primary display dimensions
|
||||
const primaryDisplay = screen.getPrimaryDisplay()
|
||||
const { width, height } = primaryDisplay.workAreaSize
|
||||
|
||||
// Check for kiosk mode - enable if in production, has kiosk flag, or KIOSK env var
|
||||
const isKioskMode = process.env.NODE_ENV === 'production' ||
|
||||
process.argv.includes('--kiosk') ||
|
||||
process.env.KIOSK === 'true'
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: width,
|
||||
height: height,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
...(process.platform === 'linux' ? { icon } : {}),
|
||||
fullscreen: true,
|
||||
kiosk: isKioskMode,
|
||||
frame: false, // Remove window frame for true fullscreen experience
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false
|
||||
sandbox: false,
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
webSecurity: false // Allow loading external resources
|
||||
},
|
||||
...(process.platform === 'linux' ? { icon } : {})
|
||||
})
|
||||
|
||||
// Disable zoom and context menu for touch interface
|
||||
mainWindow.webContents.on('before-input-event', (event, input) => {
|
||||
// Prevent zooming with Ctrl++ and Ctrl+-
|
||||
if (input.control && (input.key === '+' || input.key === '-' || input.key === '=' || input.key === '0')) {
|
||||
event.preventDefault()
|
||||
}
|
||||
// Prevent F11 fullscreen toggle in kiosk mode
|
||||
if (isKioskMode && input.key === 'F11') {
|
||||
event.preventDefault()
|
||||
}
|
||||
})
|
||||
|
||||
// Disable right-click context menu
|
||||
mainWindow.webContents.on('context-menu', (event) => {
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
mainWindow.on('ready-to-show', () => {
|
||||
mainWindow.show()
|
||||
// Ensure fullscreen mode is enabled
|
||||
if (!mainWindow.isFullScreen()) {
|
||||
mainWindow.setFullScreen(true)
|
||||
}
|
||||
// Focus the window
|
||||
mainWindow.focus()
|
||||
})
|
||||
|
||||
// Prevent accidentally leaving fullscreen in kiosk mode
|
||||
mainWindow.on('leave-full-screen', () => {
|
||||
if (isKioskMode) {
|
||||
mainWindow.setFullScreen(true)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle window focus events to maintain fullscreen in kiosk mode
|
||||
mainWindow.on('focus', () => {
|
||||
if (isKioskMode && !mainWindow.isFullScreen()) {
|
||||
mainWindow.setFullScreen(true)
|
||||
}
|
||||
})
|
||||
|
||||
// Prevent window from being minimized in kiosk mode
|
||||
mainWindow.on('minimize', () => {
|
||||
if (isKioskMode) {
|
||||
mainWindow.restore()
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
@@ -32,6 +93,35 @@ const createWindow = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// IPC handlers for input overlay
|
||||
ipcMain.on('show-input-overlay', (event, data) => {
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('show-input-overlay', data)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.on('hide-input-overlay', () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('hide-input-overlay')
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.on('input-overlay-submit', (event, data) => {
|
||||
console.log('Input overlay submit:', data)
|
||||
// You can handle the submitted input here
|
||||
// For example, send it to other parts of your application
|
||||
})
|
||||
|
||||
// Function to show input overlay (can be called globally)
|
||||
const showInputOverlay = (options = {}) => {
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('show-input-overlay', options)
|
||||
}
|
||||
}
|
||||
|
||||
// Export for potential use by other modules
|
||||
global.showInputOverlay = showInputOverlay
|
||||
|
||||
app.whenReady().then(() => {
|
||||
electronApp.setAppUserModelId('com.electron')
|
||||
|
||||
|
@@ -1,7 +1,17 @@
|
||||
import { contextBridge } from 'electron'
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
|
||||
const api = {}
|
||||
const api = {
|
||||
// Input overlay API
|
||||
showInputOverlay: (options) => ipcRenderer.send('show-input-overlay', options),
|
||||
hideInputOverlay: () => ipcRenderer.send('hide-input-overlay'),
|
||||
onInputOverlayShow: (callback) => ipcRenderer.on('show-input-overlay', callback),
|
||||
onInputOverlayHide: (callback) => ipcRenderer.on('hide-input-overlay', callback),
|
||||
removeInputOverlayListeners: () => {
|
||||
ipcRenderer.removeAllListeners('show-input-overlay')
|
||||
ipcRenderer.removeAllListeners('hide-input-overlay')
|
||||
}
|
||||
}
|
||||
|
||||
if (process.contextIsolated) {
|
||||
try {
|
||||
|
@@ -3,10 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>OpenWall</title>
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src *; script-src * 'unsafe-inline' 'unsafe-eval'; style-src * 'unsafe-inline'; img-src * data: blob:; frame-src *; connect-src *; font-src *; media-src *; object-src *; child-src *;"
|
||||
/>
|
||||
<!-- CSP disabled for development -->
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
@@ -4,9 +4,10 @@ import Calendar from './pages/Calendar'
|
||||
import Shopping from './pages/Shopping'
|
||||
import Notes from './pages/Notes'
|
||||
import Search from './pages/Search'
|
||||
import InputOverlay from './components/InputOverlay'
|
||||
|
||||
// Configuration
|
||||
const BACKGROUND_IMAGE_URL = 'https://cdn.pixabay.com/photo/2018/11/19/03/26/iceland-3824494_1280.jpg'
|
||||
const BACKGROUND_IMAGE_URL = 'https://i.imgur.com/SjjtyaO.jpeg'
|
||||
|
||||
const APP_BACKGROUND_STYLE = {
|
||||
backgroundImage: `url(${BACKGROUND_IMAGE_URL})`,
|
||||
@@ -30,6 +31,7 @@ const App = () => {
|
||||
<Route path="/search" element={<Search />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<InputOverlay />
|
||||
</div>
|
||||
</Router>
|
||||
)
|
||||
|
@@ -0,0 +1,97 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { showInputOverlay, setupAutoInputOverlay, onInputOverlaySubmit } from '../../utils/InputOverlayHelper'
|
||||
import './InputDemo.sass'
|
||||
|
||||
const InputDemo = () => {
|
||||
const [manualInput, setManualInput] = useState('')
|
||||
const [autoInput, setAutoInput] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
// Setup automatic input overlay for focused elements
|
||||
const cleanupAutoOverlay = setupAutoInputOverlay()
|
||||
|
||||
// Listen for input overlay submissions
|
||||
const cleanupSubmitListener = onInputOverlaySubmit((data) => {
|
||||
console.log('Input overlay submitted:', data)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cleanupAutoOverlay()
|
||||
cleanupSubmitListener()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleManualTrigger = () => {
|
||||
showInputOverlay({
|
||||
targetElement: null,
|
||||
initialValue: manualInput,
|
||||
preferredMode: 'keyboard'
|
||||
})
|
||||
}
|
||||
|
||||
const handleDrawingTrigger = () => {
|
||||
showInputOverlay({
|
||||
targetElement: null,
|
||||
initialValue: manualInput,
|
||||
preferredMode: 'drawing'
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="input-demo">
|
||||
<h3>🎯 Intelligente Eingabehilfe</h3>
|
||||
|
||||
<div className="demo-section">
|
||||
<h4>⚡ Automatische Aktivierung</h4>
|
||||
<p>Klicken Sie in die Eingabefelder unten, um die Eingabehilfe automatisch zu öffnen:</p>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Klicken Sie hier für Tastatur-Eingabe..."
|
||||
value={autoInput}
|
||||
onChange={(e) => setAutoInput(e.target.value)}
|
||||
className="demo-input"
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Oder hier für längere Texte..."
|
||||
value={autoInput}
|
||||
onChange={(e) => setAutoInput(e.target.value)}
|
||||
className="demo-textarea"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="demo-section">
|
||||
<h4>🎮 Manuelle Steuerung</h4>
|
||||
<p>Verwenden Sie die Buttons unten, um die Eingabehilfe manuell zu öffnen:</p>
|
||||
<div className="demo-controls">
|
||||
<button onClick={handleManualTrigger} className="demo-btn demo-btn--keyboard">
|
||||
⌨️ Tastatur öffnen
|
||||
</button>
|
||||
<button onClick={handleDrawingTrigger} className="demo-btn demo-btn--drawing">
|
||||
✍️ Handschrift öffnen
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Manueller Eingabetext..."
|
||||
value={manualInput}
|
||||
onChange={(e) => setManualInput(e.target.value)}
|
||||
className="demo-input"
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="demo-section">
|
||||
<h4>📖 Funktionen</h4>
|
||||
<ul className="demo-usage">
|
||||
<li>Die Eingabehilfe öffnet sich automatisch bei Fokus auf Eingabefelder</li>
|
||||
<li>Wählen Sie zwischen Tastatur-Eingabe und Handschrift-Erkennung</li>
|
||||
<li>Die OCR unterstützt deutsche Texterkennung mit Tesseract.js</li>
|
||||
<li>Verwenden Sie ESC zum Schließen oder klicken Sie außerhalb der Eingabehilfe</li>
|
||||
<li>Vollständig responsive für Touch-Displays und mobile Geräte</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InputDemo
|
175
dashboard/src/renderer/src/components/InputDemo/InputDemo.sass
Normal file
@@ -0,0 +1,175 @@
|
||||
.input-demo
|
||||
padding: 32px
|
||||
max-width: 900px
|
||||
margin: 0 auto
|
||||
|
||||
h3
|
||||
color: rgba(255, 255, 255, 0.95)
|
||||
margin-bottom: 32px
|
||||
font-size: 28px
|
||||
font-weight: 700
|
||||
text-align: center
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3)
|
||||
|
||||
h4
|
||||
color: rgba(255, 255, 255, 0.9)
|
||||
margin: 32px 0 16px 0
|
||||
font-size: 20px
|
||||
font-weight: 600
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2)
|
||||
|
||||
.demo-section
|
||||
margin-bottom: 40px
|
||||
padding: 32px
|
||||
background: rgba(255, 255, 255, 0.1)
|
||||
backdrop-filter: blur(20px)
|
||||
-webkit-backdrop-filter: blur(20px)
|
||||
border: 1px solid rgba(255, 255, 255, 0.2)
|
||||
border-radius: 20px
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.1)
|
||||
|
||||
p
|
||||
color: rgba(255, 255, 255, 0.8)
|
||||
margin-bottom: 24px
|
||||
font-size: 16px
|
||||
line-height: 1.6
|
||||
|
||||
.demo-input, .demo-textarea
|
||||
width: 100%
|
||||
padding: 16px 20px
|
||||
border: 1px solid rgba(255, 255, 255, 0.2)
|
||||
border-radius: 16px
|
||||
font-size: 15px
|
||||
margin-bottom: 16px
|
||||
background: rgba(255, 255, 255, 0.1)
|
||||
color: rgba(255, 255, 255, 0.95)
|
||||
backdrop-filter: blur(10px)
|
||||
-webkit-backdrop-filter: blur(10px)
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.1)
|
||||
|
||||
&::placeholder
|
||||
color: rgba(255, 255, 255, 0.5)
|
||||
|
||||
&:focus
|
||||
outline: none
|
||||
border-color: rgba(59, 130, 246, 0.5)
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1), 0 4px 15px rgba(0, 0, 0, 0.15)
|
||||
background: rgba(255, 255, 255, 0.15)
|
||||
transform: translateY(-1px)
|
||||
|
||||
.demo-textarea
|
||||
min-height: 100px
|
||||
resize: vertical
|
||||
|
||||
.demo-controls
|
||||
display: flex
|
||||
gap: 16px
|
||||
margin-bottom: 24px
|
||||
|
||||
.demo-btn
|
||||
padding: 14px 24px
|
||||
border: none
|
||||
border-radius: 16px
|
||||
cursor: pointer
|
||||
font-size: 15px
|
||||
font-weight: 500
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)
|
||||
position: relative
|
||||
overflow: hidden
|
||||
backdrop-filter: blur(10px)
|
||||
-webkit-backdrop-filter: blur(10px)
|
||||
|
||||
&::before
|
||||
content: ''
|
||||
position: absolute
|
||||
top: 0
|
||||
left: 0
|
||||
right: 0
|
||||
bottom: 0
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05))
|
||||
opacity: 0
|
||||
transition: opacity 0.3s ease
|
||||
|
||||
&:hover
|
||||
transform: translateY(-2px)
|
||||
|
||||
&::before
|
||||
opacity: 1
|
||||
|
||||
&--keyboard
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))
|
||||
color: white
|
||||
border: 1px solid rgba(59, 130, 246, 0.3)
|
||||
box-shadow: 0 4px 15px rgba(59, 130, 246, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.2)
|
||||
|
||||
&:hover
|
||||
box-shadow: 0 8px 25px rgba(59, 130, 246, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.2)
|
||||
|
||||
&--drawing
|
||||
background: linear-gradient(135deg, rgba(34, 197, 94, 0.8), rgba(22, 163, 74, 0.8))
|
||||
color: white
|
||||
border: 1px solid rgba(34, 197, 94, 0.3)
|
||||
box-shadow: 0 4px 15px rgba(34, 197, 94, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.2)
|
||||
|
||||
&:hover
|
||||
box-shadow: 0 8px 25px rgba(34, 197, 94, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.2)
|
||||
|
||||
.demo-usage
|
||||
color: rgba(255, 255, 255, 0.8)
|
||||
list-style: none
|
||||
margin-left: 0
|
||||
padding-left: 0
|
||||
|
||||
li
|
||||
margin-bottom: 12px
|
||||
padding-left: 24px
|
||||
position: relative
|
||||
line-height: 1.5
|
||||
|
||||
&::before
|
||||
content: '✨'
|
||||
position: absolute
|
||||
left: 0
|
||||
top: 0
|
||||
font-size: 16px
|
||||
|
||||
@media (max-width: 768px)
|
||||
.input-demo
|
||||
padding: 24px 20px
|
||||
|
||||
.demo-section
|
||||
padding: 24px 20px
|
||||
|
||||
.demo-controls
|
||||
flex-direction: column
|
||||
|
||||
.demo-btn
|
||||
width: 100%
|
||||
padding: 12px 20px
|
||||
|
||||
h3
|
||||
font-size: 24px
|
||||
|
||||
h4
|
||||
font-size: 18px
|
||||
|
||||
@media (max-width: 480px)
|
||||
.input-demo
|
||||
padding: 20px 16px
|
||||
|
||||
.demo-section
|
||||
padding: 20px 16px
|
||||
border-radius: 16px
|
||||
|
||||
.demo-input, .demo-textarea
|
||||
padding: 12px 16px
|
||||
border-radius: 12px
|
||||
|
||||
.demo-btn
|
||||
padding: 10px 16px
|
||||
font-size: 14px
|
||||
border-radius: 12px
|
||||
|
||||
.demo-usage li
|
||||
padding-left: 20px
|
1
dashboard/src/renderer/src/components/InputDemo/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './InputDemo'
|
@@ -0,0 +1,165 @@
|
||||
import { useRef, useState, useEffect } from 'react'
|
||||
import './DrawingCanvas.sass'
|
||||
|
||||
const DrawingCanvas = ({ onDrawingComplete, disabled }) => {
|
||||
const canvasRef = useRef(null)
|
||||
const [isDrawing, setIsDrawing] = useState(false)
|
||||
const [brushSize, setBrushSize] = useState(3)
|
||||
const [hasDrawing, setHasDrawing] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
// Set canvas size
|
||||
canvas.width = canvas.offsetWidth
|
||||
canvas.height = canvas.offsetHeight
|
||||
|
||||
// Set drawing styles
|
||||
ctx.lineCap = 'round'
|
||||
ctx.lineJoin = 'round'
|
||||
ctx.fillStyle = 'white'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
ctx.strokeStyle = '#000000'
|
||||
ctx.lineWidth = brushSize
|
||||
}, [brushSize])
|
||||
|
||||
const startDrawing = (e) => {
|
||||
if (disabled) return
|
||||
|
||||
setIsDrawing(true)
|
||||
const canvas = canvasRef.current
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const y = e.clientY - rect.top
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, y)
|
||||
}
|
||||
|
||||
const draw = (e) => {
|
||||
if (!isDrawing || disabled) return
|
||||
|
||||
const canvas = canvasRef.current
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const y = e.clientY - rect.top
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.lineTo(x, y)
|
||||
ctx.stroke()
|
||||
|
||||
setHasDrawing(true)
|
||||
}
|
||||
|
||||
const stopDrawing = () => {
|
||||
setIsDrawing(false)
|
||||
}
|
||||
|
||||
// Touch event handlers for mobile/tablet support
|
||||
const handleTouchStart = (e) => {
|
||||
e.preventDefault()
|
||||
const touch = e.touches[0]
|
||||
const mouseEvent = new MouseEvent('mousedown', {
|
||||
clientX: touch.clientX,
|
||||
clientY: touch.clientY
|
||||
})
|
||||
startDrawing(mouseEvent)
|
||||
}
|
||||
|
||||
const handleTouchMove = (e) => {
|
||||
e.preventDefault()
|
||||
const touch = e.touches[0]
|
||||
const mouseEvent = new MouseEvent('mousemove', {
|
||||
clientX: touch.clientX,
|
||||
clientY: touch.clientY
|
||||
})
|
||||
draw(mouseEvent)
|
||||
}
|
||||
|
||||
const handleTouchEnd = (e) => {
|
||||
e.preventDefault()
|
||||
stopDrawing()
|
||||
}
|
||||
|
||||
const clearCanvas = () => {
|
||||
const canvas = canvasRef.current
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.fillStyle = 'white'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
setHasDrawing(false)
|
||||
}
|
||||
|
||||
const processDrawing = () => {
|
||||
if (!hasDrawing || disabled) return
|
||||
|
||||
const canvas = canvasRef.current
|
||||
onDrawingComplete(canvas)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="drawing-canvas-container">
|
||||
<div className="drawing-canvas-controls">
|
||||
<div className="brush-size-control">
|
||||
<label htmlFor="brush-size">Pinselgröße:</label>
|
||||
<input
|
||||
id="brush-size"
|
||||
type="range"
|
||||
min="1"
|
||||
max="10"
|
||||
value={brushSize}
|
||||
onChange={(e) => setBrushSize(Number(e.target.value))}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span>{brushSize}px</span>
|
||||
</div>
|
||||
<div className="canvas-actions">
|
||||
<button
|
||||
onClick={clearCanvas}
|
||||
className="canvas-btn canvas-btn--secondary"
|
||||
disabled={disabled}
|
||||
>
|
||||
🗑️ Löschen
|
||||
</button>
|
||||
<button
|
||||
onClick={processDrawing}
|
||||
className="canvas-btn canvas-btn--primary"
|
||||
disabled={!hasDrawing || disabled}
|
||||
>
|
||||
🔍 Text erkennen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="drawing-canvas-wrapper">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={`drawing-canvas ${disabled ? 'disabled' : ''}`}
|
||||
onMouseDown={startDrawing}
|
||||
onMouseMove={draw}
|
||||
onMouseUp={stopDrawing}
|
||||
onMouseLeave={stopDrawing}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
/>
|
||||
{!hasDrawing && !disabled && (
|
||||
<div className="drawing-canvas-placeholder">
|
||||
✍️ Schreiben oder zeichnen Sie hier...
|
||||
</div>
|
||||
)}
|
||||
{disabled && (
|
||||
<div className="drawing-canvas-overlay">
|
||||
<div className="processing-spinner" />
|
||||
<span>🔍 Verarbeitung läuft...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DrawingCanvas
|
@@ -0,0 +1,249 @@
|
||||
.drawing-canvas-container
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 20px
|
||||
max-width: 900px
|
||||
|
||||
.drawing-canvas-controls
|
||||
display: flex
|
||||
justify-content: space-between
|
||||
align-items: center
|
||||
padding: 20px 24px
|
||||
background: rgba(255, 255, 255, 0.1)
|
||||
backdrop-filter: blur(10px)
|
||||
-webkit-backdrop-filter: blur(10px)
|
||||
border-radius: 16px
|
||||
border: 1px solid rgba(255, 255, 255, 0.2)
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.1)
|
||||
|
||||
.brush-size-control
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 16px
|
||||
|
||||
label
|
||||
font-size: 14px
|
||||
font-weight: 500
|
||||
color: rgba(255, 255, 255, 0.9)
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1)
|
||||
|
||||
input[type="range"]
|
||||
width: 120px
|
||||
height: 6px
|
||||
background: rgba(255, 255, 255, 0.2)
|
||||
border-radius: 3px
|
||||
outline: none
|
||||
-webkit-appearance: none
|
||||
backdrop-filter: blur(5px)
|
||||
|
||||
&::-webkit-slider-thumb
|
||||
-webkit-appearance: none
|
||||
width: 20px
|
||||
height: 20px
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.9), rgba(37, 99, 235, 0.9))
|
||||
border-radius: 50%
|
||||
cursor: pointer
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.2)
|
||||
transition: all 0.2s ease
|
||||
|
||||
&:hover
|
||||
transform: scale(1.1)
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.2)
|
||||
|
||||
&::-moz-range-thumb
|
||||
width: 20px
|
||||
height: 20px
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.9), rgba(37, 99, 235, 0.9))
|
||||
border-radius: 50%
|
||||
cursor: pointer
|
||||
border: none
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3)
|
||||
|
||||
span
|
||||
font-size: 13px
|
||||
font-weight: 500
|
||||
color: rgba(255, 255, 255, 0.7)
|
||||
min-width: 35px
|
||||
text-align: center
|
||||
background: rgba(255, 255, 255, 0.1)
|
||||
padding: 4px 8px
|
||||
border-radius: 8px
|
||||
backdrop-filter: blur(5px)
|
||||
|
||||
.canvas-actions
|
||||
display: flex
|
||||
gap: 12px
|
||||
|
||||
.canvas-btn
|
||||
padding: 10px 20px
|
||||
border-radius: 12px
|
||||
font-size: 14px
|
||||
font-weight: 500
|
||||
cursor: pointer
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)
|
||||
border: none
|
||||
position: relative
|
||||
overflow: hidden
|
||||
|
||||
&::before
|
||||
content: ''
|
||||
position: absolute
|
||||
top: 0
|
||||
left: 0
|
||||
right: 0
|
||||
bottom: 0
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05))
|
||||
opacity: 0
|
||||
transition: opacity 0.3s ease
|
||||
|
||||
&:hover:not(:disabled)
|
||||
transform: translateY(-2px)
|
||||
|
||||
&::before
|
||||
opacity: 1
|
||||
|
||||
&:disabled
|
||||
cursor: not-allowed
|
||||
opacity: 0.5
|
||||
transform: none
|
||||
|
||||
&--primary
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))
|
||||
color: white
|
||||
box-shadow: 0 4px 15px rgba(59, 130, 246, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.2)
|
||||
|
||||
&:hover:not(:disabled)
|
||||
box-shadow: 0 8px 25px rgba(59, 130, 246, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.2)
|
||||
|
||||
&:disabled
|
||||
background: rgba(107, 114, 128, 0.3)
|
||||
box-shadow: none
|
||||
|
||||
&--secondary
|
||||
background: rgba(239, 68, 68, 0.15)
|
||||
color: rgba(248, 113, 113, 0.9)
|
||||
border: 1px solid rgba(239, 68, 68, 0.3)
|
||||
backdrop-filter: blur(10px)
|
||||
-webkit-backdrop-filter: blur(10px)
|
||||
|
||||
&:hover:not(:disabled)
|
||||
background: rgba(239, 68, 68, 0.25)
|
||||
border-color: rgba(239, 68, 68, 0.4)
|
||||
box-shadow: 0 4px 15px rgba(239, 68, 68, 0.2)
|
||||
|
||||
.drawing-canvas-wrapper
|
||||
position: relative
|
||||
width: 100%
|
||||
height: 320px
|
||||
border: 2px solid rgba(255, 255, 255, 0.2)
|
||||
border-radius: 16px
|
||||
overflow: hidden
|
||||
background: rgba(255, 255, 255, 0.95)
|
||||
backdrop-filter: blur(10px)
|
||||
-webkit-backdrop-filter: blur(10px)
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.5)
|
||||
|
||||
.drawing-canvas
|
||||
width: 100%
|
||||
height: 100%
|
||||
cursor: crosshair
|
||||
display: block
|
||||
background: white
|
||||
|
||||
&.disabled
|
||||
cursor: not-allowed
|
||||
opacity: 0.7
|
||||
|
||||
.drawing-canvas-placeholder
|
||||
position: absolute
|
||||
top: 50%
|
||||
left: 50%
|
||||
transform: translate(-50%, -50%)
|
||||
color: rgba(107, 114, 128, 0.6)
|
||||
font-style: italic
|
||||
font-size: 16px
|
||||
font-weight: 500
|
||||
pointer-events: none
|
||||
text-align: center
|
||||
background: rgba(255, 255, 255, 0.8)
|
||||
padding: 12px 20px
|
||||
border-radius: 12px
|
||||
backdrop-filter: blur(10px)
|
||||
-webkit-backdrop-filter: blur(10px)
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05)
|
||||
|
||||
.drawing-canvas-overlay
|
||||
position: absolute
|
||||
top: 0
|
||||
left: 0
|
||||
width: 100%
|
||||
height: 100%
|
||||
background: rgba(255, 255, 255, 0.9)
|
||||
backdrop-filter: blur(10px)
|
||||
-webkit-backdrop-filter: blur(10px)
|
||||
display: flex
|
||||
flex-direction: column
|
||||
align-items: center
|
||||
justify-content: center
|
||||
gap: 16px
|
||||
border-radius: 14px
|
||||
|
||||
span
|
||||
font-size: 16px
|
||||
font-weight: 500
|
||||
color: rgba(59, 130, 246, 0.8)
|
||||
|
||||
.processing-spinner
|
||||
width: 40px
|
||||
height: 40px
|
||||
border: 3px solid rgba(59, 130, 246, 0.2)
|
||||
border-top: 3px solid rgba(59, 130, 246, 0.8)
|
||||
border-radius: 50%
|
||||
animation: spin 1s linear infinite
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.2)
|
||||
|
||||
@keyframes spin
|
||||
0%
|
||||
transform: rotate(0deg)
|
||||
100%
|
||||
transform: rotate(360deg)
|
||||
|
||||
@media (max-width: 768px)
|
||||
.drawing-canvas-controls
|
||||
flex-direction: column
|
||||
gap: 16px
|
||||
align-items: stretch
|
||||
padding: 16px 20px
|
||||
|
||||
.brush-size-control
|
||||
justify-content: center
|
||||
|
||||
.canvas-actions
|
||||
justify-content: center
|
||||
|
||||
.drawing-canvas-wrapper
|
||||
height: 280px
|
||||
|
||||
.drawing-canvas-placeholder
|
||||
font-size: 14px
|
||||
padding: 10px 16px
|
||||
|
||||
@media (max-width: 480px)
|
||||
.drawing-canvas-container
|
||||
gap: 16px
|
||||
|
||||
.drawing-canvas-controls
|
||||
padding: 14px 16px
|
||||
|
||||
.brush-size-control
|
||||
gap: 12px
|
||||
|
||||
input[type="range"]
|
||||
width: 100px
|
||||
|
||||
.canvas-btn
|
||||
padding: 8px 16px
|
||||
font-size: 13px
|
||||
|
||||
.drawing-canvas-wrapper
|
||||
height: 240px
|
@@ -0,0 +1,209 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { createWorker } from 'tesseract.js'
|
||||
import OnScreenKeyboard from './OnScreenKeyboard'
|
||||
import DrawingCanvas from './DrawingCanvas'
|
||||
import './InputOverlay.sass'
|
||||
|
||||
const InputOverlay = () => {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [inputMode, setInputMode] = useState('keyboard') // 'keyboard' or 'drawing'
|
||||
const [currentInput, setCurrentInput] = useState('')
|
||||
const [targetElement, setTargetElement] = useState(null)
|
||||
const [isProcessingOCR, setIsProcessingOCR] = useState(false)
|
||||
const workerRef = useRef(null)
|
||||
|
||||
// Initialize Tesseract worker
|
||||
useEffect(() => {
|
||||
const initWorker = async () => {
|
||||
try {
|
||||
workerRef.current = await createWorker('deu') // German language
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize Tesseract worker:', error)
|
||||
}
|
||||
}
|
||||
|
||||
initWorker()
|
||||
|
||||
return () => {
|
||||
if (workerRef.current) {
|
||||
workerRef.current.terminate()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Listen for Electron events to show/hide the input overlay
|
||||
useEffect(() => {
|
||||
const handleShowInputOverlay = (event, data) => {
|
||||
setTargetElement(data.targetElement || null)
|
||||
setCurrentInput(data.initialValue || '')
|
||||
setInputMode(data.preferredMode || 'keyboard')
|
||||
setIsVisible(true)
|
||||
}
|
||||
|
||||
const handleHideInputOverlay = () => {
|
||||
setIsVisible(false)
|
||||
setCurrentInput('')
|
||||
setTargetElement(null)
|
||||
}
|
||||
|
||||
// Listen for IPC events from main process
|
||||
if (window.electron && window.electron.ipcRenderer) {
|
||||
window.electron.ipcRenderer.on('show-input-overlay', handleShowInputOverlay)
|
||||
window.electron.ipcRenderer.on('hide-input-overlay', handleHideInputOverlay)
|
||||
}
|
||||
|
||||
// Listen for custom DOM events as fallback
|
||||
document.addEventListener('show-input-overlay', handleShowInputOverlay)
|
||||
document.addEventListener('hide-input-overlay', handleHideInputOverlay)
|
||||
|
||||
return () => {
|
||||
if (window.electron && window.electron.ipcRenderer) {
|
||||
window.electron.ipcRenderer.removeAllListeners('show-input-overlay')
|
||||
window.electron.ipcRenderer.removeAllListeners('hide-input-overlay')
|
||||
}
|
||||
document.removeEventListener('show-input-overlay', handleShowInputOverlay)
|
||||
document.removeEventListener('hide-input-overlay', handleHideInputOverlay)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleKeyboardInput = (value) => {
|
||||
setCurrentInput(value)
|
||||
}
|
||||
|
||||
const handleDrawingComplete = async (canvas) => {
|
||||
if (!workerRef.current || isProcessingOCR) return
|
||||
|
||||
setIsProcessingOCR(true)
|
||||
try {
|
||||
const { data: { text } } = await workerRef.current.recognize(canvas)
|
||||
const cleanedText = text.trim().replace(/\n/g, ' ')
|
||||
setCurrentInput(prev => prev + cleanedText)
|
||||
} catch (error) {
|
||||
console.error('OCR recognition failed:', error)
|
||||
} finally {
|
||||
setIsProcessingOCR(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
// Send the input back to the target element or emit an event
|
||||
if (targetElement) {
|
||||
// Try to set value for input elements
|
||||
if (targetElement.tagName === 'INPUT' || targetElement.tagName === 'TEXTAREA') {
|
||||
targetElement.value = currentInput
|
||||
targetElement.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
targetElement.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
|
||||
// Emit custom event with the input value
|
||||
const event = new CustomEvent('input-overlay-submit', {
|
||||
detail: { value: currentInput, targetElement }
|
||||
})
|
||||
document.dispatchEvent(event)
|
||||
|
||||
// Also send via Electron IPC if available
|
||||
if (window.electron && window.electron.ipcRenderer) {
|
||||
window.electron.ipcRenderer.send('input-overlay-submit', {
|
||||
value: currentInput,
|
||||
targetElement: targetElement ? targetElement.id || targetElement.className : null
|
||||
})
|
||||
}
|
||||
|
||||
handleClose()
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setIsVisible(false)
|
||||
setCurrentInput('')
|
||||
setTargetElement(null)
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
setCurrentInput('')
|
||||
}
|
||||
|
||||
if (!isVisible) return null
|
||||
|
||||
return (
|
||||
<div className="input-overlay">
|
||||
<div className="input-overlay__backdrop" onClick={handleClose} />
|
||||
<div className="input-overlay__container">
|
||||
<div className="input-overlay__header">
|
||||
<h3>Eingabehilfe</h3>
|
||||
<div className="input-overlay__mode-toggle">
|
||||
<button
|
||||
className={inputMode === 'keyboard' ? 'active' : ''}
|
||||
onClick={() => setInputMode('keyboard')}
|
||||
>
|
||||
Tastatur
|
||||
</button>
|
||||
<button
|
||||
className={inputMode === 'drawing' ? 'active' : ''}
|
||||
onClick={() => setInputMode('drawing')}
|
||||
>
|
||||
Handschrift
|
||||
</button>
|
||||
</div>
|
||||
<button className="input-overlay__close" onClick={handleClose}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="input-overlay__content">
|
||||
<div className="input-overlay__preview">
|
||||
<textarea
|
||||
value={currentInput}
|
||||
onChange={(e) => setCurrentInput(e.target.value)}
|
||||
placeholder="Ihr Text wird hier angezeigt..."
|
||||
className="input-overlay__preview-text"
|
||||
/>
|
||||
<div className="input-overlay__preview-actions">
|
||||
<button onClick={handleClear} className="input-overlay__btn input-overlay__btn--secondary">
|
||||
Zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="input-overlay__input-area">
|
||||
{inputMode === 'keyboard' ? (
|
||||
<OnScreenKeyboard
|
||||
value={currentInput}
|
||||
onChange={handleKeyboardInput}
|
||||
/>
|
||||
) : (
|
||||
<div className="input-overlay__drawing-container">
|
||||
<DrawingCanvas
|
||||
onDrawingComplete={handleDrawingComplete}
|
||||
disabled={isProcessingOCR}
|
||||
/>
|
||||
{isProcessingOCR && (
|
||||
<div className="input-overlay__ocr-status">
|
||||
🔍 Texterkennung läuft...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="input-overlay__footer">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="input-overlay__btn input-overlay__btn--primary"
|
||||
>
|
||||
✓ Übernehmen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="input-overlay__btn input-overlay__btn--secondary"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InputOverlay
|
@@ -0,0 +1,295 @@
|
||||
.input-overlay
|
||||
position: fixed
|
||||
top: 0
|
||||
left: 0
|
||||
width: 100vw
|
||||
height: 100vh
|
||||
z-index: 9999
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
animation: overlayFadeIn 0.3s ease-out
|
||||
|
||||
&__backdrop
|
||||
position: absolute
|
||||
top: 0
|
||||
left: 0
|
||||
width: 100%
|
||||
height: 100%
|
||||
background: rgba(0, 0, 0, 0.4)
|
||||
backdrop-filter: blur(12px)
|
||||
-webkit-backdrop-filter: blur(12px)
|
||||
|
||||
&__container
|
||||
position: relative
|
||||
background: rgba(255, 255, 255, 0.15)
|
||||
backdrop-filter: blur(20px)
|
||||
-webkit-backdrop-filter: blur(20px)
|
||||
border: 1px solid rgba(255, 255, 255, 0.2)
|
||||
border-radius: 24px
|
||||
box-shadow: 0 25px 45px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(255, 255, 255, 0.05), inset 0 1px 0 rgba(255, 255, 255, 0.1)
|
||||
max-width: 90vw
|
||||
max-height: 90vh
|
||||
overflow: hidden
|
||||
display: flex
|
||||
flex-direction: column
|
||||
animation: containerSlideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1)
|
||||
|
||||
&__header
|
||||
padding: 24px 32px 20px 32px
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05))
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1)
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: space-between
|
||||
|
||||
h3
|
||||
margin: 0
|
||||
font-size: 20px
|
||||
font-weight: 600
|
||||
color: rgba(255, 255, 255, 0.95)
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1)
|
||||
|
||||
&__mode-toggle
|
||||
display: flex
|
||||
gap: 4px
|
||||
background: rgba(255, 255, 255, 0.1)
|
||||
padding: 4px
|
||||
border-radius: 16px
|
||||
backdrop-filter: blur(10px)
|
||||
-webkit-backdrop-filter: blur(10px)
|
||||
|
||||
button
|
||||
padding: 10px 20px
|
||||
border: none
|
||||
background: transparent
|
||||
border-radius: 12px
|
||||
cursor: pointer
|
||||
font-size: 14px
|
||||
font-weight: 500
|
||||
color: rgba(255, 255, 255, 0.7)
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)
|
||||
position: relative
|
||||
overflow: hidden
|
||||
|
||||
&::before
|
||||
content: ''
|
||||
position: absolute
|
||||
top: 0
|
||||
left: 0
|
||||
right: 0
|
||||
bottom: 0
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0.1))
|
||||
opacity: 0
|
||||
transition: opacity 0.3s ease
|
||||
border-radius: 12px
|
||||
|
||||
&:hover
|
||||
color: rgba(255, 255, 255, 0.9)
|
||||
transform: translateY(-1px)
|
||||
|
||||
&::before
|
||||
opacity: 1
|
||||
|
||||
&.active
|
||||
color: white
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))
|
||||
box-shadow: 0 4px 15px rgba(59, 130, 246, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.2)
|
||||
transform: translateY(-1px)
|
||||
|
||||
&::before
|
||||
opacity: 0
|
||||
|
||||
&__close
|
||||
background: rgba(255, 255, 255, 0.1)
|
||||
border: 1px solid rgba(255, 255, 255, 0.2)
|
||||
font-size: 20px
|
||||
cursor: pointer
|
||||
color: rgba(255, 255, 255, 0.7)
|
||||
padding: 0
|
||||
width: 40px
|
||||
height: 40px
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
border-radius: 12px
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)
|
||||
backdrop-filter: blur(10px)
|
||||
-webkit-backdrop-filter: blur(10px)
|
||||
|
||||
&:hover
|
||||
background: rgba(239, 68, 68, 0.2)
|
||||
color: rgb(248, 113, 113)
|
||||
transform: translateY(-1px)
|
||||
box-shadow: 0 4px 15px rgba(239, 68, 68, 0.2)
|
||||
|
||||
&__content
|
||||
display: flex
|
||||
flex-direction: column
|
||||
flex: 1
|
||||
min-height: 0
|
||||
|
||||
&__preview
|
||||
padding: 24px 32px
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1)
|
||||
display: flex
|
||||
gap: 20px
|
||||
align-items: flex-start
|
||||
|
||||
&__preview-text
|
||||
flex: 1
|
||||
min-height: 100px
|
||||
max-height: 140px
|
||||
padding: 16px 20px
|
||||
border: 1px solid rgba(255, 255, 255, 0.2)
|
||||
border-radius: 16px
|
||||
font-family: inherit
|
||||
font-size: 15px
|
||||
resize: vertical
|
||||
background: rgba(255, 255, 255, 0.1)
|
||||
color: rgba(255, 255, 255, 0.95)
|
||||
backdrop-filter: blur(10px)
|
||||
-webkit-backdrop-filter: blur(10px)
|
||||
transition: all 0.3s ease
|
||||
|
||||
&::placeholder
|
||||
color: rgba(255, 255, 255, 0.5)
|
||||
|
||||
&:focus
|
||||
outline: none
|
||||
border-color: rgba(59, 130, 246, 0.5)
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1), 0 4px 15px rgba(0, 0, 0, 0.1)
|
||||
background: rgba(255, 255, 255, 0.15)
|
||||
|
||||
&__preview-actions
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 12px
|
||||
|
||||
&__input-area
|
||||
flex: 1
|
||||
padding: 24px 32px
|
||||
overflow-y: auto
|
||||
|
||||
&__drawing-container
|
||||
position: relative
|
||||
|
||||
&__ocr-status
|
||||
position: absolute
|
||||
top: 50%
|
||||
left: 50%
|
||||
transform: translate(-50%, -50%)
|
||||
background: rgba(0, 0, 0, 0.8)
|
||||
color: white
|
||||
padding: 16px 24px
|
||||
border-radius: 16px
|
||||
font-size: 14px
|
||||
font-weight: 500
|
||||
backdrop-filter: blur(10px)
|
||||
-webkit-backdrop-filter: blur(10px)
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3)
|
||||
|
||||
&__footer
|
||||
padding: 24px 32px
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.02))
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1)
|
||||
display: flex
|
||||
gap: 16px
|
||||
justify-content: flex-end
|
||||
|
||||
&__btn
|
||||
padding: 12px 24px
|
||||
border-radius: 16px
|
||||
font-size: 14px
|
||||
font-weight: 500
|
||||
cursor: pointer
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)
|
||||
border: none
|
||||
position: relative
|
||||
overflow: hidden
|
||||
|
||||
&::before
|
||||
content: ''
|
||||
position: absolute
|
||||
top: 0
|
||||
left: 0
|
||||
right: 0
|
||||
bottom: 0
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05))
|
||||
opacity: 0
|
||||
transition: opacity 0.3s ease
|
||||
|
||||
&:hover
|
||||
transform: translateY(-2px)
|
||||
|
||||
&::before
|
||||
opacity: 1
|
||||
|
||||
&--primary
|
||||
background: linear-gradient(135deg, rgba(34, 197, 94, 0.8), rgba(22, 163, 74, 0.8))
|
||||
color: white
|
||||
box-shadow: 0 4px 15px rgba(34, 197, 94, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.2)
|
||||
|
||||
&:hover
|
||||
box-shadow: 0 8px 25px rgba(34, 197, 94, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.2)
|
||||
|
||||
&--secondary
|
||||
background: rgba(255, 255, 255, 0.1)
|
||||
color: rgba(255, 255, 255, 0.8)
|
||||
border: 1px solid rgba(255, 255, 255, 0.2)
|
||||
backdrop-filter: blur(10px)
|
||||
-webkit-backdrop-filter: blur(10px)
|
||||
|
||||
&:hover
|
||||
background: rgba(255, 255, 255, 0.15)
|
||||
color: rgba(255, 255, 255, 0.95)
|
||||
|
||||
@keyframes overlayFadeIn
|
||||
from
|
||||
opacity: 0
|
||||
|
||||
to
|
||||
opacity: 1
|
||||
|
||||
@keyframes containerSlideUp
|
||||
from
|
||||
opacity: 0
|
||||
transform: translateY(30px) scale(0.95)
|
||||
|
||||
to
|
||||
opacity: 1
|
||||
transform: translateY(0) scale(1)
|
||||
|
||||
@media (max-width: 768px)
|
||||
.input-overlay
|
||||
&__container
|
||||
max-width: 95vw
|
||||
max-height: 95vh
|
||||
border-radius: 20px
|
||||
|
||||
&__header
|
||||
padding: 20px 24px 16px 24px
|
||||
|
||||
h3
|
||||
font-size: 18px
|
||||
|
||||
&__mode-toggle
|
||||
button
|
||||
padding: 8px 16px
|
||||
font-size: 13px
|
||||
|
||||
&__close
|
||||
width: 36px
|
||||
height: 36px
|
||||
font-size: 18px
|
||||
|
||||
&__preview, &__input-area, &__footer
|
||||
padding: 20px 24px
|
||||
|
||||
&__preview
|
||||
flex-direction: column
|
||||
gap: 16px
|
||||
|
||||
&__preview-actions
|
||||
flex-direction: row
|
||||
justify-content: center
|
@@ -0,0 +1,168 @@
|
||||
import { useState } from 'react'
|
||||
import './OnScreenKeyboard.sass'
|
||||
|
||||
const OnScreenKeyboard = ({ value, onChange }) => {
|
||||
const [isShiftActive, setIsShiftActive] = useState(false)
|
||||
const [isAltGrActive, setIsAltGrActive] = useState(false)
|
||||
|
||||
// German keyboard layout
|
||||
const keyboardLayout = [
|
||||
[
|
||||
{ key: '1', shift: '!', altGr: null },
|
||||
{ key: '2', shift: '"', altGr: '²' },
|
||||
{ key: '3', shift: '§', altGr: '³' },
|
||||
{ key: '4', shift: '$', altGr: null },
|
||||
{ key: '5', shift: '%', altGr: null },
|
||||
{ key: '6', shift: '&', altGr: null },
|
||||
{ key: '7', shift: '/', altGr: '{' },
|
||||
{ key: '8', shift: '(', altGr: '[' },
|
||||
{ key: '9', shift: ')', altGr: ']' },
|
||||
{ key: '0', shift: '=', altGr: '}' },
|
||||
{ key: 'ß', shift: '?', altGr: '\\' },
|
||||
{ key: '´', shift: '`', altGr: null },
|
||||
],
|
||||
[
|
||||
{ key: 'q', shift: 'Q', altGr: '@' },
|
||||
{ key: 'w', shift: 'W', altGr: null },
|
||||
{ key: 'e', shift: 'E', altGr: '€' },
|
||||
{ key: 'r', shift: 'R', altGr: null },
|
||||
{ key: 't', shift: 'T', altGr: null },
|
||||
{ key: 'z', shift: 'Z', altGr: null },
|
||||
{ key: 'u', shift: 'U', altGr: null },
|
||||
{ key: 'i', shift: 'I', altGr: null },
|
||||
{ key: 'o', shift: 'O', altGr: null },
|
||||
{ key: 'p', shift: 'P', altGr: null },
|
||||
{ key: 'ü', shift: 'Ü', altGr: null },
|
||||
{ key: '+', shift: '*', altGr: '~' },
|
||||
],
|
||||
[
|
||||
{ key: 'a', shift: 'A', altGr: null },
|
||||
{ key: 's', shift: 'S', altGr: null },
|
||||
{ key: 'd', shift: 'D', altGr: null },
|
||||
{ key: 'f', shift: 'F', altGr: null },
|
||||
{ key: 'g', shift: 'G', altGr: null },
|
||||
{ key: 'h', shift: 'H', altGr: null },
|
||||
{ key: 'j', shift: 'J', altGr: null },
|
||||
{ key: 'k', shift: 'K', altGr: null },
|
||||
{ key: 'l', shift: 'L', altGr: null },
|
||||
{ key: 'ö', shift: 'Ö', altGr: null },
|
||||
{ key: 'ä', shift: 'Ä', altGr: null },
|
||||
{ key: '#', shift: "'", altGr: null },
|
||||
],
|
||||
[
|
||||
{ key: '<', shift: '>', altGr: '|' },
|
||||
{ key: 'y', shift: 'Y', altGr: null },
|
||||
{ key: 'x', shift: 'X', altGr: null },
|
||||
{ key: 'c', shift: 'C', altGr: null },
|
||||
{ key: 'v', shift: 'V', altGr: null },
|
||||
{ key: 'b', shift: 'B', altGr: null },
|
||||
{ key: 'n', shift: 'N', altGr: null },
|
||||
{ key: 'm', shift: 'M', altGr: 'µ' },
|
||||
{ key: ',', shift: ';', altGr: null },
|
||||
{ key: '.', shift: ':', altGr: null },
|
||||
{ key: '-', shift: '_', altGr: null },
|
||||
]
|
||||
]
|
||||
|
||||
const getKeyValue = (keyObj) => {
|
||||
if (isAltGrActive && keyObj.altGr) return keyObj.altGr
|
||||
if (isShiftActive && keyObj.shift) return keyObj.shift
|
||||
return keyObj.key
|
||||
}
|
||||
|
||||
const handleKeyPress = (keyObj) => {
|
||||
const keyValue = getKeyValue(keyObj)
|
||||
const newValue = value + keyValue
|
||||
onChange(newValue)
|
||||
|
||||
// Reset shift after key press (unless it's a modifier key)
|
||||
if (isShiftActive && keyObj.key !== 'Shift') {
|
||||
setIsShiftActive(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBackspace = () => {
|
||||
onChange(value.slice(0, -1))
|
||||
}
|
||||
|
||||
const handleSpace = () => {
|
||||
onChange(value + ' ')
|
||||
}
|
||||
|
||||
const handleShift = () => {
|
||||
setIsShiftActive(!isShiftActive)
|
||||
if (isAltGrActive) setIsAltGrActive(false)
|
||||
}
|
||||
|
||||
const handleAltGr = () => {
|
||||
setIsAltGrActive(!isAltGrActive)
|
||||
if (isShiftActive) setIsShiftActive(false)
|
||||
}
|
||||
|
||||
const handleEnter = () => {
|
||||
onChange(value + '\n')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="on-screen-keyboard">
|
||||
{keyboardLayout.map((row, rowIndex) => (
|
||||
<div key={rowIndex} className="keyboard-row">
|
||||
{row.map((keyObj, keyIndex) => (
|
||||
<button
|
||||
key={`${rowIndex}-${keyIndex}`}
|
||||
className="keyboard-key"
|
||||
onClick={() => handleKeyPress(keyObj)}
|
||||
>
|
||||
<span className="key-main">{getKeyValue(keyObj)}</span>
|
||||
{(keyObj.shift || keyObj.altGr) && (
|
||||
<div className="key-alternates">
|
||||
{keyObj.shift && (
|
||||
<span className="key-shift">{keyObj.shift}</span>
|
||||
)}
|
||||
{keyObj.altGr && (
|
||||
<span className="key-altgr">{keyObj.altGr}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="keyboard-row keyboard-row--bottom">
|
||||
<button
|
||||
className={`keyboard-key keyboard-key--modifier ${isShiftActive ? 'active' : ''}`}
|
||||
onClick={handleShift}
|
||||
>
|
||||
Shift
|
||||
</button>
|
||||
<button
|
||||
className={`keyboard-key keyboard-key--modifier ${isAltGrActive ? 'active' : ''}`}
|
||||
onClick={handleAltGr}
|
||||
>
|
||||
AltGr
|
||||
</button>
|
||||
<button
|
||||
className="keyboard-key keyboard-key--space"
|
||||
onClick={handleSpace}
|
||||
>
|
||||
Leertaste
|
||||
</button>
|
||||
<button
|
||||
className="keyboard-key keyboard-key--function"
|
||||
onClick={handleEnter}
|
||||
>
|
||||
Enter
|
||||
</button>
|
||||
<button
|
||||
className="keyboard-key keyboard-key--function"
|
||||
onClick={handleBackspace}
|
||||
>
|
||||
⌫
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OnScreenKeyboard
|
@@ -0,0 +1,187 @@
|
||||
.on-screen-keyboard
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 12px
|
||||
max-width: 900px
|
||||
padding: 24px
|
||||
background: rgba(255, 255, 255, 0.05)
|
||||
border-radius: 20px
|
||||
backdrop-filter: blur(10px)
|
||||
-webkit-backdrop-filter: blur(10px)
|
||||
border: 1px solid rgba(255, 255, 255, 0.1)
|
||||
|
||||
.keyboard-row
|
||||
display: flex
|
||||
gap: 8px
|
||||
justify-content: center
|
||||
|
||||
&--bottom
|
||||
margin-top: 12px
|
||||
|
||||
.keyboard-key
|
||||
position: relative
|
||||
min-width: 52px
|
||||
height: 52px
|
||||
border: 1px solid rgba(255, 255, 255, 0.2)
|
||||
background: rgba(255, 255, 255, 0.1)
|
||||
backdrop-filter: blur(10px)
|
||||
-webkit-backdrop-filter: blur(10px)
|
||||
border-radius: 12px
|
||||
cursor: pointer
|
||||
font-family: inherit
|
||||
font-size: 15px
|
||||
font-weight: 500
|
||||
color: rgba(255, 255, 255, 0.9)
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1)
|
||||
user-select: none
|
||||
overflow: hidden
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.1)
|
||||
|
||||
&::before
|
||||
content: ''
|
||||
position: absolute
|
||||
top: 0
|
||||
left: 0
|
||||
right: 0
|
||||
bottom: 0
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0.1))
|
||||
opacity: 0
|
||||
transition: opacity 0.2s ease
|
||||
border-radius: 12px
|
||||
|
||||
&:hover
|
||||
transform: translateY(-2px)
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.2)
|
||||
border-color: rgba(255, 255, 255, 0.3)
|
||||
|
||||
&::before
|
||||
opacity: 1
|
||||
|
||||
&:active
|
||||
transform: translateY(0)
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.1)
|
||||
|
||||
&--modifier
|
||||
min-width: 90px
|
||||
background: rgba(99, 102, 241, 0.2)
|
||||
border-color: rgba(99, 102, 241, 0.3)
|
||||
|
||||
&.active
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.8), rgba(67, 56, 202, 0.8))
|
||||
color: white
|
||||
border-color: rgba(99, 102, 241, 0.6)
|
||||
box-shadow: 0 4px 15px rgba(99, 102, 241, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.2)
|
||||
transform: translateY(-1px)
|
||||
|
||||
&::before
|
||||
opacity: 0
|
||||
|
||||
&--space
|
||||
min-width: 240px
|
||||
background: rgba(59, 130, 246, 0.15)
|
||||
border-color: rgba(59, 130, 246, 0.3)
|
||||
|
||||
&:hover
|
||||
background: rgba(59, 130, 246, 0.25)
|
||||
border-color: rgba(59, 130, 246, 0.4)
|
||||
|
||||
&--function
|
||||
min-width: 90px
|
||||
background: rgba(34, 197, 94, 0.15)
|
||||
border-color: rgba(34, 197, 94, 0.3)
|
||||
color: rgba(255, 255, 255, 0.95)
|
||||
|
||||
&:hover
|
||||
background: rgba(34, 197, 94, 0.25)
|
||||
border-color: rgba(34, 197, 94, 0.4)
|
||||
|
||||
.key-main
|
||||
font-size: 17px
|
||||
font-weight: 600
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1)
|
||||
|
||||
.key-alternates
|
||||
position: absolute
|
||||
top: 4px
|
||||
right: 4px
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 1px
|
||||
|
||||
.key-shift
|
||||
font-size: 9px
|
||||
color: rgba(255, 255, 255, 0.6)
|
||||
line-height: 1
|
||||
text-shadow: none
|
||||
background: rgba(0, 0, 0, 0.2)
|
||||
padding: 1px 3px
|
||||
border-radius: 3px
|
||||
|
||||
.key-altgr
|
||||
font-size: 9px
|
||||
color: rgba(59, 130, 246, 0.8)
|
||||
line-height: 1
|
||||
text-shadow: none
|
||||
background: rgba(59, 130, 246, 0.1)
|
||||
padding: 1px 3px
|
||||
border-radius: 3px
|
||||
|
||||
// Special key animations
|
||||
.keyboard-key--modifier.active
|
||||
animation: pulseActive 2s infinite
|
||||
|
||||
@keyframes pulseActive
|
||||
0%, 100%
|
||||
box-shadow: 0 4px 15px rgba(99, 102, 241, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.2)
|
||||
50%
|
||||
box-shadow: 0 4px 20px rgba(99, 102, 241, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.2)
|
||||
|
||||
@media (max-width: 768px)
|
||||
.on-screen-keyboard
|
||||
padding: 16px
|
||||
gap: 8px
|
||||
|
||||
.keyboard-key
|
||||
min-width: 44px
|
||||
height: 44px
|
||||
font-size: 13px
|
||||
border-radius: 10px
|
||||
|
||||
&--modifier, &--function
|
||||
min-width: 70px
|
||||
|
||||
&--space
|
||||
min-width: 180px
|
||||
|
||||
.key-main
|
||||
font-size: 15px
|
||||
|
||||
.key-alternates
|
||||
top: 2px
|
||||
right: 2px
|
||||
|
||||
.key-shift, .key-altgr
|
||||
font-size: 8px
|
||||
|
||||
@media (max-width: 480px)
|
||||
.on-screen-keyboard
|
||||
padding: 12px
|
||||
gap: 6px
|
||||
|
||||
.keyboard-key
|
||||
min-width: 36px
|
||||
height: 36px
|
||||
font-size: 12px
|
||||
border-radius: 8px
|
||||
|
||||
&--modifier, &--function
|
||||
min-width: 60px
|
||||
|
||||
&--space
|
||||
min-width: 140px
|
||||
|
||||
.key-main
|
||||
font-size: 13px
|
@@ -0,0 +1 @@
|
||||
export { default } from './InputOverlay'
|
@@ -4,30 +4,42 @@
|
||||
margin: 0
|
||||
padding: 0
|
||||
box-sizing: border-box
|
||||
// Improve touch interactions
|
||||
-webkit-tap-highlight-color: transparent
|
||||
-webkit-touch-callout: none
|
||||
-webkit-user-select: none
|
||||
-khtml-user-select: none
|
||||
-moz-user-select: none
|
||||
-ms-user-select: none
|
||||
user-select: none
|
||||
|
||||
html, body
|
||||
height: 100vh
|
||||
width: 100vw
|
||||
overflow: hidden
|
||||
// Prevent scrolling and bouncing on touch devices
|
||||
-webkit-overflow-scrolling: touch
|
||||
overscroll-behavior: none
|
||||
|
||||
body
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif
|
||||
background: linear-gradient(rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.3)), url('https://cdn.pixabay.com/photo/2024/07/16/23/33/waterfall-8900207_1280.png')
|
||||
background-size: cover
|
||||
background-position: center
|
||||
background-attachment: fixed
|
||||
color: #1e293b
|
||||
font-weight: 400
|
||||
letter-spacing: -0.01em
|
||||
line-height: 1.5
|
||||
-webkit-font-smoothing: antialiased
|
||||
-moz-osx-font-smoothing: grayscale
|
||||
// Prevent pull-to-refresh and other touch gestures
|
||||
overscroll-behavior-y: none
|
||||
-webkit-user-select: none
|
||||
|
||||
#root
|
||||
height: 100vh
|
||||
width: 100vw
|
||||
display: flex
|
||||
flex-direction: column
|
||||
// Prevent touch scrolling
|
||||
touch-action: none
|
||||
|
||||
// App Layout Styles
|
||||
.app
|
||||
@@ -35,6 +47,8 @@ body
|
||||
width: 100vw
|
||||
display: flex
|
||||
flex-direction: column
|
||||
// Prevent touch scrolling
|
||||
touch-action: none
|
||||
|
||||
&__main
|
||||
flex: 1
|
||||
@@ -68,4 +82,46 @@ body
|
||||
font-size: 1.25rem
|
||||
color: rgba(30, 41, 59, 0.8)
|
||||
font-weight: 500
|
||||
line-height: 1.6
|
||||
line-height: 1.6
|
||||
|
||||
// Touch-friendly interactive elements
|
||||
button, a, [role="button"], .interactive
|
||||
// Improve touch target size
|
||||
min-height: 44px
|
||||
min-width: 44px
|
||||
// Add touch feedback
|
||||
transition: all 0.2s ease
|
||||
cursor: pointer
|
||||
|
||||
&:hover, &:focus
|
||||
transform: scale(1.05)
|
||||
|
||||
&:active
|
||||
transform: scale(0.95)
|
||||
opacity: 0.8
|
||||
|
||||
// Prevent text selection on interactive elements
|
||||
button, a, [role="button"], .nav-item, .interactive
|
||||
-webkit-user-select: none
|
||||
-moz-user-select: none
|
||||
-ms-user-select: none
|
||||
user-select: none
|
||||
|
||||
// Allow text selection on input fields and content areas
|
||||
input, textarea, [contenteditable], .selectable
|
||||
-webkit-user-select: text
|
||||
-moz-user-select: text
|
||||
-ms-user-select: text
|
||||
user-select: text
|
||||
// Re-enable touch actions for inputs
|
||||
touch-action: manipulation
|
||||
|
||||
// Fullscreen specific styles
|
||||
@media (display-mode: fullscreen)
|
||||
body
|
||||
background-attachment: fixed
|
||||
|
||||
.app
|
||||
// Ensure content fits fullscreen properly
|
||||
min-height: 100vh
|
||||
max-height: 100vh
|
@@ -1,9 +1,361 @@
|
||||
const Calendar = () => {
|
||||
return (
|
||||
<div>
|
||||
<h1>Kalender</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { FaPlus, FaTrash, FaCalendarAlt, FaTimes, FaSave } from 'react-icons/fa';
|
||||
import calendarService from '../services/CalendarService';
|
||||
import './Calendar.sass';
|
||||
|
||||
export default Calendar
|
||||
const Calendar = () => {
|
||||
const [appointments, setAppointments] = useState({});
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [selectedDay, setSelectedDay] = useState(null);
|
||||
const [newAppointment, setNewAppointment] = useState({
|
||||
title: '',
|
||||
user: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadAppointments();
|
||||
}, [currentDate]);
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const userData = await calendarService.getUsers();
|
||||
setUsers(userData);
|
||||
if (userData.length > 0 && !newAppointment.user) {
|
||||
setNewAppointment(prev => ({ ...prev, user: userData[0].name }));
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to load users');
|
||||
console.error('Error loading users:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAppointments = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const year = currentDate.getFullYear();
|
||||
const month = String(currentDate.getMonth() + 1).padStart(2, '0');
|
||||
|
||||
const eventData = await calendarService.getEventsForMonth(year, month, null, 'family');
|
||||
|
||||
// Group events by day
|
||||
const appointmentsByDay = {};
|
||||
eventData.forEach(event => {
|
||||
const day = parseInt(event.date.split('-')[2]);
|
||||
if (!appointmentsByDay[day]) {
|
||||
appointmentsByDay[day] = [];
|
||||
}
|
||||
appointmentsByDay[day].push({
|
||||
id: event.id,
|
||||
title: event.text,
|
||||
user: event.user,
|
||||
type: event.type
|
||||
});
|
||||
});
|
||||
|
||||
setAppointments(appointmentsByDay);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Failed to load appointments');
|
||||
console.error('Error loading appointments:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddAppointment = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!newAppointment.title.trim() || !newAppointment.user) return;
|
||||
|
||||
try {
|
||||
const year = currentDate.getFullYear();
|
||||
const month = String(currentDate.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(selectedDay).padStart(2, '0');
|
||||
const date = `${year}-${month}-${day}`;
|
||||
|
||||
const eventData = {
|
||||
user: newAppointment.user,
|
||||
date: date,
|
||||
text: newAppointment.title
|
||||
};
|
||||
|
||||
const createdEvent = await calendarService.createEvent(eventData);
|
||||
|
||||
// Update local state
|
||||
const newAppt = {
|
||||
id: createdEvent.id,
|
||||
title: newAppointment.title,
|
||||
user: newAppointment.user
|
||||
};
|
||||
|
||||
setAppointments(prev => ({
|
||||
...prev,
|
||||
[selectedDay]: [...(prev[selectedDay] || []), newAppt]
|
||||
}));
|
||||
|
||||
// Reset form
|
||||
setNewAppointment({ title: '', user: users[0]?.name || '' });
|
||||
setShowModal(false);
|
||||
setSelectedDay(null);
|
||||
} catch (err) {
|
||||
setError('Failed to create appointment');
|
||||
console.error('Error creating appointment:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAppointment = async (day, appointmentId, user) => {
|
||||
try {
|
||||
if (appointmentId.includes('birthday-')) {
|
||||
setError('Birthday events cannot be deleted');
|
||||
return;
|
||||
}
|
||||
|
||||
await calendarService.deleteEvent(appointmentId, user);
|
||||
|
||||
setAppointments(prev => ({
|
||||
...prev,
|
||||
[day]: prev[day].filter(appt => appt.id !== appointmentId)
|
||||
}));
|
||||
} catch (err) {
|
||||
setError('Failed to delete appointment');
|
||||
console.error('Error deleting appointment:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const openAddModal = (day) => {
|
||||
setSelectedDay(day);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setShowModal(false);
|
||||
setSelectedDay(null);
|
||||
setNewAppointment({ title: '', user: users[0]?.name || '' });
|
||||
};
|
||||
|
||||
const goToToday = () => {
|
||||
setCurrentDate(new Date());
|
||||
};
|
||||
|
||||
const navigateMonth = (direction) => {
|
||||
const newDate = new Date(currentDate);
|
||||
newDate.setMonth(newDate.getMonth() + direction);
|
||||
setCurrentDate(newDate);
|
||||
};
|
||||
|
||||
const getDayName = (day) => {
|
||||
const date = new Date(currentDate.getFullYear(), currentDate.getMonth(), day);
|
||||
return date.toLocaleDateString('de-DE', { weekday: 'short' }).toUpperCase();
|
||||
};
|
||||
|
||||
const isWeekend = (day) => {
|
||||
const date = new Date(currentDate.getFullYear(), currentDate.getMonth(), day);
|
||||
const dayOfWeek = date.getDay();
|
||||
return dayOfWeek === 0 || dayOfWeek === 6; // Sunday or Saturday
|
||||
};
|
||||
|
||||
const isToday = (day) => {
|
||||
const today = new Date();
|
||||
return (
|
||||
day === today.getDate() &&
|
||||
currentDate.getMonth() === today.getMonth() &&
|
||||
currentDate.getFullYear() === today.getFullYear()
|
||||
);
|
||||
};
|
||||
|
||||
const getCurrentMonthYear = () => {
|
||||
return currentDate.toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const getDaysInMonth = () => {
|
||||
return new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0).getDate();
|
||||
};
|
||||
|
||||
const getUserColor = (userName) => {
|
||||
const colors = [
|
||||
'#3b82f6', // blue
|
||||
'#10b981', // green
|
||||
'#f59e0b', // amber
|
||||
'#ef4444', // red
|
||||
'#8b5cf6', // purple
|
||||
'#06b6d4', // cyan
|
||||
'#ec4899', // pink
|
||||
'#84cc16' // lime
|
||||
];
|
||||
|
||||
const userIndex = users.findIndex(user => user.name === userName);
|
||||
return colors[userIndex % colors.length];
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="calendar-container loading">
|
||||
<div className="loading-spinner">
|
||||
<FaCalendarAlt />
|
||||
<p>Loading calendar...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="calendar-container">
|
||||
{/* Header */}
|
||||
<header className="calendar-header">
|
||||
<div className="header-content">
|
||||
<div className="month-navigation">
|
||||
<button
|
||||
className="nav-btn prev"
|
||||
onClick={() => navigateMonth(-1)}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<h1 className="month-title">{getCurrentMonthYear()}</h1>
|
||||
<button
|
||||
className="nav-btn next"
|
||||
onClick={() => navigateMonth(1)}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
<button className="today-btn" onClick={goToToday}>
|
||||
Today
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
{error}
|
||||
<button onClick={() => setError(null)}>×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Calendar Grid */}
|
||||
<main className="calendar-grid">
|
||||
{Array.from({ length: getDaysInMonth() }, (_, i) => {
|
||||
const day = i + 1;
|
||||
const dayAppointments = appointments[day] || [];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day}
|
||||
className={`day-row ${isWeekend(day) ? 'weekend' : ''} ${isToday(day) ? 'today' : ''}`}
|
||||
>
|
||||
{/* Day Info Box */}
|
||||
<div className="day-info">
|
||||
<span className="day-number">{day}</span>
|
||||
<span className="day-name">{getDayName(day)}</span>
|
||||
</div>
|
||||
|
||||
{/* Add Button */}
|
||||
<button
|
||||
className="add-button"
|
||||
onClick={() => openAddModal(day)}
|
||||
title={`Add appointment for ${day}`}
|
||||
>
|
||||
<FaPlus />
|
||||
</button>
|
||||
|
||||
{/* Appointments Container */}
|
||||
<div className="appointments-container">
|
||||
{dayAppointments.map((appointment, index) => (
|
||||
<div
|
||||
key={appointment.id}
|
||||
className={`appointment-card ${appointment.type === 'birthday' ? 'birthday' : ''}`}
|
||||
style={{ borderLeftColor: getUserColor(appointment.user) }}
|
||||
>
|
||||
<div className="appointment-content">
|
||||
<div className="appointment-title">{appointment.title}</div>
|
||||
<div className="appointment-user" style={{ color: getUserColor(appointment.user) }}>
|
||||
{appointment.user}
|
||||
</div>
|
||||
</div>
|
||||
{appointment.type !== 'birthday' && (
|
||||
<button
|
||||
className="delete-btn"
|
||||
onClick={() => handleDeleteAppointment(day, appointment.id, appointment.user)}
|
||||
title="Delete appointment"
|
||||
>
|
||||
<FaTrash />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</main>
|
||||
|
||||
{/* Add Appointment Modal */}
|
||||
{showModal && (
|
||||
<div className="modal-overlay" onClick={closeModal}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>Add Appointment - Day {selectedDay}</h3>
|
||||
<button className="close-btn" onClick={closeModal}>
|
||||
<FaTimes />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleAddAppointment} className="appointment-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="user">User</label>
|
||||
<select
|
||||
id="user"
|
||||
value={newAppointment.user}
|
||||
onChange={(e) => setNewAppointment({ ...newAppointment, user: e.target.value })}
|
||||
required
|
||||
>
|
||||
{users.map(user => (
|
||||
<option key={user.id} value={user.name}>
|
||||
{user.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="title">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
placeholder="Enter appointment title..."
|
||||
value={newAppointment.title}
|
||||
onChange={(e) => setNewAppointment({ ...newAppointment, title: e.target.value })}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={closeModal}>
|
||||
<FaTimes />
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<FaSave />
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Calendar;
|
||||
|
663
dashboard/src/renderer/src/pages/Calendar.sass
Normal file
@@ -0,0 +1,663 @@
|
||||
// Calendar.sass - Wall-mounted display calendar with glassmorphism design
|
||||
// Optimized for 9:16 aspect ratio, touch interaction, no scrolling
|
||||
|
||||
.calendar-container
|
||||
height: calc(100vh - 5rem - 60px) // Full viewport minus app header minus margins
|
||||
width: calc(100vw - 40px) // Full width minus left/right margins
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05))
|
||||
backdrop-filter: blur(20px) saturate(180%)
|
||||
border: 1px solid rgba(255, 255, 255, 0.2)
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif
|
||||
color: #1a1a1a
|
||||
position: fixed
|
||||
top: calc(5rem + 40px) // Account for the app header plus top margin
|
||||
left: 20px // Left margin
|
||||
right: 20px // Right margin
|
||||
bottom: 20px // Bottom margin
|
||||
overflow: hidden
|
||||
display: flex
|
||||
flex-direction: column
|
||||
margin: 0
|
||||
padding: 0
|
||||
border-radius: 20px
|
||||
box-shadow: 0 8px 32px rgba(31, 38, 135, 0.37), inset 0 1px 0 rgba(255, 255, 255, 0.4)
|
||||
|
||||
// Loading state
|
||||
&.loading
|
||||
display: flex
|
||||
justify-content: center
|
||||
align-items: center
|
||||
|
||||
.loading-spinner
|
||||
background: rgba(255, 255, 255, 0.25)
|
||||
backdrop-filter: blur(40px) saturate(180%)
|
||||
border: 1px solid rgba(255, 255, 255, 0.3)
|
||||
border-radius: 20px
|
||||
padding: 40px
|
||||
text-align: center
|
||||
box-shadow: 0 8px 32px rgba(31, 38, 135, 0.37), inset 0 1px 0 rgba(255, 255, 255, 0.4)
|
||||
|
||||
svg
|
||||
font-size: 3rem
|
||||
color: #ffffff
|
||||
margin-bottom: 20px
|
||||
animation: pulse 2s infinite
|
||||
|
||||
p
|
||||
color: #ffffff
|
||||
font-size: 1.2rem
|
||||
margin: 0
|
||||
|
||||
// Header with glassmorphism
|
||||
.calendar-header
|
||||
background: rgba(255, 255, 255, 0.15)
|
||||
backdrop-filter: blur(30px) saturate(180%)
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2)
|
||||
padding: 10px 15px
|
||||
position: sticky
|
||||
top: 0
|
||||
z-index: 100
|
||||
flex-shrink: 0
|
||||
height: 60px
|
||||
box-sizing: border-box
|
||||
width: 100%
|
||||
margin: 0
|
||||
border-radius: 20px 20px 0 0
|
||||
|
||||
.header-content
|
||||
display: flex
|
||||
justify-content: space-between
|
||||
align-items: center
|
||||
width: 100%
|
||||
height: 100%
|
||||
margin: 0
|
||||
padding: 0
|
||||
|
||||
.month-navigation
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 20px
|
||||
|
||||
.nav-btn
|
||||
background: rgba(255, 255, 255, 0.2)
|
||||
backdrop-filter: blur(20px) saturate(180%)
|
||||
border: 1px solid rgba(255, 255, 255, 0.3)
|
||||
color: #ffffff
|
||||
font-size: 1.5rem
|
||||
width: 45px
|
||||
height: 45px
|
||||
border-radius: 50%
|
||||
cursor: pointer
|
||||
transition: all 0.3s ease
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
box-shadow: 0 4px 16px rgba(31, 38, 135, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.4)
|
||||
|
||||
&:hover
|
||||
background: rgba(255, 255, 255, 0.3)
|
||||
transform: scale(1.05)
|
||||
box-shadow: 0 6px 20px rgba(31, 38, 135, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.5)
|
||||
|
||||
&:active
|
||||
transform: scale(0.95)
|
||||
|
||||
.month-title
|
||||
color: #ffffff
|
||||
font-size: 1.8rem
|
||||
font-weight: 600
|
||||
margin: 0
|
||||
text-shadow: none
|
||||
min-width: 250px
|
||||
text-align: center
|
||||
|
||||
.today-btn
|
||||
background: rgba(255, 255, 255, 0.2)
|
||||
backdrop-filter: blur(20px) saturate(180%)
|
||||
border: 1px solid rgba(255, 255, 255, 0.3)
|
||||
color: #ffffff
|
||||
padding: 10px 20px
|
||||
border-radius: 25px
|
||||
cursor: pointer
|
||||
font-size: 0.9rem
|
||||
font-weight: 500
|
||||
transition: all 0.3s ease
|
||||
box-shadow: 0 4px 16px rgba(31, 38, 135, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.4)
|
||||
|
||||
&:hover
|
||||
background: rgba(255, 255, 255, 0.3)
|
||||
transform: translateY(-2px)
|
||||
box-shadow: 0 6px 20px rgba(31, 38, 135, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.5)
|
||||
|
||||
// Error message
|
||||
.error-message
|
||||
background: rgba(220, 38, 38, 0.9)
|
||||
color: white
|
||||
padding: 10px 15px
|
||||
margin: 0
|
||||
border-radius: 0
|
||||
display: flex
|
||||
justify-content: space-between
|
||||
align-items: center
|
||||
backdrop-filter: blur(10px)
|
||||
animation: slideDown 0.3s ease
|
||||
width: 100%
|
||||
box-sizing: border-box
|
||||
|
||||
button
|
||||
background: none
|
||||
border: none
|
||||
color: white
|
||||
font-size: 1.2rem
|
||||
cursor: pointer
|
||||
padding: 0
|
||||
width: 30px
|
||||
height: 30px
|
||||
border-radius: 50%
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
|
||||
&:hover
|
||||
background: rgba(255, 255, 255, 0.2)
|
||||
|
||||
// Main calendar grid - optimized for no scrolling
|
||||
.calendar-grid
|
||||
flex: 1
|
||||
padding: 12px
|
||||
display: grid
|
||||
grid-template-columns: 1fr
|
||||
gap: 3px
|
||||
width: 100%
|
||||
box-sizing: border-box
|
||||
margin: 0
|
||||
|
||||
// Calculate exact height using the remaining space after header
|
||||
height: calc(100% - 60px) // Full container height minus calendar header
|
||||
grid-template-rows: repeat(31, 1fr)
|
||||
overflow: hidden
|
||||
|
||||
// Individual day row with three distinct sections
|
||||
.day-row
|
||||
display: grid
|
||||
grid-template-columns: 120px 50px 1fr
|
||||
gap: 8px
|
||||
align-items: stretch
|
||||
height: 100%
|
||||
padding: 0
|
||||
margin: 0
|
||||
transition: all 0.3s ease
|
||||
width: 100%
|
||||
box-sizing: border-box
|
||||
|
||||
// Weekend styling
|
||||
&.weekend
|
||||
.day-info
|
||||
background: rgba(200, 200, 200, 0.2)
|
||||
backdrop-filter: blur(20px) saturate(180%)
|
||||
border: 1px solid rgba(200, 200, 200, 0.3)
|
||||
|
||||
.day-number
|
||||
color: #ffffff
|
||||
|
||||
.day-name
|
||||
color: #e5e7eb
|
||||
|
||||
.appointments-container
|
||||
background: rgba(200, 200, 200, 0.15)
|
||||
backdrop-filter: blur(20px) saturate(180%)
|
||||
border: 1px solid rgba(200, 200, 200, 0.25)
|
||||
|
||||
// Today highlighting
|
||||
&.today
|
||||
.day-info
|
||||
background: rgba(59, 130, 246, 0.25)
|
||||
backdrop-filter: blur(20px) saturate(180%)
|
||||
border: 2px solid rgba(59, 130, 246, 0.4)
|
||||
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.5)
|
||||
|
||||
.day-number
|
||||
color: #1e40af
|
||||
font-weight: 800
|
||||
|
||||
.day-name
|
||||
color: #3b82f6
|
||||
font-weight: 700
|
||||
|
||||
.appointments-container
|
||||
background: rgba(59, 130, 246, 0.15)
|
||||
border: 1px solid rgba(59, 130, 246, 0.3)
|
||||
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.4)
|
||||
|
||||
// Day info box (left section)
|
||||
.day-info
|
||||
background: rgba(255, 255, 255, 0.2)
|
||||
backdrop-filter: blur(20px) saturate(180%)
|
||||
border: 1px solid rgba(255, 255, 255, 0.3)
|
||||
border-radius: 12px
|
||||
padding: 8px
|
||||
display: flex
|
||||
flex-direction: row
|
||||
align-items: center
|
||||
justify-content: center
|
||||
gap: 6px
|
||||
box-shadow: 0 4px 16px rgba(31, 38, 135, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.4)
|
||||
transition: all 0.3s ease
|
||||
height: 100%
|
||||
width: 100%
|
||||
box-sizing: border-box
|
||||
|
||||
.day-number
|
||||
font-size: 1.3rem
|
||||
font-weight: 700
|
||||
color: #ffffff
|
||||
line-height: 1
|
||||
text-shadow: none
|
||||
|
||||
.day-name
|
||||
font-size: 0.7rem
|
||||
color: #e5e7eb
|
||||
font-weight: 600
|
||||
text-transform: uppercase
|
||||
letter-spacing: 0.3px
|
||||
text-shadow: none
|
||||
|
||||
// Add button (center section)
|
||||
.add-button
|
||||
background: linear-gradient(135deg, rgba(34, 197, 94, 0.8), rgba(16, 185, 129, 0.8))
|
||||
backdrop-filter: blur(20px) saturate(180%)
|
||||
border: 1px solid rgba(255, 255, 255, 0.3)
|
||||
color: white
|
||||
width: 45px
|
||||
height: 45px
|
||||
border-radius: 50%
|
||||
cursor: pointer
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
font-size: 1.1rem
|
||||
transition: all 0.3s ease
|
||||
box-shadow: 0 4px 16px rgba(34, 197, 94, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.4)
|
||||
align-self: center
|
||||
|
||||
&:hover
|
||||
background: linear-gradient(135deg, rgba(34, 197, 94, 1), rgba(16, 185, 129, 1))
|
||||
transform: scale(1.1)
|
||||
box-shadow: 0 6px 20px rgba(34, 197, 94, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.5)
|
||||
|
||||
&:active
|
||||
transform: scale(0.95)
|
||||
|
||||
svg
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2))
|
||||
|
||||
// Appointments container (right section)
|
||||
.appointments-container
|
||||
background: rgba(255, 255, 255, 0.2)
|
||||
backdrop-filter: blur(20px) saturate(180%)
|
||||
border: 1px solid rgba(255, 255, 255, 0.3)
|
||||
border-radius: 12px
|
||||
padding: 6px
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
gap: 4px
|
||||
height: 100%
|
||||
width: 100%
|
||||
align-items: flex-start
|
||||
align-content: flex-start
|
||||
box-shadow: 0 4px 16px rgba(31, 38, 135, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.4)
|
||||
transition: all 0.3s ease
|
||||
box-sizing: border-box
|
||||
|
||||
// When empty, show subtle hint
|
||||
&:empty::after
|
||||
content: ""
|
||||
width: 100%
|
||||
height: 100%
|
||||
display: block
|
||||
|
||||
// Individual appointment cards
|
||||
.appointment-card
|
||||
background: rgba(255, 255, 255, 0.25)
|
||||
backdrop-filter: blur(15px) saturate(180%)
|
||||
border: 1px solid rgba(255, 255, 255, 0.3)
|
||||
border-radius: 8px
|
||||
padding: 6px 8px
|
||||
border-left: 3px solid #3b82f6
|
||||
box-shadow: 0 2px 8px rgba(31, 38, 135, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.4)
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 6px
|
||||
transition: all 0.3s ease
|
||||
min-width: 80px
|
||||
max-width: 150px
|
||||
font-size: 0.7rem
|
||||
|
||||
&:hover
|
||||
transform: translateY(-2px)
|
||||
background: rgba(255, 255, 255, 0.3)
|
||||
box-shadow: 0 4px 16px rgba(31, 38, 135, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.5)
|
||||
|
||||
&.birthday
|
||||
border-left-color: #f59e0b
|
||||
background: rgba(251, 191, 36, 0.2)
|
||||
|
||||
.appointment-content
|
||||
flex: 1
|
||||
min-width: 0
|
||||
|
||||
.appointment-title
|
||||
font-size: 0.65rem
|
||||
font-weight: 600
|
||||
color: #ffffff
|
||||
line-height: 1.2
|
||||
overflow: hidden
|
||||
text-overflow: ellipsis
|
||||
white-space: nowrap
|
||||
text-shadow: none
|
||||
|
||||
.appointment-user
|
||||
font-size: 0.55rem
|
||||
font-weight: 600
|
||||
margin-top: 2px
|
||||
text-transform: uppercase
|
||||
letter-spacing: 0.3px
|
||||
text-shadow: none
|
||||
color: #d1d5db
|
||||
|
||||
.delete-btn
|
||||
background: rgba(239, 68, 68, 0.2)
|
||||
backdrop-filter: blur(10px)
|
||||
border: 1px solid rgba(255, 255, 255, 0.3)
|
||||
color: #dc2626
|
||||
width: 22px
|
||||
height: 22px
|
||||
border-radius: 50%
|
||||
cursor: pointer
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
font-size: 0.6rem
|
||||
transition: all 0.3s ease
|
||||
opacity: 0.8
|
||||
flex-shrink: 0
|
||||
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.4)
|
||||
|
||||
&:hover
|
||||
background: rgba(239, 68, 68, 0.3)
|
||||
opacity: 1
|
||||
transform: scale(1.1)
|
||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.5)
|
||||
|
||||
&:active
|
||||
transform: scale(0.9)
|
||||
|
||||
// Modal styling with glassmorphism
|
||||
.modal-overlay
|
||||
position: fixed
|
||||
top: 0
|
||||
left: 0
|
||||
right: 0
|
||||
bottom: 0
|
||||
background: rgba(0, 0, 0, 0.5)
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
z-index: 1000
|
||||
backdrop-filter: blur(10px)
|
||||
animation: fadeIn 0.3s ease
|
||||
|
||||
.modal-content
|
||||
background: rgba(255, 255, 255, 0.15)
|
||||
backdrop-filter: blur(40px) saturate(180%)
|
||||
border: 1px solid rgba(255, 255, 255, 0.3)
|
||||
border-radius: 24px
|
||||
padding: 0
|
||||
width: 90%
|
||||
max-width: 500px
|
||||
box-shadow: 0 20px 60px rgba(31, 38, 135, 0.37), inset 0 1px 0 rgba(255, 255, 255, 0.4)
|
||||
animation: slideUp 0.3s ease
|
||||
overflow: hidden
|
||||
|
||||
.modal-header
|
||||
background: rgba(59, 130, 246, 0.15)
|
||||
backdrop-filter: blur(20px) saturate(180%)
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2)
|
||||
padding: 20px 25px
|
||||
display: flex
|
||||
justify-content: space-between
|
||||
align-items: center
|
||||
|
||||
h3
|
||||
margin: 0
|
||||
color: #ffffff
|
||||
font-size: 1.3rem
|
||||
font-weight: 600
|
||||
text-shadow: none
|
||||
|
||||
.close-btn
|
||||
background: rgba(239, 68, 68, 0.15)
|
||||
backdrop-filter: blur(15px)
|
||||
border: 1px solid rgba(255, 255, 255, 0.3)
|
||||
color: #dc2626
|
||||
font-size: 1.2rem
|
||||
cursor: pointer
|
||||
width: 35px
|
||||
height: 35px
|
||||
border-radius: 50%
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
transition: all 0.3s ease
|
||||
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.4)
|
||||
|
||||
&:hover
|
||||
background: rgba(239, 68, 68, 0.25)
|
||||
transform: scale(1.1)
|
||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.5)
|
||||
|
||||
// Form styling
|
||||
.appointment-form
|
||||
padding: 25px
|
||||
|
||||
.form-group
|
||||
margin-bottom: 20px
|
||||
|
||||
label
|
||||
display: block
|
||||
margin-bottom: 8px
|
||||
color: #ffffff
|
||||
font-weight: 500
|
||||
font-size: 0.9rem
|
||||
|
||||
input, select
|
||||
width: 100%
|
||||
padding: 12px 15px
|
||||
border: 1px solid rgba(255, 255, 255, 0.3)
|
||||
border-radius: 12px
|
||||
font-size: 1rem
|
||||
background: rgba(255, 255, 255, 0.2)
|
||||
backdrop-filter: blur(20px) saturate(180%)
|
||||
box-shadow: 0 2px 8px rgba(31, 38, 135, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.4)
|
||||
transition: all 0.3s ease
|
||||
box-sizing: border-box
|
||||
color: #000000
|
||||
|
||||
&:focus
|
||||
outline: none
|
||||
border-color: rgba(59, 130, 246, 0.5)
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1), 0 2px 8px rgba(31, 38, 135, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.5)
|
||||
background: rgba(255, 255, 255, 0.3)
|
||||
|
||||
&::placeholder
|
||||
color: #9ca3af
|
||||
|
||||
.form-actions
|
||||
display: flex
|
||||
gap: 15px
|
||||
justify-content: flex-end
|
||||
margin-top: 30px
|
||||
|
||||
.btn
|
||||
padding: 12px 20px
|
||||
border: 1px solid rgba(255, 255, 255, 0.3)
|
||||
border-radius: 12px
|
||||
font-size: 1rem
|
||||
font-weight: 500
|
||||
cursor: pointer
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 8px
|
||||
transition: all 0.3s ease
|
||||
backdrop-filter: blur(20px) saturate(180%)
|
||||
box-shadow: 0 2px 8px rgba(31, 38, 135, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.4)
|
||||
|
||||
&.btn-secondary
|
||||
background: rgba(107, 114, 128, 0.15)
|
||||
color: #ffffff
|
||||
|
||||
&:hover
|
||||
background: rgba(107, 114, 128, 0.25)
|
||||
transform: translateY(-2px)
|
||||
box-shadow: 0 4px 12px rgba(107, 114, 128, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.5)
|
||||
|
||||
&.btn-primary
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(29, 78, 216, 0.8))
|
||||
color: white
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2)
|
||||
|
||||
&:hover
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 1), rgba(29, 78, 216, 1))
|
||||
transform: translateY(-2px)
|
||||
box-shadow: 0 6px 16px rgba(59, 130, 246, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.5)
|
||||
|
||||
&:active
|
||||
transform: translateY(0)
|
||||
|
||||
// Animations
|
||||
@keyframes fadeIn
|
||||
from
|
||||
opacity: 0
|
||||
to
|
||||
opacity: 1
|
||||
|
||||
@keyframes slideUp
|
||||
from
|
||||
opacity: 0
|
||||
transform: translateY(50px)
|
||||
to
|
||||
opacity: 1
|
||||
transform: translateY(0)
|
||||
|
||||
@keyframes slideDown
|
||||
from
|
||||
opacity: 0
|
||||
transform: translateY(-20px)
|
||||
to
|
||||
opacity: 1
|
||||
transform: translateY(0)
|
||||
|
||||
@keyframes pulse
|
||||
0%, 100%
|
||||
opacity: 1
|
||||
50%
|
||||
opacity: 0.5
|
||||
|
||||
// Responsive adjustments for different screen sizes
|
||||
@media (max-height: 900px)
|
||||
.calendar-container
|
||||
top: calc(5rem + 15px)
|
||||
left: 15px
|
||||
right: 15px
|
||||
bottom: 15px
|
||||
width: calc(100vw - 30px)
|
||||
height: calc(100vh - 5rem - 30px)
|
||||
|
||||
.calendar-grid
|
||||
gap: 2px
|
||||
padding: 8px
|
||||
|
||||
.day-row
|
||||
gap: 6px
|
||||
grid-template-columns: 110px 45px 1fr
|
||||
|
||||
.day-info
|
||||
padding: 6px
|
||||
|
||||
.day-number
|
||||
font-size: 1rem
|
||||
|
||||
.day-name
|
||||
font-size: 0.6rem
|
||||
|
||||
.appointments-container
|
||||
padding: 4px
|
||||
|
||||
@media (max-height: 800px)
|
||||
.calendar-container
|
||||
top: calc(5rem + 10px)
|
||||
left: 10px
|
||||
right: 10px
|
||||
bottom: 10px
|
||||
width: calc(100vw - 20px)
|
||||
height: calc(100vh - 5rem - 20px)
|
||||
|
||||
.calendar-grid
|
||||
gap: 1px
|
||||
padding: 6px
|
||||
|
||||
.day-row
|
||||
gap: 4px
|
||||
grid-template-columns: 100px 40px 1fr
|
||||
|
||||
.day-info
|
||||
padding: 4px
|
||||
|
||||
.day-number
|
||||
font-size: 0.9rem
|
||||
|
||||
.day-name
|
||||
font-size: 0.55rem
|
||||
|
||||
.appointments-container
|
||||
padding: 3px
|
||||
|
||||
.add-button
|
||||
width: 35px
|
||||
height: 35px
|
||||
font-size: 0.9rem
|
||||
|
||||
@media (min-width: 1400px)
|
||||
.calendar-container
|
||||
top: calc(5rem + 30px)
|
||||
left: 30px
|
||||
right: 30px
|
||||
bottom: 30px
|
||||
width: calc(100vw - 60px)
|
||||
height: calc(100vh - 5rem - 60px)
|
||||
|
||||
.calendar-grid
|
||||
gap: 4px
|
||||
padding: 16px
|
||||
|
||||
.day-row
|
||||
gap: 10px
|
||||
grid-template-columns: 140px 55px 1fr
|
||||
|
||||
// Touch optimization for wall displays
|
||||
@media (pointer: coarse)
|
||||
.add-button
|
||||
width: 50px
|
||||
height: 50px
|
||||
|
||||
.delete-btn
|
||||
width: 28px
|
||||
height: 28px
|
||||
|
||||
.nav-btn
|
||||
width: 50px
|
||||
height: 50px
|
||||
|
||||
.today-btn
|
||||
padding: 15px 30px
|
||||
font-size: 1.1rem
|
@@ -1,16 +1,23 @@
|
||||
import { useRef, useEffect, useState } from 'react'
|
||||
import { useRef, useEffect, useState, useCallback, useMemo } from 'react'
|
||||
import { FiEdit3, FiTrash, FiChevronDown } from 'react-icons/fi'
|
||||
import { TbEraser } from 'react-icons/tb'
|
||||
import './Notes.sass'
|
||||
|
||||
const Notes = () => {
|
||||
const canvasRef = useRef(null)
|
||||
const ctxRef = useRef(null)
|
||||
const rectRef = useRef(null)
|
||||
const saveTimeoutRef = useRef(null)
|
||||
const lastDrawTimeRef = useRef(0)
|
||||
const pathPointsRef = useRef([])
|
||||
const animationFrameRef = useRef(null)
|
||||
const [isDrawing, setIsDrawing] = useState(false)
|
||||
const [tool, setTool] = useState('pen') // 'pen' or 'eraser'
|
||||
const [penColor, setPenColor] = useState('#2d3748')
|
||||
const [showColorPicker, setShowColorPicker] = useState(false)
|
||||
|
||||
const colors = [
|
||||
// Memoize colors array to prevent recreation on every render
|
||||
const colors = useMemo(() => [
|
||||
'#2d3748', // Dark gray
|
||||
'#000000', // Black
|
||||
'#e53e3e', // Red
|
||||
@@ -21,47 +28,13 @@ const Notes = () => {
|
||||
'#dd6b20', // Orange
|
||||
'#e91e63', // Pink
|
||||
'#00acc1' // Cyan
|
||||
]
|
||||
], [])
|
||||
|
||||
useEffect(() => {
|
||||
const loadCanvasFromStorage = useCallback(() => {
|
||||
const canvas = canvasRef.current
|
||||
const ctx = canvas.getContext('2d')
|
||||
const ctx = ctxRef.current
|
||||
if (!canvas || !ctx) return
|
||||
|
||||
// Set canvas size
|
||||
const resizeCanvas = () => {
|
||||
const container = canvas.parentElement
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
||||
|
||||
canvas.width = container.clientWidth
|
||||
canvas.height = container.clientHeight
|
||||
|
||||
// Set drawing properties
|
||||
ctx.lineCap = 'round'
|
||||
ctx.lineJoin = 'round'
|
||||
ctx.imageSmoothingEnabled = true
|
||||
|
||||
// Restore canvas content after resize
|
||||
ctx.putImageData(imageData, 0, 0)
|
||||
|
||||
// Load saved canvas data
|
||||
loadCanvasFromStorage()
|
||||
}
|
||||
|
||||
resizeCanvas()
|
||||
window.addEventListener('resize', resizeCanvas)
|
||||
|
||||
return () => window.removeEventListener('resize', resizeCanvas)
|
||||
}, [])
|
||||
|
||||
const saveCanvasToStorage = () => {
|
||||
const canvas = canvasRef.current
|
||||
const dataURL = canvas.toDataURL()
|
||||
localStorage.setItem('notes-canvas', dataURL)
|
||||
}
|
||||
|
||||
const loadCanvasFromStorage = () => {
|
||||
const canvas = canvasRef.current
|
||||
const ctx = canvas.getContext('2d')
|
||||
const savedData = localStorage.getItem('notes-canvas')
|
||||
|
||||
if (savedData) {
|
||||
@@ -72,58 +45,325 @@ const Notes = () => {
|
||||
}
|
||||
img.src = savedData
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startDrawing = (e) => {
|
||||
setIsDrawing(true)
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
ctxRef.current = ctx
|
||||
|
||||
const x = e.clientX - rect.left
|
||||
const y = e.clientY - rect.top
|
||||
// Set canvas size and cache the rect
|
||||
const resizeCanvas = () => {
|
||||
const container = canvas.parentElement
|
||||
if (!container) return
|
||||
|
||||
canvas.width = container.clientWidth
|
||||
canvas.height = container.clientHeight
|
||||
|
||||
// Set white background
|
||||
ctx.fillStyle = 'white'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// Set drawing properties
|
||||
ctx.lineCap = 'round'
|
||||
ctx.lineJoin = 'round'
|
||||
ctx.imageSmoothingEnabled = true
|
||||
|
||||
// Cache the bounding rect
|
||||
rectRef.current = canvas.getBoundingClientRect()
|
||||
|
||||
// Load saved canvas data from storage
|
||||
loadCanvasFromStorage()
|
||||
}
|
||||
|
||||
resizeCanvas()
|
||||
|
||||
// Debounced resize handler
|
||||
let resizeTimeout
|
||||
const handleResize = () => {
|
||||
clearTimeout(resizeTimeout)
|
||||
resizeTimeout = setTimeout(resizeCanvas, 150)
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
clearTimeout(resizeTimeout)
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current)
|
||||
}
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
}
|
||||
}
|
||||
}, [loadCanvasFromStorage])
|
||||
|
||||
// Optimized save function with compression and size check
|
||||
const debouncedSave = useCallback(() => {
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current)
|
||||
}
|
||||
saveTimeoutRef.current = setTimeout(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
try {
|
||||
// Use JPEG for better compression on large canvases
|
||||
const dataURL = canvas.width * canvas.height > 500000
|
||||
? canvas.toDataURL('image/jpeg', 0.8)
|
||||
: canvas.toDataURL('image/png')
|
||||
|
||||
// Check if data is too large for localStorage (usually 5-10MB limit)
|
||||
if (dataURL.length < 4900000) { // ~5MB safety margin
|
||||
localStorage.setItem('notes-canvas', dataURL)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to save canvas:', error)
|
||||
}
|
||||
}, 500) // Save 500ms after user stops drawing
|
||||
}, [])
|
||||
|
||||
const saveCanvasToStorage = useCallback(() => {
|
||||
debouncedSave()
|
||||
}, [debouncedSave])
|
||||
|
||||
// Optimized drawing with path smoothing and throttling
|
||||
const drawSmoothPath = useCallback(() => {
|
||||
const ctx = ctxRef.current
|
||||
const points = pathPointsRef.current
|
||||
|
||||
if (!ctx || points.length < 2) return
|
||||
|
||||
// Use quadratic curves for smoother lines
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, y)
|
||||
}
|
||||
|
||||
const draw = (e) => {
|
||||
if (!isDrawing) return
|
||||
ctx.moveTo(points[0].x, points[0].y)
|
||||
|
||||
const canvas = canvasRef.current
|
||||
const ctx = canvas.getContext('2d')
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
for (let i = 1; i < points.length - 1; i++) {
|
||||
const currentPoint = points[i]
|
||||
const nextPoint = points[i + 1]
|
||||
const cpx = (currentPoint.x + nextPoint.x) / 2
|
||||
const cpy = (currentPoint.y + nextPoint.y) / 2
|
||||
ctx.quadraticCurveTo(currentPoint.x, currentPoint.y, cpx, cpy)
|
||||
}
|
||||
|
||||
// Draw the last point
|
||||
if (points.length > 1) {
|
||||
const lastPoint = points[points.length - 1]
|
||||
ctx.lineTo(lastPoint.x, lastPoint.y)
|
||||
}
|
||||
|
||||
ctx.stroke()
|
||||
}, [])
|
||||
|
||||
const startDrawing = useCallback((e) => {
|
||||
setIsDrawing(true)
|
||||
const ctx = ctxRef.current
|
||||
const rect = rectRef.current || canvasRef.current.getBoundingClientRect()
|
||||
|
||||
if (!ctx) return
|
||||
|
||||
const x = e.clientX - rect.left
|
||||
const y = e.clientY - rect.top
|
||||
|
||||
// Reset path points for new stroke
|
||||
pathPointsRef.current = [{ x, y }]
|
||||
|
||||
// Set tool properties once at the start of drawing
|
||||
if (tool === 'pen') {
|
||||
ctx.globalCompositeOperation = 'source-over'
|
||||
ctx.strokeStyle = penColor
|
||||
ctx.lineWidth = 3
|
||||
ctx.lineTo(x, y)
|
||||
ctx.stroke()
|
||||
} else if (tool === 'eraser') {
|
||||
ctx.globalCompositeOperation = 'destination-out'
|
||||
ctx.lineWidth = 40 // Increased eraser size
|
||||
ctx.lineTo(x, y)
|
||||
ctx.stroke()
|
||||
ctx.lineWidth = 40
|
||||
}
|
||||
}
|
||||
|
||||
// Draw initial dot using stroke for consistency
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, y)
|
||||
ctx.lineTo(x + 0.1, y + 0.1) // Small offset to ensure a visible dot
|
||||
ctx.stroke()
|
||||
}, [tool, penColor])
|
||||
|
||||
const stopDrawing = () => {
|
||||
// Throttled drawing with requestAnimationFrame for smooth performance
|
||||
const draw = useCallback((e) => {
|
||||
if (!isDrawing) return
|
||||
|
||||
const now = performance.now()
|
||||
const timeDiff = now - lastDrawTimeRef.current
|
||||
|
||||
// Throttle to ~60fps for performance
|
||||
if (timeDiff < 16) return
|
||||
|
||||
const rect = rectRef.current || canvasRef.current.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const y = e.clientY - rect.top
|
||||
|
||||
// Add point to path
|
||||
pathPointsRef.current.push({ x, y })
|
||||
|
||||
// Limit path points to prevent memory issues
|
||||
if (pathPointsRef.current.length > 50) {
|
||||
pathPointsRef.current = pathPointsRef.current.slice(-25)
|
||||
}
|
||||
|
||||
// Cancel previous animation frame
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
}
|
||||
|
||||
// Schedule drawing on next frame
|
||||
animationFrameRef.current = requestAnimationFrame(() => {
|
||||
const ctx = ctxRef.current
|
||||
if (!ctx) return
|
||||
|
||||
// Simple line drawing for real-time feedback
|
||||
const points = pathPointsRef.current
|
||||
if (points.length >= 2) {
|
||||
const lastTwo = points.slice(-2)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(lastTwo[0].x, lastTwo[0].y)
|
||||
ctx.lineTo(lastTwo[1].x, lastTwo[1].y)
|
||||
ctx.stroke()
|
||||
}
|
||||
})
|
||||
|
||||
lastDrawTimeRef.current = now
|
||||
}, [isDrawing])
|
||||
|
||||
const stopDrawing = useCallback(() => {
|
||||
if (!isDrawing) return
|
||||
|
||||
setIsDrawing(false)
|
||||
// Save canvas state after drawing
|
||||
|
||||
// Cancel any pending animation frame
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
animationFrameRef.current = null
|
||||
}
|
||||
|
||||
// Draw final smooth path if we have enough points
|
||||
if (pathPointsRef.current.length > 2) {
|
||||
drawSmoothPath()
|
||||
}
|
||||
|
||||
// Clear path points
|
||||
pathPointsRef.current = []
|
||||
|
||||
// Save canvas state after drawing (debounced)
|
||||
saveCanvasToStorage()
|
||||
}
|
||||
}, [isDrawing, drawSmoothPath, saveCanvasToStorage])
|
||||
|
||||
const clearCanvas = () => {
|
||||
// Optimized touch event handlers
|
||||
const handleTouchStart = useCallback((e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
// Only handle single touch
|
||||
if (e.touches.length !== 1) return
|
||||
|
||||
const touch = e.touches[0]
|
||||
const mouseEvent = new MouseEvent('mousedown', {
|
||||
clientX: touch.clientX,
|
||||
clientY: touch.clientY,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
startDrawing(mouseEvent)
|
||||
}, [startDrawing])
|
||||
|
||||
const handleTouchMove = useCallback((e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
// Only handle single touch
|
||||
if (e.touches.length !== 1) return
|
||||
|
||||
const touch = e.touches[0]
|
||||
const mouseEvent = new MouseEvent('mousemove', {
|
||||
clientX: touch.clientX,
|
||||
clientY: touch.clientY,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
draw(mouseEvent)
|
||||
}, [draw])
|
||||
|
||||
const handleTouchEnd = useCallback((e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
stopDrawing()
|
||||
}, [stopDrawing])
|
||||
|
||||
const clearCanvas = useCallback(() => {
|
||||
const canvas = canvasRef.current
|
||||
const ctx = canvas.getContext('2d')
|
||||
const ctx = ctxRef.current
|
||||
if (!canvas || !ctx) return
|
||||
|
||||
// Clear and set white background
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
ctx.fillStyle = 'white'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// Clear saved state
|
||||
localStorage.removeItem('notes-canvas')
|
||||
}
|
||||
// Clear any pending save
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Update rect when canvas is clicked (in case of layout changes)
|
||||
const updateRect = useCallback(() => {
|
||||
rectRef.current = canvasRef.current.getBoundingClientRect()
|
||||
}, [])
|
||||
|
||||
// Memoized color picker to prevent recreation
|
||||
const colorPicker = useMemo(() => (
|
||||
showColorPicker && (
|
||||
<div className="color-picker-popover">
|
||||
<div className="color-grid">
|
||||
{colors.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
className={`color-option ${penColor === color ? 'active' : ''}`}
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={() => {
|
||||
setPenColor(color)
|
||||
setShowColorPicker(false)
|
||||
}}
|
||||
title={`Farbe: ${color}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
), [showColorPicker, colors, penColor])
|
||||
|
||||
// Memoized toolbar buttons to prevent unnecessary re-renders
|
||||
const toolbarButtons = useMemo(() => (
|
||||
<>
|
||||
<button
|
||||
className={`tool-button ${tool === 'pen' ? 'active' : ''}`}
|
||||
onClick={() => setTool('pen')}
|
||||
title="Stift"
|
||||
style={{ backgroundColor: tool === 'pen' ? penColor + '20' : undefined }}
|
||||
>
|
||||
<FiEdit3 style={{ color: tool === 'pen' ? penColor : undefined }} />
|
||||
</button>
|
||||
<button
|
||||
className="color-picker-button"
|
||||
onClick={() => setShowColorPicker(!showColorPicker)}
|
||||
title="Farbe wählen"
|
||||
style={{ backgroundColor: penColor }}
|
||||
>
|
||||
<FiChevronDown />
|
||||
</button>
|
||||
</>
|
||||
), [tool, penColor, showColorPicker])
|
||||
|
||||
return (
|
||||
<div className="notes-page">
|
||||
@@ -131,40 +371,8 @@ const Notes = () => {
|
||||
{/* Toolbar */}
|
||||
<div className="notes-toolbar">
|
||||
<div className="pen-tool-group">
|
||||
<button
|
||||
className={`tool-button ${tool === 'pen' ? 'active' : ''}`}
|
||||
onClick={() => setTool('pen')}
|
||||
title="Stift"
|
||||
style={{ backgroundColor: tool === 'pen' ? penColor + '20' : undefined }}
|
||||
>
|
||||
<FiEdit3 style={{ color: tool === 'pen' ? penColor : undefined }} />
|
||||
</button>
|
||||
<button
|
||||
className="color-picker-button"
|
||||
onClick={() => setShowColorPicker(!showColorPicker)}
|
||||
title="Farbe wählen"
|
||||
style={{ backgroundColor: penColor }}
|
||||
>
|
||||
<FiChevronDown />
|
||||
</button>
|
||||
{showColorPicker && (
|
||||
<div className="color-picker-popover">
|
||||
<div className="color-grid">
|
||||
{colors.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
className={`color-option ${penColor === color ? 'active' : ''}`}
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={() => {
|
||||
setPenColor(color)
|
||||
setShowColorPicker(false)
|
||||
}}
|
||||
title={`Farbe: ${color}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{toolbarButtons}
|
||||
{colorPicker}
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -189,10 +397,17 @@ const Notes = () => {
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="drawing-canvas"
|
||||
onMouseDown={startDrawing}
|
||||
onMouseDown={(e) => {
|
||||
updateRect()
|
||||
startDrawing(e)
|
||||
}}
|
||||
onMouseMove={draw}
|
||||
onMouseUp={stopDrawing}
|
||||
onMouseLeave={stopDrawing}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
style={{ touchAction: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -166,6 +166,9 @@
|
||||
background: rgba(255, 255, 255, 0.9)
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5)
|
||||
overflow: hidden
|
||||
// Ensure touch events can reach the canvas
|
||||
touch-action: auto !important
|
||||
position: relative
|
||||
|
||||
.drawing-canvas
|
||||
width: 100%
|
||||
@@ -174,6 +177,12 @@
|
||||
display: block
|
||||
background: #ffffff
|
||||
border-radius: 20px
|
||||
// Enable touch interactions - override global touch-action: none
|
||||
touch-action: auto !important
|
||||
-webkit-user-select: none
|
||||
-moz-user-select: none
|
||||
-ms-user-select: none
|
||||
user-select: none
|
||||
|
||||
&.eraser-mode
|
||||
cursor: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="10" r="8" fill="none" stroke="black" stroke-width="2"/></svg>') 10 10, auto
|
||||
|
54
dashboard/src/renderer/src/services/CalendarService.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import requestUtil from '../utils/RequestUtil';
|
||||
|
||||
class CalendarService {
|
||||
constructor() {
|
||||
this.endpoint = '/api/calendar';
|
||||
}
|
||||
|
||||
// Get all calendar users
|
||||
async getUsers() {
|
||||
return requestUtil.get(`${this.endpoint}/users`);
|
||||
}
|
||||
|
||||
// Get calendar events for a specific month
|
||||
async getEventsForMonth(year, month, user = null, type = 'family') {
|
||||
let url = `${this.endpoint}/events/${year}/${month}?type=${type}`;
|
||||
if (user && type === 'individual') {
|
||||
url += `&user=${encodeURIComponent(user)}`;
|
||||
}
|
||||
return requestUtil.get(url);
|
||||
}
|
||||
|
||||
// Create a new calendar event
|
||||
async createEvent(event) {
|
||||
return requestUtil.post(`${this.endpoint}/events`, event);
|
||||
}
|
||||
|
||||
// Update a calendar event
|
||||
async updateEvent(eventId, event) {
|
||||
return requestUtil.put(`${this.endpoint}/events/${eventId}`, event);
|
||||
}
|
||||
|
||||
// Delete a calendar event
|
||||
async deleteEvent(eventId, user) {
|
||||
return requestUtil.delete(`${this.endpoint}/events/${eventId}`, { user });
|
||||
}
|
||||
|
||||
// Get contact birthdays for a specific month
|
||||
async getContactBirthdaysForMonth(year, month, user) {
|
||||
let url = `${this.endpoint}/birthdays/${year}/${month}`;
|
||||
if (user) {
|
||||
url += `?user=${encodeURIComponent(user)}`;
|
||||
}
|
||||
return requestUtil.get(url);
|
||||
}
|
||||
|
||||
// Health check
|
||||
async healthCheck() {
|
||||
return requestUtil.get('/api/health');
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export a singleton instance
|
||||
const calendarService = new CalendarService();
|
||||
export default calendarService;
|
135
dashboard/src/renderer/src/utils/InputOverlayHelper.js
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Utility functions for triggering the input overlay
|
||||
*/
|
||||
|
||||
/**
|
||||
* Show the input overlay with specified options
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {HTMLElement} options.targetElement - The target input element (optional)
|
||||
* @param {string} options.initialValue - Initial text value (optional)
|
||||
* @param {string} options.preferredMode - 'keyboard' or 'drawing' (optional)
|
||||
*/
|
||||
export const showInputOverlay = (options = {}) => {
|
||||
// Try using Electron API first
|
||||
if (window.api && window.api.showInputOverlay) {
|
||||
window.api.showInputOverlay(options)
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback to custom DOM event
|
||||
const event = new CustomEvent('show-input-overlay', { detail: options })
|
||||
document.dispatchEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the input overlay
|
||||
*/
|
||||
export const hideInputOverlay = () => {
|
||||
// Try using Electron API first
|
||||
if (window.api && window.api.hideInputOverlay) {
|
||||
window.api.hideInputOverlay()
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback to custom DOM event
|
||||
const event = new CustomEvent('hide-input-overlay')
|
||||
document.dispatchEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup automatic input overlay for input elements
|
||||
* Call this to automatically show the overlay when input elements are focused
|
||||
*/
|
||||
export const setupAutoInputOverlay = () => {
|
||||
const handleFocus = (event) => {
|
||||
const element = event.target
|
||||
|
||||
// Only handle text inputs, textareas, and contenteditable elements
|
||||
if (
|
||||
element.tagName === 'INPUT' &&
|
||||
['text', 'search', 'email', 'url', 'password'].includes(element.type)
|
||||
) {
|
||||
showInputOverlay({
|
||||
targetElement: element,
|
||||
initialValue: element.value || '',
|
||||
preferredMode: 'keyboard'
|
||||
})
|
||||
} else if (element.tagName === 'TEXTAREA') {
|
||||
showInputOverlay({
|
||||
targetElement: element,
|
||||
initialValue: element.value || '',
|
||||
preferredMode: 'keyboard'
|
||||
})
|
||||
} else if (element.contentEditable === 'true') {
|
||||
showInputOverlay({
|
||||
targetElement: element,
|
||||
initialValue: element.textContent || '',
|
||||
preferredMode: 'keyboard'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Add event listener for focus events
|
||||
document.addEventListener('focusin', handleFocus, true)
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
document.removeEventListener('focusin', handleFocus, true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for input overlay submissions
|
||||
* @param {Function} callback - Callback function to handle submitted values
|
||||
*/
|
||||
export const onInputOverlaySubmit = (callback) => {
|
||||
const handleSubmit = (event) => {
|
||||
callback(event.detail)
|
||||
}
|
||||
|
||||
document.addEventListener('input-overlay-submit', handleSubmit)
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
document.removeEventListener('input-overlay-submit', handleSubmit)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus trap helper for accessibility
|
||||
*/
|
||||
export const setupFocusTrap = (container) => {
|
||||
const focusableElements = container.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
|
||||
const firstElement = focusableElements[0]
|
||||
const lastElement = focusableElements[focusableElements.length - 1]
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Tab') {
|
||||
if (e.shiftKey && document.activeElement === firstElement) {
|
||||
e.preventDefault()
|
||||
lastElement.focus()
|
||||
} else if (!e.shiftKey && document.activeElement === lastElement) {
|
||||
e.preventDefault()
|
||||
firstElement.focus()
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
hideInputOverlay()
|
||||
}
|
||||
}
|
||||
|
||||
container.addEventListener('keydown', handleKeyDown)
|
||||
|
||||
// Focus first element
|
||||
if (firstElement) {
|
||||
firstElement.focus()
|
||||
}
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}
|
@@ -73,9 +73,10 @@ class RequestUtil {
|
||||
}
|
||||
|
||||
// DELETE request
|
||||
async delete(endpoint) {
|
||||
async delete(endpoint, data = {}) {
|
||||
return this.request(endpoint, {
|
||||
method: 'DELETE'
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
52
docker-compose.yml
Normal file
@@ -0,0 +1,52 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
server:
|
||||
build:
|
||||
context: ./server
|
||||
dockerfile: Dockerfile
|
||||
container_name: openwall-server
|
||||
ports:
|
||||
- "3001:3001"
|
||||
volumes:
|
||||
- server-data:/app/data
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_PATH=/app/data/database.sqlite
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- openwall-network
|
||||
|
||||
mobile-calendar:
|
||||
build:
|
||||
context: ./mobile-calendar
|
||||
dockerfile: Dockerfile
|
||||
container_name: openwall-mobile-calendar
|
||||
ports:
|
||||
- "8080:80"
|
||||
depends_on:
|
||||
- server
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- openwall-network
|
||||
|
||||
mobile-shopping:
|
||||
build:
|
||||
context: ./mobile-shopping
|
||||
dockerfile: Dockerfile
|
||||
container_name: openwall-mobile-shopping
|
||||
ports:
|
||||
- "8081:80"
|
||||
depends_on:
|
||||
- server
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- openwall-network
|
||||
|
||||
volumes:
|
||||
server-data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
openwall-network:
|
||||
driver: bridge
|
17
mobile-calendar/.dockerignore
Normal file
@@ -0,0 +1,17 @@
|
||||
node_modules
|
||||
dist
|
||||
build
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.DS_Store
|
||||
.vscode
|
||||
coverage
|
24
mobile-calendar/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
35
mobile-calendar/Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
||||
# Use Node.js official image for building
|
||||
FROM node:18-alpine as builder
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
# Install pnpm globally
|
||||
RUN npm install -g pnpm
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN pnpm build
|
||||
|
||||
# Use nginx for serving the built application
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built application from builder stage
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx configuration
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Expose port
|
||||
EXPOSE 80
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
29
mobile-calendar/eslint.config.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs['recommended-latest'],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
},
|
||||
},
|
||||
])
|
26
mobile-calendar/index.html
Normal file
@@ -0,0 +1,26 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="icon" type="image/png" href="/pwa-192x192.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OpenWall Calendar</title>
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta name="description" content="Mobile calendar app for OpenWall Smart Home Dashboard">
|
||||
<meta name="theme-color" content="#3b82f6">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<meta name="apple-mobile-web-app-title" content="OpenWall Calendar">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
|
||||
<!-- Additional PWA Meta Tags -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="application-name" content="OpenWall Calendar">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
31
mobile-calendar/nginx.conf
Normal file
@@ -0,0 +1,31 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Handle client-side routing
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy API requests to the backend server
|
||||
location /api/ {
|
||||
proxy_pass http://server:3001;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
}
|
30
mobile-calendar/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "mobile-calendar",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-icons": "^5.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.30.1",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"eslint": "^9.30.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"sass": "^1.89.2",
|
||||
"vite": "^7.0.4",
|
||||
"vite-plugin-pwa": "^1.0.1"
|
||||
}
|
||||
}
|
4789
mobile-calendar/pnpm-lock.yaml
generated
Normal file
BIN
mobile-calendar/public/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 6.4 KiB |
167
mobile-calendar/public/calendar-icon.svg
Normal file
@@ -0,0 +1,167 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<defs>
|
||||
<!-- Background gradient -->
|
||||
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#2563eb;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#3b82f6;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1d4ed8;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
|
||||
<!-- Calendar body gradient -->
|
||||
<linearGradient id="calendarGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#f1f5f9;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
|
||||
<!-- Header gradient -->
|
||||
<linearGradient id="headerGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#3b82f6;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#2563eb;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
|
||||
<!-- Today highlight gradient -->
|
||||
<radialGradient id="todayGradient" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" style="stop-color:#60a5fa;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#3b82f6;stop-opacity:1" />
|
||||
</radialGradient>
|
||||
|
||||
<!-- Drop shadow -->
|
||||
<filter id="dropShadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feDropShadow dx="4" dy="8" stdDeviation="6" flood-color="#1e293b" flood-opacity="0.3"/>
|
||||
</filter>
|
||||
|
||||
<!-- Inner shadow -->
|
||||
<filter id="innerShadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feOffset dx="0" dy="2"/>
|
||||
<feGaussianBlur stdDeviation="4" result="offset-blur"/>
|
||||
<feFlood flood-color="#334155" flood-opacity="0.15"/>
|
||||
<feComposite in2="offset-blur" operator="in"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
<feMergeNode/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background circle -->
|
||||
<circle cx="256" cy="256" r="240" fill="url(#bgGradient)" filter="url(#dropShadow)"/>
|
||||
|
||||
<!-- Calendar container -->
|
||||
<rect x="96" y="80" width="320" height="352" rx="24" ry="24"
|
||||
fill="url(#calendarGradient)"
|
||||
stroke="#e2e8f0"
|
||||
stroke-width="4"
|
||||
filter="url(#innerShadow)"/>
|
||||
|
||||
<!-- Calendar header -->
|
||||
<rect x="96" y="80" width="320" height="80" rx="24" ry="24"
|
||||
fill="url(#headerGradient)"/>
|
||||
<rect x="96" y="136" width="320" height="24"
|
||||
fill="url(#headerGradient)"/>
|
||||
|
||||
<!-- Binding rings -->
|
||||
<circle cx="160" cy="64" r="12" fill="#64748b" stroke="#475569" stroke-width="2"/>
|
||||
<circle cx="256" cy="64" r="12" fill="#64748b" stroke="#475569" stroke-width="2"/>
|
||||
<circle cx="352" cy="64" r="12" fill="#64748b" stroke="#475569" stroke-width="2"/>
|
||||
|
||||
<!-- Binding ring holes -->
|
||||
<circle cx="160" cy="64" r="6" fill="#334155"/>
|
||||
<circle cx="256" cy="64" r="6" fill="#334155"/>
|
||||
<circle cx="352" cy="64" r="6" fill="#334155"/>
|
||||
|
||||
<!-- Month/Year text area -->
|
||||
<rect x="120" y="100" width="272" height="40" rx="8" ry="8" fill="#ffffff" opacity="0.9"/>
|
||||
|
||||
<!-- Calendar grid -->
|
||||
<g stroke="#cbd5e1" stroke-width="1" opacity="0.6">
|
||||
<!-- Vertical lines -->
|
||||
<line x1="141" y1="160" x2="141" y2="416"/>
|
||||
<line x1="186" y1="160" x2="186" y2="416"/>
|
||||
<line x1="231" y1="160" x2="231" y2="416"/>
|
||||
<line x1="276" y1="160" x2="276" y2="416"/>
|
||||
<line x1="321" y1="160" x2="321" y2="416"/>
|
||||
<line x1="366" y1="160" x2="366" y2="416"/>
|
||||
|
||||
<!-- Horizontal lines -->
|
||||
<line x1="96" y1="196" x2="416" y2="196"/>
|
||||
<line x1="96" y1="232" x2="416" y2="232"/>
|
||||
<line x1="96" y1="268" x2="416" y2="268"/>
|
||||
<line x1="96" y1="304" x2="416" y2="304"/>
|
||||
<line x1="96" y1="340" x2="416" y2="340"/>
|
||||
<line x1="96" y1="376" x2="416" y2="376"/>
|
||||
</g>
|
||||
|
||||
<!-- Day of week labels area -->
|
||||
<rect x="96" y="160" width="320" height="36" fill="#f8fafc" opacity="0.8"/>
|
||||
|
||||
<!-- Sample dates -->
|
||||
<g fill="#64748b" font-family="Arial, sans-serif" font-size="16" font-weight="500" text-anchor="middle">
|
||||
<!-- Week days (abbreviated) -->
|
||||
<text x="118" y="182" fill="#475569" font-size="12">M</text>
|
||||
<text x="163" y="182" fill="#475569" font-size="12">T</text>
|
||||
<text x="208" y="182" fill="#475569" font-size="12">W</text>
|
||||
<text x="253" y="182" fill="#475569" font-size="12">T</text>
|
||||
<text x="298" y="182" fill="#475569" font-size="12">F</text>
|
||||
<text x="343" y="182" fill="#475569" font-size="12">S</text>
|
||||
<text x="388" y="182" fill="#475569" font-size="12">S</text>
|
||||
|
||||
<!-- Sample date numbers -->
|
||||
<text x="118" y="218">1</text>
|
||||
<text x="163" y="218">2</text>
|
||||
<text x="208" y="218">3</text>
|
||||
<text x="253" y="218">4</text>
|
||||
<text x="298" y="218">5</text>
|
||||
<text x="343" y="218">6</text>
|
||||
<text x="388" y="218">7</text>
|
||||
|
||||
<text x="118" y="254">8</text>
|
||||
<text x="163" y="254">9</text>
|
||||
<text x="208" y="254">10</text>
|
||||
<text x="253" y="254">11</text>
|
||||
<text x="298" y="254">12</text>
|
||||
<text x="343" y="254">13</text>
|
||||
<text x="388" y="254">14</text>
|
||||
|
||||
<text x="118" y="290">15</text>
|
||||
<text x="163" y="290">16</text>
|
||||
<text x="208" y="290">17</text>
|
||||
</g>
|
||||
|
||||
<!-- Today highlight (day 18) -->
|
||||
<circle cx="253" cy="285" r="18" fill="url(#todayGradient)" opacity="0.9"/>
|
||||
<text x="253" y="291" fill="white" font-family="Arial, sans-serif" font-size="16" font-weight="600" text-anchor="middle">18</text>
|
||||
|
||||
<!-- Additional date numbers -->
|
||||
<g fill="#64748b" font-family="Arial, sans-serif" font-size="16" font-weight="500" text-anchor="middle">
|
||||
<text x="298" y="290">19</text>
|
||||
<text x="343" y="290">20</text>
|
||||
<text x="388" y="290">21</text>
|
||||
|
||||
<text x="118" y="326">22</text>
|
||||
<text x="163" y="326">23</text>
|
||||
<text x="208" y="326">24</text>
|
||||
<text x="253" y="326">25</text>
|
||||
<text x="298" y="326">26</text>
|
||||
<text x="343" y="326">27</text>
|
||||
<text x="388" y="326">28</text>
|
||||
|
||||
<text x="118" y="362">29</text>
|
||||
<text x="163" y="362">30</text>
|
||||
<text x="208" y="362">31</text>
|
||||
</g>
|
||||
|
||||
<!-- Event indicators -->
|
||||
<circle cx="163" cy="244" r="3" fill="#10b981"/>
|
||||
<circle cx="298" cy="244" r="3" fill="#f59e0b"/>
|
||||
<circle cx="343" cy="280" r="3" fill="#ef4444"/>
|
||||
<circle cx="118" cy="316" r="3" fill="#8b5cf6"/>
|
||||
|
||||
<!-- Small OpenWall branding element -->
|
||||
<g transform="translate(360, 380)" opacity="0.4">
|
||||
<circle cx="16" cy="16" r="12" fill="#3b82f6"/>
|
||||
<rect x="10" y="10" width="12" height="12" rx="2" fill="white" opacity="0.8"/>
|
||||
<rect x="12" y="12" width="8" height="2" fill="#3b82f6"/>
|
||||
<circle cx="14" cy="16" r="1" fill="#3b82f6"/>
|
||||
<circle cx="18" cy="16" r="1" fill="#3b82f6"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 6.6 KiB |
BIN
mobile-calendar/public/favicon-16x16.png
Normal file
After Width: | Height: | Size: 947 B |
BIN
mobile-calendar/public/favicon.ico
Normal file
After Width: | Height: | Size: 2.2 KiB |
BIN
mobile-calendar/public/favicon.png
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
mobile-calendar/public/pwa-192x192.png
Normal file
After Width: | Height: | Size: 6.3 KiB |
BIN
mobile-calendar/public/pwa-512x512.png
Normal file
After Width: | Height: | Size: 21 KiB |
613
mobile-calendar/src/App.jsx
Normal file
@@ -0,0 +1,613 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
FaPlus,
|
||||
FaTrash,
|
||||
FaEdit,
|
||||
FaCalendarAlt,
|
||||
FaUsers,
|
||||
FaUser,
|
||||
FaChevronLeft,
|
||||
FaChevronRight,
|
||||
FaSave,
|
||||
FaTimes,
|
||||
FaBirthdayCake,
|
||||
FaGift
|
||||
} from 'react-icons/fa';
|
||||
import calendarService from './services/CalendarService';
|
||||
import './App.sass';
|
||||
|
||||
const App = () => {
|
||||
const [events, setEvents] = useState([]);
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [viewType, setViewType] = useState('family'); // 'family' or 'individual'
|
||||
const [selectedUser, setSelectedUser] = useState(null);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [editingEvent, setEditingEvent] = useState(null);
|
||||
const [newEvent, setNewEvent] = useState({
|
||||
user: '',
|
||||
date: '',
|
||||
text: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
loadEvents();
|
||||
}, [currentDate, viewType, selectedUser]);
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const userData = await calendarService.getUsers();
|
||||
setUsers(userData);
|
||||
if (userData.length > 0 && !selectedUser) {
|
||||
setSelectedUser(userData[0].name);
|
||||
setNewEvent(prev => ({ ...prev, user: userData[0].name }));
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Fehler beim Laden der Benutzer');
|
||||
console.error('Error loading users:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadEvents = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const year = currentDate.getFullYear();
|
||||
const month = String(currentDate.getMonth() + 1).padStart(2, '0');
|
||||
|
||||
const eventData = await calendarService.getEventsForMonth(
|
||||
year,
|
||||
month,
|
||||
selectedUser,
|
||||
viewType
|
||||
);
|
||||
|
||||
setEvents(eventData);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Laden der Termine');
|
||||
console.error('Error loading events:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddEvent = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!newEvent.text.trim() || !newEvent.date || !newEvent.user) return;
|
||||
|
||||
try {
|
||||
const createdEvent = await calendarService.createEvent(newEvent);
|
||||
setEvents([...events, createdEvent]);
|
||||
setNewEvent({
|
||||
user: selectedUser || users[0]?.name || '',
|
||||
date: '',
|
||||
text: ''
|
||||
});
|
||||
setShowAddForm(false);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Erstellen des Termins');
|
||||
console.error('Error creating event:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateEvent = async (eventId, updatedData) => {
|
||||
try {
|
||||
const updatedEvent = await calendarService.updateEvent(eventId, updatedData);
|
||||
setEvents(events.map(event =>
|
||||
event.id === eventId ? updatedEvent : event
|
||||
));
|
||||
setEditingEvent(null);
|
||||
} catch (err) {
|
||||
console.error('Error updating event:', err);
|
||||
|
||||
// Show more specific error messages based on the error
|
||||
if (err.message && err.message.includes('belong to you')) {
|
||||
setError('Dieser Termin gehört einem anderen Benutzer und kann nicht bearbeitet werden');
|
||||
} else if (err.message && err.message.includes('not found')) {
|
||||
setError('Termin nicht gefunden');
|
||||
} else if (err.message && err.message.includes('uid already exists')) {
|
||||
setError('Fehler beim Aktualisieren: Termin-ID bereits vorhanden');
|
||||
} else {
|
||||
setError('Fehler beim Aktualisieren des Termins');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteEvent = async (eventId, user) => {
|
||||
try {
|
||||
// Additional protection: don't allow deleting birthday events
|
||||
if (eventId && eventId.includes('birthday-')) {
|
||||
setError('Geburtstage können nicht über die Kalender-App gelöscht werden');
|
||||
return;
|
||||
}
|
||||
|
||||
await calendarService.deleteEvent(eventId, user);
|
||||
setEvents(events.filter(event => event.id !== eventId));
|
||||
} catch (err) {
|
||||
console.error('Error deleting event:', err);
|
||||
|
||||
// Show more specific error messages based on the error
|
||||
if (err.message && err.message.includes('belong to you')) {
|
||||
setError('Dieser Termin gehört einem anderen Benutzer und kann nicht gelöscht werden');
|
||||
} else if (err.message && err.message.includes('not found')) {
|
||||
setError('Termin nicht gefunden oder bereits gelöscht');
|
||||
} else if (err.message && err.message.includes('Birthday events')) {
|
||||
setError('Geburtstage können nicht gelöscht werden');
|
||||
} else {
|
||||
setError('Fehler beim Löschen des Termins');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const navigateMonth = (direction) => {
|
||||
const newDate = new Date(currentDate);
|
||||
newDate.setMonth(newDate.getMonth() + direction);
|
||||
setCurrentDate(newDate);
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
// Parse the date string ensuring we don't have timezone issues
|
||||
const dateParts = dateString.split('-');
|
||||
const date = new Date(parseInt(dateParts[0]), parseInt(dateParts[1]) - 1, parseInt(dateParts[2]));
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0); // Reset time for comparison
|
||||
|
||||
// Create a date object for the event date for comparison
|
||||
const eventDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
|
||||
return {
|
||||
dayNumber: date.getDate(),
|
||||
dayName: date.toLocaleDateString('de-DE', { weekday: 'long' }),
|
||||
monthYear: date.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' }),
|
||||
isToday: eventDate.getTime() === today.getTime(),
|
||||
isThisMonth: date.getMonth() === currentDate.getMonth() && date.getFullYear() === currentDate.getFullYear()
|
||||
};
|
||||
};
|
||||
|
||||
const getCurrentMonthYear = () => {
|
||||
return currentDate.toLocaleDateString('de-DE', {
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const groupEventsByDate = (events) => {
|
||||
// First, ensure events are properly sorted by date
|
||||
const sortedEvents = [...events].sort((a, b) => {
|
||||
// Compare dates as strings to avoid timezone issues
|
||||
if (a.date !== b.date) {
|
||||
return a.date.localeCompare(b.date);
|
||||
}
|
||||
|
||||
// If dates are the same, sort by type (birthdays first)
|
||||
if (a.type === 'birthday' && b.type !== 'birthday') return -1;
|
||||
if (a.type !== 'birthday' && b.type === 'birthday') return 1;
|
||||
|
||||
// Then sort by event text for consistent ordering
|
||||
return (a.text || '').localeCompare(b.text || '');
|
||||
});
|
||||
|
||||
const grouped = {};
|
||||
sortedEvents.forEach(event => {
|
||||
const date = event.date;
|
||||
if (!grouped[date]) {
|
||||
grouped[date] = [];
|
||||
}
|
||||
grouped[date].push(event);
|
||||
});
|
||||
|
||||
// Sort dates chronologically using string comparison for YYYY-MM-DD format
|
||||
const sortedDates = Object.keys(grouped).sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const result = {};
|
||||
sortedDates.forEach(date => {
|
||||
// Events within each date are already sorted from the initial sort
|
||||
result[date] = grouped[date];
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const groupedEvents = groupEventsByDate(events);
|
||||
|
||||
const getUserColor = (userName, eventType) => {
|
||||
// Different color scheme for birthdays
|
||||
if (eventType === 'birthday') {
|
||||
return '#ff6b9d'; // Pink for birthdays
|
||||
}
|
||||
|
||||
const colors = [
|
||||
'#3b82f6', // blue
|
||||
'#10b981', // green
|
||||
'#f59e0b', // amber
|
||||
'#ef4444', // red
|
||||
'#8b5cf6', // purple
|
||||
'#06b6d4', // cyan
|
||||
'#ec4899', // pink
|
||||
'#84cc16' // lime
|
||||
];
|
||||
|
||||
const userIndex = users.findIndex(user => user.name === userName);
|
||||
return colors[userIndex % colors.length];
|
||||
};
|
||||
|
||||
if (loading && events.length === 0) {
|
||||
return (
|
||||
<div className="app">
|
||||
<div className="loading">
|
||||
<FaCalendarAlt className="loading-icon" />
|
||||
<p>Kalender wird geladen...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app calendar-app">
|
||||
<header className="header">
|
||||
<div className="header-content">
|
||||
<h1>
|
||||
<FaCalendarAlt />
|
||||
Kalender
|
||||
</h1>
|
||||
|
||||
<div className="month-navigation">
|
||||
<button
|
||||
className="btn btn-icon"
|
||||
onClick={() => navigateMonth(-1)}
|
||||
>
|
||||
<FaChevronLeft />
|
||||
</button>
|
||||
<h2 className="current-month">{getCurrentMonthYear()}</h2>
|
||||
<button
|
||||
className="btn btn-icon"
|
||||
onClick={() => navigateMonth(1)}
|
||||
>
|
||||
<FaChevronRight />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="view-controls">
|
||||
<div className="view-type-toggle">
|
||||
<button
|
||||
className={`btn ${viewType === 'family' ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => setViewType('family')}
|
||||
>
|
||||
<FaUsers />
|
||||
Familie
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${viewType === 'individual' ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => setViewType('individual')}
|
||||
>
|
||||
<FaUser />
|
||||
Einzeln
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{viewType === 'individual' && (
|
||||
<select
|
||||
className="user-select"
|
||||
value={selectedUser || ''}
|
||||
onChange={(e) => setSelectedUser(e.target.value)}
|
||||
>
|
||||
{users.map(user => (
|
||||
<option key={user.id} value={user.name}>
|
||||
{user.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="main">
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
{error}
|
||||
<button onClick={() => setError(null)}>×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAddForm && (
|
||||
<div className="add-form-overlay">
|
||||
<form className="add-form" onSubmit={handleAddEvent}>
|
||||
<h3>Neuer Termin</h3>
|
||||
<div className="form-group">
|
||||
<label>Benutzer</label>
|
||||
<select
|
||||
value={newEvent.user}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, user: e.target.value })}
|
||||
required
|
||||
>
|
||||
<option value="">Benutzer auswählen</option>
|
||||
{users.map(user => (
|
||||
<option key={user.id} value={user.name}>
|
||||
{user.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Datum</label>
|
||||
<input
|
||||
type="date"
|
||||
value={newEvent.date}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, date: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Beschreibung</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Termin beschreiben..."
|
||||
value={newEvent.text}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, text: e.target.value })}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setShowAddForm(false)}>
|
||||
<FaTimes />
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<FaSave />
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="calendar-content">
|
||||
{Object.keys(groupedEvents).length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<FaCalendarAlt />
|
||||
<p>Keine Termine in diesem Monat</p>
|
||||
<small>
|
||||
{viewType === 'family'
|
||||
? 'Füge einen neuen Termin hinzu für alle Familienmitglieder'
|
||||
: `Keine Termine für ${selectedUser} gefunden`
|
||||
}
|
||||
</small>
|
||||
</div>
|
||||
) : (
|
||||
<div className="events-list">
|
||||
{Object.entries(groupedEvents).map(([date, dayEvents]) => {
|
||||
const dateInfo = formatDate(date);
|
||||
return (
|
||||
<div key={date} className={`day-section ${dateInfo.isToday ? 'today' : ''} ${!dateInfo.isThisMonth ? 'other-month' : ''}`}>
|
||||
<div className="day-header">
|
||||
<div className="day-info">
|
||||
<div className="day-number">{dateInfo.dayNumber}</div>
|
||||
<div className="day-details">
|
||||
<div className="day-name">{dateInfo.dayName}</div>
|
||||
{!dateInfo.isThisMonth && (
|
||||
<div className="month-indicator">{dateInfo.monthYear}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="event-count">
|
||||
<span className="count-number">{dayEvents.length}</span>
|
||||
<span className="count-label">Termin{dayEvents.length !== 1 ? 'e' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="day-events">
|
||||
{dayEvents.map(event => (
|
||||
<CalendarEvent
|
||||
key={event.id}
|
||||
event={event}
|
||||
onUpdate={handleUpdateEvent}
|
||||
onDelete={handleDeleteEvent}
|
||||
editingEvent={editingEvent}
|
||||
setEditingEvent={setEditingEvent}
|
||||
userColor={getUserColor(event.user, event.type)}
|
||||
showUser={viewType === 'family'}
|
||||
users={users}
|
||||
viewType={viewType}
|
||||
selectedUser={selectedUser}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div className="fab-container">
|
||||
<button
|
||||
className="fab"
|
||||
onClick={() => setShowAddForm(true)}
|
||||
>
|
||||
<FaPlus />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Calendar Event Component
|
||||
const CalendarEvent = ({
|
||||
event,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
editingEvent,
|
||||
setEditingEvent,
|
||||
userColor,
|
||||
showUser,
|
||||
users,
|
||||
viewType,
|
||||
selectedUser
|
||||
}) => {
|
||||
const [editData, setEditData] = useState({
|
||||
user: event.user,
|
||||
date: event.date,
|
||||
text: event.text
|
||||
});
|
||||
|
||||
const handleEdit = () => {
|
||||
// Don't allow editing of birthday events (they come from external calendar)
|
||||
if (event.type === 'birthday') {
|
||||
return;
|
||||
}
|
||||
|
||||
// In individual view, only allow editing events that belong to the selected user
|
||||
if (viewType === 'individual' && event.user !== selectedUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEditingEvent(event.id);
|
||||
setEditData({
|
||||
user: event.user,
|
||||
date: event.date,
|
||||
text: event.text
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onUpdate(event.id, editData);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditingEvent(null);
|
||||
setEditData({
|
||||
user: event.user,
|
||||
date: event.date,
|
||||
text: event.text
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
// Don't allow deleting of birthday events (they come from external calendar)
|
||||
if (event.type === 'birthday') {
|
||||
return;
|
||||
}
|
||||
|
||||
// In individual view, only allow deleting events that belong to the selected user
|
||||
if (viewType === 'individual' && event.user !== selectedUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Always pass the event's original user for deletion (important for family view)
|
||||
onDelete(event.id, event.user);
|
||||
};
|
||||
|
||||
const isEditing = editingEvent === event.id;
|
||||
const isBirthday = event.type === 'birthday';
|
||||
|
||||
// Determine if this event can be edited/deleted
|
||||
const canEdit = !isBirthday && (viewType === 'family' || event.user === selectedUser);
|
||||
const canDelete = !isBirthday && (viewType === 'family' || event.user === selectedUser);
|
||||
|
||||
return (
|
||||
<div className={`calendar-event ${isBirthday ? 'birthday-event' : ''}`} style={{ borderLeftColor: userColor }}>
|
||||
{isEditing ? (
|
||||
<div className="edit-form">
|
||||
<div className="form-group">
|
||||
<select
|
||||
value={editData.user}
|
||||
onChange={(e) => setEditData({ ...editData, user: e.target.value })}
|
||||
>
|
||||
{users.map(user => (
|
||||
<option key={user.id} value={user.name}>
|
||||
{user.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<input
|
||||
type="date"
|
||||
value={editData.date}
|
||||
onChange={(e) => setEditData({ ...editData, date: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<input
|
||||
type="text"
|
||||
value={editData.text}
|
||||
onChange={(e) => setEditData({ ...editData, text: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSave();
|
||||
if (e.key === 'Escape') handleCancel();
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="edit-actions">
|
||||
<button className="btn btn-primary btn-small" onClick={handleSave}>
|
||||
<FaSave />
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-small" onClick={handleCancel}>
|
||||
<FaTimes />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="event-content">
|
||||
<div className="event-text">
|
||||
{isBirthday && (
|
||||
<span className="birthday-icon">
|
||||
<FaBirthdayCake />
|
||||
</span>
|
||||
)}
|
||||
{isBirthday && event.contactName ? event.contactName : event.text}
|
||||
{isBirthday && (
|
||||
<span className="birthday-label">Geburtstag</span>
|
||||
)}
|
||||
</div>
|
||||
{showUser && (
|
||||
<div className="event-user" style={{ color: userColor }}>
|
||||
{event.user}
|
||||
{isBirthday && <span className="event-source"> (Kontakte)</span>}
|
||||
{!canEdit && !isBirthday && <span className="read-only-indicator"> (nur lesen)</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div className="event-actions">
|
||||
<button className="btn btn-secondary btn-small" onClick={handleEdit}>
|
||||
<FaEdit />
|
||||
</button>
|
||||
{canDelete && (
|
||||
<button
|
||||
className="btn btn-danger btn-small"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<FaTrash />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isBirthday && (
|
||||
<div className="event-actions">
|
||||
<div className="birthday-indicator">
|
||||
<FaGift className="gift-icon" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!canEdit && !isBirthday && (
|
||||
<div className="event-actions">
|
||||
<div className="read-only-indicator">
|
||||
<span className="read-only-text">👁️</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
1000
mobile-calendar/src/App.sass
Normal file
556
mobile-calendar/src/App.scss
Normal file
@@ -0,0 +1,556 @@
|
||||
// Mobile Shopping App Styles
|
||||
// Clean, simple design without glassmorphism
|
||||
|
||||
:root {
|
||||
--primary-color: #007AFF;
|
||||
--secondary-color: #5856D6;
|
||||
--success-color: #34C759;
|
||||
--danger-color: #FF3B30;
|
||||
--warning-color: #FF9500;
|
||||
--background-color: #f8f9fa;
|
||||
--surface-color: #ffffff;
|
||||
--border-color: #e1e5e9;
|
||||
--text-primary: #1d1d1f;
|
||||
--text-secondary: #6e6e73;
|
||||
--text-muted: #8e8e93;
|
||||
--shadow-light: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
--shadow-medium: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
--border-radius: 12px;
|
||||
--border-radius-small: 8px;
|
||||
--spacing-xs: 4px;
|
||||
--spacing-sm: 8px;
|
||||
--spacing-md: 16px;
|
||||
--spacing-lg: 24px;
|
||||
--spacing-xl: 32px;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
// Header
|
||||
.header {
|
||||
background: var(--surface-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
box-shadow: var(--shadow-light);
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
|
||||
svg {
|
||||
color: var(--primary-color);
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main Content
|
||||
.main {
|
||||
flex: 1;
|
||||
padding: var(--spacing-md);
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
padding-bottom: 100px; // Space for FAB
|
||||
}
|
||||
|
||||
// Error Message
|
||||
.error-message {
|
||||
background: var(--danger-color);
|
||||
color: white;
|
||||
padding: var(--spacing-md);
|
||||
border-radius: var(--border-radius-small);
|
||||
margin-bottom: var(--spacing-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-weight: 500;
|
||||
|
||||
button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Loading State
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 60vh;
|
||||
color: var(--text-secondary);
|
||||
|
||||
.loading-icon {
|
||||
font-size: 3rem;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: var(--spacing-md);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
// Shopping Content
|
||||
.shopping-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.shopping-section {
|
||||
h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-md);
|
||||
padding: 0 var(--spacing-xs);
|
||||
}
|
||||
}
|
||||
|
||||
.items-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
// Empty State
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-xl);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
|
||||
svg {
|
||||
font-size: 3rem;
|
||||
margin-bottom: var(--spacing-md);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// Shopping Item
|
||||
.shopping-item {
|
||||
background: var(--surface-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: var(--spacing-md);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-md);
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: var(--shadow-light);
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow-medium);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&.checked {
|
||||
opacity: 0.7;
|
||||
background: #f8f9fa;
|
||||
|
||||
.item-name {
|
||||
text-decoration: line-through;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.check-btn {
|
||||
background: var(--success-color);
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background: #28a745;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.check-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--border-color);
|
||||
background: var(--surface-color);
|
||||
color: transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--success-color);
|
||||
background: rgba(52, 199, 89, 0.1);
|
||||
}
|
||||
|
||||
svg {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.item-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.item-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
|
||||
.item-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
flex: 1;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.item-amount {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
margin-left: var(--spacing-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
|
||||
.deletion-timer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
color: var(--warning-color);
|
||||
font-weight: 500;
|
||||
|
||||
svg {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-small);
|
||||
font-size: 1rem;
|
||||
background: var(--surface-color);
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.edit-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Buttons
|
||||
.btn {
|
||||
border: none;
|
||||
border-radius: var(--border-radius-small);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-xs);
|
||||
transition: all 0.2s ease;
|
||||
text-decoration: none;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.btn-primary {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #0056b3;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-secondary {
|
||||
background: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #d1d5db;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-danger {
|
||||
background: var(--danger-color);
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #dc3545;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-small {
|
||||
padding: var(--spacing-xs);
|
||||
font-size: 0.75rem;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
svg {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Add Form Overlay
|
||||
.add-form-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: var(--spacing-md);
|
||||
|
||||
.add-form {
|
||||
background: var(--surface-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: var(--spacing-lg);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: var(--shadow-medium);
|
||||
|
||||
h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--spacing-md);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: var(--spacing-md);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-small);
|
||||
font-size: 1rem;
|
||||
background: var(--surface-color);
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Floating Action Button
|
||||
.fab-container {
|
||||
position: fixed;
|
||||
bottom: var(--spacing-lg);
|
||||
right: var(--spacing-lg);
|
||||
z-index: 100;
|
||||
|
||||
.fab {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.25rem;
|
||||
box-shadow: var(--shadow-medium);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: #0056b3;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive Design
|
||||
@media (max-width: 480px) {
|
||||
.main {
|
||||
padding: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
|
||||
.header-content h1 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.shopping-item {
|
||||
padding: var(--spacing-sm);
|
||||
|
||||
.item-content .item-main {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-xs);
|
||||
|
||||
.item-amount {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.add-form-overlay {
|
||||
padding: var(--spacing-sm);
|
||||
|
||||
.add-form {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
}
|
||||
|
||||
.fab-container {
|
||||
bottom: var(--spacing-md);
|
||||
right: var(--spacing-md);
|
||||
|
||||
.fab {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
||||
}
|
22
mobile-calendar/src/main.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
|
||||
// Register service worker for PWA
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
.then((registration) => {
|
||||
console.log('SW registered: ', registration);
|
||||
})
|
||||
.catch((registrationError) => {
|
||||
console.log('SW registration failed: ', registrationError);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
54
mobile-calendar/src/services/CalendarService.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import requestUtil from '../utils/RequestUtil';
|
||||
|
||||
class CalendarService {
|
||||
constructor() {
|
||||
this.endpoint = '/api/calendar';
|
||||
}
|
||||
|
||||
// Get all calendar users
|
||||
async getUsers() {
|
||||
return requestUtil.get(`${this.endpoint}/users`);
|
||||
}
|
||||
|
||||
// Get calendar events for a specific month
|
||||
async getEventsForMonth(year, month, user = null, type = 'family') {
|
||||
let url = `${this.endpoint}/events/${year}/${month}?type=${type}`;
|
||||
if (user && type === 'individual') {
|
||||
url += `&user=${encodeURIComponent(user)}`;
|
||||
}
|
||||
return requestUtil.get(url);
|
||||
}
|
||||
|
||||
// Create a new calendar event
|
||||
async createEvent(event) {
|
||||
return requestUtil.post(`${this.endpoint}/events`, event);
|
||||
}
|
||||
|
||||
// Update a calendar event
|
||||
async updateEvent(eventId, event) {
|
||||
return requestUtil.put(`${this.endpoint}/events/${eventId}`, event);
|
||||
}
|
||||
|
||||
// Delete a calendar event
|
||||
async deleteEvent(eventId, user) {
|
||||
return requestUtil.delete(`${this.endpoint}/events/${eventId}`, { user });
|
||||
}
|
||||
|
||||
// Get contact birthdays for a specific month
|
||||
async getContactBirthdaysForMonth(year, month, user) {
|
||||
let url = `${this.endpoint}/birthdays/${year}/${month}`;
|
||||
if (user) {
|
||||
url += `?user=${encodeURIComponent(user)}`;
|
||||
}
|
||||
return requestUtil.get(url);
|
||||
}
|
||||
|
||||
// Health check
|
||||
async healthCheck() {
|
||||
return requestUtil.get('/api/health');
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export a singleton instance
|
||||
const calendarService = new CalendarService();
|
||||
export default calendarService;
|
51
mobile-calendar/src/services/ShoppingService.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import requestUtil from '../utils/RequestUtil';
|
||||
|
||||
class ShoppingService {
|
||||
constructor() {
|
||||
this.endpoint = '/api/shopping';
|
||||
}
|
||||
|
||||
// Get all shopping items
|
||||
async getItems() {
|
||||
return requestUtil.get(this.endpoint);
|
||||
}
|
||||
|
||||
// Get a specific shopping item
|
||||
async getItem(id) {
|
||||
return requestUtil.get(`${this.endpoint}/${id}`);
|
||||
}
|
||||
|
||||
// Create a new shopping item
|
||||
async createItem(item) {
|
||||
return requestUtil.post(this.endpoint, item);
|
||||
}
|
||||
|
||||
// Update a shopping item
|
||||
async updateItem(id, item) {
|
||||
return requestUtil.put(`${this.endpoint}/${id}`, item);
|
||||
}
|
||||
|
||||
// Toggle checked status of an item
|
||||
async toggleItem(id) {
|
||||
return requestUtil.patch(`${this.endpoint}/${id}/toggle`);
|
||||
}
|
||||
|
||||
// Delete a shopping item
|
||||
async deleteItem(id) {
|
||||
return requestUtil.delete(`${this.endpoint}/${id}`);
|
||||
}
|
||||
|
||||
// Delete all checked items
|
||||
async deleteCheckedItems() {
|
||||
return requestUtil.delete(`${this.endpoint}/checked/all`);
|
||||
}
|
||||
|
||||
// Health check
|
||||
async healthCheck() {
|
||||
return requestUtil.get('/api/health');
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export a singleton instance
|
||||
const shoppingService = new ShoppingService();
|
||||
export default shoppingService;
|
91
mobile-calendar/src/utils/RequestUtil.js
Normal file
@@ -0,0 +1,91 @@
|
||||
class RequestUtil {
|
||||
constructor() {
|
||||
this.baseURL = this.getBaseURL();
|
||||
}
|
||||
|
||||
getBaseURL() {
|
||||
// In production, use the static endpoint, otherwise use proxy
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return 'https://static.endpoint.com';
|
||||
}
|
||||
return ''; // Uses proxy in development
|
||||
}
|
||||
|
||||
async request(endpoint, options = {}) {
|
||||
const url = `${this.baseURL}${endpoint}`;
|
||||
|
||||
const defaultOptions = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
};
|
||||
|
||||
const config = {
|
||||
...defaultOptions,
|
||||
...options
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, config);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ error: 'Request failed' }));
|
||||
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Request failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// GET request
|
||||
async get(endpoint) {
|
||||
return this.request(endpoint, {
|
||||
method: 'GET'
|
||||
});
|
||||
}
|
||||
|
||||
// POST request
|
||||
async post(endpoint, data = {}) {
|
||||
return this.request(endpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
// PUT request
|
||||
async put(endpoint, data = {}) {
|
||||
return this.request(endpoint, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
// PATCH request
|
||||
async patch(endpoint, data = {}) {
|
||||
return this.request(endpoint, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
// DELETE request
|
||||
async delete(endpoint, data = null) {
|
||||
const options = {
|
||||
method: 'DELETE'
|
||||
};
|
||||
|
||||
if (data) {
|
||||
options.body = JSON.stringify(data);
|
||||
}
|
||||
|
||||
return this.request(endpoint, options);
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export a singleton instance
|
||||
const requestUtil = new RequestUtil();
|
||||
export default requestUtil;
|
53
mobile-calendar/vite.config.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg}']
|
||||
},
|
||||
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'calendar-icon.svg'],
|
||||
manifest: {
|
||||
name: 'OpenWall Calendar',
|
||||
short_name: 'Calendar',
|
||||
description: 'Mobile calendar app for OpenWall Smart Home Dashboard',
|
||||
theme_color: '#3b82f6',
|
||||
background_color: '#f8f9fa',
|
||||
display: 'standalone',
|
||||
scope: '/',
|
||||
start_url: '/',
|
||||
icons: [
|
||||
{
|
||||
src: 'pwa-192x192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png'
|
||||
},
|
||||
{
|
||||
src: 'pwa-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png'
|
||||
},
|
||||
{
|
||||
src: 'pwa-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'any maskable'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
17
mobile-shopping/.dockerignore
Normal file
@@ -0,0 +1,17 @@
|
||||
node_modules
|
||||
dist
|
||||
build
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.DS_Store
|
||||
.vscode
|
||||
coverage
|
35
mobile-shopping/Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
||||
# Use Node.js official image for building
|
||||
FROM node:18-alpine as builder
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
# Install pnpm globally
|
||||
RUN npm install -g pnpm
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN pnpm build
|
||||
|
||||
# Use nginx for serving the built application
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built application from builder stage
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx configuration
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Expose port
|
||||
EXPOSE 80
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
31
mobile-shopping/nginx.conf
Normal file
@@ -0,0 +1,31 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Handle client-side routing
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy API requests to the backend server
|
||||
location /api/ {
|
||||
proxy_pass http://server:3001;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
}
|
@@ -99,9 +99,11 @@ mkdir -p "${CHROOT_DIR}" "${ISO_DIR}"
|
||||
|
||||
# Bootstrap Debian base system
|
||||
echo "Bootstrapping Debian base system..."
|
||||
debootstrap --arch=amd64 --variant=minbase "${DEBIAN_VERSION}" "${CHROOT_DIR}" http://deb.debian.org/debian/
|
||||
debootstrap --arch=amd64 --variant=minbase "${DEBIAN_VERSION}" "${CHROOT_DIR}" http://deb.debian.org/debian/ 2>/dev/null
|
||||
|
||||
# Mount necessary filesystems
|
||||
echo "Mounting virtual filesystems..."
|
||||
mkdir -p "${CHROOT_DIR}"/{dev,dev/pts,proc,sys}
|
||||
mount --bind /dev "${CHROOT_DIR}/dev"
|
||||
mount --bind /dev/pts "${CHROOT_DIR}/dev/pts"
|
||||
mount --bind /proc "${CHROOT_DIR}/proc"
|
||||
@@ -114,8 +116,11 @@ cleanup() {
|
||||
umount -lf "${CHROOT_DIR}/dev" 2>/dev/null || true
|
||||
umount -lf "${CHROOT_DIR}/proc" 2>/dev/null || true
|
||||
umount -lf "${CHROOT_DIR}/sys" 2>/dev/null || true
|
||||
# Additional cleanup - remove any leftover mount points
|
||||
sync
|
||||
sleep 1
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
# Copy OpenWall files into chroot
|
||||
echo "Copying OpenWall files..."
|
||||
@@ -258,13 +263,35 @@ chmod +x "${CHROOT_DIR}/setup-system.sh"
|
||||
echo "Running system setup in chroot..."
|
||||
chroot "${CHROOT_DIR}" /setup-system.sh
|
||||
|
||||
# Clean up mounts before creating squashfs
|
||||
echo "Cleaning up mounts before filesystem creation..."
|
||||
# Disable trap temporarily
|
||||
trap - EXIT INT TERM
|
||||
umount -lf "${CHROOT_DIR}/dev/pts" 2>/dev/null || true
|
||||
umount -lf "${CHROOT_DIR}/dev" 2>/dev/null || true
|
||||
umount -lf "${CHROOT_DIR}/proc" 2>/dev/null || true
|
||||
umount -lf "${CHROOT_DIR}/sys" 2>/dev/null || true
|
||||
sync
|
||||
sleep 2
|
||||
|
||||
# Create live boot configuration
|
||||
echo "Setting up live boot configuration..."
|
||||
mkdir -p "${ISO_DIR}/live"
|
||||
|
||||
# Create filesystem image
|
||||
echo "Creating filesystem image..."
|
||||
mksquashfs "${CHROOT_DIR}" "${ISO_DIR}/live/filesystem.squashfs" -comp xz -e boot
|
||||
echo "Creating filesystem image (this may take a while)..."
|
||||
mksquashfs "${CHROOT_DIR}" "${ISO_DIR}/live/filesystem.squashfs" \
|
||||
-comp xz \
|
||||
-e boot \
|
||||
-e proc \
|
||||
-e sys \
|
||||
-e dev \
|
||||
-e tmp \
|
||||
-e var/tmp \
|
||||
-e var/cache \
|
||||
-e var/log \
|
||||
-no-progress \
|
||||
-quiet
|
||||
|
||||
# Copy kernel and initrd
|
||||
cp "${CHROOT_DIR}/boot/vmlinuz-"* "${ISO_DIR}/live/vmlinuz"
|
||||
@@ -338,9 +365,18 @@ xorriso -as mkisofs \
|
||||
-append_partition 2 0xef "${ISO_DIR}/EFI/boot/bootx64.efi" \
|
||||
-output "${OUTPUT_ISO}" \
|
||||
-graft-points \
|
||||
"${ISO_DIR}"
|
||||
"${ISO_DIR}" 2>/dev/null
|
||||
|
||||
echo "ISO created: ${OUTPUT_ISO}"
|
||||
|
||||
# Final cleanup
|
||||
final_cleanup() {
|
||||
umount -lf "${CHROOT_DIR}/dev/pts" 2>/dev/null || true
|
||||
umount -lf "${CHROOT_DIR}/dev" 2>/dev/null || true
|
||||
umount -lf "${CHROOT_DIR}/proc" 2>/dev/null || true
|
||||
umount -lf "${CHROOT_DIR}/sys" 2>/dev/null || true
|
||||
}
|
||||
final_cleanup
|
||||
EOF
|
||||
|
||||
chmod +x "$BUILD_DIR/build-iso.sh"
|
||||
|
12
server/.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
.env
|
||||
.nyc_output
|
||||
coverage
|
||||
.nyc_output
|
||||
*.log
|
||||
.DS_Store
|
||||
.vscode
|
30
server/Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
||||
# Use Node.js official image
|
||||
FROM node:18-alpine
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json yarn.lock ./
|
||||
|
||||
# Install yarn globally
|
||||
RUN npm install -g yarn
|
||||
|
||||
# Install dependencies
|
||||
RUN yarn install
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Create directory for SQLite database
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3001
|
||||
|
||||
# Set environment variables
|
||||
ENV NODE_ENV=production
|
||||
ENV DATABASE_PATH=/app/data/database.sqlite
|
||||
|
||||
# Start the application
|
||||
CMD ["yarn", "start"]
|
@@ -4,6 +4,7 @@ require('dotenv').config();
|
||||
|
||||
const { sequelize, cleanupCheckedItems } = require('./models');
|
||||
const shoppingRoutes = require('./routes/shopping');
|
||||
const calendarRoutes = require('./routes/calendar');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
@@ -15,13 +16,15 @@ app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Routes
|
||||
app.use('/api/shopping', shoppingRoutes);
|
||||
app.use('/api/calendar', calendarRoutes);
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'OK',
|
||||
message: 'Shopping List Server is running',
|
||||
timestamp: new Date().toISOString()
|
||||
message: 'OpenWall Server is running',
|
||||
timestamp: new Date().toISOString(),
|
||||
features: ['shopping', 'calendar']
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,9 +55,10 @@ const startServer = async () => {
|
||||
|
||||
// Start the server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Shopping List Server is running on port ${PORT}`);
|
||||
console.log(`OpenWall Server is running on port ${PORT}`);
|
||||
console.log(`Health check available at: http://localhost:${PORT}/api/health`);
|
||||
console.log(`Shopping API available at: http://localhost:${PORT}/api/shopping`);
|
||||
console.log(`Calendar API available at: http://localhost:${PORT}/api/calendar`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Unable to start server:', error);
|
||||
|
41
server/models/CalendarUser.js
Normal file
@@ -0,0 +1,41 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
const CalendarUser = sequelize.define('CalendarUser', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
unique: true
|
||||
},
|
||||
nextcloudUrl: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
username: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
password: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
calendarName: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true // Will default to personal calendar if not specified
|
||||
},
|
||||
isActive: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: true
|
||||
}
|
||||
}, {
|
||||
tableName: 'calendar_users',
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
return CalendarUser;
|
||||
};
|
@@ -4,39 +4,41 @@ const path = require('path');
|
||||
// Initialize Sequelize with SQLite
|
||||
const sequelize = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
storage: path.join(__dirname, '../database.sqlite'),
|
||||
logging: false, // Set to console.log to see SQL queries
|
||||
storage: path.join(__dirname, '..', 'database.sqlite'),
|
||||
logging: process.env.NODE_ENV === 'development' ? console.log : false
|
||||
});
|
||||
|
||||
// Import models
|
||||
const ShoppingItem = require('./ShoppingItem')(sequelize);
|
||||
// Import and initialize models
|
||||
const createShoppingItem = require('./ShoppingItem');
|
||||
const createCalendarUser = require('./CalendarUser');
|
||||
|
||||
// Function to clean up checked items older than 2 hours
|
||||
const ShoppingItem = createShoppingItem(sequelize);
|
||||
const CalendarUser = createCalendarUser(sequelize);
|
||||
|
||||
// Function to clean up checked shopping items older than 24 hours
|
||||
const cleanupCheckedItems = async () => {
|
||||
try {
|
||||
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
|
||||
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const deletedCount = await ShoppingItem.destroy({
|
||||
where: {
|
||||
checked: true,
|
||||
checkedAt: {
|
||||
[Sequelize.Op.lt]: twoHoursAgo,
|
||||
},
|
||||
},
|
||||
updatedAt: {
|
||||
[Sequelize.Op.lt]: oneDayAgo
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (deletedCount > 0) {
|
||||
console.log(`Cleaned up ${deletedCount} checked items older than 2 hours`);
|
||||
console.log(`Cleaned up ${deletedCount} checked shopping items older than 24 hours`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cleaning up checked items:', error);
|
||||
console.error('Error during cleanup:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Run cleanup every 30 minutes
|
||||
setInterval(cleanupCheckedItems, 30 * 60 * 1000);
|
||||
|
||||
module.exports = {
|
||||
sequelize,
|
||||
ShoppingItem,
|
||||
cleanupCheckedItems,
|
||||
CalendarUser,
|
||||
cleanupCheckedItems
|
||||
};
|
||||
|
2727
server/package-lock.json
generated
@@ -11,7 +11,6 @@
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"packageManager": "pnpm@10.13.1",
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.0",
|
||||
|
403
server/routes/calendar.js
Normal file
@@ -0,0 +1,403 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { CalendarUser } = require('../models');
|
||||
const CalendarService = require('../services/CalendarService');
|
||||
|
||||
// Get all calendar users
|
||||
router.get('/users', async (req, res) => {
|
||||
try {
|
||||
const users = await CalendarUser.findAll({
|
||||
where: { isActive: true },
|
||||
attributes: ['id', 'name', 'nextcloudUrl', 'username', 'calendarName', 'isActive']
|
||||
});
|
||||
res.json(users);
|
||||
} catch (error) {
|
||||
console.error('Error fetching calendar users:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch calendar users' });
|
||||
}
|
||||
});
|
||||
|
||||
// Add a new calendar user
|
||||
router.post('/users', async (req, res) => {
|
||||
try {
|
||||
const { name, nextcloudUrl, username, password, calendarName } = req.body;
|
||||
|
||||
if (!name || !nextcloudUrl || !username || !password) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required fields: name, nextcloudUrl, username, password'
|
||||
});
|
||||
}
|
||||
|
||||
// Test connection before saving
|
||||
const testConfig = { name, nextcloudUrl, username, password, calendarName };
|
||||
const connectionTest = await CalendarService.testConnection(testConfig);
|
||||
|
||||
if (!connectionTest) {
|
||||
return res.status(400).json({
|
||||
error: 'Failed to connect to Nextcloud instance. Please check your credentials.'
|
||||
});
|
||||
}
|
||||
|
||||
const user = await CalendarUser.create({
|
||||
name,
|
||||
nextcloudUrl,
|
||||
username,
|
||||
password,
|
||||
calendarName
|
||||
});
|
||||
|
||||
// Return user without password
|
||||
const { password: _, ...userWithoutPassword } = user.toJSON();
|
||||
res.status(201).json(userWithoutPassword);
|
||||
} catch (error) {
|
||||
console.error('Error creating calendar user:', error);
|
||||
if (error.name === 'SequelizeUniqueConstraintError') {
|
||||
res.status(400).json({ error: 'User name already exists' });
|
||||
} else {
|
||||
res.status(500).json({ error: 'Failed to create calendar user' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update a calendar user
|
||||
router.put('/users/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, nextcloudUrl, username, password, calendarName, isActive } = req.body;
|
||||
|
||||
const user = await CalendarUser.findByPk(id);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'Calendar user not found' });
|
||||
}
|
||||
|
||||
// Test connection if credentials are being updated
|
||||
if (nextcloudUrl || username || password) {
|
||||
const testConfig = {
|
||||
name: name || user.name,
|
||||
nextcloudUrl: nextcloudUrl || user.nextcloudUrl,
|
||||
username: username || user.username,
|
||||
password: password || user.password,
|
||||
calendarName: calendarName || user.calendarName
|
||||
};
|
||||
|
||||
const connectionTest = await CalendarService.testConnection(testConfig);
|
||||
if (!connectionTest) {
|
||||
return res.status(400).json({
|
||||
error: 'Failed to connect to Nextcloud instance. Please check your credentials.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await user.update({
|
||||
...(name && { name }),
|
||||
...(nextcloudUrl && { nextcloudUrl }),
|
||||
...(username && { username }),
|
||||
...(password && { password }),
|
||||
...(calendarName !== undefined && { calendarName }),
|
||||
...(isActive !== undefined && { isActive })
|
||||
});
|
||||
|
||||
// Return user without password
|
||||
const { password: _, ...userWithoutPassword } = user.toJSON();
|
||||
res.json(userWithoutPassword);
|
||||
} catch (error) {
|
||||
console.error('Error updating calendar user:', error);
|
||||
res.status(500).json({ error: 'Failed to update calendar user' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a calendar user
|
||||
router.delete('/users/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const user = await CalendarUser.findByPk(id);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'Calendar user not found' });
|
||||
}
|
||||
|
||||
await user.destroy();
|
||||
res.json({ message: 'Calendar user deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting calendar user:', error);
|
||||
res.status(500).json({ error: 'Failed to delete calendar user' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get calendar events for a specific month
|
||||
// GET /api/calendar/events/:year/:month?user=username&type=family|individual
|
||||
router.get('/events/:year/:month', async (req, res) => {
|
||||
try {
|
||||
const { year, month } = req.params;
|
||||
const { user, type } = req.query;
|
||||
|
||||
// Validate year and month
|
||||
if (!year || !month || !/^\d{4}$/.test(year) || !/^(0[1-9]|1[0-2])$/.test(month)) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid year or month format. Use YYYY for year and MM for month.'
|
||||
});
|
||||
}
|
||||
|
||||
if (type === 'family') {
|
||||
// Get events from all active users
|
||||
const users = await CalendarUser.findAll({
|
||||
where: { isActive: true },
|
||||
attributes: ['name', 'nextcloudUrl', 'username', 'password', 'calendarName']
|
||||
});
|
||||
|
||||
if (users.length === 0) {
|
||||
return res.json([]);
|
||||
}
|
||||
|
||||
// Convert Sequelize instances to plain objects
|
||||
const userConfigs = users.map(user => user.toJSON());
|
||||
|
||||
const events = await CalendarService.getFamilyEventsForMonth(userConfigs, year, month);
|
||||
res.json(events);
|
||||
} else {
|
||||
// Get events for a specific user
|
||||
if (!user) {
|
||||
return res.status(400).json({
|
||||
error: 'User parameter required for individual calendar view'
|
||||
});
|
||||
}
|
||||
|
||||
const userConfig = await CalendarUser.findOne({
|
||||
where: { name: user, isActive: true },
|
||||
attributes: ['name', 'nextcloudUrl', 'username', 'password', 'calendarName']
|
||||
});
|
||||
|
||||
if (!userConfig) {
|
||||
return res.status(404).json({ error: 'User not found or inactive' });
|
||||
}
|
||||
|
||||
// Convert Sequelize instance to plain object
|
||||
const userConfigPlain = userConfig.toJSON();
|
||||
|
||||
const events = await CalendarService.getEventsForMonth(userConfigPlain, year, month);
|
||||
const birthdayEvents = await CalendarService.getContactBirthdaysForMonth(userConfigPlain, year, month);
|
||||
|
||||
// Combine regular events and birthday events
|
||||
const allEvents = [...events, ...birthdayEvents];
|
||||
|
||||
// Sort events by date string first (YYYY-MM-DD format) - this ensures proper chronological ordering
|
||||
allEvents.sort((a, b) => {
|
||||
// First, compare dates as strings to avoid timezone issues
|
||||
if (a.date !== b.date) {
|
||||
return a.date.localeCompare(b.date);
|
||||
}
|
||||
|
||||
// If dates are the same, sort by type (birthdays first)
|
||||
if (a.type === 'birthday' && b.type !== 'birthday') return -1;
|
||||
if (a.type !== 'birthday' && b.type === 'birthday') return 1;
|
||||
|
||||
// Then sort by event text for consistent ordering
|
||||
return (a.text || '').localeCompare(b.text || '');
|
||||
});
|
||||
|
||||
res.json(allEvents);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error retrieving calendar events:', error.message);
|
||||
res.status(500).json({ error: 'Failed to retrieve calendar events' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create a new calendar event
|
||||
// POST /api/calendar/events
|
||||
router.post('/events', async (req, res) => {
|
||||
try {
|
||||
const { user, date, text } = req.body;
|
||||
|
||||
if (!user || !date || !text) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required fields: user, date, text'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate date format (YYYY-MM-DD)
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid date format. Use YYYY-MM-DD.'
|
||||
});
|
||||
}
|
||||
|
||||
const userConfig = await CalendarUser.findOne({
|
||||
where: { name: user, isActive: true },
|
||||
attributes: ['name', 'nextcloudUrl', 'username', 'password', 'calendarName']
|
||||
});
|
||||
|
||||
if (!userConfig) {
|
||||
return res.status(404).json({ error: 'User not found or inactive' });
|
||||
}
|
||||
|
||||
const event = await CalendarService.createEvent(userConfig.toJSON(), date, text);
|
||||
res.status(201).json(event);
|
||||
} catch (error) {
|
||||
console.error('Error creating calendar event:', error.message);
|
||||
res.status(500).json({ error: 'Failed to create calendar event' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update a calendar event
|
||||
// PUT /api/calendar/events/:eventId
|
||||
router.put('/events/:eventId', async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { user, date, text } = req.body;
|
||||
|
||||
if (!user || !date || !text) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required fields: user, date, text'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate date format (YYYY-MM-DD)
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid date format. Use YYYY-MM-DD.'
|
||||
});
|
||||
}
|
||||
|
||||
const userConfig = await CalendarUser.findOne({
|
||||
where: { name: user, isActive: true },
|
||||
attributes: ['name', 'nextcloudUrl', 'username', 'password', 'calendarName']
|
||||
});
|
||||
|
||||
if (!userConfig) {
|
||||
return res.status(404).json({ error: 'User not found or inactive' });
|
||||
}
|
||||
|
||||
const event = await CalendarService.updateEvent(userConfig.toJSON(), eventId, date, text);
|
||||
res.json(event);
|
||||
} catch (error) {
|
||||
console.error('Error updating calendar event:', error);
|
||||
if (error.message === 'Event not found') {
|
||||
res.status(404).json({ error: 'Event not found' });
|
||||
} else if (error.message.includes('You can only edit events that belong to you')) {
|
||||
res.status(403).json({ error: 'You can only edit events that belong to you' });
|
||||
} else if (error.message.includes('Permission denied')) {
|
||||
res.status(403).json({ error: error.message });
|
||||
} else if (error.message.includes('CalDAV authentication failed')) {
|
||||
res.status(401).json({ error: 'Calendar authentication failed' });
|
||||
} else {
|
||||
res.status(500).json({ error: 'Failed to update calendar event' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a calendar event
|
||||
// DELETE /api/calendar/events/:eventId
|
||||
router.delete('/events/:eventId', async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { user } = req.body;
|
||||
|
||||
if (!user) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required field: user'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if this is a birthday event (birthday events should not be deletable)
|
||||
if (eventId && eventId.includes('birthday-')) {
|
||||
return res.status(403).json({
|
||||
error: 'Birthday events cannot be deleted from the calendar interface'
|
||||
});
|
||||
}
|
||||
|
||||
const userConfig = await CalendarUser.findOne({
|
||||
where: { name: user, isActive: true },
|
||||
attributes: ['name', 'nextcloudUrl', 'username', 'password', 'calendarName']
|
||||
});
|
||||
|
||||
if (!userConfig) {
|
||||
return res.status(404).json({ error: 'User not found or inactive' });
|
||||
}
|
||||
|
||||
await CalendarService.deleteEvent(userConfig.toJSON(), eventId);
|
||||
res.json({ message: 'Event deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting calendar event:', error);
|
||||
if (error.message === 'Event not found') {
|
||||
res.status(404).json({
|
||||
error: 'Event not found. This event may belong to a different user or have already been deleted.'
|
||||
});
|
||||
} else if (error.message.includes('You can only delete events that belong to you')) {
|
||||
res.status(403).json({ error: 'You can only delete events that belong to you' });
|
||||
} else if (error.message === 'Birthday events cannot be deleted from the calendar interface') {
|
||||
res.status(403).json({ error: error.message });
|
||||
} else if (error.message.includes('Permission denied') || error.message.includes('authentication failed')) {
|
||||
res.status(403).json({ error: 'Permission denied. You may not have access to delete this event.' });
|
||||
} else {
|
||||
res.status(500).json({ error: 'Failed to delete calendar event' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Test Nextcloud connection for a user
|
||||
router.post('/users/:id/test-connection', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const user = await CalendarUser.findByPk(id);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'Calendar user not found' });
|
||||
}
|
||||
|
||||
const connectionTest = await CalendarService.testConnection({
|
||||
name: user.name,
|
||||
nextcloudUrl: user.nextcloudUrl,
|
||||
username: user.username,
|
||||
password: user.password,
|
||||
calendarName: user.calendarName
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: connectionTest,
|
||||
message: connectionTest ? 'Connection successful' : 'Connection failed'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error testing connection:', error);
|
||||
res.status(500).json({ error: 'Failed to test connection' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get contact birthdays for a specific month
|
||||
// GET /api/calendar/birthdays/:year/:month?user=username
|
||||
router.get('/birthdays/:year/:month', async (req, res) => {
|
||||
try {
|
||||
const { year, month } = req.params;
|
||||
const { user } = req.query;
|
||||
|
||||
// Validate year and month
|
||||
if (!year || !month || !/^\d{4}$/.test(year) || !/^(0[1-9]|1[0-2])$/.test(month)) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid year or month format. Use YYYY for year and MM for month.'
|
||||
});
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return res.status(400).json({
|
||||
error: 'User parameter required for birthday calendar access'
|
||||
});
|
||||
}
|
||||
|
||||
const userConfig = await CalendarUser.findOne({
|
||||
where: { name: user, isActive: true },
|
||||
attributes: ['name', 'nextcloudUrl', 'username', 'password', 'calendarName']
|
||||
});
|
||||
|
||||
if (!userConfig) {
|
||||
return res.status(404).json({ error: 'User not found or inactive' });
|
||||
}
|
||||
|
||||
const birthdayEvents = await CalendarService.getContactBirthdaysForMonth(userConfig.toJSON(), year, month);
|
||||
res.json(birthdayEvents);
|
||||
} catch (error) {
|
||||
console.error('Error retrieving contact birthdays:', error.message);
|
||||
res.status(500).json({ error: 'Failed to retrieve contact birthdays' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
970
server/services/CalendarService.js
Normal file
@@ -0,0 +1,970 @@
|
||||
// Calendar Service for Nextcloud CalDAV integration
|
||||
// This is a basic implementation that can be enhanced with the @nextcloud/cdav-library
|
||||
const crypto = require('crypto');
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const { URL } = require('url');
|
||||
|
||||
class CalendarService {
|
||||
/**
|
||||
* Create a calendar event
|
||||
* @param {Object} userConfig - Nextcloud user configuration
|
||||
* @param {string} date - Date in YYYY-MM-DD format
|
||||
* @param {string} text - Event description
|
||||
* @returns {Promise<Object>} Created event
|
||||
*/
|
||||
async createEvent(userConfig, date, text) {
|
||||
try {
|
||||
// Validate input parameters
|
||||
if (!userConfig) {
|
||||
throw new Error('User configuration is required');
|
||||
}
|
||||
if (!userConfig.name) {
|
||||
throw new Error('User name is required');
|
||||
}
|
||||
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
throw new Error('Valid date in YYYY-MM-DD format is required');
|
||||
}
|
||||
if (!text || text.trim().length === 0) {
|
||||
throw new Error('Event text is required');
|
||||
}
|
||||
|
||||
// Generate event ID and structure
|
||||
const eventId = crypto.randomUUID();
|
||||
const event = {
|
||||
id: eventId,
|
||||
date: date,
|
||||
text: text.trim(),
|
||||
user: userConfig.name,
|
||||
summary: text.trim(),
|
||||
dtstart: this._formatDateForCalDAV(date),
|
||||
dtend: this._formatDateForCalDAV(date),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Create event via CalDAV
|
||||
const caldavEvent = await this._createCalDAVEvent(userConfig, event);
|
||||
|
||||
return caldavEvent;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to create calendar event: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get calendar events for a specific month
|
||||
* @param {Object} userConfig - Nextcloud user configuration
|
||||
* @param {string} year - Year (YYYY)
|
||||
* @param {string} month - Month (MM)
|
||||
* @returns {Promise<Array>} Array of events
|
||||
*/
|
||||
async getEventsForMonth(userConfig, year, month) {
|
||||
try {
|
||||
// Validate input parameters
|
||||
if (!userConfig || !userConfig.name) {
|
||||
throw new Error('Valid user configuration is required');
|
||||
}
|
||||
if (!year || !/^\d{4}$/.test(year)) {
|
||||
throw new Error('Valid year in YYYY format is required');
|
||||
}
|
||||
if (!month || !/^(0[1-9]|1[0-2])$/.test(month)) {
|
||||
throw new Error('Valid month in MM format is required');
|
||||
}
|
||||
|
||||
// Fetch events from CalDAV
|
||||
const events = await this._fetchCalDAVEvents(userConfig, year, month);
|
||||
|
||||
return events;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to retrieve calendar events: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get contact birthdays for a specific month
|
||||
* @param {Object} userConfig - Nextcloud user configuration
|
||||
* @param {string} year - Year (YYYY)
|
||||
* @param {string} month - Month (MM)
|
||||
* @returns {Promise<Array>} Array of birthday events
|
||||
*/
|
||||
async getContactBirthdaysForMonth(userConfig, year, month) {
|
||||
try {
|
||||
// Validate input parameters
|
||||
if (!userConfig || !userConfig.name) {
|
||||
console.warn('Valid user configuration is required for birthday fetch');
|
||||
return [];
|
||||
}
|
||||
if (!userConfig.nextcloudUrl || !userConfig.username || !userConfig.password) {
|
||||
console.warn(`Missing required connection details for ${userConfig.name} birthday calendar`);
|
||||
return [];
|
||||
}
|
||||
if (!year || !/^\d{4}$/.test(year)) {
|
||||
throw new Error('Valid year in YYYY format is required');
|
||||
}
|
||||
if (!month || !/^(0[1-9]|1[0-2])$/.test(month)) {
|
||||
throw new Error('Valid month in MM format is required');
|
||||
}
|
||||
|
||||
// Create a separate config for the contact_birthdays calendar
|
||||
const birthdayConfig = {
|
||||
name: userConfig.name,
|
||||
nextcloudUrl: userConfig.nextcloudUrl,
|
||||
username: userConfig.username,
|
||||
password: userConfig.password,
|
||||
calendarName: 'contact_birthdays'
|
||||
};
|
||||
|
||||
try {
|
||||
// Fetch birthday events from the contact_birthdays calendar
|
||||
const birthdayEvents = await this._fetchCalDAVEvents(birthdayConfig, year, month);
|
||||
|
||||
// Mark these events as birthday events and add additional properties
|
||||
const processedBirthdayEvents = birthdayEvents.map(event => ({
|
||||
...event,
|
||||
id: `birthday-${event.id}`, // Prefix birthday event IDs to make them identifiable
|
||||
type: 'birthday',
|
||||
isBirthday: true,
|
||||
source: 'contact_birthdays',
|
||||
// Extract contact name from birthday text if possible
|
||||
contactName: this._extractContactNameFromBirthday(event.text || event.summary)
|
||||
}));
|
||||
|
||||
return processedBirthdayEvents;
|
||||
} catch (error) {
|
||||
// If contact_birthdays calendar doesn't exist or is inaccessible, return empty array
|
||||
console.warn(`Could not fetch contact birthdays for ${userConfig.name}: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to retrieve contact birthdays: ${error.message}`);
|
||||
return []; // Return empty array instead of throwing to not break other calendar functionality
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get calendar events for all users (family view)
|
||||
* @param {Array} userConfigs - Array of user configurations
|
||||
* @param {string} year - Year (YYYY)
|
||||
* @param {string} month - Month (MM)
|
||||
* @returns {Promise<Array>} Array of events from all users
|
||||
*/
|
||||
async getFamilyEventsForMonth(userConfigs, year, month) {
|
||||
try {
|
||||
const allEvents = [];
|
||||
|
||||
for (const userConfig of userConfigs) {
|
||||
const userEvents = await this.getEventsForMonth(userConfig, year, month);
|
||||
allEvents.push(...userEvents);
|
||||
}
|
||||
|
||||
// Fetch contact birthdays only from the first user to avoid duplicates
|
||||
// Birthday calendars are typically shared/synchronized across users
|
||||
if (userConfigs.length > 0) {
|
||||
const birthdayEvents = await this.getContactBirthdaysForMonth(userConfigs[0], year, month);
|
||||
|
||||
// Filter out duplicate birthday events and merge with regular events that might be on the same day
|
||||
const seenBirthdays = new Set();
|
||||
const uniqueBirthdayEvents = birthdayEvents.filter(event => {
|
||||
const birthdayKey = `${event.date}-${(event.text || '').trim()}-${(event.contactName || '').trim()}`;
|
||||
if (!seenBirthdays.has(birthdayKey)) {
|
||||
seenBirthdays.add(birthdayKey);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}).map(event => ({
|
||||
...event,
|
||||
// Use the first user's name for birthday events or mark as shared
|
||||
user: userConfigs[0].name,
|
||||
isSharedBirthday: true
|
||||
}));
|
||||
|
||||
allEvents.push(...uniqueBirthdayEvents);
|
||||
}
|
||||
|
||||
// Sort events by date string first (YYYY-MM-DD format) - this ensures proper chronological ordering
|
||||
allEvents.sort((a, b) => {
|
||||
// First, compare dates as strings to avoid timezone issues
|
||||
if (a.date !== b.date) {
|
||||
return a.date.localeCompare(b.date);
|
||||
}
|
||||
|
||||
// If dates are the same, sort by type (birthdays first)
|
||||
if (a.type === 'birthday' && b.type !== 'birthday') return -1;
|
||||
if (a.type !== 'birthday' && b.type === 'birthday') return 1;
|
||||
|
||||
// Then sort by event text for consistent ordering
|
||||
return (a.text || '').localeCompare(b.text || '');
|
||||
});
|
||||
|
||||
return allEvents;
|
||||
} catch (error) {
|
||||
console.error('Error retrieving family calendar events:', error);
|
||||
throw new Error('Failed to retrieve family calendar events');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a calendar event
|
||||
* @param {Object} userConfig - Nextcloud user configuration
|
||||
* @param {string} eventId - Event ID
|
||||
* @param {string} date - New date
|
||||
* @param {string} text - New text
|
||||
* @returns {Promise<Object>} Updated event
|
||||
*/
|
||||
async updateEvent(userConfig, eventId, date, text) {
|
||||
try {
|
||||
// Build updated event object
|
||||
const event = {
|
||||
id: eventId,
|
||||
date: date,
|
||||
text: text,
|
||||
user: userConfig.name,
|
||||
summary: text,
|
||||
dtstart: this._formatDateForCalDAV(date),
|
||||
dtend: this._formatDateForCalDAV(date),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Update event via CalDAV - let the server handle existence validation
|
||||
const updatedEvent = await this._updateCalDAVEvent(userConfig, event);
|
||||
|
||||
return updatedEvent;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a calendar event
|
||||
* @param {Object} userConfig - Nextcloud user configuration
|
||||
* @param {string} eventId - Event ID
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
async deleteEvent(userConfig, eventId) {
|
||||
try {
|
||||
// Check if this is a birthday event that shouldn't be deleted
|
||||
if (eventId && eventId.includes('birthday-')) {
|
||||
console.warn(`Blocked attempt to delete birthday event ${eventId}`);
|
||||
throw new Error('Birthday events cannot be deleted from the calendar interface');
|
||||
}
|
||||
|
||||
// Simply attempt the deletion - let CalDAV server handle existence validation
|
||||
// The previous approach of checking multiple months was too complex and unreliable
|
||||
await this._deleteCalDAVEvent(userConfig, eventId);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete event ${eventId} for user ${userConfig.name}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to Nextcloud instance
|
||||
* @param {Object} userConfig - Nextcloud user configuration
|
||||
* @returns {Promise<boolean>} Connection status
|
||||
*/
|
||||
async testConnection(userConfig) {
|
||||
try {
|
||||
// Validate user configuration
|
||||
if (!userConfig.nextcloudUrl || !userConfig.username || !userConfig.password) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Basic HTTP test to verify Nextcloud instance is reachable
|
||||
const url = new URL(userConfig.nextcloudUrl);
|
||||
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||
path: '/status.php',
|
||||
method: 'GET',
|
||||
timeout: 5000,
|
||||
headers: {
|
||||
'User-Agent': 'OpenWall/1.0'
|
||||
}
|
||||
};
|
||||
|
||||
const protocol = url.protocol === 'https:' ? https : http;
|
||||
|
||||
const result = await new Promise((resolve) => {
|
||||
const req = protocol.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const status = JSON.parse(data);
|
||||
const isNextcloud = status.productname && status.productname.toLowerCase().includes('nextcloud');
|
||||
resolve(isNextcloud);
|
||||
} catch (e) {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', () => {
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
|
||||
// If basic connection works, test CalDAV endpoint
|
||||
if (result) {
|
||||
const caldavResult = await this._testCalDAVConnection(userConfig);
|
||||
return caldavResult;
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for CalDAV (YYYYMMDD format for all-day events)
|
||||
* @private
|
||||
*/
|
||||
_formatDateForCalDAV(dateString) {
|
||||
const date = new Date(dateString);
|
||||
return date.toISOString().slice(0, 10).replace(/-/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test CalDAV connection by trying to access the calendar endpoint
|
||||
* @private
|
||||
*/
|
||||
async _testCalDAVConnection(userConfig) {
|
||||
try {
|
||||
// Try to access the CalDAV principal endpoint
|
||||
const caldavUrl = this._buildCalDAVUrl(userConfig);
|
||||
|
||||
const url = new URL(caldavUrl);
|
||||
const auth = Buffer.from(`${userConfig.username}:${userConfig.password}`).toString('base64');
|
||||
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||
path: url.pathname,
|
||||
method: 'PROPFIND',
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
'Depth': '0',
|
||||
'User-Agent': 'OpenWall/1.0 CalDAV-Client'
|
||||
}
|
||||
};
|
||||
|
||||
const protocol = url.protocol === 'https:' ? https : http;
|
||||
|
||||
const body = `<?xml version="1.0" encoding="utf-8" ?>
|
||||
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:prop>
|
||||
<D:resourcetype/>
|
||||
<D:displayname/>
|
||||
<C:calendar-description/>
|
||||
</D:prop>
|
||||
</D:propfind>`;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const req = protocol.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve(true);
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', () => {
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build CalDAV URL for a user
|
||||
* @private
|
||||
*/
|
||||
_buildCalDAVUrl(userConfig) {
|
||||
try {
|
||||
// Validate userConfig
|
||||
if (!userConfig || !userConfig.nextcloudUrl || !userConfig.username) {
|
||||
throw new Error('Invalid user configuration: missing required fields');
|
||||
}
|
||||
|
||||
// Remove trailing slash from nextcloudUrl if present
|
||||
const baseUrl = userConfig.nextcloudUrl.replace(/\/$/, '');
|
||||
|
||||
// Build CalDAV principal URL - this is the standard Nextcloud CalDAV path
|
||||
const calendarName = userConfig.calendarName || 'personal';
|
||||
return `${baseUrl}/remote.php/dav/calendars/${userConfig.username}/${calendarName}/`;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to build CalDAV URL: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build CalDAV principals URL for a user
|
||||
* @private
|
||||
*/
|
||||
_buildCalDAVPrincipalsUrl(userConfig) {
|
||||
try {
|
||||
// Validate userConfig
|
||||
if (!userConfig || !userConfig.nextcloudUrl || !userConfig.username) {
|
||||
throw new Error('Invalid user configuration: missing required fields');
|
||||
}
|
||||
|
||||
const baseUrl = userConfig.nextcloudUrl.replace(/\/$/, '');
|
||||
return `${baseUrl}/remote.php/dav/principals/users/${userConfig.username}/`;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to build CalDAV principals URL: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a CalDAV event
|
||||
* @private
|
||||
*/
|
||||
async _createCalDAVEvent(userConfig, event) {
|
||||
try {
|
||||
const caldavUrl = this._buildCalDAVUrl(userConfig);
|
||||
const eventUrl = `${caldavUrl}${event.id}.ics`;
|
||||
|
||||
const url = new URL(eventUrl);
|
||||
const auth = Buffer.from(`${userConfig.username}:${userConfig.password}`).toString('base64');
|
||||
|
||||
const icsContent = this._generateICSEvent(event);
|
||||
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||
path: url.pathname,
|
||||
method: 'PUT',
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Content-Type': 'text/calendar; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(icsContent, 'utf8'),
|
||||
'User-Agent': 'OpenWall/1.0 CalDAV-Client',
|
||||
'If-None-Match': '*' // Prevent overwriting existing events
|
||||
}
|
||||
};
|
||||
|
||||
const protocol = url.protocol === 'https:' ? https : http;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = protocol.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve(event);
|
||||
} else if (res.statusCode === 401) {
|
||||
reject(new Error('CalDAV authentication failed'));
|
||||
} else if (res.statusCode === 412) {
|
||||
reject(new Error('CalDAV event already exists'));
|
||||
} else {
|
||||
reject(new Error(`CalDAV event creation failed with status ${res.statusCode}: ${data}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
reject(new Error(`CalDAV request failed: ${error.message}`));
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error('CalDAV request timeout'));
|
||||
});
|
||||
|
||||
req.write(icsContent);
|
||||
req.end();
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch CalDAV events
|
||||
* @private
|
||||
*/
|
||||
async _fetchCalDAVEvents(userConfig, year, month) {
|
||||
try {
|
||||
const caldavUrl = this._buildCalDAVUrl(userConfig);
|
||||
|
||||
const url = new URL(caldavUrl);
|
||||
const auth = Buffer.from(`${userConfig.username}:${userConfig.password}`).toString('base64');
|
||||
|
||||
// Build date range for the month
|
||||
const startDate = new Date(parseInt(year), parseInt(month) - 1, 1);
|
||||
const endDate = new Date(parseInt(year), parseInt(month), 0);
|
||||
|
||||
const startDateStr = startDate.toISOString().slice(0, 10).replace(/-/g, '') + 'T000000Z';
|
||||
const endDateStr = endDate.toISOString().slice(0, 10).replace(/-/g, '') + 'T235959Z';
|
||||
|
||||
const reportBody = `<?xml version="1.0" encoding="utf-8" ?>
|
||||
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:prop>
|
||||
<D:getetag/>
|
||||
<C:calendar-data/>
|
||||
</D:prop>
|
||||
<C:filter>
|
||||
<C:comp-filter name="VCALENDAR">
|
||||
<C:comp-filter name="VEVENT">
|
||||
<C:time-range start="${startDateStr}" end="${endDateStr}"/>
|
||||
</C:comp-filter>
|
||||
</C:comp-filter>
|
||||
</C:filter>
|
||||
</C:calendar-query>`;
|
||||
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||
path: url.pathname,
|
||||
method: 'REPORT',
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(reportBody, 'utf8'),
|
||||
'Depth': '1',
|
||||
'User-Agent': 'OpenWall/1.0 CalDAV-Client'
|
||||
}
|
||||
};
|
||||
|
||||
const protocol = url.protocol === 'https:' ? https : http;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = protocol.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
try {
|
||||
const events = this._parseCalDAVResponse(data, userConfig, year, month);
|
||||
resolve(events);
|
||||
} catch (parseError) {
|
||||
reject(parseError);
|
||||
}
|
||||
} else if (res.statusCode === 401) {
|
||||
reject(new Error('CalDAV authentication failed'));
|
||||
} else {
|
||||
reject(new Error(`CalDAV event fetch failed with status ${res.statusCode}: ${data}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
reject(new Error(`CalDAV request failed: ${error.message}`));
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error('CalDAV request timeout'));
|
||||
});
|
||||
|
||||
req.write(reportBody);
|
||||
req.end();
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse CalDAV response and extract events
|
||||
* @private
|
||||
*/
|
||||
_parseCalDAVResponse(xmlData, userConfig, requestedYear, requestedMonth) {
|
||||
try {
|
||||
// This is a basic parser - in a real implementation, you'd use a proper XML parser
|
||||
const events = [];
|
||||
|
||||
// Look for calendar-data with different possible namespace prefixes
|
||||
const eventMatches = xmlData.match(/<(?:C:|cal:)?calendar-data[^>]*>([\s\S]*?)<\/(?:C:|cal:)?calendar-data>/gi);
|
||||
|
||||
if (eventMatches) {
|
||||
eventMatches.forEach((match, index) => {
|
||||
try {
|
||||
// Extract the VCALENDAR content - handle CDATA and escaped content
|
||||
let icsContent = match
|
||||
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1') // Remove CDATA wrapper
|
||||
.replace(/<[^>]*>/g, '') // Remove XML tags
|
||||
.replace(/</g, '<') // Decode HTML entities
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.trim();
|
||||
|
||||
const event = this._parseICSEvent(icsContent, userConfig, requestedYear, requestedMonth);
|
||||
|
||||
if (event) {
|
||||
events.push(event);
|
||||
}
|
||||
} catch (eventError) {
|
||||
// Skip individual events that fail to parse
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return events;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse CalDAV response: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse ICS event content
|
||||
* @private
|
||||
*/
|
||||
_parseICSEvent(icsContent, userConfig, requestedYear, requestedMonth) {
|
||||
try {
|
||||
const lines = icsContent.split('\n').map(line => line.trim()).filter(line => line);
|
||||
|
||||
const event = {
|
||||
user: userConfig.name,
|
||||
source: 'caldav'
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('UID:')) {
|
||||
event.id = line.substring(4);
|
||||
} else if (line.startsWith('SUMMARY:')) {
|
||||
event.text = line.substring(8);
|
||||
event.summary = event.text;
|
||||
} else if (line.startsWith('DTSTART')) {
|
||||
const dateValue = line.split(':')[1];
|
||||
event.dtstart = dateValue;
|
||||
// Parse date more robustly
|
||||
if (dateValue && dateValue.length >= 8) {
|
||||
// Handle different DTSTART formats: YYYYMMDD, YYYYMMDDTHHMMSSZ, etc.
|
||||
const dateOnly = dateValue.substring(0, 8);
|
||||
if (/^\d{8}$/.test(dateOnly)) {
|
||||
const year = parseInt(dateOnly.substring(0, 4));
|
||||
const month = parseInt(dateOnly.substring(4, 6));
|
||||
const day = parseInt(dateOnly.substring(6, 8));
|
||||
|
||||
// Validate month and day are reasonable (don't restrict year for recurring events)
|
||||
if (month >= 1 && month <= 12 && day >= 1 && day <= 31) {
|
||||
// Create date and validate it's correct
|
||||
const testDate = new Date(year, month - 1, day);
|
||||
if (!isNaN(testDate.getTime()) &&
|
||||
testDate.getMonth() === (month - 1) &&
|
||||
testDate.getDate() === day) {
|
||||
event.date = `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`;
|
||||
event.originalYear = year;
|
||||
event.originalDate = event.date;
|
||||
} else {
|
||||
// If date validation fails, try with current year
|
||||
const currentYear = new Date().getFullYear();
|
||||
const fallbackDate = new Date(currentYear, month - 1, day);
|
||||
if (!isNaN(fallbackDate.getTime())) {
|
||||
event.date = `${currentYear}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`;
|
||||
event.originalYear = year;
|
||||
event.isFallback = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (line.startsWith('DTEND')) {
|
||||
const dateValue = line.split(':')[1];
|
||||
event.dtend = dateValue;
|
||||
// If we don't have a start date yet, try to get it from end date
|
||||
if (!event.date && dateValue && dateValue.length >= 8) {
|
||||
const dateOnly = dateValue.substring(0, 8);
|
||||
if (/^\d{8}$/.test(dateOnly)) {
|
||||
const year = parseInt(dateOnly.substring(0, 4));
|
||||
const month = parseInt(dateOnly.substring(4, 6));
|
||||
const day = parseInt(dateOnly.substring(6, 8));
|
||||
|
||||
// Validate month and day are reasonable (don't restrict year for recurring events)
|
||||
if (month >= 1 && month <= 12 && day >= 1 && day <= 31) {
|
||||
// Create date and validate it's correct
|
||||
const testDate = new Date(year, month - 1, day);
|
||||
if (!isNaN(testDate.getTime()) &&
|
||||
testDate.getMonth() === (month - 1) &&
|
||||
testDate.getDate() === day) {
|
||||
event.date = `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`;
|
||||
event.originalYear = year;
|
||||
event.originalDate = event.date;
|
||||
} else {
|
||||
// If date validation fails, try with current year
|
||||
const currentYear = new Date().getFullYear();
|
||||
const fallbackDate = new Date(currentYear, month - 1, day);
|
||||
if (!isNaN(fallbackDate.getTime())) {
|
||||
event.date = `${currentYear}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`;
|
||||
event.originalYear = year;
|
||||
event.isFallback = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (line.startsWith('CREATED:')) {
|
||||
event.createdAt = line.substring(8);
|
||||
} else if (line.startsWith('LAST-MODIFIED:')) {
|
||||
event.updatedAt = line.substring(14);
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize date to the requested year/month for proper grouping
|
||||
if (event.date && requestedYear && requestedMonth) {
|
||||
const eventDate = new Date(event.date);
|
||||
|
||||
// Validate that the date parsed correctly
|
||||
if (!isNaN(eventDate.getTime())) {
|
||||
const eventDay = eventDate.getDate();
|
||||
|
||||
// Validate day is reasonable for the month
|
||||
if (eventDay >= 1 && eventDay <= 31) {
|
||||
// Always use the requested year for proper grouping, regardless of original event year
|
||||
const normalizedDate = `${requestedYear}-${requestedMonth.padStart(2, '0')}-${eventDay.toString().padStart(2, '0')}`;
|
||||
|
||||
// Validate the normalized date is valid
|
||||
const testNormalizedDate = new Date(normalizedDate);
|
||||
if (!isNaN(testNormalizedDate.getTime())) {
|
||||
event.date = normalizedDate;
|
||||
event.isNormalized = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only return events with required fields and valid dates
|
||||
if (event.id && event.text && event.date) {
|
||||
return event;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a calendar event
|
||||
* @private
|
||||
*/
|
||||
async _updateCalDAVEvent(userConfig, event) {
|
||||
try {
|
||||
const caldavUrl = this._buildCalDAVUrl(userConfig);
|
||||
const eventUrl = `${caldavUrl}${event.id}.ics`;
|
||||
|
||||
const url = new URL(eventUrl);
|
||||
const auth = Buffer.from(`${userConfig.username}:${userConfig.password}`).toString('base64');
|
||||
|
||||
const icsContent = this._generateICSEvent(event);
|
||||
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||
path: url.pathname,
|
||||
method: 'PUT',
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Content-Type': 'text/calendar; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(icsContent, 'utf8'),
|
||||
'User-Agent': 'OpenWall/1.0 CalDAV-Client',
|
||||
'If-Match': '*' // Allow overwriting existing events
|
||||
}
|
||||
};
|
||||
|
||||
const protocol = url.protocol === 'https:' ? https : http;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = protocol.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve(event);
|
||||
} else if (res.statusCode === 401) {
|
||||
reject(new Error('CalDAV authentication failed'));
|
||||
} else if (res.statusCode === 404) {
|
||||
reject(new Error('Event not found'));
|
||||
} else if (res.statusCode === 400 && data.includes('uid already exists')) {
|
||||
// If UID conflict, try to delete and recreate
|
||||
this._deleteCalDAVEvent(userConfig, event.id)
|
||||
.then(() => this._createCalDAVEvent(userConfig, event))
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
return;
|
||||
} else {
|
||||
reject(new Error(`CalDAV event update failed with status ${res.statusCode}: ${data}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
reject(new Error(`CalDAV request failed: ${error.message}`));
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error('CalDAV request timeout'));
|
||||
});
|
||||
|
||||
req.write(icsContent);
|
||||
req.end();
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a CalDAV event
|
||||
* @private
|
||||
*/
|
||||
async _deleteCalDAVEvent(userConfig, eventId) {
|
||||
try {
|
||||
const caldavUrl = this._buildCalDAVUrl(userConfig);
|
||||
const eventUrl = `${caldavUrl}${eventId}.ics`;
|
||||
|
||||
const url = new URL(eventUrl);
|
||||
const auth = Buffer.from(`${userConfig.username}:${userConfig.password}`).toString('base64');
|
||||
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||
path: url.pathname,
|
||||
method: 'DELETE',
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'User-Agent': 'OpenWall/1.0 CalDAV-Client'
|
||||
}
|
||||
};
|
||||
|
||||
const protocol = url.protocol === 'https:' ? https : http;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = protocol.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve(true);
|
||||
} else if (res.statusCode === 401) {
|
||||
reject(new Error('CalDAV authentication failed'));
|
||||
} else if (res.statusCode === 404) {
|
||||
reject(new Error('Event not found'));
|
||||
} else {
|
||||
reject(new Error(`CalDAV event deletion failed with status ${res.statusCode}: ${data}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
reject(new Error(`CalDAV request failed: ${error.message}`));
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error('CalDAV request timeout'));
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate ICS event content
|
||||
* @private
|
||||
*/
|
||||
_generateICSEvent(event) {
|
||||
const now = new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
||||
const created = event.createdAt ? new Date(event.createdAt).toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z' : now;
|
||||
const modified = event.updatedAt ? new Date(event.updatedAt).toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z' : now;
|
||||
|
||||
return `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//OpenWall//Calendar//EN
|
||||
BEGIN:VEVENT
|
||||
UID:${event.id}
|
||||
DTSTART;VALUE=DATE:${event.dtstart}
|
||||
DTEND;VALUE=DATE:${event.dtend}
|
||||
SUMMARY:${event.summary}
|
||||
DESCRIPTION:${event.text}
|
||||
CREATED:${created}
|
||||
LAST-MODIFIED:${modified}
|
||||
DTSTAMP:${now}
|
||||
END:VEVENT
|
||||
END:VCALENDAR`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract contact name from birthday event text
|
||||
* @private
|
||||
*/
|
||||
_extractContactNameFromBirthday(text) {
|
||||
if (!text) return null;
|
||||
|
||||
// Common birthday text patterns in different languages
|
||||
const patterns = [
|
||||
/^(.+?)(?:'s|s)?\s*(?:Birthday|Geburtstag|birthday|geburtstag)$/i,
|
||||
/^(?:Birthday|Geburtstag)\s*[:-]?\s*(.+)$/i,
|
||||
/^(.+?)\s*\(.*\)$/i, // Remove parentheses content
|
||||
/^(.+?)(?:\s*-\s*Birthday)?$/i
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = text.match(pattern);
|
||||
if (match && match[1]) {
|
||||
return match[1].trim();
|
||||
}
|
||||
}
|
||||
|
||||
// If no pattern matches, return the original text (minus common birthday words)
|
||||
return text.replace(/\b(Birthday|Geburtstag|birthday|geburtstag)\b/gi, '').trim() || text;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new CalendarService();
|