feat: Add word-level translation panel to article page
- Add DeepL integration for word translation - Parse article body into sentences and tokens - Highlight active sentence and selected word - Show translation in a sticky panel or mobile drawer - Refactor audio timing and body parsing logic - Enable SvelteKit remote functions and async compiler options - Add dependencies: deepl-node, valibot
This commit is contained in:
parent
8252b6fcf0
commit
13911331a3
9 changed files with 647 additions and 297 deletions
|
|
@ -39,5 +39,9 @@
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
"vitest": "^4.1.0",
|
"vitest": "^4.1.0",
|
||||||
"vitest-browser-svelte": "^2.0.2"
|
"vitest-browser-svelte": "^2.0.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"deepl-node": "^1.24.0",
|
||||||
|
"valibot": "^1.3.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,13 @@ settings:
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
|
dependencies:
|
||||||
|
deepl-node:
|
||||||
|
specifier: ^1.24.0
|
||||||
|
version: 1.24.0
|
||||||
|
valibot:
|
||||||
|
specifier: ^1.3.1
|
||||||
|
version: 1.3.1(typescript@5.9.3)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@eslint/compat':
|
'@eslint/compat':
|
||||||
specifier: ^2.0.2
|
specifier: ^2.0.2
|
||||||
|
|
@ -709,6 +716,10 @@ packages:
|
||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
adm-zip@0.5.16:
|
||||||
|
resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==}
|
||||||
|
engines: {node: '>=12.0'}
|
||||||
|
|
||||||
ajv@6.14.0:
|
ajv@6.14.0:
|
||||||
resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==}
|
resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==}
|
||||||
|
|
||||||
|
|
@ -731,6 +742,12 @@ packages:
|
||||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
asynckit@0.4.0:
|
||||||
|
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||||
|
|
||||||
|
axios@1.14.0:
|
||||||
|
resolution: {integrity: sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==}
|
||||||
|
|
||||||
axobject-query@4.1.0:
|
axobject-query@4.1.0:
|
||||||
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
|
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
@ -761,6 +778,10 @@ packages:
|
||||||
magicast:
|
magicast:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
call-bind-apply-helpers@1.0.2:
|
||||||
|
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
callsites@3.1.0:
|
callsites@3.1.0:
|
||||||
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
@ -802,6 +823,10 @@ packages:
|
||||||
resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==}
|
resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
combined-stream@1.0.8:
|
||||||
|
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||||
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
commander@14.0.3:
|
commander@14.0.3:
|
||||||
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
|
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
|
|
@ -847,6 +872,10 @@ packages:
|
||||||
deep-is@0.1.4:
|
deep-is@0.1.4:
|
||||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||||
|
|
||||||
|
deepl-node@1.24.0:
|
||||||
|
resolution: {integrity: sha512-vZ9jUpzJRvFamgVOfm1LDy3YYJ7k8FhxtAX9whR92EFshLIP9JlYS0HFwXL5yYsfqzXdb/wssGRSWvR48t7nSg==}
|
||||||
|
engines: {node: '>=12.0'}
|
||||||
|
|
||||||
deepmerge@4.3.1:
|
deepmerge@4.3.1:
|
||||||
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
|
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
@ -866,6 +895,10 @@ packages:
|
||||||
defu@6.1.4:
|
defu@6.1.4:
|
||||||
resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
|
resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
|
||||||
|
|
||||||
|
delayed-stream@1.0.0:
|
||||||
|
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||||
|
engines: {node: '>=0.4.0'}
|
||||||
|
|
||||||
destr@2.0.5:
|
destr@2.0.5:
|
||||||
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
|
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
|
||||||
|
|
||||||
|
|
@ -876,9 +909,29 @@ packages:
|
||||||
resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==}
|
resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
dunder-proto@1.0.1:
|
||||||
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
es-define-property@1.0.1:
|
||||||
|
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
es-errors@1.3.0:
|
||||||
|
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
es-module-lexer@2.0.0:
|
es-module-lexer@2.0.0:
|
||||||
resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==}
|
resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==}
|
||||||
|
|
||||||
|
es-object-atoms@1.1.1:
|
||||||
|
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
es-set-tostringtag@2.1.0:
|
||||||
|
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
esbuild@0.27.4:
|
esbuild@0.27.4:
|
||||||
resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==}
|
resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -1002,6 +1055,23 @@ packages:
|
||||||
flatted@3.4.2:
|
flatted@3.4.2:
|
||||||
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
|
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
|
||||||
|
|
||||||
|
follow-redirects@1.15.11:
|
||||||
|
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
|
||||||
|
engines: {node: '>=4.0'}
|
||||||
|
peerDependencies:
|
||||||
|
debug: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
debug:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
form-data@3.0.4:
|
||||||
|
resolution: {integrity: sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==}
|
||||||
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
|
form-data@4.0.5:
|
||||||
|
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
|
||||||
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
fsevents@2.3.2:
|
fsevents@2.3.2:
|
||||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
|
|
@ -1015,6 +1085,14 @@ packages:
|
||||||
function-bind@1.1.2:
|
function-bind@1.1.2:
|
||||||
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
||||||
|
|
||||||
|
get-intrinsic@1.3.0:
|
||||||
|
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
get-proto@1.0.1:
|
||||||
|
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
get-tsconfig@4.13.6:
|
get-tsconfig@4.13.6:
|
||||||
resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==}
|
resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==}
|
||||||
|
|
||||||
|
|
@ -1038,10 +1116,22 @@ packages:
|
||||||
resolution: {integrity: sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==}
|
resolution: {integrity: sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
gopd@1.2.0:
|
||||||
|
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
has-flag@4.0.0:
|
has-flag@4.0.0:
|
||||||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
has-symbols@1.1.0:
|
||||||
|
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
has-tostringtag@1.0.2:
|
||||||
|
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
hasown@2.0.2:
|
hasown@2.0.2:
|
||||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
@ -1149,9 +1239,25 @@ packages:
|
||||||
lodash.merge@4.6.2:
|
lodash.merge@4.6.2:
|
||||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||||
|
|
||||||
|
loglevel@1.9.2:
|
||||||
|
resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==}
|
||||||
|
engines: {node: '>= 0.6.0'}
|
||||||
|
|
||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
|
|
||||||
|
math-intrinsics@1.1.0:
|
||||||
|
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
mime-db@1.52.0:
|
||||||
|
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
mime-types@2.1.35:
|
||||||
|
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
minimatch@10.2.4:
|
minimatch@10.2.4:
|
||||||
resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
|
resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
|
||||||
engines: {node: 18 || 20 || >=22}
|
engines: {node: 18 || 20 || >=22}
|
||||||
|
|
@ -1304,6 +1410,10 @@ packages:
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
proxy-from-env@2.1.0:
|
||||||
|
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
punycode@2.3.1:
|
punycode@2.3.1:
|
||||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
@ -1465,6 +1575,18 @@ packages:
|
||||||
util-deprecate@1.0.2:
|
util-deprecate@1.0.2:
|
||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
|
|
||||||
|
uuid@8.3.2:
|
||||||
|
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
valibot@1.3.1:
|
||||||
|
resolution: {integrity: sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg==}
|
||||||
|
peerDependencies:
|
||||||
|
typescript: '>=5'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
typescript:
|
||||||
|
optional: true
|
||||||
|
|
||||||
vite@7.3.1:
|
vite@7.3.1:
|
||||||
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
|
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
|
@ -2169,6 +2291,8 @@ snapshots:
|
||||||
|
|
||||||
acorn@8.16.0: {}
|
acorn@8.16.0: {}
|
||||||
|
|
||||||
|
adm-zip@0.5.16: {}
|
||||||
|
|
||||||
ajv@6.14.0:
|
ajv@6.14.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-deep-equal: 3.1.3
|
fast-deep-equal: 3.1.3
|
||||||
|
|
@ -2188,6 +2312,16 @@ snapshots:
|
||||||
|
|
||||||
assertion-error@2.0.1: {}
|
assertion-error@2.0.1: {}
|
||||||
|
|
||||||
|
asynckit@0.4.0: {}
|
||||||
|
|
||||||
|
axios@1.14.0:
|
||||||
|
dependencies:
|
||||||
|
follow-redirects: 1.15.11
|
||||||
|
form-data: 4.0.5
|
||||||
|
proxy-from-env: 2.1.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- debug
|
||||||
|
|
||||||
axobject-query@4.1.0: {}
|
axobject-query@4.1.0: {}
|
||||||
|
|
||||||
balanced-match@1.0.2: {}
|
balanced-match@1.0.2: {}
|
||||||
|
|
@ -2222,6 +2356,11 @@ snapshots:
|
||||||
pkg-types: 2.3.0
|
pkg-types: 2.3.0
|
||||||
rc9: 2.1.2
|
rc9: 2.1.2
|
||||||
|
|
||||||
|
call-bind-apply-helpers@1.0.2:
|
||||||
|
dependencies:
|
||||||
|
es-errors: 1.3.0
|
||||||
|
function-bind: 1.1.2
|
||||||
|
|
||||||
callsites@3.1.0: {}
|
callsites@3.1.0: {}
|
||||||
|
|
||||||
chai@6.2.2: {}
|
chai@6.2.2: {}
|
||||||
|
|
@ -2255,6 +2394,10 @@ snapshots:
|
||||||
|
|
||||||
color-support@1.1.3: {}
|
color-support@1.1.3: {}
|
||||||
|
|
||||||
|
combined-stream@1.0.8:
|
||||||
|
dependencies:
|
||||||
|
delayed-stream: 1.0.0
|
||||||
|
|
||||||
commander@14.0.3: {}
|
commander@14.0.3: {}
|
||||||
|
|
||||||
commondir@1.0.1: {}
|
commondir@1.0.1: {}
|
||||||
|
|
@ -2283,6 +2426,17 @@ snapshots:
|
||||||
|
|
||||||
deep-is@0.1.4: {}
|
deep-is@0.1.4: {}
|
||||||
|
|
||||||
|
deepl-node@1.24.0:
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 22.19.15
|
||||||
|
adm-zip: 0.5.16
|
||||||
|
axios: 1.14.0
|
||||||
|
form-data: 3.0.4
|
||||||
|
loglevel: 1.9.2
|
||||||
|
uuid: 8.3.2
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- debug
|
||||||
|
|
||||||
deepmerge@4.3.1: {}
|
deepmerge@4.3.1: {}
|
||||||
|
|
||||||
default-browser-id@5.0.1: {}
|
default-browser-id@5.0.1: {}
|
||||||
|
|
@ -2296,14 +2450,37 @@ snapshots:
|
||||||
|
|
||||||
defu@6.1.4: {}
|
defu@6.1.4: {}
|
||||||
|
|
||||||
|
delayed-stream@1.0.0: {}
|
||||||
|
|
||||||
destr@2.0.5: {}
|
destr@2.0.5: {}
|
||||||
|
|
||||||
devalue@5.6.4: {}
|
devalue@5.6.4: {}
|
||||||
|
|
||||||
dotenv@17.3.1: {}
|
dotenv@17.3.1: {}
|
||||||
|
|
||||||
|
dunder-proto@1.0.1:
|
||||||
|
dependencies:
|
||||||
|
call-bind-apply-helpers: 1.0.2
|
||||||
|
es-errors: 1.3.0
|
||||||
|
gopd: 1.2.0
|
||||||
|
|
||||||
|
es-define-property@1.0.1: {}
|
||||||
|
|
||||||
|
es-errors@1.3.0: {}
|
||||||
|
|
||||||
es-module-lexer@2.0.0: {}
|
es-module-lexer@2.0.0: {}
|
||||||
|
|
||||||
|
es-object-atoms@1.1.1:
|
||||||
|
dependencies:
|
||||||
|
es-errors: 1.3.0
|
||||||
|
|
||||||
|
es-set-tostringtag@2.1.0:
|
||||||
|
dependencies:
|
||||||
|
es-errors: 1.3.0
|
||||||
|
get-intrinsic: 1.3.0
|
||||||
|
has-tostringtag: 1.0.2
|
||||||
|
hasown: 2.0.2
|
||||||
|
|
||||||
esbuild@0.27.4:
|
esbuild@0.27.4:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@esbuild/aix-ppc64': 0.27.4
|
'@esbuild/aix-ppc64': 0.27.4
|
||||||
|
|
@ -2470,6 +2647,24 @@ snapshots:
|
||||||
|
|
||||||
flatted@3.4.2: {}
|
flatted@3.4.2: {}
|
||||||
|
|
||||||
|
follow-redirects@1.15.11: {}
|
||||||
|
|
||||||
|
form-data@3.0.4:
|
||||||
|
dependencies:
|
||||||
|
asynckit: 0.4.0
|
||||||
|
combined-stream: 1.0.8
|
||||||
|
es-set-tostringtag: 2.1.0
|
||||||
|
hasown: 2.0.2
|
||||||
|
mime-types: 2.1.35
|
||||||
|
|
||||||
|
form-data@4.0.5:
|
||||||
|
dependencies:
|
||||||
|
asynckit: 0.4.0
|
||||||
|
combined-stream: 1.0.8
|
||||||
|
es-set-tostringtag: 2.1.0
|
||||||
|
hasown: 2.0.2
|
||||||
|
mime-types: 2.1.35
|
||||||
|
|
||||||
fsevents@2.3.2:
|
fsevents@2.3.2:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -2478,6 +2673,24 @@ snapshots:
|
||||||
|
|
||||||
function-bind@1.1.2: {}
|
function-bind@1.1.2: {}
|
||||||
|
|
||||||
|
get-intrinsic@1.3.0:
|
||||||
|
dependencies:
|
||||||
|
call-bind-apply-helpers: 1.0.2
|
||||||
|
es-define-property: 1.0.1
|
||||||
|
es-errors: 1.3.0
|
||||||
|
es-object-atoms: 1.1.1
|
||||||
|
function-bind: 1.1.2
|
||||||
|
get-proto: 1.0.1
|
||||||
|
gopd: 1.2.0
|
||||||
|
has-symbols: 1.1.0
|
||||||
|
hasown: 2.0.2
|
||||||
|
math-intrinsics: 1.1.0
|
||||||
|
|
||||||
|
get-proto@1.0.1:
|
||||||
|
dependencies:
|
||||||
|
dunder-proto: 1.0.1
|
||||||
|
es-object-atoms: 1.1.1
|
||||||
|
|
||||||
get-tsconfig@4.13.6:
|
get-tsconfig@4.13.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
resolve-pkg-maps: 1.0.0
|
resolve-pkg-maps: 1.0.0
|
||||||
|
|
@ -2501,8 +2714,16 @@ snapshots:
|
||||||
|
|
||||||
globals@17.4.0: {}
|
globals@17.4.0: {}
|
||||||
|
|
||||||
|
gopd@1.2.0: {}
|
||||||
|
|
||||||
has-flag@4.0.0: {}
|
has-flag@4.0.0: {}
|
||||||
|
|
||||||
|
has-symbols@1.1.0: {}
|
||||||
|
|
||||||
|
has-tostringtag@1.0.2:
|
||||||
|
dependencies:
|
||||||
|
has-symbols: 1.1.0
|
||||||
|
|
||||||
hasown@2.0.2:
|
hasown@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
function-bind: 1.1.2
|
function-bind: 1.1.2
|
||||||
|
|
@ -2587,10 +2808,20 @@ snapshots:
|
||||||
|
|
||||||
lodash.merge@4.6.2: {}
|
lodash.merge@4.6.2: {}
|
||||||
|
|
||||||
|
loglevel@1.9.2: {}
|
||||||
|
|
||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
math-intrinsics@1.1.0: {}
|
||||||
|
|
||||||
|
mime-db@1.52.0: {}
|
||||||
|
|
||||||
|
mime-types@2.1.35:
|
||||||
|
dependencies:
|
||||||
|
mime-db: 1.52.0
|
||||||
|
|
||||||
minimatch@10.2.4:
|
minimatch@10.2.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 5.0.4
|
brace-expansion: 5.0.4
|
||||||
|
|
@ -2718,6 +2949,8 @@ snapshots:
|
||||||
|
|
||||||
prettier@3.8.1: {}
|
prettier@3.8.1: {}
|
||||||
|
|
||||||
|
proxy-from-env@2.1.0: {}
|
||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
|
|
||||||
rc9@2.1.2:
|
rc9@2.1.2:
|
||||||
|
|
@ -2895,6 +3128,12 @@ snapshots:
|
||||||
|
|
||||||
util-deprecate@1.0.2: {}
|
util-deprecate@1.0.2: {}
|
||||||
|
|
||||||
|
uuid@8.3.2: {}
|
||||||
|
|
||||||
|
valibot@1.3.1(typescript@5.9.3):
|
||||||
|
optionalDependencies:
|
||||||
|
typescript: 5.9.3
|
||||||
|
|
||||||
vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1):
|
vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.27.4
|
esbuild: 0.27.4
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { getArticleBffArticlesArticleIdGet } from '../../../../client/sdk.gen.ts';
|
import { getArticleBffArticlesArticleIdGet } from '../../../../client/sdk.gen.ts';
|
||||||
import { PUBLIC_API_BASE_URL } from '$env/static/public';
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||||
const { data, response } = await getArticleBffArticlesArticleIdGet({
|
const { data, response } = await getArticleBffArticlesArticleIdGet({
|
||||||
|
|
@ -13,9 +12,5 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||||
error(response.status === 404 ? 404 : 500, 'Article not found');
|
error(response.status === 404 ? 404 : 500, 'Article not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const audioUrl = data.target_audio_url
|
return { article: data };
|
||||||
? `${PUBLIC_API_BASE_URL}/media/${data.target_audio_url}`
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return { article: data, audioUrl };
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,81 +1,53 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import type { PartsOfSpeechData } from '$lib/spacy/types';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
import TargetLanguageBody from './TargetLanguageBody.svelte';
|
||||||
|
import type { Paragraph, Sentence, SentenceToken, Transcript } from './Transcript';
|
||||||
|
import TranslationPanel from './TranslationPanel.svelte';
|
||||||
|
import { translateText } from './translate.remote';
|
||||||
|
|
||||||
const { data }: PageProps = $props();
|
const { data }: PageProps = $props();
|
||||||
const { article, audioUrl } = data;
|
const { article } = data;
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Body parsing: split into paragraphs → sentences → tokens
|
// Body parsing: split into paragraphs → sentences → tokens
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
type WordToken = { type: 'word'; text: string; wordIdx: number };
|
function extractParagraphsAndWordCount(text: PartsOfSpeechData): {
|
||||||
type OtherToken = { type: 'other'; text: string };
|
paragraphs: Paragraph[];
|
||||||
type Token = WordToken | OtherToken;
|
totalWords: number;
|
||||||
|
} {
|
||||||
type Sentence = {
|
const paragraphs: Paragraph[] = [{ index: 0, sentences: [] }];
|
||||||
tokens: Token[];
|
|
||||||
idx: number; // global sentence index
|
|
||||||
startWordIdx: number;
|
|
||||||
endWordIdx: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Paragraph = { sentences: Sentence[] };
|
|
||||||
|
|
||||||
function parseBody(text: string): { paragraphs: Paragraph[]; totalWords: number } {
|
|
||||||
const paragraphs: Paragraph[] = [];
|
|
||||||
let wordIdx = 0;
|
let wordIdx = 0;
|
||||||
let sentenceIdx = 0;
|
let sentenceIdx = 0;
|
||||||
|
|
||||||
for (const paraText of text.split(/\n\n+/)) {
|
text.sentences.forEach((s) => {
|
||||||
if (!paraText.trim()) continue;
|
const sentence: Sentence = {
|
||||||
|
idx: sentenceIdx++,
|
||||||
|
text: s.text,
|
||||||
|
startWordIdx: wordIdx,
|
||||||
|
endWordIdx: wordIdx + s.tokens.length - 1,
|
||||||
|
tokens: s.tokens.map((t) => ({
|
||||||
|
...t,
|
||||||
|
idx: wordIdx++
|
||||||
|
})) as SentenceToken[]
|
||||||
|
};
|
||||||
|
|
||||||
// Split into alternating word / non-word tokens
|
const sentenceEndsWithNewLine = s.text.endsWith('\n');
|
||||||
const rawTokens = paraText.match(/[\p{L}\p{N}\u2019'''-]+|[^\p{L}\p{N}\u2019'''-]+/gu) ?? [];
|
if (sentenceEndsWithNewLine) {
|
||||||
|
paragraphs.push({ index: paragraphs.length, sentences: [] });
|
||||||
const sentences: Sentence[] = [];
|
} else {
|
||||||
let currentTokens: Token[] = [];
|
paragraphs[paragraphs.length - 1].sentences.push(sentence);
|
||||||
let startWordIdx = wordIdx;
|
|
||||||
let hasWord = false;
|
|
||||||
|
|
||||||
for (const raw of rawTokens) {
|
|
||||||
if (/[\p{L}\p{N}]/u.test(raw)) {
|
|
||||||
currentTokens.push({ type: 'word', text: raw, wordIdx: wordIdx++ });
|
|
||||||
hasWord = true;
|
|
||||||
} else {
|
|
||||||
currentTokens.push({ type: 'other', text: raw });
|
|
||||||
// Flush sentence on sentence-ending punctuation
|
|
||||||
if (hasWord && /[.!?]/.test(raw)) {
|
|
||||||
sentences.push({
|
|
||||||
tokens: [...currentTokens],
|
|
||||||
idx: sentenceIdx++,
|
|
||||||
startWordIdx,
|
|
||||||
endWordIdx: wordIdx - 1
|
|
||||||
});
|
|
||||||
currentTokens = [];
|
|
||||||
startWordIdx = wordIdx;
|
|
||||||
hasWord = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
if (currentTokens.length > 0) {
|
|
||||||
sentences.push({
|
|
||||||
tokens: currentTokens,
|
|
||||||
idx: sentenceIdx++,
|
|
||||||
startWordIdx,
|
|
||||||
endWordIdx: wordIdx - 1
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sentences.length > 0) {
|
|
||||||
paragraphs.push({ sentences });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { paragraphs, totalWords: wordIdx };
|
return { paragraphs, totalWords: wordIdx };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { paragraphs } = parseBody(article.target_body);
|
const { paragraphs } = extractParagraphsAndWordCount(
|
||||||
|
article.target_body_pos as Record<string, any> as PartsOfSpeechData
|
||||||
|
);
|
||||||
|
|
||||||
// Flat sentence list for O(n) audio-time lookup
|
// Flat sentence list for O(n) audio-time lookup
|
||||||
const allSentences: Array<{ idx: number; startWordIdx: number; endWordIdx: number }> = [];
|
const allSentences: Array<{ idx: number; startWordIdx: number; endWordIdx: number }> = [];
|
||||||
|
|
@ -91,18 +63,24 @@
|
||||||
|
|
||||||
type WordTiming = { start: number; end: number };
|
type WordTiming = { start: number; end: number };
|
||||||
|
|
||||||
function extractWordTimings(transcript: Record<string, unknown> | null): WordTiming[] {
|
function extractWordTimings(transcript: Transcript | null): WordTiming[] {
|
||||||
if (!transcript) return [];
|
if (!transcript) return [];
|
||||||
try {
|
try {
|
||||||
const words = (transcript as any)?.results?.channels?.[0]?.alternatives?.[0]?.words;
|
const timings: WordTiming[] = [];
|
||||||
if (!Array.isArray(words)) return [];
|
for (const utterance of transcript.utterances) {
|
||||||
return words.map((w: any) => ({ start: Number(w.start), end: Number(w.end) }));
|
for (const word of utterance.words) {
|
||||||
|
timings.push({ start: word.start, end: word.end });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return timings;
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const wordTimings = extractWordTimings(article.target_body_transcript);
|
const wordTimings = extractWordTimings(
|
||||||
|
article.target_body_transcript as unknown as Transcript | null
|
||||||
|
);
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Reactive state
|
// Reactive state
|
||||||
|
|
@ -110,7 +88,8 @@
|
||||||
|
|
||||||
let audioEl: HTMLAudioElement | null = $state(null);
|
let audioEl: HTMLAudioElement | null = $state(null);
|
||||||
let activeSentenceIdx = $state(-1);
|
let activeSentenceIdx = $state(-1);
|
||||||
let selectedWord: WordToken | null = $state(null);
|
let selectedSentenceToken: SentenceToken | null = $state(null);
|
||||||
|
let selectedSentence: Sentence | null = $state(null);
|
||||||
let translatedText: string | null = $state(null);
|
let translatedText: string | null = $state(null);
|
||||||
let translating = $state(false);
|
let translating = $state(false);
|
||||||
|
|
||||||
|
|
@ -149,21 +128,22 @@
|
||||||
// Word click: fetch translation
|
// Word click: fetch translation
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
async function handleWordClick(token: WordToken) {
|
async function handleWordClick(token: SentenceToken, sentence: Sentence) {
|
||||||
selectedWord = token;
|
selectedSentenceToken = token;
|
||||||
|
activeSentenceIdx = sentence.idx;
|
||||||
translatedText = null;
|
translatedText = null;
|
||||||
translating = true;
|
translating = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const result = await translateText({
|
||||||
text: token.text,
|
fromLanguage: article.target_language,
|
||||||
target_language: article.source_language
|
toLanguage: article.source_language,
|
||||||
|
sentenceText: sentence.text,
|
||||||
|
text: token.text
|
||||||
});
|
});
|
||||||
const res = await fetch(`/app/translate?${params}`);
|
|
||||||
if (res.ok) {
|
translatedText = result.text;
|
||||||
const body = await res.json();
|
console.log({ result });
|
||||||
translatedText = body.translated_text ?? null;
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
translatedText = null;
|
translatedText = null;
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -172,7 +152,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function closePanel() {
|
function closePanel() {
|
||||||
selectedWord = null;
|
selectedSentenceToken = null;
|
||||||
translatedText = null;
|
translatedText = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -207,7 +187,7 @@
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<nav class="breadcrumb">
|
<nav class="breadcrumb">
|
||||||
<a href="/app/articles" class="link">← Articles</a>
|
<a href={resolve('/app/articles')} class="link">← Articles</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<header class="article-header">
|
<header class="article-header">
|
||||||
|
|
@ -218,11 +198,11 @@
|
||||||
<div class="article-layout">
|
<div class="article-layout">
|
||||||
<!-- Main content: audio + body -->
|
<!-- Main content: audio + body -->
|
||||||
<div class="article-main">
|
<div class="article-main">
|
||||||
{#if audioUrl}
|
{#if article.target_audio_url}
|
||||||
<div class="audio-section">
|
<div class="audio-section">
|
||||||
<audio
|
<audio
|
||||||
bind:this={audioEl}
|
bind:this={audioEl}
|
||||||
src={audioUrl}
|
src={article.target_audio_url}
|
||||||
controls
|
controls
|
||||||
ontimeupdate={handleTimeUpdate}
|
ontimeupdate={handleTimeUpdate}
|
||||||
class="audio-player"
|
class="audio-player"
|
||||||
|
|
@ -232,54 +212,16 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="article-body" lang={article.target_language}>
|
<TargetLanguageBody
|
||||||
{#each paragraphs as para}
|
lang={article.source_language}
|
||||||
<p class="paragraph">
|
{paragraphs}
|
||||||
{#each para.sentences as sentence}<span
|
{activeSentenceIdx}
|
||||||
class="sentence"
|
onWordClick={handleWordClick}
|
||||||
class:sentence--active={activeSentenceIdx === sentence.idx}
|
{selectedSentenceToken}
|
||||||
>{#each sentence.tokens as token}{#if token.type === 'word'}<button
|
/>
|
||||||
class="word"
|
|
||||||
class:word--selected={selectedWord?.wordIdx === token.wordIdx}
|
|
||||||
onclick={() => handleWordClick(token)}>{token.text}</button
|
|
||||||
>{:else}{token.text}{/if}{/each}</span
|
|
||||||
>{/each}
|
|
||||||
</p>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Translation panel (desktop: sticky sidebar; mobile: bottom drawer) -->
|
<TranslationPanel {closePanel} {selectedSentenceToken} {translatedText} {translating} />
|
||||||
<aside
|
|
||||||
class="translation-panel"
|
|
||||||
class:is-open={selectedWord !== null}
|
|
||||||
aria-label="Word translation"
|
|
||||||
>
|
|
||||||
{#if selectedWord}
|
|
||||||
<div class="panel-header">
|
|
||||||
<p class="panel-word">{selectedWord.text}</p>
|
|
||||||
<button class="btn btn-ghost panel-close" onclick={closePanel} aria-label="Close panel">
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if translating}
|
|
||||||
<div class="panel-loading">
|
|
||||||
<div class="spinner" aria-hidden="true"></div>
|
|
||||||
<span>Translating…</span>
|
|
||||||
</div>
|
|
||||||
{:else if translatedText}
|
|
||||||
<p class="panel-translation">{translatedText}</p>
|
|
||||||
<button class="btn btn-secondary panel-save" disabled aria-disabled="true">
|
|
||||||
Add to flashcard
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<p class="panel-error">Could not load translation.</p>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<p class="panel-hint">Tap any word for a translation</p>
|
|
||||||
{/if}
|
|
||||||
</aside>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -287,7 +229,7 @@
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="drawer-backdrop"
|
class="drawer-backdrop"
|
||||||
class:is-visible={selectedWord !== null}
|
class:is-visible={selectedSentenceToken !== null}
|
||||||
onclick={closePanel}
|
onclick={closePanel}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
></div>
|
></div>
|
||||||
|
|
@ -381,69 +323,7 @@
|
||||||
accent-color: var(--color-primary);
|
accent-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Article body --- */
|
|
||||||
.article-body {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.paragraph {
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: var(--text-body-xl);
|
|
||||||
line-height: 2;
|
|
||||||
color: var(--color-on-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sentence: highlighted when audio is at that point */
|
|
||||||
.sentence {
|
|
||||||
border-radius: var(--radius-xs);
|
|
||||||
transition: background-color var(--duration-normal) var(--ease-standard);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sentence--active {
|
|
||||||
background-color: var(--color-primary-container);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Word buttons --- */
|
|
||||||
.word {
|
|
||||||
display: inline;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 0 0.05em;
|
|
||||||
margin: 0;
|
|
||||||
font: inherit;
|
|
||||||
color: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: var(--radius-xs);
|
|
||||||
transition:
|
|
||||||
background-color var(--duration-fast) var(--ease-standard),
|
|
||||||
color var(--duration-fast) var(--ease-standard);
|
|
||||||
}
|
|
||||||
|
|
||||||
.word:hover {
|
|
||||||
background-color: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.word--selected {
|
|
||||||
background-color: color-mix(in srgb, var(--color-primary) 20%, transparent);
|
|
||||||
color: var(--color-primary);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Translation panel: Desktop (sticky sidebar) --- */
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.translation-panel {
|
|
||||||
position: sticky;
|
|
||||||
top: var(--space-6);
|
|
||||||
background-color: var(--color-surface-container-lowest);
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
padding: var(--space-5);
|
|
||||||
min-height: 16rem;
|
|
||||||
box-shadow: var(--shadow-tonal-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawer-backdrop {
|
.drawer-backdrop {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
@ -451,26 +331,6 @@
|
||||||
|
|
||||||
/* --- Translation panel: Mobile (bottom drawer) --- */
|
/* --- Translation panel: Mobile (bottom drawer) --- */
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.translation-panel {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 300;
|
|
||||||
background-color: var(--color-surface-container-lowest);
|
|
||||||
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
|
|
||||||
padding: var(--space-5) var(--space-5) calc(var(--space-5) + env(safe-area-inset-bottom));
|
|
||||||
max-height: 55vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
transform: translateY(100%);
|
|
||||||
transition: transform var(--duration-slow) var(--ease-standard);
|
|
||||||
box-shadow: 0 -8px 32px color-mix(in srgb, var(--color-on-surface) 8%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.translation-panel.is-open {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawer-backdrop {
|
.drawer-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|
@ -487,78 +347,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Panel internals --- */
|
|
||||||
.panel-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-2);
|
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-word {
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: var(--text-headline-md);
|
|
||||||
font-weight: var(--weight-semibold);
|
|
||||||
line-height: var(--leading-snug);
|
|
||||||
color: var(--color-on-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-close {
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: var(--color-on-surface-variant);
|
|
||||||
font-size: var(--text-body-lg);
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-translation {
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: var(--text-body-lg);
|
|
||||||
line-height: var(--leading-relaxed);
|
|
||||||
color: var(--color-on-surface-variant);
|
|
||||||
font-style: italic;
|
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-save {
|
|
||||||
width: 100%;
|
|
||||||
padding-block: var(--space-2);
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-hint {
|
|
||||||
font-family: var(--font-label);
|
|
||||||
font-size: var(--text-body-sm);
|
|
||||||
color: var(--color-on-surface-variant);
|
|
||||||
text-align: center;
|
|
||||||
padding: var(--space-4) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-error {
|
|
||||||
font-family: var(--font-label);
|
|
||||||
font-size: var(--text-body-sm);
|
|
||||||
color: var(--color-on-surface-variant);
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-loading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
font-family: var(--font-label);
|
|
||||||
font-size: var(--text-body-sm);
|
|
||||||
color: var(--color-on-surface-variant);
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
border: 2px solid var(--color-outline-variant);
|
|
||||||
border-top-color: var(--color-primary);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.9s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
to {
|
to {
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Paragraph, Sentence, SentenceToken } from './Transcript';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
paragraphs: Paragraph[];
|
||||||
|
activeSentenceIdx: number;
|
||||||
|
selectedSentenceToken: SentenceToken | null;
|
||||||
|
onWordClick: (token: SentenceToken, sentence: Sentence) => void;
|
||||||
|
lang: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { paragraphs, activeSentenceIdx, selectedSentenceToken, onWordClick, lang }: Props =
|
||||||
|
$props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="article-body" {lang}>
|
||||||
|
{#each paragraphs as para (para.index)}
|
||||||
|
<p class="paragraph">
|
||||||
|
{#each para.sentences as sentence (sentence.idx)}
|
||||||
|
<span class="sentence" class:sentence--active={activeSentenceIdx === sentence.idx}>
|
||||||
|
{#each sentence.tokens as token (token.idx)}
|
||||||
|
{#if !token.is_punct}
|
||||||
|
<button
|
||||||
|
class="word"
|
||||||
|
class:word--selected={selectedSentenceToken?.idx === token.idx}
|
||||||
|
onclick={() => onWordClick(token, sentence)}
|
||||||
|
>
|
||||||
|
{token.text}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
{token.text}
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.article-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: var(--text-body-xl);
|
||||||
|
line-height: 2;
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sentence: highlighted when audio is at that point */
|
||||||
|
.sentence {
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
transition: background-color var(--duration-normal) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sentence--active {
|
||||||
|
background-color: var(--color-primary-container);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Word buttons --- */
|
||||||
|
.word {
|
||||||
|
display: inline;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0 0.1em;
|
||||||
|
margin: 0;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
transition:
|
||||||
|
background-color var(--duration-fast) var(--ease-standard),
|
||||||
|
color var(--duration-fast) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.word:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.word--selected {
|
||||||
|
background-color: color-mix(in srgb, var(--color-primary) 20%, transparent);
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
40
frontend/src/routes/app/articles/[article_id]/Transcript.ts
Normal file
40
frontend/src/routes/app/articles/[article_id]/Transcript.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import type { PartOfSpeechToken } from '$lib/spacy/types';
|
||||||
|
|
||||||
|
export interface Transcript {
|
||||||
|
channels: {
|
||||||
|
alternatives: {
|
||||||
|
end: number;
|
||||||
|
word: string;
|
||||||
|
start: number;
|
||||||
|
confidence: number;
|
||||||
|
punctuated_word: string;
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
|
utterances: {
|
||||||
|
id: string;
|
||||||
|
transcript: string;
|
||||||
|
confidence: number;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
words: {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
word: string;
|
||||||
|
confidence: number;
|
||||||
|
punctuated_word: string;
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SentenceToken extends PartOfSpeechToken {
|
||||||
|
idx: number; // index of the token in the entire article
|
||||||
|
}
|
||||||
|
export type Sentence = {
|
||||||
|
tokens: SentenceToken[];
|
||||||
|
text: string;
|
||||||
|
idx: number; // global sentence index
|
||||||
|
startWordIdx: number;
|
||||||
|
endWordIdx: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Paragraph = { index: number; sentences: Sentence[] };
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { SentenceToken } from './Transcript';
|
||||||
|
|
||||||
|
let {
|
||||||
|
selectedSentenceToken = null,
|
||||||
|
translating = false,
|
||||||
|
translatedText = '',
|
||||||
|
closePanel
|
||||||
|
}: {
|
||||||
|
selectedSentenceToken?: SentenceToken | null;
|
||||||
|
translating?: boolean;
|
||||||
|
translatedText: string | null;
|
||||||
|
closePanel: () => void;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Translation panel (desktop: sticky sidebar; mobile: bottom drawer) -->
|
||||||
|
<aside
|
||||||
|
class="translation-panel"
|
||||||
|
class:is-open={selectedSentenceToken !== null}
|
||||||
|
aria-label="Word translation"
|
||||||
|
>
|
||||||
|
{#if selectedSentenceToken}
|
||||||
|
<div class="panel-header">
|
||||||
|
<p class="panel-word">{selectedSentenceToken.text}</p>
|
||||||
|
<button class="btn btn-ghost panel-close" onclick={closePanel} aria-label="Close panel">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if translating}
|
||||||
|
<div class="panel-loading">
|
||||||
|
<div class="spinner" aria-hidden="true"></div>
|
||||||
|
<span>Translating…</span>
|
||||||
|
</div>
|
||||||
|
{:else if translatedText}
|
||||||
|
<p class="panel-translation">{translatedText}</p>
|
||||||
|
<button class="btn btn-secondary panel-save" disabled aria-disabled="true">
|
||||||
|
Add to flashcard
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<p class="panel-error">Could not load translation.</p>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<p class="panel-hint">Tap any word for a translation</p>
|
||||||
|
{/if}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-word {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-headline-md);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-close {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
font-size: var(--text-body-lg);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-translation {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: var(--text-body-lg);
|
||||||
|
line-height: var(--leading-relaxed);
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
font-style: italic;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-save {
|
||||||
|
width: 100%;
|
||||||
|
padding-block: var(--space-2);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-hint {
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-body-sm);
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-4) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-error {
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-body-sm);
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-body-sm);
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border: 2px solid var(--color-outline-variant);
|
||||||
|
border-top-color: var(--color-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
/* --- Translation panel: Desktop (sticky sidebar) --- */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.translation-panel {
|
||||||
|
position: sticky;
|
||||||
|
top: var(--space-6);
|
||||||
|
background-color: var(--color-surface-container-lowest);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: var(--space-5);
|
||||||
|
min-height: 16rem;
|
||||||
|
box-shadow: var(--shadow-tonal-sm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Translation panel: Mobile (bottom drawer) --- */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.translation-panel {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 300;
|
||||||
|
background-color: var(--color-surface-container-lowest);
|
||||||
|
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
|
||||||
|
padding: var(--space-5) var(--space-5) calc(var(--space-5) + env(safe-area-inset-bottom));
|
||||||
|
max-height: 55vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
transform: translateY(100%);
|
||||||
|
transition: transform var(--duration-slow) var(--ease-standard);
|
||||||
|
box-shadow: 0 -8px 32px color-mix(in srgb, var(--color-on-surface) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-panel.is-open {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { query } from '$app/server';
|
||||||
|
import * as v from 'valibot';
|
||||||
|
import * as deepl from 'deepl-node';
|
||||||
|
import { PRIVATE_DEEPL_API_KEY } from '$env/static/private';
|
||||||
|
|
||||||
|
const deeplClient = new deepl.DeepLClient(PRIVATE_DEEPL_API_KEY);
|
||||||
|
|
||||||
|
export const translateText = query(
|
||||||
|
v.object({
|
||||||
|
text: v.string(),
|
||||||
|
fromLanguage: v.string(),
|
||||||
|
toLanguage: v.string(),
|
||||||
|
sentenceText: v.string()
|
||||||
|
}),
|
||||||
|
async ({ fromLanguage, sentenceText, text, toLanguage }) => {
|
||||||
|
const safeToLanguage = toLanguage === 'en' ? 'en-gb' : toLanguage;
|
||||||
|
return await deeplClient.translateText(
|
||||||
|
text,
|
||||||
|
fromLanguage as deepl.SourceLanguageCode,
|
||||||
|
safeToLanguage as deepl.TargetLanguageCode,
|
||||||
|
{ context: sentenceText }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
@ -2,16 +2,24 @@ import adapter from '@sveltejs/adapter-node';
|
||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
kit: {
|
kit: {
|
||||||
adapter: adapter(),
|
adapter: adapter(),
|
||||||
alias: {
|
experimental: {
|
||||||
'@client': 'src/client/client.gen.ts'
|
remoteFunctions: true
|
||||||
}
|
},
|
||||||
},
|
alias: {
|
||||||
vitePlugin: {
|
'@client': 'src/client/client.gen.ts'
|
||||||
dynamicCompileOptions: ({ filename }) =>
|
}
|
||||||
filename.includes('node_modules') ? undefined : { runes: true }
|
},
|
||||||
}
|
compilerOptions: {
|
||||||
|
experimental: {
|
||||||
|
async: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
vitePlugin: {
|
||||||
|
dynamicCompileOptions: ({ filename }) =>
|
||||||
|
filename.includes('node_modules') ? undefined : { runes: true }
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue