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",
|
||||
"vitest": "^4.1.0",
|
||||
"vitest-browser-svelte": "^2.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"deepl-node": "^1.24.0",
|
||||
"valibot": "^1.3.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,13 @@ settings:
|
|||
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:
|
||||
'@eslint/compat':
|
||||
specifier: ^2.0.2
|
||||
|
|
@ -709,6 +716,10 @@ packages:
|
|||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
adm-zip@0.5.16:
|
||||
resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==}
|
||||
engines: {node: '>=12.0'}
|
||||
|
||||
ajv@6.14.0:
|
||||
resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==}
|
||||
|
||||
|
|
@ -731,6 +742,12 @@ packages:
|
|||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
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:
|
||||
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -761,6 +778,10 @@ packages:
|
|||
magicast:
|
||||
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:
|
||||
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
|
@ -802,6 +823,10 @@ packages:
|
|||
resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==}
|
||||
hasBin: true
|
||||
|
||||
combined-stream@1.0.8:
|
||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
commander@14.0.3:
|
||||
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
|
||||
engines: {node: '>=20'}
|
||||
|
|
@ -847,6 +872,10 @@ packages:
|
|||
deep-is@0.1.4:
|
||||
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:
|
||||
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -866,6 +895,10 @@ packages:
|
|||
defu@6.1.4:
|
||||
resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
|
||||
|
||||
delayed-stream@1.0.0:
|
||||
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
destr@2.0.5:
|
||||
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
|
||||
|
||||
|
|
@ -876,9 +909,29 @@ packages:
|
|||
resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==}
|
||||
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:
|
||||
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:
|
||||
resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -1002,6 +1055,23 @@ packages:
|
|||
flatted@3.4.2:
|
||||
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:
|
||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
|
|
@ -1015,6 +1085,14 @@ packages:
|
|||
function-bind@1.1.2:
|
||||
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:
|
||||
resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==}
|
||||
|
||||
|
|
@ -1038,10 +1116,22 @@ packages:
|
|||
resolution: {integrity: sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
gopd@1.2.0:
|
||||
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
has-flag@4.0.0:
|
||||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
||||
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:
|
||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -1149,9 +1239,25 @@ packages:
|
|||
lodash.merge@4.6.2:
|
||||
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:
|
||||
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:
|
||||
resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
|
@ -1304,6 +1410,10 @@ packages:
|
|||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
proxy-from-env@2.1.0:
|
||||
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
punycode@2.3.1:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
|
@ -1465,6 +1575,18 @@ packages:
|
|||
util-deprecate@1.0.2:
|
||||
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:
|
||||
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
|
|
@ -2169,6 +2291,8 @@ snapshots:
|
|||
|
||||
acorn@8.16.0: {}
|
||||
|
||||
adm-zip@0.5.16: {}
|
||||
|
||||
ajv@6.14.0:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
|
|
@ -2188,6 +2312,16 @@ snapshots:
|
|||
|
||||
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: {}
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
|
|
@ -2222,6 +2356,11 @@ snapshots:
|
|||
pkg-types: 2.3.0
|
||||
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: {}
|
||||
|
||||
chai@6.2.2: {}
|
||||
|
|
@ -2255,6 +2394,10 @@ snapshots:
|
|||
|
||||
color-support@1.1.3: {}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
dependencies:
|
||||
delayed-stream: 1.0.0
|
||||
|
||||
commander@14.0.3: {}
|
||||
|
||||
commondir@1.0.1: {}
|
||||
|
|
@ -2283,6 +2426,17 @@ snapshots:
|
|||
|
||||
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: {}
|
||||
|
||||
default-browser-id@5.0.1: {}
|
||||
|
|
@ -2296,14 +2450,37 @@ snapshots:
|
|||
|
||||
defu@6.1.4: {}
|
||||
|
||||
delayed-stream@1.0.0: {}
|
||||
|
||||
destr@2.0.5: {}
|
||||
|
||||
devalue@5.6.4: {}
|
||||
|
||||
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-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:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.27.4
|
||||
|
|
@ -2470,6 +2647,24 @@ snapshots:
|
|||
|
||||
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:
|
||||
optional: true
|
||||
|
||||
|
|
@ -2478,6 +2673,24 @@ snapshots:
|
|||
|
||||
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:
|
||||
dependencies:
|
||||
resolve-pkg-maps: 1.0.0
|
||||
|
|
@ -2501,8 +2714,16 @@ snapshots:
|
|||
|
||||
globals@17.4.0: {}
|
||||
|
||||
gopd@1.2.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:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
|
@ -2587,10 +2808,20 @@ snapshots:
|
|||
|
||||
lodash.merge@4.6.2: {}
|
||||
|
||||
loglevel@1.9.2: {}
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@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:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.4
|
||||
|
|
@ -2718,6 +2949,8 @@ snapshots:
|
|||
|
||||
prettier@3.8.1: {}
|
||||
|
||||
proxy-from-env@2.1.0: {}
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
rc9@2.1.2:
|
||||
|
|
@ -2895,6 +3128,12 @@ snapshots:
|
|||
|
||||
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):
|
||||
dependencies:
|
||||
esbuild: 0.27.4
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getArticleBffArticlesArticleIdGet } from '../../../../client/sdk.gen.ts';
|
||||
import { PUBLIC_API_BASE_URL } from '$env/static/public';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
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');
|
||||
}
|
||||
|
||||
const audioUrl = data.target_audio_url
|
||||
? `${PUBLIC_API_BASE_URL}/media/${data.target_audio_url}`
|
||||
: null;
|
||||
|
||||
return { article: data, audioUrl };
|
||||
return { article: data };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,81 +1,53 @@
|
|||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import type { PartsOfSpeechData } from '$lib/spacy/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 { article, audioUrl } = data;
|
||||
const { article } = data;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Body parsing: split into paragraphs → sentences → tokens
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
type WordToken = { type: 'word'; text: string; wordIdx: number };
|
||||
type OtherToken = { type: 'other'; text: string };
|
||||
type Token = WordToken | OtherToken;
|
||||
|
||||
type Sentence = {
|
||||
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[] = [];
|
||||
function extractParagraphsAndWordCount(text: PartsOfSpeechData): {
|
||||
paragraphs: Paragraph[];
|
||||
totalWords: number;
|
||||
} {
|
||||
const paragraphs: Paragraph[] = [{ index: 0, sentences: [] }];
|
||||
let wordIdx = 0;
|
||||
let sentenceIdx = 0;
|
||||
|
||||
for (const paraText of text.split(/\n\n+/)) {
|
||||
if (!paraText.trim()) continue;
|
||||
text.sentences.forEach((s) => {
|
||||
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 rawTokens = paraText.match(/[\p{L}\p{N}\u2019'''-]+|[^\p{L}\p{N}\u2019'''-]+/gu) ?? [];
|
||||
|
||||
const sentences: Sentence[] = [];
|
||||
let currentTokens: Token[] = [];
|
||||
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;
|
||||
}
|
||||
}
|
||||
const sentenceEndsWithNewLine = s.text.endsWith('\n');
|
||||
if (sentenceEndsWithNewLine) {
|
||||
paragraphs.push({ index: paragraphs.length, sentences: [] });
|
||||
} else {
|
||||
paragraphs[paragraphs.length - 1].sentences.push(sentence);
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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
|
||||
const allSentences: Array<{ idx: number; startWordIdx: number; endWordIdx: number }> = [];
|
||||
|
|
@ -91,18 +63,24 @@
|
|||
|
||||
type WordTiming = { start: number; end: number };
|
||||
|
||||
function extractWordTimings(transcript: Record<string, unknown> | null): WordTiming[] {
|
||||
function extractWordTimings(transcript: Transcript | null): WordTiming[] {
|
||||
if (!transcript) return [];
|
||||
try {
|
||||
const words = (transcript as any)?.results?.channels?.[0]?.alternatives?.[0]?.words;
|
||||
if (!Array.isArray(words)) return [];
|
||||
return words.map((w: any) => ({ start: Number(w.start), end: Number(w.end) }));
|
||||
const timings: WordTiming[] = [];
|
||||
for (const utterance of transcript.utterances) {
|
||||
for (const word of utterance.words) {
|
||||
timings.push({ start: word.start, end: word.end });
|
||||
}
|
||||
}
|
||||
return timings;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const wordTimings = extractWordTimings(article.target_body_transcript);
|
||||
const wordTimings = extractWordTimings(
|
||||
article.target_body_transcript as unknown as Transcript | null
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Reactive state
|
||||
|
|
@ -110,7 +88,8 @@
|
|||
|
||||
let audioEl: HTMLAudioElement | null = $state(null);
|
||||
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 translating = $state(false);
|
||||
|
||||
|
|
@ -149,21 +128,22 @@
|
|||
// Word click: fetch translation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async function handleWordClick(token: WordToken) {
|
||||
selectedWord = token;
|
||||
async function handleWordClick(token: SentenceToken, sentence: Sentence) {
|
||||
selectedSentenceToken = token;
|
||||
activeSentenceIdx = sentence.idx;
|
||||
translatedText = null;
|
||||
translating = true;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
text: token.text,
|
||||
target_language: article.source_language
|
||||
const result = await translateText({
|
||||
fromLanguage: article.target_language,
|
||||
toLanguage: article.source_language,
|
||||
sentenceText: sentence.text,
|
||||
text: token.text
|
||||
});
|
||||
const res = await fetch(`/app/translate?${params}`);
|
||||
if (res.ok) {
|
||||
const body = await res.json();
|
||||
translatedText = body.translated_text ?? null;
|
||||
}
|
||||
|
||||
translatedText = result.text;
|
||||
console.log({ result });
|
||||
} catch {
|
||||
translatedText = null;
|
||||
} finally {
|
||||
|
|
@ -172,7 +152,7 @@
|
|||
}
|
||||
|
||||
function closePanel() {
|
||||
selectedWord = null;
|
||||
selectedSentenceToken = null;
|
||||
translatedText = null;
|
||||
}
|
||||
|
||||
|
|
@ -207,7 +187,7 @@
|
|||
|
||||
<div class="page">
|
||||
<nav class="breadcrumb">
|
||||
<a href="/app/articles" class="link">← Articles</a>
|
||||
<a href={resolve('/app/articles')} class="link">← Articles</a>
|
||||
</nav>
|
||||
|
||||
<header class="article-header">
|
||||
|
|
@ -218,11 +198,11 @@
|
|||
<div class="article-layout">
|
||||
<!-- Main content: audio + body -->
|
||||
<div class="article-main">
|
||||
{#if audioUrl}
|
||||
{#if article.target_audio_url}
|
||||
<div class="audio-section">
|
||||
<audio
|
||||
bind:this={audioEl}
|
||||
src={audioUrl}
|
||||
src={article.target_audio_url}
|
||||
controls
|
||||
ontimeupdate={handleTimeUpdate}
|
||||
class="audio-player"
|
||||
|
|
@ -232,54 +212,16 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="article-body" lang={article.target_language}>
|
||||
{#each paragraphs as para}
|
||||
<p class="paragraph">
|
||||
{#each para.sentences as sentence}<span
|
||||
class="sentence"
|
||||
class:sentence--active={activeSentenceIdx === sentence.idx}
|
||||
>{#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>
|
||||
<TargetLanguageBody
|
||||
lang={article.source_language}
|
||||
{paragraphs}
|
||||
{activeSentenceIdx}
|
||||
onWordClick={handleWordClick}
|
||||
{selectedSentenceToken}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Translation panel (desktop: sticky sidebar; mobile: bottom drawer) -->
|
||||
<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>
|
||||
<TranslationPanel {closePanel} {selectedSentenceToken} {translatedText} {translating} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -287,7 +229,7 @@
|
|||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="drawer-backdrop"
|
||||
class:is-visible={selectedWord !== null}
|
||||
class:is-visible={selectedSentenceToken !== null}
|
||||
onclick={closePanel}
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
|
@ -381,69 +323,7 @@
|
|||
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) {
|
||||
.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 {
|
||||
display: none;
|
||||
}
|
||||
|
|
@ -451,26 +331,6 @@
|
|||
|
||||
/* --- 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);
|
||||
}
|
||||
|
||||
.drawer-backdrop {
|
||||
position: fixed;
|
||||
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 {
|
||||
to {
|
||||
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} */
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: adapter(),
|
||||
alias: {
|
||||
'@client': 'src/client/client.gen.ts'
|
||||
}
|
||||
},
|
||||
vitePlugin: {
|
||||
dynamicCompileOptions: ({ filename }) =>
|
||||
filename.includes('node_modules') ? undefined : { runes: true }
|
||||
}
|
||||
kit: {
|
||||
adapter: adapter(),
|
||||
experimental: {
|
||||
remoteFunctions: true
|
||||
},
|
||||
alias: {
|
||||
'@client': 'src/client/client.gen.ts'
|
||||
}
|
||||
},
|
||||
compilerOptions: {
|
||||
experimental: {
|
||||
async: true
|
||||
}
|
||||
},
|
||||
vitePlugin: {
|
||||
dynamicCompileOptions: ({ filename }) =>
|
||||
filename.includes('node_modules') ? undefined : { runes: true }
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
|
|
|||
Loading…
Reference in a new issue