Files
ArchiTools/src/modules/mini-utilities/components/mini-utilities-module.tsx

852 lines
27 KiB
TypeScript

"use client";
import { useState, useRef } from "react";
import {
Copy,
Check,
Hash,
Type,
Percent,
Ruler,
Zap,
Wand2,
Building2,
FileDown,
ScanText,
} 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>
);
}
function AreaConverter() {
const [mp, setMp] = useState("");
const v = parseFloat(mp);
const conversions = !isNaN(v)
? [
{ label: "mp (m²)", value: v.toFixed(2) },
{ label: "ari (100 m²)", value: (v / 100).toFixed(4) },
{ label: "hectare (10.000 m²)", value: (v / 10000).toFixed(6) },
{ label: "km²", value: (v / 1000000).toFixed(8) },
{ label: "sq ft", value: (v * 10.7639).toFixed(2) },
]
: [];
return (
<div className="space-y-3">
<div>
<Label>Suprafață (m²)</Label>
<Input
type="number"
value={mp}
onChange={(e) => setMp(e.target.value)}
className="mt-1"
placeholder="Introdu suprafața..."
/>
</div>
{conversions.length > 0 && (
<div className="space-y-1.5">
{conversions.map(({ label, value: val }) => (
<div key={label} className="flex items-center gap-2">
<code className="flex-1 rounded border bg-muted/30 px-2 py-1 text-xs">
{val}
</code>
<span className="w-36 text-xs text-muted-foreground">
{label}
</span>
<CopyButton text={val} />
</div>
))}
</div>
)}
</div>
);
}
// ─── U-value → R-value Converter ─────────────────────────────────────────────
function UValueConverter() {
const [uValue, setUValue] = useState("");
const [thickness, setThickness] = useState("");
const u = parseFloat(uValue);
const t = parseFloat(thickness);
const rValue = !isNaN(u) && u > 0 ? (1 / u).toFixed(4) : null;
const rsi = 0.13;
const rse = 0.04;
const rTotal =
rValue !== null ? (parseFloat(rValue) + rsi + rse).toFixed(4) : null;
const lambda =
rValue !== null && !isNaN(t) && t > 0
? (t / 100 / parseFloat(rValue)).toFixed(4)
: null;
return (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<div>
<Label>Coeficient U (W/m²K)</Label>
<Input
type="number"
step="0.01"
min="0"
value={uValue}
onChange={(e) => setUValue(e.target.value)}
className="mt-1"
placeholder="ex: 0.35"
/>
</div>
<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>
{rValue !== null && (
<div className="space-y-2 rounded-md border bg-muted/30 p-3 text-sm">
<div className="flex items-center justify-between">
<span className="font-medium">R = 1/U</span>
<div className="flex items-center gap-1">
<code className="rounded border bg-muted px-2 py-0.5">
{rValue} m²K/W
</code>
<CopyButton text={rValue} />
</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>
<div className="flex items-center justify-between font-medium border-t pt-2 mt-1">
<span>R total (cu Rsi + Rse)</span>
<div className="flex items-center gap-1">
<code className="rounded border bg-muted px-2 py-0.5">
{rTotal} m²K/W
</code>
<CopyButton text={rTotal ?? ""} />
</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>
);
}
// ─── 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-3">
<div className="flex flex-wrap items-center gap-3 text-sm">
<a
href="https://datelocale.mdlpa.ro"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2"
>
Deschide datelocale.mdlpa.ro ↗
</a>
<span className="text-muted-foreground">•</span>
<a
href="https://datelocale.mdlpa.ro/ro/about/tutorials"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2"
>
Tutoriale video ↗
</a>
<span className="text-muted-foreground">•</span>
<a
href="https://datelocale.mdlpa.ro/ro/about/info/reguli"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2"
>
Reguli de calcul ↗
</a>
</div>
<div
className="overflow-hidden rounded-md border"
style={{ height: "560px" }}
>
<iframe
src="https://datelocale.mdlpa.ro"
className="h-full w-full"
title="MDLPA — Date Locale"
allow="fullscreen"
/>
</div>
</div>
);
}
// ─── PDF Reducer (Stirling PDF) ───────────────────────────────────────────────
function PdfReducer() {
const [file, setFile] = useState<File | null>(null);
const [optimizeLevel, setOptimizeLevel] = useState("2");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const fileRef = useRef<HTMLInputElement>(null);
const handleCompress = async () => {
if (!file) return;
setLoading(true);
setError("");
try {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("optimizeLevel", optimizeLevel);
const res = await fetch("/api/compress-pdf", {
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, "-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) => {
setFile(e.target.files?.[0] ?? null);
setError("");
}}
/>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" onClick={() => fileRef.current?.click()}>
Selectează PDF...
</Button>
{file && (
<span className="text-sm text-muted-foreground">{file.name}</span>
)}
</div>
</div>
<div className="space-y-1.5">
<Label>Nivel compresie</Label>
<select
value={optimizeLevel}
onChange={(e) => setOptimizeLevel(e.target.value)}
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm"
>
<option value="0">0 — fără modificări (test)</option>
<option value="1">1 — compresie minimă</option>
<option value="2">2 — echilibrat (recomandat)</option>
<option value="3">3 — compresie mare</option>
<option value="4">4 — compresie maximă</option>
</select>
</div>
<div className="flex flex-wrap gap-2">
<Button onClick={handleCompress} disabled={!file || loading}>
{loading ? "Se comprimă..." : "Comprimă PDF"}
</Button>
<Button variant="ghost" asChild>
<a
href="http://10.10.10.166:8087/compress-pdf"
target="_blank"
rel="noopener noreferrer"
>
Deschide Stirling PDF ↗
</a>
</Button>
</div>
{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>
);
}
// ─── Main Module ──────────────────────────────────────────────────────────────
export function MiniUtilitiesModule() {
return (
<Tabs defaultValue="text-case" className="space-y-4">
<TabsList className="flex-wrap">
<TabsTrigger value="text-case">
<Type className="mr-1 h-3.5 w-3.5" /> Transformare text
</TabsTrigger>
<TabsTrigger value="char-count">
<Hash className="mr-1 h-3.5 w-3.5" /> Numărare caractere
</TabsTrigger>
<TabsTrigger value="percentage">
<Percent className="mr-1 h-3.5 w-3.5" /> Procente
</TabsTrigger>
<TabsTrigger value="area">
<Ruler className="mr-1 h-3.5 w-3.5" /> Suprafețe
</TabsTrigger>
<TabsTrigger value="u-value">
<Zap className="mr-1 h-3.5 w-3.5" /> U → R
</TabsTrigger>
<TabsTrigger value="ai-cleaner">
<Wand2 className="mr-1 h-3.5 w-3.5" /> Curățare AI
</TabsTrigger>
<TabsTrigger value="mdlpa">
<Building2 className="mr-1 h-3.5 w-3.5" /> MDLPA
</TabsTrigger>
<TabsTrigger value="pdf-reducer">
<FileDown className="mr-1 h-3.5 w-3.5" /> Reducere PDF
</TabsTrigger>
<TabsTrigger value="ocr">
<ScanText className="mr-1 h-3.5 w-3.5" /> OCR
</TabsTrigger>
</TabsList>
<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="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="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="ocr">
<Card>
<CardHeader>
<CardTitle className="text-base">
OCR — extragere text din imagini
</CardTitle>
</CardHeader>
<CardContent>
<QuickOcr />
</CardContent>
</Card>
</TabsContent>
</Tabs>
);
}