[dyad] Adding internationalization with next-intl - wrote 19 file(s), renamed 5 file(s), added next-intl package(s)

This commit is contained in:
[dyad]
2026-01-18 16:26:22 +01:00
parent d33dceb08c
commit 918e2334f7
19 changed files with 656 additions and 93 deletions

100
messages/en.json Normal file
View File

@@ -0,0 +1,100 @@
{
"HomePage": {
"title": "Image Web Exporter",
"subtitle": "Upload a picture, then export it in a different resolution and format."
},
"ImageUploadArea": {
"title": "Upload Images",
"prompt": "Click or drag and drop to upload",
"supportedFormats": "PNG, JPG, WEBP supported"
},
"ImageList": {
"title": "Uploaded Images",
"clearAll": "Clear All",
"clearAllTooltip": "Remove all uploaded images.",
"downloadAll": "Download All ({count})",
"downloadAllDefault": "Download All",
"converting": "Converting...",
"downloadAllTooltip": "Convert and download all images with the current settings."
},
"ImageListItem": {
"baseName": "Base Name",
"finalName": "Final name: {finalFilename}.{format}",
"downloadTooltip": "Download this image",
"removeTooltip": "Remove this image"
},
"SettingsPanel": {
"imageSettingsTitle": "Image Settings",
"imageSettingsSubtitle": "Adjust resolution and scaling for all images.",
"filenameSettingsTitle": "Filename Settings",
"filenameSettingsSubtitle": "Customize the output filenames.",
"qualitySettingsTitle": "Quality Settings",
"qualitySettingsSubtitle": "Choose format and compression level."
},
"ImageSettings": {
"aspectRatio": "Aspect Ratio",
"aspectRatioTooltip": "Choose a preset aspect ratio or select 'Custom' to enter dimensions manually.",
"custom": "Custom",
"square": "1:1 (Square)",
"standard": "4:3 (Standard)",
"photography": "3:2 (Photography)",
"widescreen": "16:9 (Widescreen)",
"width": "Width (px)",
"widthTooltip": "Set the output width in pixels. Leave blank to use the original width.",
"swapTooltip": "Swap the entered width and height values.",
"height": "Height (px)",
"heightTooltip": "Set the output height in pixels. Leave blank to use the original height.",
"keepOrientation": "Keep original orientation",
"keepOrientationTooltip": "Automatically swaps width and height to match the original image's orientation.",
"scaling": "Scaling",
"scalingTooltip": "Determines how the image fits into the new dimensions.",
"fill": "Fill (stretch to fit)",
"cover": "Cover (crop to fit)",
"contain": "Contain (letterbox)",
"position": "Position",
"positionTooltip": "Sets the anchor point for 'Cover' or 'Contain' scaling."
},
"FilenameSettings": {
"useDefaultBaseName": "Use default base name",
"useDefaultBaseNameTooltip": "When enabled, all newly uploaded images will use the specified default base name.",
"defaultBaseName": "Default base name",
"applyToAll": "Apply to all",
"applyToAllTooltip": "Apply this base name to all currently uploaded images.",
"prefix": "Prefix",
"prefixTooltip": "Add text to the beginning of every filename.",
"suffix": "Suffix",
"suffixTooltip": "Add text to the end of every filename (before the number).",
"addSequentialNumber": "Add sequential number",
"addSequentialNumberTooltip": "Append a numbered sequence to each filename.",
"startNumber": "Start number",
"startNumberTooltip": "The first number to use in the sequence.",
"paddingDigits": "Padding digits",
"paddingDigitsTooltip": "Total number of digits for the counter, padded with leading zeros (e.g., 3 for 001)."
},
"QualitySettings": {
"format": "Format",
"formatTooltip": "Choose the output file format for the images.",
"quality": "Quality",
"qualityTooltip": "Set compression quality for JPEG/WEBP. Higher is better quality but larger file size.",
"pngWarning": "Quality slider is disabled for PNG (lossless format)."
},
"ActionButtons": {
"reset": "Reset",
"resetTooltip": "Reset all settings to their default values.",
"apply": "Apply",
"applyTooltip": "Confirm and apply all the settings above. This does not download the images."
},
"Footer": {
"imprint": "Imprint",
"privacy": "Privacy"
},
"ChangelogPage": {
"back": "Back to Converter"
},
"ImprintPage": {
"back": "Back to Converter"
},
"PrivacyPage": {
"back": "Back to Converter"
}
}

View File

@@ -44,6 +44,7 @@
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lucide-react": "^0.511.0", "lucide-react": "^0.511.0",
"next": "15.3.8", "next": "15.3.8",
"next-intl": "^4.7.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2.1", "react": "^19.2.1",
"react-day-picker": "^8.10.1", "react-day-picker": "^8.10.1",

403
pnpm-lock.yaml generated
View File

