Compare commits

..

No commits in common. "c65509b53f3c2296db6f9db8d02ceb1c11a69df8" and "8252b6fcf031fc160417474fcb0087a4857deb66" have entirely different histories.

19 changed files with 489 additions and 836 deletions

View file

@ -1,3 +0,0 @@
PUBLIC_API_BASE_URL=http://localhost:8000
PRIVATE_JWT_SECRET=changeme
PRIVATE_DEEPL_API_KEY=changeme

View file

@ -39,9 +39,5 @@
"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"
} }
} }

View file

@ -7,13 +7,6 @@ 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
@ -716,10 +709,6 @@ 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==}
@ -742,12 +731,6 @@ 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'}
@ -778,10 +761,6 @@ 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'}
@ -823,10 +802,6 @@ 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'}
@ -872,10 +847,6 @@ 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'}
@ -895,10 +866,6 @@ 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==}
@ -909,29 +876,9 @@ 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'}
@ -1055,23 +1002,6 @@ 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}
@ -1085,14 +1015,6 @@ 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==}
@ -1116,22 +1038,10 @@ 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'}
@ -1239,25 +1149,9 @@ 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}
@ -1410,10 +1304,6 @@ 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'}
@ -1575,18 +1465,6 @@ 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}
@ -2291,8 +2169,6 @@ 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
@ -2312,16 +2188,6 @@ 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: {}
@ -2356,11 +2222,6 @@ 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: {}
@ -2394,10 +2255,6 @@ 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: {}
@ -2426,17 +2283,6 @@ 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: {}
@ -2450,37 +2296,14 @@ 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
@ -2647,24 +2470,6 @@ 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
@ -2673,24 +2478,6 @@ 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
@ -2714,16 +2501,8 @@ 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
@ -2808,20 +2587,10 @@ 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
@ -2949,8 +2718,6 @@ 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:
@ -3128,12 +2895,6 @@ 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

View file

@ -1,3 +1,2 @@
# Makes a request to localhost:8000/openapi.json and saves the result in ./src/lib/openapi.json # Makes a request to localhost:8000/openapi.json and saves the result in ./src/lib/openapi.json
curl -o ./src/lib/openapi.json http://localhost:8000/openapi.json curl -o ./src/lib/openapi.json http://localhost:8000/openapi.json
pnpm openapi-ts:gen

View file

