feat: watermark tool complete — parallel processing, logo opacity, preview fix, logo upload fix, rename to Fotoğraf Damgala
This commit is contained in:
@@ -47,6 +47,7 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"driver.js": "^1.4.0",
|
"driver.js": "^1.4.0",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"maplibre-gl": "^5.24.0",
|
"maplibre-gl": "^5.24.0",
|
||||||
"next": "16.1.1",
|
"next": "16.1.1",
|
||||||
@@ -69,6 +70,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.3",
|
"@eslint/eslintrc": "^3.3.3",
|
||||||
|
"@types/jszip": "^3.4.1",
|
||||||
"@types/node": "^25.0.3",
|
"@types/node": "^25.0.3",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|||||||
Generated
+96
@@ -119,6 +119,9 @@ importers:
|
|||||||
driver.js:
|
driver.js:
|
||||||
specifier: ^1.4.0
|
specifier: ^1.4.0
|
||||||
version: 1.4.0
|
version: 1.4.0
|
||||||
|
jszip:
|
||||||
|
specifier: ^3.10.1
|
||||||
|
version: 3.10.1
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.562.0
|
specifier: ^0.562.0
|
||||||
version: 0.562.0(react@19.2.3)
|
version: 0.562.0(react@19.2.3)
|
||||||
@@ -180,6 +183,9 @@ importers:
|
|||||||
'@eslint/eslintrc':
|
'@eslint/eslintrc':
|
||||||
specifier: ^3.3.3
|
specifier: ^3.3.3
|
||||||
version: 3.3.3
|
version: 3.3.3
|
||||||
|
'@types/jszip':
|
||||||
|
specifier: ^3.4.1
|
||||||
|
version: 3.4.1
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^25.0.3
|
specifier: ^25.0.3
|
||||||
version: 25.0.3
|
version: 25.0.3
|
||||||
@@ -1628,6 +1634,10 @@ packages:
|
|||||||
'@types/json5@0.0.29':
|
'@types/json5@0.0.29':
|
||||||
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
|
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
|
||||||
|
|
||||||
|
'@types/jszip@3.4.1':
|
||||||
|
resolution: {integrity: sha512-TezXjmf3lj+zQ651r6hPqvSScqBLvyPI9FxdXBqpEwBijNGQ2NXpaFW/7joGzveYkKQUil7iiDHLo6LV71Pc0A==}
|
||||||
|
deprecated: This is a stub types definition. jszip provides its own type definitions, so you do not need this installed.
|
||||||
|
|
||||||
'@types/node@25.0.3':
|
'@types/node@25.0.3':
|
||||||
resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==}
|
resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==}
|
||||||
|
|
||||||
@@ -1970,6 +1980,9 @@ packages:
|
|||||||
convert-source-map@2.0.0:
|
convert-source-map@2.0.0:
|
||||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||||
|
|
||||||
|
core-util-is@1.0.3:
|
||||||
|
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -2425,6 +2438,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
|
|
||||||
|
immediate@3.0.6:
|
||||||
|
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
||||||
|
|
||||||
immer@10.2.0:
|
immer@10.2.0:
|
||||||
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
|
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
|
||||||
|
|
||||||
@@ -2439,6 +2455,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
|
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
|
||||||
engines: {node: '>=0.8.19'}
|
engines: {node: '>=0.8.19'}
|
||||||
|
|
||||||
|
inherits@2.0.4:
|
||||||
|
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||||
|
|
||||||
internal-slot@1.1.0:
|
internal-slot@1.1.0:
|
||||||
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2550,6 +2569,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
|
resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
isarray@1.0.0:
|
||||||
|
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
||||||
|
|
||||||
isarray@2.0.5:
|
isarray@2.0.5:
|
||||||
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
|
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
|
||||||
|
|
||||||
@@ -2604,6 +2626,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
||||||
engines: {node: '>=4.0'}
|
engines: {node: '>=4.0'}
|
||||||
|
|
||||||
|
jszip@3.10.1:
|
||||||
|
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
||||||
|
|
||||||
kdbush@4.0.2:
|
kdbush@4.0.2:
|
||||||
resolution: {integrity: sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==}
|
resolution: {integrity: sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==}
|
||||||
|
|
||||||
@@ -2621,6 +2646,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
|
lie@3.3.0:
|
||||||
|
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
|
||||||
|
|
||||||
lightningcss-android-arm64@1.30.2:
|
lightningcss-android-arm64@1.30.2:
|
||||||
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
|
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
@@ -2846,6 +2874,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
pako@1.0.11:
|
||||||
|
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
||||||
|
|
||||||
parent-module@1.0.1:
|
parent-module@1.0.1:
|
||||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -2895,6 +2926,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
|
process-nextick-args@2.0.1:
|
||||||
|
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||||
|
|
||||||
prop-types@15.8.1:
|
prop-types@15.8.1:
|
||||||
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||||
|
|
||||||
@@ -2999,6 +3033,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
|
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
readable-stream@2.3.8:
|
||||||
|
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
|
||||||
|
|
||||||
recharts@3.6.0:
|
recharts@3.6.0:
|
||||||
resolution: {integrity: sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg==}
|
resolution: {integrity: sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -3056,6 +3093,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
|
resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
|
||||||
engines: {node: '>=0.4'}
|
engines: {node: '>=0.4'}
|
||||||
|
|
||||||
|
safe-buffer@5.1.2:
|
||||||
|
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
|
||||||
|
|
||||||
safe-push-apply@1.0.0:
|
safe-push-apply@1.0.0:
|
||||||
resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==}
|
resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -3088,6 +3128,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
|
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
setimmediate@1.0.5:
|
||||||
|
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
|
||||||
|
|
||||||
sharp@0.34.5:
|
sharp@0.34.5:
|
||||||
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
@@ -3159,6 +3202,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
|
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
string_decoder@1.1.1:
|
||||||
|
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
|
||||||
|
|
||||||
strip-bom@3.0.0:
|
strip-bom@3.0.0:
|
||||||
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
|
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -3309,6 +3355,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
|
util-deprecate@1.0.2:
|
||||||
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
|
|
||||||
uzip@0.20201231.0:
|
uzip@0.20201231.0:
|
||||||
resolution: {integrity: sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==}
|
resolution: {integrity: sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==}
|
||||||
|
|
||||||
@@ -4783,6 +4832,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/json5@0.0.29': {}
|
'@types/json5@0.0.29': {}
|
||||||
|
|
||||||
|
'@types/jszip@3.4.1':
|
||||||
|
dependencies:
|
||||||
|
jszip: 3.10.1
|
||||||
|
|
||||||
'@types/node@25.0.3':
|
'@types/node@25.0.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.16.0
|
undici-types: 7.16.0
|
||||||
@@ -5146,6 +5199,8 @@ snapshots:
|
|||||||
|
|
||||||
convert-source-map@2.0.0: {}
|
convert-source-map@2.0.0: {}
|
||||||
|
|
||||||
|
core-util-is@1.0.3: {}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-key: 3.1.1
|
path-key: 3.1.1
|
||||||
@@ -5729,6 +5784,8 @@ snapshots:
|
|||||||
|
|
||||||
ignore@7.0.5: {}
|
ignore@7.0.5: {}
|
||||||
|
|
||||||
|
immediate@3.0.6: {}
|
||||||
|
|
||||||
immer@10.2.0: {}
|
immer@10.2.0: {}
|
||||||
|
|
||||||
immer@11.1.3: {}
|
immer@11.1.3: {}
|
||||||
@@ -5740,6 +5797,8 @@ snapshots:
|
|||||||
|
|
||||||
imurmurhash@0.1.4: {}
|
imurmurhash@0.1.4: {}
|
||||||
|
|
||||||
|
inherits@2.0.4: {}
|
||||||
|
|
||||||
internal-slot@1.1.0:
|
internal-slot@1.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
@@ -5860,6 +5919,8 @@ snapshots:
|
|||||||
call-bound: 1.0.4
|
call-bound: 1.0.4
|
||||||
get-intrinsic: 1.3.0
|
get-intrinsic: 1.3.0
|
||||||
|
|
||||||
|
isarray@1.0.0: {}
|
||||||
|
|
||||||
isarray@2.0.5: {}
|
isarray@2.0.5: {}
|
||||||
|
|
||||||
isexe@2.0.0: {}
|
isexe@2.0.0: {}
|
||||||
@@ -5908,6 +5969,13 @@ snapshots:
|
|||||||
object.assign: 4.1.7
|
object.assign: 4.1.7
|
||||||
object.values: 1.2.1
|
object.values: 1.2.1
|
||||||
|
|
||||||
|
jszip@3.10.1:
|
||||||
|
dependencies:
|
||||||
|
lie: 3.3.0
|
||||||
|
pako: 1.0.11
|
||||||
|
readable-stream: 2.3.8
|
||||||
|
setimmediate: 1.0.5
|
||||||
|
|
||||||
kdbush@4.0.2: {}
|
kdbush@4.0.2: {}
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
@@ -5925,6 +5993,10 @@ snapshots:
|
|||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
type-check: 0.4.0
|
type-check: 0.4.0
|
||||||
|
|
||||||
|
lie@3.3.0:
|
||||||
|
dependencies:
|
||||||
|
immediate: 3.0.6
|
||||||
|
|
||||||
lightningcss-android-arm64@1.30.2:
|
lightningcss-android-arm64@1.30.2:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -6150,6 +6222,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
p-limit: 3.1.0
|
p-limit: 3.1.0
|
||||||
|
|
||||||
|
pako@1.0.11: {}
|
||||||
|
|
||||||
parent-module@1.0.1:
|
parent-module@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
callsites: 3.1.0
|
callsites: 3.1.0
|
||||||
@@ -6188,6 +6262,8 @@ snapshots:
|
|||||||
|
|
||||||
prelude-ls@1.2.1: {}
|
prelude-ls@1.2.1: {}
|
||||||
|
|
||||||
|
process-nextick-args@2.0.1: {}
|
||||||
|
|
||||||
prop-types@15.8.1:
|
prop-types@15.8.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
loose-envify: 1.4.0
|
loose-envify: 1.4.0
|
||||||
@@ -6328,6 +6404,16 @@ snapshots:
|
|||||||
|
|
||||||
react@19.2.3: {}
|
react@19.2.3: {}
|
||||||
|
|
||||||
|
readable-stream@2.3.8:
|
||||||
|
dependencies:
|
||||||
|
core-util-is: 1.0.3
|
||||||
|
inherits: 2.0.4
|
||||||
|
isarray: 1.0.0
|
||||||
|
process-nextick-args: 2.0.1
|
||||||
|
safe-buffer: 5.1.2
|
||||||
|
string_decoder: 1.1.1
|
||||||
|
util-deprecate: 1.0.2
|
||||||
|
|
||||||
recharts@3.6.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react-is@18.3.1)(react@19.2.3)(redux@5.0.1):
|
recharts@3.6.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react-is@18.3.1)(react@19.2.3)(redux@5.0.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1))(react@19.2.3)
|
'@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1))(react@19.2.3)
|
||||||
@@ -6410,6 +6496,8 @@ snapshots:
|
|||||||
has-symbols: 1.1.0
|
has-symbols: 1.1.0
|
||||||
isarray: 2.0.5
|
isarray: 2.0.5
|
||||||
|
|
||||||
|
safe-buffer@5.1.2: {}
|
||||||
|
|
||||||
safe-push-apply@1.0.0:
|
safe-push-apply@1.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
@@ -6449,6 +6537,8 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
es-object-atoms: 1.1.1
|
es-object-atoms: 1.1.1
|
||||||
|
|
||||||
|
setimmediate@1.0.5: {}
|
||||||
|
|
||||||
sharp@0.34.5:
|
sharp@0.34.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@img/colour': 1.0.0
|
'@img/colour': 1.0.0
|
||||||
@@ -6584,6 +6674,10 @@ snapshots:
|
|||||||
define-properties: 1.2.1
|
define-properties: 1.2.1
|
||||||
es-object-atoms: 1.1.1
|
es-object-atoms: 1.1.1
|
||||||
|
|
||||||
|
string_decoder@1.1.1:
|
||||||
|
dependencies:
|
||||||
|
safe-buffer: 5.1.2
|
||||||
|
|
||||||
strip-bom@3.0.0: {}
|
strip-bom@3.0.0: {}
|
||||||
|
|
||||||
strip-json-comments@3.1.1: {}
|
strip-json-comments@3.1.1: {}
|
||||||
@@ -6755,6 +6849,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.3
|
react: 19.2.3
|
||||||
|
|
||||||
|
util-deprecate@1.0.2: {}
|
||||||
|
|
||||||
uzip@0.20201231.0: {}
|
uzip@0.20201231.0: {}
|
||||||
|
|
||||||
vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useActionState, useEffect, useRef, useState, useTransition } from "react";
|
import { useEffect, useRef, useState, useTransition } from "react";
|
||||||
import { Buildings, ImageSquare, CircleNotch, Trash, Upload } from '@/lib/icons';
|
import { Buildings, ImageSquare, CircleNotch, Trash } from '@/lib/icons';
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
removeLogoAction,
|
removeLogoAction,
|
||||||
uploadLogoAction,
|
uploadLogoAction,
|
||||||
} from "@/lib/appwrite/logo-actions";
|
} from "@/lib/appwrite/logo-actions";
|
||||||
import { initialLogoState } from "@/lib/appwrite/logo-types";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
@@ -29,32 +28,40 @@ const MAX_BYTES = 2 * 1024 * 1024;
|
|||||||
const ALLOWED_MIME = ["image/png", "image/jpeg", "image/webp", "image/svg+xml"];
|
const ALLOWED_MIME = ["image/png", "image/jpeg", "image/webp", "image/svg+xml"];
|
||||||
|
|
||||||
export function LogoUploader({ canEdit, currentLogoUrl, companyName }: Props) {
|
export function LogoUploader({ canEdit, currentLogoUrl, companyName }: Props) {
|
||||||
const [state, formAction, isPending] = useActionState(
|
const [uploading, startUpload] = useTransition();
|
||||||
uploadLogoAction,
|
|
||||||
initialLogoState,
|
|
||||||
);
|
|
||||||
const [removing, startRemove] = useTransition();
|
const [removing, startRemove] = useTransition();
|
||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(currentLogoUrl);
|
const [previewUrl, setPreviewUrl] = useState<string | null>(currentLogoUrl);
|
||||||
const [dragOver, setDragOver] = useState(false);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
const [selectedName, setSelectedName] = useState<string | null>(null);
|
const [progress, setProgress] = useState(0);
|
||||||
const formRef = useRef<HTMLFormElement>(null);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const progressInterval = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPreviewUrl(currentLogoUrl);
|
setPreviewUrl(currentLogoUrl);
|
||||||
}, [currentLogoUrl]);
|
}, [currentLogoUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
function startProgressAnimation() {
|
||||||
if (state.ok) {
|
setProgress(5);
|
||||||
toast.success("Logo güncellendi.");
|
if (progressInterval.current) clearInterval(progressInterval.current);
|
||||||
setSelectedName(null);
|
progressInterval.current = setInterval(() => {
|
||||||
} else if (state.error) {
|
setProgress((p) => {
|
||||||
toast.error(state.error);
|
if (p >= 85) return p;
|
||||||
|
return p + Math.random() * 12 + 3;
|
||||||
|
});
|
||||||
|
}, 250);
|
||||||
}
|
}
|
||||||
}, [state]);
|
|
||||||
|
|
||||||
const handleFile = (file: File | null) => {
|
function stopProgress(success: boolean) {
|
||||||
if (!file) return;
|
if (progressInterval.current) clearInterval(progressInterval.current);
|
||||||
|
if (success) {
|
||||||
|
setProgress(100);
|
||||||
|
setTimeout(() => setProgress(0), 1200);
|
||||||
|
} else {
|
||||||
|
setProgress(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadFile(file: File) {
|
||||||
if (!ALLOWED_MIME.includes(file.type)) {
|
if (!ALLOWED_MIME.includes(file.type)) {
|
||||||
toast.error("Sadece PNG, JPG, WebP veya SVG yükleyebilirsiniz.");
|
toast.error("Sadece PNG, JPG, WebP veya SVG yükleyebilirsiniz.");
|
||||||
return;
|
return;
|
||||||
@@ -63,42 +70,59 @@ export function LogoUploader({ canEdit, currentLogoUrl, companyName }: Props) {
|
|||||||
toast.error("Dosya 2MB'dan büyük olamaz.");
|
toast.error("Dosya 2MB'dan büyük olamaz.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSelectedName(file.name);
|
|
||||||
|
// Show local preview immediately
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (e) => {
|
reader.onload = (e) => {
|
||||||
setPreviewUrl(typeof e.target?.result === "string" ? e.target.result : null);
|
if (typeof e.target?.result === "string") setPreviewUrl(e.target.result);
|
||||||
};
|
};
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
|
// Auto-upload
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("logo", file);
|
||||||
|
startProgressAnimation();
|
||||||
|
|
||||||
|
startUpload(async () => {
|
||||||
|
const result = await uploadLogoAction(null, formData);
|
||||||
|
stopProgress(result.ok);
|
||||||
|
if (result.ok) {
|
||||||
|
toast.success("Logo güncellendi.");
|
||||||
|
} else {
|
||||||
|
toast.error(result.error ?? "Logo yüklenemedi.");
|
||||||
|
setPreviewUrl(currentLogoUrl); // revert preview
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInputChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) uploadFile(file);
|
||||||
|
// reset so same file can be re-selected
|
||||||
|
e.target.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(e: React.DragEvent<HTMLLabelElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setDragOver(false);
|
setDragOver(false);
|
||||||
const file = e.dataTransfer.files?.[0];
|
const file = e.dataTransfer.files?.[0];
|
||||||
if (file && inputRef.current) {
|
if (file) uploadFile(file);
|
||||||
const dt = new DataTransfer();
|
|
||||||
dt.items.add(file);
|
|
||||||
inputRef.current.files = dt.files;
|
|
||||||
handleFile(file);
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemove = () => {
|
function handleRemove() {
|
||||||
startRemove(async () => {
|
startRemove(async () => {
|
||||||
const result = await removeLogoAction();
|
const result = await removeLogoAction();
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
toast.success("Logo kaldırıldı.");
|
toast.success("Logo kaldırıldı.");
|
||||||
setPreviewUrl(null);
|
setPreviewUrl(null);
|
||||||
setSelectedName(null);
|
|
||||||
if (inputRef.current) inputRef.current.value = "";
|
if (inputRef.current) inputRef.current.value = "";
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error ?? "Logo kaldırılamadı.");
|
toast.error(result.error ?? "Logo kaldırılamadı.");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
|
||||||
const submitDisabled = isPending || removing || !selectedName;
|
const busy = uploading || removing;
|
||||||
const busy = isPending || removing;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -113,8 +137,8 @@ export function LogoUploader({ canEdit, currentLogoUrl, companyName }: Props) {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form ref={formRef} action={formAction} className="space-y-4">
|
|
||||||
<div className="grid items-start gap-6 md:grid-cols-[200px_1fr]">
|
<div className="grid items-start gap-6 md:grid-cols-[200px_1fr]">
|
||||||
|
{/* Preview */}
|
||||||
<div className="bg-muted flex aspect-square w-full items-center justify-center overflow-hidden rounded-lg border">
|
<div className="bg-muted flex aspect-square w-full items-center justify-center overflow-hidden rounded-lg border">
|
||||||
{previewUrl ? (
|
{previewUrl ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
@@ -132,58 +156,57 @@ export function LogoUploader({ canEdit, currentLogoUrl, companyName }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
{/* Drop zone — auto-uploads on select */}
|
||||||
<label
|
<label
|
||||||
onDragOver={(e) => {
|
onDragOver={(e) => { e.preventDefault(); if (canEdit) setDragOver(true); }}
|
||||||
e.preventDefault();
|
|
||||||
if (canEdit) setDragOver(true);
|
|
||||||
}}
|
|
||||||
onDragLeave={() => setDragOver(false)}
|
onDragLeave={() => setDragOver(false)}
|
||||||
onDrop={canEdit ? handleDrop : undefined}
|
onDrop={canEdit ? handleDrop : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-[120px] cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-6 text-center transition-colors",
|
"flex min-h-[120px] flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-6 text-center transition-colors",
|
||||||
|
canEdit && !busy ? "cursor-pointer hover:bg-muted/30" : "cursor-not-allowed opacity-60",
|
||||||
dragOver && "border-primary bg-primary/5",
|
dragOver && "border-primary bg-primary/5",
|
||||||
!canEdit && "cursor-not-allowed opacity-60",
|
|
||||||
!dragOver && "hover:bg-muted/30",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="file"
|
type="file"
|
||||||
name="logo"
|
|
||||||
accept={ALLOWED_MIME.join(",")}
|
accept={ALLOWED_MIME.join(",")}
|
||||||
className="sr-only"
|
className="sr-only"
|
||||||
disabled={!canEdit || busy}
|
disabled={!canEdit || busy}
|
||||||
onChange={(e) => handleFile(e.target.files?.[0] ?? null)}
|
onChange={handleInputChange}
|
||||||
/>
|
/>
|
||||||
<ImageSquare className="text-muted-foreground size-6" />
|
{uploading ? (
|
||||||
|
<CircleNotch className="size-6 text-muted-foreground animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ImageSquare className="size-6 text-muted-foreground" />
|
||||||
|
)}
|
||||||
<div className="text-sm font-medium">
|
<div className="text-sm font-medium">
|
||||||
{selectedName ?? "Logo yüklemek için tıkla veya sürükle bırak"}
|
{uploading ? "Yükleniyor…" : "Logo yüklemek için tıkla veya sürükle bırak"}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground text-xs">
|
<div className="text-muted-foreground text-xs">
|
||||||
Önerilen: kare, en az 256×256 px, şeffaf arka plan (PNG/SVG)
|
Önerilen: kare, en az 256×256 px, şeffaf arka plan (PNG/SVG)
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
{/* Progress bar */}
|
||||||
{canEdit && (
|
{progress > 0 && (
|
||||||
<Button type="submit" disabled={submitDisabled}>
|
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden">
|
||||||
{isPending ? (
|
<div
|
||||||
<>
|
className={cn(
|
||||||
<CircleNotch className="size-4 animate-spin" />
|
"h-full rounded-full transition-all",
|
||||||
Yükleniyor...
|
progress === 100 ? "bg-green-500 duration-300" : "bg-primary duration-200",
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Upload className="size-4" />
|
|
||||||
Yükle
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{canEdit && currentLogoUrl && (
|
|
||||||
|
{/* Remove button */}
|
||||||
|
{canEdit && previewUrl && !uploading && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
onClick={handleRemove}
|
onClick={handleRemove}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="text-destructive hover:text-destructive"
|
className="text-destructive hover:text-destructive"
|
||||||
@@ -193,9 +216,10 @@ export function LogoUploader({ canEdit, currentLogoUrl, companyName }: Props) {
|
|||||||
) : (
|
) : (
|
||||||
<Trash className="size-4" />
|
<Trash className="size-4" />
|
||||||
)}
|
)}
|
||||||
Kaldır
|
Logoyu Kaldır
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!canEdit && (
|
{!canEdit && (
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
Logo değiştirmek için yönetici yetkisi gerekli.
|
Logo değiştirmek için yönetici yetkisi gerekli.
|
||||||
@@ -203,8 +227,6 @@ export function LogoUploader({ canEdit, currentLogoUrl, companyName }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
|
export default function ToolsLoading() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 p-4 md:p-6">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Skeleton className="h-6 w-28" />
|
||||||
|
<Skeleton className="h-4 w-64" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-6 flex-col lg:flex-row">
|
||||||
|
<div className="w-full lg:w-60 space-y-4">
|
||||||
|
<Skeleton className="h-16 w-full rounded-lg" />
|
||||||
|
<Skeleton className="h-32 w-full rounded-lg" />
|
||||||
|
<Skeleton className="h-10 w-full rounded-lg" />
|
||||||
|
<Skeleton className="h-10 w-full rounded-lg" />
|
||||||
|
<Skeleton className="h-9 w-full rounded-md" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-4">
|
||||||
|
<Skeleton className="h-40 w-full rounded-xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||||
|
import { getLogoUrl } from "@/lib/appwrite/storage";
|
||||||
|
import { WatermarkClient } from "./watermark-client";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Fotoğraf Damgala — KovakEmlak CRM",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchLogoAsDataUrl(logoUrl: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(logoUrl, { next: { revalidate: 3600 } });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const buffer = Buffer.from(await res.arrayBuffer());
|
||||||
|
const mime = res.headers.get("content-type") ?? "image/png";
|
||||||
|
return `data:${mime};base64,${buffer.toString("base64")}`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function WatermarkPage() {
|
||||||
|
const ctx = await requireTenant();
|
||||||
|
const logoUrl = getLogoUrl(ctx.settings?.logo ?? null);
|
||||||
|
const logoDataUrl = logoUrl ? await fetchLogoAsDataUrl(logoUrl) : null;
|
||||||
|
return <WatermarkClient logoDataUrl={logoDataUrl} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,606 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
Upload, Download, CircleNotch, CheckCircle, XCircle, Repeat, WarningCircle,
|
||||||
|
} from "@/lib/icons";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type Position =
|
||||||
|
| "top-left" | "top-center" | "top-right"
|
||||||
|
| "middle-left" | "center" | "middle-right"
|
||||||
|
| "bottom-left" | "bottom-center" | "bottom-right"
|
||||||
|
| "tiled";
|
||||||
|
|
||||||
|
const GRID: Position[][] = [
|
||||||
|
["top-left", "top-center", "top-right"],
|
||||||
|
["middle-left", "center", "middle-right"],
|
||||||
|
["bottom-left", "bottom-center", "bottom-right"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const POS_LABELS: Record<Position, string> = {
|
||||||
|
"top-left": "Sol Üst", "top-center": "Üst Orta", "top-right": "Sağ Üst",
|
||||||
|
"middle-left": "Sol Orta", "center": "Merkez", "middle-right": "Sağ Orta",
|
||||||
|
"bottom-left": "Sol Alt", "bottom-center": "Alt Orta", "bottom-right": "Sağ Alt",
|
||||||
|
"tiled": "Tekrar",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface WatermarkPrefs {
|
||||||
|
position: Position;
|
||||||
|
logoSizePct: number; // 5–40
|
||||||
|
logoOpacity: number; // 10–100
|
||||||
|
bgEnabled: boolean;
|
||||||
|
bgOpacity: number; // 10–60
|
||||||
|
bgColor: "white" | "dark";
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PREFS: WatermarkPrefs = {
|
||||||
|
position: "bottom-right",
|
||||||
|
logoSizePct: 20,
|
||||||
|
logoOpacity: 100,
|
||||||
|
bgEnabled: true,
|
||||||
|
bgOpacity: 25,
|
||||||
|
bgColor: "white",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PREFS_KEY = "kovak-wm-prefs-v1";
|
||||||
|
|
||||||
|
interface WFile {
|
||||||
|
id: string;
|
||||||
|
file: File;
|
||||||
|
originalUrl: string;
|
||||||
|
blob?: Blob;
|
||||||
|
status: "idle" | "processing" | "done" | "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Canvas helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function loadImg(src: string): Promise<HTMLImageElement> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve(img);
|
||||||
|
img.onerror = reject;
|
||||||
|
img.src = src;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type FixedPos = Exclude<Position, "tiled">;
|
||||||
|
|
||||||
|
function calcXY(
|
||||||
|
pos: FixedPos,
|
||||||
|
iW: number, iH: number,
|
||||||
|
lW: number, lH: number,
|
||||||
|
pad: number,
|
||||||
|
): [number, number] {
|
||||||
|
const map: Record<FixedPos, [number, number]> = {
|
||||||
|
"top-left": [pad, pad],
|
||||||
|
"top-center": [(iW - lW) / 2, pad],
|
||||||
|
"top-right": [iW - lW - pad, pad],
|
||||||
|
"middle-left": [pad, (iH - lH) / 2],
|
||||||
|
"center": [(iW - lW) / 2, (iH - lH) / 2],
|
||||||
|
"middle-right": [iW - lW - pad, (iH - lH) / 2],
|
||||||
|
"bottom-left": [pad, iH - lH - pad],
|
||||||
|
"bottom-center": [(iW - lW) / 2, iH - lH - pad],
|
||||||
|
"bottom-right": [iW - lW - pad, iH - lH - pad],
|
||||||
|
};
|
||||||
|
return map[pos];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyWatermark(
|
||||||
|
sourceUrl: string,
|
||||||
|
logo: HTMLImageElement,
|
||||||
|
prefs: WatermarkPrefs,
|
||||||
|
): Promise<Blob> {
|
||||||
|
const src = await loadImg(sourceUrl);
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = src.naturalWidth;
|
||||||
|
canvas.height = src.naturalHeight;
|
||||||
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
|
||||||
|
// Fill white so JPEG output has no black artifacts on transparent PNGs
|
||||||
|
ctx.fillStyle = "#ffffff";
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.drawImage(src, 0, 0);
|
||||||
|
|
||||||
|
const lW = Math.round(src.naturalWidth * (prefs.logoSizePct / 100));
|
||||||
|
const lH = Math.round((logo.naturalHeight / logo.naturalWidth) * lW);
|
||||||
|
const pad = Math.round(src.naturalWidth * 0.025);
|
||||||
|
|
||||||
|
const logoAlpha = prefs.logoOpacity / 100;
|
||||||
|
|
||||||
|
if (prefs.position === "tiled") {
|
||||||
|
const stepX = lW * 2.8;
|
||||||
|
const stepY = lH * 2.8;
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = logoAlpha;
|
||||||
|
for (let y = -lH; y < src.naturalHeight + lH; y += stepY) {
|
||||||
|
for (let x = -lW; x < src.naturalWidth + lW; x += stepX) {
|
||||||
|
ctx.drawImage(logo, x, y, lW, lH);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
} else {
|
||||||
|
const [x, y] = calcXY(prefs.position as FixedPos, src.naturalWidth, src.naturalHeight, lW, lH, pad);
|
||||||
|
|
||||||
|
if (prefs.bgEnabled) {
|
||||||
|
const bp = Math.round(lW * 0.2);
|
||||||
|
const bx = x - bp, by = y - bp;
|
||||||
|
const bw = lW + bp * 2, bh = lH + bp * 2;
|
||||||
|
const r = Math.round(bp * 0.6);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(bx + r, by);
|
||||||
|
ctx.lineTo(bx + bw - r, by);
|
||||||
|
ctx.arcTo(bx + bw, by, bx + bw, by + r, r);
|
||||||
|
ctx.lineTo(bx + bw, by + bh - r);
|
||||||
|
ctx.arcTo(bx + bw, by + bh, bx + bw - r, by + bh, r);
|
||||||
|
ctx.lineTo(bx + r, by + bh);
|
||||||
|
ctx.arcTo(bx, by + bh, bx, by + bh - r, r);
|
||||||
|
ctx.lineTo(bx, by + r);
|
||||||
|
ctx.arcTo(bx, by, bx + r, by, r);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = prefs.bgColor === "white"
|
||||||
|
? `rgba(255,255,255,${prefs.bgOpacity / 100})`
|
||||||
|
: `rgba(0,0,0,${prefs.bgOpacity / 100})`;
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = logoAlpha;
|
||||||
|
ctx.drawImage(logo, x, y, lW, lH);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) =>
|
||||||
|
canvas.toBlob((b) => b ? resolve(b) : reject(new Error("toBlob")), "image/jpeg", 0.92),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function WatermarkClient({ logoDataUrl }: { logoDataUrl: string | null }) {
|
||||||
|
const [prefs, setPrefs] = useState<WatermarkPrefs>(DEFAULT_PREFS);
|
||||||
|
const [files, setFiles] = useState<WFile[]>([]);
|
||||||
|
const [previewId, setPreviewId] = useState<string | null>(null);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
|
const [processing, setProcessing] = useState(false);
|
||||||
|
const [logoLoaded, setLogoLoaded] = useState(false);
|
||||||
|
|
||||||
|
const logoRef = useRef<HTMLImageElement | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const prevPreviewRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// ── Load prefs from localStorage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(PREFS_KEY);
|
||||||
|
if (raw) setPrefs({ ...DEFAULT_PREFS, ...JSON.parse(raw) });
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Logo: data URL is pre-fetched server-side, just decode into HTMLImageElement
|
||||||
|
useEffect(() => {
|
||||||
|
if (!logoDataUrl) return;
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => { logoRef.current = img; setLogoLoaded(true); };
|
||||||
|
img.src = logoDataUrl;
|
||||||
|
}, [logoDataUrl]);
|
||||||
|
|
||||||
|
// ── Live preview (debounced 300 ms)
|
||||||
|
// logoLoaded is in deps so the preview fires once the logo Image decodes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!previewId || !logoRef.current || !logoLoaded) return;
|
||||||
|
const wf = files.find((f) => f.id === previewId);
|
||||||
|
if (!wf) return;
|
||||||
|
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(async () => {
|
||||||
|
if (!logoRef.current) return;
|
||||||
|
setPreviewLoading(true);
|
||||||
|
try {
|
||||||
|
const blob = await applyWatermark(wf.originalUrl, logoRef.current, prefs);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
setPreviewUrl((prev) => {
|
||||||
|
if (prev && prev !== prevPreviewRef.current) URL.revokeObjectURL(prev);
|
||||||
|
prevPreviewRef.current = url;
|
||||||
|
return url;
|
||||||
|
});
|
||||||
|
} catch { /* ignore */ } finally {
|
||||||
|
setPreviewLoading(false);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [previewId, prefs, logoLoaded]);
|
||||||
|
|
||||||
|
function updatePrefs(patch: Partial<WatermarkPrefs>) {
|
||||||
|
setPrefs((prev) => {
|
||||||
|
const next = { ...prev, ...patch };
|
||||||
|
try { localStorage.setItem(PREFS_KEY, JSON.stringify(next)); } catch { /* ignore */ }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFiles(incoming: FileList | File[]) {
|
||||||
|
const images = Array.from(incoming).filter((f) => f.type.startsWith("image/"));
|
||||||
|
if (!images.length) return;
|
||||||
|
setFiles((prev) => {
|
||||||
|
const added: WFile[] = images.map((f) => ({
|
||||||
|
id: `${f.name}-${f.size}-${Date.now()}-${Math.random()}`,
|
||||||
|
file: f,
|
||||||
|
originalUrl: URL.createObjectURL(f),
|
||||||
|
status: "idle",
|
||||||
|
}));
|
||||||
|
const next = [...prev, ...added];
|
||||||
|
if (!previewId && added.length > 0) setPreviewId(added[0].id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processAll() {
|
||||||
|
if (!logoRef.current || !files.length) return;
|
||||||
|
setProcessing(true);
|
||||||
|
|
||||||
|
// Snapshot logo + prefs so concurrent tasks all use the same values
|
||||||
|
const logo = logoRef.current;
|
||||||
|
const currentPrefs = prefs;
|
||||||
|
const toProcess = files.filter((f) => f.status !== "done");
|
||||||
|
|
||||||
|
// Mark all pending as "processing" in one batch
|
||||||
|
setFiles((p) => p.map((x) =>
|
||||||
|
toProcess.some((f) => f.id === x.id) ? { ...x, status: "processing" as const } : x,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Process all files in parallel — each gets its own canvas
|
||||||
|
await Promise.all(
|
||||||
|
toProcess.map(async (wf) => {
|
||||||
|
try {
|
||||||
|
const blob = await applyWatermark(wf.originalUrl, logo, currentPrefs);
|
||||||
|
setFiles((p) => p.map((x) => x.id === wf.id ? { ...x, status: "done" as const, blob } : x));
|
||||||
|
} catch {
|
||||||
|
setFiles((p) => p.map((x) => x.id === wf.id ? { ...x, status: "error" as const } : x));
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
setProcessing(false);
|
||||||
|
toast.success("Tüm görseller işlendi!");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadZip() {
|
||||||
|
const done = files.filter((f) => f.blob);
|
||||||
|
if (!done.length) { toast.error("Önce görselleri işleyin."); return; }
|
||||||
|
try {
|
||||||
|
const JSZip = (await import("jszip")).default;
|
||||||
|
const zip = new JSZip();
|
||||||
|
for (const wf of done) {
|
||||||
|
if (wf.blob) zip.file(wf.file.name.replace(/\.[^.]+$/, "") + "_wm.jpg", wf.blob);
|
||||||
|
}
|
||||||
|
const zipBlob = await zip.generateAsync({ type: "blob", compression: "DEFLATE", compressionOptions: { level: 3 } });
|
||||||
|
triggerDownload(zipBlob, "watermarked.zip");
|
||||||
|
} catch {
|
||||||
|
toast.error("ZIP oluşturulamadı.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadSingle(wf: WFile) {
|
||||||
|
if (!wf.blob) return;
|
||||||
|
triggerDownload(wf.blob, wf.file.name.replace(/\.[^.]+$/, "") + "_wm.jpg");
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerDownload(blob: Blob, filename: string) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url; a.download = filename; a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAll() {
|
||||||
|
files.forEach((wf) => {
|
||||||
|
URL.revokeObjectURL(wf.originalUrl);
|
||||||
|
});
|
||||||
|
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||||
|
setFiles([]); setPreviewId(null); setPreviewUrl(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const doneCount = files.filter((f) => f.status === "done").length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 p-4 md:p-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight">Fotoğraf Damgala</h1>
|
||||||
|
<p className="text-muted-foreground text-sm mt-0.5">Görsellere otomatik logo ekle ve ZIP olarak indir</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-6 flex-col lg:flex-row items-start">
|
||||||
|
|
||||||
|
{/* ── Settings panel ──────────────────────────────────── */}
|
||||||
|
<div className="w-full lg:w-60 shrink-0 space-y-5">
|
||||||
|
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Logo</p>
|
||||||
|
{logoDataUrl && logoLoaded ? (
|
||||||
|
<div className="flex items-center gap-3 p-3 rounded-lg border bg-muted/30">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src={logoDataUrl} alt="Logo" className="h-10 w-auto max-w-[110px] object-contain" />
|
||||||
|
<span className="text-xs text-muted-foreground">Ofis logosu</span>
|
||||||
|
</div>
|
||||||
|
) : logoDataUrl ? (
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg border text-muted-foreground text-sm">
|
||||||
|
<CircleNotch className="size-4 animate-spin shrink-0" />
|
||||||
|
<span>Yükleniyor…</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-3 rounded-lg border border-dashed text-sm text-muted-foreground flex items-start gap-2">
|
||||||
|
<WarningCircle className="size-4 mt-0.5 shrink-0 text-amber-500" />
|
||||||
|
<span>
|
||||||
|
Logo bulunamadı.{" "}
|
||||||
|
<a href="/settings/workspace" className="text-primary underline">Ayarlardan</a> yükleyin.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Position grid */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Pozisyon</p>
|
||||||
|
<div className="grid grid-cols-3 gap-1">
|
||||||
|
{GRID.map((row, ri) =>
|
||||||
|
row.map((pos, ci) => {
|
||||||
|
const active = prefs.position === pos;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${ri}-${ci}`}
|
||||||
|
type="button"
|
||||||
|
title={POS_LABELS[pos]}
|
||||||
|
onClick={() => updatePrefs({ position: pos })}
|
||||||
|
className={cn(
|
||||||
|
"h-9 rounded-md border transition-colors flex items-center justify-center",
|
||||||
|
active
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "border-border bg-muted/40 hover:bg-muted text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn(
|
||||||
|
"size-2 rounded-sm",
|
||||||
|
active ? "bg-primary-foreground" : "bg-current opacity-50",
|
||||||
|
)} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => updatePrefs({ position: "tiled" })}
|
||||||
|
className={cn(
|
||||||
|
"w-full h-8 rounded-md border text-xs font-medium transition-colors flex items-center justify-center gap-1.5",
|
||||||
|
prefs.position === "tiled"
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "border-border bg-muted/40 hover:bg-muted text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Repeat className="size-3.5" /> Tekrar (Tiled)
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-muted-foreground">{POS_LABELS[prefs.position]}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logo size */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Boyut</Label>
|
||||||
|
<span className="text-xs tabular-nums text-muted-foreground">%{prefs.logoSizePct}</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" min={5} max={40} step={1} value={prefs.logoSizePct}
|
||||||
|
onChange={(e) => updatePrefs({ logoSizePct: Number(e.target.value) })}
|
||||||
|
className="w-full accent-primary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logo opacity — always visible */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Logo Opaklığı</Label>
|
||||||
|
<span className="text-xs tabular-nums text-muted-foreground">%{prefs.logoOpacity}</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" min={10} max={100} step={5} value={prefs.logoOpacity}
|
||||||
|
onChange={(e) => updatePrefs({ logoOpacity: Number(e.target.value) })}
|
||||||
|
className="w-full accent-primary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Background — only for fixed positions */}
|
||||||
|
{prefs.position !== "tiled" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Arkaplan</Label>
|
||||||
|
<Switch
|
||||||
|
checked={prefs.bgEnabled}
|
||||||
|
onCheckedChange={(v) => updatePrefs({ bgEnabled: v })}
|
||||||
|
className="scale-90"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{prefs.bgEnabled && (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{(["white", "dark"] as const).map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
onClick={() => updatePrefs({ bgColor: c })}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 h-7 rounded-md border text-xs transition-colors",
|
||||||
|
prefs.bgColor === c
|
||||||
|
? "border-primary bg-primary/10 text-primary font-medium"
|
||||||
|
: "border-border text-muted-foreground hover:border-foreground/30",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{c === "white" ? "Beyaz" : "Koyu"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-xs text-muted-foreground">Arkaplan Opaklığı</span>
|
||||||
|
<span className="text-xs tabular-nums text-muted-foreground">%{prefs.bgOpacity}</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" min={10} max={60} step={5} value={prefs.bgOpacity}
|
||||||
|
onChange={(e) => updatePrefs({ bgOpacity: Number(e.target.value) })}
|
||||||
|
className="w-full accent-primary" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={processAll}
|
||||||
|
disabled={!files.length || !logoLoaded || processing}
|
||||||
|
>
|
||||||
|
{processing
|
||||||
|
? <><CircleNotch className="size-4 mr-2 animate-spin" />İşleniyor…</>
|
||||||
|
: files.length
|
||||||
|
? `${files.length} Görseli İşle`
|
||||||
|
: "Görsel Yükle"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Upload + preview ────────────────────────────────── */}
|
||||||
|
<div className="flex-1 min-w-0 space-y-4">
|
||||||
|
|
||||||
|
{/* Drop zone */}
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={(e) => { e.preventDefault(); addFiles(e.dataTransfer.files); }}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && fileInputRef.current?.click()}
|
||||||
|
className="border-2 border-dashed rounded-xl p-8 text-center cursor-pointer hover:border-primary/50 hover:bg-muted/20 transition-colors"
|
||||||
|
>
|
||||||
|
<Upload className="size-8 mx-auto text-muted-foreground/40 mb-2" />
|
||||||
|
<p className="text-sm font-medium">Görselleri sürükle veya tıkla</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">JPG, PNG, WebP — çoklu seçim desteklenir</p>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => e.target.files && addFiles(e.target.files)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File grid */}
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{files.length} görsel
|
||||||
|
{doneCount > 0 && (
|
||||||
|
<span className="text-muted-foreground font-normal ml-1">({doneCount} işlendi)</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{doneCount > 0 && (
|
||||||
|
<Button variant="outline" size="sm" onClick={downloadZip}>
|
||||||
|
<Download className="size-3.5 mr-1.5" />ZIP İndir
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="sm" onClick={clearAll} className="text-muted-foreground">
|
||||||
|
Temizle
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 xl:grid-cols-6 gap-2">
|
||||||
|
{files.map((wf) => (
|
||||||
|
<div
|
||||||
|
key={wf.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => setPreviewId(wf.id)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && setPreviewId(wf.id)}
|
||||||
|
className={cn(
|
||||||
|
"relative aspect-[4/3] rounded-lg overflow-hidden cursor-pointer border-2 transition-all select-none",
|
||||||
|
previewId === wf.id ? "border-primary shadow-md" : "border-transparent hover:border-border",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={wf.originalUrl}
|
||||||
|
alt={wf.file.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
{wf.status === "processing" && (
|
||||||
|
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
|
||||||
|
<CircleNotch className="size-5 text-white animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{wf.status === "done" && (
|
||||||
|
<CheckCircle weight="fill" className="absolute top-1 right-1 size-4 text-green-400 drop-shadow" />
|
||||||
|
)}
|
||||||
|
{wf.status === "error" && (
|
||||||
|
<XCircle weight="fill" className="absolute top-1 right-1 size-4 text-red-400 drop-shadow" />
|
||||||
|
)}
|
||||||
|
{wf.status === "done" && wf.blob && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); downloadSingle(wf); }}
|
||||||
|
className="absolute bottom-1 right-1 size-6 rounded bg-black/60 hover:bg-black/80 text-white flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<Download className="size-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Live preview */}
|
||||||
|
{previewId && (
|
||||||
|
<div className="rounded-xl border overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/30">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
Önizleme {previewLoading && <CircleNotch className="size-3 inline animate-spin ml-1" />}
|
||||||
|
</span>
|
||||||
|
{(() => {
|
||||||
|
const wf = files.find((f) => f.id === previewId);
|
||||||
|
return wf?.status === "done" && wf.blob ? (
|
||||||
|
<Button size="sm" variant="ghost" className="h-6 text-xs gap-1" onClick={() => downloadSingle(wf)}>
|
||||||
|
<Download className="size-3" /> İndir
|
||||||
|
</Button>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
<div className="p-3 flex justify-center bg-muted/10 min-h-32">
|
||||||
|
{previewUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt="Önizleme"
|
||||||
|
className="max-w-full h-auto rounded max-h-[55vh] object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center text-muted-foreground text-sm">
|
||||||
|
{logoLoaded ? "Ayarları değiştirince önizleme yüklenir…" : "Logo yükleniyor…"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
TrendUp,
|
TrendUp,
|
||||||
Users,
|
Users,
|
||||||
Wallet,
|
Wallet,
|
||||||
|
Wrench,
|
||||||
} from '@/lib/icons';
|
} from '@/lib/icons';
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
@@ -111,6 +112,16 @@ const navGroups: NavGroup[] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Araçlar",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "Fotoğraf Damgala",
|
||||||
|
url: "/tools/watermark",
|
||||||
|
icon: Wrench,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Hesap",
|
label: "Hesap",
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
@@ -64,12 +64,12 @@ export async function uploadLogoAction(
|
|||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
const inputFile = InputFile.fromBuffer(buffer, file.name);
|
const inputFile = InputFile.fromBuffer(buffer, file.name);
|
||||||
|
|
||||||
const created = await storage.createFile({
|
const created = await storage.createFile(
|
||||||
bucketId: BUCKETS.tenantLogos,
|
BUCKETS.tenantLogos,
|
||||||
fileId: ID.unique(),
|
ID.unique(),
|
||||||
file: inputFile,
|
inputFile,
|
||||||
permissions: teamLogoPermissions(ctx.tenantId),
|
teamLogoPermissions(ctx.tenantId),
|
||||||
});
|
);
|
||||||
newFileId = created.$id;
|
newFileId = created.$id;
|
||||||
|
|
||||||
await tablesDB.updateRow(DATABASE_ID, TABLES.tenantSettings, ctx.settings.$id, {
|
await tablesDB.updateRow(DATABASE_ID, TABLES.tenantSettings, ctx.settings.$id, {
|
||||||
@@ -78,10 +78,7 @@ export async function uploadLogoAction(
|
|||||||
|
|
||||||
if (previousLogoId && previousLogoId !== newFileId) {
|
if (previousLogoId && previousLogoId !== newFileId) {
|
||||||
try {
|
try {
|
||||||
await storage.deleteFile({
|
await storage.deleteFile(BUCKETS.tenantLogos, previousLogoId);
|
||||||
bucketId: BUCKETS.tenantLogos,
|
|
||||||
fileId: previousLogoId,
|
|
||||||
});
|
|
||||||
} catch {
|
} catch {
|
||||||
// best-effort — orphaned file is acceptable, won't block the new logo
|
// best-effort — orphaned file is acceptable, won't block the new logo
|
||||||
}
|
}
|
||||||
@@ -143,10 +140,7 @@ export async function removeLogoAction(): Promise<LogoActionState> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await storage.deleteFile({
|
await storage.deleteFile(BUCKETS.tenantLogos, previousLogoId);
|
||||||
bucketId: BUCKETS.tenantLogos,
|
|
||||||
fileId: previousLogoId,
|
|
||||||
});
|
|
||||||
} catch {
|
} catch {
|
||||||
/* file already gone, fine */
|
/* file already gone, fine */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,4 +103,8 @@ export {
|
|||||||
Download,
|
Download,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
Shield,
|
Shield,
|
||||||
|
Wrench,
|
||||||
|
Repeat,
|
||||||
|
Images,
|
||||||
|
WarningCircle,
|
||||||
} from "@phosphor-icons/react/dist/ssr";
|
} from "@phosphor-icons/react/dist/ssr";
|
||||||
|
|||||||
Reference in New Issue
Block a user