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",
|
||||
"date-fns": "^4.1.0",
|
||||
"driver.js": "^1.4.0",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"next": "16.1.1",
|
||||
@@ -69,6 +70,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@types/jszip": "^3.4.1",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
||||
Generated
+96
@@ -119,6 +119,9 @@ importers:
|
||||
driver.js:
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
jszip:
|
||||
specifier: ^3.10.1
|
||||
version: 3.10.1
|
||||
lucide-react:
|
||||
specifier: ^0.562.0
|
||||
version: 0.562.0(react@19.2.3)
|
||||
@@ -180,6 +183,9 @@ importers:
|
||||
'@eslint/eslintrc':
|
||||
specifier: ^3.3.3
|
||||
version: 3.3.3
|
||||
'@types/jszip':
|
||||
specifier: ^3.4.1
|
||||
version: 3.4.1
|
||||
'@types/node':
|
||||
specifier: ^25.0.3
|
||||
version: 25.0.3
|
||||
@@ -1628,6 +1634,10 @@ packages:
|
||||
'@types/json5@0.0.29':
|
||||
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':
|
||||
resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==}
|
||||
|
||||
@@ -1970,6 +1980,9 @@ packages:
|
||||
convert-source-map@2.0.0:
|
||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||
|
||||
core-util-is@1.0.3:
|
||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -2425,6 +2438,9 @@ packages:
|
||||
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
immediate@3.0.6:
|
||||
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
||||
|
||||
immer@10.2.0:
|
||||
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
|
||||
|
||||
@@ -2439,6 +2455,9 @@ packages:
|
||||
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
|
||||
engines: {node: '>=0.8.19'}
|
||||
|
||||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
internal-slot@1.1.0:
|
||||
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2550,6 +2569,9 @@ packages:
|
||||
resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
isarray@1.0.0:
|
||||
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
||||
|
||||
isarray@2.0.5:
|
||||
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
|
||||
|
||||
@@ -2604,6 +2626,9 @@ packages:
|
||||
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
||||
engines: {node: '>=4.0'}
|
||||
|
||||
jszip@3.10.1:
|
||||
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
||||
|
||||
kdbush@4.0.2:
|
||||
resolution: {integrity: sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==}
|
||||
|
||||
@@ -2621,6 +2646,9 @@ packages:
|
||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
lie@3.3.0:
|
||||
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
|
||||
|
||||
lightningcss-android-arm64@1.30.2:
|
||||
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@@ -2846,6 +2874,9 @@ packages:
|
||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
pako@1.0.11:
|
||||
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
||||
|
||||
parent-module@1.0.1:
|
||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -2895,6 +2926,9 @@ packages:
|
||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||
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:
|
||||
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||
|
||||
@@ -2999,6 +3033,9 @@ packages:
|
||||
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
readable-stream@2.3.8:
|
||||
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
|
||||
|
||||
recharts@3.6.0:
|
||||
resolution: {integrity: sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -3056,6 +3093,9 @@ packages:
|
||||
resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
|
||||
engines: {node: '>=0.4'}
|
||||
|
||||
safe-buffer@5.1.2:
|
||||
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
|
||||
|
||||
safe-push-apply@1.0.0:
|
||||
resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3088,6 +3128,9 @@ packages:
|
||||
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
setimmediate@1.0.5:
|
||||
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
|
||||
|
||||
sharp@0.34.5:
|
||||
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
@@ -3159,6 +3202,9 @@ packages:
|
||||
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
string_decoder@1.1.1:
|
||||
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
|
||||
|
||||
strip-bom@3.0.0:
|
||||
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -3309,6 +3355,9 @@ packages:
|
||||
peerDependencies:
|
||||
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:
|
||||
resolution: {integrity: sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==}
|
||||
|
||||
@@ -4783,6 +4832,10 @@ snapshots:
|
||||
|
||||
'@types/json5@0.0.29': {}
|
||||
|
||||
'@types/jszip@3.4.1':
|
||||
dependencies:
|
||||
jszip: 3.10.1
|
||||
|
||||
'@types/node@25.0.3':
|
||||
dependencies:
|
||||
undici-types: 7.16.0
|
||||
@@ -5146,6 +5199,8 @@ snapshots:
|
||||
|
||||
convert-source-map@2.0.0: {}
|
||||
|
||||
core-util-is@1.0.3: {}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
@@ -5729,6 +5784,8 @@ snapshots:
|
||||
|
||||
ignore@7.0.5: {}
|
||||
|
||||
immediate@3.0.6: {}
|
||||
|
||||
immer@10.2.0: {}
|
||||
|
||||
immer@11.1.3: {}
|
||||
@@ -5740,6 +5797,8 @@ snapshots:
|
||||
|
||||
imurmurhash@0.1.4: {}
|
||||
|
||||
inherits@2.0.4: {}
|
||||
|
||||
internal-slot@1.1.0:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
@@ -5860,6 +5919,8 @@ snapshots:
|
||||
call-bound: 1.0.4
|
||||
get-intrinsic: 1.3.0
|
||||
|
||||
isarray@1.0.0: {}
|
||||
|
||||
isarray@2.0.5: {}
|
||||
|
||||
isexe@2.0.0: {}
|
||||
@@ -5908,6 +5969,13 @@ snapshots:
|
||||
object.assign: 4.1.7
|
||||
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: {}
|
||||
|
||||
keyv@4.5.4:
|
||||
@@ -5925,6 +5993,10 @@ snapshots:
|
||||
prelude-ls: 1.2.1
|
||||
type-check: 0.4.0
|
||||
|
||||
lie@3.3.0:
|
||||
dependencies:
|
||||
immediate: 3.0.6
|
||||
|
||||
lightningcss-android-arm64@1.30.2:
|
||||
optional: true
|
||||
|
||||
@@ -6150,6 +6222,8 @@ snapshots:
|
||||
dependencies:
|
||||
p-limit: 3.1.0
|
||||
|
||||
pako@1.0.11: {}
|
||||
|
||||
parent-module@1.0.1:
|
||||
dependencies:
|
||||
callsites: 3.1.0
|
||||
@@ -6188,6 +6262,8 @@ snapshots:
|
||||
|
||||
prelude-ls@1.2.1: {}
|
||||
|
||||
process-nextick-args@2.0.1: {}
|
||||
|
||||
prop-types@15.8.1:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
@@ -6328,6 +6404,16 @@ snapshots:
|
||||
|
||||
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):
|
||||
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)
|
||||
@@ -6410,6 +6496,8 @@ snapshots:
|
||||
has-symbols: 1.1.0
|
||||
isarray: 2.0.5
|
||||
|
||||
safe-buffer@5.1.2: {}
|
||||
|
||||
safe-push-apply@1.0.0:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
@@ -6449,6 +6537,8 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
es-object-atoms: 1.1.1
|
||||
|
||||
setimmediate@1.0.5: {}
|
||||
|
||||
sharp@0.34.5:
|
||||
dependencies:
|
||||
'@img/colour': 1.0.0
|
||||
@@ -6584,6 +6674,10 @@ snapshots:
|
||||
define-properties: 1.2.1
|
||||
es-object-atoms: 1.1.1
|
||||
|
||||
string_decoder@1.1.1:
|
||||
dependencies:
|
||||
safe-buffer: 5.1.2
|
||||
|
||||
strip-bom@3.0.0: {}
|
||||
|
||||
strip-json-comments@3.1.1: {}
|
||||
@@ -6755,6 +6849,8 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
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):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect, useRef, useState, useTransition } from "react";
|
||||
import { Buildings, ImageSquare, CircleNotch, Trash, Upload } from '@/lib/icons';
|
||||
import { useEffect, useRef, useState, useTransition } from "react";
|
||||
import { Buildings, ImageSquare, CircleNotch, Trash } from '@/lib/icons';
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
removeLogoAction,
|
||||
uploadLogoAction,
|
||||
} from "@/lib/appwrite/logo-actions";
|
||||
import { initialLogoState } from "@/lib/appwrite/logo-types";
|
||||
|
||||
type Props = {
|
||||
canEdit: boolean;
|
||||
@@ -29,32 +28,40 @@ const MAX_BYTES = 2 * 1024 * 1024;
|
||||
const ALLOWED_MIME = ["image/png", "image/jpeg", "image/webp", "image/svg+xml"];
|
||||
|
||||
export function LogoUploader({ canEdit, currentLogoUrl, companyName }: Props) {
|
||||
const [state, formAction, isPending] = useActionState(
|
||||
uploadLogoAction,
|
||||
initialLogoState,
|
||||
);
|
||||
const [uploading, startUpload] = useTransition();
|
||||
const [removing, startRemove] = useTransition();
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(currentLogoUrl);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [selectedName, setSelectedName] = useState<string | null>(null);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const progressInterval = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setPreviewUrl(currentLogoUrl);
|
||||
}, [currentLogoUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.ok) {
|
||||
toast.success("Logo güncellendi.");
|
||||
setSelectedName(null);
|
||||
} else if (state.error) {
|
||||
toast.error(state.error);
|
||||
}
|
||||
}, [state]);
|
||||
function startProgressAnimation() {
|
||||
setProgress(5);
|
||||
if (progressInterval.current) clearInterval(progressInterval.current);
|
||||
progressInterval.current = setInterval(() => {
|
||||
setProgress((p) => {
|
||||
if (p >= 85) return p;
|
||||
return p + Math.random() * 12 + 3;
|
||||
});
|
||||
}, 250);
|
||||
}
|
||||
|
||||
const handleFile = (file: File | null) => {
|
||||
if (!file) return;
|
||||
function stopProgress(success: boolean) {
|
||||
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)) {
|
||||
toast.error("Sadece PNG, JPG, WebP veya SVG yükleyebilirsiniz.");
|
||||
return;
|
||||
@@ -63,42 +70,59 @@ export function LogoUploader({ canEdit, currentLogoUrl, companyName }: Props) {
|
||||
toast.error("Dosya 2MB'dan büyük olamaz.");
|
||||
return;
|
||||
}
|
||||
setSelectedName(file.name);
|
||||
|
||||
// Show local preview immediately
|
||||
const reader = new FileReader();
|
||||
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);
|
||||
};
|
||||
|
||||
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();
|
||||
setDragOver(false);
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file && inputRef.current) {
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(file);
|
||||
inputRef.current.files = dt.files;
|
||||
handleFile(file);
|
||||
}
|
||||
};
|
||||
if (file) uploadFile(file);
|
||||
}
|
||||
|
||||
const handleRemove = () => {
|
||||
function handleRemove() {
|
||||
startRemove(async () => {
|
||||
const result = await removeLogoAction();
|
||||
if (result.ok) {
|
||||
toast.success("Logo kaldırıldı.");
|
||||
setPreviewUrl(null);
|
||||
setSelectedName(null);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
} else {
|
||||
toast.error(result.error ?? "Logo kaldırılamadı.");
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const submitDisabled = isPending || removing || !selectedName;
|
||||
const busy = isPending || removing;
|
||||
const busy = uploading || removing;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -113,98 +137,96 @@ export function LogoUploader({ canEdit, currentLogoUrl, companyName }: Props) {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form ref={formRef} action={formAction} className="space-y-4">
|
||||
<div className="grid items-start gap-6 md:grid-cols-[200px_1fr]">
|
||||
<div className="bg-muted flex aspect-square w-full items-center justify-center overflow-hidden rounded-lg border">
|
||||
{previewUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={`${companyName} logo`}
|
||||
className="size-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex flex-col items-center gap-2 p-4 text-center text-xs">
|
||||
<Buildings className="size-8 opacity-40" />
|
||||
<span>Henüz logo yok</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
if (canEdit) setDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={canEdit ? handleDrop : undefined}
|
||||
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",
|
||||
dragOver && "border-primary bg-primary/5",
|
||||
!canEdit && "cursor-not-allowed opacity-60",
|
||||
!dragOver && "hover:bg-muted/30",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
name="logo"
|
||||
accept={ALLOWED_MIME.join(",")}
|
||||
className="sr-only"
|
||||
disabled={!canEdit || busy}
|
||||
onChange={(e) => handleFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<ImageSquare className="text-muted-foreground size-6" />
|
||||
<div className="text-sm font-medium">
|
||||
{selectedName ?? "Logo yüklemek için tıkla veya sürükle bırak"}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Önerilen: kare, en az 256×256 px, şeffaf arka plan (PNG/SVG)
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canEdit && (
|
||||
<Button type="submit" disabled={submitDisabled}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<CircleNotch className="size-4 animate-spin" />
|
||||
Yükleniyor...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="size-4" />
|
||||
Yükle
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{canEdit && currentLogoUrl && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleRemove}
|
||||
disabled={busy}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
{removing ? (
|
||||
<CircleNotch className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Trash className="size-4" />
|
||||
)}
|
||||
Kaldır
|
||||
</Button>
|
||||
)}
|
||||
{!canEdit && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Logo değiştirmek için yönetici yetkisi gerekli.
|
||||
</p>
|
||||
)}
|
||||
<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">
|
||||
{previewUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={`${companyName} logo`}
|
||||
className="size-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex flex-col items-center gap-2 p-4 text-center text-xs">
|
||||
<Buildings className="size-8 opacity-40" />
|
||||
<span>Henüz logo yok</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Drop zone — auto-uploads on select */}
|
||||
<label
|
||||
onDragOver={(e) => { e.preventDefault(); if (canEdit) setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={canEdit ? handleDrop : undefined}
|
||||
className={cn(
|
||||
"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",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={ALLOWED_MIME.join(",")}
|
||||
className="sr-only"
|
||||
disabled={!canEdit || busy}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
{uploading ? (
|
||||
<CircleNotch className="size-6 text-muted-foreground animate-spin" />
|
||||
) : (
|
||||
<ImageSquare className="size-6 text-muted-foreground" />
|
||||
)}
|
||||
<div className="text-sm font-medium">
|
||||
{uploading ? "Yükleniyor…" : "Logo yüklemek için tıkla veya sürükle bırak"}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Önerilen: kare, en az 256×256 px, şeffaf arka plan (PNG/SVG)
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Progress bar */}
|
||||
{progress > 0 && (
|
||||
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all",
|
||||
progress === 100 ? "bg-green-500 duration-300" : "bg-primary duration-200",
|
||||
)}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remove button */}
|
||||
{canEdit && previewUrl && !uploading && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRemove}
|
||||
disabled={busy}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
{removing ? (
|
||||
<CircleNotch className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Trash className="size-4" />
|
||||
)}
|
||||
Logoyu Kaldır
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!canEdit && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Logo değiştirmek için yönetici yetkisi gerekli.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</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,
|
||||
Users,
|
||||
Wallet,
|
||||
Wrench,
|
||||
} from '@/lib/icons';
|
||||
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",
|
||||
items: [
|
||||
|
||||
@@ -64,12 +64,12 @@ export async function uploadLogoAction(
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const inputFile = InputFile.fromBuffer(buffer, file.name);
|
||||
|
||||
const created = await storage.createFile({
|
||||
bucketId: BUCKETS.tenantLogos,
|
||||
fileId: ID.unique(),
|
||||
file: inputFile,
|
||||
permissions: teamLogoPermissions(ctx.tenantId),
|
||||
});
|
||||
const created = await storage.createFile(
|
||||
BUCKETS.tenantLogos,
|
||||
ID.unique(),
|
||||
inputFile,
|
||||
teamLogoPermissions(ctx.tenantId),
|
||||
);
|
||||
newFileId = created.$id;
|
||||
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.tenantSettings, ctx.settings.$id, {
|
||||
@@ -78,10 +78,7 @@ export async function uploadLogoAction(
|
||||
|
||||
if (previousLogoId && previousLogoId !== newFileId) {
|
||||
try {
|
||||
await storage.deleteFile({
|
||||
bucketId: BUCKETS.tenantLogos,
|
||||
fileId: previousLogoId,
|
||||
});
|
||||
await storage.deleteFile(BUCKETS.tenantLogos, previousLogoId);
|
||||
} catch {
|
||||
// best-effort — orphaned file is acceptable, won't block the new logo
|
||||
}
|
||||
@@ -143,10 +140,7 @@ export async function removeLogoAction(): Promise<LogoActionState> {
|
||||
});
|
||||
|
||||
try {
|
||||
await storage.deleteFile({
|
||||
bucketId: BUCKETS.tenantLogos,
|
||||
fileId: previousLogoId,
|
||||
});
|
||||
await storage.deleteFile(BUCKETS.tenantLogos, previousLogoId);
|
||||
} catch {
|
||||
/* file already gone, fine */
|
||||
}
|
||||
|
||||
@@ -103,4 +103,8 @@ export {
|
||||
Download,
|
||||
CreditCard,
|
||||
Shield,
|
||||
Wrench,
|
||||
Repeat,
|
||||
Images,
|
||||
WarningCircle,
|
||||
} from "@phosphor-icons/react/dist/ssr";
|
||||
|
||||
Reference in New Issue
Block a user