@@ -113,6 +113,9 @@ importers:
next: next:
specifier: 15.3.8 specifier: 15.3.8
version: 15.3.8(react-dom@19.2.1(react@19.2.1))(react@19.2.1) version: 15.3.8(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
next-intl:
specifier: ^4.7.0
version: 4.7.0(next@15.3.8(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(typescript@5.8.3)
next-themes: next-themes:
specifier: ^0.4.6 specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1) version: 0.4.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
@@ -222,6 +225,24 @@ packages:
'@floating-ui/utils@0.2.9': '@floating-ui/utils@0.2.9':
resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==} resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==}
'@formatjs/ecma402-abstract@2.3.6':
resolution: {integrity: sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==}
'@formatjs/fast-memoize@2.2.7':
resolution: {integrity: sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==}
'@formatjs/icu-messageformat-parser@2.11.4':
resolution: {integrity: sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==}
'@formatjs/icu-skeleton-parser@1.8.16':
resolution: {integrity: sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==}
'@formatjs/intl-localematcher@0.5.10':
resolution: {integrity: sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==}
'@formatjs/intl-localematcher@0.6.2':
resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==}
'@hookform/resolvers@5.0.1': '@hookform/resolvers@5.0.1':
resolution: {integrity: sha512-u/+Jp83luQNx9AdyW2fIPGY6Y7NG68eN2ZW8FOJYL+M0i4s49+refdJdOp/A9n9HFQtQs3HIDHQvX3ZET2o7YA==} resolution: {integrity: sha512-u/+Jp83luQNx9AdyW2fIPGY6Y7NG68eN2ZW8FOJYL+M0i4s49+refdJdOp/A9n9HFQtQs3HIDHQvX3ZET2o7YA==}
peerDependencies: peerDependencies:
@@ -431,6 +452,88 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
'@parcel/watcher-android-arm64@2.5.4':
resolution: {integrity: sha512-hoh0vx4v+b3BNI7Cjoy2/B0ARqcwVNrzN/n7DLq9ZB4I3lrsvhrkCViJyfTj/Qi5xM9YFiH4AmHGK6pgH1ss7g==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [android]
'@parcel/watcher-darwin-arm64@2.5.4':
resolution: {integrity: sha512-kphKy377pZiWpAOyTgQYPE5/XEKVMaj6VUjKT5VkNyUJlr2qZAn8gIc7CPzx+kbhvqHDT9d7EqdOqRXT6vk0zw==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [darwin]
'@parcel/watcher-darwin-x64@2.5.4':
resolution: {integrity: sha512-UKaQFhCtNJW1A9YyVz3Ju7ydf6QgrpNQfRZ35wNKUhTQ3dxJ/3MULXN5JN/0Z80V/KUBDGa3RZaKq1EQT2a2gg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [darwin]
'@parcel/watcher-freebsd-x64@2.5.4':
resolution: {integrity: sha512-Dib0Wv3Ow/m2/ttvLdeI2DBXloO7t3Z0oCp4bAb2aqyqOjKPPGrg10pMJJAQ7tt8P4V2rwYwywkDhUia/FgS+Q==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [freebsd]
'@parcel/watcher-linux-arm-glibc@2.5.4':
resolution: {integrity: sha512-I5Vb769pdf7Q7Sf4KNy8Pogl/URRCKu9ImMmnVKYayhynuyGYMzuI4UOWnegQNa2sGpsPSbzDsqbHNMyeyPCgw==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
'@parcel/watcher-linux-arm-musl@2.5.4':
resolution: {integrity: sha512-kGO8RPvVrcAotV4QcWh8kZuHr9bXi9a3bSZw7kFarYR0+fGliU7hd/zevhjw8fnvIKG3J9EO5G6sXNGCSNMYPQ==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
'@parcel/watcher-linux-arm64-glibc@2.5.4':
resolution: {integrity: sha512-KU75aooXhqGFY2W5/p8DYYHt4hrjHZod8AhcGAmhzPn/etTa+lYCDB2b1sJy3sWJ8ahFVTdy+EbqSBvMx3iFlw==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
'@parcel/watcher-linux-arm64-musl@2.5.4':
resolution: {integrity: sha512-Qx8uNiIekVutnzbVdrgSanM+cbpDD3boB1f8vMtnuG5Zau4/bdDbXyKwIn0ToqFhIuob73bcxV9NwRm04/hzHQ==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
'@parcel/watcher-linux-x64-glibc@2.5.4':
resolution: {integrity: sha512-UYBQvhYmgAv61LNUn24qGQdjtycFBKSK3EXr72DbJqX9aaLbtCOO8+1SkKhD/GNiJ97ExgcHBrukcYhVjrnogA==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
'@parcel/watcher-linux-x64-musl@2.5.4':
resolution: {integrity: sha512-YoRWCVgxv8akZrMhdyVi6/TyoeeMkQ0PGGOf2E4omODrvd1wxniXP+DBynKoHryStks7l+fDAMUBRzqNHrVOpg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
'@parcel/watcher-win32-arm64@2.5.4':
resolution: {integrity: sha512-iby+D/YNXWkiQNYcIhg8P5hSjzXEHaQrk2SLrWOUD7VeC4Ohu0WQvmV+HDJokZVJ2UjJ4AGXW3bx7Lls9Ln4TQ==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [win32]
'@parcel/watcher-win32-ia32@2.5.4':
resolution: {integrity: sha512-vQN+KIReG0a2ZDpVv8cgddlf67J8hk1WfZMMP7sMeZmJRSmEax5xNDNWKdgqSe2brOKTQQAs3aCCUal2qBHAyg==}
engines: {node: '>= 10.0.0'}
cpu: [ia32]
os: [win32]
'@parcel/watcher-win32-x64@2.5.4':
resolution: {integrity: sha512-3A6efb6BOKwyw7yk9ro2vus2YTt2nvcd56AuzxdMiVOxL9umDyN5PKkKfZ/gZ9row41SjVmTVQNWQhaRRGpOKw==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [win32]
'@parcel/watcher@2.5.4':
resolution: {integrity: sha512-WYa2tUVV5HiArWPB3ydlOc4R2ivq0IDrlqhMi3l7mVsFEXNcTfxYFPIHXHXIh/ca/y/V5N4E1zecyxdIBjYnkQ==}
engines: {node: '>= 10.0.0'}
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'} engines: {node: '>=14'}
@@ -1047,15 +1150,90 @@ packages:
'@radix-ui/rect@1.1.1': '@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
'@schummar/icu-type-parser@1.21.5':
resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==}
'@standard-schema/utils@0.3.0': '@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@swc/core-darwin-arm64@1.15.8':
resolution: {integrity: sha512-M9cK5GwyWWRkRGwwCbREuj6r8jKdES/haCZ3Xckgkl8MUQJZA3XB7IXXK1IXRNeLjg6m7cnoMICpXv1v1hlJOg==}
engines: {node: '>=10'}
cpu: [arm64]
os: [darwin]
'@swc/core-darwin-x64@1.15.8':
resolution: {integrity: sha512-j47DasuOvXl80sKJHSi2X25l44CMc3VDhlJwA7oewC1nV1VsSzwX+KOwE5tLnfORvVJJyeiXgJORNYg4jeIjYQ==}
engines: {node: '>=10'}
cpu: [x64]
os: [darwin]
'@swc/core-linux-arm-gnueabihf@1.15.8':
resolution: {integrity: sha512-siAzDENu2rUbwr9+fayWa26r5A9fol1iORG53HWxQL1J8ym4k7xt9eME0dMPXlYZDytK5r9sW8zEA10F2U3Xwg==}
engines: {node: '>=10'}
cpu: [arm]
os: [linux]
'@swc/core-linux-arm64-gnu@1.15.8':
resolution: {integrity: sha512-o+1y5u6k2FfPYbTRUPvurwzNt5qd0NTumCTFscCNuBksycloXY16J8L+SMW5QRX59n4Hp9EmFa3vpvNHRVv1+Q==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
'@swc/core-linux-arm64-musl@1.15.8':
resolution: {integrity: sha512-koiCqL09EwOP1S2RShCI7NbsQuG6r2brTqUYE7pV7kZm9O17wZ0LSz22m6gVibpwEnw8jI3IE1yYsQTVpluALw==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
'@swc/core-linux-x64-gnu@1.15.8':
resolution: {integrity: sha512-4p6lOMU3bC+Vd5ARtKJ/FxpIC5G8v3XLoPEZ5s7mLR8h7411HWC/LmTXDHcrSXRC55zvAVia1eldy6zDLz8iFQ==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
'@swc/core-linux-x64-musl@1.15.8':
resolution: {integrity: sha512-z3XBnbrZAL+6xDGAhJoN4lOueIxC/8rGrJ9tg+fEaeqLEuAtHSW2QHDHxDwkxZMjuF/pZ6MUTjHjbp8wLbuRLA==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
'@swc/core-win32-arm64-msvc@1.15.8':
resolution: {integrity: sha512-djQPJ9Rh9vP8GTS/Df3hcc6XP6xnG5c8qsngWId/BLA9oX6C7UzCPAn74BG/wGb9a6j4w3RINuoaieJB3t+7iQ==}
engines: {node: '>=10'}
cpu: [arm64]
os: [win32]
'@swc/core-win32-ia32-msvc@1.15.8':
resolution: {integrity: sha512-/wfAgxORg2VBaUoFdytcVBVCgf1isWZIEXB9MZEUty4wwK93M/PxAkjifOho9RN3WrM3inPLabICRCEgdHpKKQ==}
engines: {node: '>=10'}
cpu: [ia32]
os: [win32]
'@swc/core-win32-x64-msvc@1.15.8':
resolution: {integrity: sha512-GpMePrh9Sl4d61o4KAHOOv5is5+zt6BEXCOCgs/H0FLGeii7j9bWDE8ExvKFy2GRRZVNR1ugsnzaGWHKM6kuzA==}
engines: {node: '>=10'}
cpu: [x64]
os: [win32]
'@swc/core@1.15.8':
resolution: {integrity: sha512-T8keoJjXaSUoVBCIjgL6wAnhADIb09GOELzKg10CjNg+vLX48P93SME6jTfte9MZIm5m+Il57H3rTSk/0kzDUw==}
engines: {node: '>=10'}
peerDependencies:
'@swc/helpers': '>=0.5.17'
peerDependenciesMeta:
'@swc/helpers':
optional: true
'@swc/counter@0.1.3': '@swc/counter@0.1.3':
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
'@swc/helpers@0.5.15': '@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
'@swc/types@0.1.25':
resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==}
'@types/d3-array@3.2.1': '@types/d3-array@3.2.1':
resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==} resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==}
@@ -1348,6 +1526,9 @@ packages:
decimal.js-light@2.5.1: decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
detect-libc@2.0.4: detect-libc@2.0.4:
resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==} resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -1499,6 +1680,9 @@ packages:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'} engines: {node: '>=12'}
intl-messageformat@10.7.18:
resolution: {integrity: sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==}
is-arrayish@0.3.2: is-arrayish@0.3.2:
resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==} resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==}
@@ -1613,9 +1797,26 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true hasBin: true
negotiator@1.0.0:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
neo-async@2.6.2: neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
next-intl-swc-plugin-extractor@4.7.0:
resolution: {integrity: sha512-iAqflu2FWdQMWhwB0B2z52X7LmEpvnMNJXqVERZQ7bK5p9iqQLu70ur6Ka6NfiXLxfb+AeAkUX5qIciQOg+87A==}
next-intl@4.7.0:
resolution: {integrity: sha512-gvROzcNr/HM0jTzQlKWQxUNk8jrZ0bREz+bht3wNbv+uzlZ5Kn3J+m+viosub18QJ72S08UJnVK50PXWcUvwpQ==}
peerDependencies:
next: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0
typescript: ^5.0.0
peerDependenciesMeta:
typescript:
optional: true
next-themes@0.4.6: next-themes@0.4.6:
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
peerDependencies: peerDependencies:
@@ -1643,6 +1844,9 @@ packages:
sass: sass:
optional: true optional: true
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-releases@2.0.19: node-releases@2.0.19:
resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==}
@@ -1679,6 +1883,10 @@ packages:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
engines: {node: '>=8.6'} engines: {node: '>=8.6'}
picomatch@4.0.3:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
pify@2.3.0: pify@2.3.0:
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -1687,6 +1895,9 @@ packages:
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
po-parser@2.1.1:
resolution: {integrity: sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==}
postcss-import@15.1.0: postcss-import@15.1.0:
resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
@@ -2031,6 +2242,11 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
use-intl@4.7.0:
resolution: {integrity: sha512-jyd8nSErVRRsSlUa+SDobKHo9IiWs5fjcPl9VBUnzUyEQpVM5mwJCgw8eUiylhvBpLQzUGox1KN0XlRivSID9A==}
peerDependencies:
react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0
use-sidecar@1.1.3: use-sidecar@1.1.3:
resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -2145,6 +2361,36 @@ snapshots:
'@floating-ui/utils@0.2.9': {} '@floating-ui/utils@0.2.9': {}
'@formatjs/ecma402-abstract@2.3.6':
dependencies:
'@formatjs/fast-memoize': 2.2.7
'@formatjs/intl-localematcher': 0.6.2
decimal.js: 10.6.0
tslib: 2.8.1
'@formatjs/fast-memoize@2.2.7':
dependencies:
tslib: 2.8.1
'@formatjs/icu-messageformat-parser@2.11.4':
dependencies:
'@formatjs/ecma402-abstract': 2.3.6
'@formatjs/icu-skeleton-parser': 1.8.16
tslib: 2.8.1
'@formatjs/icu-skeleton-parser@1.8.16':
dependencies:
'@formatjs/ecma402-abstract': 2.3.6
tslib: 2.8.1
'@formatjs/intl-localematcher@0.5.10':
dependencies:
tslib: 2.8.1
'@formatjs/intl-localematcher@0.6.2':
dependencies:
tslib: 2.8.1
'@hookform/resolvers@5.0.1(react-hook-form@7.56.4(react@19.2.1))': '@hookform/resolvers@5.0.1(react-hook-form@7.56.4(react@19.2.1))':
dependencies: dependencies:
'@standard-schema/utils': 0.3.0 '@standard-schema/utils': 0.3.0
@@ -2300,6 +2546,66 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5 '@nodelib/fs.scandir': 2.1.5
fastq: 1.19.1 fastq: 1.19.1
'@parcel/watcher-android-arm64@2.5.4':
optional: true
'@parcel/watcher-darwin-arm64@2.5.4':
optional: true
'@parcel/watcher-darwin-x64@2.5.4':
optional: true
'@parcel/watcher-freebsd-x64@2.5.4':
optional: true
'@parcel/watcher-linux-arm-glibc@2.5.4':
optional: true
'@parcel/watcher-linux-arm-musl@2.5.4':
optional: true
'@parcel/watcher-linux-arm64-glibc@2.5.4':
optional: true
'@parcel/watcher-linux-arm64-musl@2.5.4':
optional: true
'@parcel/watcher-linux-x64-glibc@2.5.4':
optional: true
'@parcel/watcher-linux-x64-musl@2.5.4':
optional: true
'@parcel/watcher-win32-arm64@2.5.4':
optional: true
'@parcel/watcher-win32-ia32@2.5.4':
optional: true
'@parcel/watcher-win32-x64@2.5.4':
optional: true
'@parcel/watcher@2.5.4':
dependencies:
detect-libc: 2.0.4
is-glob: 4.0.3
node-addon-api: 7.1.1
picomatch: 4.0.3
optionalDependencies:
'@parcel/watcher-android-arm64': 2.5.4
'@parcel/watcher-darwin-arm64': 2.5.4
'@parcel/watcher-darwin-x64': 2.5.4
'@parcel/watcher-freebsd-x64': 2.5.4
'@parcel/watcher-linux-arm-glibc': 2.5.4
'@parcel/watcher-linux-arm-musl': 2.5.4
'@parcel/watcher-linux-arm64-glibc': 2.5.4
'@parcel/watcher-linux-arm64-musl': 2.5.4
'@parcel/watcher-linux-x64-glibc': 2.5.4
'@parcel/watcher-linux-x64-musl': 2.5.4
'@parcel/watcher-win32-arm64': 2.5.4
'@parcel/watcher-win32-ia32': 2.5.4
'@parcel/watcher-win32-x64': 2.5.4
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
optional: true optional: true
@@ -2956,14 +3262,66 @@ snapshots:
'@radix-ui/rect@1.1.1': {} '@radix-ui/rect@1.1.1': {}
'@schummar/icu-type-parser@1.21.5': {}
'@standard-schema/utils@0.3.0': {} '@standard-schema/utils@0.3.0': {}
'@swc/core-darwin-arm64@1.15.8':
optional: true
'@swc/core-darwin-x64@1.15.8':
optional: true
'@swc/core-linux-arm-gnueabihf@1.15.8':
optional: true
'@swc/core-linux-arm64-gnu@1.15.8':
optional: true
'@swc/core-linux-arm64-musl@1.15.8':
optional: true
'@swc/core-linux-x64-gnu@1.15.8':
optional: true
'@swc/core-linux-x64-musl@1.15.8':
optional: true
'@swc/core-win32-arm64-msvc@1.15.8':
optional: true
'@swc/core-win32-ia32-msvc@1.15.8':
optional: true
'@swc/core-win32-x64-msvc@1.15.8':
optional: true
'@swc/core@1.15.8':
dependencies:
'@swc/counter': 0.1.3
'@swc/types': 0.1.25
optionalDependencies:
'@swc/core-darwin-arm64': 1.15.8
'@swc/core-darwin-x64': 1.15.8
'@swc/core-linux-arm-gnueabihf': 1.15.8
'@swc/core-linux-arm64-gnu': 1.15.8
'@swc/core-linux-arm64-musl': 1.15.8
'@swc/core-linux-x64-gnu': 1.15.8
'@swc/core-linux-x64-musl': 1.15.8
'@swc/core-win32-arm64-msvc': 1.15.8
'@swc/core-win32-ia32-msvc': 1.15.8
'@swc/core-win32-x64-msvc': 1.15.8
'@swc/counter@0.1.3': {} '@swc/counter@0.1.3': {}
'@swc/helpers@0.5.15': '@swc/helpers@0.5.15':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
'@swc/types@0.1.25':
dependencies:
'@swc/counter': 0.1.3
'@types/d3-array@3.2.1': {} '@types/d3-array@3.2.1': {}
'@types/d3-color@3.1.3': {} '@types/d3-color@3.1.3': {}
@@ -3272,8 +3630,9 @@ snapshots:
decimal.js-light@2.5.1: {} decimal.js-light@2.5.1: {}
detect-libc@2.0.4: decimal.js@10.6.0: {}
optional: true
detect-libc@2.0.4: {}
detect-node-es@1.1.0: {} detect-node-es@1.1.0: {}
@@ -3402,6 +3761,13 @@ snapshots:
internmap@2.0.3: {} internmap@2.0.3: {}
intl-messageformat@10.7.18:
dependencies:
'@formatjs/ecma402-abstract': 2.3.6
'@formatjs/fast-memoize': 2.2.7
'@formatjs/icu-messageformat-parser': 2.11.4
tslib: 2.8.1
is-arrayish@0.3.2: is-arrayish@0.3.2:
optional: true optional: true
@@ -3496,8 +3862,28 @@ snapshots:
nanoid@3.3.11: {} nanoid@3.3.11: {}
negotiator@1.0.0: {}
neo-async@2.6.2: {} neo-async@2.6.2: {}
next-intl-swc-plugin-extractor@4.7.0: {}
next-intl@4.7.0(next@15.3.8(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(typescript@5.8.3):
dependencies:
'@formatjs/intl-localematcher': 0.5.10
'@parcel/watcher': 2.5.4
'@swc/core': 1.15.8
negotiator: 1.0.0
next: 15.3.8(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
next-intl-swc-plugin-extractor: 4.7.0
po-parser: 2.1.1
react: 19.2.1
use-intl: 4.7.0(react@19.2.1)
optionalDependencies:
typescript: 5.8.3
transitivePeerDependencies:
- '@swc/helpers'
next-themes@0.4.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1): next-themes@0.4.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
dependencies: dependencies:
react: 19.2.1 react: 19.2.1
@@ -3528,6 +3914,8 @@ snapshots:
- '@babel/core' - '@babel/core'
- babel-plugin-macros - babel-plugin-macros
node-addon-api@7.1.1: {}
node-releases@2.0.19: {} node-releases@2.0.19: {}
normalize-path@3.0.0: {} normalize-path@3.0.0: {}
@@ -3551,10 +3939,14 @@ snapshots:
picomatch@2.3.1: {} picomatch@2.3.1: {}
picomatch@4.0.3: {}
pify@2.3.0: {} pify@2.3.0: {}
pirates@4.0.7: {} pirates@4.0.7: {}
po-parser@2.1.1: {}
postcss-import@15.1.0(postcss@8.5.3): postcss-import@15.1.0(postcss@8.5.3):
dependencies: dependencies:
postcss: 8.5.3 postcss: 8.5.3
@@ -3921,6 +4313,13 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.1.5 '@types/react': 19.1.5
use-intl@4.7.0(react@19.2.1):
dependencies:
'@formatjs/fast-memoize': 2.2.7
'@schummar/icu-type-parser': 1.21.5
intl-messageformat: 10.7.18
react: 19.2.1
use-sidecar@1.1.3(@types/react@19.1.5)(react@19.2.1): use-sidecar@1.1.3(@types/react@19.1.5)(react@19.2.1):
dependencies: dependencies:
detect-node-es: 1.1.0 detect-node-es: 1.1.0

View File

@@ -1,16 +1,18 @@
import { Changelog } from "@/components/changelog"; import { Changelog } from "@/components/changelog";
import Link from "next/link"; import Link from "next-intl/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { useTranslations } from "next-intl";
export default function ChangelogPage() { export default function ChangelogPage() {
const t = useTranslations("ChangelogPage");
return ( return (
<div className="relative flex flex-col items-center min-h-screen p-4 sm:p-8 bg-gray-50 dark:bg-background font-[family-name:var(--font-geist-sans)]"> <div className="relative flex flex-col items-center min-h-screen p-4 sm:p-8 bg-gray-50 dark:bg-background font-[family-name:var(--font-geist-sans)]">
<div className="w-full max-w-4xl mx-auto"> <div className="w-full max-w-4xl mx-auto">
<Button asChild variant="ghost" className="mb-4 -ml-4"> <Button asChild variant="ghost" className="mb-4 -ml-4">
<Link href="/"> <Link href="/">
<ArrowLeft className="mr-2 h-4 w-4" /> <ArrowLeft className="mr-2 h-4 w-4" />
Back to Converter {t('back')}
</Link> </Link>
</Button> </Button>
</div> </div>

View File

@@ -1,16 +1,18 @@
import Link from "next/link"; import Link from "next-intl/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useTranslations } from "next-intl";
export default function ImprintPage() { export default function ImprintPage() {
const t = useTranslations("ImprintPage");
return ( return (
<div className="relative flex flex-col items-center min-h-screen p-4 sm:p-8 bg-gray-50 dark:bg-background"> <div className="relative flex flex-col items-center min-h-screen p-4 sm:p-8 bg-gray-50 dark:bg-background">
<div className="w-full max-w-4xl mx-auto"> <div className="w-full max-w-4xl mx-auto">
<Button asChild variant="ghost" className="mb-4 -ml-4"> <Button asChild variant="ghost" className="mb-4 -ml-4">
<Link href="/"> <Link href="/">
<ArrowLeft className="mr-2 h-4 w-4" /> <ArrowLeft className="mr-2 h-4 w-4" />
Back to Converter {t('back')}
</Link> </Link>
</Button> </Button>
<main className="w-full"> <main className="w-full">

View File

@@ -1,9 +1,11 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "../globals.css";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { Footer } from "@/components/footer"; import { Footer } from "@/components/footer";
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@@ -20,26 +22,32 @@ export const metadata: Metadata = {
description: "Upload a picture, then export it in a different resolution and format.", description: "Upload a picture, then export it in a different resolution and format.",
}; };
export default function RootLayout({ export default async function RootLayout({
children, children,
params: { locale }
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
params: { locale: string };
}>) { }>) {
const messages = await getMessages();
return ( return (
<html lang="en" suppressHydrationWarning> <html lang={locale} suppressHydrationWarning>
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${geistSans.variable} ${geistMono.variable} antialiased`}
> >
<ThemeProvider <NextIntlClientProvider messages={messages}>
attribute="class" <ThemeProvider
defaultTheme="system" attribute="class"
enableSystem defaultTheme="system"
disableTransitionOnChange enableSystem
> disableTransitionOnChange
{children} >
<Footer /> {children}
<Toaster /> <Footer />
</ThemeProvider> <Toaster />
</ThemeProvider>
</NextIntlClientProvider>
</body> </body>
</html> </html>
); );

View File

@@ -1,15 +1,17 @@
import { ImageConverter } from "@/components/image-converter"; import { ImageConverter } from "@/components/image-converter";
import { useTranslations } from 'next-intl';
export default function Home() { export default function Home() {
const t = useTranslations('HomePage');
return ( return (
<div className="relative flex flex-col items-center justify-center min-h-screen p-4 sm:p-8 bg-gray-50 dark:bg-background font-[family-name:var(--font-geist-sans)]"> <div className="relative flex flex-col items-center justify-center min-h-screen p-4 sm:p-8 bg-gray-50 dark:bg-background font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col items-center w-full max-w-6xl z-10"> <main className="flex flex-col items-center w-full max-w-6xl z-10">
<div className="text-center mb-8"> <div className="text-center mb-8">
<h1 className="text-4xl font-bold tracking-tight text-gray-900 dark:text-gray-100 sm:text-5xl"> <h1 className="text-4xl font-bold tracking-tight text-gray-900 dark:text-gray-100 sm:text-5xl">
Image Web Exporter {t('title')}
</h1> </h1>
<p className="mt-3 text-lg text-gray-600 dark:text-gray-400"> <p className="mt-3 text-lg text-gray-600 dark:text-gray-400">
Upload a picture, then export it in a different resolution and format. {t('subtitle')}
</p> </p>
</div> </div>
<ImageConverter /> <ImageConverter />

View File

@@ -1,16 +1,18 @@
import Link from "next/link"; import Link from "next-intl/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useTranslations } from "next-intl";
export default function PrivacyPage() { export default function PrivacyPage() {
const t = useTranslations("PrivacyPage");
return ( return (
<div className="relative flex flex-col items-center min-h-screen p-4 sm:p-8 bg-gray-50 dark:bg-background"> <div className="relative flex flex-col items-center min-h-screen p-4 sm:p-8 bg-gray-50 dark:bg-background">
<div className="w-full max-w-4xl mx-auto"> <div className="w-full max-w-4xl mx-auto">
<Button asChild variant="ghost" className="mb-4 -ml-4"> <Button asChild variant="ghost" className="mb-4 -ml-4">
<Link href="/"> <Link href="/">
<ArrowLeft className="mr-2 h-4 w-4" /> <ArrowLeft className="mr-2 h-4 w-4" />
Back to Converter {t('back')}
</Link> </Link>
</Button> </Button>
<main className="w-full"> <main className="w-full">

View File

@@ -4,12 +4,14 @@ import { Button } from "@/components/ui/button";
import { Check, RotateCcw } from "lucide-react"; import { Check, RotateCcw } from "lucide-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslations } from "next-intl";
interface ActionButtonsProps { interface ActionButtonsProps {
onReset: () => void; onReset: () => void;
} }
export function ActionButtons({ onReset }: ActionButtonsProps) { export function ActionButtons({ onReset }: ActionButtonsProps) {
const t = useTranslations("ActionButtons");
const handleApply = () => { const handleApply = () => {
toast.info("Settings updated and will be used for all downloads."); toast.info("Settings updated and will be used for all downloads.");
}; };
@@ -20,18 +22,18 @@ export function ActionButtons({ onReset }: ActionButtonsProps) {
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button onClick={onReset} className="w-full" variant="outline"> <Button onClick={onReset} className="w-full" variant="outline">
<RotateCcw className="mr-2 h-4 w-4" /> Reset <RotateCcw className="mr-2 h-4 w-4" /> {t('reset')}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent><p>Reset all settings to their default values.</p></TooltipContent> <TooltipContent><p>{t('resetTooltip')}</p></TooltipContent>
</Tooltip> </Tooltip>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button onClick={handleApply} className="w-full"> <Button onClick={handleApply} className="w-full">
<Check className="mr-2 h-4 w-4" /> Apply <Check className="mr-2 h-4 w-4" /> {t('apply')}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent><p>Confirm and apply all the settings above. This does not download the images.</p></TooltipContent> <TooltipContent><p>{t('applyTooltip')}</p></TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
</TooltipProvider> </TooltipProvider>

View File

@@ -1,9 +1,13 @@
import Link from "next/link"; "use client";
import Link from "next-intl/link";
import { Github, Twitter } from "lucide-react"; import { Github, Twitter } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { changelogData } from "@/lib/changelog-data"; import { changelogData } from "@/lib/changelog-data";
import { useTranslations } from "next-intl";
export function Footer() { export function Footer() {
const t = useTranslations("Footer");
const latestVersion = changelogData[0]?.version; const latestVersion = changelogData[0]?.version;
return ( return (
@@ -27,8 +31,8 @@ export function Footer() {
</div> </div>
<div className="flex items-center gap-4 text-sm text-muted-foreground"> <div className="flex items-center gap-4 text-sm text-muted-foreground">
<Link href="/imprint" className="hover:text-primary transition-colors">Imprint</Link> <Link href="/imprint" className="hover:text-primary transition-colors">{t('imprint')}</Link>
<Link href="/privacy" className="hover:text-primary transition-colors">Privacy</Link> <Link href="/privacy" className="hover:text-primary transition-colors">{t('privacy')}</Link>
{latestVersion && ( {latestVersion && (
<Link <Link
href="/changelog" href="/changelog"

View File

@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { Download, X } from "lucide-react"; import { Download, X } from "lucide-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { generateFinalFilename } from "@/lib/image-processor"; import { generateFinalFilename } from "@/lib/image-processor";
import { useTranslations } from "next-intl";
interface ImageListItemProps { interface ImageListItemProps {
image: ImageFile; image: ImageFile;
@@ -27,22 +28,24 @@ export function ImageListItem({
onDownload, onDownload,
isProcessing, isProcessing,
}: ImageListItemProps) { }: ImageListItemProps) {
const t = useTranslations("ImageListItem");
const finalFilename = generateFinalFilename(image.filename, settings, index); const finalFilename = generateFinalFilename(image.filename, settings, index);
const finalNameText = t('finalName', { finalFilename, format: settings.format });
return ( return (
<TooltipProvider> <TooltipProvider>
<div className="p-4 border rounded-lg flex items-center gap-4"> <div className="p-4 border rounded-lg flex items-center gap-4">
<img src={image.previewUrl} alt={`Preview ${index + 1}`} className="w-20 h-20 object-cover rounded-md shrink-0" /> <img src={image.previewUrl} alt={`Preview ${index + 1}`} className="w-20 h-20 object-cover rounded-md shrink-0" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<Label htmlFor={`filename-${index}`} className="text-xs text-muted-foreground">Base Name</Label> <Label htmlFor={`filename-${index}`} className="text-xs text-muted-foreground">{t('baseName')}</Label>
<Input <Input
id={`filename-${index}`} id={`filename-${index}`}
value={image.filename} value={image.filename}
onChange={(e) => onFilenameChange(index, e.target.value)} onChange={(e) => onFilenameChange(index, e.target.value)}
className="text-sm font-medium h-8 mt-1" className="text-sm font-medium h-8 mt-1"
/> />
<p className="text-xs text-muted-foreground truncate mt-1" title={`${finalFilename}.${settings.format}`}> <p className="text-xs text-muted-foreground truncate mt-1" title={finalNameText}>
Final name: {finalFilename}.{settings.format} {finalNameText}
</p> </p>
</div> </div>
<div className="flex items-center shrink-0"> <div className="flex items-center shrink-0">
@@ -58,7 +61,7 @@ export function ImageListItem({
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent><p>Download this image</p></TooltipContent> <TooltipContent><p>{t('downloadTooltip')}</p></TooltipContent>
</Tooltip> </Tooltip>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -72,7 +75,7 @@ export function ImageListItem({
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent><p>Remove this image</p></TooltipContent> <TooltipContent><p>{t('removeTooltip')}</p></TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
</div> </div>

View File

@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { Download, Trash2 } from "lucide-react"; import { Download, Trash2 } from "lucide-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { ImageListItem } from "./image-list-item"; import { ImageListItem } from "./image-list-item";
import { useTranslations } from "next-intl";
interface ImageListProps { interface ImageListProps {
images: ImageFile[]; images: ImageFile[];
@@ -30,6 +31,8 @@ export function ImageList({
isConverting, isConverting,
convertingIndex, convertingIndex,
}: ImageListProps) { }: ImageListProps) {
const t = useTranslations("ImageList");
if (images.length === 0) { if (images.length === 0) {
return null; return null;
} }
@@ -41,24 +44,24 @@ export function ImageList({
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<CardTitle>Uploaded Images</CardTitle> <CardTitle>{t('title')}</CardTitle>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="ghost" size="sm" onClick={onClearAll} disabled={isProcessing}> <Button variant="ghost" size="sm" onClick={onClearAll} disabled={isProcessing}>
<Trash2 className="mr-2 h-4 w-4" />Clear All <Trash2 className="mr-2 h-4 w-4" />{t('clearAll')}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent><p>Remove all uploaded images.</p></TooltipContent> <TooltipContent><p>{t('clearAllTooltip')}</p></TooltipContent>
</Tooltip> </Tooltip>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button onClick={onDownloadAll} disabled={isProcessing}> <Button onClick={onDownloadAll} disabled={isProcessing}>
<Download className="mr-2 h-4 w-4" /> <Download className="mr-2 h-4 w-4" />
{isConverting ? "Converting..." : `Download All (${images.length})`} {isConverting ? t('converting') : t('downloadAll', { count: images.length })}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent><p>Convert and download all images with the current settings.</p></TooltipContent> <TooltipContent><p>{t('downloadAllTooltip')}</p></TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
</div> </div>

View File

@@ -5,12 +5,14 @@ import { Input } from "@/components/ui/input";
import { Upload } from "lucide-react"; import { Upload } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Card, CardContent } from "./ui/card"; import { Card, CardContent } from "./ui/card";
import { useTranslations } from "next-intl";
interface ImageUploadAreaProps { interface ImageUploadAreaProps {
onFilesSelected: (files: FileList | null) => void; onFilesSelected: (files: FileList | null) => void;
} }
export function ImageUploadArea({ onFilesSelected }: ImageUploadAreaProps) { export function ImageUploadArea({ onFilesSelected }: ImageUploadAreaProps) {
const t = useTranslations("ImageUploadArea");
const [isDraggingOver, setIsDraggingOver] = useState(false); const [isDraggingOver, setIsDraggingOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -39,7 +41,7 @@ export function ImageUploadArea({ onFilesSelected }: ImageUploadAreaProps) {
<Card> <Card>
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-lg font-medium">Upload Images</h3> <h3 className="text-lg font-medium">{t('title')}</h3>
<div <div
className={cn( className={cn(
"w-full h-48 rounded-lg border-2 border-dashed flex items-center justify-center relative transition-colors cursor-pointer hover:border-primary/60", "w-full h-48 rounded-lg border-2 border-dashed flex items-center justify-center relative transition-colors cursor-pointer hover:border-primary/60",
@@ -52,8 +54,8 @@ export function ImageUploadArea({ onFilesSelected }: ImageUploadAreaProps) {
> >
<div className="flex flex-col items-center justify-center text-center text-muted-foreground"> <div className="flex flex-col items-center justify-center text-center text-muted-foreground">
<Upload className="w-8 h-8 mb-2" /> <Upload className="w-8 h-8 mb-2" />
<p className="font-semibold">Click or drag and drop to upload</p> <p className="font-semibold">{t('prompt')}</p>
<p className="text-xs text-muted-foreground mt-1">PNG, JPG, WEBP supported</p> <p className="text-xs text-muted-foreground mt-1">{t('supportedFormats')}</p>
</div> </div>
<Input type="file" ref={fileInputRef} onChange={handleImageChange} className="hidden" accept="image/*" multiple /> <Input type="file" ref={fileInputRef} onChange={handleImageChange} className="hidden" accept="image/*" multiple />
</div> </div>

View File

@@ -5,6 +5,7 @@ import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/
import { ImageSettings } from "./settings/image-settings"; import { ImageSettings } from "./settings/image-settings";
import { FilenameSettings } from "./settings/filename-settings"; import { FilenameSettings } from "./settings/filename-settings";
import { QualitySettings } from "./settings/quality-settings"; import { QualitySettings } from "./settings/quality-settings";
import { useTranslations } from "next-intl";
interface SettingsPanelProps { interface SettingsPanelProps {
settings: ConversionSettings; settings: ConversionSettings;
@@ -23,13 +24,14 @@ export function SettingsPanel({
onApplyDefaultBaseNameToAll, onApplyDefaultBaseNameToAll,
hasImages, hasImages,
}: SettingsPanelProps) { }: SettingsPanelProps) {
const t = useTranslations("SettingsPanel");
return ( return (
<Accordion type="single" collapsible defaultValue="image-settings" className="w-full space-y-4"> <Accordion type="single" collapsible defaultValue="image-settings" className="w-full space-y-4">
<AccordionItem value="image-settings" className="border rounded-lg bg-card"> <AccordionItem value="image-settings" className="border rounded-lg bg-card">
<AccordionTrigger className="p-6 hover:no-underline"> <AccordionTrigger className="p-6 hover:no-underline">
<div className="text-left"> <div className="text-left">
<h3 className="text-lg font-medium leading-none">Image Settings</h3> <h3 className="text-lg font-medium leading-none">{t('imageSettingsTitle')}</h3>
<p className="text-sm text-muted-foreground mt-1">Adjust resolution and scaling for all images.</p> <p className="text-sm text-muted-foreground mt-1">{t('imageSettingsSubtitle')}</p>
</div> </div>
</AccordionTrigger> </AccordionTrigger>
<AccordionContent className="px-6 pb-6"> <AccordionContent className="px-6 pb-6">
@@ -45,8 +47,8 @@ export function SettingsPanel({
<AccordionItem value="filename-settings" className="border rounded-lg bg-card"> <AccordionItem value="filename-settings" className="border rounded-lg bg-card">
<AccordionTrigger className="p-6 hover:no-underline"> <AccordionTrigger className="p-6 hover:no-underline">
<div className="text-left"> <div className="text-left">
<h3 className="text-lg font-medium leading-none">Filename Settings</h3> <h3 className="text-lg font-medium leading-none">{t('filenameSettingsTitle')}</h3>
<p className="text-sm text-muted-foreground mt-1">Customize the output filenames.</p> <p className="text-sm text-muted-foreground mt-1">{t('filenameSettingsSubtitle')}</p>
</div> </div>
</AccordionTrigger> </AccordionTrigger>
<AccordionContent className="px-6 pb-6"> <AccordionContent className="px-6 pb-6">
@@ -62,8 +64,8 @@ export function SettingsPanel({
<AccordionItem value="quality-settings" className="border rounded-lg bg-card"> <AccordionItem value="quality-settings" className="border rounded-lg bg-card">
<AccordionTrigger className="p-6 hover:no-underline"> <AccordionTrigger className="p-6 hover:no-underline">
<div className="text-left"> <div className="text-left">
<h3 className="text-lg font-medium leading-none">Quality Settings</h3> <h3 className="text-lg font-medium leading-none">{t('qualitySettingsTitle')}</h3>
<p className="text-sm text-muted-foreground mt-1">Choose format and compression level.</p> <p className="text-sm text-muted-foreground mt-1">{t('qualitySettingsSubtitle')}</p>
</div> </div>
</AccordionTrigger> </AccordionTrigger>
<AccordionContent className="px-6 pb-6"> <AccordionContent className="px-6 pb-6">

View File

@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { HelpCircle } from "lucide-react"; import { HelpCircle } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useTranslations } from "next-intl";
interface FilenameSettingsProps { interface FilenameSettingsProps {
settings: ConversionSettings; settings: ConversionSettings;
@@ -21,21 +22,22 @@ export function FilenameSettings({
onApplyDefaultBaseNameToAll, onApplyDefaultBaseNameToAll,
hasImages, hasImages,
}: FilenameSettingsProps) { }: FilenameSettingsProps) {
const t = useTranslations("FilenameSettings");
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Switch id="use-default-base-name" checked={settings.useDefaultBaseName} onCheckedChange={(checked) => onSettingsChange({ useDefaultBaseName: checked })} /> <Switch id="use-default-base-name" checked={settings.useDefaultBaseName} onCheckedChange={(checked) => onSettingsChange({ useDefaultBaseName: checked })} />
<Label htmlFor="use-default-base-name" className="flex items-center gap-1.5 cursor-pointer"> <Label htmlFor="use-default-base-name" className="flex items-center gap-1.5 cursor-pointer">
Use default base name {t('useDefaultBaseName')}
<Tooltip> <Tooltip>
<TooltipTrigger onClick={(e) => e.preventDefault()}><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger> <TooltipTrigger onClick={(e) => e.preventDefault()}><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger>
<TooltipContent><p>When enabled, all newly uploaded images will use the specified default base name.</p></TooltipContent> <TooltipContent><p>{t('useDefaultBaseNameTooltip')}</p></TooltipContent>
</Tooltip> </Tooltip>
</Label> </Label>
</div> </div>
{settings.useDefaultBaseName && ( {settings.useDefaultBaseName && (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="default-base-name">Default base name</Label> <Label htmlFor="default-base-name">{t('defaultBaseName')}</Label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input <Input
id="default-base-name" id="default-base-name"
@@ -46,30 +48,30 @@ export function FilenameSettings({
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="outline" size="sm" onClick={onApplyDefaultBaseNameToAll} disabled={!settings.defaultBaseName || !hasImages}> <Button variant="outline" size="sm" onClick={onApplyDefaultBaseNameToAll} disabled={!settings.defaultBaseName || !hasImages}>
Apply to all {t('applyToAll')}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent><p>Apply this base name to all currently uploaded images.</p></TooltipContent> <TooltipContent><p>{t('applyToAllTooltip')}</p></TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
)} )}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Label htmlFor="prefix">Prefix</Label> <Label htmlFor="prefix">{t('prefix')}</Label>
<Tooltip> <Tooltip>
<TooltipTrigger><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger> <TooltipTrigger><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger>
<TooltipContent><p>Add text to the beginning of every filename.</p></TooltipContent> <TooltipContent><p>{t('prefixTooltip')}</p></TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<Input id="prefix" placeholder="e.g., travel-" value={settings.prefix} onChange={(e) => onSettingsChange({ prefix: e.target.value })} /> <Input id="prefix" placeholder="e.g., travel-" value={settings.prefix} onChange={(e) => onSettingsChange({ prefix: e.target.value })} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Label htmlFor="suffix">Suffix</Label> <Label htmlFor="suffix">{t('suffix')}</Label>
<Tooltip> <Tooltip>
<TooltipTrigger><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger> <TooltipTrigger><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger>
<TooltipContent><p>Add text to the end of every filename (before the number).</p></TooltipContent> <TooltipContent><p>{t('suffixTooltip')}</p></TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<Input id="suffix" placeholder="e.g., -edit" value={settings.suffix} onChange={(e) => onSettingsChange({ suffix: e.target.value })} /> <Input id="suffix" placeholder="e.g., -edit" value={settings.suffix} onChange={(e) => onSettingsChange({ suffix: e.target.value })} />
@@ -77,10 +79,10 @@ export function FilenameSettings({
<div className="flex items-center space-x-2 pt-2"> <div className="flex items-center space-x-2 pt-2">
<Switch id="use-counter" checked={settings.useCounter} onCheckedChange={(checked) => onSettingsChange({ useCounter: checked })} /> <Switch id="use-counter" checked={settings.useCounter} onCheckedChange={(checked) => onSettingsChange({ useCounter: checked })} />
<Label htmlFor="use-counter" className="flex items-center gap-1.5 cursor-pointer"> <Label htmlFor="use-counter" className="flex items-center gap-1.5 cursor-pointer">
Add sequential number {t('addSequentialNumber')}
<Tooltip> <Tooltip>
<TooltipTrigger onClick={(e) => e.preventDefault()}><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger> <TooltipTrigger onClick={(e) => e.preventDefault()}><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger>
<TooltipContent><p>Append a numbered sequence to each filename.</p></TooltipContent> <TooltipContent><p>{t('addSequentialNumberTooltip')}</p></TooltipContent>
</Tooltip> </Tooltip>
</Label> </Label>
</div> </div>
@@ -88,10 +90,10 @@ export function FilenameSettings({
<div className="grid grid-cols-2 gap-4 pt-2"> <div className="grid grid-cols-2 gap-4 pt-2">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Label htmlFor="counter-start">Start number</Label> <Label htmlFor="counter-start">{t('startNumber')}</Label>
<Tooltip> <Tooltip>
<TooltipTrigger><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger> <TooltipTrigger><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger>
<TooltipContent><p>The first number to use in the sequence.</p></TooltipContent> <TooltipContent><p>{t('startNumberTooltip')}</p></TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<Input <Input
@@ -104,10 +106,10 @@ export function FilenameSettings({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Label htmlFor="counter-digits">Padding digits</Label> <Label htmlFor="counter-digits">{t('paddingDigits')}</Label>
<Tooltip> <Tooltip>
<TooltipTrigger><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger> <TooltipTrigger><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger>
<TooltipContent><p>Total number of digits for the counter, padded with leading zeros (e.g., 3 for 001).</p></TooltipContent> <TooltipContent><p>{t('paddingDigitsTooltip')}</p></TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<Input <Input

View File

@@ -9,14 +9,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import { ArrowRightLeft, HelpCircle } from "lucide-react"; import { ArrowRightLeft, HelpCircle } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { ObjectPositionControl } from "@/components/object-position-control"; import { ObjectPositionControl } from "@/components/object-position-control";
import { useTranslations } from "next-intl";
const aspectRatios = [
{ name: "Custom", value: "custom" },
{ name: "1:1 (Square)", value: "1/1" },
{ name: "4:3 (Standard)", value: "4/3" },
{ name: "3:2 (Photography)", value: "3/2" },
{ name: "16:9 (Widescreen)", value: "16/9" },
];
interface ImageSettingsProps { interface ImageSettingsProps {
settings: ConversionSettings; settings: ConversionSettings;
@@ -31,14 +24,24 @@ export function ImageSettings({
onAspectRatioChange, onAspectRatioChange,
onSwapDimensions, onSwapDimensions,
}: ImageSettingsProps) { }: ImageSettingsProps) {
const t = useTranslations("ImageSettings");
const aspectRatios = [
{ name: t('custom'), value: "custom" },
{ name: t('square'), value: "1/1" },
{ name: t('standard'), value: "4/3" },
{ name: t('photography'), value: "3/2" },
{ name: t('widescreen'), value: "16/9" },
];
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Label htmlFor="aspect-ratio">Aspect Ratio</Label> <Label htmlFor="aspect-ratio">{t('aspectRatio')}</Label>
<Tooltip> <Tooltip>
<TooltipTrigger><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger> <TooltipTrigger><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger>
<TooltipContent><p>Choose a preset aspect ratio or select 'Custom' to enter dimensions manually.</p></TooltipContent> <TooltipContent><p>{t('aspectRatioTooltip')}</p></TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<Select value={settings.aspectRatio} onValueChange={onAspectRatioChange}> <Select value={settings.aspectRatio} onValueChange={onAspectRatioChange}>
@@ -53,10 +56,10 @@ export function ImageSettings({
<div className="flex items-end gap-2"> <div className="flex items-end gap-2">
<div className="space-y-2 flex-1"> <div className="space-y-2 flex-1">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Label htmlFor="width">Width (px)</Label> <Label htmlFor="width">{t('width')}</Label>
<Tooltip> <Tooltip>
<TooltipTrigger><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger> <TooltipTrigger><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger>
<TooltipContent><p>Set the output width in pixels. Leave blank to use the original width.</p></TooltipContent> <TooltipContent><p>{t('widthTooltip')}</p></TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<Input id="width" type="number" placeholder="Auto" value={settings.width} onChange={(e) => { onSettingsChange({ width: e.target.value, aspectRatio: 'custom' }) }} /> <Input id="width" type="number" placeholder="Auto" value={settings.width} onChange={(e) => { onSettingsChange({ width: e.target.value, aspectRatio: 'custom' }) }} />
@@ -67,14 +70,14 @@ export function ImageSettings({
<ArrowRightLeft className="h-4 w-4" /> <ArrowRightLeft className="h-4 w-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent><p>Swap the entered width and height values.</p></TooltipContent> <TooltipContent><p>{t('swapTooltip')}</p></TooltipContent>
</Tooltip> </Tooltip>
<div className="space-y-2 flex-1"> <div className="space-y-2 flex-1">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Label htmlFor="height">Height (px)</Label> <Label htmlFor="height">{t('height')}</Label>
<Tooltip> <Tooltip>
<TooltipTrigger><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger> <TooltipTrigger><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger>
<TooltipContent><p>Set the output height in pixels. Leave blank to use the original height.</p></TooltipContent> <TooltipContent><p>{t('heightTooltip')}</p></TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<Input id="height" type="number" placeholder="Auto" value={settings.height} onChange={(e) => { onSettingsChange({ height: e.target.value, aspectRatio: 'custom' }) }} /> <Input id="height" type="number" placeholder="Auto" value={settings.height} onChange={(e) => { onSettingsChange({ height: e.target.value, aspectRatio: 'custom' }) }} />
@@ -83,37 +86,37 @@ export function ImageSettings({
<div className="flex items-center space-x-2 pt-2"> <div className="flex items-center space-x-2 pt-2">
<Checkbox id="keep-orientation" checked={settings.keepOrientation} onCheckedChange={(checked) => onSettingsChange({ keepOrientation: Boolean(checked) })} /> <Checkbox id="keep-orientation" checked={settings.keepOrientation} onCheckedChange={(checked) => onSettingsChange({ keepOrientation: Boolean(checked) })} />
<Label htmlFor="keep-orientation" className="cursor-pointer flex items-center gap-1.5"> <Label htmlFor="keep-orientation" className="cursor-pointer flex items-center gap-1.5">
Keep original orientation {t('keepOrientation')}
<Tooltip> <Tooltip>
<TooltipTrigger onClick={(e) => e.preventDefault()}><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger> <TooltipTrigger onClick={(e) => e.preventDefault()}><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger>
<TooltipContent><p>Automatically swaps width and height to match the original image's orientation.</p></TooltipContent> <TooltipContent><p>{t('keepOrientationTooltip')}</p></TooltipContent>
</Tooltip> </Tooltip>
</Label> </Label>
</div> </div>
<div className="mt-4 space-y-2"> <div className="mt-4 space-y-2">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Label htmlFor="scale-mode">Scaling</Label> <Label htmlFor="scale-mode">{t('scaling')}</Label>
<Tooltip> <Tooltip>
<TooltipTrigger><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger> <TooltipTrigger><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger>
<TooltipContent><p>Determines how the image fits into the new dimensions.</p></TooltipContent> <TooltipContent><p>{t('scalingTooltip')}</p></TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<Select value={settings.scaleMode} onValueChange={(value) => onSettingsChange({ scaleMode: value as any })}> <Select value={settings.scaleMode} onValueChange={(value) => onSettingsChange({ scaleMode: value as any })}>
<SelectTrigger id="scale-mode"><SelectValue placeholder="Select scaling mode" /></SelectTrigger> <SelectTrigger id="scale-mode"><SelectValue placeholder="Select scaling mode" /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="fill">Fill (stretch to fit)</SelectItem> <SelectItem value="fill">{t('fill')}</SelectItem>
<SelectItem value="cover">Cover (crop to fit)</SelectItem> <SelectItem value="cover">{t('cover')}</SelectItem>
<SelectItem value="contain">Contain (letterbox)</SelectItem> <SelectItem value="contain">{t('contain')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{settings.scaleMode !== 'fill' && ( {settings.scaleMode !== 'fill' && (
<div className="mt-4 space-y-2"> <div className="mt-4 space-y-2">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Label>Position</Label> <Label>{t('position')}</Label>
<Tooltip> <Tooltip>
<TooltipTrigger><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger> <TooltipTrigger><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger>
<TooltipContent><p>Sets the anchor point for 'Cover' or 'Contain' scaling.</p></TooltipContent> <TooltipContent><p>{t('positionTooltip')}</p></TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<ObjectPositionControl value={settings.objectPosition} onChange={(pos) => onSettingsChange({ objectPosition: pos as ObjectPosition })} /> <ObjectPositionControl value={settings.objectPosition} onChange={(pos) => onSettingsChange({ objectPosition: pos as ObjectPosition })} />

View File

@@ -6,6 +6,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import { HelpCircle } from "lucide-react"; import { HelpCircle } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useTranslations } from "next-intl";
interface QualitySettingsProps { interface QualitySettingsProps {
settings: ConversionSettings; settings: ConversionSettings;
@@ -13,14 +14,15 @@ interface QualitySettingsProps {
} }
export function QualitySettings({ settings, onSettingsChange }: QualitySettingsProps) { export function QualitySettings({ settings, onSettingsChange }: QualitySettingsProps) {
const t = useTranslations("QualitySettings");
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Label htmlFor="format">Format</Label> <Label htmlFor="format">{t('format')}</Label>
<Tooltip> <Tooltip>
<TooltipTrigger><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger> <TooltipTrigger><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger>
<TooltipContent><p>Choose the output file format for the images.</p></TooltipContent> <TooltipContent><p>{t('formatTooltip')}</p></TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<Select value={settings.format} onValueChange={(value: ImageFormat) => onSettingsChange({ format: value })}> <Select value={settings.format} onValueChange={(value: ImageFormat) => onSettingsChange({ format: value })}>
@@ -35,10 +37,10 @@ export function QualitySettings({ settings, onSettingsChange }: QualitySettingsP
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Label htmlFor="quality">Quality</Label> <Label htmlFor="quality">{t('quality')}</Label>
<Tooltip> <Tooltip>
<TooltipTrigger><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger> <TooltipTrigger><HelpCircle className="h-4 w-4 text-muted-foreground" /></TooltipTrigger>
<TooltipContent><p>Set compression quality for JPEG/WEBP. Higher is better quality but larger file size.</p></TooltipContent> <TooltipContent><p>{t('qualityTooltip')}</p></TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<span className="text-sm text-muted-foreground">{settings.quality}%</span> <span className="text-sm text-muted-foreground">{settings.quality}%</span>
@@ -53,7 +55,7 @@ export function QualitySettings({ settings, onSettingsChange }: QualitySettingsP
disabled={settings.format === 'png'} disabled={settings.format === 'png'}
/> />
{settings.format === 'png' && ( {settings.format === 'png' && (
<p className="text-xs text-muted-foreground pt-1">Quality slider is disabled for PNG (lossless format).</p> <p className="text-xs text-muted-foreground pt-1">{t('pngWarning')}</p>
)} )}
</div> </div>
</div> </div>

14
src/i18n.ts Normal file
View File

@@ -0,0 +1,14 @@
import {getRequestConfig} from 'next-intl/server';
export const locales = ['en'];
export const defaultLocale = 'en';
export default getRequestConfig(async ({locale}) => {
if (!locales.includes(locale as any)) {
locale = defaultLocale;
}
return {
messages: (await import(`../messages/${locale}.json`)).default
};
});

10
src/middleware.ts Normal file
View File

@@ -0,0 +1,10 @@
import createMiddleware from 'next-intl/middleware';
export default createMiddleware({
locales: ['en'],
defaultLocale: 'en'
});
export const config = {
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)']
};