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
@@ -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";