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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user