feat: watermark tool complete — parallel processing, logo opacity, preview fix, logo upload fix, rename to Fotoğraf Damgala

This commit is contained in:
egecankomur
2026-05-13 14:00:00 +03:00
parent 7c677dfa4b
commit 37b0928da6
9 changed files with 928 additions and 139 deletions
+2
View File
@@ -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",
+96
View File
@@ -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>
);
+24
View File
@@ -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; // 540
logoOpacity: number; // 10100
bgEnabled: boolean;
bgOpacity: number; // 1060
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>
);
}
+11
View File
@@ -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: [
+8 -14
View File
@@ -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 */
}
+4
View File
@@ -103,4 +103,8 @@ export {
Download,
CreditCard,
Shield,
Wrench,
Repeat,
Images,
WarningCircle,
} from "@phosphor-icons/react/dist/ssr";