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]) => (
|
{groupedAssets.map(([group, items]) => (
|
||||||
<div key={group}>
|
<div key={group}>
|
||||||
<h3 className="mb-3 text-sm font-medium text-muted-foreground">
|
<h3 className="mb-3 text-sm font-medium text-muted-foreground">
|
||||||
{group}{" "}
|
{group} <span className="text-xs">({items.length})</span>
|
||||||
<span className="text-xs">({items.length})</span>
|
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{items.map((asset) => (
|
{items.map((asset) => (
|
||||||
@@ -539,15 +538,11 @@ function AssetCard({
|
|||||||
<FileDown className="mr-2 h-4 w-4" />
|
<FileDown className="mr-2 h-4 w-4" />
|
||||||
<span>Original</span>
|
<span>Original</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem onClick={() => void downloadAsWord(asset)}>
|
||||||
onClick={() => void downloadAsWord(asset)}
|
|
||||||
>
|
|
||||||
<FileText className="mr-2 h-4 w-4" />
|
<FileText className="mr-2 h-4 w-4" />
|
||||||
<span>Word (.docx)</span>
|
<span>Word (.docx)</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem onClick={() => void downloadAsPdf(asset)}>
|
||||||
onClick={() => void downloadAsPdf(asset)}
|
|
||||||
>
|
|
||||||
<FileDown className="mr-2 h-4 w-4" />
|
<FileDown className="mr-2 h-4 w-4" />
|
||||||
<span>PDF (.pdf)</span>
|
<span>PDF (.pdf)</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -575,8 +570,7 @@ function ImageUploadField({
|
|||||||
const [processing, setProcessing] = useState(false);
|
const [processing, setProcessing] = useState(false);
|
||||||
|
|
||||||
const handleFile = async (file: File) => {
|
const handleFile = async (file: File) => {
|
||||||
const isTiff =
|
const isTiff = file.type === "image/tiff" || /\.tiff?$/i.test(file.name);
|
||||||
file.type === "image/tiff" || /\.tiff?$/i.test(file.name);
|
|
||||||
|
|
||||||
if (isTiff) {
|
if (isTiff) {
|
||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
@@ -638,9 +632,7 @@ function ImageUploadField({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Upload className="h-6 w-6" />
|
<Upload className="h-6 w-6" />
|
||||||
<span>
|
<span>Trage imaginea aici sau apasă pentru a selecta</span>
|
||||||
Trage imaginea aici sau apasă pentru a selecta
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground/60">
|
<span className="text-xs text-muted-foreground/60">
|
||||||
PNG, JPG, TIFF
|
PNG, JPG, TIFF
|
||||||
</span>
|
</span>
|
||||||
@@ -778,9 +770,7 @@ function AssetForm({
|
|||||||
const [company, setCompany] = useState<CompanyId>(
|
const [company, setCompany] = useState<CompanyId>(
|
||||||
initial?.company ?? "beletage",
|
initial?.company ?? "beletage",
|
||||||
);
|
);
|
||||||
const [subcategory, setSubcategory] = useState(
|
const [subcategory, setSubcategory] = useState(initial?.subcategory ?? "");
|
||||||
initial?.subcategory ?? "",
|
|
||||||
);
|
|
||||||
const [tags, setTags] = useState<string[]>(initial?.tags ?? []);
|
const [tags, setTags] = useState<string[]>(initial?.tags ?? []);
|
||||||
const [tagInput, setTagInput] = useState("");
|
const [tagInput, setTagInput] = useState("");
|
||||||
|
|
||||||
@@ -927,9 +917,7 @@ function AssetForm({
|
|||||||
if (tagInput.trim()) addTag(tagInput);
|
if (tagInput.trim()) addTag(tagInput);
|
||||||
}}
|
}}
|
||||||
placeholder={
|
placeholder={
|
||||||
tags.length === 0
|
tags.length === 0 ? "Adaugă etichete (Enter sau virgulă)..." : ""
|
||||||
? "Adaugă etichete (Enter sau virgulă)..."
|
|
||||||
: ""
|
|
||||||
}
|
}
|
||||||
className="min-w-[120px] flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
className="min-w-[120px] flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef } from "react";
|
import { useState, useRef, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
Copy,
|
Copy,
|
||||||
Check,
|
Check,
|
||||||
@@ -13,6 +13,9 @@ import {
|
|||||||
Building2,
|
Building2,
|
||||||
FileDown,
|
FileDown,
|
||||||
ScanText,
|
ScanText,
|
||||||
|
CaseUpper,
|
||||||
|
Palette,
|
||||||
|
Upload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Input } from "@/shared/components/ui/input";
|
import { Input } from "@/shared/components/ui/input";
|
||||||
@@ -204,46 +207,61 @@ function PercentageCalculator() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AreaConverter() {
|
function AreaConverter() {
|
||||||
const [mp, setMp] = useState("");
|
const units = [
|
||||||
const v = parseFloat(mp);
|
{ 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)
|
const [values, setValues] = useState<Record<string, string>>({});
|
||||||
? [
|
const [activeField, setActiveField] = useState<string | null>(null);
|
||||||
{ label: "mp (m²)", value: v.toFixed(2) },
|
|
||||||
{ label: "ari (100 m²)", value: (v / 100).toFixed(4) },
|
const handleChange = (key: string, raw: string) => {
|
||||||
{ label: "hectare (10.000 m²)", value: (v / 10000).toFixed(6) },
|
if (raw === "") {
|
||||||
{ label: "km²", value: (v / 1000000).toFixed(8) },
|
setValues({});
|
||||||
{ label: "sq ft", value: (v * 10.7639).toFixed(2) },
|
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 (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<p className="text-xs text-muted-foreground">
|
||||||
<Label>Suprafață (m²)</Label>
|
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
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={mp}
|
value={values[u.key] ?? ""}
|
||||||
onChange={(e) => setMp(e.target.value)}
|
onFocus={() => setActiveField(u.key)}
|
||||||
className="mt-1"
|
onBlur={() => setActiveField(null)}
|
||||||
placeholder="Introdu suprafața..."
|
onChange={(e) => handleChange(u.key, e.target.value)}
|
||||||
|
className={`flex-1 ${activeField === u.key ? "ring-1 ring-primary" : ""}`}
|
||||||
|
placeholder="0"
|
||||||
/>
|
/>
|
||||||
</div>
|
<CopyButton text={values[u.key] ?? ""} />
|
||||||
{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>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -251,24 +269,61 @@ function AreaConverter() {
|
|||||||
// ─── U-value → R-value Converter ─────────────────────────────────────────────
|
// ─── U-value → R-value Converter ─────────────────────────────────────────────
|
||||||
|
|
||||||
function UValueConverter() {
|
function UValueConverter() {
|
||||||
|
const [mode, setMode] = useState<"u-to-r" | "r-to-u">("u-to-r");
|
||||||
const [uValue, setUValue] = useState("");
|
const [uValue, setUValue] = useState("");
|
||||||
|
const [rInput, setRInput] = useState("");
|
||||||
const [thickness, setThickness] = 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 rsi = 0.13;
|
||||||
const rse = 0.04;
|
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 =
|
const lambda =
|
||||||
rValue !== null && !isNaN(t) && t > 0
|
activeR !== null && !isNaN(t) && t > 0
|
||||||
? (t / 100 / parseFloat(rValue)).toFixed(4)
|
? (t / 100 / parseFloat(activeR)).toFixed(4)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<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 className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{mode === "u-to-r" ? (
|
||||||
<div>
|
<div>
|
||||||
<Label>Coeficient U (W/m²K)</Label>
|
<Label>Coeficient U (W/m²K)</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -281,6 +336,20 @@ function UValueConverter() {
|
|||||||
placeholder="ex: 0.35"
|
placeholder="ex: 0.35"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<Label>Grosime material (cm) — opțional</Label>
|
<Label>Grosime material (cm) — opțional</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -294,17 +363,30 @@ function UValueConverter() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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="space-y-2 rounded-md border bg-muted/30 p-3 text-sm">
|
||||||
|
{mode === "u-to-r" && rFromU !== null && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="font-medium">R = 1/U</span>
|
<span className="font-medium">R = 1/U</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<code className="rounded border bg-muted px-2 py-0.5">
|
<code className="rounded border bg-muted px-2 py-0.5">
|
||||||
{rValue} m²K/W
|
{rFromU} m²K/W
|
||||||
</code>
|
</code>
|
||||||
<CopyButton text={rValue} />
|
<CopyButton text={rFromU} />
|
||||||
</div>
|
</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">
|
<div className="flex items-center justify-between text-muted-foreground">
|
||||||
<span>Rsi (suprafață interioară)</span>
|
<span>Rsi (suprafață interioară)</span>
|
||||||
<code className="rounded border bg-muted px-2 py-0.5">
|
<code className="rounded border bg-muted px-2 py-0.5">
|
||||||
@@ -317,15 +399,17 @@ function UValueConverter() {
|
|||||||
{rse} m²K/W
|
{rse} m²K/W
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
|
{activeRTotal !== null && (
|
||||||
<div className="flex items-center justify-between font-medium border-t pt-2 mt-1">
|
<div className="flex items-center justify-between font-medium border-t pt-2 mt-1">
|
||||||
<span>R total (cu Rsi + Rse)</span>
|
<span>R total (cu Rsi + Rse)</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<code className="rounded border bg-muted px-2 py-0.5">
|
<code className="rounded border bg-muted px-2 py-0.5">
|
||||||
{rTotal} m²K/W
|
{activeRTotal} m²K/W
|
||||||
</code>
|
</code>
|
||||||
<CopyButton text={rTotal ?? ""} />
|
<CopyButton text={activeRTotal} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{lambda !== null && (
|
{lambda !== null && (
|
||||||
<div className="flex items-center justify-between text-muted-foreground border-t pt-2 mt-1">
|
<div className="flex items-center justify-between text-muted-foreground border-t pt-2 mt-1">
|
||||||
<span>Conductivitate λ = d/R</span>
|
<span>Conductivitate λ = d/R</span>
|
||||||
@@ -429,46 +513,47 @@ function AiArtifactCleaner() {
|
|||||||
|
|
||||||
function MdlpaValidator() {
|
function MdlpaValidator() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
<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
|
<a
|
||||||
href="https://datelocale.mdlpa.ro"
|
href="https://datelocale.mdlpa.ro"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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>
|
</a>
|
||||||
<span className="text-muted-foreground">•</span>
|
|
||||||
<a
|
<a
|
||||||
href="https://datelocale.mdlpa.ro/ro/about/tutorials"
|
href="https://datelocale.mdlpa.ro/ro/about/tutorials"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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>
|
</a>
|
||||||
<span className="text-muted-foreground">•</span>
|
|
||||||
<a
|
<a
|
||||||
href="https://datelocale.mdlpa.ro/ro/about/info/reguli"
|
href="https://datelocale.mdlpa.ro/ro/about/info/reguli"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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>
|
</a>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -480,8 +565,14 @@ function PdfReducer() {
|
|||||||
const [optimizeLevel, setOptimizeLevel] = useState("2");
|
const [optimizeLevel, setOptimizeLevel] = useState("2");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const setFileFromInput = (f: File | null) => {
|
||||||
|
setFile(f);
|
||||||
|
setError("");
|
||||||
|
};
|
||||||
|
|
||||||
const handleCompress = async () => {
|
const handleCompress = async () => {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -518,6 +609,7 @@ function PdfReducer() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{/* Drag & Drop zone */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>Fișier PDF</Label>
|
<Label>Fișier PDF</Label>
|
||||||
<input
|
<input
|
||||||
@@ -525,17 +617,34 @@ function PdfReducer() {
|
|||||||
type="file"
|
type="file"
|
||||||
accept=".pdf"
|
accept=".pdf"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => {
|
onChange={(e) => setFileFromInput(e.target.files?.[0] ?? null)}
|
||||||
setFile(e.target.files?.[0] ?? null);
|
|
||||||
setError("");
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div
|
||||||
<Button variant="outline" onClick={() => fileRef.current?.click()}>
|
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 ${
|
||||||
Selectează PDF...
|
dragging
|
||||||
</Button>
|
? "border-primary bg-primary/5"
|
||||||
{file && (
|
: "border-border text-muted-foreground hover:border-primary/50"
|
||||||
<span className="text-sm text-muted-foreground">{file.name}</span>
|
}`}
|
||||||
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -547,11 +656,8 @@ function PdfReducer() {
|
|||||||
onChange={(e) => setOptimizeLevel(e.target.value)}
|
onChange={(e) => setOptimizeLevel(e.target.value)}
|
||||||
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm"
|
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="2">Echilibrat (recomandat)</option>
|
||||||
<option value="1">1 — compresie minimă</option>
|
<option value="4">Compresie maximă</option>
|
||||||
<option value="2">2 — echilibrat (recomandat)</option>
|
|
||||||
<option value="3">3 — compresie mare</option>
|
|
||||||
<option value="4">4 — compresie maximă</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -565,11 +671,36 @@ function PdfReducer() {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
Deschide Stirling PDF ↗
|
Stirling PDF ↗
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>}
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
</div>
|
</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 ──────────────────────────────────────────────────────────────
|
// ─── Main Module ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function MiniUtilitiesModule() {
|
export function MiniUtilitiesModule() {
|
||||||
@@ -734,7 +1211,10 @@ export function MiniUtilitiesModule() {
|
|||||||
<Ruler className="mr-1 h-3.5 w-3.5" /> Suprafețe
|
<Ruler className="mr-1 h-3.5 w-3.5" /> Suprafețe
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="u-value">
|
<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>
|
||||||
<TabsTrigger value="ai-cleaner">
|
<TabsTrigger value="ai-cleaner">
|
||||||
<Wand2 className="mr-1 h-3.5 w-3.5" /> Curățare AI
|
<Wand2 className="mr-1 h-3.5 w-3.5" /> Curățare AI
|
||||||
@@ -748,6 +1228,9 @@ export function MiniUtilitiesModule() {
|
|||||||
<TabsTrigger value="ocr">
|
<TabsTrigger value="ocr">
|
||||||
<ScanText className="mr-1 h-3.5 w-3.5" /> OCR
|
<ScanText className="mr-1 h-3.5 w-3.5" /> OCR
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="color-palette">
|
||||||
|
<Palette className="mr-1 h-3.5 w-3.5" /> Paletă culori
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="text-case">
|
<TabsContent value="text-case">
|
||||||
@@ -794,7 +1277,7 @@ export function MiniUtilitiesModule() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">
|
<CardTitle className="text-base">
|
||||||
Convertor U → R (termoizolație)
|
Convertor U ↔ R (termoizolație)
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -846,6 +1329,28 @@ export function MiniUtilitiesModule() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</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>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user