3.12 Mini Utilities Extindere si Fix-uri

- NumberToText: Romanian number-to-words converter (lei/bani, compact, spaced)
- ColorPaletteExtractor: image upload -> top 8 color swatches with hex copy
- AreaConverter: bidirectional (mp, ari, ha, km2, sq ft)
- UValueConverter: bidirectional U->R and R->U toggle
- MDLPA: replaced broken iframe with 3 styled external link cards
- PdfReducer: drag-and-drop, simplified to 2 levels, Deblocare PDF + PDF/A links
- DWG-to-DXF skipped (needs backend service)
This commit is contained in:
AI Assistant
2026-02-28 01:54:40 +02:00
parent 6535c8ce7f
commit 989a9908ba
2 changed files with 623 additions and 130 deletions
@@ -343,8 +343,7 @@ export function DigitalSignaturesModule() {
{groupedAssets.map(([group, items]) => (
<div key={group}>
<h3 className="mb-3 text-sm font-medium text-muted-foreground">
{group}{" "}
<span className="text-xs">({items.length})</span>
{group} <span className="text-xs">({items.length})</span>
</h3>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{items.map((asset) => (
@@ -539,15 +538,11 @@ function AssetCard({
<FileDown className="mr-2 h-4 w-4" />
<span>Original</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void downloadAsWord(asset)}
>
<DropdownMenuItem onClick={() => void downloadAsWord(asset)}>
<FileText className="mr-2 h-4 w-4" />
<span>Word (.docx)</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void downloadAsPdf(asset)}
>
<DropdownMenuItem onClick={() => void downloadAsPdf(asset)}>
<FileDown className="mr-2 h-4 w-4" />
<span>PDF (.pdf)</span>
</DropdownMenuItem>
@@ -575,8 +570,7 @@ function ImageUploadField({
const [processing, setProcessing] = useState(false);
const handleFile = async (file: File) => {
const isTiff =
file.type === "image/tiff" || /\.tiff?$/i.test(file.name);
const isTiff = file.type === "image/tiff" || /\.tiff?$/i.test(file.name);
if (isTiff) {
setProcessing(true);
@@ -638,9 +632,7 @@ function ImageUploadField({
) : (
<>
<Upload className="h-6 w-6" />
<span>
Trage imaginea aici sau apasă pentru a selecta
</span>
<span>Trage imaginea aici sau apasă pentru a selecta</span>
<span className="text-xs text-muted-foreground/60">
PNG, JPG, TIFF
</span>
@@ -778,9 +770,7 @@ function AssetForm({
const [company, setCompany] = useState<CompanyId>(
initial?.company ?? "beletage",
);
const [subcategory, setSubcategory] = useState(
initial?.subcategory ?? "",
);
const [subcategory, setSubcategory] = useState(initial?.subcategory ?? "");
const [tags, setTags] = useState<string[]>(initial?.tags ?? []);
const [tagInput, setTagInput] = useState("");
@@ -927,9 +917,7 @@ function AssetForm({
if (tagInput.trim()) addTag(tagInput);
}}
placeholder={
tags.length === 0
? "Adaugă etichete (Enter sau virgulă)..."
: ""
tags.length === 0 ? "Adaugă etichete (Enter sau virgulă)..." : ""
}
className="min-w-[120px] flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
@@ -1,6 +1,6 @@
"use client";
import { useState, useRef } from "react";
import { useState, useRef, useCallback } from "react";
import {
Copy,
Check,
@@ -13,6 +13,9 @@ import {
Building2,
FileDown,
ScanText,
CaseUpper,
Palette,
Upload,
} from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
@@ -204,46 +207,61 @@ function PercentageCalculator() {
}
function AreaConverter() {
const [mp, setMp] = useState("");
const v = parseFloat(mp);
const units = [
{ key: "mp", label: "mp (m²)", factor: 1 },
{ key: "ari", label: "ari (100 m²)", factor: 100 },
{ key: "ha", label: "hectare (10.000 m²)", factor: 10000 },
{ key: "km2", label: "km²", factor: 1000000 },
{ key: "sqft", label: "sq ft", factor: 1 / 10.7639 },
] as const;
const conversions = !isNaN(v)
? [
{ label: "mp (m²)", value: v.toFixed(2) },
{ label: "ari (100 m²)", value: (v / 100).toFixed(4) },
{ label: "hectare (10.000 m²)", value: (v / 10000).toFixed(6) },
{ label: "km²", value: (v / 1000000).toFixed(8) },
{ label: "sq ft", value: (v * 10.7639).toFixed(2) },
]
: [];
const [values, setValues] = useState<Record<string, string>>({});
const [activeField, setActiveField] = useState<string | null>(null);
const handleChange = (key: string, raw: string) => {
if (raw === "") {
setValues({});
return;
}
const v = parseFloat(raw);
if (isNaN(v)) return;
const unit = units.find((u) => u.key === key);
if (!unit) return;
const mp = v * unit.factor;
const next: Record<string, string> = {};
for (const u of units) {
next[u.key] =
u.key === key
? raw
: (mp / u.factor).toPrecision(8).replace(/\.?0+$/, "");
}
setValues(next);
};
return (
<div className="space-y-3">
<div>
<Label>Suprafață (m²)</Label>
<Input
type="number"
value={mp}
onChange={(e) => setMp(e.target.value)}
className="mt-1"
placeholder="Introdu suprafața..."
/>
<p className="text-xs text-muted-foreground">
Introdu o valoare în orice câmp celelalte se calculează automat.
</p>
<div className="space-y-2">
{units.map((u) => (
<div key={u.key} className="flex items-center gap-2">
<span className="w-36 text-xs text-muted-foreground">
{u.label}
</span>
<Input
type="number"
value={values[u.key] ?? ""}
onFocus={() => setActiveField(u.key)}
onBlur={() => setActiveField(null)}
onChange={(e) => handleChange(u.key, e.target.value)}
className={`flex-1 ${activeField === u.key ? "ring-1 ring-primary" : ""}`}
placeholder="0"
/>
<CopyButton text={values[u.key] ?? ""} />
</div>
))}
</div>
{conversions.length > 0 && (
<div className="space-y-1.5">
{conversions.map(({ label, value: val }) => (
<div key={label} className="flex items-center gap-2">
<code className="flex-1 rounded border bg-muted/30 px-2 py-1 text-xs">
{val}
</code>
<span className="w-36 text-xs text-muted-foreground">
{label}
</span>
<CopyButton text={val} />
</div>
))}
</div>
)}
</div>
);
}
@@ -251,36 +269,87 @@ function AreaConverter() {
// ─── U-value → R-value Converter ─────────────────────────────────────────────
function UValueConverter() {
const [mode, setMode] = useState<"u-to-r" | "r-to-u">("u-to-r");
const [uValue, setUValue] = useState("");
const [rInput, setRInput] = useState("");
const [thickness, setThickness] = useState("");
const u = parseFloat(uValue);
const t = parseFloat(thickness);
const rValue = !isNaN(u) && u > 0 ? (1 / u).toFixed(4) : null;
const rsi = 0.13;
const rse = 0.04;
const rTotal =
rValue !== null ? (parseFloat(rValue) + rsi + rse).toFixed(4) : null;
// U → R
const u = parseFloat(uValue);
const rFromU = !isNaN(u) && u > 0 ? (1 / u).toFixed(4) : null;
const rTotalFromU =
rFromU !== null ? (parseFloat(rFromU) + rsi + rse).toFixed(4) : null;
// R → U
const rIn = parseFloat(rInput);
const uFromR = !isNaN(rIn) && rIn > 0 ? (1 / rIn).toFixed(4) : null;
const rTotalFromR = uFromR !== null ? (rIn + rsi + rse).toFixed(4) : null;
const activeR = mode === "u-to-r" ? rFromU : rInput || null;
const activeU = mode === "u-to-r" ? uValue || null : uFromR;
const activeRTotal = mode === "u-to-r" ? rTotalFromU : rTotalFromR;
const t = parseFloat(thickness);
const lambda =
rValue !== null && !isNaN(t) && t > 0
? (t / 100 / parseFloat(rValue)).toFixed(4)
activeR !== null && !isNaN(t) && t > 0
? (t / 100 / parseFloat(activeR)).toFixed(4)
: null;
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Button
variant={mode === "u-to-r" ? "default" : "outline"}
size="sm"
onClick={() => {
setMode("u-to-r");
setRInput("");
}}
>
U R
</Button>
<Button
variant={mode === "r-to-u" ? "default" : "outline"}
size="sm"
onClick={() => {
setMode("r-to-u");
setUValue("");
}}
>
R U
</Button>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<Label>Coeficient U (W/m²K)</Label>
<Input
type="number"
step="0.01"
min="0"
value={uValue}
onChange={(e) => setUValue(e.target.value)}
className="mt-1"
placeholder="ex: 0.35"
/>
</div>
{mode === "u-to-r" ? (
<div>
<Label>Coeficient U (W/m²K)</Label>
<Input
type="number"
step="0.01"
min="0"
value={uValue}
onChange={(e) => setUValue(e.target.value)}
className="mt-1"
placeholder="ex: 0.35"
/>
</div>
) : (
<div>
<Label>Rezistență termică R (m²K/W)</Label>
<Input
type="number"
step="0.01"
min="0"
value={rInput}
onChange={(e) => setRInput(e.target.value)}
className="mt-1"
placeholder="ex: 2.86"
/>
</div>
)}
<div>
<Label>Grosime material (cm) opțional</Label>
<Input
@@ -294,17 +363,30 @@ function UValueConverter() {
/>
</div>
</div>
{rValue !== null && (
{(activeR !== null || activeU !== null) && (
<div className="space-y-2 rounded-md border bg-muted/30 p-3 text-sm">
<div className="flex items-center justify-between">
<span className="font-medium">R = 1/U</span>
<div className="flex items-center gap-1">
<code className="rounded border bg-muted px-2 py-0.5">
{rValue} m²K/W
</code>
<CopyButton text={rValue} />
{mode === "u-to-r" && rFromU !== null && (
<div className="flex items-center justify-between">
<span className="font-medium">R = 1/U</span>
<div className="flex items-center gap-1">
<code className="rounded border bg-muted px-2 py-0.5">
{rFromU} m²K/W
</code>
<CopyButton text={rFromU} />
</div>
</div>
</div>
)}
{mode === "r-to-u" && uFromR !== null && (
<div className="flex items-center justify-between">
<span className="font-medium">U = 1/R</span>
<div className="flex items-center gap-1">
<code className="rounded border bg-muted px-2 py-0.5">
{uFromR} W/m²K
</code>
<CopyButton text={uFromR} />
</div>
</div>
)}
<div className="flex items-center justify-between text-muted-foreground">
<span>Rsi (suprafață interioară)</span>
<code className="rounded border bg-muted px-2 py-0.5">
@@ -317,15 +399,17 @@ function UValueConverter() {
{rse} m²K/W
</code>
</div>
<div className="flex items-center justify-between font-medium border-t pt-2 mt-1">
<span>R total (cu Rsi + Rse)</span>
<div className="flex items-center gap-1">
<code className="rounded border bg-muted px-2 py-0.5">
{rTotal} m²K/W
</code>
<CopyButton text={rTotal ?? ""} />
{activeRTotal !== null && (
<div className="flex items-center justify-between font-medium border-t pt-2 mt-1">
<span>R total (cu Rsi + Rse)</span>
<div className="flex items-center gap-1">
<code className="rounded border bg-muted px-2 py-0.5">
{activeRTotal} m²K/W
</code>
<CopyButton text={activeRTotal} />
</div>
</div>
</div>
)}
{lambda !== null && (
<div className="flex items-center justify-between text-muted-foreground border-t pt-2 mt-1">
<span>Conductivitate λ = d/R</span>
@@ -429,46 +513,47 @@ function AiArtifactCleaner() {
function MdlpaValidator() {
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-3 text-sm">
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Portalul MDLPA nu permite încadrarea în iframe (blochează via
X-Frame-Options). Folosește linkurile de mai jos pentru a accesa direct
portalul.
</p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<a
href="https://datelocale.mdlpa.ro"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2"
className="flex flex-col gap-1 rounded-lg border p-4 transition-colors hover:bg-muted/50"
>
Deschide datelocale.mdlpa.ro ↗
<span className="font-medium text-primary">Portalul principal ↗</span>
<span className="text-xs text-muted-foreground">
datelocale.mdlpa.ro — date locale construcții
</span>
</a>
<span className="text-muted-foreground">•</span>
<a
href="https://datelocale.mdlpa.ro/ro/about/tutorials"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2"
className="flex flex-col gap-1 rounded-lg border p-4 transition-colors hover:bg-muted/50"
>
Tutoriale video ↗
<span className="font-medium text-primary">Tutoriale video ↗</span>
<span className="text-xs text-muted-foreground">
Ghiduri de utilizare pas cu pas
</span>
</a>
<span className="text-muted-foreground">•</span>
<a
href="https://datelocale.mdlpa.ro/ro/about/info/reguli"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2"
className="flex flex-col gap-1 rounded-lg border p-4 transition-colors hover:bg-muted/50"
>
Reguli de calcul ↗
<span className="font-medium text-primary">Reguli de calcul ↗</span>
<span className="text-xs text-muted-foreground">
Metodologie și formule de calcul
</span>
</a>
</div>
<div
className="overflow-hidden rounded-md border"
style={{ height: "560px" }}
>
<iframe
src="https://datelocale.mdlpa.ro"
className="h-full w-full"
title="MDLPA — Date Locale"
allow="fullscreen"
/>
</div>
</div>
);
}
@@ -480,8 +565,14 @@ function PdfReducer() {
const [optimizeLevel, setOptimizeLevel] = useState("2");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [dragging, setDragging] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const setFileFromInput = (f: File | null) => {
setFile(f);
setError("");
};
const handleCompress = async () => {
if (!file) return;
setLoading(true);
@@ -518,6 +609,7 @@ function PdfReducer() {
return (
<div className="space-y-4">
{/* Drag & Drop zone */}
<div className="space-y-1.5">
<Label>Fișier PDF</Label>
<input
@@ -525,17 +617,34 @@ function PdfReducer() {
type="file"
accept=".pdf"
className="hidden"
onChange={(e) => {
setFile(e.target.files?.[0] ?? null);
setError("");
}}
onChange={(e) => setFileFromInput(e.target.files?.[0] ?? null)}
/>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" onClick={() => fileRef.current?.click()}>
Selectează PDF...
</Button>
{file && (
<span className="text-sm text-muted-foreground">{file.name}</span>
<div
className={`flex min-h-[80px] cursor-pointer flex-col items-center justify-center gap-1 rounded-md border-2 border-dashed p-4 text-sm transition-colors ${
dragging
? "border-primary bg-primary/5"
: "border-border text-muted-foreground hover:border-primary/50"
}`}
onClick={() => fileRef.current?.click()}
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
const f = e.dataTransfer.files[0];
if (f?.type === "application/pdf") setFileFromInput(f);
}}
>
{file ? (
<span className="font-medium text-foreground">{file.name}</span>
) : (
<>
<Upload className="h-5 w-5" />
<span>Trage un PDF aici sau apasă pentru a selecta</span>
</>
)}
</div>
</div>
@@ -547,11 +656,8 @@ function PdfReducer() {
onChange={(e) => setOptimizeLevel(e.target.value)}
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm"
>
<option value="0">0 — fără modificări (test)</option>
<option value="1">1 — compresie minimă</option>
<option value="2">2 — echilibrat (recomandat)</option>
<option value="3">3 — compresie mare</option>
<option value="4">4 — compresie maximă</option>
<option value="2">Echilibrat (recomandat)</option>
<option value="4">Compresie maximă</option>
</select>
</div>
@@ -565,11 +671,36 @@ function PdfReducer() {
target="_blank"
rel="noopener noreferrer"
>
Deschide Stirling PDF ↗
Stirling PDF ↗
</a>
</Button>
</div>
<div className="grid gap-2 sm:grid-cols-2 pt-2 border-t">
<a
href="http://10.10.10.166:8087/remove-password"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 rounded-lg border p-3 text-sm transition-colors hover:bg-muted/50"
>
<span className="font-medium text-primary">🔓 Deblocare PDF ↗</span>
<span className="text-xs text-muted-foreground ml-auto">
Remove protection
</span>
</a>
<a
href="http://10.10.10.166:8087/pdf-to-pdfa"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 rounded-lg border p-3 text-sm transition-colors hover:bg-muted/50"
>
<span className="font-medium text-primary">📋 Conversie PDF/A ↗</span>
<span className="text-xs text-muted-foreground ml-auto">
Arhivare termen lung
</span>
</a>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
);
@@ -715,6 +846,352 @@ function QuickOcr() {
);
}
// ─── Number to Text (Romanian) ────────────────────────────────────────────────
const ONES = [
"",
"unu",
"doi",
"trei",
"patru",
"cinci",
"șase",
"șapte",
"opt",
"nouă",
"zece",
"unsprezece",
"doisprezece",
"treisprezece",
"paisprezece",
"cincisprezece",
"șaisprezece",
"șaptesprezece",
"optsprezece",
"nouăsprezece",
];
const TENS = [
"",
"zece",
"douăzeci",
"treizeci",
"patruzeci",
"cincizeci",
"șaizeci",
"șaptezeci",
"optzeci",
"nouăzeci",
];
function numberToRoText(n: number): string {
if (n === 0) return "zero";
if (n < 0) return "minus " + numberToRoText(-n);
const parts: string[] = [];
// Billions
const mld = Math.floor(n / 1_000_000_000);
if (mld > 0) {
if (mld === 1) parts.push("un miliard");
else if (mld < 20) parts.push(ONES[mld]! + " miliarde");
else parts.push(twoDigitRo(mld) + " de miliarde");
n %= 1_000_000_000;
}
// Millions
const mil = Math.floor(n / 1_000_000);
if (mil > 0) {
if (mil === 1) parts.push("un milion");
else if (mil < 20) parts.push(ONES[mil]! + " milioane");
else parts.push(twoDigitRo(mil) + " de milioane");
n %= 1_000_000;
}
// Thousands
const th = Math.floor(n / 1000);
if (th > 0) {
if (th === 1) parts.push("o mie");
else if (th === 2) parts.push("două mii");
else if (th < 20) parts.push(ONES[th]! + " mii");
else if (th < 100) parts.push(twoDigitRo(th) + " de mii");
else parts.push(threeDigitRo(th) + " de mii");
n %= 1000;
}
// Hundreds
const h = Math.floor(n / 100);
if (h > 0) {
if (h === 1) parts.push("o sută");
else if (h === 2) parts.push("două sute");
else parts.push(ONES[h]! + " sute");
n %= 100;
}
// Remainder < 100
if (n > 0) {
if (parts.length > 0) parts.push("și");
if (n < 20) {
parts.push(ONES[n]!);
} else {
parts.push(twoDigitRo(n));
}
}
return parts.join(" ");
}
function twoDigitRo(n: number): string {
if (n < 20) return ONES[n]!;
const t = Math.floor(n / 10);
const o = n % 10;
if (o === 0) return TENS[t]!;
return TENS[t]! + " și " + ONES[o]!;
}
function threeDigitRo(n: number): string {
const h = Math.floor(n / 100);
const rest = n % 100;
let s = "";
if (h === 1) s = "o sută";
else if (h === 2) s = "două sute";
else s = ONES[h]! + " sute";
if (rest > 0) s += " " + twoDigitRo(rest);
return s;
}
function NumberToText() {
const [input, setInput] = useState("");
const v = parseFloat(input);
const isValid = !isNaN(v) && isFinite(v) && v >= 0 && v < 1e12;
const intPart = isValid ? Math.floor(v) : 0;
const decStr = input.includes(".") ? (input.split(".")[1] ?? "") : "";
const decPart = decStr ? parseInt(decStr.slice(0, 2).padEnd(2, "0"), 10) : 0;
const intText = isValid ? numberToRoText(intPart) : "";
const decText = decPart > 0 ? numberToRoText(decPart) : "";
// lei + bani version
const leiBani = isValid
? intText + " lei" + (decPart > 0 ? " și " + decText + " de bani" : "")
: "";
// Compact (no spaces between groups)
const compact = isValid
? intText.replace(/ și /g, "șI").replace(/ /g, "").replace(/șI/g, "și") +
(decPart > 0
? " virgulă " +
decText.replace(/ și /g, "șI").replace(/ /g, "").replace(/șI/g, "și")
: "")
: "";
// Normal
const normal = isValid
? intText + (decPart > 0 ? " virgulă " + decText : "")
: "";
return (
<div className="space-y-3">
<div>
<Label>Sumă (număr)</Label>
<Input
type="number"
step="0.01"
min="0"
value={input}
onChange={(e) => setInput(e.target.value)}
className="mt-1"
placeholder="ex: 432.20"
/>
</div>
{isValid && intText && (
<div className="space-y-2">
<div className="flex items-start gap-2 rounded-md border bg-muted/30 p-3">
<div className="flex-1 text-sm">
<p className="text-xs font-medium text-muted-foreground mb-1">
Lei și bani
</p>
<p>{leiBani}</p>
</div>
<CopyButton text={leiBani} />
</div>
<div className="flex items-start gap-2 rounded-md border bg-muted/30 p-3">
<div className="flex-1 text-sm">
<p className="text-xs font-medium text-muted-foreground mb-1">
Cu spații
</p>
<p>{normal}</p>
</div>
<CopyButton text={normal} />
</div>
<div className="flex items-start gap-2 rounded-md border bg-muted/30 p-3">
<div className="flex-1 text-sm">
<p className="text-xs font-medium text-muted-foreground mb-1">
Compact (fără spații)
</p>
<p>{compact}</p>
</div>
<CopyButton text={compact} />
</div>
</div>
)}
</div>
);
}
// ─── Color Palette Extractor ──────────────────────────────────────────────────
function ColorPaletteExtractor() {
const [imageSrc, setImageSrc] = useState<string | null>(null);
const [colors, setColors] = useState<string[]>([]);
const [copiedIdx, setCopiedIdx] = useState<number | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const extractColors = useCallback((src: string) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
const canvas = document.createElement("canvas");
const size = 100;
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d")!;
ctx.drawImage(img, 0, 0, size, size);
const data = ctx.getImageData(0, 0, size, size).data;
// Simple K-means-ish clustering: bucket colors
const buckets = new Map<
string,
{ r: number; g: number; b: number; count: number }
>();
for (let i = 0; i < data.length; i += 4) {
const r = Math.round(data[i]! / 32) * 32;
const g = Math.round(data[i + 1]! / 32) * 32;
const b = Math.round(data[i + 2]! / 32) * 32;
const key = `${r},${g},${b}`;
const existing = buckets.get(key);
if (existing) {
existing.r += data[i]!;
existing.g += data[i + 1]!;
existing.b += data[i + 2]!;
existing.count++;
} else {
buckets.set(key, {
r: data[i]!,
g: data[i + 1]!,
b: data[i + 2]!,
count: 1,
});
}
}
const sorted = Array.from(buckets.values())
.sort((a, b) => b.count - a.count)
.slice(0, 8);
const palette = sorted.map((b) => {
const r = Math.round(b.r / b.count);
const g = Math.round(b.g / b.count);
const bl = Math.round(b.b / b.count);
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${bl.toString(16).padStart(2, "0")}`;
});
setColors(palette);
};
img.src = src;
}, []);
const handleFile = (file: File) => {
if (!file.type.startsWith("image/")) return;
const reader = new FileReader();
reader.onload = (e) => {
const src = e.target?.result as string;
setImageSrc(src);
extractColors(src);
};
reader.readAsDataURL(file);
};
const copyColor = async (hex: string, idx: number) => {
try {
await navigator.clipboard.writeText(hex.toUpperCase());
setCopiedIdx(idx);
setTimeout(() => setCopiedIdx(null), 1500);
} catch {
/* silent */
}
};
return (
<div className="space-y-4">
<div
className="flex min-h-[120px] cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed p-4 text-sm text-muted-foreground transition-colors hover:border-primary/50"
onClick={() => fileRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const f = e.dataTransfer.files[0];
if (f) handleFile(f);
}}
>
{imageSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={imageSrc}
alt="preview"
className="max-h-40 max-w-full rounded object-contain"
/>
) : (
<>
<Upload className="h-6 w-6" />
<span>Trage o imagine sau apasă pentru a selecta</span>
</>
)}
</div>
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleFile(f);
}}
/>
{colors.length > 0 && (
<div className="space-y-2">
<Label>Paletă extrasă ({colors.length} culori)</Label>
<div className="flex flex-wrap gap-2">
{colors.map((hex, i) => (
<button
key={i}
className="group relative flex flex-col items-center gap-1 rounded-lg border p-2 transition-colors hover:bg-muted/50"
onClick={() => void copyColor(hex, i)}
title={`Click pentru a copia ${hex.toUpperCase()}`}
>
<div
className="h-12 w-12 rounded-md border shadow-sm"
style={{ backgroundColor: hex }}
/>
<code className="text-[10px] text-muted-foreground">
{copiedIdx === i ? "Copiat!" : hex.toUpperCase()}
</code>
</button>
))}
</div>
<div className="flex items-center gap-2 pt-1">
<CopyButton text={colors.map((c) => c.toUpperCase()).join(", ")} />
<span className="text-xs text-muted-foreground">
Copiază toate culorile
</span>
</div>
</div>
)}
</div>
);
}
// ─── Main Module ──────────────────────────────────────────────────────────────
export function MiniUtilitiesModule() {
@@ -734,7 +1211,10 @@ export function MiniUtilitiesModule() {
<Ruler className="mr-1 h-3.5 w-3.5" /> Suprafețe
</TabsTrigger>
<TabsTrigger value="u-value">
<Zap className="mr-1 h-3.5 w-3.5" /> U R
<Zap className="mr-1 h-3.5 w-3.5" /> U R
</TabsTrigger>
<TabsTrigger value="num-to-text">
<CaseUpper className="mr-1 h-3.5 w-3.5" /> Numere → Text
</TabsTrigger>
<TabsTrigger value="ai-cleaner">
<Wand2 className="mr-1 h-3.5 w-3.5" /> Curățare AI
@@ -748,6 +1228,9 @@ export function MiniUtilitiesModule() {
<TabsTrigger value="ocr">
<ScanText className="mr-1 h-3.5 w-3.5" /> OCR
</TabsTrigger>
<TabsTrigger value="color-palette">
<Palette className="mr-1 h-3.5 w-3.5" /> Paletă culori
</TabsTrigger>
</TabsList>
<TabsContent value="text-case">
@@ -794,7 +1277,7 @@ export function MiniUtilitiesModule() {
<Card>
<CardHeader>
<CardTitle className="text-base">
Convertor U R (termoizolație)
Convertor U R (termoizolație)
</CardTitle>
</CardHeader>
<CardContent>
@@ -846,6 +1329,28 @@ export function MiniUtilitiesModule() {
</CardContent>
</Card>
</TabsContent>
<TabsContent value="num-to-text">
<Card>
<CardHeader>
<CardTitle className="text-base">
Transformare numere în litere
</CardTitle>
</CardHeader>
<CardContent>
<NumberToText />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="color-palette">
<Card>
<CardHeader>
<CardTitle className="text-base">Extractor paletă culori</CardTitle>
</CardHeader>
<CardContent>
<ColorPaletteExtractor />
</CardContent>
</Card>
</TabsContent>
</Tabs>
);
}