Files
ArchiTools/src/modules/mini-utilities/components/mini-utilities-module.tsx
T
AI Assistant d75fcb1d1c fix(pdf-compress): remove /screen preset that destroys font encoding
The -dPDFSETTINGS=/screen GS preset overwrites font encoding tables,
producing garbled text in output PDFs. Replace with individual params
that ONLY compress images while preserving fonts intact.

Three quality levels via GS (no Stirling dependency):
- extreme: 100 DPI, QFactor 1.2 (~quality 35)
- high: 150 DPI, QFactor 0.76 (~quality 50)
- balanced: 200 DPI, QFactor 0.4 (~quality 70)

Route all UI modes through the GS endpoint with level parameter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:19:42 +02:00

2968 lines
94 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useRef, useCallback, useEffect } from "react";
import {
Copy,
Check,
Hash,
Type,
Percent,
Ruler,
Zap,
Wand2,
Building2,
FileDown,
ScanText,
CaseUpper,
Palette,
Upload,
Receipt,
Layers,
ArrowUp,
ArrowDown,
Unlock,
PenTool,
Maximize2,
} from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { Textarea } from "@/shared/components/ui/textarea";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/shared/components/ui/tabs";
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* silent */
}
};
return (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleCopy}
disabled={!text}
>
{copied ? (
<Check className="h-3.5 w-3.5 text-green-500" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
);
}
function TextCaseConverter() {
const [input, setInput] = useState("");
const upper = input.toUpperCase();
const lower = input.toLowerCase();
const title = input.replace(/\b\w/g, (c) => c.toUpperCase());
const sentence = input.charAt(0).toUpperCase() + input.slice(1).toLowerCase();
return (
<div className="space-y-3">
<div>
<Label>Text sursă</Label>
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
rows={3}
className="mt-1"
placeholder="Introdu text..."
/>
</div>
{[
{ label: "UPPERCASE", value: upper },
{ label: "lowercase", value: lower },
{ label: "Title Case", value: title },
{ label: "Sentence case", value: sentence },
].map(({ label, value }) => (
<div key={label} className="flex items-center gap-2">
<code className="flex-1 truncate rounded border bg-muted/30 px-2 py-1 text-xs">
{value || "—"}
</code>
<span className="w-24 text-xs text-muted-foreground">{label}</span>
<CopyButton text={value} />
</div>
))}
</div>
);
}
function CharacterCounter() {
const [input, setInput] = useState("");
const chars = input.length;
const charsNoSpaces = input.replace(/\s/g, "").length;
const words = input.trim() ? input.trim().split(/\s+/).length : 0;
const lines = input ? input.split("\n").length : 0;
return (
<div className="space-y-3">
<div>
<Label>Text</Label>
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
rows={5}
className="mt-1"
placeholder="Introdu text..."
/>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Card>
<CardContent className="p-3">
<p className="text-xs text-muted-foreground">Caractere</p>
<p className="text-xl font-bold">{chars}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-3">
<p className="text-xs text-muted-foreground">Fără spații</p>
<p className="text-xl font-bold">{charsNoSpaces}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-3">
<p className="text-xs text-muted-foreground">Cuvinte</p>
<p className="text-xl font-bold">{words}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-3">
<p className="text-xs text-muted-foreground">Linii</p>
<p className="text-xl font-bold">{lines}</p>
</CardContent>
</Card>
</div>
</div>
);
}
function PercentageCalculator() {
const [value, setValue] = useState("");
const [total, setTotal] = useState("");
const [percent, setPercent] = useState("");
const v = parseFloat(value);
const t = parseFloat(total);
const p = parseFloat(percent);
const pctOfTotal =
!isNaN(v) && !isNaN(t) && t !== 0 ? ((v / t) * 100).toFixed(2) : "—";
const valFromPct = !isNaN(p) && !isNaN(t) ? ((p / 100) * t).toFixed(2) : "—";
return (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-3">
<div>
<Label>Valoare</Label>
<Input
type="number"
value={value}
onChange={(e) => setValue(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label>Total</Label>
<Input
type="number"
value={total}
onChange={(e) => setTotal(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label>Procent</Label>
<Input
type="number"
value={percent}
onChange={(e) => setPercent(e.target.value)}
className="mt-1"
/>
</div>
</div>
<div className="space-y-2 rounded-md border bg-muted/30 p-3 text-sm">
<p>
<strong>{value || "?"}</strong> din <strong>{total || "?"}</strong> ={" "}
<strong>{pctOfTotal}%</strong>
</p>
<p>
<strong>{percent || "?"}%</strong> din <strong>{total || "?"}</strong>{" "}
= <strong>{valFromPct}</strong>
</p>
</div>
</div>
);
}
const TVA_PRESETS = [5, 9, 19, 21];
function TvaCalculator() {
const [tvaRate, setTvaRate] = useState(19);
const [customRate, setCustomRate] = useState("");
const [amount, setAmount] = useState("");
const [mode, setMode] = useState<"add" | "extract">("add");
const effectiveRate =
customRate !== "" ? parseFloat(customRate) || tvaRate : tvaRate;
const val = parseFloat(amount);
const tvaMultiplier = effectiveRate / 100;
const cuTva = !isNaN(val) && mode === "add" ? val * (1 + tvaMultiplier) : NaN;
const faraTva =
!isNaN(val) && mode === "extract" ? val / (1 + tvaMultiplier) : NaN;
const tvaAmount = !isNaN(val)
? mode === "add"
? val * tvaMultiplier
: val - val / (1 + tvaMultiplier)
: NaN;
const fmt = (n: number) =>
isNaN(n)
? "—"
: n.toLocaleString("ro-RO", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
return (
<div className="space-y-4">
{/* Rate selector */}
<div>
<Label className="text-xs text-muted-foreground">Cotă TVA</Label>
<div className="mt-1 flex flex-wrap items-center gap-2">
{TVA_PRESETS.map((r) => (
<Button
key={r}
type="button"
variant={tvaRate === r && customRate === "" ? "default" : "outline"}
size="sm"
onClick={() => { setTvaRate(r); setCustomRate(""); }}
>
{r}%
</Button>
))}
<div className="flex items-center gap-1">
<Input
type="number"
min={0}
max={100}
step={0.5}
value={customRate}
onChange={(e) => setCustomRate(e.target.value)}
placeholder="Altă cotă"
className="w-28 text-sm"
/>
{customRate && (
<span className="text-xs text-muted-foreground">%</span>
)}
</div>
</div>
</div>
<div className="flex gap-2">
<Button
variant={mode === "add" ? "default" : "outline"}
size="sm"
onClick={() => setMode("add")}
>
Adaugă TVA
</Button>
<Button
variant={mode === "extract" ? "default" : "outline"}
size="sm"
onClick={() => setMode("extract")}
>
Extrage TVA
</Button>
</div>
<div>
<Label>{mode === "add" ? "Sumă fără TVA" : "Sumă cu TVA"}</Label>
<div className="mt-1 flex gap-2">
<Input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder={mode === "add" ? "Ex: 1000" : "Ex: 1190"}
className="flex-1"
/>
<span className="flex items-center text-sm text-muted-foreground">
RON
</span>
</div>
</div>
{!isNaN(val) && val > 0 && (
<div className="rounded-md border bg-muted/30 p-4 space-y-2 text-sm">
{mode === "add" ? (
<>
<p>
Sumă fără TVA: <strong>{fmt(val)} RON</strong>
</p>
<p>
TVA ({effectiveRate}%): <strong>{fmt(tvaAmount)} RON</strong>
<CopyButton text={fmt(tvaAmount)} />
</p>
<p className="text-base pt-1 border-t">
Total cu TVA:{" "}
<strong className="text-primary">{fmt(cuTva)} RON</strong>
<CopyButton text={fmt(cuTva)} />
</p>
</>
) : (
<>
<p>
Sumă cu TVA: <strong>{fmt(val)} RON</strong>
</p>
<p>
TVA ({effectiveRate}%): <strong>{fmt(tvaAmount)} RON</strong>
<CopyButton text={fmt(tvaAmount)} />
</p>
<p className="text-base pt-1 border-t">
Sumă fără TVA:{" "}
<strong className="text-primary">{fmt(faraTva)} RON</strong>
<CopyButton text={fmt(faraTva)} />
</p>
</>
)}
</div>
)}
</div>
);
}
function AreaConverter() {
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 [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">
<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>
</div>
);
}
// ─── 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 rsi = 0.13;
const rse = 0.04;
// 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 =
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">
{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
type="number"
step="0.1"
min="0"
value={thickness}
onChange={(e) => setThickness(e.target.value)}
className="mt-1"
placeholder="ex: 20"
/>
</div>
</div>
{(activeR !== null || activeU !== null) && (
<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">
<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>
)}
{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">
{rsi} m²K/W
</code>
</div>
<div className="flex items-center justify-between text-muted-foreground">
<span>Rse (suprafață exterioară)</span>
<code className="rounded border bg-muted px-2 py-0.5">
{rse} m²K/W
</code>
</div>
{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>
)}
{lambda !== null && (
<div className="flex items-center justify-between text-muted-foreground border-t pt-2 mt-1">
<span>Conductivitate λ = d/R</span>
<div className="flex items-center gap-1">
<code className="rounded border bg-muted px-2 py-0.5">
{lambda} W/mK
</code>
<CopyButton text={lambda} />
</div>
</div>
)}
</div>
)}
</div>
);
}
// ─── Material Thermal Comparison ─────────────────────────────────────────────
interface ThermalMaterial {
id: string;
name: string;
lambda: number; // W/mK — design value (λD) from Romanian technical sheets
category: string;
source: string; // brand / standard reference
}
// λD values from Romanian-market technical data sheets (fișe tehnice)
// Sources: Austrotherm, ISOVER, Knauf Insulation, Ytong/Xella, Porotherm/Wienerberger,
// Baumit, Caparol, Tondach, SR EN ISO 10456, C107
const THERMAL_MATERIALS: ThermalMaterial[] = [
// Termoizolații
{
id: "eps-70",
name: "Polistiren expandat EPS 70",
lambda: 0.037,
category: "Termoizolații",
source: "Austrotherm EPS A70",
},
{
id: "eps-80",
name: "Polistiren expandat EPS 80",
lambda: 0.035,
category: "Termoizolații",
source: "Austrotherm EPS A80",
},
{
id: "eps-100",
name: "Polistiren expandat EPS 100",
lambda: 0.033,
category: "Termoizolații",
source: "Austrotherm EPS A100",
},
{
id: "eps-graf",
name: "Polistiren grafitat (EPS-G)",
lambda: 0.031,
category: "Termoizolații",
source: "Austrotherm EPS A150 Grafit",
},
{
id: "xps-30",
name: "Polistiren extrudat XPS 30",
lambda: 0.034,
category: "Termoizolații",
source: "Austrotherm XPS TOP 30",
},
{
id: "xps-50",
name: "Polistiren extrudat XPS 50",
lambda: 0.036,
category: "Termoizolații",
source: "Austrotherm XPS TOP 50",
},
{
id: "vata-min",
name: "Vată minerală bazaltică",
lambda: 0.036,
category: "Termoizolații",
source: "ISOVER PLE / Knauf TP 136",
},
{
id: "vata-stc",
name: "Vată minerală de sticlă",
lambda: 0.035,
category: "Termoizolații",
source: "ISOVER Uniroll Plus",
},
{
id: "pu-spray",
name: "Spumă poliuretanică (PUR)",
lambda: 0.024,
category: "Termoizolații",
source: "Basf Elastopor",
},
{
id: "pir",
name: "Plăci PIR (poliizocianurat)",
lambda: 0.022,
category: "Termoizolații",
source: "Pir/Kingspan",
},
// Zidărie
{
id: "bca-35",
name: "BCA densitate 350 kg/m³",
lambda: 0.09,
category: "Zidărie",
source: "Ytong A+ PP2/0.35",
},
{
id: "bca-40",
name: "BCA densitate 400 kg/m³",
lambda: 0.1,
category: "Zidărie",
source: "Ytong / Celco 400",
},
{
id: "bca-50",
name: "BCA densitate 500 kg/m³",
lambda: 0.13,
category: "Zidărie",
source: "Celco A+ 500",
},
{
id: "bca-60",
name: "BCA densitate 600 kg/m³",
lambda: 0.16,
category: "Zidărie",
source: "Ytong PP4/0.6",
},
{
id: "poro-30",
name: "Porotherm 30 N+F",
lambda: 0.21,
category: "Zidărie",
source: "Wienerberger Porotherm 30",
},
{
id: "poro-38",
name: "Porotherm 38 Thermo",
lambda: 0.16,
category: "Zidărie",
source: "Wienerberger Porotherm 38 Thermo",
},
{
id: "poro-44",
name: "Porotherm 44 Thermo",
lambda: 0.13,
category: "Zidărie",
source: "Wienerberger Porotherm 44 Thermo",
},
{
id: "car-plin",
name: "Cărămidă plină presată",
lambda: 0.74,
category: "Zidărie",
source: "C107 / SR EN ISO 10456",
},
{
id: "car-gol",
name: "Cărămidă cu goluri G25",
lambda: 0.46,
category: "Zidărie",
source: "C107 / SR EN ISO 10456",
},
// Tencuieli / Finisaje
{
id: "tenc-term",
name: "Tencuială termoizolantă",
lambda: 0.08,
category: "Tencuieli",
source: "Baumit ThermoExtra",
},
{
id: "tenc-var",
name: "Tencuială var-ciment",
lambda: 0.87,
category: "Tencuieli",
source: "C107 / SR EN ISO 10456",
},
{
id: "tenc-cem",
name: "Mortar de ciment",
lambda: 1.0,
category: "Tencuieli",
source: "C107 / SR EN ISO 10456",
},
{
id: "gips-cart",
name: "Gips-carton",
lambda: 0.25,
category: "Tencuieli",
source: "Knauf / Rigips",
},
{
id: "tenc-dec",
name: "Tencuială decorativă",
lambda: 0.7,
category: "Tencuieli",
source: "Baumit / Caparol",
},
// Structură
{
id: "beton",
name: "Beton armat",
lambda: 1.74,
category: "Structură",
source: "C107 / SR EN ISO 10456",
},
{
id: "lemn-mol",
name: "Lemn molid/brad",
lambda: 0.14,
category: "Structură",
source: "C107 / SR EN ISO 10456",
},
{
id: "osb",
name: "Plăci OSB",
lambda: 0.13,
category: "Structură",
source: "Egger / Kronospan",
},
{
id: "clt",
name: "Lemn CLT (cross-laminated)",
lambda: 0.12,
category: "Structură",
source: "Stora Enso / Binderholz",
},
// Hidroizolații / Bariere
{
id: "bpv",
name: "Barieră de vapori (PE)",
lambda: 0.4,
category: "Bariere/Hidro",
source: "Bramac / Dörken Delta",
},
{
id: "hidro-bit",
name: "Membrană bituminoasă",
lambda: 0.23,
category: "Bariere/Hidro",
source: "IKO Armourbase / Siplast",
},
{
id: "hidro-pvc",
name: "Membrană PVC/TPO",
lambda: 0.16,
category: "Bariere/Hidro",
source: "Sika Sarnafil / Renolit",
},
{
id: "folie-difuz",
name: "Folie difuzie (sub-acoperiș)",
lambda: 0.4,
category: "Bariere/Hidro",
source: "Bramac Pro / Dörken",
},
// Pardoseli / Învelitori
{
id: "sapa-cem",
name: "Șapă ciment",
lambda: 1.4,
category: "Pardoseli",
source: "C107 / SR EN ISO 10456",
},
{
id: "sapa-anh",
name: "Șapă anhidrit",
lambda: 1.2,
category: "Pardoseli",
source: "Knauf FE 80 Eco",
},
{
id: "gresie",
name: "Gresie/Faianță ceramică",
lambda: 1.3,
category: "Pardoseli",
source: "C107 / SR EN ISO 10456",
},
{
id: "parchet",
name: "Parchet lemn stejar",
lambda: 0.18,
category: "Pardoseli",
source: "C107 / SR EN ISO 10456",
},
{
id: "tigla-cer",
name: "Țiglă ceramică",
lambda: 1.0,
category: "Pardoseli",
source: "Tondach / Bramac",
},
{
id: "tigla-bet",
name: "Țiglă beton",
lambda: 1.5,
category: "Pardoseli",
source: "Bramac",
},
{
id: "tabl-falt",
name: "Tablă fălțuită (oțel)",
lambda: 50.0,
category: "Pardoseli",
source: "Lindab / Bilka",
},
// Umpluturi
{
id: "pietris",
name: "Pietriș/Balast compactat",
lambda: 2.0,
category: "Umpluturi",
source: "C107 / SR EN ISO 10456",
},
{
id: "nisip",
name: "Nisip compactat",
lambda: 2.0,
category: "Umpluturi",
source: "C107 / SR EN ISO 10456",
},
{
id: "argila-exp",
name: "Argilă expandată (Leca)",
lambda: 0.1,
category: "Umpluturi",
source: "Leca / Weber Saint-Gobain",
},
{
id: "aer-inch",
name: "Strat de aer închis (ventilat)",
lambda: 0.025,
category: "Umpluturi",
source: "C107 (Rconvențional)",
},
];
interface LayerEntry {
materialId: string;
thickness: number; // cm
}
interface CompositionTemplate {
name: string;
description: string;
category: string;
layers: LayerEntry[];
}
const COMPOSITION_TEMPLATES: CompositionTemplate[] = [
// Pereți exteriori
{
name: "Perete BCA 30 + EPS 10",
description: "Sistem clasic ETICS cu BCA",
category: "Pereți exteriori",
layers: [
{ materialId: "tenc-var", thickness: 1.5 },
{ materialId: "bca-40", thickness: 30 },
{ materialId: "eps-80", thickness: 10 },
{ materialId: "tenc-dec", thickness: 0.5 },
],
},
{
name: "Perete BCA 30 + vată 10",
description: "ETICS cu vată minerală (A1 foc)",
category: "Pereți exteriori",
layers: [
{ materialId: "tenc-var", thickness: 1.5 },
{ materialId: "bca-40", thickness: 30 },
{ materialId: "vata-min", thickness: 10 },
{ materialId: "tenc-dec", thickness: 0.5 },
],
},
{
name: "Perete Porotherm 30 + EPS 10",
description: "Ceramică + polistiren",
category: "Pereți exteriori",
layers: [
{ materialId: "tenc-var", thickness: 1.5 },
{ materialId: "poro-30", thickness: 30 },
{ materialId: "eps-80", thickness: 10 },
{ materialId: "tenc-dec", thickness: 0.5 },
],
},
{
name: "Perete Porotherm 38 Thermo neizolat",
description: "Bloc ceramic termoizolant, fără ETICS",
category: "Pereți exteriori",
layers: [
{ materialId: "tenc-var", thickness: 1.5 },
{ materialId: "poro-38", thickness: 38 },
{ materialId: "tenc-dec", thickness: 0.5 },
],
},
{
name: "Perete Porotherm 44 Thermo neizolat",
description: "Bloc ceramic termoizolant gros",
category: "Pereți exteriori",
layers: [
{ materialId: "tenc-var", thickness: 1.5 },
{ materialId: "poro-44", thickness: 44 },
{ materialId: "tenc-dec", thickness: 0.5 },
],
},
{
name: "Perete cărămidă + tencuială termoizolantă",
description: "Renovare cu termoizolantă Baumit",
category: "Pereți exteriori",
layers: [
{ materialId: "tenc-var", thickness: 1.5 },
{ materialId: "car-gol", thickness: 25 },
{ materialId: "tenc-term", thickness: 8 },
{ materialId: "tenc-dec", thickness: 0.5 },
],
},
{
name: "Perete lemn CLT + vată",
description: "Structură CLT cu izolație exterioară",
category: "Pereți exteriori",
layers: [
{ materialId: "gips-cart", thickness: 1.25 },
{ materialId: "clt", thickness: 10 },
{ materialId: "vata-min", thickness: 15 },
{ materialId: "folie-difuz", thickness: 0.05 },
{ materialId: "tenc-dec", thickness: 0.5 },
],
},
// Pereți subsol
{
name: "Perete subsol beton + XPS",
description: "Fundație beton cu izolație XPS în pământ",
category: "Pereți subsol",
layers: [
{ materialId: "tenc-var", thickness: 1.5 },
{ materialId: "beton", thickness: 25 },
{ materialId: "hidro-bit", thickness: 0.5 },
{ materialId: "xps-30", thickness: 10 },
],
},
{
name: "Perete subsol beton + XPS 15",
description: "Fundație cu izolație sporită",
category: "Pereți subsol",
layers: [
{ materialId: "tenc-var", thickness: 1.5 },
{ materialId: "beton", thickness: 30 },
{ materialId: "hidro-bit", thickness: 0.5 },
{ materialId: "xps-30", thickness: 15 },
],
},
// Terase / Acoperișuri plate
{
name: "Terasă necirculabilă — clasic",
description: "Placa beton + termoizolație + hidroizolație",
category: "Terase",
layers: [
{ materialId: "tenc-var", thickness: 1.5 },
{ materialId: "beton", thickness: 15 },
{ materialId: "bpv", thickness: 0.05 },
{ materialId: "eps-100", thickness: 15 },
{ materialId: "sapa-cem", thickness: 4 },
{ materialId: "hidro-bit", thickness: 1 },
],
},
{
name: "Terasă necirculabilă — PIR",
description: "Cu PIR pentru grosime minimă",
category: "Terase",
layers: [
{ materialId: "tenc-var", thickness: 1.5 },
{ materialId: "beton", thickness: 15 },
{ materialId: "bpv", thickness: 0.05 },
{ materialId: "pir", thickness: 10 },
{ materialId: "sapa-cem", thickness: 4 },
{ materialId: "hidro-bit", thickness: 1 },
],
},
{
name: "Terasă circulabilă — gresie",
description: "Terasă cu finisaj gresie pe plot",
category: "Terase",
layers: [
{ materialId: "tenc-var", thickness: 1.5 },
{ materialId: "beton", thickness: 15 },
{ materialId: "bpv", thickness: 0.05 },
{ materialId: "eps-100", thickness: 15 },
{ materialId: "sapa-cem", thickness: 5 },
{ materialId: "hidro-bit", thickness: 1 },
{ materialId: "gresie", thickness: 2 },
],
},
{
name: "Acoperiș verde extensiv",
description: "Strat vegetal pe terasă",
category: "Terase",
layers: [
{ materialId: "tenc-var", thickness: 1.5 },
{ materialId: "beton", thickness: 15 },
{ materialId: "bpv", thickness: 0.05 },
{ materialId: "xps-30", thickness: 15 },
{ materialId: "sapa-cem", thickness: 4 },
{ materialId: "hidro-pvc", thickness: 0.2 },
{ materialId: "argila-exp", thickness: 8 },
],
},
// Șarpante
{
name: "Șarpantă lemn + vată între căpriori",
description: "Izolație între și sub căpriori, clasic",
category: "Șarpante",
layers: [
{ materialId: "gips-cart", thickness: 1.25 },
{ materialId: "bpv", thickness: 0.05 },
{ materialId: "vata-min", thickness: 20 },
{ materialId: "osb", thickness: 1.2 },
{ materialId: "folie-difuz", thickness: 0.05 },
{ materialId: "aer-inch", thickness: 4 },
{ materialId: "tigla-cer", thickness: 2 },
],
},
{
name: "Șarpantă lemn + vată 30cm",
description: "Izolație sporită suprapusă",
category: "Șarpante",
layers: [
{ materialId: "gips-cart", thickness: 1.25 },
{ materialId: "bpv", thickness: 0.05 },
{ materialId: "vata-min", thickness: 30 },
{ materialId: "osb", thickness: 1.2 },
{ materialId: "folie-difuz", thickness: 0.05 },
{ materialId: "aer-inch", thickness: 4 },
{ materialId: "tigla-cer", thickness: 2 },
],
},
{
name: "Șarpantă cu tablă fălțuită",
description: "Acoperiș metalic cu izolație vată",
category: "Șarpante",
layers: [
{ materialId: "gips-cart", thickness: 1.25 },
{ materialId: "bpv", thickness: 0.05 },
{ materialId: "vata-min", thickness: 25 },
{ materialId: "osb", thickness: 1.2 },
{ materialId: "folie-difuz", thickness: 0.05 },
{ materialId: "aer-inch", thickness: 4 },
{ materialId: "tabl-falt", thickness: 0.06 },
],
},
// Plăci pe sol
{
name: "Placă pe sol — EPS sub placă",
description: "Izolație sub radier, clasic",
category: "Plăci pe sol",
layers: [
{ materialId: "parchet", thickness: 1.5 },
{ materialId: "sapa-cem", thickness: 6 },
{ materialId: "bpv", thickness: 0.05 },
{ materialId: "eps-100", thickness: 10 },
{ materialId: "hidro-bit", thickness: 0.5 },
{ materialId: "beton", thickness: 15 },
{ materialId: "pietris", thickness: 15 },
],
},
{
name: "Placă pe sol — XPS sub placă",
description: "XPS rezistent la umiditate",
category: "Plăci pe sol",
layers: [
{ materialId: "gresie", thickness: 1 },
{ materialId: "sapa-cem", thickness: 6 },
{ materialId: "bpv", thickness: 0.05 },
{ materialId: "xps-30", thickness: 10 },
{ materialId: "hidro-bit", thickness: 0.5 },
{ materialId: "beton", thickness: 15 },
{ materialId: "pietris", thickness: 15 },
],
},
{
name: "Placă pe sol — izolație sporită 15cm",
description: "Pentru clădiri nZEB",
category: "Plăci pe sol",
layers: [
{ materialId: "parchet", thickness: 1.5 },
{ materialId: "sapa-cem", thickness: 6 },
{ materialId: "bpv", thickness: 0.05 },
{ materialId: "xps-30", thickness: 15 },
{ materialId: "hidro-bit", thickness: 0.5 },
{ materialId: "beton", thickness: 15 },
{ materialId: "pietris", thickness: 15 },
],
},
];
function MaterialThermalComparison() {
const [layersA, setLayersA] = useState<LayerEntry[]>([
{ materialId: "tenc-var", thickness: 1.5 },
{ materialId: "bca-40", thickness: 30 },
{ materialId: "vata-min", thickness: 10 },
{ materialId: "tenc-dec", thickness: 0.5 },
]);
const [layersB, setLayersB] = useState<LayerEntry[]>([
{ materialId: "tenc-var", thickness: 1.5 },
{ materialId: "poro-38", thickness: 38 },
{ materialId: "tenc-dec", thickness: 0.5 },
]);
// Drag-and-drop reorder state
const dragIdxRef = useRef<number | null>(null);
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
const rsi = 0.13;
const rse = 0.04;
const categories = Array.from(
new Set(THERMAL_MATERIALS.map((m) => m.category)),
);
const templateCategories = Array.from(
new Set(COMPOSITION_TEMPLATES.map((t) => t.category)),
);
const calcR = (layers: LayerEntry[]): number => {
let r = 0;
for (const layer of layers) {
const mat = THERMAL_MATERIALS.find((m) => m.id === layer.materialId);
if (mat && layer.thickness > 0) {
r += layer.thickness / 100 / mat.lambda;
}
}
return r;
};
const rA = calcR(layersA);
const rB = calcR(layersB);
const rTotalA = rA + rsi + rse;
const rTotalB = rB + rsi + rse;
const uA = rTotalA > 0 ? 1 / rTotalA : 0;
const uB = rTotalB > 0 ? 1 / rTotalB : 0;
const updateLayer = (
setLayers: React.Dispatch<React.SetStateAction<LayerEntry[]>>,
idx: number,
field: "materialId" | "thickness",
value: string,
) => {
setLayers((prev) => {
const next = [...prev];
const entry = next[idx];
if (!entry) return prev;
if (field === "materialId") {
next[idx] = { ...entry, materialId: value };
} else {
next[idx] = { ...entry, thickness: parseFloat(value) || 0 };
}
return next;
});
};
const addLayer = (
setLayers: React.Dispatch<React.SetStateAction<LayerEntry[]>>,
) => {
setLayers((prev) => [...prev, { materialId: "eps-80", thickness: 10 }]);
};
const removeLayer = (
setLayers: React.Dispatch<React.SetStateAction<LayerEntry[]>>,
idx: number,
) => {
setLayers((prev) => prev.filter((_, i) => i !== idx));
};
const moveLayer = (
setLayers: React.Dispatch<React.SetStateAction<LayerEntry[]>>,
idx: number,
direction: "up" | "down",
) => {
setLayers((prev) => {
const next = [...prev];
const targetIdx = direction === "up" ? idx - 1 : idx + 1;
if (targetIdx < 0 || targetIdx >= next.length) return prev;
const a = next[idx];
const b = next[targetIdx];
if (!a || !b) return prev;
next[idx] = b;
next[targetIdx] = a;
return next;
});
};
const applyTemplate = (
setLayers: React.Dispatch<React.SetStateAction<LayerEntry[]>>,
template: CompositionTemplate,
) => {
setLayers(template.layers.map((l) => ({ ...l })));
};
const renderComposition = (
label: string,
layers: LayerEntry[],
setLayers: React.Dispatch<React.SetStateAction<LayerEntry[]>>,
r: number,
rTotal: number,
u: number,
) => (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold">{label}</h4>
<select
onChange={(e) => {
const tpl = COMPOSITION_TEMPLATES.find(
(t) => t.name === e.target.value,
);
if (tpl) applyTemplate(setLayers, tpl);
e.target.value = "";
}}
value=""
className="rounded-md border border-border bg-background px-2 py-1 text-xs max-w-[200px]"
>
<option value="" disabled>
Șablon...
</option>
{templateCategories.map((cat) => (
<optgroup key={cat} label={cat}>
{COMPOSITION_TEMPLATES.filter((t) => t.category === cat).map(
(t) => (
<option key={t.name} value={t.name}>
{t.name}
</option>
),
)}
</optgroup>
))}
</select>
</div>
{/* Interior label */}
<div className="flex items-center gap-2 text-[10px] uppercase tracking-widest text-blue-500/60 font-medium">
<div className="h-px flex-1 bg-blue-500/20" />
Interior
<div className="h-px flex-1 bg-blue-500/20" />
</div>
{layers.map((layer, idx) => {
const mat = THERMAL_MATERIALS.find((m) => m.id === layer.materialId);
return (
<div
key={idx}
draggable
onDragStart={(e) => {
dragIdxRef.current = idx;
e.dataTransfer.effectAllowed = "move";
}}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDragOverIdx(idx);
}}
onDragLeave={() => setDragOverIdx(null)}
onDrop={(e) => {
e.preventDefault();
const from = dragIdxRef.current;
if (from !== null && from !== idx) {
setLayers((prev) => {
const next = [...prev];
const item = next[from];
if (!item) return prev;
next.splice(from, 1);
next.splice(idx, 0, item);
return next;
});
}
dragIdxRef.current = null;
setDragOverIdx(null);
}}
onDragEnd={() => {
dragIdxRef.current = null;
setDragOverIdx(null);
}}
className={`flex gap-1.5 items-end cursor-grab active:cursor-grabbing transition-all ${
dragOverIdx === idx
? "border-t-2 border-primary"
: "border-t-2 border-transparent"
}`}
>
{/* Move up/down */}
<div className="flex flex-col gap-0.5 shrink-0">
{idx === 0 && (
<Label className="text-xs text-transparent"></Label>
)}
<div className="flex flex-col">
<button
type="button"
onClick={() => moveLayer(setLayers, idx, "up")}
disabled={idx === 0}
className="h-4 w-5 flex items-center justify-center text-muted-foreground hover:text-foreground disabled:opacity-20 disabled:cursor-default"
>
<ArrowUp className="h-3 w-3" />
</button>
<button
type="button"
onClick={() => moveLayer(setLayers, idx, "down")}
disabled={idx === layers.length - 1}
className="h-4 w-5 flex items-center justify-center text-muted-foreground hover:text-foreground disabled:opacity-20 disabled:cursor-default"
>
<ArrowDown className="h-3 w-3" />
</button>
</div>
</div>
<div className="flex-1 min-w-0">
{idx === 0 && (
<Label className="text-xs text-muted-foreground">
Material
</Label>
)}
<select
value={layer.materialId}
onChange={(e) =>
updateLayer(setLayers, idx, "materialId", e.target.value)
}
className="mt-0.5 w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm"
>
{categories.map((cat) => (
<optgroup key={cat} label={cat}>
{THERMAL_MATERIALS.filter((m) => m.category === cat).map(
(m) => (
<option key={m.id} value={m.id}>
{m.name} (λ={m.lambda})
</option>
),
)}
</optgroup>
))}
</select>
</div>
<div className="w-16 shrink-0">
{idx === 0 && (
<Label className="text-xs text-muted-foreground">cm</Label>
)}
<Input
type="number"
min="0"
step="0.5"
value={layer.thickness || ""}
onChange={(e) =>
updateLayer(setLayers, idx, "thickness", e.target.value)
}
className="mt-0.5"
/>
</div>
<div className="w-20 shrink-0 text-right">
{idx === 0 && (
<Label className="text-xs text-muted-foreground">R</Label>
)}
<div className="mt-0.5 rounded-md border bg-muted/30 px-1.5 py-1.5 text-xs text-right tabular-nums">
{mat && layer.thickness > 0
? (layer.thickness / 100 / mat.lambda).toFixed(3)
: "—"}
</div>
</div>
{layers.length > 1 && (
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive shrink-0"
onClick={() => removeLayer(setLayers, idx)}
>
×
</Button>
)}
</div>
);
})}
{/* Exterior label */}
<div className="flex items-center gap-2 text-[10px] uppercase tracking-widest text-orange-500/60 font-medium">
<div className="h-px flex-1 bg-orange-500/20" />
Exterior
<div className="h-px flex-1 bg-orange-500/20" />
</div>
<Button variant="outline" size="sm" onClick={() => addLayer(setLayers)}>
+ Strat
</Button>
<div className="rounded-md border bg-muted/30 p-3 text-sm space-y-1.5">
<div className="flex justify-between">
<span className="text-muted-foreground">R straturi</span>
<code className="rounded border bg-muted px-2 py-0.5 tabular-nums">
{r.toFixed(3)} m²K/W
</code>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">
+ Rsi ({rsi}) + Rse ({rse})
</span>
<code className="rounded border bg-muted px-2 py-0.5 tabular-nums">
{rTotal.toFixed(3)} m²K/W
</code>
</div>
<div className="flex justify-between font-medium border-t pt-1.5">
<span>U = 1/R total</span>
<code className="rounded border bg-muted px-2 py-0.5 tabular-nums">
{u.toFixed(3)} W/m²K
</code>
</div>
</div>
</div>
);
const diff = uA > 0 && uB > 0 ? ((uA - uB) / uA) * 100 : 0;
const betterSide = uA < uB ? "A" : uA > uB ? "B" : null;
return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Compară două alcătuiri prima de sus în jos (interior exterior).
Trage straturile cu mouse-ul pentru reordonare sau folosește săgețile
. Dropdown-ul Șablon" încarcă sisteme prestabilite.
</p>
<div className="grid gap-6 lg:grid-cols-2">
{renderComposition("Alcătuire A", layersA, setLayersA, rA, rTotalA, uA)}
{renderComposition("Alcătuire B", layersB, setLayersB, rB, rTotalB, uB)}
</div>
{betterSide && (
<div className="rounded-lg border-2 border-primary/30 bg-primary/5 p-4 text-sm">
<span className="font-semibold">Verdict:</span> Alcătuirea{" "}
{betterSide} izolează mai bine cu{" "}
<span className="font-bold">{Math.abs(diff).toFixed(1)}%</span> (U
{betterSide === "A" ? "A" : "B"} ={" "}
{(betterSide === "A" ? uA : uB).toFixed(3)} vs U
{betterSide === "A" ? "B" : "A"} ={" "}
{(betterSide === "A" ? uB : uA).toFixed(3)} W/m²K).
{(betterSide === "A" ? uA : uB) <= 0.28 && (
<span className="block mt-1 text-green-600 dark:text-green-400">
Îndeplinește cerința C107 pentru perete exterior (U ≤ 0.28 W/m²K).
</span>
)}
</div>
)}
</div>
);
}
// ─── AI Artifact Cleaner ──────────────────────────────────────────────────────
function AiArtifactCleaner() {
const [input, setInput] = useState("");
const clean = (text: string): string => {
let r = text;
// Strip markdown
r = r.replace(/^#{1,6}\s+/gm, "");
r = r.replace(/\*\*(.+?)\*\*/g, "$1");
r = r.replace(/\*(.+?)\*/g, "$1");
r = r.replace(/_{2}(.+?)_{2}/g, "$1");
r = r.replace(/_(.+?)_/g, "$1");
r = r.replace(/```[\s\S]*?```/g, "");
r = r.replace(/`(.+?)`/g, "$1");
r = r.replace(/^[*\-+]\s+/gm, "");
r = r.replace(/^\d+\.\s+/gm, "");
r = r.replace(/^[-_*]{3,}$/gm, "");
r = r.replace(/\[(.+?)\]\(.*?\)/g, "$1");
r = r.replace(/^>\s+/gm, "");
// Fix encoding artifacts (UTF-8 mojibake)
r = r.replace(/â/g, "â");
r = r.replace(/î/g, "î");
r = r.replace(/Ã /g, "à");
r = r.replace(/Å£/g, "ț");
r = r.replace(/È™/g, "ș");
r = r.replace(/È›/g, "ț");
r = r.replace(/Èš/g, "Ț");
r = r.replace(/\u015f/g, "ș");
r = r.replace(/\u0163/g, "ț");
// Remove zero-width and invisible chars
r = r.replace(/[\u200b\u200c\u200d\ufeff]/g, "");
// Remove emoji
r = r.replace(/\p{Extended_Pictographic}/gu, "");
r = r.replace(/[\u{1F1E0}-\u{1F1FF}]/gu, ""); // flag emoji
r = r.replace(/[\u{FE00}-\u{FE0F}\u{20D0}-\u{20FF}]/gu, ""); // variation selectors
// Normalize typography
r = r.replace(/[""]/g, '"');
r = r.replace(/['']/g, "'");
r = r.replace(/[–—]/g, "-");
r = r.replace(/…/g, "...");
// Normalize spacing
r = r.replace(/ {2,}/g, " ");
r = r.replace(/\n{3,}/g, "\n\n");
return r.trim();
};
const cleaned = input ? clean(input) : "";
return (
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<div>
<Label>Text original (output AI)</Label>
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
className="mt-1 h-72 font-mono text-xs"
placeholder="Lipește textul generat de AI..."
/>
</div>
<div>
<div className="flex items-center justify-between">
<Label>Text curățat</Label>
{cleaned && <CopyButton text={cleaned} />}
</div>
<Textarea
value={cleaned}
readOnly
className="mt-1 h-72 font-mono text-xs bg-muted/30"
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
Operații: eliminare markdown (###, **, `, liste, citate), emoji,
corectare encoding românesc (mojibake), curățare Unicode invizibil,
normalizare ghilimele / cratime / spații multiple.
</p>
</div>
);
}
// ─── MDLPA Date Locale ────────────────────────────────────────────────────────
function MdlpaValidator() {
return (
<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="flex flex-col gap-1 rounded-lg border p-4 transition-colors hover:bg-muted/50"
>
<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
href="https://datelocale.mdlpa.ro/ro/about/tutorials"
target="_blank"
rel="noopener noreferrer"
className="flex flex-col gap-1 rounded-lg border p-4 transition-colors hover:bg-muted/50"
>
<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
href="https://datelocale.mdlpa.ro/ro/about/info/reguli"
target="_blank"
rel="noopener noreferrer"
className="flex flex-col gap-1 rounded-lg border p-4 transition-colors hover:bg-muted/50"
>
<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>
);
}
// ─── PDF Reducer (Stirling PDF) ───────────────────────────────────────────────
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
function PdfReducer() {
const [file, setFile] = useState<File | null>(null);
const [mode, setMode] = useState<"extreme" | "max" | "balanced">("extreme");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [result, setResult] = useState<{
originalSize: number;
compressedSize: number;
} | null>(null);
const [dragging, setDragging] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const setFileFromInput = (f: File | null) => {
setFile(f);
setError("");
setResult(null);
};
const handleCompress = async () => {
if (!file) return;
setLoading(true);
setError("");
setResult(null);
try {
const formData = new FormData();
formData.append("fileInput", file);
// All modes use the GS endpoint with a level parameter
formData.append("level", mode === "extreme" ? "extreme" : mode === "max" ? "high" : "balanced");
const res = await fetch("/api/compress-pdf/extreme", {
method: "POST",
body: formData,
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? `Eroare server: ${res.status}`);
}
const blob = await res.blob();
const origHeader = res.headers.get("X-Original-Size");
const compHeader = res.headers.get("X-Compressed-Size");
setResult({
originalSize: origHeader ? Number(origHeader) : file.size,
compressedSize: compHeader ? Number(compHeader) : blob.size,
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = file.name.replace(/\.pdf$/i, "-comprimat.pdf");
a.click();
URL.revokeObjectURL(url);
} catch (err) {
setError(
err instanceof Error
? err.message
: "Nu s-a putut contacta Stirling PDF.",
);
} finally {
setLoading(false);
}
};
return (
<div className="space-y-4">
<div className="space-y-1.5">
<Label>Fișier PDF</Label>
<input
ref={fileRef}
type="file"
accept=".pdf"
className="hidden"
onChange={(e) => setFileFromInput(e.target.files?.[0] ?? null)}
/>
<div
tabIndex={0}
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 outline-none focus-visible:ring-2 focus-visible:ring-ring ${
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);
}}
onPaste={(e) => {
const f = e.clipboardData.files[0];
if (f?.type === "application/pdf") setFileFromInput(f);
}}
>
{file ? (
<span className="font-medium text-foreground">
{file.name}{" "}
<span className="text-muted-foreground">
({formatBytes(file.size)})
</span>
</span>
) : (
<>
<Upload className="h-5 w-5" />
<span>Trage un PDF aici sau apasă pentru a selecta</span>
</>
)}
</div>
</div>
<div className="space-y-1.5">
<Label>Nivel compresie</Label>
<select
value={mode}
onChange={(e) =>
setMode(e.target.value as "extreme" | "max" | "balanced")
}
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm"
>
<option value="extreme">
Extremă imagini 100 DPI, calitate scăzută
</option>
<option value="max">Puternică imagini 150 DPI, calitate medie (recomandat)</option>
<option value="balanced">Echilibrată imagini 200 DPI, calitate bună</option>
</select>
<p className="text-xs text-muted-foreground">
{mode === "extreme"
? "Reduce maxim dimensiunea. Imaginile pot pierde detalii fine."
: mode === "max"
? "Balanță bună între dimensiune și calitate. Recomandat pentru majoritatea fișierelor."
: "Pierdere minimă de calitate. Ideal pentru documente cu grafice detaliate."}
</p>
</div>
<Button onClick={handleCompress} disabled={!file || loading}>
{loading ? "Se comprimă..." : "Comprimă PDF"}
</Button>
{result && (
<div className="rounded-lg border bg-muted/30 p-3">
<p className="text-sm font-medium">
{formatBytes(result.originalSize)} {" "}
{formatBytes(result.compressedSize)}
<span className="ml-2 font-bold text-green-600 dark:text-green-400">
{(
((result.originalSize - result.compressedSize) /
result.originalSize) *
100
).toFixed(0)}
% mai mic
</span>
</p>
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
);
}
// ─── PDF Unlock (Stirling PDF) ────────────────────────────────────────────────
function PdfUnlock() {
const [file, setFile] = useState<File | null>(null);
const [password, setPassword] = useState("");
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 handleUnlock = async () => {
if (!file) return;
setLoading(true);
setError("");
try {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("password", password);
const res = await fetch("/api/compress-pdf/unlock", {
method: "POST",
body: formData,
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? `Eroare server: ${res.status}`);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = file.name.replace(/\.pdf$/i, "-deblocat.pdf");
a.click();
URL.revokeObjectURL(url);
} catch (err) {
setError(
err instanceof Error ? err.message : "Nu s-a putut debloca PDF-ul.",
);
} finally {
setLoading(false);
}
};
return (
<div className="space-y-4">
<div className="space-y-1.5">
<Label>Fișier PDF protejat</Label>
<input
ref={fileRef}
type="file"
accept=".pdf"
className="hidden"
onChange={(e) => setFileFromInput(e.target.files?.[0] ?? null)}
/>
<div
tabIndex={0}
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 outline-none focus-visible:ring-2 focus-visible:ring-ring ${
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);
}}
onPaste={(e) => {
const f = e.clipboardData.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 className="space-y-1.5">
<Label>Parolă (dacă e criptat cu parolă)</Label>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Lasă gol dacă nu are parolă"
className="mt-1"
/>
</div>
<Button onClick={handleUnlock} disabled={!file || loading}>
{loading ? "Se deblochează..." : "Deblochează PDF"}
</Button>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
);
}
// ─── DWG → DXF Converter ─────────────────────────────────────────────────────
function DwgToDxf() {
const [file, setFile] = useState<File | null>(null);
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 handleConvert = async () => {
if (!file) return;
setLoading(true);
setError("");
try {
const formData = new FormData();
formData.append("fileInput", file);
const res = await fetch("/api/dwg-convert", {
method: "POST",
body: formData,
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? `Eroare server: ${res.status}`);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = file.name.replace(/\.dwg$/i, ".dxf");
a.click();
URL.revokeObjectURL(url);
} catch (err) {
setError(
err instanceof Error ? err.message : "Nu s-a putut converti fișierul.",
);
} finally {
setLoading(false);
}
};
return (
<div className="space-y-4">
<div className="space-y-1.5">
<Label>Fișier DWG</Label>
<input
ref={fileRef}
type="file"
accept=".dwg"
className="hidden"
onChange={(e) => setFileFromInput(e.target.files?.[0] ?? null)}
/>
<div
tabIndex={0}
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 outline-none focus-visible:ring-2 focus-visible:ring-ring ${
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?.name.toLowerCase().endsWith(".dwg")) setFileFromInput(f);
}}
onPaste={(e) => {
const f = e.clipboardData.files[0];
if (f?.name.toLowerCase().endsWith(".dwg")) setFileFromInput(f);
}}
>
{file ? (
<span className="font-medium text-foreground">{file.name}</span>
) : (
<>
<Upload className="h-5 w-5" />
<span>Trage un fișier .dwg aici sau apasă pentru a selecta</span>
</>
)}
</div>
</div>
<p className="text-xs text-muted-foreground">
Convertește AutoCAD DWG în format DXF deschis. Suportă versiuni până la
DWG 2018. Conversie server-side via libredwg.
</p>
<Button onClick={handleConvert} disabled={!file || loading}>
{loading ? "Se convertește..." : "Convertește în DXF"}
</Button>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
);
}
// ─── Quick OCR ────────────────────────────────────────────────────────────────
function QuickOcr() {
const [imageSrc, setImageSrc] = useState<string | null>(null);
const [text, setText] = useState("");
const [progress, setProgress] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [lang, setLang] = useState("ron+eng");
const fileRef = useRef<HTMLInputElement>(null);
const runOcr = async (src: string) => {
if (loading) return;
setLoading(true);
setError("");
setText("");
setProgress(0);
try {
const { createWorker } = await import("tesseract.js");
const worker = await createWorker(lang.split("+"), 1, {
logger: (m: { status: string; progress: number }) => {
if (m.status === "recognizing text")
setProgress(Math.round(m.progress * 100));
},
});
const { data } = await worker.recognize(src);
setText(data.text.trim());
await worker.terminate();
} catch (e) {
setError(e instanceof Error ? e.message : "Eroare OCR necunoscută");
} finally {
setLoading(false);
}
};
const handleFile = (file: File) => {
const reader = new FileReader();
reader.onload = (e) => {
const src = e.target?.result as string;
setImageSrc(src);
runOcr(src);
};
reader.readAsDataURL(file);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
const file = Array.from(e.dataTransfer.files).find((f) =>
f.type.startsWith("image/"),
);
if (file) handleFile(file);
};
const handlePaste = (e: React.ClipboardEvent) => {
const item = Array.from(e.clipboardData.items).find((i) =>
i.type.startsWith("image/"),
);
const file = item?.getAsFile();
if (file) handleFile(file);
};
return (
<div className="space-y-3" onPaste={handlePaste}>
<div className="flex flex-wrap items-center gap-3">
<select
value={lang}
onChange={(e) => setLang(e.target.value)}
className="rounded-md border bg-background px-3 py-1.5 text-sm"
>
<option value="ron+eng">Română + Engleză</option>
<option value="ron">Română</option>
<option value="eng">Engleză</option>
</select>
<span className="text-xs text-muted-foreground">
sau Ctrl+V pentru a lipi imaginea
</span>
</div>
<div
className="flex min-h-[120px] cursor-pointer items-center justify-center rounded-md border-2 border-dashed p-4 text-sm text-muted-foreground transition-colors hover:border-primary/50"
onClick={() => fileRef.current?.click()}
onDrop={handleDrop}
onDragOver={(e) => e.preventDefault()}
>
{imageSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={imageSrc}
alt="preview"
className="max-h-48 max-w-full rounded object-contain"
/>
) : (
<span>Trage o imagine aici, apasă pentru a selecta, sau Ctrl+V</span>
)}
</div>
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleFile(f);
}}
/>
{loading && (
<div className="space-y-1">
<div className="flex justify-between text-xs text-muted-foreground">
<span>Se procesează... (primul rulaj descarcă modelul ~10 MB)</span>
<span>{progress}%</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full bg-primary transition-all"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
{text && (
<div>
<div className="flex items-center justify-between">
<Label>Text extras</Label>
<CopyButton text={text} />
</div>
<Textarea
value={text}
readOnly
className="mt-1 h-56 font-mono text-xs bg-muted/30"
/>
</div>
)}
</div>
);
}
// ─── 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
tabIndex={0}
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 outline-none hover:border-primary/50 focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => fileRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const f = e.dataTransfer.files[0];
if (f) handleFile(f);
}}
onPaste={(e) => {
const f = e.clipboardData.files[0];
if (f) handleFile(f);
// Also support pasting image data from clipboard
const items = e.clipboardData.items;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item?.type.startsWith("image/")) {
const blob = item.getAsFile();
if (blob) handleFile(blob);
break;
}
}
}}
>
{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, apasă, sau lipește (Ctrl+V)</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>
);
}
// ─── Calculator Scară ─────────────────────────────────────────────────────────
const SCALE_PRESETS = [
{ label: "1:20", value: 20 },
{ label: "1:50", value: 50 },
{ label: "1:100", value: 100 },
{ label: "1:200", value: 200 },
{ label: "1:500", value: 500 },
{ label: "1:1000", value: 1000 },
{ label: "1:5000", value: 5000 },
];
// Real unit → multiplier to convert to mm
const REAL_UNITS = [
{ id: "mm", label: "mm", toMm: 1 },
{ id: "cm", label: "cm", toMm: 10 },
{ id: "m", label: "m", toMm: 1_000 },
{ id: "km", label: "km", toMm: 1_000_000 },
] as const;
type RealUnit = (typeof REAL_UNITS)[number]["id"];
/**
* Scale calculator.
*
* Core identity (all in mm):
* drawing_mm = real_mm / scale
* real_mm = drawing_mm × scale
*
* Real input in any unit → convert to mm → apply scale → drawing in mm.
* Drawing input in mm → apply scale → real mm → convert to chosen unit.
*/
function ScaleCalculator() {
const [scale, setScale] = useState(100);
const [customScale, setCustomScale] = useState("");
const [mode, setMode] = useState<"real-to-drawing" | "drawing-to-real">("real-to-drawing");
const [realUnit, setRealUnit] = useState<RealUnit>("cm");
const [inputVal, setInputVal] = useState("");
const effectiveScale = customScale !== "" ? parseFloat(customScale) || scale : scale;
const val = parseFloat(inputVal);
const unitDef = REAL_UNITS.find((u) => u.id === realUnit)!;
// drawing_mm = real_in_unit × unitDef.toMm / scale
// real_in_unit = drawing_mm × scale / unitDef.toMm
const drawing_mm = !isNaN(val) && mode === "real-to-drawing"
? (val * unitDef.toMm) / effectiveScale
: NaN;
const real_in_unit = !isNaN(val) && mode === "drawing-to-real"
? (val * effectiveScale) / unitDef.toMm
: NaN;
// For secondary display: real_mm (whichever mode)
const real_mm = mode === "real-to-drawing"
? val * unitDef.toMm
: val * effectiveScale;
const fmt = (n: number, decimals = 2) =>
isNaN(n) || !isFinite(n)
? "—"
: n.toLocaleString("ro-RO", { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
// Build secondary real conversions (exclude current unit)
const secondaryReal = (mm: number) =>
REAL_UNITS.filter((u) => u.id !== realUnit)
.map((u) => `${fmt(mm / u.toMm)} ${u.id}`)
.join(" · ");
return (
<div className="space-y-4">
{/* Scale picker */}
<div>
<Label className="text-xs text-muted-foreground">Scară</Label>
<div className="mt-1 flex flex-wrap items-center gap-2">
{SCALE_PRESETS.map((p) => (
<Button
key={p.value}
type="button"
variant={scale === p.value && customScale === "" ? "default" : "outline"}
size="sm"
onClick={() => { setScale(p.value); setCustomScale(""); }}
>
{p.label}
</Button>
))}
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">1:</span>
<Input
type="number"
min={1}
value={customScale}
onChange={(e) => setCustomScale(e.target.value)}
placeholder="Altă scară"
className="w-28 text-sm"
/>
</div>
</div>
</div>
{/* Mode + real unit picker */}
<div className="flex flex-wrap gap-3 items-center">
<div className="flex gap-2">
<Button
variant={mode === "real-to-drawing" ? "default" : "outline"}
size="sm"
onClick={() => { setMode("real-to-drawing"); setInputVal(""); }}
>
Real Desen
</Button>
<Button
variant={mode === "drawing-to-real" ? "default" : "outline"}
size="sm"
onClick={() => { setMode("drawing-to-real"); setInputVal(""); }}
>
Desen Real
</Button>
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">Unitate reală:</span>
{REAL_UNITS.map((u) => (
<Button
key={u.id}
type="button"
variant={realUnit === u.id ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2 text-xs"
onClick={() => { setRealUnit(u.id); setInputVal(""); }}
>
{u.label}
</Button>
))}
</div>
</div>
{/* Input */}
<div>
<Label>
{mode === "real-to-drawing"
? `Dimensiune reală (${realUnit})`
: "Dimensiune desen (mm)"}
</Label>
<div className="mt-1 flex gap-2 items-center">
<Input
type="number"
min={0}
step={realUnit === "km" ? 0.001 : realUnit === "m" ? 0.01 : 0.5}
value={inputVal}
onChange={(e) => setInputVal(e.target.value)}
placeholder={
mode === "real-to-drawing"
? realUnit === "m" ? "ex: 3.5" : realUnit === "km" ? "ex: 0.350" : "ex: 350"
: "ex: 35"
}
className="flex-1"
/>
<span className="text-sm text-muted-foreground shrink-0 w-8">
{mode === "real-to-drawing" ? realUnit : "mm"}
</span>
</div>
</div>
{/* Result */}
{!isNaN(val) && val > 0 && (
<div className="rounded-md border bg-muted/30 p-4 space-y-2 text-sm">
<p className="text-xs text-muted-foreground font-medium">Scară 1:{effectiveScale}</p>
{mode === "real-to-drawing" ? (
<>
<p>
Real:{" "}
<strong>{fmt(val)} {realUnit}</strong>
{realUnit !== "mm" && (
<span className="text-xs text-muted-foreground ml-2">
({secondaryReal(real_mm)})
</span>
)}
</p>
<p className="text-base border-t pt-2">
Pe desen:{" "}
<strong className="text-primary">{fmt(drawing_mm)} mm</strong>
<CopyButton text={fmt(drawing_mm)} />
</p>
{!isNaN(drawing_mm) && (
<p className="text-xs text-muted-foreground">
= {fmt(drawing_mm / 10)} cm pe desen
</p>
)}
</>
) : (
<>
<p>
Pe desen: <strong>{fmt(val)} mm</strong>
</p>
<p className="text-base border-t pt-2">
Real:{" "}
<strong className="text-primary">{fmt(real_in_unit)} {realUnit}</strong>
<CopyButton text={fmt(real_in_unit)} />
</p>
{!isNaN(real_mm) && (
<p className="text-xs text-muted-foreground">
{secondaryReal(real_mm)}
</p>
)}
</>
)}
</div>
)}
</div>
);
}
// ─── Main Module ──────────────────────────────────────────────────────────────
export function MiniUtilitiesModule() {
return (
<Tabs defaultValue="text-case" className="space-y-4">
<div className="space-y-2">
<TabsList className="flex h-auto flex-wrap gap-1 rounded-lg bg-muted/50 p-1">
{/* ── Text & Calcule ── */}
<TabsTrigger value="text-case">
<Type className="mr-1.5 h-3.5 w-3.5" /> Text
</TabsTrigger>
<TabsTrigger value="char-count">
<Hash className="mr-1.5 h-3.5 w-3.5" /> Caractere
</TabsTrigger>
<TabsTrigger value="num-to-text">
<CaseUpper className="mr-1.5 h-3.5 w-3.5" /> NrText
</TabsTrigger>
<TabsTrigger value="percentage">
<Percent className="mr-1.5 h-3.5 w-3.5" /> Procente
</TabsTrigger>
<TabsTrigger value="tva">
<Receipt className="mr-1.5 h-3.5 w-3.5" /> TVA
</TabsTrigger>
<TabsTrigger value="area">
<Ruler className="mr-1.5 h-3.5 w-3.5" /> Suprafețe
</TabsTrigger>
<TabsTrigger value="u-value">
<Zap className="mr-1.5 h-3.5 w-3.5" /> UR
</TabsTrigger>
<TabsTrigger value="thermal-compare">
<Layers className="mr-1.5 h-3.5 w-3.5" /> Termică
</TabsTrigger>
<TabsTrigger value="mdlpa">
<Building2 className="mr-1.5 h-3.5 w-3.5" /> MDLPA
</TabsTrigger>
<TabsTrigger value="scale">
<Maximize2 className="mr-1.5 h-3.5 w-3.5" /> Scară
</TabsTrigger>
</TabsList>
<TabsList className="flex h-auto flex-wrap gap-1 rounded-lg bg-muted/50 p-1">
{/* ── Documente & Unelte ── */}
<TabsTrigger value="pdf-reducer">
<FileDown className="mr-1.5 h-3.5 w-3.5" /> Compresie PDF
</TabsTrigger>
<TabsTrigger value="pdf-unlock">
<Unlock className="mr-1.5 h-3.5 w-3.5" /> Deblocare PDF
</TabsTrigger>
<TabsTrigger value="dwg-dxf">
<PenTool className="mr-1.5 h-3.5 w-3.5" /> DWGDXF
</TabsTrigger>
<TabsTrigger value="ocr">
<ScanText className="mr-1.5 h-3.5 w-3.5" /> OCR
</TabsTrigger>
<TabsTrigger value="ai-cleaner">
<Wand2 className="mr-1.5 h-3.5 w-3.5" /> Curățare AI
</TabsTrigger>
<TabsTrigger value="color-palette">
<Palette className="mr-1.5 h-3.5 w-3.5" /> Culori
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="text-case">
<Card>
<CardHeader>
<CardTitle className="text-base">Transformare text</CardTitle>
</CardHeader>
<CardContent>
<TextCaseConverter />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="char-count">
<Card>
<CardHeader>
<CardTitle className="text-base">Numărare caractere</CardTitle>
</CardHeader>
<CardContent>
<CharacterCounter />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="percentage">
<Card>
<CardHeader>
<CardTitle className="text-base">Calculator procente</CardTitle>
</CardHeader>
<CardContent>
<PercentageCalculator />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="tva">
<Card>
<CardHeader>
<CardTitle className="text-base">Calculator TVA</CardTitle>
</CardHeader>
<CardContent>
<TvaCalculator />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="area">
<Card>
<CardHeader>
<CardTitle className="text-base">Convertor suprafețe</CardTitle>
</CardHeader>
<CardContent>
<AreaConverter />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="u-value">
<Card>
<CardHeader>
<CardTitle className="text-base">
Convertor U R (termoizolație)
</CardTitle>
</CardHeader>
<CardContent>
<UValueConverter />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="thermal-compare">
<Card>
<CardHeader>
<CardTitle className="text-base">
Comparație termică materiale
</CardTitle>
</CardHeader>
<CardContent>
<MaterialThermalComparison />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="ai-cleaner">
<Card>
<CardHeader>
<CardTitle className="text-base">Curățare text AI</CardTitle>
</CardHeader>
<CardContent>
<AiArtifactCleaner />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="mdlpa">
<Card>
<CardHeader>
<CardTitle className="text-base">
MDLPA Date locale construcții
</CardTitle>
</CardHeader>
<CardContent>
<MdlpaValidator />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="pdf-reducer">
<Card>
<CardHeader>
<CardTitle className="text-base">Reducere dimensiune PDF</CardTitle>
</CardHeader>
<CardContent>
<PdfReducer />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="pdf-unlock">
<Card>
<CardHeader>
<CardTitle className="text-base">Deblocare PDF</CardTitle>
</CardHeader>
<CardContent>
<PdfUnlock />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="dwg-dxf">
<Card>
<CardHeader>
<CardTitle className="text-base">Convertor DWG DXF</CardTitle>
</CardHeader>
<CardContent>
<DwgToDxf />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="ocr">
<Card>
<CardHeader>
<CardTitle className="text-base">
OCR extragere text din imagini
</CardTitle>
</CardHeader>
<CardContent>
<QuickOcr />
</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>
<TabsContent value="scale">
<Card>
<CardHeader>
<CardTitle className="text-base">Calculator scară desen</CardTitle>
</CardHeader>
<CardContent>
<ScaleCalculator />
</CardContent>
</Card>
</TabsContent>
</Tabs>
);
}