mirror of
https://github.com/RGJorge/ContainerFlow.git
synced 2026-08-03 07:21:42 +02:00
v0.0.29
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
import { useState } from "react";
|
||||
import { AlertCircle, X, Copy, Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import type { ActionError } from "../../shared/types";
|
||||
import { useT } from "../i18n";
|
||||
|
||||
const PREVIEW_CHAR_LIMIT = 180;
|
||||
|
||||
function ToastItem({ err, onDismiss }: { err: ActionError; onDismiss: (id: string) => void }) {
|
||||
const { t } = useT();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const shortName = err.uid.split("/").pop() || err.uid;
|
||||
const project = err.uid.includes("/") ? err.uid.split("/")[0] : null;
|
||||
const isLong = err.error.length > PREVIEW_CHAR_LIMIT;
|
||||
const visibleError = expanded || !isLong ? err.error : err.error.slice(0, PREVIEW_CHAR_LIMIT) + "…";
|
||||
|
||||
const copy = async () => {
|
||||
let ok = false;
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(err.error);
|
||||
ok = true;
|
||||
} else {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = err.error;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
ok = document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
} catch {}
|
||||
if (ok) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-slate-800/95 backdrop-blur-sm border border-red-500/40 rounded-lg shadow-xl shadow-black/50 w-[380px] overflow-hidden">
|
||||
<div className="flex items-start gap-2.5 px-3.5 py-2.5 border-b border-red-500/20 bg-red-500/10">
|
||||
<AlertCircle size={16} className="text-red-400 mt-0.5 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold text-red-300 capitalize">
|
||||
{t("toast.actionFailed").replace("{action}", err.action)}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5 mt-0.5">
|
||||
<span className="text-xs text-slate-200 font-medium truncate">{shortName}</span>
|
||||
{project && <span className="text-[10px] text-slate-500 truncate">{project}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onDismiss(err.id)}
|
||||
className="p-1 rounded hover:bg-slate-700/60 text-slate-500 hover:text-slate-300 transition-colors shrink-0"
|
||||
title={t("toast.dismiss")}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-3.5 py-2.5">
|
||||
<pre className="text-[11px] text-slate-300 font-mono whitespace-pre-wrap break-words leading-snug max-h-48 overflow-auto">
|
||||
{visibleError}
|
||||
</pre>
|
||||
<div className="flex items-center justify-end gap-1 mt-2">
|
||||
{isLong && (
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="flex items-center gap-1 px-2 py-1 text-[11px] text-slate-400 hover:text-slate-200 hover:bg-slate-700/60 rounded transition-colors"
|
||||
>
|
||||
{expanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
{expanded ? t("toast.collapse") : t("toast.expand")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={copy}
|
||||
className="flex items-center gap-1 px-2 py-1 text-[11px] text-slate-400 hover:text-slate-200 hover:bg-slate-700/60 rounded transition-colors"
|
||||
>
|
||||
{copied ? <Check size={12} className="text-emerald-400" /> : <Copy size={12} />}
|
||||
{copied ? t("toast.copied") : t("toast.copy")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ActionErrorToastProps {
|
||||
errors: ActionError[];
|
||||
onDismiss: (id: string) => void;
|
||||
onClearAll: () => void;
|
||||
}
|
||||
|
||||
export function ActionErrorToast({ errors, onDismiss, onClearAll }: ActionErrorToastProps) {
|
||||
const { t } = useT();
|
||||
if (errors.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed top-16 right-4 z-50 flex flex-col gap-2">
|
||||
{errors.length > 1 && (
|
||||
<button
|
||||
onClick={onClearAll}
|
||||
className="self-end text-[10px] text-slate-500 hover:text-slate-300 underline transition-colors"
|
||||
>
|
||||
{t("toast.dismissAll")} ({errors.length})
|
||||
</button>
|
||||
)}
|
||||
{errors.map((err) => (
|
||||
<ToastItem key={err.id} err={err} onDismiss={onDismiss} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { RotateCw, Square, Play, Trash2, Terminal, ExternalLink, Hammer } from "lucide-react";
|
||||
import { RotateCw, Square, Play, Trash2, Terminal, ExternalLink, Hammer, Lock, RefreshCw } from "lucide-react";
|
||||
import type { Service } from "../../shared/types";
|
||||
import { useT } from "../i18n";
|
||||
|
||||
interface NodeContextMenuProps {
|
||||
position: { x: number; y: number };
|
||||
service: Service;
|
||||
onAction: (action: "start" | "stop" | "restart" | "remove" | "rebuild") => void;
|
||||
locked?: boolean;
|
||||
onAction: (action: "start" | "stop" | "restart" | "remove" | "rebuild" | "recreate") => void;
|
||||
onOpenLogs: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function NodeContextMenu({ position, service, onAction, onOpenLogs, onClose }: NodeContextMenuProps) {
|
||||
export function NodeContextMenu({ position, service, locked, onAction, onOpenLogs, onClose }: NodeContextMenuProps) {
|
||||
const { t } = useT();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -48,21 +49,31 @@ export function NodeContextMenu({ position, service, onAction, onOpenLogs, onClo
|
||||
className="fixed z-[10000] bg-slate-800 border border-slate-700 rounded-lg shadow-xl shadow-black/50 py-1.5 min-w-[180px]"
|
||||
style={{ left: x, top: y }}
|
||||
>
|
||||
{locked && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 px-3.5 py-1.5 text-[11px] text-slate-500 bg-slate-900/40">
|
||||
<Lock size={11} className="text-slate-500" />
|
||||
<span>{t("access.viewOnly")}</span>
|
||||
</div>
|
||||
<div className="border-t border-slate-700/50" />
|
||||
</>
|
||||
)}
|
||||
{isRunning ? (
|
||||
<>
|
||||
<MenuItem icon={RotateCw} label={t("actions.restart")} color="text-yellow-400" onClick={() => { onAction("restart"); onClose(); }} />
|
||||
<MenuItem icon={Square} label={t("actions.stop")} color="text-red-400" onClick={() => { onAction("stop"); onClose(); }} />
|
||||
<MenuItem icon={RotateCw} label={t("actions.restart")} tooltip={t("actions.restart.tooltip")} color="text-yellow-400" disabled={locked} onClick={() => { onAction("restart"); onClose(); }} />
|
||||
<MenuItem icon={Square} label={t("actions.stop")} tooltip={t("actions.stop.tooltip")} color="text-red-400" disabled={locked} onClick={() => { onAction("stop"); onClose(); }} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MenuItem icon={Play} label={t("actions.start")} color="text-emerald-400" onClick={() => { onAction("start"); onClose(); }} />
|
||||
<MenuItem icon={Trash2} label={t("actions.remove")} color="text-red-400" onClick={() => { onAction("remove"); onClose(); }} />
|
||||
<MenuItem icon={Play} label={t("actions.start")} tooltip={t("actions.start.tooltip")} color="text-emerald-400" disabled={locked} onClick={() => { onAction("start"); onClose(); }} />
|
||||
<MenuItem icon={Trash2} label={t("actions.remove")} tooltip={t("actions.remove.tooltip")} color="text-red-400" disabled={locked} onClick={() => { onAction("remove"); onClose(); }} />
|
||||
</>
|
||||
)}
|
||||
{service.compose_file && (
|
||||
<>
|
||||
<div className="border-t border-slate-700/50 my-1" />
|
||||
<MenuItem icon={Hammer} label={t("actions.rebuild")} color="text-cyan-400" onClick={() => { onAction("rebuild"); onClose(); }} />
|
||||
<MenuItem icon={RefreshCw} label={t("actions.recreate")} tooltip={t("actions.recreate.tooltip")} color="text-cyan-400" disabled={locked} onClick={() => { onAction("recreate"); onClose(); }} />
|
||||
<MenuItem icon={Hammer} label={t("actions.rebuild")} tooltip={t("actions.rebuild.tooltip")} color="text-cyan-400" disabled={locked} onClick={() => { onAction("rebuild"); onClose(); }} />
|
||||
</>
|
||||
)}
|
||||
<div className="border-t border-slate-700/50 my-1" />
|
||||
@@ -83,13 +94,15 @@ export function NodeContextMenu({ position, service, onAction, onOpenLogs, onClo
|
||||
);
|
||||
}
|
||||
|
||||
function MenuItem({ icon: Icon, label, color, onClick }: { icon: typeof Play; label: string; color: string; onClick: () => void }) {
|
||||
function MenuItem({ icon: Icon, label, color, onClick, disabled, tooltip }: { icon: typeof Play; label: string; color: string; onClick: () => void; disabled?: boolean; tooltip?: string }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="flex items-center gap-2.5 w-full px-3.5 py-2 text-sm text-slate-300 hover:bg-slate-700/60 transition-colors"
|
||||
onClick={disabled ? undefined : onClick}
|
||||
disabled={disabled}
|
||||
title={tooltip}
|
||||
className={`flex items-center gap-2.5 w-full px-3.5 py-2 text-sm transition-colors ${disabled ? "text-slate-600 cursor-not-allowed" : "text-slate-300 hover:bg-slate-700/60"}`}
|
||||
>
|
||||
<Icon size={14} className={color} />
|
||||
<Icon size={14} className={disabled ? "text-slate-600" : color} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useState } from "react";
|
||||
import { HelpCircle } from "lucide-react";
|
||||
|
||||
interface TooltipProps {
|
||||
text: string;
|
||||
/** Width of the tooltip popover. Default: w-56 */
|
||||
width?: string;
|
||||
/** Icon size. Default: 13 */
|
||||
size?: number;
|
||||
/** Where the popover opens relative to the icon. Default: "top" */
|
||||
placement?: "top" | "bottom";
|
||||
}
|
||||
|
||||
export function Tooltip({ text, width = "w-56", size = 13, placement = "top" }: TooltipProps) {
|
||||
const [show, setShow] = useState(false);
|
||||
const popoverPos =
|
||||
placement === "top"
|
||||
? "bottom-full mb-2"
|
||||
: "top-full mt-2";
|
||||
const arrowPos =
|
||||
placement === "top"
|
||||
? "top-full -mt-px border-t-slate-700"
|
||||
: "bottom-full -mb-px border-b-slate-700";
|
||||
return (
|
||||
<span className="relative inline-flex">
|
||||
<button
|
||||
type="button"
|
||||
onMouseEnter={() => setShow(true)}
|
||||
onMouseLeave={() => setShow(false)}
|
||||
onClick={(e) => { e.stopPropagation(); setShow((v) => !v); }}
|
||||
className="text-slate-500 hover:text-slate-300 transition-colors"
|
||||
>
|
||||
<HelpCircle size={size} />
|
||||
</button>
|
||||
{show && (
|
||||
<div className={`absolute ${popoverPos} left-1/2 -translate-x-1/2 px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-xs text-slate-200 ${width} text-left shadow-xl z-50 leading-relaxed whitespace-normal`}>
|
||||
{text}
|
||||
<div className={`absolute ${arrowPos} left-1/2 -translate-x-1/2 border-4 border-transparent`} />
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user