diff --git a/package.json b/package.json index f9feea5..acde7cb 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "geist": "^1.3.0", + "juice": "^11.0.0", "lucide-react": "^0.445.0", "next": "^14.2.4", "react": "^18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca08264..d88daf4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,6 +46,9 @@ importers: geist: specifier: ^1.3.0 version: 1.3.1(next@14.2.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + juice: + specifier: ^11.0.0 + version: 11.0.0 lucide-react: specifier: ^0.445.0 version: 0.445.0(react@18.3.1) @@ -1249,6 +1252,13 @@ packages: integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==, } + ansi-colors@4.1.3: + resolution: + { + integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==, + } + engines: { node: ">=6" } + ansi-regex@5.0.1: resolution: { @@ -1411,6 +1421,12 @@ packages: } engines: { node: ">=8" } + boolbase@1.0.0: + resolution: + { + integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==, + } + brace-expansion@1.1.11: resolution: { @@ -1471,6 +1487,19 @@ packages: } engines: { node: ">=10" } + cheerio-select@2.1.0: + resolution: + { + integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==, + } + + cheerio@1.0.0: + resolution: + { + integrity: sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==, + } + engines: { node: ">=18.17" } + chokidar@3.6.0: resolution: { @@ -1530,6 +1559,13 @@ packages: } engines: { node: ">=12.5.0" } + commander@12.1.0: + resolution: + { + integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==, + } + engines: { node: ">=18" } + commander@4.1.1: resolution: { @@ -1563,6 +1599,19 @@ packages: integrity: sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==, } + css-select@5.1.0: + resolution: + { + integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==, + } + + css-what@6.1.0: + resolution: + { + integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==, + } + engines: { node: ">= 6" } + cssesc@3.0.0: resolution: { @@ -1693,6 +1742,57 @@ packages: } engines: { node: ">=6.0.0" } + dom-serializer@1.4.1: + resolution: + { + integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==, + } + + dom-serializer@2.0.0: + resolution: + { + integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==, + } + + domelementtype@2.3.0: + resolution: + { + integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==, + } + + domhandler@3.3.0: + resolution: + { + integrity: sha512-J1C5rIANUbuYK+FuFL98650rihynUOEzRLxW+90bKZRWB6A1X1Tf82GxR1qAWLyfNPRvjqfip3Q5tdYlmAa9lA==, + } + engines: { node: ">= 4" } + + domhandler@4.3.1: + resolution: + { + integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==, + } + engines: { node: ">= 4" } + + domhandler@5.0.3: + resolution: + { + integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==, + } + engines: { node: ">= 4" } + + domutils@2.8.0: + resolution: + { + integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==, + } + + domutils@3.1.0: + resolution: + { + integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==, + } + eastasianwidth@0.2.0: resolution: { @@ -1711,6 +1811,12 @@ packages: integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==, } + encoding-sniffer@0.2.0: + resolution: + { + integrity: sha512-ju7Wq1kg04I3HtiYIOrUrdfdDvkyO9s5XM8QAj/bN61Yo/Vb4vgJxy5vi4Yxk01gWHbrofpPtpxM8bKger9jhg==, + } + enhanced-resolve@5.17.1: resolution: { @@ -1718,6 +1824,19 @@ packages: } engines: { node: ">=10.13.0" } + entities@2.2.0: + resolution: + { + integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==, + } + + entities@4.5.0: + resolution: + { + integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==, + } + engines: { node: ">=0.12" } + es-abstract@1.23.3: resolution: { @@ -1779,6 +1898,13 @@ packages: } engines: { node: ">= 0.4" } + escape-goat@3.0.0: + resolution: + { + integrity: sha512-w3PwNZJwRxlp47QGzhuEBldEqVHHhh8/tIPcl6ecf2Bou99cdAt0knihBV0Ecc7CGxYduXVBDheH1K2oADRlvw==, + } + engines: { node: ">=10" } + escape-string-regexp@4.0.0: resolution: { @@ -2202,12 +2328,31 @@ packages: } engines: { node: ">= 0.4" } + htmlparser2@5.0.1: + resolution: + { + integrity: sha512-vKZZra6CSe9qsJzh0BjBGXo8dvzNsq/oGvsjfRdOrrryfeD9UOBEEQdeoqCRmKZchF5h2zOBMQ6YuQ0uRUmdbQ==, + } + + htmlparser2@9.1.0: + resolution: + { + integrity: sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==, + } + hyphenate-style-name@1.1.0: resolution: { integrity: sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==, } + iconv-lite@0.6.3: + resolution: + { + integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==, + } + engines: { node: ">=0.10.0" } + ignore@5.3.2: resolution: { @@ -2557,6 +2702,14 @@ packages: } engines: { node: ">=4.0" } + juice@11.0.0: + resolution: + { + integrity: sha512-sGF8hPz9/Wg+YXbaNDqc1Iuoaw+J/P9lBHNQKXAGc9pPNjCd4fyPai0Zxj7MRtdjMr0lcgk5PjEIkP2b8R9F3w==, + } + engines: { node: ">=18.17" } + hasBin: true + keyv@4.5.4: resolution: { @@ -2643,6 +2796,12 @@ packages: integrity: sha512-wrZpoT50ehYOudhDjt/YvUJc6eUzcdFPdmbizfgvswCKNHD1/OBOHYJpHie+HXpu6bSkEGieFMYk6VuutaiRfA==, } + mensch@0.3.4: + resolution: + { + integrity: sha512-IAeFvcOnV9V0Yk+bFhYR07O3yNina9ANIN5MoXBKYJ/RLYPurd2d0yw14MDhpr9/momp0WofT1bPUh3hkzdi/g==, + } + merge2@1.4.1: resolution: { @@ -2657,6 +2816,14 @@ packages: } engines: { node: ">=8.6" } + mime@2.6.0: + resolution: + { + integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==, + } + engines: { node: ">=4.0.0" } + hasBin: true + minimatch@3.1.2: resolution: { @@ -2737,6 +2904,12 @@ packages: } engines: { node: ">=0.10.0" } + nth-check@2.1.1: + resolution: + { + integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==, + } + object-assign@4.1.1: resolution: { @@ -2847,6 +3020,24 @@ packages: } engines: { node: ">=6" } + parse5-htmlparser2-tree-adapter@7.1.0: + resolution: + { + integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==, + } + + parse5-parser-stream@7.1.2: + resolution: + { + integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==, + } + + parse5@7.2.1: + resolution: + { + integrity: sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==, + } + path-exists@4.0.0: resolution: { @@ -3234,6 +3425,12 @@ packages: } engines: { node: ">= 0.4" } + safer-buffer@2.1.2: + resolution: + { + integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==, + } + scheduler@0.23.2: resolution: { @@ -3322,6 +3519,12 @@ packages: integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==, } + slick@1.12.2: + resolution: + { + integrity: sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A==, + } + source-map-js@1.2.1: resolution: { @@ -3613,6 +3816,13 @@ packages: integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==, } + undici@6.21.0: + resolution: + { + integrity: sha512-BUgJXc752Kou3oOIuU1i+yZZypyZRqNPW0vqoMPl8VaoalSfeR0D8/t4iAS3yirs79SSMTxTag+ZC86uswv+Cw==, + } + engines: { node: ">=18.17" } + uri-js@4.4.1: resolution: { @@ -3651,6 +3861,13 @@ packages: integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==, } + valid-data-url@3.0.1: + resolution: + { + integrity: sha512-jOWVmzVceKlVVdwjNSenT4PbGghU0SBIizAev8ofZVgivk/TVHXSbNL8LP6M3spZvkR9/QolkyJavGSX5Cs0UA==, + } + engines: { node: ">=10" } + vaul@1.0.0: resolution: { @@ -3660,6 +3877,27 @@ packages: react: ^16.8 || ^17.0 || ^18.0 react-dom: ^16.8 || ^17.0 || ^18.0 + web-resource-inliner@7.0.0: + resolution: + { + integrity: sha512-NlfnGF8MY9ZUwFjyq3vOUBx7KwF8bmE+ywR781SB0nWB6MoMxN4BA8gtgP1KGTZo/O/AyWJz7HZpR704eaj4mg==, + } + engines: { node: ">=10.0.0" } + + whatwg-encoding@3.1.1: + resolution: + { + integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==, + } + engines: { node: ">=18" } + + whatwg-mimetype@4.0.0: + resolution: + { + integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==, + } + engines: { node: ">=18" } + which-boxed-primitive@1.0.2: resolution: { @@ -4445,6 +4683,8 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ansi-colors@4.1.3: {} + ansi-regex@5.0.1: {} ansi-regex@6.1.0: {} @@ -4553,6 +4793,8 @@ snapshots: binary-extensions@2.3.0: {} + boolbase@1.0.0: {} + brace-expansion@1.1.11: dependencies: balanced-match: 1.0.2 @@ -4589,6 +4831,29 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + cheerio-select@2.1.0: + dependencies: + boolbase: 1.0.0 + css-select: 5.1.0 + css-what: 6.1.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.1.0 + + cheerio@1.0.0: + dependencies: + cheerio-select: 2.1.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.1.0 + encoding-sniffer: 0.2.0 + htmlparser2: 9.1.0 + parse5: 7.2.1 + parse5-htmlparser2-tree-adapter: 7.1.0 + parse5-parser-stream: 7.1.2 + undici: 6.21.0 + whatwg-mimetype: 4.0.0 + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -4627,6 +4892,8 @@ snapshots: color-convert: 2.0.1 color-string: 1.9.1 + commander@12.1.0: {} + commander@4.1.1: {} concat-map@0.0.1: {} @@ -4643,6 +4910,16 @@ snapshots: css-mediaquery@0.1.2: {} + css-select@5.1.0: + dependencies: + boolbase: 1.0.0 + css-what: 6.1.0 + domhandler: 5.0.3 + domutils: 3.1.0 + nth-check: 2.1.1 + + css-what@6.1.0: {} + cssesc@3.0.0: {} csstype@3.1.3: {} @@ -4726,17 +5003,64 @@ snapshots: dependencies: esutils: 2.0.3 + dom-serializer@1.4.1: + dependencies: + domelementtype: 2.3.0 + domhandler: 4.3.1 + entities: 2.2.0 + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@3.3.0: + dependencies: + domelementtype: 2.3.0 + + domhandler@4.3.1: + dependencies: + domelementtype: 2.3.0 + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@2.8.0: + dependencies: + dom-serializer: 1.4.1 + domelementtype: 2.3.0 + domhandler: 4.3.1 + + domutils@3.1.0: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + eastasianwidth@0.2.0: {} emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} + encoding-sniffer@0.2.0: + dependencies: + iconv-lite: 0.6.3 + whatwg-encoding: 3.1.1 + enhanced-resolve@5.17.1: dependencies: graceful-fs: 4.2.11 tapable: 2.2.1 + entities@2.2.0: {} + + entities@4.5.0: {} + es-abstract@1.23.3: dependencies: array-buffer-byte-length: 1.0.1 @@ -4841,6 +5165,8 @@ snapshots: is-date-object: 1.0.5 is-symbol: 1.0.4 + escape-goat@3.0.0: {} + escape-string-regexp@4.0.0: {} eslint-config-next@14.2.13(eslint@8.57.1)(typescript@5.6.2): @@ -5202,8 +5528,26 @@ snapshots: dependencies: function-bind: 1.1.2 + htmlparser2@5.0.1: + dependencies: + domelementtype: 2.3.0 + domhandler: 3.3.0 + domutils: 2.8.0 + entities: 2.2.0 + + htmlparser2@9.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.1.0 + entities: 4.5.0 + hyphenate-style-name@1.1.0: {} + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + ignore@5.3.2: {} import-fresh@3.3.0: @@ -5390,6 +5734,14 @@ snapshots: object.assign: 4.1.5 object.values: 1.2.0 + juice@11.0.0: + dependencies: + cheerio: 1.0.0 + commander: 12.1.0 + mensch: 0.3.4 + slick: 1.12.2 + web-resource-inliner: 7.0.0 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -5431,6 +5783,8 @@ snapshots: dependencies: css-mediaquery: 0.1.2 + mensch@0.3.4: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -5438,6 +5792,8 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mime@2.6.0: {} + minimatch@3.1.2: dependencies: brace-expansion: 1.1.11 @@ -5489,6 +5845,10 @@ snapshots: normalize-path@3.0.0: {} + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + object-assign@4.1.1: {} object-hash@3.0.0: {} @@ -5561,6 +5921,19 @@ snapshots: dependencies: callsites: 3.1.0 + parse5-htmlparser2-tree-adapter@7.1.0: + dependencies: + domhandler: 5.0.3 + parse5: 7.2.1 + + parse5-parser-stream@7.1.2: + dependencies: + parse5: 7.2.1 + + parse5@7.2.1: + dependencies: + entities: 4.5.0 + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} @@ -5757,6 +6130,8 @@ snapshots: es-errors: 1.3.0 is-regex: 1.1.4 + safer-buffer@2.1.2: {} + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -5830,6 +6205,8 @@ snapshots: dependencies: is-arrayish: 0.3.2 + slick@1.12.2: {} + source-map-js@1.2.1: {} stop-iteration-iterator@1.0.0: @@ -6044,6 +6421,8 @@ snapshots: undici-types@6.19.8: {} + undici@6.21.0: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -6065,6 +6444,8 @@ snapshots: util-deprecate@1.0.2: {} + valid-data-url@3.0.1: {} + vaul@1.0.0(@types/react-dom@18.3.0)(@types/react@18.3.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: "@radix-ui/react-dialog": 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -6074,6 +6455,20 @@ snapshots: - "@types/react" - "@types/react-dom" + web-resource-inliner@7.0.0: + dependencies: + ansi-colors: 4.1.3 + escape-goat: 3.0.0 + htmlparser2: 5.0.1 + mime: 2.6.0 + valid-data-url: 3.0.1 + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + which-boxed-primitive@1.0.2: dependencies: is-bigint: 1.0.4 diff --git a/public/add-to-anki.svg b/public/add-to-anki.svg new file mode 100644 index 0000000..5537dbc --- /dev/null +++ b/public/add-to-anki.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/anki.css b/public/anki.css new file mode 100644 index 0000000..d5bd0b9 --- /dev/null +++ b/public/anki.css @@ -0,0 +1,1032 @@ +/* +Created a very bare-bones input.css tailwind file, with only: +@tailwind base; +@tailwind components; +@tailwind utilities; +Then ran this cli command: +npx tailwindcss -i src/styles/input.css -o src/styles/anki.css --minify +So that we can run juice against it to inline tailwind styles before creating an Anki card. +*/ +*, +:after, +:before { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgba(59, 130, 246, 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: ; +} +::backdrop { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgba(59, 130, 246, 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: ; +} +/*! tailwindcss v3.4.13 | MIT License | https://tailwindcss.com*/ +*, +:after, +:before { + box-sizing: border-box; + border: 0 solid #e5e7eb; +} +:after, +:before { + --tw-content: ""; +} +:host, +html { + line-height: 1.5; + -webkit-text-size-adjust: 100%; + -moz-tab-size: 4; + -o-tab-size: 4; + tab-size: 4; + font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + font-feature-settings: normal; + font-variation-settings: normal; + -webkit-tap-highlight-color: transparent; +} +body { + margin: 0; + line-height: inherit; +} +hr { + height: 0; + color: inherit; + border-top-width: 1px; +} +abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; +} +h1, +h2, +h3, +h4, +h5, +h6 { + font-size: inherit; + font-weight: inherit; +} +a { + color: inherit; + text-decoration: inherit; +} +b, +strong { + font-weight: bolder; +} +code, +kbd, +pre, +samp { + font-family: + ui-monospace, + SFMono-Regular, + Menlo, + Monaco, + Consolas, + Liberation Mono, + Courier New, + monospace; + font-feature-settings: normal; + font-variation-settings: normal; + font-size: 1em; +} +small { + font-size: 80%; +} +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} +sub { + bottom: -0.25em; +} +sup { + top: -0.5em; +} +table { + text-indent: 0; + border-color: inherit; + border-collapse: collapse; +} +button, +input, +optgroup, +select, +textarea { + font-family: inherit; + font-feature-settings: inherit; + font-variation-settings: inherit; + font-size: 100%; + font-weight: inherit; + line-height: inherit; + letter-spacing: inherit; + color: inherit; + margin: 0; + padding: 0; +} +button, +select { + text-transform: none; +} +button, +input:where([type="button"]), +input:where([type="reset"]), +input:where([type="submit"]) { + -webkit-appearance: button; + background-color: transparent; + background-image: none; +} +:-moz-focusring { + outline: auto; +} +:-moz-ui-invalid { + box-shadow: none; +} +progress { + vertical-align: baseline; +} +::-webkit-inner-spin-button, +::-webkit-outer-spin-button { + height: auto; +} +[type="search"] { + -webkit-appearance: textfield; + outline-offset: -2px; +} +::-webkit-search-decoration { + -webkit-appearance: none; +} +::-webkit-file-upload-button { + -webkit-appearance: button; + font: inherit; +} +summary { + display: list-item; +} +blockquote, +dd, +dl, +figure, +h1, +h2, +h3, +h4, +h5, +h6, +hr, +p, +pre { + margin: 0; +} +fieldset { + margin: 0; +} +fieldset, +legend { + padding: 0; +} +menu, +ol, +ul { + list-style: none; + margin: 0; + padding: 0; +} +dialog { + padding: 0; +} +textarea { + resize: vertical; +} +input::-moz-placeholder, +textarea::-moz-placeholder { + opacity: 1; + color: #9ca3af; +} +input::placeholder, +textarea::placeholder { + opacity: 1; + color: #9ca3af; +} +[role="button"], +button { + cursor: pointer; +} +:disabled { + cursor: default; +} +audio, +canvas, +embed, +iframe, +img, +object, +svg, +video { + display: block; + vertical-align: middle; +} +img, +video { + max-width: 100%; + height: auto; +} +[hidden] { + display: none; +} +.container { + width: 100%; +} +@media (min-width: 640px) { + .container { + max-width: 640px; + } +} +@media (min-width: 768px) { + .container { + max-width: 768px; + } +} +@media (min-width: 1024px) { + .container { + max-width: 1024px; + } +} +@media (min-width: 1280px) { + .container { + max-width: 1280px; + } +} +@media (min-width: 1536px) { + .container { + max-width: 1536px; + } +} +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} +.fixed { + position: fixed; +} +.absolute { + position: absolute; +} +.relative { + position: relative; +} +.inset-0 { + inset: 0; +} +.inset-x-0 { + left: 0; + right: 0; +} +.bottom-0 { + bottom: 0; +} +.left-5 { + left: 1.25rem; +} +.right-0 { + right: 0; +} +.top-0 { + top: 0; +} +.z-10 { + z-index: 10; +} +.z-50 { + z-index: 50; +} +.mx-auto { + margin-left: auto; + margin-right: auto; +} +.my-1 { + margin-top: 0.25rem; +} +.mb-1, +.my-1 { + margin-bottom: 0.25rem; +} +.mb-2 { + margin-bottom: 0.5rem; +} +.ml-2 { + margin-left: 0.5rem; +} +.ml-3 { + margin-left: 0.75rem; +} +.ml-4 { + margin-left: 1rem; +} +.mr-1 { + margin-right: 0.25rem; +} +.mr-11 { + margin-right: 2.75rem; +} +.mt-1 { + margin-top: 0.25rem; +} +.mt-2 { + margin-top: 0.5rem; +} +.mt-24 { + margin-top: 6rem; +} +.mt-4 { + margin-top: 1rem; +} +.mt-auto { + margin-top: auto; +} +.block { + display: block; +} +.inline { + display: inline; +} +.flex { + display: flex; +} +.inline-flex { + display: inline-flex; +} +.grid { + display: grid; +} +.hidden { + display: none; +} +.h-10 { + height: 2.5rem; +} +.h-11 { + height: 2.75rem; +} +.h-2 { + height: 0.5rem; +} +.h-5 { + height: 1.25rem; +} +.h-80 { + height: 20rem; +} +.h-9 { + height: 2.25rem; +} +.h-\[20\%\] { + height: 20%; +} +.h-\[85vh\] { + height: 85vh; +} +.h-auto { + height: auto; +} +.h-fit { + height: -moz-fit-content; + height: fit-content; +} +.h-full { + height: 100%; +} +.h-screen { + height: 100vh; +} +.max-h-96 { + max-height: 24rem; +} +.max-h-\[250px\] { + max-height: 250px; +} +.w-10 { + width: 2.5rem; +} +.w-2 { + width: 0.5rem; +} +.w-5 { + width: 1.25rem; +} +.w-\[100px\] { + width: 100px; +} +.w-fit { + width: -moz-fit-content; + width: fit-content; +} +.w-full { + width: 100%; +} +.min-w-full { + min-width: 100%; +} +.flex-shrink { + flex-shrink: 1; +} +.grow { + flex-grow: 1; +} +.cursor-pointer { + cursor: pointer; +} +.cursor-text { + cursor: text; +} +.touch-none { + touch-action: none; +} +.select-none { + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; +} +.select-text { + -webkit-user-select: text; + -moz-user-select: text; + user-select: text; +} +.list-inside { + list-style-position: inside; +} +.list-decimal { + list-style-type: decimal; +} +.grid-cols-1 { + grid-template-columns: repeat(1, minmax(0, 1fr)); +} +.flex-col { + flex-direction: column; +} +.items-center { + align-items: center; +} +.justify-center { + justify-content: center; +} +.justify-between { + justify-content: space-between; +} +.justify-around { + justify-content: space-around; +} +.gap-1\.5 { + gap: 0.375rem; +} +.gap-2 { + gap: 0.5rem; +} +.gap-3 { + gap: 0.75rem; +} +.gap-4 { + gap: 1rem; +} +.gap-6 { + gap: 1.5rem; +} +.space-y-1\.5 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(0.375rem * (1 - var(--tw-space-y-reverse))); + margin-bottom: calc(0.375rem * var(--tw-space-y-reverse)); +} +.overflow-hidden { + overflow: hidden; +} +.overflow-y-scroll { + overflow-y: scroll; +} +.whitespace-nowrap { + white-space: nowrap; +} +.text-nowrap { + text-wrap: nowrap; +} +.rounded-full { + border-radius: 9999px; +} +.rounded-lg { + border-radius: var(--radius); +} +.rounded-md { + border-radius: calc(var(--radius) - 2px); +} +.rounded-sm { + border-radius: calc(var(--radius) - 4px); +} +.rounded-t-\[10px\] { + border-top-left-radius: 10px; + border-top-right-radius: 10px; +} +.border { + border-width: 1px; +} +.border-2 { + border-width: 2px; +} +.border-accent-foreground { + border-color: hsl(var(--accent-foreground)); +} +.border-green-400 { + --tw-border-opacity: 1; + border-color: rgb(74 222 128 / var(--tw-border-opacity)); +} +.border-input { + border-color: hsl(var(--input)); +} +.border-primary { + border-color: hsl(var(--primary)); +} +.bg-accent { + background-color: hsl(var(--accent)); +} +.bg-background { + background-color: hsl(var(--background)); +} +.bg-black\/80 { + background-color: rgba(0, 0, 0, 0.8); +} +.bg-card { + background-color: hsl(var(--card)); +} +.bg-destructive { + background-color: hsl(var(--destructive)); +} +.bg-muted { + background-color: hsl(var(--muted)); +} +.bg-primary { + background-color: hsl(var(--primary)); +} +.bg-secondary { + background-color: hsl(var(--secondary)); +} +.object-contain { + -o-object-fit: contain; + object-fit: contain; +} +.object-left { + -o-object-position: left; + object-position: left; +} +.p-1 { + padding: 0.25rem; +} +.p-3 { + padding: 0.75rem; +} +.p-4 { + padding: 1rem; +} +.p-6 { + padding: 1.5rem; +} +.px-3 { + padding-left: 0.75rem; + padding-right: 0.75rem; +} +.px-4 { + padding-left: 1rem; + padding-right: 1rem; +} +.px-8 { + padding-left: 2rem; + padding-right: 2rem; +} +.py-0 { + padding-top: 0; + padding-bottom: 0; +} +.py-1 { + padding-top: 0.25rem; + padding-bottom: 0.25rem; +} +.py-1\.5 { + padding-top: 0.375rem; + padding-bottom: 0.375rem; +} +.py-2 { + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} +.pb-0 { + padding-bottom: 0; +} +.pt-0 { + padding-top: 0; +} +.text-left { + text-align: left; +} +.text-center { + text-align: center; +} +.text-2xl { + font-size: 1.5rem; + line-height: 2rem; +} +.text-4xl { + font-size: 2.25rem; + line-height: 2.5rem; +} +.text-5xl { + font-size: 3rem; + line-height: 1; +} +.text-\[0\.6rem\] { + font-size: 0.6rem; +} +.text-lg { + font-size: 1.125rem; + line-height: 1.75rem; +} +.text-sm { + font-size: 0.875rem; + line-height: 1.25rem; +} +.text-xs { + font-size: 0.75rem; + line-height: 1rem; +} +.font-bold { + font-weight: 700; +} +.font-extrabold { + font-weight: 800; +} +.font-light { + font-weight: 300; +} +.font-medium { + font-weight: 500; +} +.font-semibold { + font-weight: 600; +} +.leading-none { + line-height: 1; +} +.tracking-tight { + letter-spacing: -0.025em; +} +.text-\[hsl\(280\2c 100\%\2c 70\%\)\] { + --tw-text-opacity: 1; + color: hsl(280 100% 70% / var(--tw-text-opacity)); +} +.text-accent-foreground { + color: hsl(var(--accent-foreground)); +} +.text-card-foreground { + color: hsl(var(--card-foreground)); +} +.text-destructive-foreground { + color: hsl(var(--destructive-foreground)); +} +.text-green-600 { + --tw-text-opacity: 1; + color: rgb(22 163 74 / var(--tw-text-opacity)); +} +.text-muted-foreground { + color: hsl(var(--muted-foreground)); +} +.text-primary { + color: hsl(var(--primary)); +} +.text-primary-foreground { + color: hsl(var(--primary-foreground)); +} +.text-secondary-foreground { + color: hsl(var(--secondary-foreground)); +} +.text-zinc-600 { + --tw-text-opacity: 1; + color: rgb(82 82 91 / var(--tw-text-opacity)); +} +.underline { + text-decoration-line: underline; +} +.underline-offset-4 { + text-underline-offset: 4px; +} +.shadow-sm { + --tw-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), + var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} +.outline { + outline-style: solid; +} +.ring-offset-background { + --tw-ring-offset-color: hsl(var(--background)); +} +.transition-all { + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 0.15s; +} +.transition-colors { + transition-property: color, background-color, border-color, + text-decoration-color, fill, stroke; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 0.15s; +} +@keyframes enter { + 0% { + opacity: var(--tw-enter-opacity, 1); + transform: translate3d( + var(--tw-enter-translate-x, 0), + var(--tw-enter-translate-y, 0), + 0 + ) + scale3d( + var(--tw-enter-scale, 1), + var(--tw-enter-scale, 1), + var(--tw-enter-scale, 1) + ) + rotate(var(--tw-enter-rotate, 0)); + } +} +@keyframes exit { + to { + opacity: var(--tw-exit-opacity, 1); + transform: translate3d( + var(--tw-exit-translate-x, 0), + var(--tw-exit-translate-y, 0), + 0 + ) + scale3d( + var(--tw-exit-scale, 1), + var(--tw-exit-scale, 1), + var(--tw-exit-scale, 1) + ) + rotate(var(--tw-exit-rotate, 0)); + } +} +.hover\:bg-accent:hover { + background-color: hsl(var(--accent)); +} +.hover\:bg-destructive\/90:hover { + background-color: hsl(var(--destructive) / 0.9); +} +.hover\:bg-primary\/90:hover { + background-color: hsl(var(--primary) / 0.9); +} +.hover\:bg-secondary\/80:hover { + background-color: hsl(var(--secondary) / 0.8); +} +.hover\:text-accent-foreground:hover { + color: hsl(var(--accent-foreground)); +} +.hover\:underline:hover { + text-decoration-line: underline; +} +.focus-visible\:outline-none:focus-visible { + outline: 2px solid transparent; + outline-offset: 2px; +} +.focus-visible\:ring-2:focus-visible { + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 + var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 + calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), + var(--tw-shadow, 0 0 #0000); +} +.focus-visible\:ring-ring:focus-visible { + --tw-ring-color: hsl(var(--ring)); +} +.focus-visible\:ring-offset-2:focus-visible { + --tw-ring-offset-width: 2px; +} +.disabled\:pointer-events-none:disabled { + pointer-events: none; +} +.disabled\:opacity-50:disabled { + opacity: 0.5; +} +.group:hover .group-hover\:bg-black { + --tw-bg-opacity: 1; + background-color: rgb(0 0 0 / var(--tw-bg-opacity)); +} +.data-\[state\=active\]\:bg-background[data-state="active"] { + background-color: hsl(var(--background)); +} +.data-\[state\=active\]\:text-foreground[data-state="active"] { + color: hsl(var(--foreground)); +} +.data-\[state\=active\]\:shadow-sm[data-state="active"] { + --tw-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), + var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} +.dark\:text-zinc-200:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(228 228 231 / var(--tw-text-opacity)); +} +@media not all and (min-width: 768px) { + .max-md\:hidden { + display: none; + } +} +@media (min-width: 640px) { + .sm\:grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .sm\:text-left { + text-align: left; + } + .sm\:text-\[5rem\] { + font-size: 5rem; + } +} +@media (min-width: 768px) { + .md\:fixed { + position: fixed; + } + .md\:left-0 { + left: 0; + } + .md\:top-0 { + top: 0; + } + .md\:mt-3 { + margin-top: 0.75rem; + } + .md\:h-20 { + height: 5rem; + } + .md\:h-\[100vh\] { + height: 100vh; + } + .md\:h-\[125vh\] { + height: 125vh; + } + .md\:h-\[150vh\] { + height: 150vh; + } + .md\:h-\[175vh\] { + height: 175vh; + } + .md\:h-\[200vh\] { + height: 200vh; + } + .md\:h-\[225vh\] { + height: 225vh; + } + .md\:h-\[250vh\] { + height: 250vh; + } + .md\:h-\[75vh\] { + height: 75vh; + } + .md\:h-full { + height: 100%; + } + .md\:w-20 { + width: 5rem; + } + .md\:w-24 { + width: 6rem; + } + .md\:w-auto { + width: auto; + } + .md\:grid-cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .md\:flex-col { + flex-direction: column; + } + .md\:justify-start { + justify-content: flex-start; + } + .md\:border-r { + border-right-width: 1px; + } + .group:hover .md\:group-hover\:inline-block { + display: inline-block; + } +} +@media (min-width: 1024px) { + .lg\:grid-cols-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + .lg\:text-6xl { + font-size: 3.75rem; + line-height: 1; + } +} diff --git a/src/app/_components/mangaPageView.tsx b/src/app/_components/mangaPageView.tsx index 4fc6ced..aa0de54 100644 --- a/src/app/_components/mangaPageView.tsx +++ b/src/app/_components/mangaPageView.tsx @@ -15,29 +15,14 @@ import WordReadingCard from "@/app/_components/wordReadingCard"; import useKeyPress from "@/app/_hooks/useKeyPress"; import { useLanguage } from "@/app/_hooks/useLanguage"; import { useZoomPercentage } from "@/app/_hooks/useZoomPercentage"; -import type { MokuroResponse } from "@/types/mokuro"; -import type { IchiranResponse, WordReadingForRender } from "@/types/ichiran"; import { Language, type MangaPageParams, type MangaPagePaths, } from "@/types/language"; import { cn } from "@/lib/ui/utils"; - -export const ZOOM_PERCENTAGES_VH_STYLES: Record = { - 75: "md:h-[75vh]", - 100: "md:h-[100vh]", - 125: "md:h-[125vh]", - 150: "md:h-[150vh]", - 175: "md:h-[175vh]", - 200: "md:h-[200vh]", - 225: "md:h-[225vh]", - 250: "md:h-[250vh]", -}; - -// Regular expression to match only special characters (excluding letters in any language or numbers) -const containsOnlySpecialCharacters = (input: string) => - /^[^\p{L}\p{N}]+$/u.test(input); +import { type MokuroResponseForRender } from "@/types/ui"; +import { ZOOM_PERCENTAGES_VH_STYLES } from "@/app/_components/navigationBar"; const MangaPageView = ({ mangaSlug, @@ -45,9 +30,11 @@ const MangaPageView = ({ pageNumber, ocr, paths, + onAddWordToAnki, }: MangaPageParams & { - ocr: MokuroResponse; + ocr: MokuroResponseForRender; paths: MangaPagePaths; + onAddWordToAnki: (blockIdx: number, wordIdx: number) => Promise; }) => { const router = useRouter(); @@ -67,98 +54,67 @@ const MangaPageView = ({ preload(paths.nextImgPath, { as: "image", fetchPriority: "high" }); - const [selectedSegmentation, setSelectedSegmentation] = - useState(null); - const [selectedWordId, setSelectedWordId] = useState(null); + const [selectedBlockIdx, setSelectedBlockIdx] = useState(null); + const [selectedWordIdx, setSelectedWordIdx] = useState(null); const { language } = useLanguage(); const { zoomPercentage } = useZoomPercentage(); - const wordRefs = useRef>(new Map()); + const wordRefs = useRef>(new Map()); - const scrollWordReadingIntoView = (wordId: string) => + const scrollWordReadingIntoView = (wordId: number) => wordRefs.current.get(wordId)?.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest", }); - const wordReadings = useMemo( - () => - selectedSegmentation - ? selectedSegmentation.flatMap( - (wordChain, wordChainIdx) => { - // Edge case when dictionary info available - if (typeof wordChain === "string") - return { - id: `chain-${wordChainIdx}`, - reading: wordChain, - text: wordChain, - isPunctuation: containsOnlySpecialCharacters(wordChain), - }; - - const [[words]] = wordChain; - return words.map((word, wordIdx) => { - const [romaji, wordAlternatives] = word; - - // If we have alternative readings, just take the first one. - const wordReading = - "alternative" in wordAlternatives - ? wordAlternatives.alternative[0]! - : wordAlternatives; - - return { - ...wordReading, - id: `chain-${wordChainIdx}-word-${wordIdx}`, - romaji, - text: wordReading.text!, - isPunctuation: false, - }; - }); - }, - ) - : null, - [selectedSegmentation], - ); + const wordReadings = + selectedBlockIdx !== null + ? ocr.blocks[selectedBlockIdx]?.wordReadings + : null; - const speechBubbles = ocr.blocks.map( - ({ box, font_size, vertical, lines, segmentation }, blockIdx) => { - // Find the ratio/percentages for positioning the speech bubbles - const left = `${(box[0] * 100) / ocr.img_width}%`; - const top = `${(box[1] * 100) / ocr.img_height}%`; - const width = `${((box[2] - box[0]) * 100) / ocr.img_width}%`; - const height = `${((box[3] - box[1]) * 100) / ocr.img_height}%`; - const fontSize = `${(font_size * zoomPercentage) / ocr.img_height}vh`; - - return ( -
{ - if (e.defaultPrevented) return; - e.preventDefault(); // Send message to parent components to not run their onClick handlers - setSelectedSegmentation(segmentation); - }} - > - {lines.map((line, lineIdx) => ( -

- {line} -

- ))} -
- ); - }, + const speechBubbles = useMemo( + () => + ocr.blocks.map(({ box, font_size, vertical, lines }, blockIdx) => { + // Find the ratio/percentages for positioning the speech bubbles + const left = `${(box[0] * 100) / ocr.img_width}%`; + const top = `${(box[1] * 100) / ocr.img_height}%`; + const width = `${((box[2] - box[0]) * 100) / ocr.img_width}%`; + const height = `${((box[3] - box[1]) * 100) / ocr.img_height}%`; + const fontSize = `${(font_size * zoomPercentage) / ocr.img_height}vh`; + + return ( +
{ + if (e.defaultPrevented) return; + e.preventDefault(); // Send message to parent components to not run their onClick handlers + setSelectedBlockIdx(blockIdx); + }} + > + {lines.map((line, lineIdx) => ( +

+ {line} +

+ ))} +
+ ); + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [zoomPercentage], ); return ( @@ -184,28 +140,28 @@ const MangaPageView = ({ { - setSelectedSegmentation(null); - setSelectedWordId(null); + setSelectedBlockIdx(null); + setSelectedWordIdx(null); }} >

- {wordReadings?.map(({ id, isPunctuation, text }) => ( + {wordReadings?.map(({ isPunctuation, text }, wordIdx) => ( { - scrollWordReadingIntoView(id); - setSelectedWordId(id); + scrollWordReadingIntoView(wordIdx); + setSelectedWordIdx(wordIdx); }} > {text} @@ -215,26 +171,31 @@ const MangaPageView = ({

- {wordReadings - ?.filter(({ isPunctuation }) => !isPunctuation) - .map((wordReading, idx) => ( + {wordReadings?.map((wordReading, wordIdx) => + wordReading.isPunctuation ? null : ( { - scrollWordReadingIntoView(wordReading.id); - setSelectedWordId(wordReading.id); + scrollWordReadingIntoView(wordIdx); + setSelectedWordIdx(wordIdx); + }} + onAddWordToAnki={() => { + console.log( + `Adding word to Anki selectedBlockIdx: ${selectedBlockIdx} wordIdx: ${wordIdx}`, + ); + void onAddWordToAnki(selectedBlockIdx!, wordIdx); }} ref={(node) => { - if (node) wordRefs.current.set(wordReading.id, node); - else wordRefs.current.delete(wordReading.id); + if (node) wordRefs.current.set(wordIdx, node); + else wordRefs.current.delete(wordIdx); }} className={cn( - selectedWordId === wordReading.id && - "border-accent-foreground", + selectedWordIdx === wordIdx && "border-accent-foreground", )} /> - ))} + ), + )}
diff --git a/src/app/_components/navigationBar.tsx b/src/app/_components/navigationBar.tsx index a7f82e0..7a2f3c7 100644 --- a/src/app/_components/navigationBar.tsx +++ b/src/app/_components/navigationBar.tsx @@ -14,14 +14,25 @@ import { Language, } from "@/types/language"; import { useLanguage } from "@/app/_hooks/useLanguage"; -import { useZoomPercentage } from "@/app/_hooks/useZoomPercentage"; -import { ZOOM_PERCENTAGES_VH_STYLES } from "./mangaPageView"; +import { + DEFAULT_ZOOM_PERCENTAGE, + useZoomPercentage, +} from "@/app/_hooks/useZoomPercentage"; +export const ZOOM_PERCENTAGES_VH_STYLES: Record = { + 75: "md:h-[75vh]", + 100: "md:h-[100vh]", + 125: "md:h-[125vh]", + 150: "md:h-[150vh]", + 175: "md:h-[175vh]", + 200: "md:h-[200vh]", + 225: "md:h-[225vh]", + 250: "md:h-[250vh]", +}; const ZOOM_PERCENTAGES = Object.keys(ZOOM_PERCENTAGES_VH_STYLES); const ZOOM_PERCENTAGES_REVERSED = ZOOM_PERCENTAGES.slice().reverse(); const MIN_PERCENTAGE = parseInt(ZOOM_PERCENTAGES[0]!); const MAX_PERCENTAGE = parseInt(ZOOM_PERCENTAGES[ZOOM_PERCENTAGES.length - 1]!); -export const DEFAULT_ZOOM_PERCENTAGE = 100; const PERCENTAGES_TO_SHOW = [DEFAULT_ZOOM_PERCENTAGE, MAX_PERCENTAGE].map( String, ); diff --git a/src/app/_components/wordReadingCard.tsx b/src/app/_components/wordReadingCard.tsx index ac6236e..1780bf3 100644 --- a/src/app/_components/wordReadingCard.tsx +++ b/src/app/_components/wordReadingCard.tsx @@ -1,10 +1,6 @@ -import React, { forwardRef } from "react"; -import type { - WordReadingForRender, - Conj, - Gloss, - WordReading, -} from "@/types/ichiran"; +import React, { forwardRef, type ReactNode } from "react"; +import Image from "next/image"; +import type { Conj, Gloss, WordReading } from "@/types/ichiran"; import { Card, CardContent, @@ -12,7 +8,9 @@ import { CardHeader, CardTitle, } from "@/app/_components/ui/card"; +import { Button } from "@/app/_components/ui/button"; import { cn } from "@/lib/ui/utils"; +import { type WordReadingForRender } from "@/types/ui"; function WordGloss({ gloss, @@ -61,7 +59,7 @@ function WordConj({ conj }: { conj?: Conj[] }) { ); } -function WordReadingContent({ +export function WordReadingContent({ wordReading, showReading = true, }: { @@ -98,10 +96,12 @@ function WordReadingContent({ type Props = { wordReading: WordReadingForRender; + onAddWordToAnki?: () => void; + sentence?: ReactNode; } & React.HTMLAttributes; export default forwardRef(function WordReadingCard( - { wordReading, className, ...props }, + { wordReading, onAddWordToAnki, sentence, className, ...props }, ref, ) { return ( @@ -110,11 +110,26 @@ export default forwardRef(function WordReadingCard( className={cn("max-h-96 select-text", className)} {...props} > - + + {onAddWordToAnki && ( + + )} {wordReading.reading} {wordReading.romaji && ( {wordReading.romaji} )} + {sentence} diff --git a/src/app/_hooks/useZoomPercentage.tsx b/src/app/_hooks/useZoomPercentage.tsx index fdfe71d..a3f589e 100644 --- a/src/app/_hooks/useZoomPercentage.tsx +++ b/src/app/_hooks/useZoomPercentage.tsx @@ -1,7 +1,8 @@ "use client"; import React, { createContext, useState } from "react"; -import { DEFAULT_ZOOM_PERCENTAGE } from "@/app/_components/navigationBar"; + +export const DEFAULT_ZOOM_PERCENTAGE = 100; type ZoomPercentageContextValue = { zoomPercentage: number; diff --git a/src/app/api/revalidate/route.ts b/src/app/api/revalidate/route.ts index fa02e8f..a0b009a 100644 --- a/src/app/api/revalidate/route.ts +++ b/src/app/api/revalidate/route.ts @@ -1,17 +1,17 @@ -import { revalidatePath } from 'next/cache' -import { type NextRequest } from 'next/server' +import { revalidatePath } from "next/cache"; +import { type NextRequest } from "next/server"; -export const dynamic = 'force-dynamic' +export const dynamic = "force-dynamic"; export function GET(request: NextRequest) { - const searchParams = request.nextUrl.searchParams - const path = searchParams.get('path') + const searchParams = request.nextUrl.searchParams; + const path = searchParams.get("path"); if (!path) { - return Response.json({ error: 'path is required' }, { status: 500 }) + return Response.json({ error: "path is required" }, { status: 500 }); } - revalidatePath(path) + revalidatePath(path); - return Response.json({ message: `Successfully revalidated path: ${path}` }) -} \ No newline at end of file + return Response.json({ message: `Successfully revalidated path: ${path}` }); +} diff --git a/src/app/read/[mangaSlug]/[volumeNumber]/[pageNumber]/page.tsx b/src/app/read/[mangaSlug]/[volumeNumber]/[pageNumber]/page.tsx index c7713da..fbd4b09 100644 --- a/src/app/read/[mangaSlug]/[volumeNumber]/[pageNumber]/page.tsx +++ b/src/app/read/[mangaSlug]/[volumeNumber]/[pageNumber]/page.tsx @@ -1,4 +1,5 @@ import MangaPageView from "@/app/_components/mangaPageView"; +import WordReadingCard from "@/app/_components/wordReadingCard"; import { getPageImagePath, getPageNextJsImagePath } from "@/lib/filepath/utils"; import { getPageOcr } from "@/lib/ocr/utils"; import { @@ -6,6 +7,19 @@ import { type MangaPagePaths, type MangaPageParams, } from "@/types/language"; +import { + type MokuroResponseForRender, + type WordReadingForRender, +} from "@/types/ui"; +import addWordToAnki from "@/lib/anki"; +import juice from "juice"; +import fs from "fs"; + +// Regular expression to match only special characters (excluding letters in any language or numbers) +const containsOnlySpecialCharacters = (input: string) => + /^[^\p{L}\p{N}]+$/u.test(input); + +const ANKI_CSS = fs.readFileSync(`${process.cwd()}/public/anki.css`, "utf8"); export default async function MangaPage({ params, @@ -13,8 +27,84 @@ export default async function MangaPage({ params: MangaPageParams; }) { const { mangaSlug, volumeNumber, pageNumber } = params; - const ocr = await getPageOcr(mangaSlug, volumeNumber, pageNumber); - if (!ocr) return
Page not found.
; + const _ocr = await getPageOcr(mangaSlug, volumeNumber, pageNumber); + if (!_ocr) return
Page not found.
; + + // Convert the OCR response to a format that can be rendered + const ocr: MokuroResponseForRender = { + ..._ocr, + blocks: _ocr.blocks.map((block) => ({ + ...block, + wordReadings: block.segmentation.flatMap( + (wordChain, wordChainIdx) => { + // Edge case when dictionary info available + if (typeof wordChain === "string") + return { + id: `chain-${wordChainIdx}`, + reading: wordChain, + text: wordChain, + isPunctuation: containsOnlySpecialCharacters(wordChain), + }; + + const [[words]] = wordChain; + return words.map((word, wordIdx) => { + const [romaji, wordAlternatives] = word; + + // If we have alternative readings, just take the first one. + const wordReading = + "alternative" in wordAlternatives + ? wordAlternatives.alternative[0]! + : wordAlternatives; + + return { + ...wordReading, + id: `chain-${wordChainIdx}-word-${wordIdx}`, + romaji, + text: wordReading.text!, + isPunctuation: false, + }; + }); + }, + ), + })), + }; + + // Add word to Anki + const onAddWordToAnki = async (blockIdx: number, wordIdx: number) => { + "use server"; + const block = ocr.blocks[blockIdx]; + const wordReading = block?.wordReadings[wordIdx]; + + if (!wordReading) { + console.error( + `Word reading not found. blockIdx: ${blockIdx}, wordIdx: ${wordIdx}`, + ); + return; + } + + // Color the word in the sentence + const sentenceWordTexts = block.wordReadings.map((reading) => reading.text); + const precedingText = sentenceWordTexts.slice(0, wordIdx).join(""); + const succeedingText = sentenceWordTexts + .slice(wordIdx + 1, wordIdx) + .join(""); + const sentence = ( +

+ {precedingText} + {wordReading.text} + {succeedingText} +

+ ); + + const ReactDOMServer = (await import("react-dom/server")).default; + const backOfCard = juice.inlineContent( + ReactDOMServer.renderToStaticMarkup( + , + ), + ANKI_CSS, + ); + await addWordToAnki(wordReading.text, backOfCard); + }; const pageNumberParsed = parseInt(pageNumber, 10); const paths: MangaPagePaths = { @@ -41,5 +131,12 @@ export default async function MangaPage({ ), }; - return ; + return ( + + ); } diff --git a/src/env.js b/src/env.js index 56e06b3..de2a5f7 100644 --- a/src/env.js +++ b/src/env.js @@ -8,6 +8,7 @@ export const env = createEnv({ */ server: { NODE_ENV: z.enum(["development", "test", "production"]), + ANKI_CONNECT_URL: z.string().default("http://localhost:8765"), }, /** @@ -28,6 +29,7 @@ export const env = createEnv({ runtimeEnv: { NODE_ENV: process.env.NODE_ENV, NEXT_PUBLIC_IMAGE_HOST: process.env.NEXT_PUBLIC_IMAGE_HOST, + ANKI_CONNECT_URL: process.env.ANKI_CONNECT_URL, }, /** * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially diff --git a/src/lib/anki/index.ts b/src/lib/anki/index.ts new file mode 100644 index 0000000..c74b67b --- /dev/null +++ b/src/lib/anki/index.ts @@ -0,0 +1,42 @@ +import { env } from "@/env"; + +async function addWordToAnki(Front: string, Back: string) { + const res = await fetch(env.ANKI_CONNECT_URL, { + method: "POST", + body: JSON.stringify({ + action: "addNote", + params: { + note: { + fields: { + Front, + Back, + }, + tags: ["louismollick"], + deckName: "Expression Mining", + modelName: "Basic", + options: { + allowDuplicate: true, + duplicateScope: "collection", + duplicateScopeOptions: { + deckName: null, + checkChildren: false, + checkAllModels: false, + }, + }, + }, + }, + version: 6, + }), + }); + + if (!res.ok) { + const reason = await res.text(); + const message = `HTTP error adding word to Anki: ${reason}`; + console.error(message); + throw new Error(message); + } + + console.log("Successfully added word to Anki:", Front); +} + +export default addWordToAnki; diff --git a/src/lib/filepath/utils.ts b/src/lib/filepath/utils.ts index fef7776..7c2386f 100644 --- a/src/lib/filepath/utils.ts +++ b/src/lib/filepath/utils.ts @@ -1,5 +1,5 @@ import { Language, type LanguageType } from "@/types/language"; -import { env } from '@/env'; +import { env } from "@/env"; export const VOLUME_PREFIX = "volume-"; diff --git a/src/types/ichiran.ts b/src/types/ichiran.ts index 57a39b2..f1bf121 100644 --- a/src/types/ichiran.ts +++ b/src/types/ichiran.ts @@ -31,14 +31,6 @@ export type WordReading = { suffix?: string; }; -// Format after processing for render -export type WordReadingForRender = WordReading & { - text: string; - id: string; // Used for uniquely referencing a word in speech bubble - romaji?: string; // I move this from the Word object to WordReading object, so it's easier to access. - isPunctuation: boolean; // Generated on render using regex -}; - export type Gloss = { pos: string; gloss: string; diff --git a/src/types/ui.ts b/src/types/ui.ts new file mode 100644 index 0000000..53cf3b7 --- /dev/null +++ b/src/types/ui.ts @@ -0,0 +1,18 @@ +import { type WordReading } from "./ichiran"; +import { type Block, type MokuroResponse } from "./mokuro"; + +// Format after processing for render +export type WordReadingForRender = WordReading & { + text: string; + id: string; // Used for uniquely referencing a word in speech bubble + romaji?: string; // I move this from the Word object to WordReading object, so it's easier to access. + isPunctuation: boolean; // Generated on render using regex +}; + +export type BlockForRender = Block & { + wordReadings: WordReadingForRender[]; +}; + +export type MokuroResponseForRender = MokuroResponse & { + blocks: BlockForRender[]; +};