@ -28,12 +28,6 @@ export type ArticleDetail = {
* Source Body * Source Body
*/ */
source_body: string; source_body: string;
/**
* Source Body Pos
*/
source_body_pos: {
[key: string]: unknown;
};
/** /**
* Target Language * Target Language
*/ */
@ -59,7 +53,7 @@ export type ArticleDetail = {
*/ */
target_body_pos: { target_body_pos: {
[key: string]: unknown; [key: string]: unknown;
}; } | null;
/** /**
* Target Body Transcript * Target Body Transcript
*/ */
@ -177,9 +171,17 @@ export type JobResponse = {
*/ */
status: string; status: string;
/** /**
* Translated Article Id * Source Language
*/ */
translated_article_id?: string | null; source_language: string;
/**
* Target Language
*/
target_language: string;
/**
* Complexity Level
*/
complexity_level: string;
/** /**
* Created At * Created At
*/ */
@ -192,6 +194,40 @@ export type JobResponse = {
* Completed At * Completed At
*/ */
completed_at?: string | null; completed_at?: string | null;
/**
* Generated Text
*/
generated_text?: string | null;
/**
* Generated Text Pos
*/
generated_text_pos?: {
[key: string]: unknown;
} | null;
/**
* Translated Text
*/
translated_text?: string | null;
/**
* Translated Text Pos
*/
translated_text_pos?: {
[key: string]: unknown;
} | null;
/**
* Input Summary
*/
input_summary?: string | null;
/**
* Audio Url
*/
audio_url?: string | null;
/**
* Audio Transcript
*/
audio_transcript?: {
[key: string]: unknown;
} | null;
/** /**
* Error Message * Error Message
*/ */

View file

@ -1,9 +0,0 @@
const languageNames: Record<string, string> = {
en: 'English',
fr: 'French',
es: 'Spanish',
it: 'Italian',
de: 'German'
};
export const formatLanguage = (code: string) => languageNames[code] ?? code.toUpperCase();

View file

@ -1 +0,0 @@
export { formatLanguage } from './formatLanguage';

File diff suppressed because one or more lines are too long

View file

@ -1,19 +0,0 @@
export interface PartsOfSpeechData {
language: string;
sentences: {
text: string;
tokens: PartOfSpeechToken[];
}[];
}
export interface PartOfSpeechToken {
dep: string;
pos: string;
tag: string;
text: string;
type: null | string;
lemma: string;
is_stop: boolean;
is_alpha: boolean;
is_punct: boolean;
}

View file

@ -1,6 +1,7 @@
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({
@ -12,5 +13,9 @@ 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');
} }
return { article: data }; const audioUrl = data.target_audio_url
? `${PUBLIC_API_BASE_URL}/media/${data.target_audio_url}`
: null;
return { article: data, audioUrl };
}; };

View file

@ -1,53 +1,81 @@
<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 } = data; const { article, audioUrl } = data;
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Body parsing: split into paragraphs → sentences → tokens // Body parsing: split into paragraphs → sentences → tokens
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
function extractParagraphsAndWordCount(text: PartsOfSpeechData): { type WordToken = { type: 'word'; text: string; wordIdx: number };
paragraphs: Paragraph[]; type OtherToken = { type: 'other'; text: string };
totalWords: number; type Token = WordToken | OtherToken;
} {
const paragraphs: Paragraph[] = [{ index: 0, sentences: [] }]; 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[] = [];
let wordIdx = 0; let wordIdx = 0;
let sentenceIdx = 0; let sentenceIdx = 0;
text.sentences.forEach((s) => { for (const paraText of text.split(/\n\n+/)) {
const sentence: Sentence = { if (!paraText.trim()) continue;
idx: sentenceIdx++,
text: s.text,
startWordIdx: wordIdx,
endWordIdx: wordIdx + s.tokens.length - 1,
tokens: s.tokens.map((t) => ({
...t,
idx: wordIdx++
})) as SentenceToken[]
};
const sentenceEndsWithNewLine = s.text.endsWith('\n'); // Split into alternating word / non-word tokens
if (sentenceEndsWithNewLine) { const rawTokens = paraText.match(/[\p{L}\p{N}\u2019'''-]+|[^\p{L}\p{N}\u2019'''-]+/gu) ?? [];
paragraphs.push({ index: paragraphs.length, sentences: [] });
} else { const sentences: Sentence[] = [];
paragraphs[paragraphs.length - 1].sentences.push(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;
}
}
} }
});
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 } = extractParagraphsAndWordCount( const { paragraphs } = parseBody(article.target_body);
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 }> = [];
@ -63,24 +91,18 @@
type WordTiming = { start: number; end: number }; type WordTiming = { start: number; end: number };
function extractWordTimings(transcript: Transcript | null): WordTiming[] { function extractWordTimings(transcript: Record<string, unknown> | null): WordTiming[] {
if (!transcript) return []; if (!transcript) return [];
try { try {
const timings: WordTiming[] = []; const words = (transcript as any)?.results?.channels?.[0]?.alternatives?.[0]?.words;
for (const utterance of transcript.utterances) { if (!Array.isArray(words)) return [];
for (const word of utterance.words) { return words.map((w: any) => ({ start: Number(w.start), end: Number(w.end) }));
timings.push({ start: word.start, end: word.end });
}
}
return timings;
} catch { } catch {
return []; return [];
} }
} }
const wordTimings = extractWordTimings( const wordTimings = extractWordTimings(article.target_body_transcript);
article.target_body_transcript as unknown as Transcript | null
);
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Reactive state // Reactive state
@ -88,8 +110,7 @@
let audioEl: HTMLAudioElement | null = $state(null); let audioEl: HTMLAudioElement | null = $state(null);
let activeSentenceIdx = $state(-1); let activeSentenceIdx = $state(-1);
let selectedSentenceToken: SentenceToken | null = $state(null); let selectedWord: WordToken | 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);
@ -128,22 +149,21 @@
// Word click: fetch translation // Word click: fetch translation
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
async function handleWordClick(token: SentenceToken, sentence: Sentence) { async function handleWordClick(token: WordToken) {
selectedSentenceToken = token; selectedWord = token;
activeSentenceIdx = sentence.idx;
translatedText = null; translatedText = null;
translating = true; translating = true;
try { try {
const result = await translateText({ const params = new URLSearchParams({
fromLanguage: article.target_language, text: token.text,
toLanguage: article.source_language, target_language: article.source_language
sentenceText: sentence.text,
text: token.text
}); });
const res = await fetch(`/app/translate?${params}`);
translatedText = result.text; if (res.ok) {
console.log({ result }); const body = await res.json();
translatedText = body.translated_text ?? null;
}
} catch { } catch {
translatedText = null; translatedText = null;
} finally { } finally {
@ -152,7 +172,7 @@
} }
function closePanel() { function closePanel() {
selectedSentenceToken = null; selectedWord = null;
translatedText = null; translatedText = null;
} }
@ -187,7 +207,7 @@
<div class="page"> <div class="page">
<nav class="breadcrumb"> <nav class="breadcrumb">
<a href={resolve('/app/articles')} class="link">← Articles</a> <a href="/app/articles" class="link">← Articles</a>
</nav> </nav>
<header class="article-header"> <header class="article-header">
@ -198,11 +218,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 article.target_audio_url} {#if audioUrl}
<div class="audio-section"> <div class="audio-section">
<audio <audio
bind:this={audioEl} bind:this={audioEl}
src={article.target_audio_url} src={audioUrl}
controls controls
ontimeupdate={handleTimeUpdate} ontimeupdate={handleTimeUpdate}
class="audio-player" class="audio-player"
@ -212,16 +232,54 @@
</div> </div>
{/if} {/if}
<TargetLanguageBody <div class="article-body" lang={article.target_language}>
lang={article.source_language} {#each paragraphs as para}
{paragraphs} <p class="paragraph">
{activeSentenceIdx} {#each para.sentences as sentence}<span
onWordClick={handleWordClick} class="sentence"
{selectedSentenceToken} 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>
</div> </div>
<TranslationPanel {closePanel} {selectedSentenceToken} {translatedText} {translating} /> <!-- 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>
</div> </div>
</div> </div>
@ -229,7 +287,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={selectedSentenceToken !== null} class:is-visible={selectedWord !== null}
onclick={closePanel} onclick={closePanel}
aria-hidden="true" aria-hidden="true"
></div> ></div>
@ -323,7 +381,69 @@
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;
} }
@ -331,6 +451,26 @@
/* --- 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;
@ -347,6 +487,78 @@
} }
} }
/* --- 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);

View file

@ -1,91 +0,0 @@
<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>

View file

@ -1,40 +0,0 @@
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[] };

View file

@ -1,161 +0,0 @@
<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>

View file

@ -1,24 +0,0 @@
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 }
);
}
);

View file

@ -1,5 +1,5 @@
import { error, type ServerLoad } from '@sveltejs/kit'; import { error, type ServerLoad } from '@sveltejs/kit';
import { getJobApiJobsJobIdGet, getArticleBffArticlesArticleIdGet } from '../../../../client/sdk.gen.ts'; import { getJobApiJobsJobIdGet } from '../../../../client/sdk.gen.ts';
import { PUBLIC_API_BASE_URL } from '$env/static/public'; import { PUBLIC_API_BASE_URL } from '$env/static/public';
export const load: ServerLoad = async ({ params, locals }) => { export const load: ServerLoad = async ({ params, locals }) => {
@ -8,23 +8,10 @@ export const load: ServerLoad = async ({ params, locals }) => {
path: { job_id: params.job_id as string } path: { job_id: params.job_id as string }
}); });
let translatedArticle = null;
if (!data || response.status !== 200) { if (!data || response.status !== 200) {
error(response.status === 404 ? 404 : 500, 'Job not found'); error(response.status === 404 ? 404 : 500, 'Job not found');
} }
if (data.translated_article_id) {
const articleResponse = await getArticleBffArticlesArticleIdGet({
headers: { Authorization: `Bearer ${locals.authToken ?? ''}` },
path: { article_id: data.translated_article_id as string }
});
if (articleResponse.data) {
translatedArticle = articleResponse.data;
}
}
const fullAudioUrl = `${PUBLIC_API_BASE_URL}/media/${data.audio_url}`; const fullAudioUrl = `${PUBLIC_API_BASE_URL}/media/${data.audio_url}`;
return { job: data, fullAudioUrl, translatedArticle }; return { job: data, fullAudioUrl };
}; };

View file

@ -1,10 +1,18 @@
<script lang="ts"> <script lang="ts">
import { formatLanguage } from '$lib/formatters';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
import ArticlePreview from './ArticlePreview.svelte';
const { data }: PageProps = $props(); const { data }: PageProps = $props();
const { job, fullAudioUrl, translatedArticle } = data; const { job } = data;
const languageNames: Record<string, string> = {
en: 'English',
fr: 'French',
es: 'Spanish',
it: 'Italian',
de: 'German'
};
const lang = (code: string) => languageNames[code] ?? code.toUpperCase();
const fmt = (iso: string | null | undefined) => { const fmt = (iso: string | null | undefined) => {
if (!iso) return null; if (!iso) return null;
@ -34,6 +42,10 @@
</header> </header>
<dl class="meta-grid"> <dl class="meta-grid">
<div class="meta-item">
<dt class="field-label">Language Pair</dt>
<dd class="meta-value">{lang(job.source_language)}{lang(job.target_language)}</dd>
</div>
<div class="meta-item"> <div class="meta-item">
<dt class="field-label">Complexity</dt> <dt class="field-label">Complexity</dt>
<dd class="meta-value">{job.complexity_level}</dd> <dd class="meta-value">{job.complexity_level}</dd>
@ -68,7 +80,62 @@
</div> </div>
{/if} {/if}
<ArticlePreview article={translatedArticle} {fullAudioUrl} /> {#if job.input_summary}
<section class="content-section">
<h2 class="section-title">Input Summary</h2>
<div class="prose">{job.input_summary}</div>
</section>
{/if}
{#if job.generated_text}
<section class="content-section">
<h2 class="section-title">
Generated Text
<span class="section-lang">{lang(job.target_language)}</span>
</h2>
{#if job.audio_url}
<section class="content-section">
<h2 class="section-title">Audio</h2>
<audio class="audio-player" controls src={data.fullAudioUrl}>
Your browser does not support the audio element.
</audio>
</section>
{/if}
<div class="prose prose-target">{job.generated_text}</div>
</section>
{/if}
{#if job.translated_text}
<section class="content-section">
<h2 class="section-title">
Translation
<span class="section-lang">{lang(job.source_language)}</span>
</h2>
<div class="prose prose-translated">{job.translated_text}</div>
</section>
{/if}
<!-- POS is JSON data about parts-of-speech -->
{#if job.audio_transcript}
<section class="content-section">
<h2 class="section-title">Audio Transcript</h2>
<div class="pos-text">{JSON.stringify(job.audio_transcript, null, 2)}</div>
</section>
{/if}
{#if job.generated_text_pos}
<section class="content-section">
<h2 class="section-title">Generated Text with POS Tags</h2>
<div class="pos-text">{JSON.stringify(job.generated_text_pos, null, 2)}</div>
</section>
{/if}
{#if job.translated_text_pos}
<section class="content-section">
<h2 class="section-title">Translated Text with POS Tags</h2>
<div class="pos-text">{JSON.stringify(job.translated_text_pos, null, 2)}</div>
</section>
{/if}
</div> </div>
<style> <style>
@ -184,6 +251,79 @@
} }
} }
/* --- Content sections --- */
.content-section {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding-top: var(--space-4);
border-top: 1px solid color-mix(in srgb, var(--color-outline-variant) 30%, transparent);
}
.section-title {
font-family: var(--font-display);
font-size: var(--text-title-lg);
font-weight: var(--weight-semibold);
color: var(--color-on-surface);
display: flex;
align-items: baseline;
gap: var(--space-2);
}
.section-lang {
font-family: var(--font-label);
font-size: var(--text-label-md);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--color-on-surface-variant);
}
/* --- Prose --- */
.prose {
font-family: var(--font-body);
font-size: var(--text-body-lg);
line-height: var(--leading-relaxed);
color: var(--color-on-surface);
white-space: pre-wrap;
}
.prose-target {
padding: var(--space-5) var(--space-6);
background-color: var(--color-surface-container-low);
border-radius: var(--radius-lg);
font-size: calc(var(--text-body-lg) * 1.05);
}
.prose-translated {
color: var(--color-on-surface-variant);
font-size: var(--text-body-md);
font-style: italic;
}
.pos-text {
font-family: var(--font-mono);
font-size: var(--text-body-sm);
line-height: var(--leading-relaxed);
color: var(--color-on-surface);
background-color: var(--color-surface-container-low);
padding: var(--space-4);
border-radius: var(--radius-md);
overflow-x: auto;
overflow-y: scroll;
max-height: 300px;
white-space: pre-wrap;
}
/* --- Audio --- */
.audio-player {
width: 100%;
accent-color: var(--color-primary);
}
/* --- Responsive --- */ /* --- Responsive --- */
@media (max-width: 640px) { @media (max-width: 640px) {

View file

@ -1,127 +0,0 @@
<script lang="ts">
import { formatLanguage } from '$lib/formatters';
import type { GetArticleBffArticlesArticleIdGetResponse } from '../../../../client/types.gen';
interface Props {
article: GetArticleBffArticlesArticleIdGetResponse | null;
fullAudioUrl?: string;
}
const { article, fullAudioUrl }: Props = $props();
</script>
{#if article}
<section class="content-section">
{#if fullAudioUrl}
<section class="content-section">
<h2 class="section-title">Audio</h2>
<audio class="audio-player" controls src={fullAudioUrl}>
Your browser does not support the audio element.
</audio>
</section>
{/if}
<h2 class="section-title">
Generated Text
<span class="section-lang">{formatLanguage(article.target_language)}</span>
</h2>
<div class="prose prose-target">{article.target_body}</div>
</section>
<section class="content-section">
<h2 class="section-title">
Translation
<span class="section-lang">{formatLanguage(article.source_language)}</span>
</h2>
<div class="prose prose-translated">{article.source_body}</div>
</section>
<section class="content-section">
<h2 class="section-title">Audio Transcript</h2>
<div class="pos-text">{JSON.stringify(article.target_body_transcript, null, 2)}</div>
</section>
<section class="content-section">
<h2 class="section-title">Generated Text with POS Tags</h2>
<div class="pos-text">{JSON.stringify(article.target_body_pos, null, 2)}</div>
</section>
<section class="content-section">
<h2 class="section-title">Translated Text with POS Tags</h2>
<div class="pos-text">{JSON.stringify(article.source_body_pos, null, 2)}</div>
</section>
{/if}
<style>
/* --- Content sections --- */
.content-section {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding-top: var(--space-4);
border-top: 1px solid color-mix(in srgb, var(--color-outline-variant) 30%, transparent);
}
.section-title {
font-family: var(--font-display);
font-size: var(--text-title-lg);
font-weight: var(--weight-semibold);
color: var(--color-on-surface);
display: flex;
align-items: baseline;
gap: var(--space-2);
}
.section-lang {
font-family: var(--font-label);
font-size: var(--text-label-md);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--color-on-surface-variant);
}
/* --- Prose --- */
.prose {
font-family: var(--font-body);
font-size: var(--text-body-lg);
line-height: var(--leading-relaxed);
color: var(--color-on-surface);
white-space: pre-wrap;
}
.prose-target {
padding: var(--space-5) var(--space-6);
background-color: var(--color-surface-container-low);
border-radius: var(--radius-lg);
font-size: calc(var(--text-body-lg) * 1.05);
}
/* --- Audio --- */
.audio-player {
width: 100%;
accent-color: var(--color-primary);
}
.prose-translated {
color: var(--color-on-surface-variant);
font-size: var(--text-body-md);
font-style: italic;
}
.pos-text {
font-family: var(--font-mono);
font-size: var(--text-body-sm);
line-height: var(--leading-relaxed);
color: var(--color-on-surface);
background-color: var(--color-surface-container-low);
padding: var(--space-4);
border-radius: var(--radius-md);
overflow-x: auto;
overflow-y: scroll;
max-height: 300px;
white-space: pre-wrap;
}
</style>

View file

@ -2,24 +2,16 @@ 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(),
experimental: { alias: {
remoteFunctions: true '@client': 'src/client/client.gen.ts'
}, }
alias: { },
'@client': 'src/client/client.gen.ts' vitePlugin: {
} 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;