generated from nhcarrigan/template
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
9fe720b6a7
|
|||
|
f3c2e8fa40
|
|||
| 5bfd25e60d | |||
| 662d6119fa |
+1
-1
@@ -26,7 +26,7 @@
|
||||
"autoprefixer": "10.4.21",
|
||||
"eslint": "9.25.1",
|
||||
"postcss": "8.5.3",
|
||||
"tailwindcss": "4.2.2",
|
||||
"tailwindcss": "3.4.17",
|
||||
"typescript": "5.8.3",
|
||||
"vite": "6.3.2",
|
||||
"@vitejs/plugin-react": "4.4.1"
|
||||
|
||||
Generated
+275
-9
@@ -52,8 +52,8 @@ importers:
|
||||
specifier: 8.5.3
|
||||
version: 8.5.3
|
||||
tailwindcss:
|
||||
specifier: 4.2.2
|
||||
version: 4.2.2
|
||||
specifier: 3.4.17
|
||||
version: 3.4.17
|
||||
typescript:
|
||||
specifier: 5.8.3
|
||||
version: 5.8.3
|
||||
@@ -63,6 +63,10 @@ importers:
|
||||
|
||||
packages:
|
||||
|
||||
'@alloc/quick-lru@5.2.0':
|
||||
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
'@babel/code-frame@7.29.0':
|
||||
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -870,10 +874,20 @@ packages:
|
||||
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
any-promise@1.3.0:
|
||||
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
|
||||
|
||||
anymatch@3.1.3:
|
||||
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
are-docs-informative@0.0.2:
|
||||
resolution: {integrity: sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
arg@5.0.2:
|
||||
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
|
||||
|
||||
argparse@2.0.1:
|
||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||
|
||||
@@ -944,6 +958,10 @@ packages:
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
binary-extensions@2.3.0:
|
||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
brace-expansion@1.1.13:
|
||||
resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==}
|
||||
|
||||
@@ -983,6 +1001,10 @@ packages:
|
||||
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
camelcase-css@2.0.1:
|
||||
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
caniuse-lite@1.0.30001787:
|
||||
resolution: {integrity: sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==}
|
||||
|
||||
@@ -994,6 +1016,10 @@ packages:
|
||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
chokidar@3.6.0:
|
||||
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
||||
engines: {node: '>= 8.10.0'}
|
||||
|
||||
ci-info@4.4.0:
|
||||
resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1009,6 +1035,10 @@ packages:
|
||||
color-name@1.1.4:
|
||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||
|
||||
commander@4.1.1:
|
||||
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
comment-parser@1.4.1:
|
||||
resolution: {integrity: sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@@ -1026,6 +1056,11 @@ packages:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
cssesc@3.0.0:
|
||||
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
||||
engines: {node: '>=4'}
|
||||
hasBin: true
|
||||
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
@@ -1069,10 +1104,16 @@ packages:
|
||||
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
didyoumean@1.2.2:
|
||||
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
||||
|
||||
dir-glob@3.0.1:
|
||||
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
dlv@1.1.3:
|
||||
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
|
||||
|
||||
doctrine@2.1.0:
|
||||
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1466,6 +1507,10 @@ packages:
|
||||
resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
is-binary-path@2.1.0:
|
||||
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-boolean-object@1.2.2:
|
||||
resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1624,6 +1669,10 @@ packages:
|
||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
lilconfig@3.1.3:
|
||||
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
lines-and-columns@1.2.4:
|
||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
||||
|
||||
@@ -1681,6 +1730,9 @@ packages:
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
mz@2.7.0:
|
||||
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
|
||||
|
||||
nanoid@3.3.11:
|
||||
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
@@ -1699,6 +1751,10 @@ packages:
|
||||
normalize-package-data@2.5.0:
|
||||
resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==}
|
||||
|
||||
normalize-path@3.0.0:
|
||||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
normalize-range@0.1.2:
|
||||
resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1707,6 +1763,10 @@ packages:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
object-hash@3.0.0:
|
||||
resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
object-inspect@1.13.4:
|
||||
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1807,6 +1867,14 @@ packages:
|
||||
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
pify@2.3.0:
|
||||
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
pirates@4.0.7:
|
||||
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
playwright-core@1.59.1:
|
||||
resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1825,6 +1893,40 @@ packages:
|
||||
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
postcss-import@15.1.0:
|
||||
resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
peerDependencies:
|
||||
postcss: ^8.0.0
|
||||
|
||||
postcss-js@4.1.0:
|
||||
resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==}
|
||||
engines: {node: ^12 || ^14 || >= 16}
|
||||
peerDependencies:
|
||||
postcss: ^8.4.21
|
||||
|
||||
postcss-load-config@4.0.2:
|
||||
resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==}
|
||||
engines: {node: '>= 14'}
|
||||
peerDependencies:
|
||||
postcss: '>=8.0.9'
|
||||
ts-node: '>=9.0.0'
|
||||
peerDependenciesMeta:
|
||||
postcss:
|
||||
optional: true
|
||||
ts-node:
|
||||
optional: true
|
||||
|
||||
postcss-nested@6.2.0:
|
||||
resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==}
|
||||
engines: {node: '>=12.0'}
|
||||
peerDependencies:
|
||||
postcss: ^8.2.14
|
||||
|
||||
postcss-selector-parser@6.1.2:
|
||||
resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
postcss-value-parser@4.2.0:
|
||||
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
|
||||
|
||||
@@ -1862,6 +1964,9 @@ packages:
|
||||
resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
read-cache@1.0.0:
|
||||
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
|
||||
|
||||
read-pkg-up@7.0.1:
|
||||
resolution: {integrity: sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1870,6 +1975,10 @@ packages:
|
||||
resolution: {integrity: sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
readdirp@3.6.0:
|
||||
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
||||
engines: {node: '>=8.10.0'}
|
||||
|
||||
reflect.getprototypeof@1.0.10:
|
||||
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2050,6 +2159,11 @@ packages:
|
||||
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
sucrase@3.35.1:
|
||||
resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
hasBin: true
|
||||
|
||||
supports-color@7.2.0:
|
||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -2062,8 +2176,17 @@ packages:
|
||||
resolution: {integrity: sha512-JJoOEKTfL1urb1mDoEblhD9NhEbWmq9jHEMEnxoC4ujUaZ4itA8vKgwkFAyNClgxplLi9tsUKX+EduK0p/l7sg==}
|
||||
engines: {node: ^14.18.0 || >=16.0.0}
|
||||
|
||||
tailwindcss@4.2.2:
|
||||
resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==}
|
||||
tailwindcss@3.4.17:
|
||||
resolution: {integrity: sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
hasBin: true
|
||||
|
||||
thenify-all@1.6.0:
|
||||
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
thenify@3.3.1:
|
||||
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
|
||||
|
||||
tinybench@2.9.0:
|
||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||
@@ -2096,6 +2219,9 @@ packages:
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4'
|
||||
|
||||
ts-interface-checker@0.1.13:
|
||||
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
|
||||
|
||||
tsconfig-paths@3.15.0:
|
||||
resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
|
||||
|
||||
@@ -2152,6 +2278,9 @@ packages:
|
||||
uri-js@4.4.1:
|
||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
validate-npm-package-license@3.0.4:
|
||||
resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
|
||||
|
||||
@@ -2280,6 +2409,8 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@alloc/quick-lru@5.2.0': {}
|
||||
|
||||
'@babel/code-frame@7.29.0':
|
||||
dependencies:
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
@@ -3066,8 +3197,17 @@ snapshots:
|
||||
dependencies:
|
||||
color-convert: 2.0.1
|
||||
|
||||
any-promise@1.3.0: {}
|
||||
|
||||
anymatch@3.1.3:
|
||||
dependencies:
|
||||
normalize-path: 3.0.0
|
||||
picomatch: 2.3.2
|
||||
|
||||
are-docs-informative@0.0.2: {}
|
||||
|
||||
arg@5.0.2: {}
|
||||
|
||||
argparse@2.0.1: {}
|
||||
|
||||
array-buffer-byte-length@1.0.2:
|
||||
@@ -3163,6 +3303,8 @@ snapshots:
|
||||
|
||||
baseline-browser-mapping@2.10.17: {}
|
||||
|
||||
binary-extensions@2.3.0: {}
|
||||
|
||||
brace-expansion@1.1.13:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
@@ -3209,6 +3351,8 @@ snapshots:
|
||||
|
||||
callsites@3.1.0: {}
|
||||
|
||||
camelcase-css@2.0.1: {}
|
||||
|
||||
caniuse-lite@1.0.30001787: {}
|
||||
|
||||
chai@6.2.2: {}
|
||||
@@ -3218,6 +3362,18 @@ snapshots:
|
||||
ansi-styles: 4.3.0
|
||||
supports-color: 7.2.0
|
||||
|
||||
chokidar@3.6.0:
|
||||
dependencies:
|
||||
anymatch: 3.1.3
|
||||
braces: 3.0.3
|
||||
glob-parent: 5.1.2
|
||||
is-binary-path: 2.1.0
|
||||
is-glob: 4.0.3
|
||||
normalize-path: 3.0.0
|
||||
readdirp: 3.6.0
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
ci-info@4.4.0: {}
|
||||
|
||||
clean-regexp@1.0.0:
|
||||
@@ -3230,6 +3386,8 @@ snapshots:
|
||||
|
||||
color-name@1.1.4: {}
|
||||
|
||||
commander@4.1.1: {}
|
||||
|
||||
comment-parser@1.4.1: {}
|
||||
|
||||
concat-map@0.0.1: {}
|
||||
@@ -3246,6 +3404,8 @@ snapshots:
|
||||
shebang-command: 2.0.0
|
||||
which: 2.0.2
|
||||
|
||||
cssesc@3.0.0: {}
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
data-view-buffer@1.0.2:
|
||||
@@ -3288,10 +3448,14 @@ snapshots:
|
||||
has-property-descriptors: 1.0.2
|
||||
object-keys: 1.1.1
|
||||
|
||||
didyoumean@1.2.2: {}
|
||||
|
||||
dir-glob@3.0.1:
|
||||
dependencies:
|
||||
path-type: 4.0.0
|
||||
|
||||
dlv@1.1.3: {}
|
||||
|
||||
doctrine@2.1.0:
|
||||
dependencies:
|
||||
esutils: 2.0.3
|
||||
@@ -3857,6 +4021,10 @@ snapshots:
|
||||
dependencies:
|
||||
has-bigints: 1.1.0
|
||||
|
||||
is-binary-path@2.1.0:
|
||||
dependencies:
|
||||
binary-extensions: 2.3.0
|
||||
|
||||
is-boolean-object@1.2.2:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
@@ -3964,8 +4132,7 @@ snapshots:
|
||||
has-symbols: 1.1.0
|
||||
set-function-name: 2.0.2
|
||||
|
||||
jiti@1.21.7:
|
||||
optional: true
|
||||
jiti@1.21.7: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
@@ -4009,6 +4176,8 @@ snapshots:
|
||||
prelude-ls: 1.2.1
|
||||
type-check: 0.4.0
|
||||
|
||||
lilconfig@3.1.3: {}
|
||||
|
||||
lines-and-columns@1.2.4: {}
|
||||
|
||||
locate-path@5.0.0:
|
||||
@@ -4060,6 +4229,12 @@ snapshots:
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
mz@2.7.0:
|
||||
dependencies:
|
||||
any-promise: 1.3.0
|
||||
object-assign: 4.1.1
|
||||
thenify-all: 1.6.0
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
@@ -4080,10 +4255,14 @@ snapshots:
|
||||
semver: 5.7.2
|
||||
validate-npm-package-license: 3.0.4
|
||||
|
||||
normalize-path@3.0.0: {}
|
||||
|
||||
normalize-range@0.1.2: {}
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-hash@3.0.0: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
|
||||
object-keys@1.1.1: {}
|
||||
@@ -4191,6 +4370,10 @@ snapshots:
|
||||
|
||||
picomatch@4.0.4: {}
|
||||
|
||||
pify@2.3.0: {}
|
||||
|
||||
pirates@4.0.7: {}
|
||||
|
||||
playwright-core@1.59.1: {}
|
||||
|
||||
playwright@1.59.1:
|
||||
@@ -4203,6 +4386,35 @@ snapshots:
|
||||
|
||||
possible-typed-array-names@1.1.0: {}
|
||||
|
||||
postcss-import@15.1.0(postcss@8.5.3):
|
||||
dependencies:
|
||||
postcss: 8.5.3
|
||||
postcss-value-parser: 4.2.0
|
||||
read-cache: 1.0.0
|
||||
resolve: 1.22.11
|
||||
|
||||
postcss-js@4.1.0(postcss@8.5.3):
|
||||
dependencies:
|
||||
camelcase-css: 2.0.1
|
||||
postcss: 8.5.3
|
||||
|
||||
postcss-load-config@4.0.2(postcss@8.5.3):
|
||||
dependencies:
|
||||
lilconfig: 3.1.3
|
||||
yaml: 2.8.3
|
||||
optionalDependencies:
|
||||
postcss: 8.5.3
|
||||
|
||||
postcss-nested@6.2.0(postcss@8.5.3):
|
||||
dependencies:
|
||||
postcss: 8.5.3
|
||||
postcss-selector-parser: 6.1.2
|
||||
|
||||
postcss-selector-parser@6.1.2:
|
||||
dependencies:
|
||||
cssesc: 3.0.0
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
postcss-value-parser@4.2.0: {}
|
||||
|
||||
postcss@8.5.3:
|
||||
@@ -4234,6 +4446,10 @@ snapshots:
|
||||
|
||||
react@19.1.0: {}
|
||||
|
||||
read-cache@1.0.0:
|
||||
dependencies:
|
||||
pify: 2.3.0
|
||||
|
||||
read-pkg-up@7.0.1:
|
||||
dependencies:
|
||||
find-up: 4.1.0
|
||||
@@ -4247,6 +4463,10 @@ snapshots:
|
||||
parse-json: 5.2.0
|
||||
type-fest: 0.6.0
|
||||
|
||||
readdirp@3.6.0:
|
||||
dependencies:
|
||||
picomatch: 2.3.2
|
||||
|
||||
reflect.getprototypeof@1.0.10:
|
||||
dependencies:
|
||||
call-bind: 1.0.9
|
||||
@@ -4500,6 +4720,16 @@ snapshots:
|
||||
|
||||
strip-json-comments@3.1.1: {}
|
||||
|
||||
sucrase@3.35.1:
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
commander: 4.1.1
|
||||
lines-and-columns: 1.2.4
|
||||
mz: 2.7.0
|
||||
pirates: 4.0.7
|
||||
tinyglobby: 0.2.16
|
||||
ts-interface-checker: 0.1.13
|
||||
|
||||
supports-color@7.2.0:
|
||||
dependencies:
|
||||
has-flag: 4.0.0
|
||||
@@ -4511,7 +4741,40 @@ snapshots:
|
||||
'@pkgr/core': 0.1.2
|
||||
tslib: 2.8.1
|
||||
|
||||
tailwindcss@4.2.2: {}
|
||||
tailwindcss@3.4.17:
|
||||
dependencies:
|
||||
'@alloc/quick-lru': 5.2.0
|
||||
arg: 5.0.2
|
||||
chokidar: 3.6.0
|
||||
didyoumean: 1.2.2
|
||||
dlv: 1.1.3
|
||||
fast-glob: 3.3.3
|
||||
glob-parent: 6.0.2
|
||||
is-glob: 4.0.3
|
||||
jiti: 1.21.7
|
||||
lilconfig: 3.1.3
|
||||
micromatch: 4.0.8
|
||||
normalize-path: 3.0.0
|
||||
object-hash: 3.0.0
|
||||
picocolors: 1.1.1
|
||||
postcss: 8.5.3
|
||||
postcss-import: 15.1.0(postcss@8.5.3)
|
||||
postcss-js: 4.1.0(postcss@8.5.3)
|
||||
postcss-load-config: 4.0.2(postcss@8.5.3)
|
||||
postcss-nested: 6.2.0(postcss@8.5.3)
|
||||
postcss-selector-parser: 6.1.2
|
||||
resolve: 1.22.11
|
||||
sucrase: 3.35.1
|
||||
transitivePeerDependencies:
|
||||
- ts-node
|
||||
|
||||
thenify-all@1.6.0:
|
||||
dependencies:
|
||||
thenify: 3.3.1
|
||||
|
||||
thenify@3.3.1:
|
||||
dependencies:
|
||||
any-promise: 1.3.0
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
|
||||
@@ -4536,6 +4799,8 @@ snapshots:
|
||||
dependencies:
|
||||
typescript: 5.8.3
|
||||
|
||||
ts-interface-checker@0.1.13: {}
|
||||
|
||||
tsconfig-paths@3.15.0:
|
||||
dependencies:
|
||||
'@types/json5': 0.0.29
|
||||
@@ -4607,6 +4872,8 @@ snapshots:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
validate-npm-package-license@3.0.4:
|
||||
dependencies:
|
||||
spdx-correct: 3.2.0
|
||||
@@ -4704,7 +4971,6 @@ snapshots:
|
||||
|
||||
yallist@3.1.1: {}
|
||||
|
||||
yaml@2.8.3:
|
||||
optional: true
|
||||
yaml@2.8.3: {}
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 298 KiB |
+54
-19
@@ -46,12 +46,19 @@ fn build_safety_settings() -> Value {
|
||||
])
|
||||
}
|
||||
|
||||
fn build_generation_config(mode: &str) -> Value {
|
||||
fn build_generation_config(
|
||||
mode: &str,
|
||||
aspect_ratio: Option<&str>,
|
||||
image_size: &str,
|
||||
) -> Value {
|
||||
let image_config = match mode {
|
||||
"avatar" => json!({ "aspectRatio": "1:1", "imageSize": "4K" }),
|
||||
"art" => json!({ "aspectRatio": "16:9", "imageSize": "4K" }),
|
||||
"avatar" => json!({ "aspectRatio": "1:1", "imageSize": image_size }),
|
||||
"art" => {
|
||||
let ratio = aspect_ratio.unwrap_or("16:9");
|
||||
json!({ "aspectRatio": ratio, "imageSize": image_size })
|
||||
}
|
||||
// replace mode: omit aspectRatio so the model infers it from the source image
|
||||
_ => json!({ "imageSize": "4K" }),
|
||||
_ => json!({ "imageSize": image_size }),
|
||||
};
|
||||
json!({
|
||||
"imageConfig": image_config,
|
||||
@@ -83,37 +90,56 @@ fn build_user_gemini_parts(
|
||||
user_image_base64: &Option<String>,
|
||||
user_image_mime: &Option<String>,
|
||||
) -> Vec<Value> {
|
||||
if mode == "replace" && user_image_base64.is_some() {
|
||||
if let Some(image_data) = user_image_base64.as_deref() {
|
||||
let mime = user_image_mime.as_deref().unwrap_or("image/png");
|
||||
let data = user_image_base64.as_deref().unwrap_or("");
|
||||
let base_text = user_text.as_deref().unwrap_or("");
|
||||
let final_text = if base_text.is_empty() {
|
||||
let final_text = if mode == "replace" {
|
||||
if base_text.is_empty() {
|
||||
REPLACE_MODE_APPEND.to_string()
|
||||
} else {
|
||||
format!("{}\n{}", base_text, REPLACE_MODE_APPEND)
|
||||
}
|
||||
} else {
|
||||
base_text.to_string()
|
||||
};
|
||||
|
||||
vec![
|
||||
json!({"inlineData": {"mimeType": mime, "data": data}}),
|
||||
json!({"text": final_text}),
|
||||
]
|
||||
let mut parts = vec![json!({"inlineData": {"mimeType": mime, "data": image_data}})];
|
||||
if !final_text.is_empty() {
|
||||
parts.push(json!({"text": final_text}));
|
||||
}
|
||||
parts
|
||||
} else {
|
||||
// Art/avatar mode, or replace mode follow-up correction (text only)
|
||||
// No image: text-only message
|
||||
let text = user_text.as_deref().unwrap_or("");
|
||||
vec![json!({"text": text})]
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GeminiCallParams {
|
||||
pub mode: String,
|
||||
pub aspect_ratio: Option<String>,
|
||||
pub image_size: String,
|
||||
pub user_text: Option<String>,
|
||||
pub user_image_base64: Option<String>,
|
||||
pub user_image_mime: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn call_gemini(
|
||||
api_key: String,
|
||||
mode: String,
|
||||
history: Vec<ThreadMessage>,
|
||||
user_text: Option<String>,
|
||||
user_image_base64: Option<String>,
|
||||
user_image_mime: Option<String>,
|
||||
params: GeminiCallParams,
|
||||
) -> Result<(Vec<MessagePart>, f64), String> {
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let GeminiCallParams {
|
||||
mode,
|
||||
aspect_ratio,
|
||||
image_size,
|
||||
user_text,
|
||||
user_image_base64,
|
||||
user_image_mime,
|
||||
} = params;
|
||||
|
||||
let is_first_message = history.is_empty();
|
||||
|
||||
let mut contents: Vec<Value> = history
|
||||
@@ -156,7 +182,11 @@ pub async fn call_gemini(
|
||||
|
||||
contents.push(json!({"role": "user", "parts": user_parts}));
|
||||
|
||||
let generation_config = build_generation_config(mode.as_str());
|
||||
let generation_config = build_generation_config(
|
||||
mode.as_str(),
|
||||
aspect_ratio.as_deref(),
|
||||
image_size.as_str(),
|
||||
);
|
||||
let safety_settings = build_safety_settings();
|
||||
|
||||
let request_body = json!({
|
||||
@@ -248,8 +278,13 @@ pub async fn call_gemini(
|
||||
let candidates_tokens = usage["candidatesTokenCount"].as_u64().unwrap_or(0);
|
||||
let image_part_count = result_parts.iter().filter(|p| p.part_type == "image").count() as u64;
|
||||
|
||||
// Image output tokens (4K = 2000 tokens each) billed at $120/1M tokens
|
||||
let image_output_tokens = image_part_count * 2_000_u64;
|
||||
// Image output tokens per image vary by resolution, billed at $120/1M tokens
|
||||
let tokens_per_image: u64 = match image_size.as_str() {
|
||||
"1K" => 500,
|
||||
"2K" => 1_000,
|
||||
_ => 2_000, // 4K default
|
||||
};
|
||||
let image_output_tokens = image_part_count * tokens_per_image;
|
||||
// Remaining candidates tokens are text/thinking, billed at $12/1M tokens
|
||||
let text_output_tokens = candidates_tokens.saturating_sub(image_output_tokens);
|
||||
|
||||
|
||||
+17
-3
@@ -1,7 +1,7 @@
|
||||
mod gemini;
|
||||
mod storage;
|
||||
|
||||
use gemini::{call_gemini, read_reference_image_base64};
|
||||
use gemini::{call_gemini, read_reference_image_base64, GeminiCallParams};
|
||||
use serde::Serialize;
|
||||
use storage::{
|
||||
delete_thread_from_disk, load_config_from_disk, load_threads_from_disk, save_config_to_disk,
|
||||
@@ -46,16 +46,30 @@ async fn save_config(config: Config) -> Result<(), String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn send_message(
|
||||
api_key: String,
|
||||
mode: String,
|
||||
aspect_ratio: Option<String>,
|
||||
image_size: String,
|
||||
history: Vec<ThreadMessage>,
|
||||
user_text: Option<String>,
|
||||
user_image_base64: Option<String>,
|
||||
user_image_mime: Option<String>,
|
||||
) -> Result<SendMessageResult, String> {
|
||||
let (parts, cost_usd) =
|
||||
call_gemini(api_key, mode, history, user_text, user_image_base64, user_image_mime).await?;
|
||||
let (parts, cost_usd) = call_gemini(
|
||||
api_key,
|
||||
history,
|
||||
GeminiCallParams {
|
||||
mode,
|
||||
aspect_ratio,
|
||||
image_size,
|
||||
user_text,
|
||||
user_image_base64,
|
||||
user_image_mime,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(SendMessageResult { parts, cost_usd })
|
||||
}
|
||||
|
||||
|
||||
@@ -25,11 +25,19 @@ pub struct ThreadMessage {
|
||||
pub cost_usd: Option<f64>,
|
||||
}
|
||||
|
||||
fn default_image_size() -> String {
|
||||
"4K".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Thread {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub mode: String,
|
||||
#[serde(rename = "aspectRatio", skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio: Option<String>,
|
||||
#[serde(rename = "imageSize", default = "default_image_size")]
|
||||
pub image_size: String,
|
||||
pub messages: Vec<ThreadMessage>,
|
||||
#[serde(rename = "createdAt")]
|
||||
pub created_at: i64,
|
||||
|
||||
+16
-2
@@ -16,7 +16,14 @@ import { SettingsModal } from "./components/settingsModal.tsx";
|
||||
import { Sidebar } from "./components/sidebar.tsx";
|
||||
import { ThreadView } from "./components/threadView.tsx";
|
||||
import { WelcomeScreen } from "./components/welcomeScreen.tsx";
|
||||
import type { Config, Mode, PendingInput, Thread } from "./types/index.ts";
|
||||
import type {
|
||||
AspectRatio,
|
||||
Config,
|
||||
ImageSize,
|
||||
Mode,
|
||||
PendingInput,
|
||||
Thread,
|
||||
} from "./types/index.ts";
|
||||
|
||||
const generateThreadName = (mode: Mode, text?: string): string => {
|
||||
if (
|
||||
@@ -120,11 +127,18 @@ const app = (): JSX.Element => {
|
||||
});
|
||||
|
||||
const handleNewThread = useCallback(
|
||||
(mode: Mode): void => {
|
||||
(
|
||||
mode: Mode,
|
||||
aspectRatio: AspectRatio | undefined,
|
||||
imageSize: ImageSize,
|
||||
): void => {
|
||||
const now = Date.now();
|
||||
const createdThread: Thread = {
|
||||
/* eslint-disable-next-line stylistic/no-extra-parens -- Required for conditional spread with exactOptionalPropertyTypes */
|
||||
...(aspectRatio !== undefined && { aspectRatio }),
|
||||
createdAt: now,
|
||||
id: generateId(),
|
||||
imageSize: imageSize,
|
||||
messages: [],
|
||||
mode: mode,
|
||||
name: `New ${mode} thread`,
|
||||
|
||||
@@ -19,7 +19,12 @@ import {
|
||||
type JSX,
|
||||
type KeyboardEvent,
|
||||
} from "react";
|
||||
import type { Mode, PendingInput } from "../types/index.ts";
|
||||
import type {
|
||||
AspectRatio,
|
||||
ImageSize,
|
||||
Mode,
|
||||
PendingInput,
|
||||
} from "../types/index.ts";
|
||||
|
||||
const dropZoneBaseClass = [
|
||||
"border-2 border-dashed rounded-xl p-8",
|
||||
@@ -113,14 +118,19 @@ const readClipboardImage = async(
|
||||
onFileRead(new File([ blob ], "clipboard.png", { type: imageType }));
|
||||
};
|
||||
|
||||
const modeLabelText = (mode: Mode): string => {
|
||||
const modeLabelText = (
|
||||
mode: Mode,
|
||||
aspectRatio: AspectRatio | undefined,
|
||||
imageSize: ImageSize,
|
||||
): string => {
|
||||
if (mode === "avatar") {
|
||||
return "🟣 Avatar Mode (1:1)";
|
||||
return `🟣 Avatar Mode · ${imageSize}`;
|
||||
}
|
||||
if (mode === "art") {
|
||||
return "🩷 Art Mode (16:9)";
|
||||
const ratio = aspectRatio ?? "16:9";
|
||||
return `🩷 Art Mode (${ratio}) · ${imageSize}`;
|
||||
}
|
||||
return "🔵 Replace Mode";
|
||||
return `🔵 Replace Mode · ${imageSize}`;
|
||||
};
|
||||
|
||||
type OnSendCallback = (
|
||||
@@ -130,7 +140,9 @@ type OnSendCallback = (
|
||||
)=> void;
|
||||
|
||||
interface InputAreaProperties {
|
||||
readonly aspectRatio?: AspectRatio;
|
||||
readonly hasMessages: boolean;
|
||||
readonly imageSize: ImageSize;
|
||||
readonly initialImageBase64?: string;
|
||||
readonly initialImageMime?: string;
|
||||
readonly initialImagePreview?: string;
|
||||
@@ -144,7 +156,9 @@ interface InputAreaProperties {
|
||||
/**
|
||||
* Renders the input area for composing and sending messages.
|
||||
* @param props - The component props.
|
||||
* @param props.aspectRatio - The thread's aspect ratio setting (Art mode only).
|
||||
* @param props.hasMessages - Whether the thread already has messages (affects replace mode UI).
|
||||
* @param props.imageSize - The thread's resolution setting.
|
||||
* @param props.initialImageBase64 - Initial base64 image data to pre-populate.
|
||||
* @param props.initialImageMime - Initial image MIME type to pre-populate.
|
||||
* @param props.initialImagePreview - Initial image preview URL to pre-populate.
|
||||
@@ -156,7 +170,9 @@ interface InputAreaProperties {
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const inputArea = ({
|
||||
aspectRatio,
|
||||
hasMessages,
|
||||
imageSize,
|
||||
initialImageBase64,
|
||||
initialImageMime,
|
||||
initialImagePreview,
|
||||
@@ -256,7 +272,8 @@ const inputArea = ({
|
||||
if (isInitialReplace && imageBase64 === undefined) {
|
||||
return;
|
||||
}
|
||||
if (!isInitialReplace && text.trim().length === 0) {
|
||||
const hasContent = text.trim().length > 0 || imageBase64 !== undefined;
|
||||
if (!isInitialReplace && !hasContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -332,11 +349,14 @@ const inputArea = ({
|
||||
: dropZoneInactiveClass,
|
||||
].join(" ");
|
||||
|
||||
const hasNoContent = text.trim().length === 0 && imageBase64 === undefined;
|
||||
const isSendDisabled = isLoading || hasNoContent;
|
||||
|
||||
return (
|
||||
<div className="border-t border-purple-900/30 p-4 bg-[#0f0a1a]">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs text-gray-500 uppercase tracking-wider">
|
||||
{modeLabelText(mode)}
|
||||
{modeLabelText(mode, aspectRatio, imageSize)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -397,6 +417,15 @@ const inputArea = ({
|
||||
type="file"
|
||||
/>
|
||||
|
||||
<textarea
|
||||
className={textareaClass}
|
||||
disabled={isLoading}
|
||||
onChange={handleTextChange}
|
||||
placeholder="Add notes to include with the image (optional)..."
|
||||
rows={2}
|
||||
value={text}
|
||||
/>
|
||||
|
||||
<button
|
||||
className={replaceButtonClass}
|
||||
disabled={isLoading || imageBase64 === undefined}
|
||||
@@ -414,15 +443,16 @@ const inputArea = ({
|
||||
</div>
|
||||
|
||||
: <div className="flex flex-col gap-3">
|
||||
{mode === "replace"
|
||||
? <div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
{imagePreview === undefined
|
||||
? <button
|
||||
className={pasteButtonClass}
|
||||
onClick={handlePasteButtonClick}
|
||||
type="button"
|
||||
>
|
||||
{"📋 Paste replacement image (optional)"}
|
||||
{mode === "replace"
|
||||
? "📋 Paste replacement image (optional)"
|
||||
: "📋 Paste image (optional)"}
|
||||
</button>
|
||||
|
||||
: <div className="relative inline-block">
|
||||
@@ -448,8 +478,6 @@ const inputArea = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
: null}
|
||||
|
||||
<div className="flex gap-3 items-end">
|
||||
<textarea
|
||||
className={textareaClass}
|
||||
@@ -464,7 +492,7 @@ const inputArea = ({
|
||||
/>
|
||||
<button
|
||||
className={sendButtonClass}
|
||||
disabled={isLoading || text.trim().length === 0}
|
||||
disabled={isSendDisabled}
|
||||
onClick={handleSend}
|
||||
type="button"
|
||||
>
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
/**
|
||||
* @file Modal for selecting a new thread generation mode.
|
||||
* @file Modal for selecting a new thread generation mode and options.
|
||||
* @copyright Naomi Carrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Component JSX inherently requires many lines */
|
||||
import { useCallback, type JSX, type MouseEvent } from "react";
|
||||
import type { Mode } from "../types/index.ts";
|
||||
/* eslint-disable max-lines -- Two-step modal requires many option definitions */
|
||||
import {
|
||||
useCallback,
|
||||
useState,
|
||||
type JSX,
|
||||
type MouseEvent,
|
||||
} from "react";
|
||||
import type { AspectRatio, ImageSize, Mode } from "../types/index.ts";
|
||||
|
||||
interface ModeOption {
|
||||
colour: string;
|
||||
@@ -16,14 +22,25 @@ interface ModeOption {
|
||||
mode: Mode;
|
||||
}
|
||||
|
||||
interface AspectRatioOption {
|
||||
label: string;
|
||||
value: AspectRatio;
|
||||
}
|
||||
|
||||
interface ImageSizeOption {
|
||||
description: string;
|
||||
label: string;
|
||||
value: ImageSize;
|
||||
}
|
||||
|
||||
const avatarDescription = [
|
||||
"Generate a square 1:1 portrait.",
|
||||
"Perfect for profile pictures and avatars.",
|
||||
].join(" ");
|
||||
|
||||
const artDescription = [
|
||||
"Generate wide 16:9 landscape artwork.",
|
||||
"Great for wallpapers and banners.",
|
||||
"Generate landscape or portrait artwork.",
|
||||
"Choose your aspect ratio and resolution.",
|
||||
].join(" ");
|
||||
|
||||
const replaceDescription = [
|
||||
@@ -55,6 +72,21 @@ const modeOptionList: Array<ModeOption> = [
|
||||
},
|
||||
];
|
||||
|
||||
const aspectRatioOptions: Array<AspectRatioOption> = [
|
||||
{ label: "1:1 Square", value: "1:1" },
|
||||
{ label: "4:3 Standard", value: "4:3" },
|
||||
{ label: "3:4 Portrait", value: "3:4" },
|
||||
{ label: "16:9 Widescreen", value: "16:9" },
|
||||
{ label: "9:16 Wallpaper", value: "9:16" },
|
||||
{ label: "21:9 Ultrawide", value: "21:9" },
|
||||
];
|
||||
|
||||
const imageSizeOptions: Array<ImageSizeOption> = [
|
||||
{ description: "Faster, cheaper", label: "1K", value: "1K" },
|
||||
{ description: "Balanced", label: "2K", value: "2K" },
|
||||
{ description: "Best quality", label: "4K", value: "4K" },
|
||||
];
|
||||
|
||||
const colourMap: Record<
|
||||
string,
|
||||
{ badge: string; button: string; hover: string }
|
||||
@@ -82,6 +114,20 @@ const isMode = (value: string): value is Mode => {
|
||||
return validModesSet.has(value);
|
||||
};
|
||||
|
||||
const validAspectRatios = new Set<string>([
|
||||
"1:1", "3:4", "4:3", "9:16", "16:9", "21:9",
|
||||
]);
|
||||
|
||||
const isAspectRatio = (value: string): value is AspectRatio => {
|
||||
return validAspectRatios.has(value);
|
||||
};
|
||||
|
||||
const validImageSizes = new Set<string>([ "1K", "2K", "4K" ]);
|
||||
|
||||
const isImageSize = (value: string): value is ImageSize => {
|
||||
return validImageSizes.has(value);
|
||||
};
|
||||
|
||||
const overlayClass = [
|
||||
"fixed inset-0 bg-black/70 backdrop-blur-sm",
|
||||
"flex items-center justify-center z-50",
|
||||
@@ -93,32 +139,243 @@ const panelClass = [
|
||||
"shadow-2xl shadow-purple-900/30",
|
||||
].join(" ");
|
||||
|
||||
const pillBaseClass = [
|
||||
"px-4 py-2 rounded-xl border text-sm font-medium",
|
||||
"transition-all duration-200 cursor-pointer",
|
||||
].join(" ");
|
||||
|
||||
const pillActiveClass = "border-purple-500 bg-purple-600/30 text-white";
|
||||
|
||||
const pillInactiveClass = [
|
||||
"border-purple-900/40 bg-transparent text-gray-400",
|
||||
"hover:border-purple-500/60 hover:text-gray-200",
|
||||
].join(" ");
|
||||
|
||||
const startButtonClass = [
|
||||
"w-full bg-gradient-to-r from-purple-600 to-pink-600",
|
||||
"hover:from-purple-500 hover:to-pink-500",
|
||||
"text-white font-semibold py-3 rounded-xl",
|
||||
"transition-all duration-200 mt-2",
|
||||
].join(" ");
|
||||
|
||||
const sizeButtonActiveClass = "border-purple-500 bg-purple-600/30 text-white";
|
||||
|
||||
const sizeButtonInactiveClass = [
|
||||
"border-purple-900/40 bg-transparent text-gray-400",
|
||||
"hover:border-purple-500/60 hover:text-gray-200",
|
||||
].join(" ");
|
||||
|
||||
const backButtonClass = [
|
||||
"text-gray-400 hover:text-white",
|
||||
"transition-colors text-xl",
|
||||
].join(" ");
|
||||
|
||||
const sectionLabelClass = [
|
||||
"text-sm text-gray-400",
|
||||
"uppercase tracking-wider mb-3",
|
||||
].join(" ");
|
||||
|
||||
interface NewThreadModalProperties {
|
||||
readonly onClose: ()=> void;
|
||||
readonly onSelect: (mode: Mode)=> void;
|
||||
readonly onSelect: (
|
||||
mode: Mode,
|
||||
aspectRatio: AspectRatio | undefined,
|
||||
imageSize: ImageSize,
|
||||
)=> void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the new thread modal for selecting a generation mode.
|
||||
* Renders the new thread modal for selecting a generation mode and options.
|
||||
* @param props - The component props.
|
||||
* @param props.onClose - Callback to close the modal.
|
||||
* @param props.onSelect - Callback when a mode is selected.
|
||||
* @param props.onSelect - Callback when a mode and options are confirmed.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const threadModal = ({
|
||||
onClose,
|
||||
onSelect,
|
||||
}: NewThreadModalProperties): JSX.Element => {
|
||||
const [ step, setStep ] = useState<"mode" | "options">("mode");
|
||||
const [ selectedMode, setSelectedMode ] = useState<Mode | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [ aspectRatio, setAspectRatio ] = useState<AspectRatio>("16:9");
|
||||
const [ imageSize, setImageSize ] = useState<ImageSize>("4K");
|
||||
|
||||
const handleModeSelect = useCallback(
|
||||
(event: MouseEvent<HTMLButtonElement>): void => {
|
||||
const rawMode = event.currentTarget.dataset.mode;
|
||||
if (rawMode !== undefined && isMode(rawMode)) {
|
||||
onSelect(rawMode);
|
||||
setSelectedMode(rawMode);
|
||||
setStep("options");
|
||||
}
|
||||
},
|
||||
[ onSelect ],
|
||||
[],
|
||||
);
|
||||
|
||||
const handleBack = useCallback((): void => {
|
||||
setStep("mode");
|
||||
}, []);
|
||||
|
||||
const handleStart = useCallback((): void => {
|
||||
if (selectedMode === undefined) {
|
||||
return;
|
||||
}
|
||||
const ratio = selectedMode === "art"
|
||||
? aspectRatio
|
||||
: undefined;
|
||||
onSelect(selectedMode, ratio, imageSize);
|
||||
}, [ selectedMode, aspectRatio, imageSize, onSelect ]);
|
||||
|
||||
const handleAspectRatioSelect = useCallback(
|
||||
(event: MouseEvent<HTMLButtonElement>): void => {
|
||||
const rawRatio = event.currentTarget.dataset.ratio;
|
||||
if (rawRatio !== undefined && isAspectRatio(rawRatio)) {
|
||||
setAspectRatio(rawRatio);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleImageSizeSelect = useCallback(
|
||||
(event: MouseEvent<HTMLButtonElement>): void => {
|
||||
const rawSize = event.currentTarget.dataset.size;
|
||||
if (rawSize !== undefined && isImageSize(rawSize)) {
|
||||
setImageSize(rawSize);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const selectedModeOption = modeOptionList.find((o) => {
|
||||
return o.mode === selectedMode;
|
||||
});
|
||||
const selectedColours = selectedModeOption === undefined
|
||||
? colourMap.purple
|
||||
: colourMap[selectedModeOption.colour];
|
||||
|
||||
if (step === "options" && selectedModeOption !== undefined) {
|
||||
return (
|
||||
<div className={overlayClass}>
|
||||
<div className={panelClass}>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<button
|
||||
className={backButtonClass}
|
||||
onClick={handleBack}
|
||||
type="button"
|
||||
>
|
||||
{"←"}
|
||||
</button>
|
||||
<h2 className="text-2xl font-bold text-white flex-1">
|
||||
{"Configure Thread"}
|
||||
</h2>
|
||||
<button
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
{"×"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={[
|
||||
"flex items-center gap-3 mb-6 p-4",
|
||||
"rounded-xl bg-[#241836] border border-purple-900/30",
|
||||
].join(" ")}
|
||||
>
|
||||
<span className="text-3xl">{selectedModeOption.icon}</span>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white font-semibold">
|
||||
{selectedModeOption.label}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full ${selectedColours?.badge ?? ""}`}
|
||||
>
|
||||
{selectedModeOption.mode}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-400 text-xs mt-0.5">
|
||||
{selectedModeOption.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedMode === "art"
|
||||
? <div className="mb-6">
|
||||
<p className={sectionLabelClass}>
|
||||
{"Aspect Ratio"}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{aspectRatioOptions.map((option) => {
|
||||
const pillClass = [
|
||||
pillBaseClass,
|
||||
aspectRatio === option.value
|
||||
? pillActiveClass
|
||||
: pillInactiveClass,
|
||||
].join(" ");
|
||||
return (
|
||||
<button
|
||||
className={pillClass}
|
||||
data-ratio={option.value}
|
||||
key={option.value}
|
||||
onClick={handleAspectRatioSelect}
|
||||
type="button"
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
: null}
|
||||
|
||||
<div className="mb-6">
|
||||
<p className={sectionLabelClass}>
|
||||
{"Resolution"}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
{imageSizeOptions.map((option) => {
|
||||
const sizeClass = [
|
||||
"flex-1 flex flex-col items-center py-3 px-2",
|
||||
"rounded-xl border text-sm font-medium",
|
||||
"transition-all duration-200 cursor-pointer",
|
||||
imageSize === option.value
|
||||
? sizeButtonActiveClass
|
||||
: sizeButtonInactiveClass,
|
||||
].join(" ");
|
||||
return (
|
||||
<button
|
||||
className={sizeClass}
|
||||
data-size={option.value}
|
||||
key={option.value}
|
||||
onClick={handleImageSizeSelect}
|
||||
type="button"
|
||||
>
|
||||
<span className="text-base font-bold">{option.label}</span>
|
||||
<span className="text-xs mt-0.5 opacity-70">
|
||||
{option.description}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={startButtonClass}
|
||||
onClick={handleStart}
|
||||
type="button"
|
||||
>
|
||||
{"Start Thread →"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={overlayClass}>
|
||||
<div className={panelClass}>
|
||||
|
||||
@@ -68,19 +68,19 @@ const modeLabel = (mode: Mode): string => {
|
||||
interface BuildUserPartsOptions {
|
||||
readonly imageBase64?: string;
|
||||
readonly imageMime?: string;
|
||||
readonly mode: Mode;
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
const buildUserParts = ({
|
||||
imageBase64,
|
||||
imageMime,
|
||||
mode,
|
||||
text,
|
||||
}: BuildUserPartsOptions): Array<MessagePart> => {
|
||||
const userParts: Array<MessagePart> = [];
|
||||
|
||||
if (mode === "replace" && imageBase64 !== undefined) {
|
||||
if (imageBase64 === undefined) {
|
||||
userParts.push({ text: text, type: "text" });
|
||||
} else {
|
||||
userParts.push({
|
||||
imageData: imageBase64,
|
||||
mimeType: imageMime ?? "image/png",
|
||||
@@ -89,8 +89,6 @@ const buildUserParts = ({
|
||||
if (text.length > 0) {
|
||||
userParts.push({ text: text, type: "text" });
|
||||
}
|
||||
} else {
|
||||
userParts.push({ text: text, type: "text" });
|
||||
}
|
||||
|
||||
return userParts;
|
||||
@@ -174,13 +172,18 @@ const threadView = ({
|
||||
onLoadingChange(true);
|
||||
onErrorChange(undefined);
|
||||
|
||||
const { messages, mode } = thread;
|
||||
const {
|
||||
aspectRatio,
|
||||
imageSize: rawImageSize,
|
||||
messages,
|
||||
mode,
|
||||
} = thread;
|
||||
const imageSize = rawImageSize ?? "4K";
|
||||
const userParts = buildUserParts({
|
||||
/* eslint-disable-next-line stylistic/no-extra-parens -- Required for conditional spread with exactOptionalPropertyTypes */
|
||||
...(imageBase64 !== undefined && { imageBase64 }),
|
||||
/* eslint-disable-next-line stylistic/no-extra-parens -- Required for conditional spread with exactOptionalPropertyTypes */
|
||||
...(imageMime !== undefined && { imageMime }),
|
||||
mode,
|
||||
text,
|
||||
});
|
||||
|
||||
@@ -217,7 +220,9 @@ const threadView = ({
|
||||
"send_message",
|
||||
{
|
||||
apiKey,
|
||||
aspectRatio,
|
||||
history,
|
||||
imageSize,
|
||||
mode,
|
||||
userImageBase64,
|
||||
userImageMime,
|
||||
@@ -253,7 +258,13 @@ const threadView = ({
|
||||
|
||||
const handleRetry = useCallback(
|
||||
(modelMessageIndex: number): void => {
|
||||
const { messages, mode } = thread;
|
||||
const {
|
||||
aspectRatio,
|
||||
imageSize: rawImageSize,
|
||||
messages,
|
||||
mode,
|
||||
} = thread;
|
||||
const imageSize = rawImageSize ?? "4K";
|
||||
const userMessageIndex = modelMessageIndex - 1;
|
||||
const userMessage = messages[userMessageIndex];
|
||||
|
||||
@@ -300,7 +311,9 @@ const threadView = ({
|
||||
}
|
||||
const response = await invoke<SendResult>("send_message", {
|
||||
apiKey,
|
||||
aspectRatio,
|
||||
history,
|
||||
imageSize,
|
||||
mode,
|
||||
userImageBase64,
|
||||
userImageMime,
|
||||
@@ -353,7 +366,13 @@ const threadView = ({
|
||||
|
||||
const handleEditCommit = useCallback(
|
||||
(messageIndex: number, editedText: string): void => {
|
||||
const { messages, mode } = thread;
|
||||
const {
|
||||
aspectRatio,
|
||||
imageSize: rawImageSize,
|
||||
messages,
|
||||
mode,
|
||||
} = thread;
|
||||
const imageSize = rawImageSize ?? "4K";
|
||||
const originalMessage = messages[messageIndex];
|
||||
if (originalMessage === undefined) {
|
||||
return;
|
||||
@@ -404,7 +423,9 @@ const threadView = ({
|
||||
}
|
||||
const response = await invoke<SendResult>("send_message", {
|
||||
apiKey,
|
||||
aspectRatio,
|
||||
history,
|
||||
imageSize,
|
||||
mode,
|
||||
userImageBase64,
|
||||
userImageMime,
|
||||
@@ -536,7 +557,11 @@ const threadView = ({
|
||||
{...(pendingInput?.text !== undefined && {
|
||||
initialText: pendingInput.text,
|
||||
})}
|
||||
{...(thread.aspectRatio !== undefined && {
|
||||
aspectRatio: thread.aspectRatio,
|
||||
})}
|
||||
hasMessages={thread.messages.length > 0}
|
||||
imageSize={thread.imageSize ?? "4K"}
|
||||
isLoading={isLoading}
|
||||
mode={thread.mode}
|
||||
onInputChange={onPendingInputChange}
|
||||
|
||||
+14
-1
@@ -5,6 +5,8 @@
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
type AspectRatio = "1:1" | "3:4" | "4:3" | "9:16" | "16:9" | "21:9";
|
||||
type ImageSize = "1K" | "2K" | "4K";
|
||||
type Mode = "avatar" | "art" | "replace";
|
||||
|
||||
interface Config {
|
||||
@@ -33,12 +35,23 @@ interface ThreadMessage {
|
||||
}
|
||||
|
||||
interface Thread {
|
||||
aspectRatio?: AspectRatio;
|
||||
createdAt: number;
|
||||
id: string;
|
||||
imageSize?: ImageSize;
|
||||
messages: Array<ThreadMessage>;
|
||||
mode: Mode;
|
||||
name: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export type { Config, MessagePart, Mode, PendingInput, Thread, ThreadMessage };
|
||||
export type {
|
||||
AspectRatio,
|
||||
Config,
|
||||
ImageSize,
|
||||
MessagePart,
|
||||
Mode,
|
||||
PendingInput,
|
||||
Thread,
|
||||
ThreadMessage,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user