From 37b0928da66180a119585c8efb195cabf61086c1 Mon Sep 17 00:00:00 2001 From: egecankomur Date: Wed, 13 May 2026 14:00:00 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20watermark=20tool=20complete=20=E2=80=94?= =?UTF-8?q?=20parallel=20processing,=20logo=20opacity,=20preview=20fix,=20?= =?UTF-8?q?logo=20upload=20fix,=20rename=20to=20Foto=C4=9Fraf=20Damgala?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 + pnpm-lock.yaml | 96 +++ .../workspace/components/logo-uploader.tsx | 272 ++++---- src/app/(dashboard)/tools/loading.tsx | 24 + src/app/(dashboard)/tools/watermark/page.tsx | 30 + .../tools/watermark/watermark-client.tsx | 606 ++++++++++++++++++ src/components/app-sidebar.tsx | 11 + src/lib/appwrite/logo-actions.ts | 22 +- src/lib/icons.ts | 4 + 9 files changed, 928 insertions(+), 139 deletions(-) create mode 100644 src/app/(dashboard)/tools/loading.tsx create mode 100644 src/app/(dashboard)/tools/watermark/page.tsx create mode 100644 src/app/(dashboard)/tools/watermark/watermark-client.tsx diff --git a/package.json b/package.json index b018c16..eaee8f6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7fd1320..209251e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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): diff --git a/src/app/(dashboard)/settings/workspace/components/logo-uploader.tsx b/src/app/(dashboard)/settings/workspace/components/logo-uploader.tsx index cd5ca1a..f7841a5 100644 --- a/src/app/(dashboard)/settings/workspace/components/logo-uploader.tsx +++ b/src/app/(dashboard)/settings/workspace/components/logo-uploader.tsx @@ -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(currentLogoUrl); const [dragOver, setDragOver] = useState(false); - const [selectedName, setSelectedName] = useState(null); - const formRef = useRef(null); + const [progress, setProgress] = useState(0); const inputRef = useRef(null); + const progressInterval = useRef | 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) => { + // 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) { + 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) { 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 ( @@ -113,98 +137,96 @@ export function LogoUploader({ canEdit, currentLogoUrl, companyName }: Props) { -
-
-
- {previewUrl ? ( - // eslint-disable-next-line @next/next/no-img-element - {`${companyName} - ) : ( -
- - Henüz logo yok -
- )} -
- -
- - -
- {canEdit && ( - - )} - {canEdit && currentLogoUrl && ( - - )} - {!canEdit && ( -

- Logo değiştirmek için yönetici yetkisi gerekli. -

- )} +
+ {/* Preview */} +
+ {previewUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {`${companyName} + ) : ( +
+ + Henüz logo yok
-
+ )}
- + +
+ {/* Drop zone — auto-uploads on select */} + + + {/* Progress bar */} + {progress > 0 && ( +
+
+
+ )} + + {/* Remove button */} + {canEdit && previewUrl && !uploading && ( + + )} + + {!canEdit && ( +

+ Logo değiştirmek için yönetici yetkisi gerekli. +

+ )} +
+
); diff --git a/src/app/(dashboard)/tools/loading.tsx b/src/app/(dashboard)/tools/loading.tsx new file mode 100644 index 0000000..7e3b1bb --- /dev/null +++ b/src/app/(dashboard)/tools/loading.tsx @@ -0,0 +1,24 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export default function ToolsLoading() { + return ( +
+
+ + +
+
+
+ + + + + +
+
+ +
+
+
+ ); +} diff --git a/src/app/(dashboard)/tools/watermark/page.tsx b/src/app/(dashboard)/tools/watermark/page.tsx new file mode 100644 index 0000000..33357c3 --- /dev/null +++ b/src/app/(dashboard)/tools/watermark/page.tsx @@ -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 { + 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 ; +} diff --git a/src/app/(dashboard)/tools/watermark/watermark-client.tsx b/src/app/(dashboard)/tools/watermark/watermark-client.tsx new file mode 100644 index 0000000..f2becc2 --- /dev/null +++ b/src/app/(dashboard)/tools/watermark/watermark-client.tsx @@ -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 = { + "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 { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = reject; + img.src = src; + }); +} + + +type FixedPos = Exclude; + +function calcXY( + pos: FixedPos, + iW: number, iH: number, + lW: number, lH: number, + pad: number, +): [number, number] { + const map: Record = { + "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 { + 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(DEFAULT_PREFS); + const [files, setFiles] = useState([]); + const [previewId, setPreviewId] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); + const [processing, setProcessing] = useState(false); + const [logoLoaded, setLogoLoaded] = useState(false); + + const logoRef = useRef(null); + const fileInputRef = useRef(null); + const debounceRef = useRef | null>(null); + const prevPreviewRef = useRef(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) { + 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 ( +
+ {/* Header */} +
+

Fotoğraf Damgala

+

Görsellere otomatik logo ekle ve ZIP olarak indir

+
+ +
+ + {/* ── Settings panel ──────────────────────────────────── */} +
+ + {/* Logo */} +
+

Logo

+ {logoDataUrl && logoLoaded ? ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Logo + Ofis logosu +
+ ) : logoDataUrl ? ( +
+ + Yükleniyor… +
+ ) : ( +
+ + + Logo bulunamadı.{" "} + Ayarlardan yükleyin. + +
+ )} +
+ + {/* Position grid */} +
+

Pozisyon

+
+ {GRID.map((row, ri) => + row.map((pos, ci) => { + const active = prefs.position === pos; + return ( + + ); + }) + )} +
+ +

{POS_LABELS[prefs.position]}

+
+ + {/* Logo size */} +
+
+ + %{prefs.logoSizePct} +
+ updatePrefs({ logoSizePct: Number(e.target.value) })} + className="w-full accent-primary" /> +
+ + {/* Logo opacity — always visible */} +
+
+ + %{prefs.logoOpacity} +
+ updatePrefs({ logoOpacity: Number(e.target.value) })} + className="w-full accent-primary" /> +
+ + {/* Background — only for fixed positions */} + {prefs.position !== "tiled" && ( +
+
+ + updatePrefs({ bgEnabled: v })} + className="scale-90" + /> +
+ + {prefs.bgEnabled && ( + <> +
+ {(["white", "dark"] as const).map((c) => ( + + ))} +
+
+
+ Arkaplan Opaklığı + %{prefs.bgOpacity} +
+ updatePrefs({ bgOpacity: Number(e.target.value) })} + className="w-full accent-primary" /> +
+ + )} +
+ )} + + +
+ + {/* ── Upload + preview ────────────────────────────────── */} +
+ + {/* Drop zone */} +
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" + > + +

Görselleri sürükle veya tıkla

+

JPG, PNG, WebP — çoklu seçim desteklenir

+ e.target.files && addFiles(e.target.files)} + /> +
+ + {/* File grid */} + {files.length > 0 && ( +
+
+ + {files.length} görsel + {doneCount > 0 && ( + ({doneCount} işlendi) + )} + +
+ {doneCount > 0 && ( + + )} + +
+
+ +
+ {files.map((wf) => ( +
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 */} + {wf.file.name} + {wf.status === "processing" && ( +
+ +
+ )} + {wf.status === "done" && ( + + )} + {wf.status === "error" && ( + + )} + {wf.status === "done" && wf.blob && ( + + )} +
+ ))} +
+
+ )} + + {/* Live preview */} + {previewId && ( +
+
+ + Önizleme {previewLoading && } + + {(() => { + const wf = files.find((f) => f.id === previewId); + return wf?.status === "done" && wf.blob ? ( + + ) : null; + })()} +
+
+ {previewUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + Önizleme + ) : ( +
+ {logoLoaded ? "Ayarları değiştirince önizleme yüklenir…" : "Logo yükleniyor…"} +
+ )} +
+
+ )} +
+
+
+ ); +} diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index 5a58cc0..9748763 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -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: [ diff --git a/src/lib/appwrite/logo-actions.ts b/src/lib/appwrite/logo-actions.ts index 113cfcc..e9f1af0 100644 --- a/src/lib/appwrite/logo-actions.ts +++ b/src/lib/appwrite/logo-actions.ts @@ -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 { }); try { - await storage.deleteFile({ - bucketId: BUCKETS.tenantLogos, - fileId: previousLogoId, - }); + await storage.deleteFile(BUCKETS.tenantLogos, previousLogoId); } catch { /* file already gone, fine */ } diff --git a/src/lib/icons.ts b/src/lib/icons.ts index 26e07a5..1410fad 100644 --- a/src/lib/icons.ts +++ b/src/lib/icons.ts @@ -103,4 +103,8 @@ export { Download, CreditCard, Shield, + Wrench, + Repeat, + Images, + WarningCircle, } from "@phosphor-icons/react/dist/ssr";