Deployment projeleri için yeniden başlatma (restart) yeteneği eklendi. Backend servisi, API endpoint'i ve kullanıcı arayüzü butonları güncellendi.
691 lines
26 KiB
TypeScript
691 lines
26 KiB
TypeScript
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
||
import { toast } from "sonner";
|
||
import { useLocation, useNavigate } from "react-router-dom";
|
||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||
import {
|
||
faCloudArrowUp,
|
||
faEye,
|
||
faEyeSlash,
|
||
faPenToSquare,
|
||
faPlus,
|
||
faRotate,
|
||
faRocket
|
||
} from "@fortawesome/free-solid-svg-icons";
|
||
import { Card, CardContent } from "../components/ui/card";
|
||
import { Button } from "../components/ui/button";
|
||
import { Input } from "../components/ui/input";
|
||
import { Label } from "../components/ui/label";
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||
import {
|
||
createDeployment,
|
||
deleteDeployment,
|
||
DeploymentInput,
|
||
DeploymentProject,
|
||
fetchDeploymentComposeFiles,
|
||
fetchDeploymentBranches,
|
||
fetchDeploymentEnvExamples,
|
||
fetchDeployments,
|
||
restartDeployment,
|
||
runDeployment,
|
||
updateDeployment
|
||
} from "../api/deployments";
|
||
import { JobStatusBadge } from "../components/JobStatusBadge";
|
||
import { useLiveData } from "../providers/live-provider";
|
||
|
||
type FormState = {
|
||
_id?: string;
|
||
name: string;
|
||
repoUrl: string;
|
||
branch: string;
|
||
composeFile: DeploymentInput["composeFile"];
|
||
port: string;
|
||
};
|
||
|
||
type EnvExample = { name: string; content: string };
|
||
|
||
const defaultForm: FormState = {
|
||
name: "",
|
||
repoUrl: "",
|
||
branch: "main",
|
||
composeFile: "docker-compose.yml",
|
||
port: ""
|
||
};
|
||
|
||
export function DeploymentsPage() {
|
||
const navigate = useNavigate();
|
||
const location = useLocation();
|
||
const { deploymentStreams } = useLiveData();
|
||
const apiBase = (import.meta.env.VITE_API_URL || "").replace(/\/$/, "");
|
||
const [deployments, setDeployments] = useState<DeploymentProject[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [modalOpen, setModalOpen] = useState(false);
|
||
const [saving, setSaving] = useState(false);
|
||
const [form, setForm] = useState<FormState>(defaultForm);
|
||
const [pendingEditId, setPendingEditId] = useState<string | null>(null);
|
||
const [branchOptions, setBranchOptions] = useState<string[]>([]);
|
||
const [branchLoading, setBranchLoading] = useState(false);
|
||
const [composeOptions, setComposeOptions] = useState<DeploymentInput["composeFile"][]>([]);
|
||
const [composeLoading, setComposeLoading] = useState(false);
|
||
const [envExamples, setEnvExamples] = useState<EnvExample[]>([]);
|
||
const [envLoading, setEnvLoading] = useState(false);
|
||
const [envContent, setEnvContent] = useState("");
|
||
const [envExampleName, setEnvExampleName] = useState("");
|
||
const [showEnv, setShowEnv] = useState(false);
|
||
const [activeTab, setActiveTab] = useState("details");
|
||
const [faviconErrors, setFaviconErrors] = useState<Record<string, boolean>>({});
|
||
|
||
const isEdit = useMemo(() => !!form._id, [form._id]);
|
||
|
||
const loadDeployments = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const data = await fetchDeployments();
|
||
setDeployments(data);
|
||
} catch {
|
||
toast.error("Deployment listesi alınamadı");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
loadDeployments();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const repoUrl = form.repoUrl.trim();
|
||
if (!repoUrl) {
|
||
setBranchOptions([]);
|
||
setComposeOptions([]);
|
||
return;
|
||
}
|
||
if (!form._id && !form.name) {
|
||
const normalized = repoUrl.replace(/\/+$/, "");
|
||
const lastPart = normalized.split("/").pop() || "";
|
||
const name = lastPart.replace(/\.git$/i, "");
|
||
if (name) {
|
||
setForm((prev) => ({ ...prev, name }));
|
||
}
|
||
}
|
||
const timer = setTimeout(async () => {
|
||
setBranchLoading(true);
|
||
try {
|
||
const branches = await fetchDeploymentBranches(repoUrl);
|
||
setBranchOptions(branches);
|
||
if (!form.branch && branches.length > 0) {
|
||
setForm((prev) => ({ ...prev, branch: branches.includes("main") ? "main" : branches[0] }));
|
||
}
|
||
} catch {
|
||
setBranchOptions([]);
|
||
} finally {
|
||
setBranchLoading(false);
|
||
}
|
||
}, 400);
|
||
return () => clearTimeout(timer);
|
||
}, [form.repoUrl, form.branch]);
|
||
|
||
useEffect(() => {
|
||
const repoUrl = form.repoUrl.trim();
|
||
const branch = form.branch.trim();
|
||
if (!repoUrl || !branch) {
|
||
setEnvExamples([]);
|
||
setEnvExampleName("");
|
||
if (!isEdit) {
|
||
setEnvContent("");
|
||
}
|
||
setComposeOptions([]);
|
||
return;
|
||
}
|
||
const timer = setTimeout(async () => {
|
||
setComposeLoading(true);
|
||
try {
|
||
const files = await fetchDeploymentComposeFiles(repoUrl, branch);
|
||
setComposeOptions(files);
|
||
if (files.length > 0 && !files.includes(form.composeFile)) {
|
||
setForm((prev) => ({ ...prev, composeFile: files[0] }));
|
||
}
|
||
} catch {
|
||
setComposeOptions([]);
|
||
} finally {
|
||
setComposeLoading(false);
|
||
}
|
||
}, 400);
|
||
return () => clearTimeout(timer);
|
||
}, [form.repoUrl, form.branch, form.composeFile]);
|
||
|
||
useEffect(() => {
|
||
const repoUrl = form.repoUrl.trim();
|
||
const branch = form.branch.trim();
|
||
if (!repoUrl || !branch) {
|
||
return;
|
||
}
|
||
const timer = setTimeout(async () => {
|
||
setEnvLoading(true);
|
||
try {
|
||
const examples = await fetchDeploymentEnvExamples(repoUrl, branch);
|
||
setEnvExamples(examples);
|
||
if (examples.length === 0) {
|
||
if (!isEdit) {
|
||
setEnvExampleName("");
|
||
setEnvContent("");
|
||
}
|
||
return;
|
||
}
|
||
const selected = examples.find((example) => example.name === envExampleName) || examples[0];
|
||
if (!isEdit || !envContent) {
|
||
setEnvExampleName(selected.name);
|
||
setEnvContent(selected.content);
|
||
}
|
||
} catch {
|
||
setEnvExamples([]);
|
||
} finally {
|
||
setEnvLoading(false);
|
||
}
|
||
}, 400);
|
||
return () => clearTimeout(timer);
|
||
}, [form.repoUrl, form.branch, envExampleName, isEdit]);
|
||
|
||
useEffect(() => {
|
||
const state = location.state as { editDeploymentId?: string } | null;
|
||
if (state?.editDeploymentId) {
|
||
setPendingEditId(state.editDeploymentId);
|
||
navigate(location.pathname, { replace: true });
|
||
}
|
||
}, [location.state, navigate, location.pathname]);
|
||
|
||
useEffect(() => {
|
||
if (!pendingEditId || deployments.length === 0) return;
|
||
const deployment = deployments.find((d) => d._id === pendingEditId);
|
||
if (deployment) {
|
||
handleEdit(deployment);
|
||
setPendingEditId(null);
|
||
}
|
||
}, [pendingEditId, deployments]);
|
||
|
||
const handleOpenNew = async () => {
|
||
setForm(defaultForm);
|
||
setBranchOptions([]);
|
||
setComposeOptions([]);
|
||
setEnvExamples([]);
|
||
setEnvContent("");
|
||
setEnvExampleName("");
|
||
setShowEnv(false);
|
||
setActiveTab("details");
|
||
setModalOpen(true);
|
||
};
|
||
|
||
const handleEdit = (deployment: DeploymentProject) => {
|
||
const { _id, name, repoUrl, branch, composeFile, port } = deployment;
|
||
setForm({
|
||
_id,
|
||
name,
|
||
repoUrl,
|
||
branch,
|
||
composeFile,
|
||
port: port ? String(port) : ""
|
||
});
|
||
setEnvContent(deployment.envContent || "");
|
||
setEnvExampleName(deployment.envExampleName || "");
|
||
setShowEnv(false);
|
||
setActiveTab("details");
|
||
setModalOpen(true);
|
||
};
|
||
|
||
const handleClose = () => {
|
||
setModalOpen(false);
|
||
};
|
||
|
||
const handleSave = async () => {
|
||
setSaving(true);
|
||
try {
|
||
const payload: DeploymentInput = {
|
||
name: form.name,
|
||
repoUrl: form.repoUrl,
|
||
branch: form.branch,
|
||
composeFile: form.composeFile,
|
||
port: form.port ? Number(form.port) : undefined,
|
||
envContent: envContent.trim() ? envContent : undefined,
|
||
envExampleName: envExampleName || undefined
|
||
};
|
||
|
||
if (!payload.name || !payload.repoUrl || !payload.branch || !payload.composeFile) {
|
||
toast.error("Tüm alanları doldurun");
|
||
setSaving(false);
|
||
return;
|
||
}
|
||
|
||
if (isEdit && form._id) {
|
||
const updated = await updateDeployment(form._id, {
|
||
name: payload.name,
|
||
repoUrl: payload.repoUrl,
|
||
branch: payload.branch,
|
||
composeFile: payload.composeFile,
|
||
port: payload.port,
|
||
envContent: payload.envContent,
|
||
envExampleName: payload.envExampleName
|
||
});
|
||
setDeployments((prev) => prev.map((d) => (d._id === updated._id ? updated : d)));
|
||
try {
|
||
await runDeployment(updated._id, "update deploy");
|
||
} catch {
|
||
toast.error("Deploy tetiklenemedi");
|
||
}
|
||
toast.success("Deployment güncellendi");
|
||
} else {
|
||
const created = await createDeployment(payload);
|
||
setDeployments((prev) => [created, ...prev]);
|
||
toast.success("Deployment oluşturuldu");
|
||
}
|
||
|
||
setModalOpen(false);
|
||
} catch {
|
||
toast.error("İşlem sırasında hata oluştu");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleRun = async (id: string) => {
|
||
try {
|
||
await runDeployment(id);
|
||
toast.success("Deploy tetiklendi");
|
||
} catch {
|
||
toast.error("Deploy tetiklenemedi");
|
||
}
|
||
};
|
||
|
||
const handleRestart = async (id: string) => {
|
||
try {
|
||
await restartDeployment(id, "restart");
|
||
toast.success("Restart tetiklendi");
|
||
} catch {
|
||
toast.error("Restart tetiklenemedi");
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (deployment: DeploymentProject) => {
|
||
const ok = window.confirm("Bu deployment'ı silmek istediğinize emin misiniz?");
|
||
if (!ok) return;
|
||
try {
|
||
await deleteDeployment(deployment._id);
|
||
setDeployments((prev) => prev.filter((d) => d._id !== deployment._id));
|
||
toast.success("Deployment silindi");
|
||
} catch {
|
||
toast.error("Deployment silinemedi");
|
||
}
|
||
};
|
||
|
||
const formatDate = (value?: string) => {
|
||
if (!value) return "-";
|
||
return new Date(value).toLocaleString();
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<div className="flex items-center justify-between gap-4">
|
||
<div>
|
||
<h2 className="text-xl font-semibold text-foreground">Deployments</h2>
|
||
</div>
|
||
<Button onClick={handleOpenNew} className="gap-2">
|
||
<FontAwesomeIcon icon={faPlus} className="h-4 w-4" />
|
||
New Deployment
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="grid gap-4">
|
||
{loading && (
|
||
<div className="rounded-md border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
|
||
Deployments yükleniyor...
|
||
</div>
|
||
)}
|
||
|
||
{!loading && deployments.length === 0 && (
|
||
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
|
||
Henüz deployment eklenmemiş.
|
||
</div>
|
||
)}
|
||
|
||
{deployments.map((deployment) => {
|
||
const faviconUrl = apiBase
|
||
? `${apiBase}/deployments/${deployment._id}/favicon`
|
||
: `/api/deployments/${deployment._id}/favicon`;
|
||
return (
|
||
<Card
|
||
key={deployment._id}
|
||
className="cursor-pointer transition hover:border-primary/50"
|
||
onClick={() => navigate(`/deployments/${deployment._id}`)}
|
||
>
|
||
<CardContent className="p-5">
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div className="space-y-2">
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||
{!faviconErrors[deployment._id] ? (
|
||
<img
|
||
src={faviconUrl}
|
||
alt={`${deployment.name} favicon`}
|
||
className="h-4 w-4"
|
||
onError={() =>
|
||
setFaviconErrors((prev) => ({ ...prev, [deployment._id]: true }))
|
||
}
|
||
/>
|
||
) : (
|
||
<FontAwesomeIcon icon={faRocket} className="h-4 w-4" />
|
||
)}
|
||
</div>
|
||
<div>
|
||
<div className="text-base font-semibold text-foreground">{deployment.name}</div>
|
||
<div className="text-sm text-muted-foreground">{deployment.rootPath}</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
|
||
<JobStatusBadge
|
||
status={deploymentStreams[deployment._id]?.status || deployment.lastStatus}
|
||
/>
|
||
<span className="rounded-md bg-muted px-2 py-1 text-xs font-semibold text-foreground/80">
|
||
{deployment.env.toUpperCase()}
|
||
</span>
|
||
<span className="rounded-md bg-muted px-2 py-1 text-xs font-semibold text-foreground/80">
|
||
{deployment.composeFile}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant="outline"
|
||
size="icon"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleRun(deployment._id);
|
||
}}
|
||
title="Deploy tetikle"
|
||
>
|
||
<FontAwesomeIcon icon={faCloudArrowUp} className="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="icon"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleRestart(deployment._id);
|
||
}}
|
||
title="Restart"
|
||
aria-label="Restart"
|
||
>
|
||
<FontAwesomeIcon icon={faRotate} className="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="icon"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleEdit(deployment);
|
||
}}
|
||
title="Düzenle"
|
||
>
|
||
<FontAwesomeIcon icon={faPenToSquare} className="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="icon"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleDelete(deployment);
|
||
}}
|
||
title="Sil"
|
||
>
|
||
✕
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 grid gap-1 text-sm text-muted-foreground">
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-medium text-foreground">Repo:</span>
|
||
<span className="truncate text-foreground/80">{deployment.repoUrl}</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-medium text-foreground">Branch:</span>
|
||
<span className="text-foreground/80">{deployment.branch}</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-medium text-foreground">Last Deploy:</span>
|
||
<span className="text-foreground/80">{formatDate(deployment.lastDeployAt)}</span>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{modalOpen && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4 py-8">
|
||
<div
|
||
className="flex w-full max-w-lg flex-col overflow-hidden rounded-lg border border-border bg-card card-shadow"
|
||
style={{ height: 626 }}
|
||
>
|
||
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
||
<div className="space-y-1">
|
||
<div className="text-lg font-semibold text-foreground">
|
||
{isEdit ? "Deployment Güncelle" : "Yeni Deployment"}
|
||
</div>
|
||
<div className="text-sm text-muted-foreground">
|
||
Repo ve branch seçimi sonrası webhook tetiklemeleriyle deploy yapılır.
|
||
</div>
|
||
</div>
|
||
<Button variant="ghost" size="icon" onClick={handleClose}>
|
||
✕
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-hidden px-5 py-4">
|
||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
|
||
<TabsList>
|
||
<TabsTrigger value="details">Genel</TabsTrigger>
|
||
<TabsTrigger value="environment">Environment</TabsTrigger>
|
||
</TabsList>
|
||
|
||
<TabsContent value="details" className="h-[420px] space-y-4">
|
||
{!isEdit && (
|
||
<div className="h-[1.25rem] text-xs text-muted-foreground">
|
||
Repo URL girildiğinde branch ve compose dosyaları listelenir.
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="repo">Repo URL</Label>
|
||
<Input
|
||
id="repo"
|
||
value={form.repoUrl}
|
||
onChange={(e) => setForm((prev) => ({ ...prev, repoUrl: e.target.value }))}
|
||
placeholder="https://gitea.example.com/org/repo"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="name">Deployment Name</Label>
|
||
<Input
|
||
id="name"
|
||
value={form.name}
|
||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||
placeholder="wisecolt-app"
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="branch">Branch</Label>
|
||
{branchOptions.length > 0 ? (
|
||
<Select
|
||
value={form.branch}
|
||
onValueChange={(value) => setForm((prev) => ({ ...prev, branch: value }))}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="Branch seçin" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{branchOptions.map((branch) => (
|
||
<SelectItem key={branch} value={branch}>
|
||
{branch}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
) : (
|
||
<Input
|
||
id="branch"
|
||
value={form.branch}
|
||
onChange={(e) => setForm((prev) => ({ ...prev, branch: e.target.value }))}
|
||
placeholder="main"
|
||
required
|
||
/>
|
||
)}
|
||
<div className="h-[1.25rem] text-xs text-muted-foreground">
|
||
{branchLoading
|
||
? "Branch listesi alınıyor..."
|
||
: branchOptions.length > 0
|
||
? "Repo üzerindeki branch'lar listelendi."
|
||
: "Repo URL girildiğinde branch listesi otomatik gelir."}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<div className="space-y-2">
|
||
<Label>Compose Dosyası</Label>
|
||
<Select
|
||
value={form.composeFile}
|
||
onValueChange={(value) =>
|
||
setForm((prev) => ({
|
||
...prev,
|
||
composeFile: value as DeploymentInput["composeFile"]
|
||
}))
|
||
}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="Compose seçin" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{(composeOptions.length > 0
|
||
? composeOptions
|
||
: ["docker-compose.yml", "docker-compose.dev.yml"]
|
||
).map((file) => (
|
||
<SelectItem key={file} value={file}>
|
||
{file}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<div className="h-[1.25rem] text-xs text-muted-foreground">
|
||
{composeLoading
|
||
? "Compose dosyaları alınıyor..."
|
||
: composeOptions.length > 0
|
||
? "Repo üzerindeki compose dosyaları listelendi."
|
||
: "Repo URL ve branch sonrası compose dosyaları listelenir."}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="port">Port (opsiyonel)</Label>
|
||
<Input
|
||
id="port"
|
||
type="number"
|
||
min={1}
|
||
value={form.port}
|
||
onChange={(e) => setForm((prev) => ({ ...prev, port: e.target.value }))}
|
||
placeholder="3000"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="environment" className="h-[420px] space-y-4">
|
||
<div className="space-y-2">
|
||
<Label>.env.example</Label>
|
||
{envExamples.length > 0 ? (
|
||
<Select
|
||
value={envExampleName}
|
||
onValueChange={(value) => {
|
||
const example = envExamples.find((item) => item.name === value);
|
||
setEnvExampleName(value);
|
||
if (example) {
|
||
setEnvContent(example.content);
|
||
}
|
||
}}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="Env example seçin" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{envExamples.map((example) => (
|
||
<SelectItem key={example.name} value={example.name}>
|
||
{example.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
) : (
|
||
<div className="h-[2.5rem] rounded-md border border-dashed border-border px-3 py-2 text-xs text-muted-foreground">
|
||
{envLoading
|
||
? "Env example dosyaları alınıyor..."
|
||
: "Repo içinde .env.example bulunamadı."}
|
||
</div>
|
||
)}
|
||
<div className="h-[1.25rem] text-xs text-muted-foreground">
|
||
{envExamples.length > 0
|
||
? "Repo üzerindeki env example dosyaları listelendi."
|
||
: envLoading
|
||
? "Env example dosyaları alınıyor..."
|
||
: "Repo içinde .env.example bulunamadı."}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<Label htmlFor="env-content">Environment</Label>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={() => setShowEnv((prev) => !prev)}
|
||
>
|
||
<FontAwesomeIcon icon={showEnv ? faEyeSlash : faEye} className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
<textarea
|
||
id="env-content"
|
||
value={envContent}
|
||
onChange={(e) => setEnvContent(e.target.value)}
|
||
className="h-[180px] w-full resize-none rounded-md border border-input bg-background px-3 py-2 text-sm font-mono text-foreground shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||
style={
|
||
showEnv ? undefined : ({ WebkitTextSecurity: "disc" } as CSSProperties)
|
||
}
|
||
placeholder="ENV içerikleri burada listelenir."
|
||
/>
|
||
<div className="min-h-[1.25rem] text-xs text-muted-foreground">
|
||
Kaydedince içerik deployment kök dizinine <span className="font-mono">.env</span> olarak yazılır.
|
||
</div>
|
||
</div>
|
||
</TabsContent>
|
||
</Tabs>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-end gap-3 border-t border-border px-5 py-4">
|
||
<Button variant="ghost" onClick={handleClose} disabled={saving}>
|
||
İptal
|
||
</Button>
|
||
<Button onClick={handleSave} disabled={saving}>
|
||
{saving ? "Kaydediliyor..." : "Kaydet"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|