diff options
32 files changed, 3163 insertions, 65 deletions
@@ -6,6 +6,7 @@ "dependencies": { "@edge-runtime/cookies": "^6.0.0", "@hookform/resolvers": "^5.0.1", + "@radix-ui/react-dialog": "^1.1.13", "@radix-ui/react-label": "^2.1.6", "@radix-ui/react-select": "^2.2.4", "@radix-ui/react-slot": "^1.2.2", @@ -14,8 +15,8 @@ "cookie": "^1.0.2", "franc-all": "^7.2.0", "lucide-react": "^0.510.0", + "motion": "^12.11.3", "next-themes": "^0.4.6", - "prosody-ui": "file:../../libs/prosody-ui", "react": "19.1.0", "react-dom": "19.1.0", "react-hook-form": "^7.56.3", @@ -202,6 +203,8 @@ "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.9", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.6", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.8", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.2", "@radix-ui/react-slot": "1.2.2", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ARFmqUyhIVS3+riWzwGTe7JLjqwqgnODBUZdqpWar/z1WFs9z76fuOs/2BOWCR+YboRn4/WN9aoaGVwqNRr8VA=="], + "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.9", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.2", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-way197PiTvNp+WBP7svMJasHl+vibhWGQDb6Mgf5mhEWJkgb85z7Lfl9TUdkqpWsf8GRNmoopx9ZxCyDzmgRMQ=="], @@ -218,6 +221,8 @@ "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-hQsTUIn7p7fxCPvao/q6wpbxmCwgLrlz+nOrJgC+RwfZqWY/WN+UMqkXzrtKbPrF82P43eCTl3ekeKuyAQbFeg=="], + "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="], + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.2", "", { "dependencies": { "@radix-ui/react-slot": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-uHa+l/lKfxuDD2zjN/0peM/RhhSmRjr5YWdk/37EnSv1nJ88uvG85DPexSm8HdFQROd2VdERJ6ynXbkCFi+APw=="], "@radix-ui/react-select": ["@radix-ui/react-select@2.2.4", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.6", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.9", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.6", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.6", "@radix-ui/react-portal": "1.1.8", "@radix-ui/react-primitive": "2.1.2", "@radix-ui/react-slot": "1.2.2", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/OOm58Gil4Ev5zT8LyVzqfBcij4dTHYdeyuF5lMHZ2bIp0Lk9oETocYiJ5QC0dHekEQnK6L/FNJCceeb4AkZ6Q=="], @@ -436,6 +441,8 @@ "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "bcp-47": ["bcp-47@2.1.0", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-9IIS3UPrvIa1Ej+lVDdDwO7zLehjqsaByECw0bu2RRGP73jALm6FYbzI5gWbgHLvNdkvfXB5YrSbocZdOS0c0w=="], + "bignumber.js": ["bignumber.js@9.3.0", "", {}, "sha512-EM7aMFTXbptt/wZdMlBv2t8IViwQL+h6SLHosp8Yf0dqJMTnY6iL32opnAB6kAdL0SZPuvcAzFr31o0c/R3/RA=="], "browserslist": ["browserslist@4.24.5", "", { "dependencies": { "caniuse-lite": "^1.0.30001716", "electron-to-chromium": "^1.5.149", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw=="], @@ -494,7 +501,7 @@ "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], - "electron-to-chromium": ["electron-to-chromium@1.5.153", "", {}, "sha512-4bwluTFwjXZ0/ei1qDpHDGzVveuBfx4wiZ9VQ8j/30+T2JxSF2TfZ00d1X+wNMeDyUdZXgLkJFbarJdAMtd+/w=="], + "electron-to-chromium": ["electron-to-chromium@1.5.154", "", {}, "sha512-G4VCFAyKbp1QJ+sWdXYIRYsPGvlV5sDACfCmoMFog3rjm1syLhI41WXm/swZypwCIWIm4IFLWzHY14joWMQ5Fw=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -542,6 +549,8 @@ "formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="], + "framer-motion": ["framer-motion@12.11.3", "", { "dependencies": { "motion-dom": "^12.11.2", "motion-utils": "^12.9.4", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-ksUtDFBZtrbQFt4bEMFrFgo7camhmXcLeuylKQxEYSd9czkZ4tZmFROxWczWeu51WqC2m91ifpvgGCBLd0uviQ=="], + "franc-all": ["franc-all@7.2.0", "", { "dependencies": { "trigram-utils": "^2.0.0" } }, "sha512-ZR6ciLQTDBaOvBdkOd8+vqDzaLtmIXRa9GCzcAlaBpqNAKg9QrtClPmqiKac5/xZXfCZGMo1d8dIu1T0BLhHEg=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -598,10 +607,18 @@ "infobox-parser": ["infobox-parser@3.6.4", "", { "dependencies": { "camelcase": "^4.1.0" } }, "sha512-d2lTlxKZX7WsYxk9/UPt51nkmZv5tbC75SSw4hfHqZ3LpRAn6ug0oru9xI2X+S78va3aUAze3xl/UqMuwLmJUw=="], + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], + + "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], + + "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + "iso-639-3": ["iso-639-3@3.0.1", "", {}, "sha512-SdljCYXOexv/JmbQ0tvigHN43yECoscVpe2y2hlEqy/CStXQlroPhZLj7zKLRiGqLJfw8k7B973UAMDoQczVgQ=="], + "jest-worker": ["jest-worker@27.5.1", "", { "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg=="], "jiti": ["jiti@2.4.2", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="], @@ -662,6 +679,12 @@ "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "motion": ["motion@12.11.3", "", { "dependencies": { "framer-motion": "^12.11.3", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-R9t8IYJ5hSl+Ao5rj6XGS4lJN+fXQstcpwKOcFA5aWjlwjf3IHcHr8DUjPV0My6T/5ZCQ1jqh0pmjggO4zUpEA=="], + + "motion-dom": ["motion-dom@12.11.2", "", { "dependencies": { "motion-utils": "^12.9.4" } }, "sha512-wZ396XNNTI9GOkyrr80wFSbZc1JbIHSHTbLdririSbkEgahWWKmsHzsxyxqBBvuBU/iaQWVu1YCjdpXYNfo2yQ=="], + + "motion-utils": ["motion-utils@12.9.4", "", {}, "sha512-BW3I65zeM76CMsfh3kHid9ansEJk9Qvl+K5cu4DVHKGsI52n76OJ4z2CUJUV+Mn3uEP9k1JJA3tClG0ggSrRcg=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "n-gram": ["n-gram@2.0.2", "", {}, "sha512-S24aGsn+HLBxUGVAUFOwGpKs7LBcG4RudKU//eWzt/mQ97/NMKQxDWHyHx63UNWk/OOdihgmzoETn1tf5nQDzQ=="], @@ -692,8 +715,6 @@ "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], - "prosody-ui": ["prosody-ui@file:../../libs/prosody-ui", { "dependencies": { "franc-all": "^7.2.0", "glotscript": "file:/home/y/code/bun/libs/glotscript", "sortug": "file:/home/y/code/npm/sortug" }, "devDependencies": { "@types/bun": "latest", "@types/react": "latest", "react": "latest" }, "peerDependencies": { "typescript": "^5.0.0" } }], - "protobufjs": ["protobufjs@7.5.2", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-f2ls6rpO6G153Cy+o2XQ+Y0sARLOZ17+OGVLHrc3VUKcLHYKEKWbkSujdBWQXM7gKn5NTfp0XnRPZn1MIu8n9w=="], "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], @@ -742,7 +763,7 @@ "sonner": ["sonner@2.0.3", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-njQ4Hht92m0sMqqHVDL32V2Oun9W1+PHO9NDv9FHfJjT3JT22IG4Jpo3FPQy+mouRKCXFWO+r67v6MrHX2zeIA=="], - "sortug-ai": ["models@file:../../libs/models", { "dependencies": { "@anthropic-ai/sdk": "^0.36.3", "@google/genai": "^0.13.0", "@google/generative-ai": "^0.21.0", "groq-sdk": "^0.15.0", "openai": "^4.84.0", "playht": "^0.16.0", "replicate": "^1.0.1", "sortug": "file://home/y/code/npm/sortug" }, "devDependencies": { "@types/bun": "^1.2.12" }, "peerDependencies": { "typescript": "^5.7.3" } }], + "sortug-ai": ["models@file:../../libs/models", { "dependencies": { "@anthropic-ai/sdk": "^0.36.3", "@google/genai": "^0.13.0", "@google/generative-ai": "^0.21.0", "bcp-47": "^2.1.0", "franc-all": "^7.2.0", "groq-sdk": "^0.15.0", "iso-639-3": "^3.0.1", "openai": "^4.84.0", "playht": "^0.16.0", "replicate": "^1.0.1", "sortug": "file://home/y/code/npm/sortug" }, "devDependencies": { "@types/bun": "^1.2.12" }, "peerDependencies": { "typescript": "^5.7.3" } }], "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -852,12 +873,6 @@ "openai/@types/node": ["@types/node@18.19.100", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-ojmMP8SZBKprc3qGrGk8Ujpo80AXkrP7G2tOT4VWr5jlr5DHjsJF+emXJz+Wm0glmy4Js62oKMdZZ6B9Y+tEcA=="], - "prosody-ui/@types/react": ["@types/react@19.1.4", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-EB1yiiYdvySuIITtD5lhW4yPyJ31RkJkkDw794LaQYrxCSaQV/47y5o1FMC4zF9ZyjUjzJMZwbovEnT5yHTW6g=="], - - "prosody-ui/glotscript": ["glotscript@file:../../libs/glotscript", {}], - - "prosody-ui/sortug": ["sortug@file:../../../npm/sortug", {}], - "sortug-ai/sortug": ["sortug@file:../../../npm/sortug", {}], "@anthropic-ai/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], diff --git a/package.json b/package.json index affbfc4..9fec0ca 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dependencies": { "@edge-runtime/cookies": "^6.0.0", "@hookform/resolvers": "^5.0.1", + "@radix-ui/react-dialog": "^1.1.13", "@radix-ui/react-label": "^2.1.6", "@radix-ui/react-select": "^2.2.4", "@radix-ui/react-slot": "^1.2.2", @@ -19,19 +20,19 @@ "cookie": "^1.0.2", "franc-all": "^7.2.0", "lucide-react": "^0.510.0", + "motion": "^12.11.3", "next-themes": "^0.4.6", "react": "19.1.0", "react-dom": "19.1.0", "react-hook-form": "^7.56.3", "react-server-dom-webpack": "19.1.0", "sonner": "^2.0.3", + "sortug-ai": "file:../../libs/models", "tailwind-merge": "^3.2.0", "tw-animate-css": "^1.2.9", "waku": "0.22.4", "wikipedia": "^2.1.2", - "zod": "^3.24.4", - "sortug-ai": "file:../../libs/models", - "prosody-ui": "file:../../libs/prosody-ui" + "zod": "^3.24.4" }, "devDependencies": { "@tailwindcss/postcss": "4.1.4", diff --git a/src/components/Main.tsx b/src/components/Main.tsx index 2157a91..3e6f3e7 100644 --- a/src/components/Main.tsx +++ b/src/components/Main.tsx @@ -19,6 +19,7 @@ import { CardTitle, } from "@/components/ui/card"; import { Loader2 } from "lucide-react"; // Loading spinner +import { useRouter } from "waku"; const SorlangPage: React.FC = () => { const [textValue, setTextValue] = useState<string>(""); @@ -82,24 +83,30 @@ const SorlangPage: React.FC = () => { }; }, [handlePaste]); - const handleProcessText = async () => { - setIsAnalyzing(true); - const text = textValue.trim(); - if (!text) { - alert("Text area is empty!"); - return; - } + const router = useRouter(); + async function fetchNLP(text: string, app: "spacy" | "stanza") { const opts = { method: "POST", headers: { "Content-type": "application/json" }, - body: JSON.stringify({ text, app: "spacy" }), + body: JSON.stringify({ text, app }), }; const res = await fetch("/api/nlp", opts); const j = await res.json(); console.log("j", j); if ("ok" in j) { - console.log("good"); + sessionStorage.setItem(`${app}res`, JSON.stringify(j.ok)); + } + } + + const handleProcessText = async () => { + setIsAnalyzing(true); + const text = textValue.trim(); + if (!text) { + alert("Text area is empty!"); + return; } + await Promise.all([fetchNLP(text, "spacy"), fetchNLP(text, "stanza")]); + router.push("/zoom"); setIsAnalyzing(false); }; diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx new file mode 100644 index 0000000..4c52caa --- /dev/null +++ b/src/components/Modal.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useState, ReactNode, useEffect } from "react"; + +interface ClientModalProps { + isOpen: boolean; + onClose: () => void; + children: ReactNode; // This will receive the Server Component's output + title?: string; +} + +export default function ClientModal({ + isOpen, + onClose, + children, +}: ClientModalProps) { + // Optional: Prevent body scroll when modal is open + useEffect(() => { + if (isOpen) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = "unset"; + } + return () => { + document.body.style.overflow = "unset"; + }; + }, [isOpen]); + + if (!isOpen) { + return null; + } + + return ( + <div + className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50" + onClick={onClose} // Close on overlay click + > + <div + className="p-6 bg-white rounded-lg shadow-xl w-11/12 max-w-lg" + onClick={(e) => e.stopPropagation()} // Prevent click from closing modal if clicking inside content + > + <button + onClick={onClose} + className="text-gray-500 hover:text-gray-700" + aria-label="Close modal" + > + × {/* A simple 'X' close button */} + </button> + <div> + {children} {/* Server Component content will be rendered here */} + </div> + </div> + </div> + ); +} diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..981e999 --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,133 @@ +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { XIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Dialog({ + ...props +}: React.ComponentProps<typeof DialogPrimitive.Root>) { + return <DialogPrimitive.Root data-slot="dialog" {...props} /> +} + +function DialogTrigger({ + ...props +}: React.ComponentProps<typeof DialogPrimitive.Trigger>) { + return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} /> +} + +function DialogPortal({ + ...props +}: React.ComponentProps<typeof DialogPrimitive.Portal>) { + return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} /> +} + +function DialogClose({ + ...props +}: React.ComponentProps<typeof DialogPrimitive.Close>) { + return <DialogPrimitive.Close data-slot="dialog-close" {...props} /> +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps<typeof DialogPrimitive.Overlay>) { + return ( + <DialogPrimitive.Overlay + data-slot="dialog-overlay" + className={cn( + "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", + className + )} + {...props} + /> + ) +} + +function DialogContent({ + className, + children, + ...props +}: React.ComponentProps<typeof DialogPrimitive.Content>) { + return ( + <DialogPortal data-slot="dialog-portal"> + <DialogOverlay /> + <DialogPrimitive.Content + data-slot="dialog-content" + className={cn( + "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg", + className + )} + {...props} + > + {children} + <DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"> + <XIcon /> + <span className="sr-only">Close</span> + </DialogPrimitive.Close> + </DialogPrimitive.Content> + </DialogPortal> + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="dialog-header" + className={cn("flex flex-col gap-2 text-center sm:text-left", className)} + {...props} + /> + ) +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="dialog-footer" + className={cn( + "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", + className + )} + {...props} + /> + ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps<typeof DialogPrimitive.Title>) { + return ( + <DialogPrimitive.Title + data-slot="dialog-title" + className={cn("text-lg leading-none font-semibold", className)} + {...props} + /> + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps<typeof DialogPrimitive.Description>) { + return ( + <DialogPrimitive.Description + data-slot="dialog-description" + className={cn("text-muted-foreground text-sm", className)} + {...props} + /> + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/src/components/zoom/Entry.tsx b/src/components/zoom/Entry.tsx index a60c75c..9e6eed9 100644 --- a/src/components/zoom/Entry.tsx +++ b/src/components/zoom/Entry.tsx @@ -1,14 +1,9 @@ -"use client"; -import { Zoom } from "prosody-ui"; +import * as Zoom from "@/zoom"; +import { useEffect, useState } from "react"; import { NLP } from "sortug-ai"; -type Props = { text: string; doc: NLP.Spacy.SpacyRes }; -function ZoomText(props: Props) { - return ( - <div> - <Zoom.FullText {...props} /> - </div> - ); +function ZoomText() { + return <div></div>; } export default ZoomText; diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index 3d46fd9..a710a1e 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -2,6 +2,7 @@ import Database from "bun:sqlite"; import { getDBOffset, wordFactorial } from "../utils"; import type { AddSense, AddWord, State } from "../types"; import { DEFAULT_SRS } from "../services/srs"; +import { DBWord, WordData } from "@/zoom/logic/types"; const PAGE_SIZE = 100; @@ -199,7 +200,28 @@ class DatabaseHandler { ORDER BY e.frequency DESC `; const query = this.db.query(queryString); - return query.get(spelling, lang); + const row = query.get(spelling, lang) as DBWord | null; + if (!row) return row; + const sense_array = JSON.parse(row.senses_array); + const senses = sense_array.map((s: any) => { + const senses = JSON.parse(s.senses); + const related = JSON.parse(s.related); + const forms = JSON.parse(s.forms); + return { ...s, senses, related, forms }; + }); + const expression: WordData = { + ipa: JSON.parse(row.ipa), + prosody: JSON.parse(row.prosody), + syllables: row.syllables, + frequency: row.frequency, + type: row.type, + lang: row.lang, + spelling: row.spelling, + id: row.id, + confidence: row.confidence, + senses, + }; + return expression; } fetchExpressionsByCard(cid: number) { const queryString = ` diff --git a/src/pages.gen.ts b/src/pages.gen.ts index 9e2fcfb..2cb3b4a 100644 --- a/src/pages.gen.ts +++ b/src/pages.gen.ts @@ -4,21 +4,30 @@ import type { PathsForPages, GetConfigResponse } from 'waku/router'; // prettier-ignore +import type { getConfig as Zoom_getConfig } from './pages/zoom'; +// prettier-ignore import type { getConfig as Login_getConfig } from './pages/login'; // prettier-ignore import type { getConfig as Db_getConfig } from './pages/db'; // prettier-ignore import type { getConfig as Form_getConfig } from './pages/form'; // prettier-ignore +import type { getConfig as Picker_getConfig } from './pages/picker'; +// prettier-ignore import type { getConfig as About_getConfig } from './pages/about'; // prettier-ignore import type { getConfig as Index_getConfig } from './pages/index'; // prettier-ignore type Page = +| ({ path: '/zoom' } & GetConfigResponse<typeof Zoom_getConfig>) | ({ path: '/login' } & GetConfigResponse<typeof Login_getConfig>) | ({ path: '/db' } & GetConfigResponse<typeof Db_getConfig>) +| { path: '/test/client-modal'; render: 'dynamic' } +| { path: '/test/trigger-modal-button'; render: 'dynamic' } +| { path: '/test'; render: 'dynamic' } | ({ path: '/form' } & GetConfigResponse<typeof Form_getConfig>) +| ({ path: '/picker' } & GetConfigResponse<typeof Picker_getConfig>) | ({ path: '/about' } & GetConfigResponse<typeof About_getConfig>) | ({ path: '/' } & GetConfigResponse<typeof Index_getConfig>); diff --git a/src/pages/_layout.tsx b/src/pages/_layout.tsx index 6d227c9..7f6a434 100644 --- a/src/pages/_layout.tsx +++ b/src/pages/_layout.tsx @@ -1,9 +1,9 @@ -import '../styles.css'; +import "../styles.css"; -import type { ReactNode } from 'react'; +import type { ReactNode } from "react"; -import { Header } from '../components/header'; -import { Footer } from '../components/footer'; +import { Header } from "../components/header"; +import { Footer } from "../components/footer"; type RootLayoutProps = { children: ReactNode }; @@ -14,19 +14,17 @@ export default async function RootLayout({ children }: RootLayoutProps) { <div className="font-['Nunito']"> <meta name="description" content={data.description} /> <link rel="icon" type="image/png" href={data.icon} /> - <Header /> - <main className="m-6 flex items-center *:min-h-64 *:min-w-64 lg:m-0 lg:min-h-svh lg:justify-center"> + <main className="m-6 items-center *:min-h-64 *:min-w-64 lg:m-0 lg:min-h-svh lg:justify-center"> {children} </main> - <Footer /> </div> ); } const getData = async () => { const data = { - description: 'An internet website!', - icon: '/images/favicon.png', + description: "An internet website!", + icon: "/images/favicon.png", }; return data; @@ -34,6 +32,6 @@ const getData = async () => { export const getConfig = async () => { return { - render: 'static', + render: "static", } as const; }; diff --git a/src/pages/api/proxy.ts b/src/pages/api/proxy.ts index 3114f6b..72e5fec 100644 --- a/src/pages/api/proxy.ts +++ b/src/pages/api/proxy.ts @@ -1,7 +1,7 @@ // import db from "../../lib/db"; import { z } from "zod"; -export const proxySchema = z.object({ +const proxySchema = z.object({ path: z.string().startsWith("/").optional(), url: z.string().url("Invalid urladdress"), body: z.any().optional(), diff --git a/src/pages/picker.tsx b/src/pages/picker.tsx new file mode 100644 index 0000000..9cd86f5 --- /dev/null +++ b/src/pages/picker.tsx @@ -0,0 +1,29 @@ +import { getContextData } from "waku/middleware/context"; +import App from "@/picker/App"; + +export default async function HomePage() { + const { user } = getContextData(); + + return ( + <div> + <h1 className="text-lg text-center">Interactive Language Explorer</h1> + <App />; + </div> + ); +} + +const getData = async () => { + const data = { + title: "Waku", + headline: "Waku", + body: "Hello world!", + }; + + return data; +}; + +export const getConfig = async () => { + return { + render: "dynamic", + } as const; +}; diff --git a/src/pages/test/index.tsx b/src/pages/test/index.tsx index 35ce5db..4c9325c 100644 --- a/src/pages/test/index.tsx +++ b/src/pages/test/index.tsx @@ -1,21 +1,12 @@ // This is a Server Component by default -import ProductDetailsServer from "./product-details-server"; +import ServerWord from "@/zoom/ServerWord"; import TriggerModalButton from "./trigger-modal-button"; // We'll make this a client component to manage state export default function SomePage() { const productIdForModal = "123"; // Or get this dynamically return ( - <main className="container p-8 mx-auto"> - <h1 className="mb-6 text-3xl font-bold"> - Modal with Server Component Content - </h1> - <p> - This page demonstrates opening a modal whose content is rendered by a - Server Component. The modal shell (open/close logic) is a Client - Component. - </p> - + <div> {/* The TriggerModalButton will manage the modal's open/close state. It will receive the Server Component as a child to pass to ClientModal. @@ -23,12 +14,8 @@ export default function SomePage() { <TriggerModalButton modalTitle={`Product Details for ID: ${productIdForModal}`} > - <ProductDetailsServer word={"fantastic"} /> + <ServerWord word={"fantastic"} lang={"en"} /> </TriggerModalButton> - - <div className="mt-8"> - <p>Other content on the page...</p> - </div> - </main> + </div> ); } diff --git a/src/pages/zoom.tsx b/src/pages/zoom.tsx new file mode 100644 index 0000000..d088553 --- /dev/null +++ b/src/pages/zoom.tsx @@ -0,0 +1,29 @@ +import { getContextData } from "waku/middleware/context"; +import * as Zoom from "@/zoom"; + +export default async function HomePage() { + const { user } = getContextData(); + + return ( + <div> + <h1 className="text-lg text-center">Interactive Language Explorer</h1> + <Zoom.App />; + </div> + ); +} + +const getData = async () => { + const data = { + title: "Waku", + headline: "Waku", + body: "Hello world!", + }; + + return data; +}; + +export const getConfig = async () => { + return { + render: "dynamic", + } as const; +}; diff --git a/src/picker/App.tsx b/src/picker/App.tsx new file mode 100644 index 0000000..a17a006 --- /dev/null +++ b/src/picker/App.tsx @@ -0,0 +1,396 @@ +// +"use client"; + +import React, { useState, useCallback, useMemo, useEffect } from "react"; +import { + TextSelect, + Combine, + WholeWord, + Highlighter, + Atom, + Mic2, + CheckCircle2, + ExternalLink, + Brain, + Zap, +} from "lucide-react"; +import { NLP } from "sortug-ai"; + +// --- Granularity Definition --- +const GRANULARITY_LEVELS = [ + { id: "text", name: "Text", icon: TextSelect }, + { id: "paragraph", name: "Paragraph", icon: Combine }, + { id: "sentence", name: "Sentence", icon: Highlighter }, + { id: "clause", name: "Clause (Sentence Lvl)", icon: Highlighter }, + { id: "word", name: "Word/Token", icon: WholeWord }, + { id: "syllable", name: "Syllable (Word Lvl)", icon: Mic2 }, + { id: "phoneme", name: "Phoneme (Word Lvl)", icon: Atom }, +] as const; +type GranularityId = (typeof GRANULARITY_LEVELS)[number]["id"]; +type AnalysisEngine = "spacy" | "stanza"; + +// --- Sample Data (Simplified) --- + +interface Paragraph { + id: string; + text: string; + start_char: number; + end_char: number; + sentences: NLP.Spacy.Sentence[]; +} + +const segmentByParagraphs = ( + inputText: string, + allSentences: NLP.Spacy.Sentence[], +): Paragraph[] => { + const paragraphs: Paragraph[] = []; + const paraTexts = inputText.split(/\n\n+/); + let currentDocCharOffset = 0; + let sentenceIdx = 0; + + paraTexts.forEach((paraText, idx) => { + const paraStartChar = currentDocCharOffset; + const paraEndChar = paraStartChar + paraText.length; + const paraSentences: NLP.Spacy.Sentence[] = []; + + while (sentenceIdx < allSentences.length) { + const sent = allSentences[sentenceIdx]!; + if (sent.start < paraEndChar) { + paraSentences.push(sent); + sentenceIdx++; + } else { + break; + } + } + + paragraphs.push({ + id: `para-${idx}`, + text: paraText, + start_char: paraStartChar, + end_char: paraEndChar, + sentences: paraSentences, + }); + currentDocCharOffset = + paraEndChar + + (inputText.substring(paraEndChar).match(/^\n\n+/)?.[0].length || 0); + }); + return paragraphs; +}; + +// --- Granularity Menu --- +interface GranularityMenuProps { + selectedGranularity: GranularityId; + onSelectGranularity: (granularity: GranularityId) => void; +} +const GranularityMenu: React.FC<GranularityMenuProps> = ({ + selectedGranularity, + onSelectGranularity, +}) => ( + <nav className="w-full bg-slate-800 text-slate-100 p-4 space-y-2 rounded-lg shadow-lg"> + <h2 className="text-lg font-semibold text-sky-400 mb-4"> + Granularity Level + </h2> + {GRANULARITY_LEVELS.map((level) => { + const Icon = level.icon; + const isSelected = selectedGranularity === level.id; + return ( + <button + key={level.id} + onClick={() => onSelectGranularity(level.id)} + className={`w-full flex items-center space-x-3 p-3 rounded-md text-left transition-all duration-150 ease-in-out + ${isSelected ? "bg-sky-500 text-white shadow-md scale-105" : "hover:bg-slate-700 hover:text-sky-300"}`} + > + <Icon + size={20} + className={`${isSelected ? "text-white" : "text-sky-400"}`} + /> + <span>{level.name}</span> + {isSelected && ( + <CheckCircle2 size={18} className="ml-auto text-white" /> + )} + </button> + ); + })} + </nav> +); + +// --- Text Viewer --- +interface TextViewerProps { + nlpData: NLP.Spacy.SpacyRes; + engine: AnalysisEngine; + granularity: GranularityId; + onElementSelect: ( + elementType: GranularityId, + elementData: any, + fullText: string, + ) => void; +} + +const TextViewer: React.FC<TextViewerProps> = ({ + nlpData, + engine, + granularity, + onElementSelect, +}) => { + const paragraphs = useMemo( + () => segmentByParagraphs(nlpData.input, nlpData.segments), + [nlpData], + ); + + const getElementText = (element: any, fullInput: string): string => { + if (element.text) return element.text; // Already has text + if ("start_char" in element && "end_char" in element) { + // Stanza word/token/sentence/entity + return fullInput.substring(element.start_char, element.end_char); + } + if ("start" in element && "end" in element) { + // spaCy token/sentence/entity + return fullInput.substring(element.start, element.end); + } + return "N/A"; + }; + + const renderInteractiveSpan = ( + key: string | number, + text: string, + data: any, + type: GranularityId, + baseClasses: string = "", + hoverClasses: string = "hover:bg-yellow-200", + ) => ( + <span + key={key} + className={`cursor-pointer transition-colors duration-150 ${baseClasses} ${hoverClasses} p-0.5 rounded`} + onClick={(e) => { + e.stopPropagation(); // Prevent clicks bubbling to parent elements + onElementSelect(type, data, getElementText(data, nlpData.input)); + }} + > + {text} + </span> + ); + + return ( + <div className="text-lg text-gray-800 leading-relaxed bg-white p-4 sm:p-6 rounded-xl shadow-inner"> + {granularity === "text" + ? renderInteractiveSpan( + "full-text", + nlpData.input, + nlpData, + "text", + "block", + "hover:bg-sky-100", + ) + : paragraphs.map((para) => ( + <div + key={para.id} + className={`mb-4 ${granularity === "paragraph" ? "p-2 rounded-md shadow-sm bg-gray-50" : ""}`} + onClick={ + granularity === "paragraph" + ? (e) => { + e.stopPropagation(); + onElementSelect("paragraph", para, para.text); + } + : undefined + } + style={granularity === "paragraph" ? { cursor: "pointer" } : {}} + > + {para.sentences.map((sent, sentIdx) => { + const sentenceText = getElementText(sent, nlpData.input); + const sentenceKey = `sent-${para.id}-${sentIdx}`; + + if (granularity === "sentence" || granularity === "clause") { + return renderInteractiveSpan( + sentenceKey, + sentenceText, + sent, + granularity, + "mr-1 inline-block bg-gray-100 shadow-xs", + "hover:bg-sky-200", + ); + } else if ( + granularity === "word" || + granularity === "syllable" || + granularity === "phoneme" + ) { + let currentWordRenderIndex = 0; // to add spaces correctly + return ( + <span key={sentenceKey} className="mr-1"> + {" "} + {/* Sentence wrapper */} + {sent.words.map((word, idx) => { + const wordText = getElementText(word, nlpData.input); + const wordKey = `${sentenceKey}-tok-${idx}-word-${word}`; + const space = currentWordRenderIndex > 0 ? " " : ""; + currentWordRenderIndex++; + return ( + <React.Fragment key={wordKey}> + {space} + {renderInteractiveSpan( + wordKey, + wordText, + word, + granularity, + "bg-gray-50", + "hover:bg-yellow-300", + )} + </React.Fragment> + ); + })} + </span> + ); + } + // Fallback for paragraph view if no other granularity matches (should not happen if logic is correct) + return ( + <span key={sentenceKey} className="mr-1"> + {sentenceText} + </span> + ); + })} + </div> + ))} + </div> + ); +}; + +// --- Main Application Component --- +export default function NlpTextAnalysisScreen() { + const [selectedGranularity, setSelectedGranularity] = + useState<GranularityId>("word"); + const [currentEngine, setCurrentEngine] = useState<AnalysisEngine>("stanza"); + const [selectedElementInfo, setSelectedElementInfo] = useState<string | null>( + null, + ); + const [activeNlpData, setData] = useState<NLP.Spacy.SpacyRes>(); + useEffect(() => { + // const nlpdata = sessionStorage.getItem( + // currentEngine === "spacy" ? "spacyres" : "stanzares", + // ); + // const activeNlpData = JSON.parse(nlpdata!); + }, []); + + const handleGranularityChange = useCallback((granularity: GranularityId) => { + setSelectedGranularity(granularity); + setSelectedElementInfo(null); + }, []); + + const handleElementSelect = useCallback( + (elementType: GranularityId, elementData: any, elementText: string) => { + let info = `Selected: ${elementType.toUpperCase()} (${currentEngine})\n`; + info += `Text: "${elementText}"\n`; + + if (elementType === "syllable" || elementType === "phoneme") { + info += `(Granularity: ${elementType}, showing parent Word/Token details)\n`; + } + + // Add specific details based on element type and engine + if (elementType === "word") { + if (currentEngine === "stanza" && elementData.lemma) { + // StanzaWord + info += `Lemma: ${elementData.lemma}\nUPOS: ${elementData.upos}\nXPOS: ${elementData.xpos}\nDepRel: ${elementData.deprel} (Head ID: ${elementData.head})\n`; + if (elementData.parentToken?.ner) + info += `NER (Token): ${elementData.parentToken.ner}\n`; + } else if (currentEngine === "spacy" && elementData.lemma_) { + // SpacyToken + info += `Lemma: ${elementData.lemma_}\nPOS: ${elementData.pos_}\nTag: ${elementData.tag_}\nDep: ${elementData.dep_} (Head ID: ${elementData.head?.i})\n`; + if (elementData.ent_type_) + info += `Entity: ${elementData.ent_type_} (${elementData.ent_iob_})\n`; + } + } else if (elementType === "sentence") { + if ( + currentEngine === "stanza" && + (elementData as NLP.Stanza.Sentence).sentiment + ) { + info += `Sentiment: ${(elementData as NLP.Stanza.Sentence).sentiment}\n`; + } + if ( + (elementData as NLP.Stanza.Sentence | NLP.Spacy.Sentence).entities + ?.length + ) { + info += `Entities in sentence: ${(elementData.entities as any[]).map((e) => `${e.text} (${e.type || e.label_})`).join(", ")}\n`; + } + } else if (elementType === "paragraph") { + info += `Char range: ${elementData.start_char}-${elementData.end_char}\n`; + info += `Sentence count: ${elementData.sentences.length}\n`; + } + + info += `Raw Data Keys: ${Object.keys(elementData).slice(0, 5).join(", ")}...`; // Show some keys + setSelectedElementInfo(info); + console.log( + "Selected Element:", + elementType, + elementData, + "Text:", + elementText, + ); + }, + [currentEngine], + ); + + const toggleEngine = () => { + setCurrentEngine((prev) => (prev === "spacy" ? "stanza" : "spacy")); + setSelectedElementInfo(null); + }; + + return ( + <div className="min-h-screen bg-gradient-to-br from-slate-100 to-sky-100 p-4 sm:p-8 font-sans"> + <header className="mb-6 text-center"> + <h1 className="text-3xl sm:text-4xl font-bold text-slate-800"> + NLP Text Analyzer + </h1> + <button + onClick={toggleEngine} + className="mt-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg shadow-md transition-colors flex items-center mx-auto" + > + {currentEngine === "spacy" ? ( + <Zap size={18} className="mr-2" /> + ) : ( + <Brain size={18} className="mr-2" /> + )} + Switch to {currentEngine === "spacy" ? "Stanza" : "spaCy"} View + </button> + <p className="text-sm text-slate-600 mt-1"> + Currently viewing with:{" "} + <span className="font-semibold">{currentEngine.toUpperCase()}</span> + </p> + </header> + + <div className="flex flex-col lg:flex-row gap-6 max-w-7xl mx-auto"> + <aside className="lg:w-72 lg:sticky lg:top-8 h-full flex flex-col gap-6"> + <GranularityMenu + selectedGranularity={selectedGranularity} + onSelectGranularity={handleGranularityChange} + /> + {selectedElementInfo && ( + <div className="p-4 bg-white rounded-lg shadow-md text-xs text-slate-700 overflow-auto max-h-96"> + <h3 className="font-semibold text-sky-600 mb-2 text-sm"> + Selection Details: + </h3> + <pre className="whitespace-pre-wrap break-all"> + {selectedElementInfo} + </pre> + </div> + )} + </aside> + + <main className="flex-1 min-w-0"> + {" "} + {/* min-w-0 for flex child to prevent overflow */} + <TextViewer + nlpData={activeNlpData} + engine={currentEngine} + granularity={selectedGranularity} + onElementSelect={handleElementSelect} + /> + </main> + </div> + + <footer className="text-center mt-12 text-sm text-slate-500"> + <p> + © {new Date().getFullYear()} NLP Analysis Tool. Powered by React, + TailwindCSS, and your NLP engine of choice! + </p> + </footer> + </div> + ); +} diff --git a/src/picker/Old.tsx b/src/picker/Old.tsx new file mode 100644 index 0000000..f02e538 --- /dev/null +++ b/src/picker/Old.tsx @@ -0,0 +1,252 @@ +import React, { useState, useMemo, useCallback } from "react"; +import { + TextSelect, + Combine, + WholeWord, + Highlighter, + Atom, + Mic2, + ChevronRight, + CheckCircle2, +} from "lucide-react"; + +// Define granularity levels +const GRANULARITY_LEVELS = [ + { id: "text", name: "Text", icon: TextSelect }, + { id: "paragraph", name: "Paragraph", icon: Combine }, + { id: "sentence", name: "Sentence", icon: Highlighter }, + { id: "clause", name: "Clause", icon: Highlighter }, // Simplified + { id: "word", name: "Word", icon: WholeWord }, + { id: "syllable", name: "Syllable", icon: Mic2 }, // Conceptual + { id: "phoneme", name: "Phoneme", icon: Atom }, // Conceptual +] as const; + +type GranularityId = (typeof GRANULARITY_LEVELS)[number]["id"]; + +// Granularity Menu Component +interface GranularityMenuProps { + selectedGranularity: GranularityId; + onSelectGranularity: (granularity: GranularityId) => void; +} + +const GranularityMenu: React.FC<GranularityMenuProps> = ({ + selectedGranularity, + onSelectGranularity, +}) => { + return ( + <nav className="w-64 bg-slate-800 text-slate-100 p-4 space-y-2 rounded-lg shadow-lg"> + <h2 className="text-lg font-semibold text-sky-400 mb-4"> + Granularity Level + </h2> + {GRANULARITY_LEVELS.map((level) => { + const Icon = level.icon; + const isSelected = selectedGranularity === level.id; + return ( + <button + key={level.id} + onClick={() => onSelectGranularity(level.id)} + className={`w-full flex items-center space-x-3 p-3 rounded-md text-left transition-all duration-150 ease-in-out + ${ + isSelected + ? "bg-sky-500 text-white shadow-md scale-105" + : "hover:bg-slate-700 hover:text-sky-300" + }`} + > + <Icon + size={20} + className={`${isSelected ? "text-white" : "text-sky-400"}`} + /> + <span>{level.name}</span> + {isSelected && ( + <CheckCircle2 size={18} className="ml-auto text-white" /> + )} + </button> + ); + })} + </nav> + ); +}; + +// Text Viewer Component +interface TextViewerProps { + document: TextDocument; + granularity: GranularityId; + onElementSelect: (elementType: GranularityId, element: any) => void; // element type can be more specific +} + +const TextViewer: React.FC<TextViewerProps> = ({ + document, + granularity, + onElementSelect, +}) => { + const handleElementClick = (type: GranularityId, data: any) => { + // For syllable/phoneme, pass the parent word data for now + if ((type === "syllable" || type === "phoneme") && data.type === "word") { + onElementSelect(type, { ...data, originalClickType: type }); + } else { + onElementSelect(type, data); + } + }; + + const renderContent = () => { + if (granularity === "text") { + return ( + <div + className="p-4 rounded-md bg-white shadow hover:bg-sky-50 cursor-pointer transition-colors" + onClick={() => handleElementClick("text", document)} + > + {document.paragraphs.map((p) => ( + <p key={p.id} className="mb-4 leading-relaxed"> + {p.text} + </p> + ))} + </div> + ); + } + + return document.paragraphs.map((paragraph) => ( + <div + key={paragraph.id} + className={`p-3 mb-4 rounded-md transition-all duration-150 + ${granularity === "paragraph" ? "bg-white shadow hover:bg-sky-100 cursor-pointer" : "bg-transparent"}`} + onClick={ + granularity === "paragraph" + ? () => handleElementClick("paragraph", paragraph) + : undefined + } + > + {paragraph.sentences.map((sentence) => ( + <span // Sentences are inline for paragraph flow, but can be styled as blocks if needed + key={sentence.id} + className={`mr-1 transition-all duration-150 + ${granularity === "sentence" || granularity === "clause" ? "p-1 hover:bg-sky-200 bg-white shadow-sm rounded cursor-pointer" : ""} + ${granularity === "syllable" || granularity === "phoneme" ? "" : ""} + `} + onClick={ + granularity === "sentence" || granularity === "clause" + ? () => handleElementClick(granularity, sentence) + : undefined + } + > + {granularity === "word" || + granularity === "syllable" || + granularity === "phoneme" + ? sentence.words + .map((word, wordIndex) => ( + <span + key={word.id} + className="p-0.5 hover:bg-yellow-200 bg-white rounded cursor-pointer transition-colors" + onClick={() => handleElementClick(granularity, word)} // Syllable/Phoneme click conceptually targets word + > + {word.text} + </span> + )) + .reduce( + (prev, curr, idx) => ( + <> + {prev} + {idx > 0 && " "} + {curr} + </> + ), + <></>, + ) // Add spaces between words + : sentence.text} + </span> + ))} + </div> + )); + }; + + return ( + <div className="text-lg text-gray-800 leading-relaxed"> + {renderContent()} + </div> + ); +}; + +// Main Application Component +export default function TextAnalysisScreen() { + const [selectedGranularity, setSelectedGranularity] = + useState<GranularityId>("word"); + const [currentDocument, setCurrentDocument] = + useState<TextDocument>(sampleTextDocument); + const [selectedElementInfo, setSelectedElementInfo] = useState<string | null>( + null, + ); + + const handleGranularityChange = useCallback((granularity: GranularityId) => { + setSelectedGranularity(granularity); + setSelectedElementInfo(null); // Clear selection when granularity changes + }, []); + + const handleElementSelect = useCallback( + (elementType: GranularityId, elementData: any) => { + let info = `Selected: ${elementType.toUpperCase()}\n`; + if (elementData.text) { + info += `Text: "${elementData.text.substring(0, 100)}${elementData.text.length > 100 ? "..." : ""}"\n`; + } + info += `ID: ${elementData.id}`; + if ( + elementData.originalClickType && + elementData.originalClickType !== elementType + ) { + info += `\n(Clicked as ${elementData.originalClickType}, showing parent Word)`; + } + setSelectedElementInfo(info); + // Here you would typically navigate to a detail view or open a modal + // For example: router.push(`/details/${elementType}/${elementData.id}`); + console.log("Selected Element:", elementType, elementData); + }, + [], + ); + + return ( + <div className="min-h-screen bg-gradient-to-br from-slate-100 to-sky-100 p-4 sm:p-8 font-sans"> + <header className="mb-8 text-center"> + <h1 className="text-3xl sm:text-4xl font-bold text-slate-800"> + Text Analyzer + </h1> + </header> + + <div className="flex flex-col lg:flex-row gap-6 max-w-7xl mx-auto"> + {/* Sticky container for the menu */} + <div className="lg:w-72 lg:sticky lg:top-8 h-full"> + {" "} + {/* Ensure menu is sticky on larger screens */} + <GranularityMenu + selectedGranularity={selectedGranularity} + onSelectGranularity={handleGranularityChange} + /> + {selectedElementInfo && ( + <div className="mt-6 p-4 bg-white rounded-lg shadow-md text-sm text-slate-700"> + <h3 className="font-semibold text-sky-600 mb-2"> + Selection Details: + </h3> + <pre className="whitespace-pre-wrap break-all"> + {selectedElementInfo} + </pre> + </div> + )} + </div> + + <main className="flex-1 bg-slate-50 p-4 sm:p-6 rounded-xl shadow-xl min-w-0"> + {" "} + {/* min-w-0 for flex child */} + <TextViewer + document={currentDocument} + granularity={selectedGranularity} + onElementSelect={handleElementSelect} + /> + </main> + </div> + + <footer className="text-center mt-12 text-sm text-slate-500"> + <p> + © {new Date().getFullYear()} Advanced Text Analysis Tool. All + rights reserved. + </p> + </footer> + </div> + ); +} diff --git a/src/picker/index.ts b/src/picker/index.ts new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/picker/index.ts diff --git a/src/zoom/App.tsx b/src/zoom/App.tsx new file mode 100644 index 0000000..d41dd7f --- /dev/null +++ b/src/zoom/App.tsx @@ -0,0 +1,98 @@ +"use client"; +import React, { useEffect, useState } from "react"; +import { motion } from "framer-motion"; +import FullText from "./FullText"; +import "./zoom.css"; +import { useZoom, ZoomProvider } from "./hooks/useZoom"; +import { NLP } from "sortug-ai"; +import { Loader2 } from "lucide-react"; + +const App: React.FC = () => { + const { viewState } = useZoom(); + const [isLoading, setLoading] = useState(true); + const [spacy, setSpacy] = useState<NLP.Spacy.SpacyRes>(); + const [stanza, setStanza] = useState<NLP.Stanza.StanzaRes>(); + useEffect(() => { + const sp = sessionStorage.getItem("spacyres"); + const st = sessionStorage.getItem("stanzares"); + if (sp) setSpacy(JSON.parse(sp)); + if (st) setStanza(JSON.parse(st)); + setLoading(false); + }, []); + console.log(viewState.level, "level"); + return ( + <> + <motion.header + className="app-header" + initial={{ opacity: 0, y: -20 }} + animate={{ opacity: 1, y: 0 }} + transition={{ delay: 0.2, duration: 0.5 }} + > + <p className="instructions"> + Click on any text element to zoom in, exploring language from: + <br /> + <span> + <span className={viewState.level === "text" ? "highlight" : ""}> + Text + </span> + <span>→</span> + <span + className={viewState.level === "paragraph" ? "highlight" : ""} + > + Paragraph + </span> + <span>→</span> + <span className={viewState.level === "sentence" ? "highlight" : ""}> + Sentence + </span> + <span>→</span> + <span className={viewState.level === "clause" ? "highlight" : ""}> + Clause + </span> + <span>→</span> + <span className={viewState.level === "word" ? "highlight" : ""}> + Word + </span> + <span>→</span> + <span className={viewState.level === "syllable" ? "highlight" : ""}> + Syllable + </span> + <span>→</span> + <span className={viewState.level === "phoneme" ? "highlight" : ""}> + Phoneme + </span> + </span> + <br /> + Click the Back button or on empty space to return to the previous + level. + </p> + </motion.header> + + <main> + {isLoading ? ( + <Loader2 /> + ) : spacy ? ( + <FullText text={spacy.input} doc={spacy} stanza={stanza} /> + ) : ( + <p>Oops</p> + )} + </main> + + <motion.footer + className="app-footer" + initial={{ opacity: 0 }} + animate={{ opacity: 0.7 }} + transition={{ delay: 1.5 }} + > + <p>A language learning tool with seamless zoom animations</p> + </motion.footer> + </> + ); +}; +export default function () { + return ( + <ZoomProvider> + <App /> + </ZoomProvider> + ); +} diff --git a/src/zoom/FullText.tsx b/src/zoom/FullText.tsx new file mode 100644 index 0000000..615fe66 --- /dev/null +++ b/src/zoom/FullText.tsx @@ -0,0 +1,62 @@ +import React from "react"; +import { motion, AnimatePresence } from "motion/react"; +import Paragraph from "./Paragraph"; +import { useZoom } from "./hooks/useZoom"; +import { containerVariants, buttonVariants } from "./animations"; +import { NLP } from "sortug-ai"; + +interface TextFocusMorphProps { + text: string; + doc: NLP.Spacy.SpacyRes; + stanza?: NLP.Stanza.StanzaRes | undefined; +} + +const FullText: React.FC<TextFocusMorphProps> = ({ text, doc, stanza }) => { + const { viewState, navigateBack, handleElementClick } = useZoom(); + const { level } = viewState; + + // Split text into paragraphs + const paragraphs = text + .split("\n\n") + .map((p) => p.trim()) + .filter(Boolean); + + return ( + <div className="text-focus-morph"> + {level !== "text" && ( + <AnimatePresence> + <motion.button + className="back-button" + onClick={navigateBack} + variants={buttonVariants} + initial="initial" + animate="animate" + exit="exit" + > + ← Back + </motion.button> + </AnimatePresence> + )} + + <motion.div + className="content-container" + variants={containerVariants} + initial="text" + animate={level} + > + {paragraphs.map((paragraph, idx) => ( + <Paragraph + doc={doc} + stanza={stanza} + key={paragraph + idx} + rawText={paragraph} + context={{ idx, parentText: text, segmented: paragraphs }} + idx={idx} + /> + ))} + </motion.div> + </div> + ); +}; + +export default FullText; diff --git a/src/zoom/Paragraph.tsx b/src/zoom/Paragraph.tsx new file mode 100644 index 0000000..285aab4 --- /dev/null +++ b/src/zoom/Paragraph.tsx @@ -0,0 +1,74 @@ +import React, { memo, useCallback, useEffect, useState } from "react"; +import { motion } from "motion/react"; +import type { ViewProps, LoadingStatus } from "./logic/types"; +import { NLP } from "sortug-ai"; +import Sentence from "./Sentence"; +import { paragraphVariants, createHoverEffect } from "./animations"; +import { useZoom } from "./hooks/useZoom"; + +function Paragraph({ rawText, context, idx, doc, stanza }: ViewProps) { + const filterSegs = useCallback(() => {}, [rawText, doc.segments]); + useEffect(() => { + const relevantSegs = doc.segments.filter((seg) => + rawText.includes(seg.text), + ); + setSegs(relevantSegs); + }, [filterSegs]); + console.log(rawText); + console.log(doc.segments.length); + const [segs, setSegs] = useState<NLP.Spacy.Sentence[]>([]); + + console.log(segs.length); + const { viewState, handleElementClick } = useZoom(); + const { level, pIndex } = viewState; + const selected = pIndex === idx; + const isFocused = level === "paragraph" && selected; + + // State for sentences + const [loading, setLoading] = useState<LoadingStatus>("pending"); + + return ( + <> + <motion.div + key={idx + rawText} + className={`paragraph-wrapper ${selected ? "selected" : ""}`} + custom={selected} + variants={paragraphVariants} + initial="text" + animate={level} + onClick={(e) => handleElementClick(e, idx)} + whileHover={ + level === "text" + ? createHoverEffect(level, "text", "255, 255, 200") + : {} + } + > + {loading === "loading" && <div className="spinner" />} + {level === "text" || !selected || segs.length === 0 ? ( + <p className="paragraph">{rawText}</p> + ) : ( + <div className="sentences-container"> + {segs.map((sentence, sentIdx) => ( + <Sentence + key={sentence.text + sentIdx} + idx={sentIdx} + rawText={sentence.text} + spacy={sentence} + context={{ + idx: sentIdx, + parentText: rawText, + segmented: segs.map((s) => s.text), + }} + doc={doc} + stanza={stanza} + stanzas={stanza?.segments[sentIdx]} + /> + ))} + </div> + )} + </motion.div> + </> + ); +} + +export default memo(Paragraph); diff --git a/src/zoom/Sentence.tsx b/src/zoom/Sentence.tsx new file mode 100644 index 0000000..35e00d6 --- /dev/null +++ b/src/zoom/Sentence.tsx @@ -0,0 +1,55 @@ +import React, { memo } from "react"; +import { motion } from "motion/react"; +import type { ViewProps, LoadingStatus } from "./logic/types"; +import { NLP } from "sortug-ai"; +import SpacyClause from "./SpacyClause"; +import { sentenceVariants, createHoverEffect } from "./animations"; +import { useZoom } from "./hooks/useZoom"; +import StanzaClause from "./StanzaClause"; + +interface Props extends ViewProps { + spacy: NLP.Spacy.Sentence; + stanzas?: NLP.Stanza.Sentence | undefined; +} + +function Sentence(props: Props) { + const { spacy, stanzas, context, idx } = props; + const { viewState, handleElementClick } = useZoom(); + const { level, sIndex } = viewState; + const selected = sIndex === idx; + const isFocused = level === "sentence" && selected; + + return ( + <> + <motion.span + key={idx + spacy.text} + className={`sentence-wrapper ${selected ? "selected" : ""}`} + custom={selected} + variants={sentenceVariants} + initial="paragraph" + animate={level} + onClick={(e) => handleElementClick(e, idx)} + whileHover={ + level === "paragraph" + ? createHoverEffect(level, "paragraph", "200, 220, 255") + : {} + } + > + {level === "paragraph" || !selected ? ( + <span className="sentence">{spacy.text}</span> + ) : stanzas ? ( + <StanzaClause + {...props} + sentence={stanzas} + rawText={stanzas.text} + data={stanzas.constituency} + /> + ) : ( + <SpacyClause sentence={spacy} /> + )} + </motion.span> + </> + ); +} + +export default memo(Sentence); diff --git a/src/pages/test/product-details-server.tsx b/src/zoom/ServerWord.tsx index 552ff21..26902f5 100644 --- a/src/pages/test/product-details-server.tsx +++ b/src/zoom/ServerWord.tsx @@ -43,7 +43,7 @@ export default async function Wordd({ if (!data) return <p>oh...</p>; return ( - <Card> + <Card className="overflow-y-scroll max-h-[80vh]"> <CardHeader> <CardTitle> <h1 className="text-xl">{word}</h1> @@ -161,9 +161,9 @@ const RelatedTermsDisplay = ({ > {term.word} </a> - {term.source && ( + {/*term.source && ( <span className="text-xxs text-gray-400"> ({term.source})</span> - )} + )*/} {idx < terms.length - 1 && ", "} </React.Fragment> ))} @@ -269,7 +269,7 @@ const SenseCard = ({ <div className="mb-6 p-4 border border-gray-200 rounded-lg shadow-sm bg-white"> <div className="flex justify-between items-center mb-2"> <h3 className="text-xl font-semibold text-indigo-700"> - {senseNumber}. {senseData.pos} + {senseNumber}. {NLP.unpackPos(senseData.pos)} </h3> </div> diff --git a/src/zoom/SpacyClause.tsx b/src/zoom/SpacyClause.tsx new file mode 100644 index 0000000..6b6f178 --- /dev/null +++ b/src/zoom/SpacyClause.tsx @@ -0,0 +1,125 @@ +import React, { memo, useState } from "react"; +import { motion } from "motion/react"; +import "./spacy.css"; +import { NLP } from "sortug-ai"; +// import { clauseVariants, createHoverEffect } from "./animations"; +// import { useZoom } from "./hooks/useZoom"; + +function Grammar({ sentence }: { sentence: NLP.Spacy.Sentence }) { + const [hoveredClause, setHoveredClause] = useState<number | null>(null); + + // Ref to manage the timeout for debouncing mouse leave + return ( + <div className="clause-container"> + {sentence.words.map((w, idx) => { + const isRoot = + w.ancestors.length === 0 || w.dep.toLowerCase() === "root"; + const isSubj = NLP.Spacy.isChild(w, sentence.subj.id); + const isPred = !isSubj && !isRoot; + const predClass = isPred ? "pred" : ""; + const relClass = isRoot ? "root" : `rel-${w.dep}`; + const ownClass = isRoot + ? "" + : isSubj + ? "subj" + : w.children.length === 0 + ? "" + : `clause-${w.id}`; + const clase = w.ancestors.reduce((acc, item) => { + if (item === sentence.subj.id || item === sentence.root.id) + return acc; + else return `${acc} clause-${item}`; + }, ``); + const className = `suword ${relClass} ${ownClass} ${clase} ${predClass}`; + const isHovering = + !isRoot && + !!hoveredClause && + (w.id === hoveredClause || w.ancestors.includes(hoveredClause)); + function handleClick(w: NLP.Spacy.Word) { + console.log("show the whole clause and all that", w); + } + return ( + <ClauseSpan + word={w} + key={w.id} + className={className} + hovering={isHovering} + setHovering={setHoveredClause} + onClick={handleClick} + /> + ); + })} + </div> + ); +} + +const spanVariants: any = { + initial: { + // Base style + backgroundColor: "rgba(0, 0, 0, 0)", // Transparent background initially + fontWeight: "normal", + scale: 1, + zIndex: 0, // Default stacking + position: "relative", // Needed for zIndex to work reliably + // Add other base styles if needed + }, + hovered: { + // Style when this span's group is hovered + backgroundColor: "rgba(255, 255, 0, 0.5)", // Yellow highlight + scale: 1.05, + zIndex: 1, // Bring hovered spans slightly forward + boxShadow: "0px 2px 5px rgba(0,0,0,0.2)", + // Add other hover effects + }, +}; + +// Define the transition +const spanTransition = { + type: "spring", + stiffness: 500, + damping: 30, + // duration: 0.1 // Or use duration for non-spring types +}; + +function ClauseSpan({ + word, + className, + hovering, + setHovering, + onClick, +}: { + word: NLP.Spacy.Word; + className: string; + hovering: boolean; + setHovering: (n: number | null) => void; + onClick: (w: NLP.Spacy.Word) => void; +}) { + function handleMouseOver() { + setHovering(word.id); + // if (word.children.length > 0) setHovering(word.id); + // else setHovering(word.head); + } + function handleMouseLeave() { + setHovering(null); + } + function handleClick(e: React.MouseEvent) { + e.stopPropagation(); + onClick(word); + } + return ( + <motion.span + className={className} + variants={spanVariants} + initial="initial" + animate={hovering ? "hovered" : "initial"} + transition={spanTransition} + onMouseOver={handleMouseOver} + onMouseLeave={handleMouseLeave} + onClick={handleClick} + > + {word.text} + </motion.span> + ); +} + +export default memo(Grammar); diff --git a/src/zoom/StanzaClause.tsx b/src/zoom/StanzaClause.tsx new file mode 100644 index 0000000..ce645fc --- /dev/null +++ b/src/zoom/StanzaClause.tsx @@ -0,0 +1,279 @@ +import React, { memo } from "react"; +import { motion } from "motion/react"; +import "./parsing.css"; +import type { ViewProps } from "./logic/types"; +import Word from "./Word"; +import { clauseVariants, createHoverEffect } from "./animations"; +import { useZoom } from "./hooks/useZoom"; +import { NLP } from "sortug-ai"; + +// Function to check if a string is a punctuation character + +const isLeaf = (node: NLP.Stanza.TreeNode): boolean => { + return node.children && node.children.length === 0; +}; +const toIgnore = ["root", "s"]; +// Component to render each node in the constituency tree +const TreeNode = ({ + node, + nest = 0, + idx, +}: { + idx: number; + nest?: number; + node: NLP.Stanza.TreeNode; +}) => { + const neatChildren = node.children.reduce( + (acc: NLP.Stanza.TreeNode[], item) => { + if (NLP.isPunctuation(item.label)) return acc; + else return [...acc, item]; + }, + [], + ); + + return !isLeaf(node) ? ( + <BranchNode + node={{ ...node, children: neatChildren }} + nest={nest} + idx={idx} + /> + ) : ( + <LeafNode text={node.label} /> + ); +}; + +const BranchNode = ({ + node, + nest = 0, + idx, +}: { + idx: number; + nest?: number; + node: NLP.Stanza.TreeNode; +}) => { + const { viewState, handleElementClick } = useZoom(); + const { level, cIndex } = viewState; + const selected = cIndex === idx; + const isFocused = level === "clause" && selected; + const color = `rgba(${SYNTAX_COLORS[node.label]})` || "100, 100, 100"; + const style: any = { "--clause-underline-color": color }; + const out = toIgnore.includes(node.label.toLowerCase()); + const isPunct = NLP.isPunctuation(node.label); + if (isPunct) return null; + const clauseLabel = out ? null : NLP.Stanza.oneDescendant(node) ? ( + <div className="word-pos">{node.label}</div> + ) : ( + <div className="clause-pos">{node.label}</div> + ); + return ( + <motion.div + style={style} + className={`clause${selected ? " selected" : ""}`} + custom={selected} + variants={clauseVariants} + initial="sentence" + animate={level} + onClick={(e) => handleElementClick(e, idx)} + whileHover={createHoverEffect( + level, + "sentence", + SYNTAX_COLORS[node.label], + )} + > + <div className="clause-inner"> + <div style={{ display: "flex" }}> + {node.children.map((child, childIdx) => ( + <TreeNode + key={childIdx} + node={child} + nest={nest + 1} + idx={childIdx + idx + 1} // Ensure unique index + /> + ))} + </div> + {clauseLabel} + </div> + </motion.div> + ); +}; +function LeafNode({ text }: { text: string }) { + return <motion.div className="tree-leaf">{text}</motion.div>; +} + +// Main Clause component +interface Props extends ViewProps { + sentence: NLP.Stanza.Sentence; + data: NLP.Stanza.TreeNode; +} + +function SimpleClause(props: Props) { + const { sentence, data, context, rawText, idx } = props; + const { viewState, handleElementClick } = useZoom(); + const { level, cIndex } = viewState; + const selected = cIndex === idx; + const isFocused = level === "clause" && selected; + console.log({ viewState, rawText, f: isFocused, idx, cIndex }); + + const words = extractWordsFromTree(sentence, data); + const segmented = words.map((w) => w.text); + console.log({ words }); + + return ( + <motion.div + className={`clause-container ${selected ? "selected" : ""}`} + custom={selected} + variants={clauseVariants} + initial="sentence" + animate={level} + onClick={(e) => handleElementClick(e, idx)} + whileHover={createHoverEffect(level, "sentence", "220, 200, 255")} + > + {level === "sentence" ? ( + <span className="clause">{rawText}</span> + ) : ( + <div className="words-container"> + {words.map((word, wordIdx) => ( + <Word + {...props} + key={word.text + wordIdx} + idx={wordIdx} + rawText={word.text} + word={word} + context={{ + idx: wordIdx, + parentText: rawText, + segmented, + }} + /> + ))} + </div> + )} + </motion.div> + ); +} +function RecursiveClause(props: Props) { + const { sentence, data, context, rawText, idx } = props; + const { viewState, handleElementClick } = useZoom(); + const { level, cIndex } = viewState; + const selected = cIndex === idx; + const isFocused = level === "clause" && selected; + + // If we're at the word level, display words instead of the constituency tree + if (level === "word" && selected && data.children) { + // Extract word objects if available + const words = extractWordsFromTree(sentence, data); + if (words.length > 0) { + return ( + <div className="words-container"> + {words.map((word, wordIdx) => ( + <Word + {...props} + key={word.text + wordIdx} + idx={wordIdx} + rawText={word.text} + word={word} + context={{ + idx: wordIdx, + parentText: rawText, + segmented: words.map((w) => w.text), + }} + /> + ))} + </div> + ); + } + } + + return ( + <> + <TreeNode node={data} idx={idx} /> + </> + ); +} + +// Helper function to extract words from the tree +function extractWordsFromTree( + sentence: NLP.Stanza.Sentence, + node: NLP.Stanza.TreeNode, +): NLP.Stanza.Word[] { + const words: NLP.Stanza.Word[] = []; + + if (node.label && node.children.length === 0) { + // Check if this is a punctuation node + if (!NLP.isPunctuation(node.label)) { + // Find the matching word in the sentence + const word = sentence.words.find((w) => w.text === node.label); + if (word) { + words.push(word); + } + } + } + + // Recursively process children + if (node.children) { + for (const child of node.children) { + words.push(...extractWordsFromTree(sentence, child)); + } + } + + // Remove duplicates by word id + const uniqueWords = Array.from( + new Map(words.map((word) => [word?.id, word])).values(), + ).filter(Boolean) as NLP.Stanza.Word[]; + + return uniqueWords; +} + +// Syntax highlighting colors - reusing from Stanza utils +const SYNTAX_COLORS: any = { + // Sentence - cornflower blue + S: "100,149,237", + // Sentence - cornflower blue + SBAR: "100,149,237", + // Sentence - cornflower blue + SBARQ: "100,149,237", + // Noun Phrase - coral + NP: "255,127,80", + // Verb Phrase - lime green + VP: "50,205,50", + // Prepositional Phrase - medium purple + PP: "147,112,219", + // Adjective Phrase - gold + AP: "255,215,0", + // Adverb Phrase - hot pink + AVP: "255,105,180", + // Noun - light salmon + NN: "255,160,122", + // Verb - light green + V: "144,238,144", + // Verb - light green + VB: "144,238,144", + // Verb - light green + VBP: "144,238,144", + // Verb - light green + VBG: "144,238,144", + // Verb - light green + VBZ: "144,238,144", + // Verb - light green + VBD: "144,238,144", + // Verb - light green + VBN: "144,238,144", + // Adjective - khaki + JJ: "240,230,140", + // Adverb - plum + ADV: "221,160,221", + // Preposition - light sky blue + PR: "135,206,250", + // Preposition - light sky blue + IN: "135,206,250", + // Preposition - light sky blue + TO: "135,206,250", + // Determiner - light gray + DT: "211,211,211", + // Personal pronoun - thistle + PPN: "216,191,216", + // Coordinating conjunction - dark gray + CC: "169,169,169", +}; + +export default memo(RecursiveClause); diff --git a/src/zoom/Word.tsx b/src/zoom/Word.tsx new file mode 100644 index 0000000..c665004 --- /dev/null +++ b/src/zoom/Word.tsx @@ -0,0 +1,194 @@ +import React, { memo, useCallback, useEffect, useState } from "react"; +import { motion } from "motion/react"; +import { ViewProps, LoadingStatus, WordData } from "./logic/types"; +// import { fetchWord } from "../logic/calls"; +import { wordVariants, createHoverEffect } from "./animations"; +import { useZoom } from "./hooks/useZoom"; +import { NLP } from "sortug-ai"; + +interface Props extends ViewProps { + word: NLP.Stanza.Word; +} + +function Word({ rawText, context, idx, word }: Props) { + const { viewState, handleElementClick } = useZoom(); + const { level, wIndex } = viewState; + const selected = wIndex === idx; + const isFocused = level === "word" && selected; + + // State for word data + const [loading, setLoading] = useState<LoadingStatus>("pending"); + const [wordData, setData] = useState<WordData | null>(null); + const [error, setError] = useState<string | null>(null); + + // Fetch word details when selected + const getMeaning = useCallback(() => { + setLoading("loading"); + + // Try to fetch the word data + // fetchWord(rawText, "en") + // .then((res) => { + // if ("error" in res) { + // setError(`Error loading word data: ${res.error}`); + // setLoading("error"); + // } else { + // setData(res.ok); + // setLoading("success"); + // } + // }) + // .catch((err) => { + // setError(`Failed to fetch word data: ${err.message}`); + // setLoading("error"); + // }); + }, [rawText]); + + // Load word data when the word is selected + useEffect(() => { + if (isFocused && !wordData && loading === "pending") { + getMeaning(); + } + }, [isFocused, getMeaning, wordData, loading]); + + return ( + <> + {/* Overlay backdrop when word is selected */} + {isFocused && <div className="word-backdrop" aria-hidden="true"></div>} + + <motion.div + key={idx + rawText} + className={`word-container ${selected ? "selected" : ""}`} + custom={selected} + variants={wordVariants} + initial="clause" + animate={level} + onClick={(e) => handleElementClick(e, idx)} + whileHover={ + level === "clause" + ? createHoverEffect(level, "clause", "255, 200, 200") + : {} + } + style={{ + backgroundColor: isFocused ? "white" : undefined, + boxShadow: isFocused ? "0 8px 32px rgba(0, 0, 0, 0.15)" : undefined, + }} + > + {level === "clause" || !selected ? ( + <span className="word">{rawText}</span> + ) : ( + <div className="word-details-wrapper"> + {loading === "loading" && ( + <div className="word-loading"> + <div className="spinner" /> + <p>Loading word information...</p> + </div> + )} + + {loading === "error" && ( + <div className="word-error"> + <p>{error || "Failed to load word information"}</p> + </div> + )} + + {loading === "success" && wordData && ( + <div className="word-content"> + <div className="word-header"> + <h2 className="word-title">{wordData.spelling}</h2> + + {/* Syllables section moved to header - for next level of zoom */} + <div className="syllables-compact"> + {Array.from({ length: wordData.syllables || 1 }).map( + (_, i) => { + // Create a simple syllable division (not linguistically accurate) + const syllableLength = Math.ceil( + rawText.length / (wordData.syllables || 1), + ); + const start = i * syllableLength; + const end = Math.min( + start + syllableLength, + rawText.length, + ); + const syllable = rawText.substring(start, end); + + return ( + <motion.div + key={i} + className="syllable" + whileHover={{ + scale: 1.1, + backgroundColor: "rgba(200, 255, 200, 0.4)", + }} + onClick={(e) => { + e.stopPropagation(); + handleElementClick(e, i); + }} + > + {syllable} + </motion.div> + ); + }, + )} + </div> + </div> + + {/* Pronunciation */} + {wordData.ipa && wordData.ipa.length > 0 && ( + <div className="word-phonetics"> + <h3>Pronunciation</h3> + {wordData.ipa.map((pronunciation, i) => ( + <div key={i} className="pronunciation-item"> + <span className="ipa">{pronunciation.ipa}</span> + {pronunciation.tags && + pronunciation.tags.length > 0 && ( + <span className="pronunciation-tags"> + {pronunciation.tags.join(", ")} + </span> + )} + </div> + ))} + </div> + )} + + {/* Word Senses/Meanings */} + {wordData.senses && wordData.senses.length > 0 && ( + <div className="word-meanings"> + <h3>Meanings</h3> + + {wordData.senses.map((sense, i) => ( + <div key={i} className="sense-container"> + <div className="sense-header"> + {sense.pos && ( + <span className="pos-tag">{sense.pos}</span> + )} + {sense.etymology && ( + <span className="etymology">{sense.etymology}</span> + )} + </div> + + {sense.senses && sense.senses.length > 0 && ( + <ul className="sense-list"> + {sense.senses.map((subSense, j) => ( + <li key={j} className="sense-item"> + {subSense.glosses && + subSense.glosses.length > 0 && ( + <div className="glosses"> + {subSense.glosses.join("; ")} + </div> + )} + </li> + ))} + </ul> + )} + </div> + ))} + </div> + )} + </div> + )} + </div> + )} + </motion.div> + </> + ); +} + +export default memo(Word); diff --git a/src/zoom/animations.ts b/src/zoom/animations.ts new file mode 100644 index 0000000..6135e7f --- /dev/null +++ b/src/zoom/animations.ts @@ -0,0 +1,199 @@ +import type { Variants } from "motion/react"; + +// Base transition configurations for consistent animations +const baseTransition = { + duration: 0.5, + ease: [0.43, 0.13, 0.23, 0.96], // Improved easing for smoother feel +}; + +export const fadeTransition = { + ...baseTransition, + duration: 0.3, +}; + +// Shared variants for different view levels +export const containerVariants: Variants = { + text: { + opacity: 1, + transition: { + staggerChildren: 0.05, + delayChildren: 0.1, + }, + }, + paragraph: { + opacity: 1, + transition: { + staggerChildren: 0.05, + delayChildren: 0.1, + }, + }, + sentence: { + opacity: 1, + transition: { + staggerChildren: 0.05, + delayChildren: 0.1, + }, + }, + clause: { + opacity: 1, + transition: { + staggerChildren: 0.05, + delayChildren: 0.1, + }, + }, + word: { + opacity: 1, + transition: { + staggerChildren: 0.05, + delayChildren: 0.1, + }, + }, + syllable: { + opacity: 1, + transition: { + staggerChildren: 0.05, + delayChildren: 0.1, + }, + }, + phoneme: { + opacity: 1, + transition: { + staggerChildren: 0.05, + delayChildren: 0.1, + }, + }, +}; + +// Function to create element variants based on selection state +export const createElementVariants = ( + currentLevel: string, + nextLevel: string, + prevLevel: string, + selectedOpacity = 1, + unselectedOpacity = 0.1, + selectedScale = 1.05, + unselectedScale = 0.95, + selectedBlur = "0px", + unselectedBlur = "2px", + bgColor = "rgba(255, 255, 255, 0)", // Use rgba with 0 opacity instead of transparent +): Variants => { + return { + [prevLevel]: { + opacity: 1, + scale: 1, + filter: "blur(0px)", + backgroundColor: "rgba(255, 255, 255, 0)", // Use rgba with 0 opacity + transition: baseTransition, + }, + [currentLevel]: (isSelected: boolean) => ({ + opacity: isSelected ? selectedOpacity : unselectedOpacity, + scale: isSelected ? selectedScale : unselectedScale, + filter: isSelected ? `blur(${selectedBlur})` : `blur(${unselectedBlur})`, + backgroundColor: isSelected ? bgColor : "rgba(255, 255, 255, 0)", // Use rgba with 0 opacity + transition: baseTransition, + }), + [nextLevel]: (isSelected: boolean) => ({ + opacity: isSelected ? selectedOpacity : unselectedOpacity / 2, + scale: isSelected ? selectedScale : unselectedScale * 0.95, + filter: isSelected + ? `blur(${selectedBlur})` + : `blur(${parseInt(unselectedBlur) + 1}px)`, + backgroundColor: isSelected ? bgColor : "rgba(255, 255, 255, 0)", // Use rgba with 0 opacity + transition: baseTransition, + }), + }; +}; + +// Pre-configured variants for each level +export const paragraphVariants = createElementVariants( + "paragraph", + "sentence", + "text", + 1, + 0.1, + 1.05, + 0.95, + "0px", + "2px", + "rgba(200, 220, 255, 0.1)", +); + +export const sentenceVariants = createElementVariants( + "sentence", + "clause", + "paragraph", + 1, + 0.1, + 1.1, + 0.95, + "0px", + "2px", + "rgba(200, 220, 255, 0.2)", +); + +export const clauseVariants = createElementVariants( + "clause", + "word", + "sentence", + 1, + 0.1, + 1.1, + 0.95, + "0px", + "2px", + "rgba(220, 200, 255, 0.2)", +); + +export const wordVariants = createElementVariants( + "word", + "syllable", + "clause", + 1, + 0.1, + 1.15, + 0.9, + "0px", + "2px", + "rgba(255, 200, 200, 0.2)", +); + +export const syllableVariants = createElementVariants( + "syllable", + "phoneme", + "word", + 1, + 0.1, + 1.2, + 0.9, + "0px", + "2px", + "rgba(200, 255, 200, 0.2)", +); + +// Button animations +export const buttonVariants: Variants = { + initial: { opacity: 0, x: -20 }, + animate: { opacity: 1, x: 0, transition: fadeTransition }, + exit: { opacity: 0, x: -20, transition: fadeTransition }, +}; + +// Hover effects +export const createHoverEffect = ( + level: string, + currentLevel: string, + color: string, +) => { + if (level === currentLevel) { + return { + scale: 1.02, + backgroundColor: `rgba(${color}, 0.3)`, + transition: { duration: 0.2 }, + }; + } + return { + // Return empty animation with same properties to avoid errors + scale: 1, + backgroundColor: "rgba(255, 255, 255, 0)", + transition: { duration: 0.2 }, + }; +}; diff --git a/src/zoom/hooks/useZoom.tsx b/src/zoom/hooks/useZoom.tsx new file mode 100644 index 0000000..e3fb0c4 --- /dev/null +++ b/src/zoom/hooks/useZoom.tsx @@ -0,0 +1,131 @@ +"use client"; +import React, { createContext, useState, useContext, ReactNode } from "react"; +import { ViewLevel, ViewState } from "../logic/types"; + +// Type definitions for the context +interface ZoomContextType { + viewState: ViewState; + setLevel: (level: ViewLevel) => void; + setParagraphIndex: (idx: number | null) => void; + setSentenceIndex: (idx: number | null) => void; + setClauseIndex: (idx: number | null) => void; + setWordIndex: (idx: number | null) => void; + setSyllableIndex: (idx: number | null) => void; + setPhonemeIndex: (idx: number | null) => void; + navigateBack: () => void; + handleElementClick: (e: React.MouseEvent, idx: number) => void; +} + +// Create the context with default empty values +const ZoomContext = createContext<ZoomContextType>({ + viewState: { + level: "text", + pIndex: null, + sIndex: null, + cIndex: null, + wIndex: null, + yIndex: null, + fIndex: null, + }, + setLevel: () => {}, + setParagraphIndex: () => {}, + setSentenceIndex: () => {}, + setClauseIndex: () => {}, + setWordIndex: () => {}, + setSyllableIndex: () => {}, + setPhonemeIndex: () => {}, + navigateBack: () => {}, + handleElementClick: () => {}, +}); + +// Provider component +export const ZoomProvider: React.FC<{ children: ReactNode }> = ({ + children, +}) => { + const [viewState, setViewState] = useState<ViewState>({ + level: "text", + pIndex: null, + sIndex: null, + cIndex: null, + wIndex: null, + yIndex: null, + fIndex: null, + }); + + // Helper functions to update individual parts of the state + const setLevel = (level: ViewLevel) => + setViewState((prev) => ({ ...prev, level })); + const setParagraphIndex = (pIndex: number | null) => + setViewState((prev) => ({ ...prev, pIndex })); + const setSentenceIndex = (sIndex: number | null) => + setViewState((prev) => ({ ...prev, sIndex })); + const setClauseIndex = (cIndex: number | null) => + setViewState((prev) => ({ ...prev, cIndex })); + const setWordIndex = (wIndex: number | null) => + setViewState((prev) => ({ ...prev, wIndex })); + const setSyllableIndex = (yIndex: number | null) => + setViewState((prev) => ({ ...prev, yIndex })); + const setPhonemeIndex = (fIndex: number | null) => + setViewState((prev) => ({ ...prev, fIndex })); + + // Handle navigation levels + const navigateBack = () => { + const { level } = viewState; + + if (level === "paragraph") { + setViewState((prev) => ({ ...prev, level: "text", pIndex: null })); + } else if (level === "sentence") { + setViewState((prev) => ({ ...prev, level: "paragraph", sIndex: null })); + } else if (level === "clause") { + setViewState((prev) => ({ ...prev, level: "sentence", cIndex: null })); + } else if (level === "word") { + setViewState((prev) => ({ ...prev, level: "clause", wIndex: null })); + } else if (level === "syllable") { + setViewState((prev) => ({ ...prev, level: "word", yIndex: null })); + } else if (level === "phoneme") { + setViewState((prev) => ({ ...prev, level: "syllable", fIndex: null })); + } + }; + + // Handle clicks on elements to navigate forward + const handleElementClick = (e: React.MouseEvent, idx: number) => { + e.stopPropagation(); + const { level } = viewState; + + if (level === "text") { + setViewState((prev) => ({ ...prev, level: "paragraph", pIndex: idx })); + } else if (level === "paragraph") { + setViewState((prev) => ({ ...prev, level: "sentence", sIndex: idx })); + } else if (level === "sentence") { + setViewState((prev) => ({ ...prev, level: "clause", cIndex: idx })); + } else if (level === "clause") { + setViewState((prev) => ({ ...prev, level: "word", wIndex: idx })); + } else if (level === "word") { + setViewState((prev) => ({ ...prev, level: "syllable", yIndex: idx })); + } else if (level === "syllable") { + setViewState((prev) => ({ ...prev, level: "phoneme", fIndex: idx })); + } + }; + + return ( + <ZoomContext.Provider + value={{ + viewState, + setLevel, + setParagraphIndex, + setSentenceIndex, + setClauseIndex, + setWordIndex, + setSyllableIndex, + setPhonemeIndex, + navigateBack, + handleElementClick, + }} + > + {children} + </ZoomContext.Provider> + ); +}; + +// Custom hook to use the zoom context +export const useZoom = () => useContext(ZoomContext); diff --git a/src/zoom/index.ts b/src/zoom/index.ts new file mode 100644 index 0000000..4ce07ca --- /dev/null +++ b/src/zoom/index.ts @@ -0,0 +1,9 @@ +export { ZoomProvider, useZoom } from "./hooks/useZoom"; +import App from "./App"; +import FullText from "./FullText"; +import Paragraph from "./Paragraph"; +import Sentence from "./Paragraph"; +import SpacyClause from "./SpacyClause"; +import type * as Types from "./logic/types"; + +export { App, Paragraph, FullText, Sentence, SpacyClause, Types }; diff --git a/src/zoom/logic/text.ts b/src/zoom/logic/text.ts new file mode 100644 index 0000000..3213aa2 --- /dev/null +++ b/src/zoom/logic/text.ts @@ -0,0 +1,102 @@ +export const poast = ` +“So what do you stand for?” I've been asked... surprisingly few times. There used to be a tradition of intellectual debate; adversarial, but polite. With clear rules so people didn't abuse their time to shill their own stuff instead of contributing to the actual debate. But we don't do debates in these parts; we never have. Which is unfortunate. Understandable, though, to the extent public debates are still a thing they've become an incredibly stale, cringe, Reddit-ish spectacle (I wish we had a better word for that but 'Reddit' really is a perfect way of conveying the concept of midwittery). Debating skills are orthogonal to having good ideas in general; Ben Shapiro is obviously a very talented, and trained, debater, but he's full of shit. Brimming. The real key to a good debate is the moderator; he should be as smart as the participants, and good at keeping them in line, preventing good thinkers with from going off rails on a tangent or just being malicious and stirring the conversation to promote their grift. That really is a dying breed. Even podcast interviews are garbage 90% of the time. Most interviewers just let guests talk uninterrupted so they can shill their stuff instead of poking them a bit to extract actual information. The only good interviewer out there is Tyler Cowen; who's smarter than the people he interviews and has enough clout to be aggressive, but polite about it. + +So what do I stand for? I stand for the truth, and for nice things. The key moment in my intellectual life was my figuring out why the truth and nice things are often incompatible. I always suspected that was the case. It's not an obvious case; most people will tell you the facile idea that Truth Beauty and Good are one and the same thing, and that they will prevail if we work for it. But no, it's not that simple. Nice things require Collective Action, Collective Action require Schelling Points, and Truth is a terrible Schelling Point. It just doesn't work. Hence lies. “Pretty lies” , if you want. The results are often ugly, though. + +Sure, pretty lies can make you a cohesive coalition to keep social peace; but that comes at a cost. See, the pursuit of truth is not just some aesthetic hangup. To make nice things you need technology. To make technology you need science. To do science you need truth. So there's a conflict there between the pretty lies we need to make politics easy, and the truth necessary to keep the lights on. + +The best solution there is to pay lip service to the pretty lies so that we get nice politics; but we keep them isolated and not let them affect other parts of society, e.g. the economy, academia etc. But it doesn't work like that. It never works like that. It can for a while; especially at the beginning: the ruling class comes up with some pretty lie, but they know it's fake, and they know how to dance around it to get things done. But the next generation wasn't there when that negotiation happened, so they don't get the joke. They take the platitudes literally and use political power to enforce them, and they fuck things up. This happened again, and again, and again. It really is the history of leftist creep in Western political history. Womens rights, racial equality, environmentalism, we've all seen it happen. So fuck the pretty lies; that's not working. We're going to hell. Let's try the truth now. How much worse can it be. + +So says Nathan Cofnas in a recent essay. A guide for the hereditarian revolution . I'm obviously down for a Hereditarian Revolution. Again, I'm for the truth, and hereditarianism is the most important truth out there. That all biological traits are heritable, and that inheritance determines the bulk of most traits (80%-ish) is obviously, painfully true. It's empirically true and deductively true. It's just so painfully obvious that its mainstream denial is personally the biggest friend/foe tell there is. I probably wouldn't get along with Nathan Cofnas in normal circumstances; the guy doesn't lift, he's Jewish but doesn't gtfo to Israel, etc. But he gets the most important point of them all, so he's in my team. And he says it in public, with his real name, so mad respect for that. I can't be arsed myself. He still has to go back but he's a cool guy. I'll buy him a beer. + +That said... I expected more from this essay. The guy is a philosopher of science. He has one job. And given his interest in hereditarianism and it's mainstream denial, well his one job should be to understand why the elites are woke, as he puts it. Herediarianism is true, obviously so. Why aren't elites hereditarian? He doesn't say clearly, and his talk of “brainwashing” is really facile. For all his complaints about right wing idiocracy of late, which are well deserved, he seems to view the triumph of anti-hereditarianism as some contingent fact of history, something we can fix with enough effort. + +Cofnas is erring in two axes here. One is the conflict-theory vs mistake-theory axis, one of the best points of Scott Alexander which I commented on on this old post . The other is the contingent vs necessary analysis of history. Cofnas is taking the blue pill on both axes. He should know better. + +To elaborate on the latter, there are two basic approaches to human history. Things can be contingent or necessary. Either things happen for some random combination of facts, and could have been totally different; or there are wider forces at play that forced circumstances in one direction and there's basically nothing that you could do to change them. Now, history is never 100% contingent or necessary. The way biological traits are not 100% heritable. There is some randomness at play. But not a lot. My intuition is that the contingent factor in human affairs is about the same as the non-heritable part in biological traits, so about 20%. Sure, if Baby Hitler had died in a traffic accident we wouldn't have had a Third Reich. But we probably would have still got WW2 in some form. Without Jesus of Nazareth Europeans probably wouldn't carry crosses as jewelry. But some other organized religion would have taken become popular in Europe after late antiquity. + +My whole intellectual output was trying to find the wider patterns that move history as a matter of necessity. Seeing human politics as an evolutionary process. Sure, there's plenty of contingent stuff in the emergence of socialism in the 19th century, a lot of quirky personalities involved. But that's not important; what's important is the wider patterns that provided the soil for it to grow. Humans are jealous. Politics were open. In such an environment a political movement which promises status to those unable to gain it elsewhere is going to become popular. By necessity. Given enough time someone will figure it out and he will win. By necessity. That's how evolutionary processes work. Some details might be contingent, but the logic is not. + +I feel very strongly that intellectuals (as opposed to scientists) really only have one job: to describe, as accurately as possible, human nature, and from those facts analyze the past and predict the future. Why else do people spend time and money reading our shit? + +Well Cofnas is doing a terrible job of understanding human nature. He doesn't understand why wokism won. He thinks wokism can be defeated by spreading accurate information boldly. Which is just deluded. Again his heart is in the heart place, and I commend him for his courage, but he's getting the facts wrong. He cites, as pretty much his only supporting evidence, the triumph of Darwin's theory of evolution over creationism in the 19th century. He seems to think that was just because Darwin was right and the intellectual classes were converted magically to the truth, abandoning their outdated religious ideas for Darwinism. + +Bullshit. Man, if it were so easy we'd have been terraforming Mars by the Roman Empire. Darwinism is true, of course, and perhaps the most important insight in the history of human thought. But it didn't win because of its truth. It won because it undermined the Christian narrative in Europe, and the intellectual classes of Europe had been fighting Christianity for centuries . Ever heard of the Freemasons? Darwinism spread like wildfire among European intellectuals because finally there was a solid theory that they could rub against those darn hated stuck-up Christian intellectuals they hated so much. Even Communists loved Darwin. The best predictor of support of evolution is anticlericalism. I derive no pleasure from this realization but it's a historical fact. The triumph of evolutionary theory was 90% politics. Like it always is. + +Why did Hereditarianism, which is again a painfully obvious derivation from Darwinism, not gain the same level of adoption? Because the politics are terrible! Not just the race stuff, to which Cofnas bizarrely dedicates half his essay. Forget about race, who cares? That's a very specific American hangup, because American parochial internal rivalries (e.g. North vs South) and the presence of millions of Bantus there. The American interest in the improvement of the black race started pretty much as a Northern obsession to spite the hated Southerners. + +Until 20 years ago Europe didn't have a race problem, nor anywhere in Asia; yet hereditarianism has never ever been popular in the history of human thought. Most people understand at a gut level that tall people have taller kids and smart people have smarter kids. But they don't like to think about it; especially if they're not tall or smart themselves. Besides Calvinists nobody likes determinism, nobody likes stuff to be written and unchangeable. Everything is set up at birth? How depressing is that? Everybody wants to think they have a change to make it big if they only stop drinking or whatever. + +You know what sells? Education. Oh that sells. How many of you have heard from normies that Africa is poor because they have bad education? What do East Asians, who share none of our cultural history or our ideological baggage believe? That everyone can get far in life if they study hard enough. China used to have a feudal aristocratic culture where rank was set at birth and commoners were seen as cattle to administrate. Then Confucius comes and says that all men are equal, and the 君子, the superior gentlemen, is not born, but made, through “cultivation” . You become one by, conveniently, reading Confucius' books. Anyone, no matter how low born, can become a gentleman if they buy Confucius' supplements and subscribe to his podcast. Cringe, you might think. But who won? + +This is obviously bonkers, and East Asian countries (South Korea in particular) are wasting a good third of their GDP in abusing their kids with cram school homework in the false belief that they'll grow more intelligent. A random Chinese will tell you very happily, especially if he has experience abroad, that Africans are congenitally dumber than Chinese are. But he will protest vehemently if you suggest that his kid isn't gonna grow any brighter by doing 5 hours of homework every day. + +What is Christianity if not an egalitarian revolt against the Pagan cult of the strong? Classical civilization never wrote down the laws of inheritance but it did assume that good breeding was a thing and better people had better children. Christianity makes a point of assuming the rich and strong are evil and sinful and the poor and downtrodden are morally superior. Who won? + +When Darwinism became common knowledge among the intellectuals in Europe, you would expect hereditarianism to become the common obsession of all civilized men, and all countries to start massive eugenics projects. Francis Galton certainly thought so! And he got some traction from the few good-hearted autists of the time. But he didn't get very far. Some countries enacted the very minimum eugenic legislation (e.g. sterilizing retards and the congenitally sick) but that's about it. Where are the national breeding projects to improve the national genepool? To create the Kwisatz Haderach? + +Never happened. Why? Because people hate hereditarianism. Again, for good reason. It's not that they don't know. Come on, it's not rocket science. Steve Sailer has been doing God's work and spreading proper biological science to the public for decades. Has he ever converted someone who didn't already agree? + +Even during the heyday of Western intellectual culture, when people could write books about phrenology or Negro intelligence and whatnot, hereditarianism wasn't popular politically. Cofnas mentions himself that Nazis didn't like Darwinism or IQ tests. It just makes for terrible politics. The game theory of hereditarianism is very simple. Say you have an election. There are two parties. One party, the Spandrell party, says, with overwhelming evidence, that intelligence is set at birth, education thus should be given according to measured IQ, in tightly segregated levels, with the dumbest just drilled to read, write and do basic arithmetic, and the smartest given public support to learn anything they want. Welfare is abolished and the general goal of all state policy is the improvement of the genepool, through generous subsidies to the healthy and intelligent. The other party says that everyone's a special snowflake, evolution stops at the neck, and we can all be what we want if the government spends enough money in edukashun. + +Who's gonna win? Think not only of who is going to attract more voters from the pure messaging; think of what kind of party apparatus will both parties have. Which party will have the more motivated activists? Which party will get the most funding? Which party will be able to promise more sinecures to its supporters? Which party is more willing to do foul play to win? + +To understand the world you have to understand power at the ground level. Ideas themselves don't matter. The second and third order level consequences of ideas matter. All the nitty-gritty ground level stuff is what actually carries the day. + +Intellectuals understand this too! Why are intellectuals today woke? Because they understand power at a gut level. They know where the power is, where the money is. Do they believe in wokeness? No! What does the word belief mean, really? Have all intellectuals put effort into understanding the factual basic of hereditarianism? Fuck no. Do they actually behave in their personal lives in ways fully consistent with their professed woke beliefs? Hell no. So how can we say that they believe in wokeness? Belief is a mental state, and mental states can't be proven. Wittgenstein famously said that you can never know for sure if someone else is in pain. All you can see is external signs, which can be fake. There's just no way to know for sure. Belief is the same. People will say things. They might even do things. But belief is not something you can measure. It's what I call a bad word, a very misleading concept. I try not to use it besides its original, most basic meaning ( “honey why are you so late” , “oh I had to stay longer at work” , “I don't believe you, you were at the brothel again” ). + +So how do we make the intellectuals become hereditarian? Cofnas says: + + When people discover that the taboo at the heart of our culture was constructed to protect a lie, their moral intuitions will change, and they will become receptive to new moral authorities. It’s difficult to change people’s values just by presenting moral arguments. But if you show people that they’ve been lied to about such a fundamental issue as race, you will trigger their emotions in a way that will bring down the value system that was associated with the lie. All we have to do is make people aware of a simple scientific fact. The cultural revolution will take care of itself. + +This is delusion of the worst sort. I facepalmed so hard my fingers are still marked red on my temple. I don't know where he gets his whitepills but they're better than my molly. + +No, man, that's not gonna work. It's not so easy. Would be really fucking funny if we were here right now, on the verge of the physical extinction of western civilization and of the white race as a thing, and all it took was to “make people aware of a simple scientific fact” that we have known for more than a century. No, of course not. I mean Cofnas talks of doing an “information campaign” , speaking boldly about it, to spread the message. A speech tour, say. He's kinda right but for the wrong reason. A speech tour doesn't spread information; hell we have the internet. A speech tour is a strong power statement, however. It signals that I can say these things and, if people don't stop you, that I have the power to say them. If Cofnas can pull off a speech tour across American and European universities saying that in a pure meritocracy Harvard would have zero Bantu professors, that would actually convert a lot of intellectuals, because they know that those ideas are incompatible with the current orthodoxy, so if the current orthodoxy doesn't shut them down with violence, that means the orthodoxy is going to change. And they want to stay in the good side of it. + +I do encourage Cofnas and Emil Kirkegaard and all the good hereditarian chads out there to try. I expect it won't work, but don't let me discourage you. If Cofnas can pull it off I'll stop saying he should go back to Israel. + +But again, I don't think the politics work, because politics is a messy business and the incentives of egalitarianism are orders of magnitude better at mobilizing people. That's just the way it is. Nayib Bukele in El Salvador is, by all accounts, a literal Twitter frog, reading Moldbug, quoting BAP, buying Bitcoin. Probably reads my blog or has at some point. He has improved the government of El Salvador more than any statesman since LKY. In relative terms probably more than anyone ever, given the low level where he started. For all we know Bukele is probably a hereditarian and understands all the science. Does he dare mention it to his 88 IQ Mayan populace? Hell no. What he does is build massive public libraries. Because that's gonna make all those Mayan become machine learning engineers and rocket scientists. + +Cofnas complains at length at how the bulk of race realists are not good-natured autists who understand the truth; they're nazis with an axe to grind. Well again, why do you think that is? Why? You have one job, figure that out. The reason why there's so many dumb nazis among race realists is because the politics of race realism only work out in that context. If you have a bunch of disagreeable racist people who hate niggers, kikes, yankees and basically have the Arab “me and my brother against my cousin” sort of personality, then race realism is a useful Schelling Point to make friends and build community. But it's not for the sort of smart, pleasant people that Cofnas wants to associate with. + +The logical corollary of Hereditarianism is not just “let's stop wasting money in Bantu education and welfare for bums” . No, we have huge governments, with millions of staff; people who want to do things . If you tell them the national ideology is egalitarianism, well they'll make up plans to Educate Bantus and give welfare to bums. If you tell them the national ideology is Hereditarianism they will, they must, start writing plans about breeding the Kwisatz Haderach. That's just how it works. + +Unless you fire them all; which I'm fine with. But do you understand how that also makes for bad politics? How are you gonna build a coalition arguing for destroying 99% of the government? + +There's political problems, there's coup-complete problems, and there's jihad-complete problems. Beating anti-hereditarianism is the very definition of a jihad-complete problem. And we don't have the means to start a jihad. To do that we need, first, a new religion, and for all my effortpoasting we don't have one yet. Maybe Cofnas wants to help. Maybe after he does his “information campaign”. +`; + +export const compassRose = ` +A myth pervades modern economic thinking: that prosperity naturally leads people to have fewer children. + +The measured fact to be explained is that fertility rates tend to drop below replacement in countries with a high measured economic output per person. This is both a big long-run problem and a puzzle. Microeconomists typically construe this as rational individual choice, as though people in wealthy societies simply discover better things to do with their time than raising families. But this causal explanation is backwards. + +From a microeconomic perspective, if the state wants less of some behavior, it should tax it, increasing its cost, which can be expected to reduce the frequency of that behavior. If it wants more of some behavior, it should subsidize it, reducing its cost, which can be expected to increase the frequency of that behavior. But at least in the USA - and probably in other high per-capita GDP countries participating in the same economic system - fertility is suppressed in large part by the macroeconomic policies with which the state constructs and regulates stores of financial value. If we are rich within this system of account, that means we can demand a great deal of labor from young people who might otherwise be producing and caring for their own children. We cannot pay young people enough to offset this without reversing much of the measured increase in rich-country wealth since 1971.1 + +This arrangement did not emerge through open deliberation about how we wanted to arrange our society. Rather, it developed through a series of historical contingencies and power struggles following the closure of imperial frontiers. Fixing this problem would not be a relatively modest technical adjustment to a well-understood system of inputs and outputs, but would constitute a radical change in how our society functions, with hard to predict consequences that would likely seriously disrupt our current modes of governance. +`; + +export const sample = "Today I will go to eat at my favorite restaurant"; +export const zhongwen = `特朗普不可预测的关税政策,对越南庞大的出口行业造成巨大冲击。《南华早报》称,近年来,许多中国企业在越南建厂,涉及服装、鞋类、家具和电子产品等各行各业。 + +当地时间4月2日,特朗普宣布对全世界征收所谓“对等关税”,引发全球股市巨震。但此后,随着特朗普关税政策带来经济和政治反噬,他在贸易问题上的强硬立场已变得越来越模糊。 + +9日,在债券市场抛售推高美国债务利率后,特朗普又宣布,在维持10%基准税率的基础上暂停“对等关税”90天,同时对中国税率提高至145%。因美债持续遭到抛售,特朗普再次更改政策,宣布对电子产品暂免部分关税,但之后又否认此举是“豁免”,并威胁实施单独征税。 + +“没人能搞清五天后的规则会是啥样。”谈到特朗普关税政策时,一名美国民主党参议员吐槽道。在市场广泛抨击和中方强势反制之下,特朗普政府已开始阵脚自乱:前脚高调加税,后脚三天两次调整关税政策,而这幕混乱荒唐的闹剧似乎还未结束。 + +“关税将打击越南出口导向型经济模式的根源。”英国《经济学人》杂志此前分析称,数据显示,过去十年(不包括疫情期间),越南经济的年均增长率为7%左右,对美出口占越南总出口的30%,占其名义GDP(国内生产总值)的27%。美国高额关税,大约相当于将越南的增长率削减一半。 + +值得一提的是,越南方面曾第一时间向美国递出了求和的“橄榄枝”,表示愿意取消对美国商品的进口关税。然而,美国似乎对此不屑一顾。当时,白宫贸易与制造业高级顾问彼得·纳瓦罗声称,越南的提议,并不足以使美国政府取消对越南关税。`; +export const nihongo = `いったいこの盛り上がりはどこから出てきたのか、これが正直、自作自演ではないかという気がするのです。石破首相の経済対策の失言報道は本年度予算を国会で討議している最中であったことから「お手つき」とされました。いざ、予算が通過すると各党、各政治家は選挙対策、特に自らが選挙に立つ立場となる参議院の議員はどんな飴玉を国民にお見せするかにかかってきます。世の東西を問わないといえばそれまでですが、声高に叫ぶ減税、現金給付は具体的に何を根拠にそう主張するのか、これがわからないのです。 + +日本という国は基本的にPreventive(予防的)措置を取らない国です。何かコトがおきてから対策をするというのが原則でした。これを前例主義と称する場合もありますが、要するに将来を予見してその対策を事前に行うという発想は日本には根付いていないのです。 + +ところが、今回の政治家の発想はまさに予防的措置であります。トランプ関税が日本経済に及ぼす影響は大きく、それを「国難」と首相は称しました。私にはこの発想が理解できないのです。民間企業は需要と供給、資本主義社会の荒波に揉まれ、優勝劣敗のチャレンジを日々行っているのです。リスクをどう評価するか、それをヘッジしているのか、これは民間企業が克服すべき課題であります。ところが時々、「官民」という言葉が走り、官主導で道を作るというわけです。これは官民ともに都合がよすぎる話でしかないのです。 + +つい先日までは春闘で沸いていました。2年連続で5%台の賃上げで大手企業の初任給の爆発的上昇が目についたのは記憶に新しいところです。物価高に追い付くか、と言われれば3月に発表になった2月の消費者物価指数は3.0%上昇なので一応、春闘はそれをクリアしています。もっとも99.7%の中小企業にはその恩恵がないとされますが、確実にトリクルダウン的に賃金引き上げは起きます。特に人口減の日本では賃上げをしないと労働力を確保できないので中小企業が「うちはとてもとても…」という甘えは許されないのです。賃金上げられないなら店を畳むか、という瀬戸際にあるとも言えます。`; +export const espaniol = `Las noticias sobre prostitutas seleccionadas por José Luis Ábalos enchufadas en cargos públicos en Ineco, Emfesa, Tragsatec… no dejan de salir. Libertad Digital ya ha publicado un mail interno de Adif aludiendo a un volumen total de "785 efectivos sin cobertura presupuestaria". Ahora se descubre que Tragsa -otra de las sociedades públicas usadas por la trama del PSOE para sus enchufes- tenía un contrato de 2,4 millones para albergar a los incrustados de Adif. Y una fuente interna a la que ha tenido acceso este diario apunta a la gravedad de la situación y desliza una cifra de coste total cercana a los 30 millones de euros. La UCO no es ajena a esta situación y sospecha de una brutal bolsa de enchufados políticos en empresas públicas tras la trama del PSOE. + +El citado mail publicado por Libertad Digital ha captado ya la atención de los investigadores del caso Koldo-PSOE. "Según la información que nos habéis pasado, en 2025 el número de efectivos sin cobertura presupuestaria sería de 785. Es un tema serio al que en este momento no sé qué tratamiento se le puede dar". Esa es la frase y así se lo remitió uno de los directivos encargados de estos asuntos en Adif a Michaux Miranda, el director de Gestión de Personas de Adif, sociedad pública dependiente de José Luis Ábalos en el momento del inicio del enchufe de Jésica. + +El mail en cuestión fue remitido por Manuel Fresno a Michaux Miranda el 5 de diciembre de 2023: "Hola Michaux, te mando las cuatro slides sobre temas de personal que hemos incorporado en la presentación de los PGE 2024. Si los datos de incorporaciones para 2024 y 2025 de la primera slide no son correctos necesitamos con urgencia que los corrijáis, ya que según la información que nos habéis pasado en 2025 el número de efectivos sin cobertura presupuestaria sería de 785. Es un tema serio que en este momento no sé qué tratamiento se le puede dar", señala el correo. Efectivamente, se trataba de un asunto muy serio. ¿Cómo se les pagaba si no había cobertura presupuestaria? ¿Cómo se controlaba su labor? ¿Cómo se justificaron sus puestos?`; diff --git a/src/zoom/logic/types.ts b/src/zoom/logic/types.ts new file mode 100644 index 0000000..1342bc7 --- /dev/null +++ b/src/zoom/logic/types.ts @@ -0,0 +1,84 @@ +import type { NLP } from "sortug-ai"; + +export type ViewLevel = + | "text" + | "paragraph" + | "sentence" + | "clause" + | "word" + | "syllable" + | "phoneme"; +export interface ViewState { + level: ViewLevel; + pIndex: number | null; + sIndex: number | null; + cIndex: number | null; + wIndex: number | null; + yIndex: number | null; + fIndex: number | null; +} + +export interface ViewProps { + idx: number; + rawText: string; + context: Context; + doc: NLP.Spacy.SpacyRes; + stanza?: NLP.Stanza.StanzaRes | undefined; +} +export type Context = { + parentText: string; + segmented: string[]; + idx: number; +}; +export type DBWord = { + confidence: number; + frequency: number | null; + id: number; + ipa: string; + spelling: string; + type: ExpressionType; + syllables: number; + lang: string; + prosody: string; + senses_array: string; +}; + +export type WordData = { + confidence: number; + frequency: number | null; + id: number; + ipa: Array<{ ipa: string; tags?: string[] }>; + spelling: string; + type: ExpressionType; + syllables: number; + lang: string; + prosody: Prosody; + senses: Sense[]; +}; +export type Prosody = { stressedSyllable: number; rhyme: string }; +export type ExpressionType = "word" | "expression" | "syllable"; +export type Sense = { + etymology: string; + pos: string; + forms: Array<{ form: string; tags: string[] }>; + related: Related; + senses: SubSense[]; +}; +export type SubSense = { + glosses: string[]; + raw_glosses: string[]; + categories: string[]; + examples: Example[]; + links: Array<[string, string]>; + synonyms: RelatedEntry[]; + tags: string[]; +}; +export type Example = { ref: string; text: string; type: "quote" }; +export type Related = { + related: RelatedEntry[]; + antonyms: RelatedEntry[]; + synonyms: RelatedEntry[]; + derived: RelatedEntry[]; +}; +export type RelatedEntry = { word: string; source?: string }; +export type LoadingStatus = "pending" | "loading" | "success" | "error"; diff --git a/src/zoom/parsing.css b/src/zoom/parsing.css new file mode 100644 index 0000000..0e6a95f --- /dev/null +++ b/src/zoom/parsing.css @@ -0,0 +1,152 @@ +/* Clause parsing styles */ +.clauses-container { + display: inline-block; + margin: 1rem 0; + padding: 0.5rem; + background-color: rgba(255, 255, 255, 0.8); + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.clause { + /* width: 90%; */ + /* flex-wrap: wrap; */ + display: flex; + cursor: pointer; + transition: all 0.2s ease; + /* */ + /* border-bottom: 0.5px solid var(--clause-underline-color); */ + /* border-radius: 0.3rem; */ +} + +.clause-inner { + position: relative; +} + +.clause-pos { + font-size: 0.7rem; + text-align: center; + border-bottom: 0.5px solid var(--clause-underline-color); + border-radius: 0.3rem; +} + +.word-pos { + font-size: 0.7rem; + position: absolute; + top: -1rem; + left: 50%; + transform: translateX(-50%); + +} + +.clause.bottom { + border-bottom: 8px solid var(--clause-underline-color); + border-radius: 0.3rem; +} + +.clause.underline { + /* Add text-decoration properties */ + text-decoration-line: underline; + text-decoration-color: var(--clause-underline-color); + /* Use CSS variable */ + text-decoration-thickness: 4px; + text-decoration-skip-ink: none; + /* Adjust thickness as needed */ + text-underline-offset: 2px; +} + +.clause.gradient { + text-decoration: none; + background-image: linear-gradient(to right, var(--clause-underline-color) 0%, black 100%); + background-position: 0 1.1em; + background-repeat: repeat-x; + background-size: 100% 4px; + display: inline; + /* Keep it as inline */ +} + +.clause.selected { + background-color: rgba(220, 200, 255, 0.3); + border-radius: 4px; +} + +.clause:hover { + background-color: rgba(220, 200, 255, 0.2); + transform: translateY(-1px); +} + + +/* New rule to disable text-decoration based on class from component */ +.clause.no-underline { + text-decoration: none !important; + border-bottom: none; +} + +.clause.punctuation { + margin: 0 0.05rem; + padding: 0; +} + +.clause.leaf-node { + padding: 0.1rem 0.2rem; +} + +.punctuation-leaf { + color: #666; + font-weight: normal; + margin: 0 0.05rem; +} + +/* Improve spacing and styling for leaf nodes */ +.tree-leaf { + font-weight: 500; + color: #333; +} + +.tree-leaf { + display: inline; + padding: 0.1rem 0.3rem 0 0.3rem; + margin: 0 0.1rem; + font-weight: 500; + color: #333; +} + +/* Words container */ +.words-container { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + padding: 0.5rem; + background-color: rgba(255, 255, 255, 0.8); + border-radius: 8px; + transition: all 0.3s ease; +} + +/* Mini spinner for clauses */ +.clause .mini-spinner { + display: inline-block; + width: 8px; + height: 8px; + margin: 0 2px; +} + +/* Tooltip for syntactic information */ +.syntax-tooltip { + position: absolute; + top: -30px; + left: 50%; + transform: translateX(-50%); + background-color: rgba(0, 0, 0, 0.8); + color: white; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + z-index: 100; + pointer-events: none; + opacity: 0; + transition: opacity 0.2s ease; +} + +.clause:hover .syntax-tooltip { + opacity: 1; +}
\ No newline at end of file diff --git a/src/zoom/spacy.css b/src/zoom/spacy.css new file mode 100644 index 0000000..0077119 --- /dev/null +++ b/src/zoom/spacy.css @@ -0,0 +1,39 @@ +.suword { + margin-left: 0.5ch; + margin-right: 0.5ch; +} + +/* .suword.pred { */ +/* color: gold; */ +/* } */ + +/* Clause level */ +.clause-container { + max-width: 600px; + white-space: normal !important; + hyphens: auto; + + padding: 2px; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s ease; + will-change: transform, opacity, filter, background-color; + + span { + white-space: normal !important; + } +} + +.clause-container.selected { + background-color: rgba(220, 200, 255, 0.2); + z-index: 3; +} + +.suword.subj { + color: blue; + /* border-bottom: 2px solid blue; */ +} + +.suword.root { + color: darkred; +}
\ No newline at end of file diff --git a/src/zoom/zoom.css b/src/zoom/zoom.css new file mode 100644 index 0000000..2c743bd --- /dev/null +++ b/src/zoom/zoom.css @@ -0,0 +1,567 @@ +.app { + font-family: 'Arial', sans-serif; + max-width: 1200px; + margin: 0 auto; + padding: 20px; + color: #333; +} + +.app-header { + text-align: center; + margin-bottom: 40px; +} + +h1 { + font-size: 2.5rem; + margin-bottom: 1rem; + color: #2c3e50; + letter-spacing: -0.5px; +} + +.instructions { + font-size: 1.1rem; + line-height: 1.6; + color: #555; + max-width: 800px; + margin: 0 auto; +} + +.highlight { + background-color: rgba(255, 255, 200, 0.5); + padding: 0.2rem 0.5rem; + border-radius: 4px; + font-weight: 500; + color: #444; +} + +main { + display: flex; + justify-content: center; + margin: 2rem 0; +} + +.app-footer { + text-align: center; + margin-top: 60px; + padding-top: 20px; + border-top: 1px solid #eee; + color: #777; + font-size: 0.9rem; +} + +.text-focus-morph { + width: 100%; + max-width: 800px; + margin: 0 auto; + padding: 20px; + font-family: 'Arial', sans-serif; + position: relative; + min-height: 80vh; +} + +.content-container { + display: flex; + flex-direction: column; + gap: 1rem; + position: relative; +} + +/* Paragraph level */ +.paragraph-container, +.paragraph-wrapper { + position: relative; + padding: 1rem; + border-radius: 8px; + cursor: pointer; + will-change: transform, opacity, filter, background-color; + transition: background-color 0.2s ease; +} + +.paragraph-container.selected, +.paragraph-wrapper.selected { + background-color: rgba(200, 220, 255, 0.1); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); + z-index: 1; +} + +.paragraph { + margin: 0; + line-height: 1.6; + text-align: left; +} + +.sentences-container { + display: inline; + line-height: 1.7; + text-align: left; +} + +/* Sentence level */ +.sentence, +.sentence-wrapper { + position: relative; + padding: 2px 4px; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s ease; + display: inline; + will-change: transform, opacity, filter, background-color; +} + +.sentence.selected, +.sentence-wrapper.selected { + background-color: rgba(200, 220, 255, 0.2); + z-index: 2; +} + + +/* Word level */ +.word-container, +.word-wrapper { + display: inline-block; + padding: 2px 4px; + margin: 0 2px; + border-radius: 4px; + cursor: pointer; + transition: all 0.3s ease; + will-change: transform, opacity, filter, background-color; + position: relative; + z-index: 1; + /* Base z-index */ +} + +.word-container.selected, +.word-wrapper.selected { + background-color: #ffffff; + /* Fully opaque background */ + width: 90%; + max-width: 600px; + margin: 1rem auto; + display: block; + padding: 1rem; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + z-index: 50; + /* Higher than everything else */ + position: relative; + isolation: isolate; + /* Creates a new stacking context */ +} + +.word { + font-weight: 500; + position: relative; +} + +/* Fix for the word details view */ +.word-details-wrapper { + width: 100%; + overflow-y: auto; + position: relative; + margin: 0 auto; + z-index: 55; + /* Higher z-index */ + background-color: #ffffff; + /* Solid background */ + border-radius: 8px; + box-shadow: 0 0 0 1000px white; + /* Create an extended white background */ +} + +/* No longer needed - using word-backdrop element instead */ + +/* Backdrop overlay for word view */ +.word-backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(255, 255, 255, 0.9); + z-index: 45; + /* Between regular content and the word container */ + pointer-events: none; + /* Allow clicks to pass through */ +} + +.word-content { + background-color: white; + border-radius: 8px; + padding: 1.5rem; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); + max-height: 60vh; + overflow-y: auto; + position: relative; + z-index: 56; + /* Even higher z-index */ +} + +.word-details { + display: flex; + flex-direction: column; + gap: 0.8rem; + background-color: white; + border-radius: 8px; + padding: 1rem; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); + position: relative; + z-index: 56; + /* Match word-content z-index */ +} + +/* Ensure all word detail elements remain on top */ +.word-container.selected *, +.word-wrapper.selected * { + position: relative; + z-index: 1; +} + +.word-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + border-bottom: 2px solid rgba(255, 200, 200, 0.3); + padding-bottom: 0.5rem; + margin-bottom: 0.8rem; +} + +.word-title, +.word-text { + font-size: 1.8rem; + margin: 0; + color: #2c3e50; + flex: 1; +} + +.word-phonetics, +.word-phonetics.ipa { + margin: 0.8rem 0; + padding: 0.5rem; + background-color: rgba(245, 245, 245, 0.5); + border-radius: 4px; +} + +.pronunciation-item { + margin: 0.5rem 0; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.ipa { + font-family: serif; + font-style: italic; + background-color: rgba(255, 255, 255, 0.8); + padding: 0.2rem 0.5rem; + border-radius: 4px; + font-weight: 500; +} + +.pronunciation-tags { + color: #666; + font-size: 0.9rem; +} + +.word-meanings { + margin: 1rem 0; + font-size: 0.95rem; + color: #333; + background-color: rgba(255, 255, 255, 0.8); + padding: 0.8rem; + border-radius: 6px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.sense-container, +.word-meaning { + margin: 1rem 0; + padding: 0.5rem; + border-left: 3px solid rgba(255, 200, 200, 0.3); + padding-left: 1rem; +} + +.sense-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.pos-tag { + background-color: rgba(200, 220, 255, 0.3); + color: #2c3e50; + padding: 0.1rem 0.5rem; + border-radius: 4px; + font-size: 0.9rem; + font-weight: 500; +} + +.etymology { + font-style: italic; + color: #666; + font-size: 0.9rem; +} + +.sense-list { + margin: 0.5rem 0; + padding-left: 1.5rem; +} + +.sense-item { + margin: 0.3rem 0; +} + +.glosses { + line-height: 1.5; +} + +/* Syllables display */ +.syllables-section { + margin: 1.5rem 0 0.5rem 0; + padding-top: 1rem; + border-top: 1px solid #eee; +} + +.syllables-container { + display: flex; + flex-direction: column; + gap: 0.8rem; +} + +.syllables-row { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.8rem; + margin-top: 0.5rem; +} + +/* Compact syllables display for header */ +.syllables-compact { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + align-items: center; + margin-left: 1rem; +} + +.syllable { + display: inline-block; + padding: 0.3rem 0.6rem; + background-color: rgba(200, 255, 200, 0.2); + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + font-weight: 500; + text-align: center; + font-size: 0.9rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.syllable:hover { + background-color: rgba(200, 255, 200, 0.4); + transform: translateY(-2px); +} + +.syllable.selected { + background-color: rgba(200, 255, 200, 0.4); +} + +.phoneme { + display: inline-block; + padding: 2px; + margin: 0 1px; + background-color: rgba(255, 255, 200, 0.2); + border-radius: 2px; +} + +/* Loading and error states */ +.word-loading, +.word-error { + text-align: center; + padding: 2rem; + background-color: white; + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); + z-index: 56; +} + +.word-error { + color: #e74c3c; + background-color: rgba(255, 200, 200, 0.1); +} + +/* Navigation */ +.back-button { + position: fixed; + top: 20px; + left: 20px; + padding: 8px 16px; + background-color: #f0f0f0; + border: none; + border-radius: 4px; + cursor: pointer; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + z-index: 100; + transition: background-color 0.2s ease; +} + +.back-button:hover { + background-color: #e0e0e0; +} + +/* Loaders */ +.spinner { + border: 4px solid rgba(0, 0, 0, 0.1); + width: 24px; + height: 24px; + border-radius: 50%; + border-left-color: #09f; + margin: 5px auto; + animation: spin 1s ease infinite; +} + +.mini-spinner { + display: inline-block; + border: 2px solid rgba(0, 0, 0, 0.1); + width: 12px; + height: 12px; + border-radius: 50%; + border-left-color: #09f; + margin: 0 3px; + animation: spin 1s ease infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + + .word-container.selected, + .word-wrapper.selected { + width: 100%; + padding: 0.5rem; + } + + .word-content, + .word-details { + padding: 1rem; + } + + .word-title, + .word-text { + font-size: 1.5rem; + } + + .word-header { + flex-direction: column; + align-items: flex-start; + } + + .syllables-compact { + margin-left: 0; + margin-top: 0.5rem; + } +} + +/* Translation modal */ +.modal-bg { + width: 100vw; + height: 100vh; + background-color: rgba(0, 0, 0, 0.3); + position: fixed; + top: 0; + left: 0; + z-index: 100; +} + +.modal-bg2 { + position: relative; + width: 100%; + height: 100%; +} + +.modal-fg { + background-color: white; + z-index: 101; + border: 1px solid black; + overflow-y: auto; + padding: 1rem; + width: 80%; + height: max-content; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + border-radius: 8px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2); +} + +/* Translation button */ +.translate-button { + position: fixed; + top: 20px; + right: 20px; + padding: 8px 16px; + background-color: #f0f0f0; + border: none; + border-radius: 4px; + cursor: pointer; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + z-index: 100; + transition: background-color 0.2s ease; +} + +.translate-button:hover { + background-color: #e0e0e0; +} + + + + +.suword { + margin-left: 0.5ch; + margin-right: 0.5ch; +} + +/* .suword.pred { */ +/* color: gold; */ +/* } */ + +/* Clause level */ +.clause-container { + max-width: 600px; + white-space: normal !important; + hyphens: auto; + + padding: 2px; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s ease; + will-change: transform, opacity, filter, background-color; + + span { + white-space: normal !important; + } +} + +.clause-container.selected { + background-color: rgba(220, 200, 255, 0.2); + z-index: 3; +} + +.suword.subj { + color: blue; + /* border-bottom: 2px solid blue; */ +} + +.suword.root { + color: darkred; +}
\ No newline at end of file |