mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
feat: finish the homepage plugin, move service links and canvas out of core (B19)
This commit is contained in:
1 parent
21937a6b02
commit
d4ec444558
256 files changed
+27365
-9077
No files matched your search
@@ -1752,7 +1752,6 @@ export function AppShell({
|
||||
const singletonLabels: Partial<Record<TabType, string>> = {
|
||||
"host-manager": t("nav.hostManager"),
|
||||
sftp: t("nav.sftp"),
|
||||
homepage: t("nav.homepage"),
|
||||
};
|
||||
// A plugin tab names itself; promoted rail panels reuse the rail's own
|
||||
// label so the two stay in sync.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { dashboardApi, handleApiError } from "@/main-axios";
|
||||
import { normalizeServiceLinkUrl } from "@/lib/service-link-url";
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
|
||||
// DASHBOARD API
|
||||
// ============================================================================
|
||||
@@ -22,7 +21,7 @@ export interface RecentActivityItem {
|
||||
|
||||
export async function getUptime(): Promise<UptimeInfo> {
|
||||
try {
|
||||
const response = await dashboardApi.get("/uptime");
|
||||
const response = await authApi.get("/dashboard/uptime");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch uptime");
|
||||
@@ -33,7 +32,7 @@ export async function getRecentActivity(
|
||||
limit?: number,
|
||||
): Promise<RecentActivityItem[]> {
|
||||
try {
|
||||
const response = await dashboardApi.get("/activity/recent", {
|
||||
const response = await authApi.get("/dashboard/activity/recent", {
|
||||
params: { limit },
|
||||
});
|
||||
return response.data;
|
||||
@@ -48,7 +47,7 @@ export async function logActivity(
|
||||
hostName: string,
|
||||
): Promise<{ message: string; id: number | string }> {
|
||||
try {
|
||||
const response = await dashboardApi.post("/activity/log", {
|
||||
const response = await authApi.post("/dashboard/activity/log", {
|
||||
type,
|
||||
hostId,
|
||||
hostName,
|
||||
@@ -61,71 +60,9 @@ export async function logActivity(
|
||||
|
||||
export async function resetRecentActivity(): Promise<{ message: string }> {
|
||||
try {
|
||||
const response = await dashboardApi.delete("/activity/reset");
|
||||
const response = await authApi.delete("/dashboard/activity/reset");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "reset recent activity");
|
||||
}
|
||||
}
|
||||
|
||||
export interface ServiceLink {
|
||||
id: number;
|
||||
userId: string;
|
||||
label: string;
|
||||
url: string;
|
||||
order: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export async function getServiceLinks(): Promise<ServiceLink[]> {
|
||||
try {
|
||||
const response = await dashboardApi.get("/service-links");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch service links");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createServiceLink(
|
||||
label: string,
|
||||
url: string,
|
||||
): Promise<ServiceLink> {
|
||||
try {
|
||||
const response = await dashboardApi.post("/service-links", {
|
||||
label,
|
||||
url: normalizeServiceLinkUrl(url),
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "create service link");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteServiceLink(
|
||||
id: number,
|
||||
): Promise<{ message: string }> {
|
||||
try {
|
||||
const response = await dashboardApi.delete(`/service-links/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "delete service link");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateServiceLink(
|
||||
id: number,
|
||||
updates: { label?: string; url?: string },
|
||||
): Promise<ServiceLink> {
|
||||
try {
|
||||
const response = await dashboardApi.put(`/service-links/${id}`, {
|
||||
...updates,
|
||||
url:
|
||||
updates.url !== undefined
|
||||
? normalizeServiceLinkUrl(updates.url)
|
||||
: undefined,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update service link");
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { homepageApi, handleApiError } from "@/main-axios";
|
||||
import type {
|
||||
HomepageItemRow,
|
||||
HomepageLayoutData,
|
||||
HomepageLayoutRow,
|
||||
WidgetTypeId,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export async function getHomepageItems(): Promise<HomepageItemRow[]> {
|
||||
try {
|
||||
const res = await homepageApi.get("/items");
|
||||
return res.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch homepage items");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createHomepageItem(data: {
|
||||
typeId: WidgetTypeId;
|
||||
title?: string | null;
|
||||
config?: Record<string, unknown>;
|
||||
}): Promise<HomepageItemRow> {
|
||||
try {
|
||||
const res = await homepageApi.post("/items", data);
|
||||
return res.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "create homepage item");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateHomepageItem(
|
||||
id: number,
|
||||
data: {
|
||||
title?: string | null;
|
||||
config?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<HomepageItemRow> {
|
||||
try {
|
||||
const res = await homepageApi.put(`/items/${id}`, data);
|
||||
return res.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update homepage item");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteHomepageItem(id: number): Promise<void> {
|
||||
try {
|
||||
await homepageApi.delete(`/items/${id}`);
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "delete homepage item");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getHomepageLayout(): Promise<HomepageLayoutRow | null> {
|
||||
try {
|
||||
const res = await homepageApi.get("/layout");
|
||||
return res.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch homepage layout");
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveHomepageLayout(
|
||||
layout: HomepageLayoutData,
|
||||
): Promise<HomepageLayoutRow> {
|
||||
try {
|
||||
const res = await homepageApi.put("/layout", layout);
|
||||
return res.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "save homepage layout");
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,19 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { enabledHostProtocols, protocolPort } from "@/sidebar/host-protocols";
|
||||
import { ComponentSlot } from "@/shell/ActionSlot";
|
||||
import { useActionSlot } from "@/hooks/use-action-slot";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { Button } from "@/components/button";
|
||||
import { Card } from "@/components/card";
|
||||
import { Separator } from "@/components/separator";
|
||||
import {
|
||||
Activity,
|
||||
Check,
|
||||
Database,
|
||||
ExternalLink,
|
||||
GripHorizontal,
|
||||
GripVertical,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
Link,
|
||||
Network,
|
||||
Plus,
|
||||
Server,
|
||||
@@ -39,12 +38,9 @@ import {
|
||||
getCredentials,
|
||||
resetRecentActivity,
|
||||
getUserInfo,
|
||||
getServiceLinks,
|
||||
createServiceLink,
|
||||
deleteServiceLink,
|
||||
isElectron,
|
||||
} from "@/main-axios";
|
||||
import type { RecentActivityItem, ServiceLink } from "@/main-axios";
|
||||
import type { RecentActivityItem } from "@/main-axios";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
getRegisteredDashboardCard,
|
||||
@@ -58,16 +54,6 @@ import {
|
||||
hostActionsFor,
|
||||
listHostActions,
|
||||
} from "@/sidebar/host-contributions";
|
||||
import { HomepagePreviewCard } from "@/dashboard/cards/HomepagePreviewCard";
|
||||
import { HomepageCanvas } from "@/features/homepage/HomepageCanvas";
|
||||
|
||||
// Side-effect imports so homepage widgets register themselves
|
||||
import "@/features/homepage/widgets/ServiceLinkWidget";
|
||||
import "@/features/homepage/widgets/ClockWidget";
|
||||
import "@/features/homepage/widgets/NotesWidget";
|
||||
import "@/features/homepage/widgets/BookmarkListWidget";
|
||||
import "@/features/homepage/widgets/HostStatusWidget";
|
||||
import "@/features/homepage/widgets/FolderWidget";
|
||||
import {
|
||||
useStatusColorScheme,
|
||||
getStatusClasses,
|
||||
@@ -75,10 +61,6 @@ import {
|
||||
import { useServerStatus } from "@/lib/ServerStatusContext";
|
||||
import { sshHostToHost } from "@/sidebar/HostManagerData";
|
||||
import { getDefaultConnectionTab } from "@/lib/host-connection-tabs";
|
||||
import {
|
||||
isValidServiceLinkUrl,
|
||||
normalizeServiceLinkUrl,
|
||||
} from "@/lib/service-link-url";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -140,8 +122,6 @@ const CORE_CARD_IDS = new Set<string>([
|
||||
"quick_actions",
|
||||
"host_status",
|
||||
"recent_activity",
|
||||
"service_links",
|
||||
"homepage_preview",
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -645,131 +625,6 @@ function RecentActivityCard({
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceLinksCard({
|
||||
links,
|
||||
onAdd,
|
||||
onDelete,
|
||||
}: {
|
||||
links: ServiceLink[];
|
||||
onAdd: (label: string, url: string) => Promise<void>;
|
||||
onDelete: (id: number) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [label, setLabel] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [urlError, setUrlError] = useState(false);
|
||||
const [addError, setAddError] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
const handleAdd = async () => {
|
||||
const normalizedUrl = normalizeServiceLinkUrl(url);
|
||||
if (!isValidServiceLinkUrl(normalizedUrl)) {
|
||||
setUrlError(true);
|
||||
setAddError("");
|
||||
return;
|
||||
}
|
||||
setUrlError(false);
|
||||
setAddError("");
|
||||
setAdding(true);
|
||||
try {
|
||||
await onAdd(label.trim(), normalizedUrl);
|
||||
setLabel("");
|
||||
setUrl("");
|
||||
} catch (error) {
|
||||
setAddError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("dashboardTab.serviceLinksAddFailed"),
|
||||
);
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col overflow-hidden w-full h-full py-0 gap-0">
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border shrink-0">
|
||||
<Link className="size-3.5 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
|
||||
{t("dashboardTab.serviceLinksTitle")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col overflow-auto flex-1">
|
||||
{links.length === 0 && (
|
||||
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground/40 py-4">
|
||||
{t("dashboardTab.serviceLinksEmpty")}
|
||||
</div>
|
||||
)}
|
||||
{links.map((link) => (
|
||||
<div
|
||||
key={link.id}
|
||||
className="flex items-center justify-between px-4 py-2 border-b border-border last:border-0 group/link"
|
||||
>
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="flex items-center gap-2 min-w-0 flex-1 hover:text-accent-brand transition-colors"
|
||||
>
|
||||
<ExternalLink className="size-3 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs font-semibold truncate">
|
||||
{link.label}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground truncate">
|
||||
{link.url}
|
||||
</span>
|
||||
</a>
|
||||
<button
|
||||
onClick={() => onDelete(link.id)}
|
||||
className="ml-2 opacity-0 group-hover/link:opacity-100 transition-opacity size-5 flex items-center justify-center hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-t border-border shrink-0">
|
||||
<input
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder={t("dashboardTab.serviceLinksLabelPlaceholder")}
|
||||
className="flex-1 min-w-0 text-xs bg-transparent border border-border px-2 py-1 focus:outline-none focus:border-accent-brand/60"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
setUrl(e.target.value);
|
||||
setUrlError(false);
|
||||
setAddError("");
|
||||
}}
|
||||
placeholder={t("dashboardTab.serviceLinksUrlPlaceholder")}
|
||||
className={`flex-[2] min-w-0 text-xs bg-transparent border px-2 py-1 focus:outline-none ${urlError ? "border-destructive" : "border-border focus:border-accent-brand/60"}`}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
className="text-xs bg-accent-brand hover:bg-accent-brand/90 text-white h-6 px-2 shrink-0"
|
||||
onClick={handleAdd}
|
||||
disabled={!label.trim() || !url.trim() || adding}
|
||||
>
|
||||
{t("dashboardTab.serviceLinksAdd")}
|
||||
</Button>
|
||||
</div>
|
||||
{urlError && (
|
||||
<div className="px-4 pb-2 text-[10px] text-destructive shrink-0">
|
||||
{t("dashboardTab.serviceLinksInvalidUrl")}
|
||||
</div>
|
||||
)}
|
||||
{addError && (
|
||||
<div className="px-4 pb-2 text-[10px] text-destructive shrink-0">
|
||||
{addError}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── CardItem ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function CardItem({
|
||||
@@ -794,9 +649,6 @@ function CardItem({
|
||||
activity,
|
||||
onClearActivity,
|
||||
isAdmin,
|
||||
serviceLinks,
|
||||
onAddServiceLink,
|
||||
onDeleteServiceLink,
|
||||
statusLoading,
|
||||
isVisible = true,
|
||||
}: {
|
||||
@@ -821,9 +673,6 @@ function CardItem({
|
||||
activity: RecentActivityItem[];
|
||||
onClearActivity: () => void;
|
||||
isAdmin: boolean;
|
||||
serviceLinks: ServiceLink[];
|
||||
onAddServiceLink: (label: string, url: string) => Promise<void>;
|
||||
onDeleteServiceLink: (id: number) => Promise<void>;
|
||||
statusLoading?: boolean;
|
||||
isVisible?: boolean;
|
||||
}) {
|
||||
@@ -926,18 +775,6 @@ function CardItem({
|
||||
onOpenSingletonTab={onOpenSingletonTab}
|
||||
/>
|
||||
)}
|
||||
{slot.id === "service_links" && (
|
||||
<ServiceLinksCard
|
||||
links={serviceLinks}
|
||||
onAdd={onAddServiceLink}
|
||||
onDelete={onDeleteServiceLink}
|
||||
/>
|
||||
)}
|
||||
{slot.id === "homepage_preview" && (
|
||||
<HomepagePreviewCard
|
||||
onOpenFullscreen={() => onOpenSingletonTab("homepage")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{editMode && !isFlex && (
|
||||
<div
|
||||
@@ -1049,9 +886,6 @@ type PanelColumnProps = {
|
||||
onClearActivity: () => void;
|
||||
cardLabels: Record<DashboardCardId, string>;
|
||||
isAdmin: boolean;
|
||||
serviceLinks: ServiceLink[];
|
||||
onAddServiceLink: (label: string, url: string) => Promise<void>;
|
||||
onDeleteServiceLink: (id: number) => Promise<void>;
|
||||
statusLoading: boolean;
|
||||
isVisible?: boolean;
|
||||
};
|
||||
@@ -1081,9 +915,6 @@ function PanelColumn({
|
||||
onClearActivity,
|
||||
cardLabels,
|
||||
isAdmin,
|
||||
serviceLinks,
|
||||
onAddServiceLink,
|
||||
onDeleteServiceLink,
|
||||
statusLoading,
|
||||
isVisible = true,
|
||||
}: PanelColumnProps) {
|
||||
@@ -1138,9 +969,6 @@ function PanelColumn({
|
||||
activity={activity}
|
||||
onClearActivity={onClearActivity}
|
||||
isAdmin={isAdmin}
|
||||
serviceLinks={serviceLinks}
|
||||
onAddServiceLink={onAddServiceLink}
|
||||
onDeleteServiceLink={onDeleteServiceLink}
|
||||
statusLoading={statusLoading}
|
||||
isVisible={isVisible}
|
||||
/>
|
||||
@@ -1237,26 +1065,23 @@ export function DashboardTab({
|
||||
return () => window.removeEventListener("dashboardSlotsChanged", handler);
|
||||
}, []);
|
||||
|
||||
const [homepageLinkCopied, setHomepageLinkCopied] = useState(false);
|
||||
// A plugin's own view next to the dashboard, e.g. the homepage plugin's
|
||||
// canvas preview. Only one is offered today; "dashboard" always exists.
|
||||
const secondaryViews = useActionSlot("dashboard.secondaryView");
|
||||
const secondaryView = secondaryViews[0];
|
||||
|
||||
const handleCopyHomepageLink = () => {
|
||||
navigator.clipboard
|
||||
.writeText(`${window.location.origin}?view=homepage`)
|
||||
.catch(() => {});
|
||||
setHomepageLinkCopied(true);
|
||||
setTimeout(() => setHomepageLinkCopied(false), 1500);
|
||||
};
|
||||
|
||||
const [dashboardView, setDashboardView] = useState<"dashboard" | "homepage">(
|
||||
() => {
|
||||
try {
|
||||
return (localStorage.getItem("dashboardView") ?? "dashboard") as
|
||||
"dashboard" | "homepage";
|
||||
} catch {
|
||||
return "dashboard";
|
||||
}
|
||||
},
|
||||
);
|
||||
const [dashboardView, setDashboardView] = useState<string>(() => {
|
||||
try {
|
||||
return localStorage.getItem("dashboardView") ?? "dashboard";
|
||||
} catch {
|
||||
return "dashboard";
|
||||
}
|
||||
});
|
||||
// Falls back to the dashboard when the view a plugin contributed is off.
|
||||
const isDashboardView =
|
||||
dashboardView === "dashboard" ||
|
||||
!secondaryView ||
|
||||
dashboardView !== secondaryView.actionId;
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
@@ -1398,24 +1223,6 @@ export function DashboardTab({
|
||||
}
|
||||
};
|
||||
|
||||
const [serviceLinks, setServiceLinks] = useState<ServiceLink[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getServiceLinks()
|
||||
.then(setServiceLinks)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleAddServiceLink = async (label: string, url: string) => {
|
||||
const created = await createServiceLink(label, url);
|
||||
setServiceLinks((prev) => [...prev, created]);
|
||||
};
|
||||
|
||||
const handleDeleteServiceLink = async (id: number) => {
|
||||
await deleteServiceLink(id);
|
||||
setServiceLinks((prev) => prev.filter((l) => l.id !== id));
|
||||
};
|
||||
|
||||
const todayLabel = new Date().toLocaleDateString(i18n.language, {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
@@ -1438,8 +1245,6 @@ export function DashboardTab({
|
||||
quick_actions: t("dashboard.quickActions"),
|
||||
host_status: t("dashboardTab.hostStatus"),
|
||||
recent_activity: t("dashboard.recentActivity"),
|
||||
service_links: t("dashboard.serviceLinks"),
|
||||
homepage_preview: t("dashboard.homepagePreview"),
|
||||
...Object.fromEntries(
|
||||
registeredCards.map((card) => [card.id, t(card.titleKey)]),
|
||||
),
|
||||
@@ -1514,11 +1319,7 @@ export function DashboardTab({
|
||||
: 0;
|
||||
const defaultHeight: number | null =
|
||||
getRegisteredDashboardCard(id)?.defaultHeight ??
|
||||
(id === "host_status" || id === "recent_activity"
|
||||
? null
|
||||
: id === "service_links"
|
||||
? 200
|
||||
: 150);
|
||||
(id === "host_status" || id === "recent_activity" ? null : 150);
|
||||
const key = `${id}_${Date.now()}`;
|
||||
return [
|
||||
...prev,
|
||||
@@ -1557,9 +1358,6 @@ export function DashboardTab({
|
||||
onOpenTab,
|
||||
cardLabels,
|
||||
isAdmin,
|
||||
serviceLinks,
|
||||
onAddServiceLink: handleAddServiceLink,
|
||||
onDeleteServiceLink: handleDeleteServiceLink,
|
||||
statusLoading,
|
||||
isVisible,
|
||||
};
|
||||
@@ -1705,13 +1503,6 @@ export function DashboardTab({
|
||||
onOpenSingletonTab={onOpenSingletonTab}
|
||||
/>
|
||||
)}
|
||||
{slot.id === "service_links" && (
|
||||
<ServiceLinksCard
|
||||
links={serviceLinks}
|
||||
onAdd={handleAddServiceLink}
|
||||
onDelete={handleDeleteServiceLink}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -1726,18 +1517,20 @@ export function DashboardTab({
|
||||
<div className="flex items-center gap-0 bg-muted/40 border border-border p-0.5">
|
||||
<button
|
||||
onClick={() => setDashboardView("dashboard")}
|
||||
className={`px-3 py-1 text-sm font-medium transition-colors ${dashboardView === "dashboard" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}
|
||||
className={`px-3 py-1 text-sm font-medium transition-colors ${isDashboardView ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
{t("dashboard.title")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDashboardView("homepage")}
|
||||
className={`px-3 py-1 text-sm font-medium transition-colors ${dashboardView === "homepage" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
{t("nav.homepage")}
|
||||
</button>
|
||||
{secondaryView && (
|
||||
<button
|
||||
onClick={() => setDashboardView(secondaryView.actionId)}
|
||||
className={`px-3 py-1 text-sm font-medium transition-colors ${!isDashboardView ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
{t(secondaryView.titleKey)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{dashboardView === "dashboard" && (
|
||||
{isDashboardView && (
|
||||
<p className="text-xs text-muted-foreground hidden sm:block">
|
||||
{todayLabel}
|
||||
</p>
|
||||
@@ -1824,7 +1617,7 @@ export function DashboardTab({
|
||||
{t("dashboard.donate")}
|
||||
</a>
|
||||
</Button>
|
||||
{dashboardView === "dashboard" && (
|
||||
{isDashboardView && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-1 h-5" />
|
||||
{editMode ? (
|
||||
@@ -1860,43 +1653,9 @@ export function DashboardTab({
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{dashboardView === "homepage" ? (
|
||||
{!isDashboardView && secondaryView?.component ? (
|
||||
<div className="flex-1 min-h-0 overflow-hidden mx-5 mb-5 mt-4 border border-border flex flex-col">
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border shrink-0 bg-muted/20">
|
||||
<span className="text-[10px] text-muted-foreground/50 uppercase tracking-widest font-semibold">
|
||||
{t("nav.homepage")}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
|
||||
onClick={handleCopyHomepageLink}
|
||||
>
|
||||
{homepageLinkCopied ? (
|
||||
<>
|
||||
<Check size={10} className="text-accent-brand" />
|
||||
<span className="text-accent-brand">
|
||||
{t("homepage.linkCopied")}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link size={10} />
|
||||
{t("homepage.copyLink")}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
|
||||
onClick={() => onOpenSingletonTab("homepage")}
|
||||
>
|
||||
<ExternalLink size={10} />
|
||||
{t("homepage.openFullView")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<HomepageCanvas />
|
||||
</div>
|
||||
<secondaryView.component onOpenSingletonTab={onOpenSingletonTab} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Card } from "@/components/card";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LayoutGrid } from "lucide-react";
|
||||
import { HomepageCanvas } from "@/features/homepage/HomepageCanvas";
|
||||
|
||||
interface HomepagePreviewCardProps {
|
||||
onOpenFullscreen: () => void;
|
||||
}
|
||||
|
||||
export function HomepagePreviewCard({
|
||||
onOpenFullscreen,
|
||||
}: HomepagePreviewCardProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card className="relative overflow-hidden w-full h-full flex flex-col p-0 gap-0">
|
||||
{/* Card header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 shrink-0 border-b border-border">
|
||||
<LayoutGrid className="size-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("homepage.previewTitle")}
|
||||
</span>
|
||||
<button
|
||||
className="ml-auto text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={onOpenFullscreen}
|
||||
>
|
||||
{t("homepage.openFullView")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Live pannable preview — no pointer-events blocking so panning works */}
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
<HomepageCanvas isReadOnly={true} fitOnLoad={true} />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,8 @@ export interface RegisteredDashboardCard {
|
||||
pluginId?: string;
|
||||
titleKey: string;
|
||||
defaultHeight?: number;
|
||||
/** Which column a preset places this card in by default. Defaults to "main". */
|
||||
defaultPanel?: "main" | "side";
|
||||
component: ComponentType<DashboardCardRenderProps>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import React from "react";
|
||||
import { HomepageCanvas } from "./HomepageCanvas";
|
||||
|
||||
const HomepageApp: React.FC = () => {
|
||||
return (
|
||||
<div className="h-screen w-screen overflow-hidden">
|
||||
<HomepageCanvas fitOnLoad={true} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomepageApp;
|
||||
@@ -1,566 +0,0 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LayoutGrid } from "lucide-react";
|
||||
import type {
|
||||
CanvasWidget,
|
||||
ContextMenuState,
|
||||
WidgetTypeId,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE, MIN_ZOOM, MAX_ZOOM } from "@/types/homepage-types";
|
||||
import {
|
||||
getHomepageItems,
|
||||
getHomepageLayout,
|
||||
createHomepageItem,
|
||||
updateHomepageItem,
|
||||
deleteHomepageItem,
|
||||
saveHomepageLayout,
|
||||
} from "@/api/homepage-api";
|
||||
import { snapToGrid } from "./canvas/snapToGrid";
|
||||
import { screenToCanvas } from "./canvas/canvasGeometry";
|
||||
import { useCanvasInput } from "./canvas/useCanvasInput";
|
||||
import { useBoxDrag } from "./canvas/useBoxDrag";
|
||||
import { useBoxResize } from "./canvas/useBoxResize";
|
||||
import { CanvasDotBackground } from "./canvas/CanvasDotBackground";
|
||||
import { WidgetShell } from "./widgets/WidgetShell";
|
||||
import { AddWidgetMenu } from "./dialogs/AddWidgetMenu";
|
||||
import { WidgetEditDialog } from "./dialogs/WidgetEditDialog";
|
||||
import { HomepageToolbar } from "./toolbar/HomepageToolbar";
|
||||
import { getWidgetType } from "./widgets/WidgetRegistry";
|
||||
|
||||
// Side-effect imports so widgets register themselves
|
||||
import "./widgets/ServiceLinkWidget";
|
||||
import "./widgets/ClockWidget";
|
||||
import "./widgets/NotesWidget";
|
||||
import "./widgets/BookmarkListWidget";
|
||||
import "./widgets/HostStatusWidget";
|
||||
import "./widgets/FolderWidget";
|
||||
import "./widgets/WeatherWidget";
|
||||
import "./widgets/IframeWidget";
|
||||
import "./widgets/RssFeedWidget";
|
||||
import "./widgets/HostGridWidget";
|
||||
import "./widgets/PingStatusWidget";
|
||||
import "./widgets/RecentActivityWidget";
|
||||
import "./widgets/TermixUptimeWidget";
|
||||
import "./widgets/SystemOverviewWidget";
|
||||
import "./widgets/SshTerminalWidget";
|
||||
import "./widgets/QuickConnectWidget";
|
||||
import "./widgets/CalendarWidget";
|
||||
import "./widgets/CountdownWidget";
|
||||
import "./widgets/SearchBarWidget";
|
||||
import "./widgets/TextBannerWidget";
|
||||
import "./widgets/ImageWidget";
|
||||
import "./widgets/MarkdownNotesWidget";
|
||||
import "./widgets/CustomApiWidget";
|
||||
import "./widgets/ServiceGridWidget";
|
||||
import "./widgets/DashboardLinksWidget";
|
||||
import "./widgets/SearchLinksWidget";
|
||||
import "./widgets/LinkTreeWidget";
|
||||
|
||||
const CANVAS_SIZE = 100_000;
|
||||
const DEFAULT_PAN = { x: CANVAS_SIZE / 2 - 600, y: CANVAS_SIZE / 2 - 400 };
|
||||
const DEFAULT_ZOOM = 1.0;
|
||||
|
||||
interface HomepageCanvasProps {
|
||||
isReadOnly?: boolean;
|
||||
fitOnLoad?: boolean;
|
||||
onOpenFullscreen?: () => void;
|
||||
}
|
||||
|
||||
function nextZOrder(widgets: CanvasWidget[]): number {
|
||||
const nonFolders = widgets.filter((w) => w.typeId !== "folder");
|
||||
return nonFolders.length === 0
|
||||
? 1
|
||||
: Math.max(...nonFolders.map((w) => w.zOrder)) + 1;
|
||||
}
|
||||
|
||||
export function HomepageCanvas({
|
||||
isReadOnly,
|
||||
fitOnLoad,
|
||||
onOpenFullscreen,
|
||||
}: HomepageCanvasProps) {
|
||||
const { t } = useTranslation();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [widgets, setWidgets] = useState<CanvasWidget[]>([]);
|
||||
const [pan, setPan] = useState(DEFAULT_PAN);
|
||||
const [zoom, setZoom] = useState(DEFAULT_ZOOM);
|
||||
const [isLocked, setIsLocked] = useState(
|
||||
() => localStorage.getItem("homepage.locked") === "true",
|
||||
);
|
||||
const [draggingId, setDraggingId] = useState<number | null>(null);
|
||||
const [resizingId, setResizingId] = useState<number | null>(null);
|
||||
const [editingWidget, setEditingWidget] = useState<CanvasWidget | null>(null);
|
||||
const [dialogKey, setDialogKey] = useState(0);
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
|
||||
visible: false,
|
||||
screenX: 0,
|
||||
screenY: 0,
|
||||
canvasX: 0,
|
||||
canvasY: 0,
|
||||
});
|
||||
const [containerSize, setContainerSize] = useState({ w: 800, h: 600 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const panRef = useRef(pan);
|
||||
const zoomRef = useRef(zoom);
|
||||
const widgetsRef = useRef(widgets);
|
||||
panRef.current = pan;
|
||||
zoomRef.current = zoom;
|
||||
widgetsRef.current = widgets;
|
||||
|
||||
const hasFitRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(() => {
|
||||
setContainerSize({ w: el.offsetWidth, h: el.offsetHeight });
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
Promise.all([getHomepageItems(), getHomepageLayout()])
|
||||
.then(([items, layoutRow]) => {
|
||||
if (cancelled) return;
|
||||
const layoutEntries = layoutRow?.layout?.entries ?? [];
|
||||
const merged: CanvasWidget[] = items.map((item) => {
|
||||
const entry = layoutEntries.find((e) => e.itemId === item.id);
|
||||
const typeDef = getWidgetType(item.typeId as WidgetTypeId);
|
||||
return {
|
||||
id: item.id,
|
||||
typeId: item.typeId as WidgetTypeId,
|
||||
title: item.title,
|
||||
config: JSON.parse(item.config || "{}"),
|
||||
x: entry?.x ?? snapToGrid(Math.random() * 600 + 100),
|
||||
y: entry?.y ?? snapToGrid(Math.random() * 400 + 100),
|
||||
w: entry?.w ?? typeDef?.defaultSize.w ?? GRID_SIZE * 8,
|
||||
h: entry?.h ?? typeDef?.defaultSize.h ?? GRID_SIZE * 6,
|
||||
zOrder: entry?.zOrder ?? 0,
|
||||
};
|
||||
});
|
||||
setWidgets(merged);
|
||||
if (layoutRow?.layout?.pan) setPan(layoutRow.layout.pan);
|
||||
if (layoutRow?.layout?.zoom) setZoom(layoutRow.layout.zoom);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const scheduleSave = useCallback(
|
||||
(newWidgets?: CanvasWidget[], newPan?: typeof pan, newZoom?: number) => {
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
const ws = newWidgets ?? widgetsRef.current;
|
||||
const p = newPan ?? panRef.current;
|
||||
const z = newZoom ?? zoomRef.current;
|
||||
saveHomepageLayout({
|
||||
entries: ws.map((w) => ({
|
||||
itemId: w.id,
|
||||
x: w.x,
|
||||
y: w.y,
|
||||
w: w.w,
|
||||
h: w.h,
|
||||
zOrder: w.zOrder,
|
||||
})),
|
||||
pan: p,
|
||||
zoom: z,
|
||||
}).catch(() => {});
|
||||
}, 500);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fitOnLoad || loading || widgets.length === 0 || hasFitRef.current)
|
||||
return;
|
||||
const el = containerRef.current;
|
||||
if (!el || el.offsetWidth === 0 || el.offsetHeight === 0) return;
|
||||
hasFitRef.current = true;
|
||||
const vw = el.offsetWidth;
|
||||
const vh = el.offsetHeight;
|
||||
const minX = Math.min(...widgets.map((w) => w.x));
|
||||
const minY = Math.min(...widgets.map((w) => w.y));
|
||||
const maxX = Math.max(...widgets.map((w) => w.x + w.w));
|
||||
const maxY = Math.max(...widgets.map((w) => w.y + w.h));
|
||||
const contentW = maxX - minX;
|
||||
const contentH = maxY - minY;
|
||||
const PADDING = 48;
|
||||
const fz = Math.min(
|
||||
MAX_ZOOM,
|
||||
Math.max(
|
||||
MIN_ZOOM,
|
||||
Math.min((vw - PADDING * 2) / contentW, (vh - PADDING * 2) / contentH),
|
||||
),
|
||||
);
|
||||
setPan({
|
||||
x: (vw - contentW * fz) / 2 - minX * fz,
|
||||
y: (vh - contentH * fz) / 2 - minY * fz,
|
||||
});
|
||||
setZoom(fz);
|
||||
}, [fitOnLoad, loading, widgets, containerSize]);
|
||||
|
||||
const handlePanChange = useCallback(
|
||||
(newPan: typeof pan) => {
|
||||
setPan(newPan);
|
||||
scheduleSave(undefined, newPan, undefined);
|
||||
},
|
||||
[scheduleSave],
|
||||
);
|
||||
|
||||
const handleZoomChange = useCallback(
|
||||
(newZoom: number, newPan: typeof pan) => {
|
||||
setZoom(newZoom);
|
||||
setPan(newPan);
|
||||
scheduleSave(undefined, newPan, newZoom);
|
||||
},
|
||||
[scheduleSave],
|
||||
);
|
||||
|
||||
const canvasInput = useCanvasInput({
|
||||
isDraggingWidget: draggingId !== null,
|
||||
isResizing: resizingId !== null,
|
||||
isContextMenuVisible: contextMenu.visible,
|
||||
onPanChange: handlePanChange,
|
||||
onZoomChange: handleZoomChange,
|
||||
getPan: () => panRef.current,
|
||||
getZoom: () => zoomRef.current,
|
||||
containerRef,
|
||||
});
|
||||
|
||||
const handleWidgetMove = useCallback(
|
||||
(id: number, x: number, y: number) => {
|
||||
setWidgets((prev) => {
|
||||
const next = prev.map((w) => (w.id === id ? { ...w, x, y } : w));
|
||||
scheduleSave(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[scheduleSave],
|
||||
);
|
||||
|
||||
const handleWidgetResize = useCallback(
|
||||
(id: number, w: number, h: number) => {
|
||||
setWidgets((prev) => {
|
||||
const next = prev.map((widget) =>
|
||||
widget.id === id ? { ...widget, w, h } : widget,
|
||||
);
|
||||
scheduleSave(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[scheduleSave],
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(id: number) => {
|
||||
setDraggingId(id);
|
||||
setWidgets((prev) => {
|
||||
const widget = prev.find((w) => w.id === id);
|
||||
if (!widget || widget.typeId === "folder") return prev;
|
||||
const top = nextZOrder(prev);
|
||||
const next = prev.map((w) => (w.id === id ? { ...w, zOrder: top } : w));
|
||||
scheduleSave(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[scheduleSave],
|
||||
);
|
||||
|
||||
const { startDrag } = useBoxDrag({
|
||||
widgets,
|
||||
zoom,
|
||||
getZoom: () => zoomRef.current,
|
||||
onWidgetMove: handleWidgetMove,
|
||||
onDragStart: handleDragStart,
|
||||
onDragEnd: () => setDraggingId(null),
|
||||
});
|
||||
|
||||
const { startResize } = useBoxResize({
|
||||
widgets,
|
||||
getZoom: () => zoomRef.current,
|
||||
onWidgetResize: handleWidgetResize,
|
||||
onResizeStart: setResizingId,
|
||||
onResizeEnd: () => setResizingId(null),
|
||||
});
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (isLocked || isReadOnly) return;
|
||||
if ((e.target as HTMLElement).closest("[data-widget]")) return;
|
||||
e.preventDefault();
|
||||
const rect = containerRef.current!.getBoundingClientRect();
|
||||
const sx = e.clientX - rect.left;
|
||||
const sy = e.clientY - rect.top;
|
||||
const { x, y } = screenToCanvas(sx, sy, panRef.current, zoomRef.current);
|
||||
setContextMenu({
|
||||
visible: true,
|
||||
screenX: e.clientX,
|
||||
screenY: e.clientY,
|
||||
canvasX: snapToGrid(x),
|
||||
canvasY: snapToGrid(y),
|
||||
});
|
||||
},
|
||||
[isLocked, isReadOnly],
|
||||
);
|
||||
|
||||
const handleAddWidget = useCallback(
|
||||
async (typeId: WidgetTypeId, canvasX: number, canvasY: number) => {
|
||||
const typeDef = getWidgetType(typeId);
|
||||
if (!typeDef) return;
|
||||
try {
|
||||
const item = await createHomepageItem({
|
||||
typeId,
|
||||
config: typeDef.defaultConfig as Record<string, unknown>,
|
||||
});
|
||||
setWidgets((prev) => {
|
||||
const zOrder = typeId === "folder" ? 0 : nextZOrder(prev);
|
||||
const newWidget: CanvasWidget = {
|
||||
id: item.id,
|
||||
typeId,
|
||||
title: item.title,
|
||||
config: typeDef.defaultConfig as Record<string, unknown>,
|
||||
x: canvasX,
|
||||
y: canvasY,
|
||||
w: typeDef.defaultSize.w,
|
||||
h: typeDef.defaultSize.h,
|
||||
zOrder,
|
||||
};
|
||||
const next = [...prev, newWidget];
|
||||
scheduleSave(next);
|
||||
return next;
|
||||
});
|
||||
} catch {}
|
||||
},
|
||||
[scheduleSave],
|
||||
);
|
||||
|
||||
const handleOpenAddMenu = useCallback(
|
||||
(anchorRect: {
|
||||
top: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
right: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}) => {
|
||||
const w = containerSize.w;
|
||||
const h = containerSize.h;
|
||||
const { x, y } = screenToCanvas(
|
||||
w / 2,
|
||||
h / 2,
|
||||
panRef.current,
|
||||
zoomRef.current,
|
||||
);
|
||||
setContextMenu({
|
||||
visible: true,
|
||||
screenX: anchorRect.left,
|
||||
screenY: anchorRect.top,
|
||||
canvasX: snapToGrid(x),
|
||||
canvasY: snapToGrid(y),
|
||||
anchorRect,
|
||||
});
|
||||
},
|
||||
[containerSize],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (id: number) => {
|
||||
try {
|
||||
await deleteHomepageItem(id);
|
||||
setWidgets((prev) => {
|
||||
const next = prev.filter((w) => w.id !== id);
|
||||
scheduleSave(next);
|
||||
return next;
|
||||
});
|
||||
} catch {}
|
||||
},
|
||||
[scheduleSave],
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(id: number) => {
|
||||
const w = widgets.find((x) => x.id === id);
|
||||
if (w) {
|
||||
setEditingWidget(w);
|
||||
setDialogKey((k) => k + 1);
|
||||
}
|
||||
},
|
||||
[widgets],
|
||||
);
|
||||
|
||||
const handleSaveEdit = useCallback(
|
||||
async (
|
||||
id: number,
|
||||
title: string | null,
|
||||
config: Record<string, unknown>,
|
||||
) => {
|
||||
try {
|
||||
await updateHomepageItem(id, { title, config });
|
||||
setWidgets((prev) =>
|
||||
prev.map((w) => (w.id === id ? { ...w, title, config } : w)),
|
||||
);
|
||||
} catch {}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const resetView = useCallback(() => {
|
||||
const ws = widgetsRef.current;
|
||||
const el = containerRef.current;
|
||||
const vw = el?.offsetWidth ?? 800;
|
||||
const vh = el?.offsetHeight ?? 600;
|
||||
|
||||
if (ws.length === 0) {
|
||||
setPan(DEFAULT_PAN);
|
||||
setZoom(DEFAULT_ZOOM);
|
||||
return;
|
||||
}
|
||||
|
||||
const minX = Math.min(...ws.map((w) => w.x));
|
||||
const minY = Math.min(...ws.map((w) => w.y));
|
||||
const maxX = Math.max(...ws.map((w) => w.x + w.w));
|
||||
const maxY = Math.max(...ws.map((w) => w.y + w.h));
|
||||
const contentW = maxX - minX;
|
||||
const contentH = maxY - minY;
|
||||
|
||||
const PADDING = 48;
|
||||
const fitZoom = Math.min(
|
||||
MAX_ZOOM,
|
||||
Math.max(
|
||||
MIN_ZOOM,
|
||||
Math.min((vw - PADDING * 2) / contentW, (vh - PADDING * 2) / contentH),
|
||||
),
|
||||
);
|
||||
|
||||
// Place the content bbox center at the viewport center
|
||||
const newPan = {
|
||||
x: (vw - contentW * fitZoom) / 2 - minX * fitZoom,
|
||||
y: (vh - contentH * fitZoom) / 2 - minY * fitZoom,
|
||||
};
|
||||
|
||||
setZoom(fitZoom);
|
||||
setPan(newPan);
|
||||
scheduleSave(undefined, newPan, fitZoom);
|
||||
}, [scheduleSave]);
|
||||
|
||||
// Folders always render first (lowest z), non-folders sorted by zOrder ascending
|
||||
const sortedWidgets = [...widgets].sort((a, b) => {
|
||||
const aFolder = a.typeId === "folder";
|
||||
const bFolder = b.typeId === "folder";
|
||||
if (aFolder && !bFolder) return -1;
|
||||
if (!aFolder && bFolder) return 1;
|
||||
return a.zOrder - b.zOrder;
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-muted-foreground text-sm">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-full h-full overflow-hidden bg-background"
|
||||
onMouseDown={editingWidget ? undefined : canvasInput.handleMouseDown}
|
||||
onMouseMove={canvasInput.handleMouseMove}
|
||||
onMouseUp={canvasInput.handleMouseUp}
|
||||
onMouseLeave={canvasInput.handleMouseUp}
|
||||
onWheel={canvasInput.handleWheel}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ cursor: draggingId ? "grabbing" : "default" }}
|
||||
>
|
||||
<CanvasDotBackground pan={pan} zoom={zoom} />
|
||||
|
||||
<div
|
||||
className="absolute origin-top-left"
|
||||
style={{
|
||||
width: CANVAS_SIZE,
|
||||
height: CANVAS_SIZE,
|
||||
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
|
||||
willChange: "transform",
|
||||
}}
|
||||
>
|
||||
{sortedWidgets.map((widget) => (
|
||||
<WidgetShell
|
||||
key={widget.id}
|
||||
widget={widget}
|
||||
isLocked={isLocked}
|
||||
isReadOnly={isReadOnly}
|
||||
isDragging={draggingId === widget.id}
|
||||
isResizing={resizingId === widget.id}
|
||||
onStartDrag={startDrag}
|
||||
onStartResize={startResize}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
onConfigUpdate={(config) =>
|
||||
handleSaveEdit(widget.id, widget.title, config)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{widgets.length === 0 && !isReadOnly && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none select-none">
|
||||
<div className="flex items-center gap-2 bg-card border border-border px-3 py-2 shadow-sm">
|
||||
<LayoutGrid className="size-3.5 text-muted-foreground/50 shrink-0" />
|
||||
<span className="text-xs text-muted-foreground/70">
|
||||
{t("homepage.noWidgets")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isReadOnly && onOpenFullscreen && (
|
||||
<button
|
||||
className="absolute top-2 right-2 z-20 text-xs text-muted-foreground hover:text-foreground bg-card/80 border border-border px-2 py-1"
|
||||
onClick={onOpenFullscreen}
|
||||
>
|
||||
{t("homepage.openFullView")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isReadOnly && (
|
||||
<HomepageToolbar
|
||||
zoom={zoom}
|
||||
isLocked={isLocked}
|
||||
pan={pan}
|
||||
containerSize={containerSize}
|
||||
onZoomChange={handleZoomChange}
|
||||
onLockToggle={() =>
|
||||
setIsLocked((v) => {
|
||||
const next = !v;
|
||||
localStorage.setItem("homepage.locked", String(next));
|
||||
return next;
|
||||
})
|
||||
}
|
||||
onResetView={resetView}
|
||||
onAddWidget={handleOpenAddMenu}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AddWidgetMenu
|
||||
state={contextMenu}
|
||||
onAdd={handleAddWidget}
|
||||
onClose={() => setContextMenu((s) => ({ ...s, visible: false }))}
|
||||
/>
|
||||
|
||||
<WidgetEditDialog
|
||||
key={dialogKey}
|
||||
widget={editingWidget}
|
||||
onSave={handleSaveEdit}
|
||||
onClose={() => setEditingWidget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface Props {
|
||||
pan: { x: number; y: number };
|
||||
zoom: number;
|
||||
}
|
||||
|
||||
export function CanvasDotBackground({ pan, zoom }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = canvas.offsetWidth;
|
||||
const h = canvas.offsetHeight;
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// Read dot color from CSS variable for theme support
|
||||
const dotColor =
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--color-muted-foreground")
|
||||
.trim() || "#6b7280";
|
||||
|
||||
// Effective grid spacing in screen pixels
|
||||
const spacing = 30 * zoom;
|
||||
|
||||
// Skip rendering dots that are too close together or too far apart
|
||||
if (spacing < 8 || spacing > 200) return;
|
||||
|
||||
// Offset so dots align with the canvas pan position
|
||||
const offsetX = ((pan.x % spacing) + spacing) % spacing;
|
||||
const offsetY = ((pan.y % spacing) + spacing) % spacing;
|
||||
|
||||
const dotRadius = Math.max(0.8, Math.min(1.5, zoom * 1.2));
|
||||
|
||||
ctx.fillStyle = dotColor;
|
||||
ctx.globalAlpha = 0.35;
|
||||
|
||||
for (let x = offsetX; x < w; x += spacing) {
|
||||
for (let y = offsetY; y < h; y += spacing) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, dotRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
}, [pan, zoom]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 w-full h-full pointer-events-none"
|
||||
style={{ zIndex: 0 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
export function screenToCanvas(
|
||||
screenX: number,
|
||||
screenY: number,
|
||||
pan: { x: number; y: number },
|
||||
zoom: number,
|
||||
): { x: number; y: number } {
|
||||
return {
|
||||
x: (screenX - pan.x) / zoom,
|
||||
y: (screenY - pan.y) / zoom,
|
||||
};
|
||||
}
|
||||
|
||||
export function canvasToScreen(
|
||||
canvasX: number,
|
||||
canvasY: number,
|
||||
pan: { x: number; y: number },
|
||||
zoom: number,
|
||||
): { x: number; y: number } {
|
||||
return {
|
||||
x: canvasX * zoom + pan.x,
|
||||
y: canvasY * zoom + pan.y,
|
||||
};
|
||||
}
|
||||
|
||||
/** Pan value that keeps the canvas point (gridX, gridY) under (mouseX, mouseY) after zoom changes. */
|
||||
export function zoomAroundPoint(
|
||||
mouseX: number,
|
||||
mouseY: number,
|
||||
pan: { x: number; y: number },
|
||||
oldZoom: number,
|
||||
newZoom: number,
|
||||
): { x: number; y: number } {
|
||||
const gridX = (mouseX - pan.x) / oldZoom;
|
||||
const gridY = (mouseY - pan.y) / oldZoom;
|
||||
return {
|
||||
x: mouseX - gridX * newZoom,
|
||||
y: mouseY - gridY * newZoom,
|
||||
};
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
|
||||
export function snapToGrid(value: number): number {
|
||||
return Math.round(value / GRID_SIZE) * GRID_SIZE;
|
||||
}
|
||||
|
||||
export function snapToGridFloor(value: number): number {
|
||||
return Math.floor(value / GRID_SIZE) * GRID_SIZE;
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import type { CanvasWidget, DragState } from "@/types/homepage-types";
|
||||
import { snapToGrid } from "./snapToGrid";
|
||||
|
||||
interface UseBoxDragOptions {
|
||||
widgets: CanvasWidget[];
|
||||
zoom: number;
|
||||
getZoom: () => number;
|
||||
onWidgetMove: (id: number, x: number, y: number) => void;
|
||||
onDragStart: (id: number) => void;
|
||||
onDragEnd: () => void;
|
||||
}
|
||||
|
||||
export function useBoxDrag({
|
||||
widgets,
|
||||
getZoom,
|
||||
onWidgetMove,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
}: UseBoxDragOptions) {
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
|
||||
const startDrag = useCallback(
|
||||
(e: React.MouseEvent, widget: CanvasWidget) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
dragRef.current = {
|
||||
widgetId: widget.id,
|
||||
startMouseX: e.clientX,
|
||||
startMouseY: e.clientY,
|
||||
startWidgetX: widget.x,
|
||||
startWidgetY: widget.y,
|
||||
};
|
||||
onDragStart(widget.id);
|
||||
|
||||
const handleMove = (ev: MouseEvent) => {
|
||||
if (!dragRef.current) return;
|
||||
const zoom = getZoom();
|
||||
const dx = (ev.clientX - dragRef.current.startMouseX) / zoom;
|
||||
const dy = (ev.clientY - dragRef.current.startMouseY) / zoom;
|
||||
const newX = snapToGrid(dragRef.current.startWidgetX + dx);
|
||||
const newY = snapToGrid(dragRef.current.startWidgetY + dy);
|
||||
|
||||
const moving = widgets.find((w) => w.id === dragRef.current!.widgetId);
|
||||
if (!moving) return;
|
||||
|
||||
if (newX !== moving.x || newY !== moving.y) {
|
||||
onWidgetMove(moving.id, newX, newY);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
dragRef.current = null;
|
||||
onDragEnd();
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleUp);
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
},
|
||||
[widgets, getZoom, onWidgetMove, onDragStart, onDragEnd],
|
||||
);
|
||||
|
||||
return { startDrag };
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import {
|
||||
GRID_SIZE,
|
||||
type CanvasWidget,
|
||||
type ResizeState,
|
||||
} from "@/types/homepage-types";
|
||||
import { snapToGrid } from "./snapToGrid";
|
||||
import { getWidgetType } from "../widgets/WidgetRegistry";
|
||||
|
||||
interface UseBoxResizeOptions {
|
||||
widgets: CanvasWidget[];
|
||||
getZoom: () => number;
|
||||
onWidgetResize: (id: number, w: number, h: number) => void;
|
||||
onResizeStart: (id: number) => void;
|
||||
onResizeEnd: () => void;
|
||||
}
|
||||
|
||||
export function useBoxResize({
|
||||
widgets,
|
||||
getZoom,
|
||||
onWidgetResize,
|
||||
onResizeStart,
|
||||
onResizeEnd,
|
||||
}: UseBoxResizeOptions) {
|
||||
const resizeRef = useRef<ResizeState | null>(null);
|
||||
|
||||
const startResize = useCallback(
|
||||
(e: React.MouseEvent, widget: CanvasWidget) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
resizeRef.current = {
|
||||
widgetId: widget.id,
|
||||
startMouseX: e.clientX,
|
||||
startMouseY: e.clientY,
|
||||
startW: widget.w,
|
||||
startH: widget.h,
|
||||
};
|
||||
onResizeStart(widget.id);
|
||||
|
||||
const handleMove = (ev: MouseEvent) => {
|
||||
if (!resizeRef.current) return;
|
||||
const zoom = getZoom();
|
||||
const dx = (ev.clientX - resizeRef.current.startMouseX) / zoom;
|
||||
const dy = (ev.clientY - resizeRef.current.startMouseY) / zoom;
|
||||
const newW = Math.max(
|
||||
GRID_SIZE * 2,
|
||||
snapToGrid(resizeRef.current.startW + dx),
|
||||
);
|
||||
const newH = Math.max(
|
||||
GRID_SIZE * 2,
|
||||
snapToGrid(resizeRef.current.startH + dy),
|
||||
);
|
||||
|
||||
const moving = widgets.find(
|
||||
(w) => w.id === resizeRef.current!.widgetId,
|
||||
);
|
||||
if (!moving) return;
|
||||
|
||||
const typeDef = getWidgetType(moving.typeId);
|
||||
const minW = typeDef?.minSize.w ?? GRID_SIZE * 4;
|
||||
const minH = typeDef?.minSize.h ?? GRID_SIZE * 4;
|
||||
const clampedW = Math.max(minW, newW);
|
||||
const clampedH = Math.max(minH, newH);
|
||||
|
||||
if (clampedW !== moving.w || clampedH !== moving.h) {
|
||||
onWidgetResize(moving.id, clampedW, clampedH);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
resizeRef.current = null;
|
||||
onResizeEnd();
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleUp);
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleUp);
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
},
|
||||
[widgets, getZoom, onWidgetResize, onResizeStart, onResizeEnd],
|
||||
);
|
||||
|
||||
return { startResize };
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import { MIN_ZOOM, MAX_ZOOM } from "@/types/homepage-types";
|
||||
import { zoomAroundPoint } from "./canvasGeometry";
|
||||
|
||||
interface UseCanvasInputOptions {
|
||||
isDraggingWidget: boolean;
|
||||
isResizing: boolean;
|
||||
isContextMenuVisible: boolean;
|
||||
onPanChange: (pan: { x: number; y: number }) => void;
|
||||
onZoomChange: (zoom: number, pan: { x: number; y: number }) => void;
|
||||
getPan: () => { x: number; y: number };
|
||||
getZoom: () => number;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export function useCanvasInput({
|
||||
isDraggingWidget,
|
||||
isResizing,
|
||||
isContextMenuVisible,
|
||||
onPanChange,
|
||||
onZoomChange,
|
||||
getPan,
|
||||
getZoom,
|
||||
containerRef,
|
||||
}: UseCanvasInputOptions) {
|
||||
const isPanningRef = useRef(false);
|
||||
const lastMouseRef = useRef({ x: 0, y: 0 });
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Only pan on the background (not on widgets), left button
|
||||
if (e.button !== 0) return;
|
||||
if ((e.target as HTMLElement).closest("[data-widget]")) return;
|
||||
if (isDraggingWidget || isResizing || isContextMenuVisible) return;
|
||||
|
||||
isPanningRef.current = true;
|
||||
lastMouseRef.current = { x: e.clientX, y: e.clientY };
|
||||
e.preventDefault();
|
||||
},
|
||||
[isDraggingWidget, isResizing, isContextMenuVisible],
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!isPanningRef.current) return;
|
||||
const dx = e.clientX - lastMouseRef.current.x;
|
||||
const dy = e.clientY - lastMouseRef.current.y;
|
||||
lastMouseRef.current = { x: e.clientX, y: e.clientY };
|
||||
const pan = getPan();
|
||||
onPanChange({ x: pan.x + dx, y: pan.y + dy });
|
||||
},
|
||||
[getPan, onPanChange],
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
isPanningRef.current = false;
|
||||
}, []);
|
||||
|
||||
const handleWheel = useCallback(
|
||||
(e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const zoom = getZoom();
|
||||
const pan = getPan();
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
|
||||
// Multiplicative zoom so each step is proportional regardless of current zoom level.
|
||||
// Normalize deltaY to avoid trackpad sending huge values.
|
||||
const rawDelta = Math.sign(e.deltaY) * Math.min(Math.abs(e.deltaY), 100);
|
||||
const factor = 1 - rawDelta * 0.001;
|
||||
const newZoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom * factor));
|
||||
if (newZoom === zoom) return;
|
||||
|
||||
const newPan = zoomAroundPoint(mouseX, mouseY, pan, zoom, newZoom);
|
||||
onZoomChange(newZoom, newPan);
|
||||
},
|
||||
[getZoom, getPan, containerRef, onZoomChange],
|
||||
);
|
||||
|
||||
return { handleMouseDown, handleMouseMove, handleMouseUp, handleWheel };
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
/** IANA zone names use underscores, but people type and paste spaces. */
|
||||
export function normalizeTimezone(timezone: string): string {
|
||||
return timezone.trim().replace(/\s+/g, "_");
|
||||
}
|
||||
|
||||
export function isValidTimezone(timezone: string): boolean {
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-US", { timeZone: timezone });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes the timezone a user typed and reports whether it is usable.
|
||||
* A blank field is valid: it means "use local time".
|
||||
*/
|
||||
export function validateClockTimezone(timezone: unknown): {
|
||||
timezone?: string;
|
||||
valid: boolean;
|
||||
} {
|
||||
if (typeof timezone !== "string" || timezone.trim() === "") {
|
||||
return { timezone: undefined, valid: true };
|
||||
}
|
||||
const normalized = normalizeTimezone(timezone);
|
||||
return { timezone: normalized, valid: isValidTimezone(normalized) };
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useWidgetTypes } from "../widgets/WidgetRegistry";
|
||||
import type { ContextMenuState, WidgetTypeId } from "@/types/homepage-types";
|
||||
|
||||
interface AddWidgetMenuProps {
|
||||
state: ContextMenuState;
|
||||
onAdd: (typeId: WidgetTypeId, canvasX: number, canvasY: number) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const CATEGORY_ORDER = ["links", "info", "system", "monitoring"] as const;
|
||||
|
||||
const MARGIN = 8;
|
||||
|
||||
export function AddWidgetMenu({ state, onAdd, onClose }: AddWidgetMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const allTypes = useWidgetTypes();
|
||||
const [pos, setPos] = useState({ left: state.screenX, top: state.screenY });
|
||||
const [selectedCat, setSelectedCat] = useState<
|
||||
(typeof CATEGORY_ORDER)[number]
|
||||
>(CATEGORY_ORDER[0]);
|
||||
const categoryLabels: Record<(typeof CATEGORY_ORDER)[number], string> = {
|
||||
links: t("homepage.categoryLinks"),
|
||||
info: t("homepage.categoryInfo"),
|
||||
system: t("homepage.categorySystem"),
|
||||
monitoring: t("homepage.categoryMonitoring"),
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!state.visible || !menuRef.current) return;
|
||||
const rect = menuRef.current.getBoundingClientRect();
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
|
||||
let left = state.screenX;
|
||||
let top = state.screenY;
|
||||
|
||||
if (state.anchorRect) {
|
||||
left = state.anchorRect.right - rect.width;
|
||||
top = state.anchorRect.top - rect.height - MARGIN;
|
||||
}
|
||||
|
||||
if (left + rect.width > vw - MARGIN) left = vw - rect.width - MARGIN;
|
||||
if (left < MARGIN) left = MARGIN;
|
||||
|
||||
if (top < MARGIN) {
|
||||
top = state.anchorRect
|
||||
? state.anchorRect.bottom + MARGIN
|
||||
: state.screenY + MARGIN;
|
||||
}
|
||||
if (top + rect.height > vh - MARGIN) top = vh - rect.height - MARGIN;
|
||||
if (top < MARGIN) top = MARGIN;
|
||||
|
||||
setPos({ left, top });
|
||||
}, [state.visible, state.screenX, state.screenY, state.anchorRect]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
const visibleCategories = CATEGORY_ORDER.filter((cat) =>
|
||||
allTypes.some((type) => type.category === cat),
|
||||
);
|
||||
|
||||
const selectedTypes = allTypes.filter(
|
||||
(type) => type.category === selectedCat,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-50 bg-card border border-border shadow-xl min-w-[220px] flex flex-col"
|
||||
style={{
|
||||
left: pos.left,
|
||||
top: pos.top,
|
||||
visibility: state.visible ? "visible" : "hidden",
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-3 py-2 border-b border-border shrink-0">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-accent-brand">
|
||||
{t("homepage.addWidget")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Category tabs */}
|
||||
<div className="flex items-center gap-0.5 px-2 pt-2 pb-1 shrink-0">
|
||||
{visibleCategories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setSelectedCat(cat)}
|
||||
className={`px-2.5 py-1 text-[10px] font-semibold uppercase tracking-wider transition-colors ${
|
||||
selectedCat === cat
|
||||
? "bg-accent-brand text-white"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
|
||||
}`}
|
||||
>
|
||||
{categoryLabels[cat]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Widget list */}
|
||||
<div className="overflow-y-auto" style={{ maxHeight: "240px" }}>
|
||||
{selectedTypes.length === 0 ? (
|
||||
<div className="px-3 py-4 text-center text-xs text-muted-foreground/60">
|
||||
No widgets in this category
|
||||
</div>
|
||||
) : (
|
||||
selectedTypes.map((type) => (
|
||||
<button
|
||||
key={type.id}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-left text-xs text-foreground hover:bg-muted/60 transition-colors"
|
||||
onClick={() => {
|
||||
onAdd(type.id, state.canvasX, state.canvasY);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<span className="text-accent-brand shrink-0">{type.icon}</span>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="font-medium truncate">{type.name}</span>
|
||||
<span className="text-[10px] text-muted-foreground truncate">
|
||||
{type.description}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import { Button } from "@/components/button";
|
||||
import { Trash2, Plus } from "lucide-react";
|
||||
import type {
|
||||
BookmarkListConfig,
|
||||
BookmarkLink,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function BookmarkListEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<BookmarkListConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { links } = config;
|
||||
|
||||
const updateLink = (i: number, patch: Partial<BookmarkLink>) => {
|
||||
const next = links.map((l, idx) => (idx === i ? { ...l, ...patch } : l));
|
||||
onChange({ ...config, links: next });
|
||||
};
|
||||
|
||||
const removeLink = (i: number) => {
|
||||
onChange({ ...config, links: links.filter((_, idx) => idx !== i) });
|
||||
};
|
||||
|
||||
const addLink = () => {
|
||||
onChange({ ...config, links: [...links, { label: "", url: "" }] });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 max-h-60 overflow-y-auto pr-1">
|
||||
{links.map((link, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<Input
|
||||
value={link.label}
|
||||
onChange={(e) => updateLink(i, { label: e.target.value })}
|
||||
placeholder={t("homepage.linkLabel")}
|
||||
className="h-7 text-xs flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={link.url}
|
||||
onChange={(e) => updateLink(i, { url: e.target.value })}
|
||||
placeholder="https://..."
|
||||
className="h-7 text-xs flex-1"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeLink(i)}
|
||||
className="p-1 hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addLink}
|
||||
className="mt-1 h-7 text-xs gap-1"
|
||||
>
|
||||
<Plus size={11} /> {t("homepage.addLink")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import type {
|
||||
CalendarConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function CalendarEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<CalendarConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.timezone")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.timezone ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, timezone: e.target.value || undefined })
|
||||
}
|
||||
placeholder="UTC"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.startOnMonday}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, startOnMonday: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.startOnMonday")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import { Switch } from "@/components/switch";
|
||||
import type { ClockConfig, WidgetEditFormProps } from "@/types/homepage-types";
|
||||
import { validateClockTimezone } from "../clock-timezone";
|
||||
|
||||
export function ClockEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<ClockConfig>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const invalidTimezone = !validateClockTimezone(config.timezone).valid;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.timezone")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.timezone ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, timezone: e.target.value || undefined })
|
||||
}
|
||||
placeholder="America/New_York (leave blank for local)"
|
||||
aria-invalid={invalidTimezone}
|
||||
className={
|
||||
invalidTimezone ? "h-8 text-sm border-destructive" : "h-8 text-sm"
|
||||
}
|
||||
/>
|
||||
{invalidTimezone && (
|
||||
<p className="text-[10px] text-destructive">
|
||||
{t("homepage.invalidTimezone")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-foreground">
|
||||
{t("homepage.showSeconds")}
|
||||
</label>
|
||||
<Switch
|
||||
checked={config.showSeconds}
|
||||
onCheckedChange={(v) => onChange({ ...config, showSeconds: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-foreground">
|
||||
{t("homepage.format12h")}
|
||||
</label>
|
||||
<Switch
|
||||
checked={config.format === "12h"}
|
||||
onCheckedChange={(v) =>
|
||||
onChange({ ...config, format: v ? "12h" : "24h" })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import type {
|
||||
CountdownConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function CountdownEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<CountdownConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.countdownLabel")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.label}
|
||||
onChange={(e) => onChange({ ...config, label: e.target.value })}
|
||||
placeholder={t("homepage.countdownLabelPlaceholder")}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.targetDate")}
|
||||
</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={config.targetDate}
|
||||
onChange={(e) => onChange({ ...config, targetDate: e.target.value })}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showDays}
|
||||
onChange={(e) => onChange({ ...config, showDays: e.target.checked })}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.countdownShowDays")}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showHours}
|
||||
onChange={(e) => onChange({ ...config, showHours: e.target.checked })}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.countdownShowHours")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import type {
|
||||
CustomApiConfig,
|
||||
CustomApiDisplayMode,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
const MODES: { id: CustomApiDisplayMode; label: string }[] = [
|
||||
{ id: "value", label: "Value" },
|
||||
{ id: "json", label: "JSON" },
|
||||
{ id: "table", label: "Table" },
|
||||
];
|
||||
|
||||
export function CustomApiEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<CustomApiConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.url")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.url}
|
||||
onChange={(e) => onChange({ ...config, url: e.target.value })}
|
||||
placeholder="https://api.example.com/data"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.displayMode")}
|
||||
</label>
|
||||
<div className="flex gap-1">
|
||||
{MODES.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...config, displayMode: m.id })}
|
||||
className={`px-2 py-0.5 text-[10px] border transition-colors ${config.displayMode === m.id ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.displayMode === "value" && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.displayField")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.displayField ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
displayField: e.target.value || undefined,
|
||||
})
|
||||
}
|
||||
placeholder="data.temperature"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col gap-1 flex-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.customApiLabel")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.label ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, label: e.target.value || undefined })
|
||||
}
|
||||
placeholder={t("homepage.customApiLabelPlaceholder")}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 w-20">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.customApiUnit")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.unit ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, unit: e.target.value || undefined })
|
||||
}
|
||||
placeholder="°C"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{config.displayMode === "table" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.jsonPath")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.jsonPath ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, jsonPath: e.target.value || undefined })
|
||||
}
|
||||
placeholder="data.items"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.refreshInterval")} ({t("homepage.seconds")})
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={10}
|
||||
max={86400}
|
||||
value={config.refreshInterval}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
refreshInterval: Math.max(10, Number(e.target.value)),
|
||||
})
|
||||
}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import type {
|
||||
DashboardLinksConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function DashboardLinksEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<DashboardLinksConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.columns")}
|
||||
</label>
|
||||
<div className="flex gap-1">
|
||||
{([1, 2, 3] as const).map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...config, columns: c })}
|
||||
className={`px-3 py-0.5 text-[10px] border transition-colors ${config.columns === c ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.maxItems")}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={config.maxItems ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
maxItems: e.target.value ? Number(e.target.value) : undefined,
|
||||
})
|
||||
}
|
||||
placeholder={t("homepage.noLimit")}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showIcons}
|
||||
onChange={(e) => onChange({ ...config, showIcons: e.target.checked })}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.showImage")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import type { FolderConfig, WidgetEditFormProps } from "@/types/homepage-types";
|
||||
|
||||
function getAccentColor(): string {
|
||||
return (
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145"
|
||||
);
|
||||
}
|
||||
|
||||
export function FolderEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<FolderConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const accent = getAccentColor();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.color")}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={config.color || accent}
|
||||
onChange={(e) => onChange({ ...config, color: e.target.value })}
|
||||
className="w-8 h-8 border border-border cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
value={config.color ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, color: e.target.value || undefined })
|
||||
}
|
||||
placeholder={accent}
|
||||
className="h-8 text-sm flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
HostGridConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
|
||||
export function HostGridEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<HostGridConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const [hosts, setHosts] = useState<{ id: number; name: string }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getSSHHosts()
|
||||
.then((h) => setHosts(h.map((x) => ({ id: x.id, name: x.name }))))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const toggleHost = (id: number) => {
|
||||
const next = config.hostIds.includes(id)
|
||||
? config.hostIds.filter((x) => x !== id)
|
||||
: [...config.hostIds, id];
|
||||
onChange({ ...config, hostIds: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.hostGridHosts")}
|
||||
</label>
|
||||
<div className="flex flex-col gap-0.5 max-h-40 overflow-y-auto border border-border/40 p-1">
|
||||
{hosts.length === 0 && (
|
||||
<span className="text-[10px] text-muted-foreground p-1">
|
||||
{t("homepage.noHosts")}
|
||||
</span>
|
||||
)}
|
||||
{hosts.map((h) => (
|
||||
<label
|
||||
key={h.id}
|
||||
className="flex items-center gap-2 text-[10px] cursor-pointer px-1 py-0.5 hover:bg-muted/40"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.hostIds.includes(h.id)}
|
||||
onChange={() => toggleHost(h.id)}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{h.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[9px] text-muted-foreground">
|
||||
{t("homepage.hostGridAllHint")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.columns")}
|
||||
</label>
|
||||
<div className="flex gap-1">
|
||||
{([2, 3, 4] as const).map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...config, columns: c })}
|
||||
className={`px-3 py-0.5 text-[10px] font-medium border transition-colors ${config.columns === c ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showIp}
|
||||
onChange={(e) => onChange({ ...config, showIp: e.target.checked })}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.showIp")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import type {
|
||||
HostMetricKey,
|
||||
HostStatusConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { Select2 } from "@/components/select2";
|
||||
|
||||
interface SimpleHost {
|
||||
id: string;
|
||||
name: string;
|
||||
metricsEnabled: boolean;
|
||||
}
|
||||
|
||||
const METRIC_OPTIONS: { key: HostMetricKey; labelKey: string }[] = [
|
||||
{ key: "cpu", labelKey: "homepage.metricCpu" },
|
||||
{ key: "memory", labelKey: "homepage.metricMemory" },
|
||||
{ key: "disk", labelKey: "homepage.metricDisk" },
|
||||
{ key: "uptime", labelKey: "homepage.metricUptime" },
|
||||
{ key: "system", labelKey: "homepage.metricSystem" },
|
||||
{ key: "network", labelKey: "homepage.metricNetwork" },
|
||||
{ key: "processes", labelKey: "homepage.metricProcesses" },
|
||||
];
|
||||
|
||||
export function HostStatusEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<HostStatusConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const [hosts, setHosts] = useState<SimpleHost[]>([]);
|
||||
|
||||
const shownMetrics: HostMetricKey[] = config.shownMetrics?.length
|
||||
? config.shownMetrics
|
||||
: config.showMetrics !== false
|
||||
? ["cpu", "memory", ...(config.showDisk ? ["disk" as HostMetricKey] : [])]
|
||||
: [];
|
||||
|
||||
useEffect(() => {
|
||||
getSSHHosts()
|
||||
.then((all) =>
|
||||
setHosts(
|
||||
all
|
||||
.filter((x) => x.statusCheckEnabled !== false)
|
||||
.map((x) => ({
|
||||
id: String(x.id),
|
||||
name: x.name,
|
||||
metricsEnabled: true,
|
||||
})),
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
function toggleMetric(key: HostMetricKey) {
|
||||
const next = shownMetrics.includes(key)
|
||||
? shownMetrics.filter((k) => k !== key)
|
||||
: [...shownMetrics, key];
|
||||
onChange({ ...config, shownMetrics: next });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.host")}
|
||||
</label>
|
||||
<Select2
|
||||
className="h-8 border border-border bg-background text-sm px-2"
|
||||
value={config.hostId || ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, hostId: parseInt(e.target.value) || 0 })
|
||||
}
|
||||
>
|
||||
<option value="">{t("homepage.selectHost")}</option>
|
||||
{hosts.map((h) => (
|
||||
<option key={h.id} value={h.id}>
|
||||
{h.name}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.displayedMetrics")}
|
||||
</label>
|
||||
<div className="flex flex-col gap-1">
|
||||
{METRIC_OPTIONS.map(({ key, labelKey }) => (
|
||||
<label
|
||||
key={key}
|
||||
className="flex items-center gap-2 cursor-pointer select-none"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={shownMetrics.includes(key)}
|
||||
onChange={() => toggleMetric(key)}
|
||||
className="w-3.5 h-3.5 accent-primary"
|
||||
/>
|
||||
<span className="text-xs text-foreground">{t(labelKey)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import { Switch } from "@/components/switch";
|
||||
import type { IframeConfig, WidgetEditFormProps } from "@/types/homepage-types";
|
||||
|
||||
export function IframeEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<IframeConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.url")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.url}
|
||||
onChange={(e) => onChange({ ...config, url: e.target.value })}
|
||||
placeholder="https://example.com"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-foreground">
|
||||
{t("homepage.scrolling")}
|
||||
</label>
|
||||
<Switch
|
||||
checked={config.scrolling}
|
||||
onCheckedChange={(v) => onChange({ ...config, scrolling: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import type {
|
||||
ImageWidgetConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function ImageWidgetEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<ImageWidgetConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.imageUrl")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.imageUrl}
|
||||
onChange={(e) => onChange({ ...config, imageUrl: e.target.value })}
|
||||
placeholder="https://example.com/image.png"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.imageLinkUrl")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.linkUrl ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, linkUrl: e.target.value || undefined })
|
||||
}
|
||||
placeholder="https://example.com"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.imageFit")}
|
||||
</label>
|
||||
<div className="flex gap-1">
|
||||
{(["contain", "cover", "fill"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...config, fit: f })}
|
||||
className={`px-2 py-0.5 text-[10px] border transition-colors capitalize ${config.fit === f ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.altText")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.alt ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, alt: e.target.value || undefined })
|
||||
}
|
||||
placeholder={t("homepage.altTextPlaceholder")}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Trash2, Plus } from "lucide-react";
|
||||
import { Input } from "@/components/input";
|
||||
import { Button } from "@/components/button";
|
||||
import type {
|
||||
LinkTreeConfig,
|
||||
LinkTreeSection,
|
||||
LinkTreeLink,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function LinkTreeEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<LinkTreeConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { sections, compact } = config;
|
||||
|
||||
const updateSection = (si: number, patch: Partial<LinkTreeSection>) => {
|
||||
onChange({
|
||||
...config,
|
||||
sections: sections.map((s, i) => (i === si ? { ...s, ...patch } : s)),
|
||||
});
|
||||
};
|
||||
const removeSection = (si: number) =>
|
||||
onChange({ ...config, sections: sections.filter((_, i) => i !== si) });
|
||||
const addSection = () =>
|
||||
onChange({
|
||||
...config,
|
||||
sections: [...sections, { heading: "", links: [] }],
|
||||
});
|
||||
|
||||
const updateLink = (si: number, li: number, patch: Partial<LinkTreeLink>) => {
|
||||
const newSections = sections.map((s, i) => {
|
||||
if (i !== si) return s;
|
||||
return {
|
||||
...s,
|
||||
links: s.links.map((l, j) => (j === li ? { ...l, ...patch } : l)),
|
||||
};
|
||||
});
|
||||
onChange({ ...config, sections: newSections });
|
||||
};
|
||||
const removeLink = (si: number, li: number) => {
|
||||
const newSections = sections.map((s, i) =>
|
||||
i !== si ? s : { ...s, links: s.links.filter((_, j) => j !== li) },
|
||||
);
|
||||
onChange({ ...config, sections: newSections });
|
||||
};
|
||||
const addLink = (si: number) => {
|
||||
const newSections = sections.map((s, i) =>
|
||||
i !== si ? s : { ...s, links: [...s.links, { label: "", url: "" }] },
|
||||
);
|
||||
onChange({ ...config, sections: newSections });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2 max-h-64 overflow-y-auto pr-1">
|
||||
{sections.map((section, si) => (
|
||||
<div
|
||||
key={si}
|
||||
className="flex flex-col gap-1 border border-border/40 p-2"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
value={section.heading}
|
||||
onChange={(e) => updateSection(si, { heading: e.target.value })}
|
||||
placeholder={t("homepage.sectionHeading")}
|
||||
className="h-6 text-xs flex-1 font-semibold"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeSection(si)}
|
||||
className="p-1 hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 pl-2">
|
||||
{section.links.map((link, li) => (
|
||||
<div key={li} className="flex items-center gap-1">
|
||||
<Input
|
||||
value={link.label}
|
||||
onChange={(e) =>
|
||||
updateLink(si, li, { label: e.target.value })
|
||||
}
|
||||
placeholder={t("homepage.linkLabel")}
|
||||
className="h-6 text-[10px] flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={link.url}
|
||||
onChange={(e) =>
|
||||
updateLink(si, li, { url: e.target.value })
|
||||
}
|
||||
placeholder="https://..."
|
||||
className="h-6 text-[10px] flex-1"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeLink(si, li)}
|
||||
className="p-0.5 hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() => addLink(si)}
|
||||
className="flex items-center gap-0.5 text-[9px] text-accent-brand hover:opacity-80 mt-0.5 self-start"
|
||||
>
|
||||
<Plus size={9} /> {t("homepage.addLink")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addSection}
|
||||
className="mt-1 h-7 text-xs gap-1"
|
||||
>
|
||||
<Plus size={11} /> {t("homepage.addSection")}
|
||||
</Button>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={compact}
|
||||
onChange={(e) => onChange({ ...config, compact: e.target.checked })}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.compactMode")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import { Textarea } from "@/components/textarea";
|
||||
import type {
|
||||
MarkdownNotesConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function MarkdownNotesEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<MarkdownNotesConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.content")}
|
||||
</label>
|
||||
<Textarea
|
||||
value={config.content}
|
||||
onChange={(e) => onChange({ ...config, content: e.target.value })}
|
||||
className="text-sm min-h-[120px] resize-none rounded-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.renderMarkdown}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, renderMarkdown: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.renderMarkdown")}
|
||||
</label>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.backgroundColor")}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={config.backgroundColor ?? "#1e1e2e"}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, backgroundColor: e.target.value })
|
||||
}
|
||||
className="w-8 h-8 border border-border cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
value={config.backgroundColor ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
backgroundColor: e.target.value || undefined,
|
||||
})
|
||||
}
|
||||
placeholder="#1e1e2e"
|
||||
className="h-8 text-sm flex-1"
|
||||
/>
|
||||
{config.backgroundColor && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onChange({ ...config, backgroundColor: undefined })
|
||||
}
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground shrink-0"
|
||||
>
|
||||
{t("homepage.clearColor")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Textarea } from "@/components/textarea";
|
||||
import type { NotesConfig, WidgetEditFormProps } from "@/types/homepage-types";
|
||||
|
||||
export function NotesEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<NotesConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.content")}
|
||||
</label>
|
||||
<Textarea
|
||||
value={config.content}
|
||||
onChange={(e) => onChange({ ...config, content: e.target.value })}
|
||||
placeholder="Write your notes here..."
|
||||
className="text-sm min-h-[120px] resize-none rounded-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Trash2, Plus } from "lucide-react";
|
||||
import { Input } from "@/components/input";
|
||||
import { Button } from "@/components/button";
|
||||
import type {
|
||||
PingStatusConfig,
|
||||
PingUrl,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function PingStatusEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<PingStatusConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { urls, refreshInterval, showLatency } = config;
|
||||
|
||||
const updateUrl = (i: number, patch: Partial<PingUrl>) => {
|
||||
onChange({
|
||||
...config,
|
||||
urls: urls.map((u, idx) => (idx === i ? { ...u, ...patch } : u)),
|
||||
});
|
||||
};
|
||||
const removeUrl = (i: number) =>
|
||||
onChange({ ...config, urls: urls.filter((_, idx) => idx !== i) });
|
||||
const addUrl = () =>
|
||||
onChange({ ...config, urls: [...urls, { label: "", url: "" }] });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2 max-h-48 overflow-y-auto pr-1">
|
||||
{urls.map((u, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<Input
|
||||
value={u.label}
|
||||
onChange={(e) => updateUrl(i, { label: e.target.value })}
|
||||
placeholder={t("homepage.pingLabel")}
|
||||
className="h-7 text-xs flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={u.url}
|
||||
onChange={(e) => updateUrl(i, { url: e.target.value })}
|
||||
placeholder="https://..."
|
||||
className="h-7 text-xs flex-1"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeUrl(i)}
|
||||
className="p-1 hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addUrl}
|
||||
className="mt-1 h-7 text-xs gap-1"
|
||||
>
|
||||
<Plus size={11} /> {t("homepage.addPingUrl")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.refreshInterval")} ({t("homepage.seconds")})
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={10}
|
||||
max={3600}
|
||||
value={refreshInterval}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
refreshInterval: Math.max(10, Number(e.target.value)),
|
||||
})
|
||||
}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showLatency}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, showLatency: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.showLatency")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
QuickConnectConfig,
|
||||
QuickConnectType,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import { useHostActions } from "@/sidebar/host-contributions";
|
||||
import { quickConnectTargets } from "../quick-connect-targets";
|
||||
|
||||
export function QuickConnectEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<QuickConnectConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]);
|
||||
const targets = quickConnectTargets(useHostActions());
|
||||
|
||||
useEffect(() => {
|
||||
getSSHHosts()
|
||||
.then(setHosts)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const toggleHost = (id: number) => {
|
||||
const current = config.hostIds;
|
||||
const ids = current.includes(id)
|
||||
? current.filter((x) => x !== id)
|
||||
: [...current, id];
|
||||
onChange({ ...config, hostIds: ids });
|
||||
};
|
||||
|
||||
const toggleType = (type: QuickConnectType) => {
|
||||
const types = config.connectionTypes.includes(type)
|
||||
? config.connectionTypes.filter((x) => x !== type)
|
||||
: [...config.connectionTypes, type];
|
||||
onChange({ ...config, connectionTypes: types });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.selectHosts")}
|
||||
</label>
|
||||
<div className="flex flex-col gap-1 max-h-36 overflow-y-auto border border-border/30 p-2">
|
||||
{hosts.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
{t("homepage.noHosts")}
|
||||
</span>
|
||||
)}
|
||||
{hosts.map((h) => (
|
||||
<label
|
||||
key={h.id}
|
||||
className="flex items-center gap-2 text-xs cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.hostIds.includes(h.id)}
|
||||
onChange={() => toggleHost(h.id)}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
<span className="truncate">{h.name || h.ip}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground/60">
|
||||
{t("homepage.hostGridAllHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.connectionTypes")}
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{targets.map(({ type, labelKey }) => (
|
||||
<label
|
||||
key={type}
|
||||
className="flex items-center gap-2 text-xs cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.connectionTypes.includes(type)}
|
||||
onChange={() => toggleType(type)}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t(labelKey, type)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.layout")}
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
{(["list", "grid"] as const).map((l) => (
|
||||
<label
|
||||
key={l}
|
||||
className="flex items-center gap-1.5 text-xs cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="layout"
|
||||
value={l}
|
||||
checked={config.layout === l}
|
||||
onChange={() => onChange({ ...config, layout: l })}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{l === "list"
|
||||
? t("homepage.listLayout")
|
||||
: t("homepage.gridLayout")}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showStatus}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, showStatus: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.showStatus")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import { activityFilterTypes } from "../quick-connect-targets";
|
||||
import type {
|
||||
RecentActivityConfig,
|
||||
ActivityType,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function RecentActivityEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<RecentActivityConfig>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const toggleType = (type: ActivityType) => {
|
||||
const next = config.filterTypes.includes(type)
|
||||
? config.filterTypes.filter((x) => x !== type)
|
||||
: [...config.filterTypes, type];
|
||||
onChange({ ...config, filterTypes: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.maxItems")}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
value={config.maxItems}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
maxItems: Math.max(1, Number(e.target.value)),
|
||||
})
|
||||
}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.filterActivityTypes")}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{activityFilterTypes().map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => toggleType(type)}
|
||||
className={`px-2 py-0.5 text-[10px] border transition-colors ${
|
||||
config.filterTypes.includes(type) ||
|
||||
config.filterTypes.length === 0
|
||||
? "bg-accent-brand border-accent-brand text-white"
|
||||
: "border-border text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{type.replace("_", " ")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[9px] text-muted-foreground">
|
||||
{t("homepage.filterTypesHint")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showTimestamp}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, showTimestamp: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.showTimestamp")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import { Switch } from "@/components/switch";
|
||||
import type {
|
||||
RssFeedConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function RssFeedEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<RssFeedConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.feedUrl")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.feedUrl}
|
||||
onChange={(e) => onChange({ ...config, feedUrl: e.target.value })}
|
||||
placeholder="https://example.com/feed.xml"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.maxItems")}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
value={config.maxItems}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
maxItems: Math.max(1, Math.min(50, Number(e.target.value))),
|
||||
})
|
||||
}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-foreground">
|
||||
{t("homepage.showDescription")}
|
||||
</label>
|
||||
<Switch
|
||||
checked={config.showDescription}
|
||||
onCheckedChange={(v) => onChange({ ...config, showDescription: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import type {
|
||||
SearchBarConfig,
|
||||
SearchEngine,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
const ENGINES: { id: SearchEngine; label: string }[] = [
|
||||
{ id: "google", label: "Google" },
|
||||
{ id: "duckduckgo", label: "DuckDuckGo" },
|
||||
{ id: "bing", label: "Bing" },
|
||||
{ id: "custom", label: "Custom" },
|
||||
];
|
||||
|
||||
export function SearchBarEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<SearchBarConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.searchEngine")}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ENGINES.map((e) => (
|
||||
<button
|
||||
key={e.id}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...config, engine: e.id })}
|
||||
className={`px-2 py-0.5 text-[10px] border transition-colors ${config.engine === e.id ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
|
||||
>
|
||||
{e.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.engine === "custom" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.customSearchUrl")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.customUrl ?? ""}
|
||||
onChange={(e) => onChange({ ...config, customUrl: e.target.value })}
|
||||
placeholder="https://example.com/search?q={q}"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.searchPlaceholderLabel")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.placeholder ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, placeholder: e.target.value || undefined })
|
||||
}
|
||||
placeholder={t("homepage.searchPlaceholderHint")}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.openInNewTab}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, openInNewTab: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.openInNewTab")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Trash2, Plus } from "lucide-react";
|
||||
import { Input } from "@/components/input";
|
||||
import { Button } from "@/components/button";
|
||||
import type {
|
||||
SearchLinksConfig,
|
||||
SearchLinkShortcut,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function SearchLinksEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<SearchLinksConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { shortcuts } = config;
|
||||
|
||||
const update = (i: number, patch: Partial<SearchLinkShortcut>) => {
|
||||
onChange({
|
||||
...config,
|
||||
shortcuts: shortcuts.map((s, idx) =>
|
||||
idx === i ? { ...s, ...patch } : s,
|
||||
),
|
||||
});
|
||||
};
|
||||
const remove = (i: number) =>
|
||||
onChange({ ...config, shortcuts: shortcuts.filter((_, idx) => idx !== i) });
|
||||
const add = () =>
|
||||
onChange({
|
||||
...config,
|
||||
shortcuts: [...shortcuts, { label: "", queryTemplate: "" }],
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2 max-h-56 overflow-y-auto pr-1">
|
||||
{shortcuts.map((s, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-col gap-1 border border-border/40 p-2"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
value={s.icon ?? ""}
|
||||
onChange={(e) =>
|
||||
update(i, { icon: e.target.value || undefined })
|
||||
}
|
||||
placeholder="🔍"
|
||||
className="h-6 text-xs w-10 text-center"
|
||||
/>
|
||||
<Input
|
||||
value={s.label}
|
||||
onChange={(e) => update(i, { label: e.target.value })}
|
||||
placeholder={t("homepage.linkLabel")}
|
||||
className="h-6 text-xs flex-1"
|
||||
/>
|
||||
<button
|
||||
onClick={() => remove(i)}
|
||||
className="p-1 hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
value={s.queryTemplate}
|
||||
onChange={(e) => update(i, { queryTemplate: e.target.value })}
|
||||
placeholder="https://example.com/search?q={q}"
|
||||
className="h-6 text-xs"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={add}
|
||||
className="mt-1 h-7 text-xs gap-1"
|
||||
>
|
||||
<Plus size={11} /> {t("homepage.addSearchShortcut")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Trash2, Plus } from "lucide-react";
|
||||
import { Input } from "@/components/input";
|
||||
import { Button } from "@/components/button";
|
||||
import type {
|
||||
ServiceGridConfig,
|
||||
ServiceGridItem,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function ServiceGridEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<ServiceGridConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { services, columns, showLabels, iconSize } = config;
|
||||
|
||||
const updateItem = (i: number, patch: Partial<ServiceGridItem>) => {
|
||||
onChange({
|
||||
...config,
|
||||
services: services.map((s, idx) => (idx === i ? { ...s, ...patch } : s)),
|
||||
});
|
||||
};
|
||||
const removeItem = (i: number) =>
|
||||
onChange({ ...config, services: services.filter((_, idx) => idx !== i) });
|
||||
const addItem = () =>
|
||||
onChange({ ...config, services: [...services, { label: "", url: "" }] });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5 max-h-48 overflow-y-auto pr-1">
|
||||
{services.map((s, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<Input
|
||||
value={s.label}
|
||||
onChange={(e) => updateItem(i, { label: e.target.value })}
|
||||
placeholder={t("homepage.linkLabel")}
|
||||
className="h-7 text-xs flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={s.url}
|
||||
onChange={(e) => updateItem(i, { url: e.target.value })}
|
||||
placeholder="https://..."
|
||||
className="h-7 text-xs flex-1"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeItem(i)}
|
||||
className="p-1 hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addItem}
|
||||
className="mt-1 h-7 text-xs gap-1"
|
||||
>
|
||||
<Plus size={11} /> {t("homepage.addService")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.columns")}
|
||||
</label>
|
||||
<div className="flex gap-1">
|
||||
{([2, 3, 4] as const).map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...config, columns: c })}
|
||||
className={`px-3 py-0.5 text-[10px] border transition-colors ${columns === c ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.iconSize")}
|
||||
</label>
|
||||
<div className="flex gap-1">
|
||||
{(["sm", "md", "lg"] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...config, iconSize: s })}
|
||||
className={`px-2 py-0.5 text-[10px] border transition-colors uppercase ${iconSize === s ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showLabels}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, showLabels: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.showLabels")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import { Switch } from "@/components/switch";
|
||||
import type {
|
||||
ServiceLinkConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
function getAccentColor(): string {
|
||||
return (
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145"
|
||||
);
|
||||
}
|
||||
|
||||
export function ServiceLinkEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<ServiceLinkConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const accent = getAccentColor();
|
||||
const showImage = config.showImage ?? true;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.url")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.url}
|
||||
onChange={(e) => onChange({ ...config, url: e.target.value })}
|
||||
placeholder="https://example.com"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.description")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.description ?? ""}
|
||||
onChange={(e) => onChange({ ...config, description: e.target.value })}
|
||||
placeholder="My service"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-foreground">
|
||||
{t("homepage.showImage")}
|
||||
</label>
|
||||
<Switch
|
||||
checked={showImage}
|
||||
onCheckedChange={(v) => onChange({ ...config, showImage: v })}
|
||||
/>
|
||||
</div>
|
||||
{showImage && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.imageUrl")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.imageUrl ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, imageUrl: e.target.value || undefined })
|
||||
}
|
||||
placeholder="https://example.com/logo.png"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground/60">
|
||||
{t("homepage.imageUrlHint")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.color")}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={config.accentColor || accent}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, accentColor: e.target.value })
|
||||
}
|
||||
className="w-8 h-8 border border-border cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
value={config.accentColor ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, accentColor: e.target.value || undefined })
|
||||
}
|
||||
placeholder={accent}
|
||||
className="h-8 text-sm flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import { Select2 } from "@/components/select2";
|
||||
|
||||
interface SingleHostEditFormProps {
|
||||
hostId: number;
|
||||
onChange: (hostId: number) => void;
|
||||
filter?: (host: SSHHostWithStatus) => boolean;
|
||||
}
|
||||
|
||||
export function SingleHostEditForm({
|
||||
hostId,
|
||||
onChange,
|
||||
filter,
|
||||
}: SingleHostEditFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getSSHHosts()
|
||||
.then((all) => setHosts(filter ? all.filter(filter) : all))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.host")}
|
||||
</label>
|
||||
<Select2
|
||||
value={hostId || ""}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="h-8 text-xs border border-border bg-background px-2"
|
||||
>
|
||||
<option value="">{t("homepage.selectHost")}</option>
|
||||
{hosts.map((h) => (
|
||||
<option key={h.id} value={h.id}>
|
||||
{h.name || h.ip}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
SshTerminalConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { SingleHostEditForm } from "./SingleHostEditForm";
|
||||
|
||||
export function SshTerminalEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<SshTerminalConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<SingleHostEditForm
|
||||
hostId={config.hostId}
|
||||
onChange={(hostId) => onChange({ ...config, hostId })}
|
||||
filter={(h) => !!(h.enableSsh && h.enableTerminal)}
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.autoConnect}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, autoConnect: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.sshTerminalAutoConnect")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
SystemOverviewConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function SystemOverviewEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<SystemOverviewConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showVersion}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, showVersion: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.overviewVersion")}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showDbHealth}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, showDbHealth: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.overviewDatabase")}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showUptime}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, showUptime: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.overviewUptime")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
TermixUptimeConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function TermixUptimeEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<TermixUptimeConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showDetailed}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, showDetailed: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.showSeconds")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import { Textarea } from "@/components/textarea";
|
||||
import type {
|
||||
TextBannerConfig,
|
||||
TextBannerFontSize,
|
||||
TextBannerAlign,
|
||||
TextBannerWeight,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
const SIZES: { id: TextBannerFontSize; label: string }[] = [
|
||||
{ id: "sm", label: "SM" },
|
||||
{ id: "md", label: "MD" },
|
||||
{ id: "lg", label: "LG" },
|
||||
{ id: "xl", label: "XL" },
|
||||
];
|
||||
|
||||
const ALIGNS: { id: TextBannerAlign; label: string }[] = [
|
||||
{ id: "left", label: "Left" },
|
||||
{ id: "center", label: "Center" },
|
||||
{ id: "right", label: "Right" },
|
||||
];
|
||||
|
||||
const WEIGHTS: { id: TextBannerWeight; label: string }[] = [
|
||||
{ id: "normal", label: "Normal" },
|
||||
{ id: "semibold", label: "Semibold" },
|
||||
{ id: "bold", label: "Bold" },
|
||||
];
|
||||
|
||||
export function TextBannerEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<TextBannerConfig>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const ButtonGroup = <T extends string>({
|
||||
opts,
|
||||
value,
|
||||
onSelect,
|
||||
}: {
|
||||
opts: { id: T; label: string }[];
|
||||
value: T;
|
||||
onSelect: (v: T) => void;
|
||||
}) => (
|
||||
<div className="flex gap-1">
|
||||
{opts.map((o) => (
|
||||
<button
|
||||
key={o.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(o.id)}
|
||||
className={`px-2 py-0.5 text-[10px] border transition-colors ${value === o.id ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.content")}
|
||||
</label>
|
||||
<Textarea
|
||||
value={config.text}
|
||||
onChange={(e) => onChange({ ...config, text: e.target.value })}
|
||||
rows={2}
|
||||
className="text-sm min-h-[60px] resize-none rounded-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.fontSize")}
|
||||
</label>
|
||||
<ButtonGroup
|
||||
opts={SIZES}
|
||||
value={config.fontSize}
|
||||
onSelect={(v) => onChange({ ...config, fontSize: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.textAlign")}
|
||||
</label>
|
||||
<ButtonGroup
|
||||
opts={ALIGNS}
|
||||
value={config.textAlign}
|
||||
onSelect={(v) => onChange({ ...config, textAlign: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.fontWeight")}
|
||||
</label>
|
||||
<ButtonGroup
|
||||
opts={WEIGHTS}
|
||||
value={config.fontWeight}
|
||||
onSelect={(v) => onChange({ ...config, fontWeight: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.backgroundColor")}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={config.backgroundColor ?? "#1e1e2e"}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, backgroundColor: e.target.value })
|
||||
}
|
||||
className="w-8 h-8 border border-border cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
value={config.backgroundColor ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
backgroundColor: e.target.value || undefined,
|
||||
})
|
||||
}
|
||||
placeholder="#1e1e2e"
|
||||
className="h-8 text-sm flex-1"
|
||||
/>
|
||||
{config.backgroundColor && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onChange({ ...config, backgroundColor: undefined })
|
||||
}
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground shrink-0"
|
||||
>
|
||||
{t("homepage.clearColor")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import { Switch } from "@/components/switch";
|
||||
import type {
|
||||
WeatherConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function WeatherEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<WeatherConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.location")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.location}
|
||||
onChange={(e) => onChange({ ...config, location: e.target.value })}
|
||||
placeholder="New York, London, Tokyo..."
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.temperatureUnit")}
|
||||
</label>
|
||||
<div className="flex gap-0 border border-border">
|
||||
{(["C", "F"] as const).map((u) => (
|
||||
<button
|
||||
key={u}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...config, unit: u })}
|
||||
className={`flex-1 h-8 text-sm transition-colors ${
|
||||
config.unit === u
|
||||
? "bg-accent-brand text-white"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
|
||||
}`}
|
||||
>
|
||||
°{u}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-foreground">
|
||||
{t("homepage.showForecast")}
|
||||
</label>
|
||||
<Switch
|
||||
checked={config.showForecast}
|
||||
onCheckedChange={(v) => onChange({ ...config, showForecast: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,352 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { getWidgetType } from "../widgets/WidgetRegistry";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/dialog";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import type {
|
||||
CanvasWidget,
|
||||
ServiceLinkConfig,
|
||||
FolderConfig,
|
||||
NotesConfig,
|
||||
BookmarkListConfig,
|
||||
ClockConfig,
|
||||
HostStatusConfig,
|
||||
WeatherConfig,
|
||||
IframeConfig,
|
||||
RssFeedConfig,
|
||||
HostGridConfig,
|
||||
PingStatusConfig,
|
||||
RecentActivityConfig,
|
||||
TermixUptimeConfig,
|
||||
SystemOverviewConfig,
|
||||
SshTerminalConfig,
|
||||
QuickConnectConfig,
|
||||
CalendarConfig,
|
||||
CountdownConfig,
|
||||
SearchBarConfig,
|
||||
TextBannerConfig,
|
||||
ImageWidgetConfig,
|
||||
MarkdownNotesConfig,
|
||||
CustomApiConfig,
|
||||
ServiceGridConfig,
|
||||
DashboardLinksConfig,
|
||||
SearchLinksConfig,
|
||||
LinkTreeConfig,
|
||||
} from "@/types/homepage-types";
|
||||
import { ServiceLinkEditForm } from "./ServiceLinkEditForm";
|
||||
import { FolderEditForm } from "./FolderEditForm";
|
||||
import { NotesEditForm } from "./NotesEditForm";
|
||||
import { BookmarkListEditForm } from "./BookmarkListEditForm";
|
||||
import { ClockEditForm } from "./ClockEditForm";
|
||||
import { HostStatusEditForm } from "./HostStatusEditForm";
|
||||
import { WeatherEditForm } from "./WeatherEditForm";
|
||||
import { IframeEditForm } from "./IframeEditForm";
|
||||
import { RssFeedEditForm } from "./RssFeedEditForm";
|
||||
import { HostGridEditForm } from "./HostGridEditForm";
|
||||
import { PingStatusEditForm } from "./PingStatusEditForm";
|
||||
import { RecentActivityEditForm } from "./RecentActivityEditForm";
|
||||
import { TermixUptimeEditForm } from "./TermixUptimeEditForm";
|
||||
import { SystemOverviewEditForm } from "./SystemOverviewEditForm";
|
||||
import { SshTerminalEditForm } from "./SshTerminalEditForm";
|
||||
import { QuickConnectEditForm } from "./QuickConnectEditForm";
|
||||
import { CalendarEditForm } from "./CalendarEditForm";
|
||||
import { CountdownEditForm } from "./CountdownEditForm";
|
||||
import { SearchBarEditForm } from "./SearchBarEditForm";
|
||||
import { TextBannerEditForm } from "./TextBannerEditForm";
|
||||
import { ImageWidgetEditForm } from "./ImageWidgetEditForm";
|
||||
import { MarkdownNotesEditForm } from "./MarkdownNotesEditForm";
|
||||
import { CustomApiEditForm } from "./CustomApiEditForm";
|
||||
import { ServiceGridEditForm } from "./ServiceGridEditForm";
|
||||
import { DashboardLinksEditForm } from "./DashboardLinksEditForm";
|
||||
import { SearchLinksEditForm } from "./SearchLinksEditForm";
|
||||
import { LinkTreeEditForm } from "./LinkTreeEditForm";
|
||||
import { validateClockTimezone } from "../clock-timezone";
|
||||
|
||||
interface WidgetEditDialogProps {
|
||||
widget: CanvasWidget | null;
|
||||
onSave: (
|
||||
id: number,
|
||||
title: string | null,
|
||||
config: Record<string, unknown>,
|
||||
) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function WidgetEditDialog({
|
||||
widget,
|
||||
onSave,
|
||||
onClose,
|
||||
}: WidgetEditDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [title, setTitle] = useState(widget?.title ?? "");
|
||||
const [config, setConfig] = useState<Record<string, unknown>>(
|
||||
widget?.config ?? {},
|
||||
);
|
||||
|
||||
if (!widget) return null;
|
||||
|
||||
// Configs that need checking before they can be stored. The clock keeps the
|
||||
// normalized zone rather than the raw input, so "America/New York" is filed
|
||||
// as "America/New_York"; the form flags an unusable one and Save stays off.
|
||||
const clockTimezone =
|
||||
widget.typeId === "clock" ? validateClockTimezone(config.timezone) : null;
|
||||
const configInvalid = clockTimezone ? !clockTimezone.valid : false;
|
||||
|
||||
const handleSave = () => {
|
||||
if (configInvalid) return;
|
||||
onSave(
|
||||
widget.id,
|
||||
title || null,
|
||||
clockTimezone ? { ...config, timezone: clockTimezone.timezone } : config,
|
||||
);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const renderForm = () => {
|
||||
switch (widget.typeId) {
|
||||
case "service_link":
|
||||
return (
|
||||
<ServiceLinkEditForm
|
||||
config={config as unknown as ServiceLinkConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "folder":
|
||||
return (
|
||||
<FolderEditForm
|
||||
config={config as unknown as FolderConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "notes":
|
||||
return (
|
||||
<NotesEditForm
|
||||
config={config as unknown as NotesConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "bookmark_list":
|
||||
return (
|
||||
<BookmarkListEditForm
|
||||
config={config as unknown as BookmarkListConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "clock":
|
||||
return (
|
||||
<ClockEditForm
|
||||
config={config as unknown as ClockConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "host_status":
|
||||
return (
|
||||
<HostStatusEditForm
|
||||
config={config as unknown as HostStatusConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "weather":
|
||||
return (
|
||||
<WeatherEditForm
|
||||
config={config as unknown as WeatherConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "iframe_embed":
|
||||
return (
|
||||
<IframeEditForm
|
||||
config={config as unknown as IframeConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "rss_feed":
|
||||
return (
|
||||
<RssFeedEditForm
|
||||
config={config as unknown as RssFeedConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "host_grid":
|
||||
return (
|
||||
<HostGridEditForm
|
||||
config={config as unknown as HostGridConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "ping_status":
|
||||
return (
|
||||
<PingStatusEditForm
|
||||
config={config as unknown as PingStatusConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "recent_activity":
|
||||
return (
|
||||
<RecentActivityEditForm
|
||||
config={config as unknown as RecentActivityConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "termix_uptime":
|
||||
return (
|
||||
<TermixUptimeEditForm
|
||||
config={config as unknown as TermixUptimeConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "system_overview":
|
||||
return (
|
||||
<SystemOverviewEditForm
|
||||
config={config as unknown as SystemOverviewConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "ssh_terminal":
|
||||
return (
|
||||
<SshTerminalEditForm
|
||||
config={config as unknown as SshTerminalConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "quick_connect":
|
||||
return (
|
||||
<QuickConnectEditForm
|
||||
config={config as unknown as QuickConnectConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "calendar":
|
||||
return (
|
||||
<CalendarEditForm
|
||||
config={config as unknown as CalendarConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "countdown":
|
||||
return (
|
||||
<CountdownEditForm
|
||||
config={config as unknown as CountdownConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "search_bar":
|
||||
return (
|
||||
<SearchBarEditForm
|
||||
config={config as unknown as SearchBarConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "text_banner":
|
||||
return (
|
||||
<TextBannerEditForm
|
||||
config={config as unknown as TextBannerConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "image_widget":
|
||||
return (
|
||||
<ImageWidgetEditForm
|
||||
config={config as unknown as ImageWidgetConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "markdown_notes":
|
||||
return (
|
||||
<MarkdownNotesEditForm
|
||||
config={config as unknown as MarkdownNotesConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "custom_api":
|
||||
return (
|
||||
<CustomApiEditForm
|
||||
config={config as unknown as CustomApiConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "service_grid":
|
||||
return (
|
||||
<ServiceGridEditForm
|
||||
config={config as unknown as ServiceGridConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "dashboard_links":
|
||||
return (
|
||||
<DashboardLinksEditForm
|
||||
config={config as unknown as DashboardLinksConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "search_links":
|
||||
return (
|
||||
<SearchLinksEditForm
|
||||
config={config as unknown as SearchLinksConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "link_tree":
|
||||
return (
|
||||
<LinkTreeEditForm
|
||||
config={config as unknown as LinkTreeConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
default: {
|
||||
// Plugin widgets bring their own form.
|
||||
const EditForm = getWidgetType(widget.typeId)?.editFormComponent;
|
||||
return EditForm ? (
|
||||
<EditForm config={config} onChange={setConfig} />
|
||||
) : null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={!!widget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-sm"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("homepage.editWidget")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.title_label")}
|
||||
</label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={t("homepage.widgetTitlePlaceholder")}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{renderForm()}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
{t("homepage.cancel")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={configInvalid}>
|
||||
{t("homepage.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { ComponentType } from "react";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import { sshHostToHost } from "@/sidebar/HostManagerData";
|
||||
import type { HostActionDef } from "@/sidebar/host-contributions";
|
||||
import { listTabTypes } from "@/shell/tab-registry";
|
||||
|
||||
export interface QuickConnectTarget {
|
||||
/** Tab type, and the value stored in a widget's config. */
|
||||
type: string;
|
||||
icon: ComponentType<{ size?: number | string; className?: string }>;
|
||||
/** i18n key, or a literal label from a plugin action. */
|
||||
labelKey: string;
|
||||
enabled: (host: SSHHostWithStatus) => boolean;
|
||||
}
|
||||
|
||||
/** What a quick connect widget can open: the tools plugins registered. */
|
||||
export function quickConnectTargets(
|
||||
actions: HostActionDef[],
|
||||
): QuickConnectTarget[] {
|
||||
const seen = new Set<string>();
|
||||
const fromPlugins: QuickConnectTarget[] = [];
|
||||
for (const action of actions) {
|
||||
if (!action.tabType || seen.has(action.tabType)) continue;
|
||||
seen.add(action.tabType);
|
||||
fromPlugins.push({
|
||||
type: action.tabType,
|
||||
icon: action.icon,
|
||||
labelKey: action.titleKey,
|
||||
enabled: (h) => {
|
||||
try {
|
||||
return action.when(sshHostToHost(h) as Host);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
return fromPlugins;
|
||||
}
|
||||
|
||||
/** Recent-activity types a filter can pick: core ones plus plugin tabs'. */
|
||||
export function activityFilterTypes(): string[] {
|
||||
const types = new Set(["file_manager"]);
|
||||
for (const def of listTabTypes()) {
|
||||
for (const type of def.activityTypes ?? []) types.add(type);
|
||||
}
|
||||
return [...types];
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Lock,
|
||||
LockOpen,
|
||||
Minus,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
PlusCircle,
|
||||
} from "lucide-react";
|
||||
import { MIN_ZOOM, MAX_ZOOM, ZOOM_STEP } from "@/types/homepage-types";
|
||||
import { zoomAroundPoint } from "../canvas/canvasGeometry";
|
||||
|
||||
interface HomepageToolbarProps {
|
||||
zoom: number;
|
||||
isLocked: boolean;
|
||||
pan: { x: number; y: number };
|
||||
containerSize: { w: number; h: number };
|
||||
onZoomChange: (zoom: number, pan: { x: number; y: number }) => void;
|
||||
onLockToggle: () => void;
|
||||
onResetView: () => void;
|
||||
onAddWidget: (anchorRect: {
|
||||
top: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
right: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
function ToolButton({
|
||||
onClick,
|
||||
title,
|
||||
disabled,
|
||||
children,
|
||||
}: {
|
||||
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
title: string;
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-muted/60 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomepageToolbar({
|
||||
zoom,
|
||||
isLocked,
|
||||
pan,
|
||||
containerSize,
|
||||
onZoomChange,
|
||||
onLockToggle,
|
||||
onResetView,
|
||||
onAddWidget,
|
||||
}: HomepageToolbarProps) {
|
||||
const { t } = useTranslation();
|
||||
const cx = containerSize.w / 2;
|
||||
const cy = containerSize.h / 2;
|
||||
|
||||
const zoomIn = () => {
|
||||
const next = Math.min(MAX_ZOOM, parseFloat((zoom + ZOOM_STEP).toFixed(1)));
|
||||
onZoomChange(next, zoomAroundPoint(cx, cy, pan, zoom, next));
|
||||
};
|
||||
const zoomOut = () => {
|
||||
const next = Math.max(MIN_ZOOM, parseFloat((zoom - ZOOM_STEP).toFixed(1)));
|
||||
onZoomChange(next, zoomAroundPoint(cx, cy, pan, zoom, next));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-4 right-4 z-30 flex items-center gap-0 bg-card border border-border shadow-lg">
|
||||
<ToolButton
|
||||
onClick={(e) => onAddWidget(e.currentTarget.getBoundingClientRect())}
|
||||
title={t("homepage.addWidget")}
|
||||
disabled={isLocked}
|
||||
>
|
||||
<PlusCircle size={15} className={isLocked ? "" : "text-accent-brand"} />
|
||||
</ToolButton>
|
||||
|
||||
<div className="w-px h-5 bg-border" />
|
||||
|
||||
<ToolButton
|
||||
onClick={zoomOut}
|
||||
title={t("homepage.zoomOut")}
|
||||
disabled={zoom <= MIN_ZOOM}
|
||||
>
|
||||
<Minus size={13} />
|
||||
</ToolButton>
|
||||
|
||||
<span className="text-[11px] font-mono text-foreground tabular-nums w-10 text-center select-none">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
|
||||
<ToolButton
|
||||
onClick={zoomIn}
|
||||
title={t("homepage.zoomIn")}
|
||||
disabled={zoom >= MAX_ZOOM}
|
||||
>
|
||||
<Plus size={13} />
|
||||
</ToolButton>
|
||||
|
||||
<div className="w-px h-5 bg-border" />
|
||||
|
||||
<ToolButton onClick={onResetView} title={t("homepage.resetView")}>
|
||||
<RotateCcw size={13} />
|
||||
</ToolButton>
|
||||
|
||||
<ToolButton
|
||||
onClick={onLockToggle}
|
||||
title={isLocked ? t("homepage.unlockLayout") : t("homepage.lockLayout")}
|
||||
>
|
||||
{isLocked ? (
|
||||
<Lock size={13} className="text-accent-brand" />
|
||||
) : (
|
||||
<LockOpen size={13} />
|
||||
)}
|
||||
</ToolButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { Bookmark, ExternalLink } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
BookmarkListConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
|
||||
function BookmarkListWidget({
|
||||
config,
|
||||
widget,
|
||||
isReadOnly,
|
||||
}: WidgetComponentProps<BookmarkListConfig>) {
|
||||
const { links } = config;
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col overflow-hidden">
|
||||
{widget.title && (
|
||||
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-border shrink-0">
|
||||
<Bookmark size={11} className="text-accent-brand shrink-0" />
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider truncate">
|
||||
{widget.title}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-auto p-3 gap-1 flex flex-col">
|
||||
{links.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground italic">
|
||||
No bookmarks
|
||||
</span>
|
||||
)}
|
||||
{links.map((link, i) =>
|
||||
isReadOnly ? (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 text-xs text-foreground py-1"
|
||||
>
|
||||
<Bookmark size={10} className="text-muted-foreground shrink-0" />
|
||||
<span className="truncate">{link.label || link.url}</span>
|
||||
</div>
|
||||
) : (
|
||||
<a
|
||||
key={i}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-xs text-foreground hover:text-primary py-1 no-underline group"
|
||||
>
|
||||
<Bookmark size={10} className="text-muted-foreground shrink-0" />
|
||||
<span className="truncate flex-1">{link.label || link.url}</span>
|
||||
<ExternalLink
|
||||
size={8}
|
||||
className="text-muted-foreground/0 group-hover:text-muted-foreground/60 shrink-0"
|
||||
/>
|
||||
</a>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<BookmarkListConfig>({
|
||||
id: "bookmark_list",
|
||||
name: "Bookmarks",
|
||||
description: "A list of quick links",
|
||||
category: "links",
|
||||
icon: <Bookmark size={14} />,
|
||||
defaultConfig: { links: [] },
|
||||
defaultSize: { w: GRID_SIZE * 9, h: GRID_SIZE * 10 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: BookmarkListWidget,
|
||||
});
|
||||
|
||||
export { BookmarkListWidget };
|
||||
@@ -1,164 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { CalendarDays, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
CalendarConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
import { runVisibleInterval } from "../use-visible-interval";
|
||||
|
||||
function getMonthData(year: number, month: number, startOnMonday: boolean) {
|
||||
const firstDay = new Date(year, month, 1).getDay();
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const offset = startOnMonday ? (firstDay === 0 ? 6 : firstDay - 1) : firstDay;
|
||||
return { daysInMonth, offset };
|
||||
}
|
||||
|
||||
function CalendarWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<CalendarConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { startOnMonday } = config;
|
||||
const [now, setNow] = useState(new Date());
|
||||
const [viewYear, setViewYear] = useState(now.getFullYear());
|
||||
const [viewMonth, setViewMonth] = useState(now.getMonth());
|
||||
|
||||
useEffect(() => {
|
||||
return runVisibleInterval(() => setNow(new Date()), 60_000);
|
||||
}, []);
|
||||
|
||||
const { daysInMonth, offset } = getMonthData(
|
||||
viewYear,
|
||||
viewMonth,
|
||||
startOnMonday,
|
||||
);
|
||||
const today = now;
|
||||
const isCurrentMonth =
|
||||
viewYear === today.getFullYear() && viewMonth === today.getMonth();
|
||||
|
||||
const dayNames = startOnMonday
|
||||
? [
|
||||
t("homepage.calMon"),
|
||||
t("homepage.calTue"),
|
||||
t("homepage.calWed"),
|
||||
t("homepage.calThu"),
|
||||
t("homepage.calFri"),
|
||||
t("homepage.calSat"),
|
||||
t("homepage.calSun"),
|
||||
]
|
||||
: [
|
||||
t("homepage.calSun"),
|
||||
t("homepage.calMon"),
|
||||
t("homepage.calTue"),
|
||||
t("homepage.calWed"),
|
||||
t("homepage.calThu"),
|
||||
t("homepage.calFri"),
|
||||
t("homepage.calSat"),
|
||||
];
|
||||
|
||||
const monthLabel = new Intl.DateTimeFormat("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}).format(new Date(viewYear, viewMonth, 1));
|
||||
|
||||
const cells: (number | null)[] = [
|
||||
...Array(offset).fill(null),
|
||||
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
|
||||
];
|
||||
|
||||
const prevMonth = () => {
|
||||
if (viewMonth === 0) {
|
||||
setViewMonth(11);
|
||||
setViewYear((y) => y - 1);
|
||||
} else setViewMonth((m) => m - 1);
|
||||
};
|
||||
const nextMonth = () => {
|
||||
if (viewMonth === 11) {
|
||||
setViewMonth(0);
|
||||
setViewYear((y) => y + 1);
|
||||
} else setViewMonth((m) => m + 1);
|
||||
};
|
||||
|
||||
const accent =
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden select-none">
|
||||
<WidgetTitle title={widget.title} icon={<CalendarDays size={11} />} />
|
||||
<div className="flex flex-col flex-1 p-2 gap-1 overflow-hidden">
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<button
|
||||
onClick={prevMonth}
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ChevronLeft size={12} />
|
||||
</button>
|
||||
<span className="text-[10px] font-semibold text-foreground">
|
||||
{monthLabel}
|
||||
</span>
|
||||
<button
|
||||
onClick={nextMonth}
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ChevronRight size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-0 shrink-0">
|
||||
{dayNames.map((d) => (
|
||||
<div
|
||||
key={d}
|
||||
className="text-center text-[8px] font-semibold text-muted-foreground/70 uppercase py-0.5"
|
||||
>
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-0 flex-1 content-start">
|
||||
{cells.map((day, i) => {
|
||||
if (day === null) return <div key={`e-${i}`} />;
|
||||
const isToday = isCurrentMonth && day === today.getDate();
|
||||
return (
|
||||
<div
|
||||
key={`d-${day}`}
|
||||
className="flex items-center justify-center aspect-square"
|
||||
>
|
||||
<span
|
||||
className="text-[9px] font-medium w-5 h-5 flex items-center justify-center rounded-full"
|
||||
style={
|
||||
isToday
|
||||
? { background: accent, color: "#fff" }
|
||||
: { color: "var(--text-primary, currentColor)" }
|
||||
}
|
||||
>
|
||||
{day}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<CalendarConfig>({
|
||||
id: "calendar",
|
||||
name: "Calendar",
|
||||
description: "A month calendar with today highlighted",
|
||||
category: "info",
|
||||
icon: <CalendarDays size={14} />,
|
||||
defaultConfig: { startOnMonday: true },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 9 },
|
||||
minSize: { w: GRID_SIZE * 7, h: GRID_SIZE * 7 },
|
||||
component: CalendarWidget,
|
||||
});
|
||||
|
||||
export { CalendarWidget };
|
||||
@@ -1,77 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Clock } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import {
|
||||
GRID_SIZE,
|
||||
type ClockConfig,
|
||||
type WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval";
|
||||
import { isValidTimezone } from "../clock-timezone";
|
||||
|
||||
function ClockWidget({ widget, config }: WidgetComponentProps<ClockConfig>) {
|
||||
const { showSeconds, format } = config;
|
||||
// Configs saved before the edit dialog validated timezones can still hold an
|
||||
// unusable one (e.g. "America/New York"), which makes toLocaleTimeString throw.
|
||||
const timezone =
|
||||
config.timezone && isValidTimezone(config.timezone)
|
||||
? config.timezone
|
||||
: undefined;
|
||||
const [now, setNow] = useState(new Date());
|
||||
|
||||
// Minute-level is enough without seconds; pause when the tab is backgrounded.
|
||||
usePageVisibleInterval(
|
||||
() => setNow(new Date()),
|
||||
showSeconds ? 1_000 : 30_000,
|
||||
);
|
||||
|
||||
const opts: Intl.DateTimeFormatOptions = {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
...(showSeconds ? { second: "2-digit" } : {}),
|
||||
hour12: format === "12h",
|
||||
...(timezone ? { timeZone: timezone } : {}),
|
||||
};
|
||||
|
||||
const dateOpts: Intl.DateTimeFormatOptions = {
|
||||
weekday: "long",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
...(timezone ? { timeZone: timezone } : {}),
|
||||
};
|
||||
|
||||
const timeStr = now.toLocaleTimeString(undefined, opts);
|
||||
const dateStr = now.toLocaleDateString(undefined, dateOpts);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden select-none">
|
||||
<WidgetTitle title={widget.title} icon={<Clock size={11} />} />
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-1 p-3 overflow-auto">
|
||||
<span className="text-2xl font-bold tabular-nums text-accent-brand">
|
||||
{timeStr}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{dateStr}</span>
|
||||
{timezone && (
|
||||
<span className="text-[10px] text-muted-foreground/60">
|
||||
{timezone}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<ClockConfig>({
|
||||
id: "clock",
|
||||
name: "Clock",
|
||||
description: "A live clock with configurable timezone",
|
||||
category: "info",
|
||||
icon: <Clock size={14} />,
|
||||
defaultConfig: { showSeconds: true, format: "24h" },
|
||||
defaultSize: { w: GRID_SIZE * 8, h: GRID_SIZE * 5 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: ClockWidget,
|
||||
});
|
||||
|
||||
export { ClockWidget };
|
||||
@@ -1,172 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Timer } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
CountdownConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function getAccentColor(): string {
|
||||
return (
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145"
|
||||
);
|
||||
}
|
||||
|
||||
function decompose(ms: number): {
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
} {
|
||||
const total = Math.max(0, Math.floor(ms / 1000));
|
||||
const days = Math.floor(total / 86400);
|
||||
const hours = Math.floor((total % 86400) / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const seconds = total % 60;
|
||||
return { days, hours, minutes, seconds };
|
||||
}
|
||||
|
||||
function UnitBlock({ value, label }: { value: number; label: string }) {
|
||||
const accent = getAccentColor();
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<span
|
||||
className="text-2xl font-bold tabular-nums leading-none"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
{String(value).padStart(2, "0")}
|
||||
</span>
|
||||
<span className="text-[9px] text-muted-foreground uppercase tracking-wider">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CountdownWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<CountdownConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { targetDate, label, showDays, showHours } = config;
|
||||
const [remaining, setRemaining] = useState(0);
|
||||
const [past, setPast] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const diff = new Date(targetDate).getTime() - Date.now();
|
||||
if (diff <= 0) {
|
||||
setRemaining(0);
|
||||
setPast(true);
|
||||
} else {
|
||||
setRemaining(diff);
|
||||
setPast(false);
|
||||
}
|
||||
};
|
||||
update();
|
||||
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
const start = () => {
|
||||
if (intervalId !== null) return;
|
||||
intervalId = setInterval(update, 1000);
|
||||
};
|
||||
const stop = () => {
|
||||
if (intervalId === null) return;
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
};
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
update();
|
||||
start();
|
||||
};
|
||||
|
||||
if (document.visibilityState !== "hidden") start();
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
return () => {
|
||||
stop();
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, [targetDate]);
|
||||
|
||||
const titleBar = (
|
||||
<WidgetTitle title={widget.title} icon={<Timer size={11} />} />
|
||||
);
|
||||
|
||||
if (!targetDate) {
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
{titleBar}
|
||||
<div className="flex items-center justify-center flex-1 text-xs text-muted-foreground">
|
||||
{t("homepage.countdownNoDate")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (past) {
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
{titleBar}
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-1">
|
||||
<Timer size={16} className="text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("homepage.countdownPast")}
|
||||
</span>
|
||||
{label && (
|
||||
<span className="text-[10px] text-muted-foreground/60">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { days, hours, minutes, seconds } = decompose(remaining);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
{titleBar}
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-2 p-2">
|
||||
{label && (
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-3 flex-wrap justify-center">
|
||||
{showDays && (
|
||||
<UnitBlock value={days} label={t("homepage.countdownDays")} />
|
||||
)}
|
||||
{showHours && (
|
||||
<UnitBlock value={hours} label={t("homepage.countdownHours")} />
|
||||
)}
|
||||
<UnitBlock value={minutes} label={t("homepage.countdownMinutes")} />
|
||||
<UnitBlock value={seconds} label={t("homepage.countdownSeconds")} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<CountdownConfig>({
|
||||
id: "countdown",
|
||||
name: "Countdown",
|
||||
description: "Counts down to a target date and time",
|
||||
category: "info",
|
||||
icon: <Timer size={14} />,
|
||||
defaultConfig: { targetDate: "", label: "", showDays: true, showHours: true },
|
||||
defaultSize: { w: GRID_SIZE * 8, h: GRID_SIZE * 5 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
|
||||
component: CountdownWidget,
|
||||
});
|
||||
|
||||
export { CountdownWidget };
|
||||
@@ -1,209 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Braces } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
CustomApiConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { homepageApi } from "@/main-axios";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
import { runVisibleInterval } from "../use-visible-interval";
|
||||
|
||||
function resolvePath(obj: unknown, path: string): unknown {
|
||||
return path.split(".").reduce((acc: unknown, key) => {
|
||||
if (acc && typeof acc === "object")
|
||||
return (acc as Record<string, unknown>)[key];
|
||||
return undefined;
|
||||
}, obj);
|
||||
}
|
||||
|
||||
function resolveArray(
|
||||
obj: unknown,
|
||||
path: string | undefined,
|
||||
): unknown[] | null {
|
||||
if (!path) {
|
||||
if (Array.isArray(obj)) return obj as unknown[];
|
||||
return null;
|
||||
}
|
||||
const val = resolvePath(obj, path);
|
||||
return Array.isArray(val) ? (val as unknown[]) : null;
|
||||
}
|
||||
|
||||
async function fetchProxy(url: string, ttl: number): Promise<unknown> {
|
||||
const res = await homepageApi.get("/proxy", { params: { url, ttl } });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
function CustomApiWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<CustomApiConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
url,
|
||||
displayField,
|
||||
label,
|
||||
unit,
|
||||
refreshInterval,
|
||||
displayMode,
|
||||
jsonPath,
|
||||
} = config;
|
||||
const [data, setData] = useState<unknown>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const interval = Math.max(10, refreshInterval || 60);
|
||||
|
||||
const fetchData = async () => {
|
||||
if (!url) return;
|
||||
try {
|
||||
const result = await fetchProxy(url, interval);
|
||||
setData(result);
|
||||
setError(false);
|
||||
} catch {
|
||||
setError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
return runVisibleInterval(() => {
|
||||
void fetchData();
|
||||
}, interval * 1000);
|
||||
}, [url, interval]);
|
||||
|
||||
const accent =
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145";
|
||||
|
||||
if (!url) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.customApiNoUrl")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-red-400">
|
||||
{t("homepage.customApiError")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const titleBar = (
|
||||
<WidgetTitle title={widget.title} icon={<Braces size={11} />} />
|
||||
);
|
||||
|
||||
if (displayMode === "value") {
|
||||
const val = displayField ? resolvePath(data, displayField) : data;
|
||||
const display =
|
||||
typeof val === "object" ? JSON.stringify(val) : String(val ?? "—");
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
{titleBar}
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-1 p-2">
|
||||
{label && (
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-wider">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="text-2xl font-bold tabular-nums"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
{display}
|
||||
</span>
|
||||
{unit && (
|
||||
<span className="text-[10px] text-muted-foreground">{unit}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (displayMode === "table") {
|
||||
const arr = resolveArray(data, jsonPath);
|
||||
if (!arr) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.customApiNotArray")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
{titleBar}
|
||||
<div className="flex-1 overflow-auto p-2">
|
||||
<table className="w-full text-[9px]">
|
||||
<tbody>
|
||||
{arr.slice(0, 50).map((row, i) => {
|
||||
if (typeof row !== "object" || row === null) {
|
||||
return (
|
||||
<tr key={i}>
|
||||
<td className="py-0.5 pr-2 text-foreground">
|
||||
{String(row)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<tr key={i} className="border-b border-border/30">
|
||||
{Object.entries(row as Record<string, unknown>)
|
||||
.slice(0, 4)
|
||||
.map(([k, v]) => (
|
||||
<td key={k} className="py-0.5 pr-2">
|
||||
<span className="text-muted-foreground">{k}: </span>
|
||||
<span className="text-foreground">
|
||||
{String(v ?? "")}
|
||||
</span>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// json mode
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
{titleBar}
|
||||
<div className="flex-1 overflow-auto p-2">
|
||||
<pre className="text-[9px] text-foreground whitespace-pre-wrap break-all leading-relaxed font-mono">
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<CustomApiConfig>({
|
||||
id: "custom_api",
|
||||
name: "Custom API",
|
||||
description: "Fetch and display data from any JSON API",
|
||||
category: "info",
|
||||
icon: <Braces size={14} />,
|
||||
defaultConfig: { url: "", refreshInterval: 60, displayMode: "value" },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 7 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
|
||||
component: CustomApiWidget,
|
||||
});
|
||||
|
||||
export { CustomApiWidget };
|
||||
@@ -1,146 +0,0 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { Link, ExternalLink } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
DashboardLinksConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { getServiceLinks, type ServiceLink } from "@/api/dashboard-api";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function getFaviconUrl(url: string): string {
|
||||
try {
|
||||
const domain = new URL(url).hostname;
|
||||
return `https://www.google.com/s2/favicons?sz=32&domain_url=${encodeURIComponent(domain)}`;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function LinkTile({
|
||||
link,
|
||||
showIcons,
|
||||
isReadOnly,
|
||||
}: {
|
||||
link: ServiceLink;
|
||||
showIcons: boolean;
|
||||
isReadOnly?: boolean;
|
||||
}) {
|
||||
const [imgFailed, setImgFailed] = useState(false);
|
||||
const mouseDownRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const faviconUrl = showIcons ? getFaviconUrl(link.url) : null;
|
||||
|
||||
const content = (
|
||||
<div className="group flex items-center gap-2 px-2 py-1.5 hover:bg-muted/40 transition-colors overflow-hidden border-b border-border/30">
|
||||
{showIcons &&
|
||||
(faviconUrl && !imgFailed ? (
|
||||
<img
|
||||
src={faviconUrl}
|
||||
alt=""
|
||||
className="w-4 h-4 shrink-0 object-contain"
|
||||
onError={() => setImgFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<ExternalLink size={12} className="shrink-0 text-muted-foreground" />
|
||||
))}
|
||||
<span className="text-[10px] font-medium text-foreground truncate flex-1">
|
||||
{link.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isReadOnly || !link.url) return content;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block no-underline"
|
||||
onMouseDown={(e) => {
|
||||
mouseDownRef.current = { x: e.clientX, y: e.clientY };
|
||||
}}
|
||||
onClick={(e) => {
|
||||
const s = mouseDownRef.current;
|
||||
if (
|
||||
s &&
|
||||
(Math.abs(e.clientX - s.x) > 4 || Math.abs(e.clientY - s.y) > 4)
|
||||
)
|
||||
e.preventDefault();
|
||||
mouseDownRef.current = null;
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardLinksWidget({
|
||||
widget,
|
||||
config,
|
||||
isReadOnly,
|
||||
}: WidgetComponentProps<DashboardLinksConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { showIcons, columns, maxItems } = config;
|
||||
const [links, setLinks] = useState<ServiceLink[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getServiceLinks()
|
||||
.then((data) => setLinks(maxItems ? data.slice(0, maxItems) : data))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [maxItems]);
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
);
|
||||
if (links.length === 0)
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.noDashboardLinks")}
|
||||
</div>
|
||||
);
|
||||
|
||||
const colClass =
|
||||
columns === 1
|
||||
? "grid-cols-1"
|
||||
: columns === 2
|
||||
? "grid-cols-2"
|
||||
: "grid-cols-3";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<Link size={11} />} />
|
||||
<div className={`grid ${colClass} flex-1 overflow-auto content-start`}>
|
||||
{links.map((link) => (
|
||||
<LinkTile
|
||||
key={link.id}
|
||||
link={link}
|
||||
showIcons={showIcons}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<DashboardLinksConfig>({
|
||||
id: "dashboard_links",
|
||||
name: "Dashboard Links",
|
||||
description: "Shows your saved dashboard service links",
|
||||
category: "links",
|
||||
icon: <Link size={14} />,
|
||||
defaultConfig: { showIcons: true, columns: 1 },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
|
||||
component: DashboardLinksWidget,
|
||||
});
|
||||
|
||||
export { DashboardLinksWidget };
|
||||
@@ -1,49 +0,0 @@
|
||||
import { Folder } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
FolderConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
|
||||
function FolderWidget({ widget, config }: WidgetComponentProps<FolderConfig>) {
|
||||
const accentColor =
|
||||
config.color ||
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() ||
|
||||
"#f59145";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full h-full flex flex-col"
|
||||
style={{
|
||||
background: `${accentColor}10`,
|
||||
borderTop: `2px solid ${accentColor}60`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5 select-none">
|
||||
<Folder size={11} style={{ color: accentColor }} className="shrink-0" />
|
||||
<span className="text-xs font-semibold text-foreground truncate">
|
||||
{widget.title || "Folder"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<FolderConfig>({
|
||||
id: "folder",
|
||||
name: "Folder",
|
||||
description: "A background card for grouping widgets visually",
|
||||
category: "links",
|
||||
icon: <Folder size={14} />,
|
||||
defaultConfig: { isExpanded: true },
|
||||
defaultSize: { w: GRID_SIZE * 14, h: GRID_SIZE * 12 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: FolderWidget as unknown as React.ComponentType<
|
||||
WidgetComponentProps<FolderConfig>
|
||||
>,
|
||||
});
|
||||
|
||||
export { FolderWidget };
|
||||
@@ -1,134 +0,0 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { LayoutGrid } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
HostGridConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval";
|
||||
|
||||
function getAccentColor(): string {
|
||||
return (
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145"
|
||||
);
|
||||
}
|
||||
|
||||
function StatusDot({ status }: { status: string }) {
|
||||
const color =
|
||||
status === "online"
|
||||
? getAccentColor()
|
||||
: status === "reachable"
|
||||
? "#fbbf24"
|
||||
: status === "offline"
|
||||
? "#ef4444"
|
||||
: "#6b7280";
|
||||
return (
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full shrink-0 inline-block"
|
||||
style={{ background: color }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function HostGridWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<HostGridConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { hostIds, showIp, columns } = config;
|
||||
const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// getSSHHosts already attaches cached server status — no second /status call.
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const allHosts = await getSSHHosts();
|
||||
const filtered =
|
||||
hostIds.length > 0
|
||||
? allHosts.filter((h) => hostIds.includes(h.id))
|
||||
: allHosts;
|
||||
setHosts(filtered);
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [hostIds.join(",")]);
|
||||
|
||||
// Align with global status cadence; pause when the tab is hidden.
|
||||
usePageVisibleInterval(() => {
|
||||
void fetchData();
|
||||
}, 30_000);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hosts.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.noHosts")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const gridCols =
|
||||
columns === 2
|
||||
? "grid-cols-2"
|
||||
: columns === 3
|
||||
? "grid-cols-3"
|
||||
: "grid-cols-4";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<LayoutGrid size={11} />} />
|
||||
<div
|
||||
className={`grid ${gridCols} gap-1.5 p-2 flex-1 content-start overflow-auto`}
|
||||
>
|
||||
{hosts.map((host) => (
|
||||
<div
|
||||
key={host.id}
|
||||
className="flex items-center gap-1.5 p-1.5 bg-muted/30 border border-border/40 overflow-hidden"
|
||||
>
|
||||
<StatusDot status={host.status ?? "unknown"} />
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-[10px] font-medium text-foreground truncate">
|
||||
{host.name}
|
||||
</span>
|
||||
{showIp && (
|
||||
<span className="text-[9px] text-muted-foreground truncate">
|
||||
{host.ip}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<HostGridConfig>({
|
||||
id: "host_grid",
|
||||
name: "Host Grid",
|
||||
description: "Live status overview grid for all your SSH hosts",
|
||||
category: "monitoring",
|
||||
icon: <LayoutGrid size={14} />,
|
||||
defaultConfig: { hostIds: [], showIp: false, columns: 3 },
|
||||
defaultSize: { w: GRID_SIZE * 12, h: GRID_SIZE * 8 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
|
||||
component: HostGridWidget,
|
||||
});
|
||||
|
||||
export { HostGridWidget };
|
||||
@@ -1,184 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Server } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
HostStatusConfig,
|
||||
HostMetricKey,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { getServerStatusById } from "@/api/host-status-api";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import { ComponentSlot } from "@/shell/ActionSlot";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function getAccentColor(): string {
|
||||
return (
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145"
|
||||
);
|
||||
}
|
||||
|
||||
const DEFAULT_METRICS: HostMetricKey[] = ["cpu", "memory"];
|
||||
|
||||
function migrateConfig(config: HostStatusConfig): HostMetricKey[] {
|
||||
if (config.shownMetrics?.length) return config.shownMetrics;
|
||||
if (config.showMetrics === false) return [];
|
||||
const metrics: HostMetricKey[] = ["cpu", "memory"];
|
||||
if (config.showDisk) metrics.push("disk");
|
||||
return metrics;
|
||||
}
|
||||
|
||||
function HostStatusWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<HostStatusConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const shownMetrics = migrateConfig(config);
|
||||
const { hostId } = config;
|
||||
const [hostName, setHostName] = useState<string | null>(null);
|
||||
const [online, setOnline] = useState<boolean | null>(null);
|
||||
const [reachable, setReachable] = useState(false);
|
||||
|
||||
const needsMetrics = shownMetrics.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostId) return;
|
||||
getSSHHosts()
|
||||
.then((hosts) => {
|
||||
const host = hosts.find((h) => h.id === hostId);
|
||||
if (host) setHostName(host.name);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [hostId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostId) return;
|
||||
let cancelled = false;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const s = await getServerStatusById(hostId);
|
||||
if (!cancelled) {
|
||||
setOnline(s.status === "online");
|
||||
setReachable(s.status === "reachable");
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setOnline(false);
|
||||
setReachable(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
const start = () => {
|
||||
if (intervalId !== null) return;
|
||||
intervalId = setInterval(() => {
|
||||
if (document.visibilityState === "hidden") return;
|
||||
void poll();
|
||||
}, 30_000);
|
||||
};
|
||||
const stop = () => {
|
||||
if (intervalId === null) return;
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
};
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
void poll();
|
||||
start();
|
||||
};
|
||||
|
||||
if (document.visibilityState !== "hidden") start();
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
stop();
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, [hostId]);
|
||||
|
||||
if (!hostId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.noHostSelected")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const onlineColor =
|
||||
online === null
|
||||
? "#6b7280"
|
||||
: online
|
||||
? getAccentColor()
|
||||
: reachable
|
||||
? "#fbbf24"
|
||||
: "#ef4444";
|
||||
const onlineLabel =
|
||||
online === null
|
||||
? t("common.unknown")
|
||||
: online
|
||||
? t("common.online")
|
||||
: reachable
|
||||
? t("common.reachable", { defaultValue: "Reachable" })
|
||||
: t("common.offline");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<Server size={11} />} />
|
||||
<div
|
||||
className={`flex flex-col gap-2.5 p-3 flex-1 overflow-auto ${!needsMetrics ? "justify-center" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Server size={14} className="text-muted-foreground shrink-0" />
|
||||
<span className="text-xs font-semibold text-foreground truncate flex-1">
|
||||
{hostName ?? `Host #${hostId}`}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 shrink-0">
|
||||
<span
|
||||
className="w-1.5 h-1.5 shrink-0 rounded-full"
|
||||
style={{ background: onlineColor }}
|
||||
/>
|
||||
<span
|
||||
className="text-[10px] font-medium"
|
||||
style={{ color: onlineColor }}
|
||||
>
|
||||
{onlineLabel}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Plugins such as host metrics fill in the live numbers. */}
|
||||
{needsMetrics && (
|
||||
<ComponentSlot
|
||||
slotId="homepage.hostMetrics"
|
||||
props={{ hostId, shownMetrics, online: !!online }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<HostStatusConfig>({
|
||||
id: "host_status",
|
||||
name: "Host Status",
|
||||
description: "Shows live status and metrics for an SSH host",
|
||||
category: "system",
|
||||
icon: <Server size={14} />,
|
||||
defaultConfig: { hostId: 0, shownMetrics: DEFAULT_METRICS },
|
||||
defaultSize: { w: GRID_SIZE * 9, h: GRID_SIZE * 6 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: HostStatusWidget,
|
||||
});
|
||||
|
||||
export { HostStatusWidget };
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Globe } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
IframeConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function IframeWidget({
|
||||
widget,
|
||||
config,
|
||||
isReadOnly,
|
||||
}: WidgetComponentProps<IframeConfig>) {
|
||||
const { url, scrolling } = config;
|
||||
|
||||
if (!url) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/50">
|
||||
<Globe size={24} />
|
||||
<span className="text-xs">Configure a URL in widget settings</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<Globe size={11} />} />
|
||||
<div className="relative flex-1">
|
||||
<iframe
|
||||
src={url}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
scrolling={scrolling ? "yes" : "no"}
|
||||
className="w-full h-full border-0"
|
||||
title="Embedded content"
|
||||
/>
|
||||
{/* Block interaction in read-only mode so the canvas can still pan */}
|
||||
{isReadOnly && <div className="absolute inset-0 pointer-events-none" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<IframeConfig>({
|
||||
id: "iframe_embed",
|
||||
name: "iFrame Embed",
|
||||
description: "Embed any URL in an iframe",
|
||||
category: "info",
|
||||
icon: <Globe size={14} />,
|
||||
defaultConfig: { url: "", scrolling: true },
|
||||
defaultSize: { w: GRID_SIZE * 14, h: GRID_SIZE * 12 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: IframeWidget,
|
||||
});
|
||||
|
||||
export { IframeWidget };
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { ImageIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
ImageWidgetConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
const FIT_MAP = {
|
||||
contain: "object-contain",
|
||||
cover: "object-cover",
|
||||
fill: "object-fill",
|
||||
};
|
||||
|
||||
function ImageWidget({
|
||||
widget,
|
||||
config,
|
||||
isReadOnly,
|
||||
}: WidgetComponentProps<ImageWidgetConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { imageUrl, fit, alt, linkUrl } = config;
|
||||
const [failed, setFailed] = useState(false);
|
||||
const mouseDownRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
const titleBar = widget.title ? (
|
||||
<WidgetTitle title={widget.title} icon={<ImageIcon size={11} />} />
|
||||
) : null;
|
||||
|
||||
const content = (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
{titleBar}
|
||||
<div className="flex-1 flex items-center justify-center overflow-hidden">
|
||||
{imageUrl && !failed ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={alt ?? ""}
|
||||
className={`w-full h-full ${FIT_MAP[fit]}`}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1 text-muted-foreground/40">
|
||||
<ImageIcon size={24} />
|
||||
<span className="text-[10px]">{t("homepage.noImage")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isReadOnly || !linkUrl) return content;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={linkUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-full h-full"
|
||||
onMouseDown={(e) => {
|
||||
mouseDownRef.current = { x: e.clientX, y: e.clientY };
|
||||
}}
|
||||
onClick={(e) => {
|
||||
const s = mouseDownRef.current;
|
||||
if (
|
||||
s &&
|
||||
(Math.abs(e.clientX - s.x) > 4 || Math.abs(e.clientY - s.y) > 4)
|
||||
)
|
||||
e.preventDefault();
|
||||
mouseDownRef.current = null;
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<ImageWidgetConfig>({
|
||||
id: "image_widget",
|
||||
name: "Image",
|
||||
description: "Display any image from a URL",
|
||||
category: "info",
|
||||
icon: <ImageIcon size={14} />,
|
||||
defaultConfig: { imageUrl: "", fit: "contain" },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: ImageWidget,
|
||||
});
|
||||
|
||||
export { ImageWidget };
|
||||
@@ -1,125 +0,0 @@
|
||||
import { useRef } from "react";
|
||||
import { List, ExternalLink } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
LinkTreeConfig,
|
||||
LinkTreeLink,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function TreeLink({
|
||||
link,
|
||||
compact,
|
||||
isReadOnly,
|
||||
}: {
|
||||
link: LinkTreeLink;
|
||||
compact: boolean;
|
||||
isReadOnly?: boolean;
|
||||
}) {
|
||||
const mouseDownRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className={`group flex items-center gap-1.5 ${compact ? "py-0.5" : "py-1"} pl-2 pr-1 hover:bg-muted/40 transition-colors overflow-hidden`}
|
||||
>
|
||||
<ExternalLink
|
||||
size={9}
|
||||
className="shrink-0 text-muted-foreground group-hover:text-accent-brand transition-colors"
|
||||
/>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-[10px] font-medium text-foreground truncate">
|
||||
{link.label}
|
||||
</span>
|
||||
{!compact && link.description && (
|
||||
<span className="text-[9px] text-muted-foreground truncate">
|
||||
{link.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isReadOnly || !link.url) return content;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block no-underline"
|
||||
onMouseDown={(e) => {
|
||||
mouseDownRef.current = { x: e.clientX, y: e.clientY };
|
||||
}}
|
||||
onClick={(e) => {
|
||||
const s = mouseDownRef.current;
|
||||
if (
|
||||
s &&
|
||||
(Math.abs(e.clientX - s.x) > 4 || Math.abs(e.clientY - s.y) > 4)
|
||||
)
|
||||
e.preventDefault();
|
||||
mouseDownRef.current = null;
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkTreeWidget({
|
||||
widget,
|
||||
config,
|
||||
isReadOnly,
|
||||
}: WidgetComponentProps<LinkTreeConfig>) {
|
||||
const { sections, compact } = config;
|
||||
|
||||
if (sections.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
No sections configured
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<List size={11} />} />
|
||||
<div className="flex-1 overflow-auto">
|
||||
{sections.map((section, si) => (
|
||||
<div key={si}>
|
||||
{section.heading && (
|
||||
<div className="flex items-center gap-1.5 px-2 pt-2 pb-0.5 border-b border-border/30">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider truncate">
|
||||
{section.heading}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{section.links.map((link, li) => (
|
||||
<TreeLink
|
||||
key={li}
|
||||
link={link}
|
||||
compact={compact}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<LinkTreeConfig>({
|
||||
id: "link_tree",
|
||||
name: "Link Tree",
|
||||
description: "Grouped links organized into labeled sections",
|
||||
category: "links",
|
||||
icon: <List size={14} />,
|
||||
defaultConfig: { sections: [], compact: false },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 12 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 4 },
|
||||
component: LinkTreeWidget,
|
||||
});
|
||||
|
||||
export { LinkTreeWidget };
|
||||
@@ -1,127 +0,0 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { FileText } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
MarkdownNotesConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { updateHomepageItem } from "@/api/homepage-api";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function renderMarkdown(src: string): string {
|
||||
return src
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
if (/^### /.test(line))
|
||||
return `<h3 class="text-[11px] font-bold mt-2 mb-0.5 text-foreground">${esc(line.slice(4))}</h3>`;
|
||||
if (/^## /.test(line))
|
||||
return `<h2 class="text-xs font-bold mt-2 mb-0.5 text-foreground">${esc(line.slice(3))}</h2>`;
|
||||
if (/^# /.test(line))
|
||||
return `<h1 class="text-sm font-bold mt-2 mb-0.5 text-foreground">${esc(line.slice(2))}</h1>`;
|
||||
if (/^- /.test(line))
|
||||
return `<li class="text-[10px] ml-3 list-disc text-foreground">${inlineFormat(line.slice(2))}</li>`;
|
||||
if (/^\d+\. /.test(line))
|
||||
return `<li class="text-[10px] ml-3 list-decimal text-foreground">${inlineFormat(line.replace(/^\d+\. /, ""))}</li>`;
|
||||
if (line.trim() === "") return "<br/>";
|
||||
return `<p class="text-[10px] text-foreground leading-relaxed">${inlineFormat(line)}</p>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function inlineFormat(s: string): string {
|
||||
return esc(s)
|
||||
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/\*(.+?)\*/g, "<em>$1</em>")
|
||||
.replace(
|
||||
/`(.+?)`/g,
|
||||
'<code class="bg-muted px-0.5 font-mono text-[9px]">$1</code>',
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownNotesWidget({
|
||||
widget,
|
||||
config,
|
||||
isReadOnly,
|
||||
onConfigUpdate,
|
||||
}: WidgetComponentProps<MarkdownNotesConfig>) {
|
||||
const { content, backgroundColor, renderMarkdown: doRender } = config;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(content);
|
||||
|
||||
const commitEdit = useCallback(async () => {
|
||||
setEditing(false);
|
||||
if (draft === content) return;
|
||||
try {
|
||||
await updateHomepageItem(widget.id, {
|
||||
config: { ...config, content: draft } as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
});
|
||||
onConfigUpdate?.({ ...config, content: draft } as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [draft, content, widget.id, config, onConfigUpdate]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col w-full h-full overflow-hidden"
|
||||
style={backgroundColor ? { background: backgroundColor } : undefined}
|
||||
>
|
||||
<WidgetTitle title={widget.title} icon={<FileText size={11} />} />
|
||||
<div className="flex-1 overflow-auto p-2 relative">
|
||||
{editing ? (
|
||||
<textarea
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commitEdit}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="w-full h-full bg-transparent text-[10px] text-foreground resize-none outline-none border-none leading-relaxed"
|
||||
style={{ minHeight: "100%" }}
|
||||
/>
|
||||
) : doRender ? (
|
||||
<div
|
||||
className="text-[10px] leading-relaxed cursor-text"
|
||||
onDoubleClick={() => {
|
||||
if (!isReadOnly) setEditing(true);
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(content || "") }}
|
||||
/>
|
||||
) : (
|
||||
<pre
|
||||
className="text-[10px] text-foreground leading-relaxed whitespace-pre-wrap break-words font-sans cursor-text"
|
||||
onDoubleClick={() => {
|
||||
if (!isReadOnly) setEditing(true);
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<MarkdownNotesConfig>({
|
||||
id: "markdown_notes",
|
||||
name: "Markdown Notes",
|
||||
description: "Notes with optional Markdown rendering and inline editing",
|
||||
category: "info",
|
||||
icon: <FileText size={14} />,
|
||||
defaultConfig: { content: "", renderMarkdown: true },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
|
||||
minSize: { w: GRID_SIZE * 3, h: GRID_SIZE * 3 },
|
||||
component: MarkdownNotesWidget,
|
||||
});
|
||||
|
||||
export { MarkdownNotesWidget };
|
||||
@@ -1,42 +0,0 @@
|
||||
import { StickyNote } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import {
|
||||
GRID_SIZE,
|
||||
type NotesConfig,
|
||||
type WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function NotesWidget({ widget, config }: WidgetComponentProps<NotesConfig>) {
|
||||
const { content, backgroundColor } = config;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col w-full h-full overflow-hidden"
|
||||
style={{ background: backgroundColor || undefined }}
|
||||
>
|
||||
<WidgetTitle title={widget.title} icon={<StickyNote size={11} />} />
|
||||
<div className="flex-1 overflow-auto p-3">
|
||||
<pre className="text-xs text-foreground whitespace-pre-wrap break-words font-sans leading-relaxed select-text w-full">
|
||||
{content || (
|
||||
<span className="text-muted-foreground italic">Empty note...</span>
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<NotesConfig>({
|
||||
id: "notes",
|
||||
name: "Notes",
|
||||
description: "A text notes widget",
|
||||
category: "info",
|
||||
icon: <StickyNote size={14} />,
|
||||
defaultConfig: { content: "" },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: NotesWidget,
|
||||
});
|
||||
|
||||
export { NotesWidget };
|
||||
@@ -1,140 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Wifi } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
PingStatusConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { homepageApi } from "@/main-axios";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
import { runVisibleInterval } from "../use-visible-interval";
|
||||
|
||||
interface PingResult {
|
||||
ok: boolean;
|
||||
statusCode: number | null;
|
||||
latencyMs: number;
|
||||
}
|
||||
|
||||
async function pingUrl(url: string): Promise<PingResult> {
|
||||
const res = await homepageApi.get("/ping", { params: { url } });
|
||||
return res.data as PingResult;
|
||||
}
|
||||
|
||||
function StatusDot({ ok, pending }: { ok: boolean | null; pending: boolean }) {
|
||||
if (pending)
|
||||
return (
|
||||
<span className="w-2 h-2 rounded-full bg-muted-foreground/40 shrink-0 inline-block" />
|
||||
);
|
||||
return (
|
||||
<span
|
||||
className="w-2 h-2 rounded-full shrink-0 inline-block"
|
||||
style={{ background: ok ? "var(--accent-brand, #f59145)" : "#ef4444" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PingStatusWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<PingStatusConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { urls, refreshInterval, showLatency } = config;
|
||||
const [results, setResults] = useState<Record<string, PingResult | null>>({});
|
||||
const [pending, setPending] = useState<Record<string, boolean>>({});
|
||||
|
||||
const fetchAll = async () => {
|
||||
if (urls.length === 0) return;
|
||||
const newPending: Record<string, boolean> = {};
|
||||
urls.forEach((u) => {
|
||||
newPending[u.url] = true;
|
||||
});
|
||||
setPending(newPending);
|
||||
|
||||
await Promise.all(
|
||||
urls.map(async (u) => {
|
||||
try {
|
||||
const r = await pingUrl(u.url);
|
||||
setResults((prev) => ({ ...prev, [u.url]: r }));
|
||||
} catch {
|
||||
setResults((prev) => ({
|
||||
...prev,
|
||||
[u.url]: { ok: false, statusCode: null, latencyMs: 0 },
|
||||
}));
|
||||
} finally {
|
||||
setPending((prev) => ({ ...prev, [u.url]: false }));
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const interval = Math.max(10, refreshInterval || 30);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAll();
|
||||
return runVisibleInterval(() => {
|
||||
void fetchAll();
|
||||
}, interval * 1000);
|
||||
}, [JSON.stringify(urls), interval]);
|
||||
|
||||
if (urls.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.noPingUrls")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle
|
||||
title={widget.title || t("homepage.widgetPingStatusName")}
|
||||
icon={<Wifi size={11} />}
|
||||
/>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{urls.map((u) => {
|
||||
const r = results[u.url];
|
||||
const isPending = pending[u.url] ?? false;
|
||||
return (
|
||||
<div
|
||||
key={u.url}
|
||||
className="flex items-center gap-2 px-2 py-1.5 border-b border-border/30"
|
||||
>
|
||||
<StatusDot ok={r?.ok ?? null} pending={isPending} />
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-[10px] font-medium text-foreground truncate">
|
||||
{u.label || u.url}
|
||||
</span>
|
||||
{r && (
|
||||
<span className="text-[9px] text-muted-foreground">
|
||||
{r.statusCode ?? (r.ok ? "OK" : "ERR")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{showLatency && r && r.latencyMs > 0 && (
|
||||
<span className="text-[9px] text-muted-foreground shrink-0 tabular-nums">
|
||||
{r.latencyMs}ms
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<PingStatusConfig>({
|
||||
id: "ping_status",
|
||||
name: "Ping Status",
|
||||
description: "Check if URLs or services are reachable",
|
||||
category: "monitoring",
|
||||
icon: <Wifi size={14} />,
|
||||
defaultConfig: { urls: [], refreshInterval: 30, showLatency: true },
|
||||
defaultSize: { w: GRID_SIZE * 9, h: GRID_SIZE * 7 },
|
||||
minSize: { w: GRID_SIZE * 3, h: GRID_SIZE * 2 },
|
||||
component: PingStatusWidget,
|
||||
});
|
||||
|
||||
export { PingStatusWidget };
|
||||
@@ -1,144 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Zap } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
QuickConnectConfig,
|
||||
QuickConnectType,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import type { TabType } from "@/types/ui-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
import { useHostActions } from "@/sidebar/host-contributions";
|
||||
import { quickConnectTargets } from "../quick-connect-targets";
|
||||
|
||||
function statusDotClass(host: SSHHostWithStatus): string {
|
||||
if (host.status === "online") return "bg-green-500";
|
||||
if (host.status === "offline") return "bg-red-500";
|
||||
return "bg-muted-foreground/30";
|
||||
}
|
||||
|
||||
function openTab(host: SSHHostWithStatus, type: QuickConnectType) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("termix:open-tab", {
|
||||
detail: { hostId: String(host.id), type: type as TabType },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function QuickConnectWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<QuickConnectConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const targets = quickConnectTargets(useHostActions());
|
||||
|
||||
// An empty list means the first tools plugins offer, in their order.
|
||||
const types =
|
||||
config.connectionTypes.length > 0
|
||||
? config.connectionTypes
|
||||
: (targets
|
||||
.slice(0, 2)
|
||||
.map((target) => target.type) as QuickConnectType[]);
|
||||
|
||||
useEffect(() => {
|
||||
getSSHHosts()
|
||||
.then((all) => {
|
||||
const filtered =
|
||||
config.hostIds.length > 0
|
||||
? all.filter((h) => config.hostIds.includes(h.id))
|
||||
: all;
|
||||
setHosts(filtered);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [config.hostIds]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hosts.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/60">
|
||||
<Zap size={18} />
|
||||
<span className="text-xs">{t("homepage.noHosts")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<Zap size={11} />} />
|
||||
<div className="flex-1 overflow-y-auto p-2 flex flex-col gap-1">
|
||||
{hosts.map((host) => {
|
||||
const availableTypes = types.flatMap((type) => {
|
||||
const target = targets.find((item) => item.type === type);
|
||||
return target && target.enabled(host) ? [target] : [];
|
||||
});
|
||||
if (availableTypes.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
key={host.id}
|
||||
className={`flex items-center gap-2 py-1.5 border-b border-border/20 last:border-0 ${config.layout === "grid" ? "flex-col items-start" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
||||
{config.showStatus && (
|
||||
<span
|
||||
className={`size-1.5 rounded-full shrink-0 ${statusDotClass(host)}`}
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs font-medium text-foreground truncate">
|
||||
{host.name || host.ip}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0 flex-wrap">
|
||||
{availableTypes.map((target) => (
|
||||
<button
|
||||
key={target.type}
|
||||
title={t(target.labelKey, target.type)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openTab(host, target.type);
|
||||
}}
|
||||
className="p-1.5 text-muted-foreground hover:text-accent-brand hover:bg-accent-brand/10 transition-colors border border-transparent hover:border-accent-brand/20"
|
||||
>
|
||||
<target.icon size={12} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<QuickConnectConfig>({
|
||||
id: "quick_connect",
|
||||
name: "Quick Connect",
|
||||
description:
|
||||
"Launch terminal, files, docker, tunnels, and more for your hosts",
|
||||
category: "system",
|
||||
icon: <Zap size={14} />,
|
||||
defaultConfig: {
|
||||
hostIds: [],
|
||||
connectionTypes: [],
|
||||
showStatus: true,
|
||||
layout: "list",
|
||||
},
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
|
||||
component: QuickConnectWidget,
|
||||
});
|
||||
@@ -1,126 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Activity } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { activityTarget } from "@/lib/activity-types";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
RecentActivityConfig,
|
||||
ActivityType,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import {
|
||||
getRecentActivity,
|
||||
type RecentActivityItem,
|
||||
} from "@/api/dashboard-api";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
import { runVisibleInterval } from "../use-visible-interval";
|
||||
|
||||
function relativeTime(ts: string): string {
|
||||
const diff = Date.now() - new Date(ts).getTime();
|
||||
const m = Math.floor(diff / 60_000);
|
||||
if (m < 1) return "just now";
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
function ActivityIcon({ type }: { type: string }) {
|
||||
const Icon = activityTarget(type)?.icon ?? Activity;
|
||||
return <Icon className="size-2.5 shrink-0 text-accent-brand" />;
|
||||
}
|
||||
|
||||
function RecentActivityWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<RecentActivityConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { maxItems, filterTypes, showTimestamp } = config;
|
||||
const [items, setItems] = useState<RecentActivityItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const data = await getRecentActivity(maxItems * 3);
|
||||
const filtered =
|
||||
filterTypes.length > 0
|
||||
? data.filter((i) => filterTypes.includes(i.type as ActivityType))
|
||||
: data;
|
||||
setItems(filtered.slice(0, maxItems));
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
return runVisibleInterval(() => {
|
||||
void fetchData();
|
||||
}, 60_000);
|
||||
}, [maxItems, filterTypes.join(",")]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.noActivity")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle
|
||||
title={widget.title || t("homepage.widgetRecentActivityName")}
|
||||
icon={<Activity size={11} />}
|
||||
/>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-2 px-2 py-1.5 border-b border-border/30"
|
||||
>
|
||||
<ActivityIcon type={item.type} />
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-[10px] font-medium text-foreground truncate">
|
||||
{item.hostName}
|
||||
</span>
|
||||
<span className="text-[9px] text-muted-foreground capitalize">
|
||||
{item.type.replace("_", " ")}
|
||||
</span>
|
||||
</div>
|
||||
{showTimestamp && (
|
||||
<span className="text-[9px] text-muted-foreground shrink-0">
|
||||
{relativeTime(item.timestamp)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<RecentActivityConfig>({
|
||||
id: "recent_activity",
|
||||
name: "Recent Activity",
|
||||
description: "Shows recent terminal, Docker, file, and tunnel activity",
|
||||
category: "system",
|
||||
icon: <Activity size={14} />,
|
||||
defaultConfig: { maxItems: 10, filterTypes: [], showTimestamp: true },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 9 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
|
||||
component: RecentActivityWidget,
|
||||
});
|
||||
|
||||
export { RecentActivityWidget };
|
||||
@@ -1,155 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Rss, ExternalLink } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import { homepageApi } from "@/main-axios";
|
||||
import type {
|
||||
RssFeedConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
interface RssItem {
|
||||
title: string;
|
||||
link: string;
|
||||
pubDate: string | null;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return "";
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
const diffMs = Date.now() - d.getTime();
|
||||
const diffH = Math.floor(diffMs / 3_600_000);
|
||||
if (diffH < 1) return "just now";
|
||||
if (diffH < 24) return `${diffH}h ago`;
|
||||
const diffD = Math.floor(diffH / 24);
|
||||
if (diffD < 7) return `${diffD}d ago`;
|
||||
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function RssFeedWidget({
|
||||
widget,
|
||||
config,
|
||||
isReadOnly,
|
||||
}: WidgetComponentProps<RssFeedConfig>) {
|
||||
const { feedUrl, maxItems, showDescription } = config;
|
||||
const [items, setItems] = useState<RssItem[]>([]);
|
||||
const [error, setError] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!feedUrl) return;
|
||||
let cancelled = false;
|
||||
const fetchFeed = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
const res = await homepageApi.get(
|
||||
`/rss?url=${encodeURIComponent(feedUrl)}&max=${maxItems ?? 10}`,
|
||||
);
|
||||
if (!cancelled) setItems(res.data);
|
||||
} catch {
|
||||
if (!cancelled) setError(true);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchFeed();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [feedUrl, maxItems]);
|
||||
|
||||
if (!feedUrl) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/50">
|
||||
<Rss size={24} />
|
||||
<span className="text-xs">Configure a feed URL in widget settings</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading && items.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/50">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-destructive/70 p-3 text-center">
|
||||
Could not load feed
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle
|
||||
title={widget.title || "RSS Feed"}
|
||||
icon={<Rss size={11} />}
|
||||
/>
|
||||
|
||||
{/* Items */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{items.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-xs text-muted-foreground/50">
|
||||
No items
|
||||
</div>
|
||||
) : (
|
||||
items.map((item, i) => (
|
||||
<div key={i} className="border-b border-border/60 last:border-0">
|
||||
<a
|
||||
href={item.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`flex flex-col px-3 py-2 gap-0.5 hover:bg-muted/40 transition-colors no-underline ${isReadOnly ? "pointer-events-none" : ""}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="text-xs font-medium text-foreground line-clamp-2 leading-snug flex-1">
|
||||
{item.title}
|
||||
</span>
|
||||
<ExternalLink
|
||||
size={9}
|
||||
className="text-muted-foreground/40 shrink-0 mt-0.5"
|
||||
/>
|
||||
</div>
|
||||
{showDescription && item.description && (
|
||||
<span className="text-[10px] text-muted-foreground/70 line-clamp-2">
|
||||
{item.description.replace(/<[^>]+>/g, "")}
|
||||
</span>
|
||||
)}
|
||||
{item.pubDate && (
|
||||
<span className="text-[9px] text-muted-foreground/50">
|
||||
{formatDate(item.pubDate)}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<RssFeedConfig>({
|
||||
id: "rss_feed",
|
||||
name: "RSS Feed",
|
||||
description: "Display items from an RSS or Atom feed",
|
||||
category: "links",
|
||||
icon: <Rss size={14} />,
|
||||
defaultConfig: { feedUrl: "", maxItems: 10, showDescription: false },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 10 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: RssFeedWidget,
|
||||
});
|
||||
|
||||
export { RssFeedWidget };
|
||||
@@ -1,103 +0,0 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
SearchBarConfig,
|
||||
SearchEngine,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
const ENGINE_URLS: Record<SearchEngine, string> = {
|
||||
google: "https://www.google.com/search?q={q}",
|
||||
duckduckgo: "https://duckduckgo.com/?q={q}",
|
||||
bing: "https://www.bing.com/search?q={q}",
|
||||
custom: "",
|
||||
};
|
||||
|
||||
const ENGINE_LABELS: Record<SearchEngine, string> = {
|
||||
google: "Google",
|
||||
duckduckgo: "DuckDuckGo",
|
||||
bing: "Bing",
|
||||
custom: "Custom",
|
||||
};
|
||||
|
||||
function buildUrl(
|
||||
engine: SearchEngine,
|
||||
customUrl: string | undefined,
|
||||
query: string,
|
||||
): string {
|
||||
const template =
|
||||
engine === "custom" ? (customUrl ?? "") : ENGINE_URLS[engine];
|
||||
return template.replace("{q}", encodeURIComponent(query));
|
||||
}
|
||||
|
||||
function SearchBarWidget({
|
||||
widget,
|
||||
config,
|
||||
isReadOnly,
|
||||
}: WidgetComponentProps<SearchBarConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { engine, customUrl, placeholder, openInNewTab } = config;
|
||||
const [query, setQuery] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!query.trim()) return;
|
||||
const url = buildUrl(engine, customUrl, query.trim());
|
||||
if (!url) return;
|
||||
if (openInNewTab) window.open(url, "_blank", "noopener,noreferrer");
|
||||
else window.location.href = url;
|
||||
setQuery("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<Search size={11} />} />
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex items-center gap-1.5 flex-1 px-2"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Search size={12} className="text-muted-foreground shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={
|
||||
placeholder ||
|
||||
`${t("homepage.searchPlaceholder")} ${ENGINE_LABELS[engine]}...`
|
||||
}
|
||||
disabled={isReadOnly}
|
||||
className="flex-1 bg-transparent text-xs text-foreground placeholder:text-muted-foreground/60 outline-none border-none min-w-0"
|
||||
/>
|
||||
{query.trim() && (
|
||||
<button
|
||||
type="submit"
|
||||
className="shrink-0 text-[10px] font-medium text-accent-brand hover:opacity-80 transition-opacity"
|
||||
>
|
||||
{t("homepage.searchGo")}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<SearchBarConfig>({
|
||||
id: "search_bar",
|
||||
name: "Search Bar",
|
||||
description: "Quick search with Google, DuckDuckGo, Bing, or a custom engine",
|
||||
category: "info",
|
||||
icon: <Search size={14} />,
|
||||
defaultConfig: { engine: "google", openInNewTab: true },
|
||||
defaultSize: { w: GRID_SIZE * 12, h: GRID_SIZE * 3 },
|
||||
minSize: { w: GRID_SIZE * 6, h: GRID_SIZE * 2 },
|
||||
component: SearchBarWidget,
|
||||
});
|
||||
|
||||
export { SearchBarWidget };
|
||||
@@ -1,129 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { SearchCode } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
SearchLinksConfig,
|
||||
SearchLinkShortcut,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function SearchLinksWidget({
|
||||
widget,
|
||||
config,
|
||||
isReadOnly,
|
||||
}: WidgetComponentProps<SearchLinksConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { shortcuts } = config;
|
||||
const [activeIdx, setActiveIdx] = useState<number | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const handleOpen = (shortcut: SearchLinkShortcut) => {
|
||||
if (!query.trim()) return;
|
||||
const url = shortcut.queryTemplate.replace(
|
||||
"{q}",
|
||||
encodeURIComponent(query.trim()),
|
||||
);
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
setQuery("");
|
||||
setActiveIdx(null);
|
||||
};
|
||||
|
||||
const handleButtonClick = (i: number) => {
|
||||
if (isReadOnly) return;
|
||||
if (activeIdx === i) {
|
||||
setActiveIdx(null);
|
||||
setQuery("");
|
||||
} else {
|
||||
setActiveIdx(i);
|
||||
setQuery("");
|
||||
}
|
||||
};
|
||||
|
||||
if (shortcuts.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.noSearchShortcuts")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<SearchCode size={11} />} />
|
||||
<div className="flex flex-col flex-1 p-2 gap-1.5 overflow-auto">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{shortcuts.map((s, i) => {
|
||||
const accent =
|
||||
s.accentColor ||
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() ||
|
||||
"#f59145";
|
||||
const isActive = activeIdx === i;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => handleButtonClick(i)}
|
||||
className="flex items-center gap-1 px-2 py-1 text-[10px] font-medium border transition-colors"
|
||||
style={
|
||||
isActive
|
||||
? { background: accent, borderColor: accent, color: "#fff" }
|
||||
: {
|
||||
borderColor: `${accent}50`,
|
||||
color: "var(--text-primary, currentColor)",
|
||||
}
|
||||
}
|
||||
>
|
||||
{s.icon && <span className="text-xs">{s.icon}</span>}
|
||||
{s.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{activeIdx !== null && (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleOpen(shortcuts[activeIdx]);
|
||||
}}
|
||||
className="flex items-center gap-1.5 border border-border/60 px-2 py-1"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("homepage.searchQueryPlaceholder")}
|
||||
className="flex-1 bg-transparent text-[10px] text-foreground placeholder:text-muted-foreground/60 outline-none border-none"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="text-[9px] font-medium text-accent-brand hover:opacity-80"
|
||||
>
|
||||
{t("homepage.searchGo")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<SearchLinksConfig>({
|
||||
id: "search_links",
|
||||
name: "Search Shortcuts",
|
||||
description: "Quick buttons to search different engines",
|
||||
category: "links",
|
||||
icon: <SearchCode size={14} />,
|
||||
defaultConfig: { shortcuts: [] },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 7 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
|
||||
component: SearchLinksWidget,
|
||||
});
|
||||
|
||||
export { SearchLinksWidget };
|
||||
@@ -1,160 +0,0 @@
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { Grid3x3, ExternalLink } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
ServiceGridConfig,
|
||||
ServiceGridItem,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function getAccentColor(): string {
|
||||
return (
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145"
|
||||
);
|
||||
}
|
||||
|
||||
function getFaviconUrl(url: string): string {
|
||||
try {
|
||||
const domain = new URL(url).hostname;
|
||||
return `https://www.google.com/s2/favicons?sz=64&domain_url=${encodeURIComponent(domain)}`;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
const ICON_SIZE_MAP = { sm: 16, md: 24, lg: 32 };
|
||||
|
||||
function ServiceTile({
|
||||
item,
|
||||
iconSize,
|
||||
showLabels,
|
||||
isReadOnly,
|
||||
}: {
|
||||
item: ServiceGridItem;
|
||||
iconSize: number;
|
||||
showLabels: boolean;
|
||||
isReadOnly?: boolean;
|
||||
}) {
|
||||
const [imgFailed, setImgFailed] = useState(false);
|
||||
const mouseDownRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setImgFailed(false);
|
||||
}, [item.imageUrl, item.url]);
|
||||
|
||||
const accent = item.accentColor || getAccentColor();
|
||||
const imageUrl = item.imageUrl || (item.url ? getFaviconUrl(item.url) : null);
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className="group flex flex-col items-center justify-center gap-1 p-1.5 w-full h-full border border-border/40 border-t-2 hover:bg-muted/40 transition-colors overflow-hidden"
|
||||
style={{ borderTopColor: `${accent}60`, background: "var(--color-card)" }}
|
||||
>
|
||||
{imageUrl && !imgFailed ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt=""
|
||||
style={{ width: iconSize, height: iconSize }}
|
||||
className="object-contain shrink-0"
|
||||
onError={() => setImgFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<ExternalLink
|
||||
size={iconSize * 0.6}
|
||||
className="text-accent-brand shrink-0"
|
||||
/>
|
||||
)}
|
||||
{showLabels && (
|
||||
<span className="text-[9px] font-medium text-foreground text-center truncate w-full leading-tight">
|
||||
{item.label || item.url}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isReadOnly || !item.url) return content;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-full h-full no-underline"
|
||||
onMouseDown={(e) => {
|
||||
mouseDownRef.current = { x: e.clientX, y: e.clientY };
|
||||
}}
|
||||
onClick={(e) => {
|
||||
const s = mouseDownRef.current;
|
||||
if (
|
||||
s &&
|
||||
(Math.abs(e.clientX - s.x) > 4 || Math.abs(e.clientY - s.y) > 4)
|
||||
)
|
||||
e.preventDefault();
|
||||
mouseDownRef.current = null;
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceGridWidget({
|
||||
widget,
|
||||
config,
|
||||
isReadOnly,
|
||||
}: WidgetComponentProps<ServiceGridConfig>) {
|
||||
const { services, columns, showLabels, iconSize } = config;
|
||||
|
||||
if (services.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
No services configured
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const gridCols =
|
||||
columns === 2
|
||||
? "grid-cols-2"
|
||||
: columns === 3
|
||||
? "grid-cols-3"
|
||||
: "grid-cols-4";
|
||||
const size = ICON_SIZE_MAP[iconSize];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<Grid3x3 size={11} />} />
|
||||
<div
|
||||
className={`grid ${gridCols} gap-1.5 p-1.5 flex-1 overflow-auto content-start`}
|
||||
>
|
||||
{services.map((s, i) => (
|
||||
<ServiceTile
|
||||
key={i}
|
||||
item={s}
|
||||
iconSize={size}
|
||||
showLabels={showLabels}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<ServiceGridConfig>({
|
||||
id: "service_grid",
|
||||
name: "Service Grid",
|
||||
description: "A grid of service tiles with icons and links",
|
||||
category: "links",
|
||||
icon: <Grid3x3 size={14} />,
|
||||
defaultConfig: { services: [], columns: 3, showLabels: true, iconSize: "md" },
|
||||
defaultSize: { w: GRID_SIZE * 14, h: GRID_SIZE * 10 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 4 },
|
||||
component: ServiceGridWidget,
|
||||
});
|
||||
|
||||
export { ServiceGridWidget };
|
||||
@@ -1,131 +0,0 @@
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
ServiceLinkConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
|
||||
function getAccentColor(): string {
|
||||
return (
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145"
|
||||
);
|
||||
}
|
||||
|
||||
function getFaviconUrl(url: string): string {
|
||||
try {
|
||||
const domain = new URL(url).hostname;
|
||||
return `https://www.google.com/s2/favicons?sz=64&domain_url=${encodeURIComponent(domain)}`;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function ServiceLinkWidget({
|
||||
widget,
|
||||
config,
|
||||
isReadOnly,
|
||||
}: WidgetComponentProps<ServiceLinkConfig>) {
|
||||
const { url, description, accentColor, imageUrl, showImage = true } = config;
|
||||
const resolvedImageUrl = showImage
|
||||
? imageUrl || (url ? getFaviconUrl(url) : null)
|
||||
: null;
|
||||
const mouseDownPosRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const [imgFailed, setImgFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setImgFailed(false);
|
||||
}, [resolvedImageUrl]);
|
||||
|
||||
let displayLabel = widget.title;
|
||||
if (!displayLabel && url) {
|
||||
try {
|
||||
displayLabel = new URL(url).hostname;
|
||||
} catch {
|
||||
displayLabel = url;
|
||||
}
|
||||
}
|
||||
|
||||
const accentBorder = accentColor || getAccentColor();
|
||||
const imgSize = Math.min(48, widget.h * 0.3);
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className="group flex flex-col items-center justify-center gap-1.5 w-full h-full p-2 select-none border-t-2 relative overflow-hidden"
|
||||
style={{ borderTopColor: `${accentBorder}80` }}
|
||||
>
|
||||
{resolvedImageUrl && !imgFailed ? (
|
||||
<img
|
||||
src={resolvedImageUrl}
|
||||
alt=""
|
||||
className="shrink-0 transition-transform duration-150 group-hover:scale-110 object-contain"
|
||||
style={{ width: imgSize, height: imgSize }}
|
||||
onError={() => setImgFailed(true)}
|
||||
/>
|
||||
) : showImage ? (
|
||||
<ExternalLink
|
||||
className="shrink-0 text-accent-brand transition-transform duration-150 group-hover:scale-110"
|
||||
style={{ width: imgSize * 0.75, height: imgSize * 0.75 }}
|
||||
/>
|
||||
) : null}
|
||||
<span className="text-xs font-semibold text-foreground text-center leading-tight w-full truncate px-1">
|
||||
{displayLabel}
|
||||
</span>
|
||||
{description && (
|
||||
<span className="text-[10px] text-muted-foreground text-center w-full truncate px-1">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
{!isReadOnly && url && (
|
||||
<ExternalLink
|
||||
size={10}
|
||||
className="absolute top-1.5 right-1.5 text-muted-foreground/0 group-hover:text-muted-foreground/60 transition-colors duration-150"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isReadOnly || !url) return content;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-full h-full no-underline"
|
||||
onMouseDown={(e) => {
|
||||
mouseDownPosRef.current = { x: e.clientX, y: e.clientY };
|
||||
}}
|
||||
onClick={(e) => {
|
||||
const start = mouseDownPosRef.current;
|
||||
if (
|
||||
start &&
|
||||
(Math.abs(e.clientX - start.x) > 4 ||
|
||||
Math.abs(e.clientY - start.y) > 4)
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
mouseDownPosRef.current = null;
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<ServiceLinkConfig>({
|
||||
id: "service_link",
|
||||
name: "Service Link",
|
||||
description: "A clickable tile linking to a service URL",
|
||||
category: "links",
|
||||
icon: <ExternalLink size={14} />,
|
||||
defaultConfig: { url: "", description: "", showImage: true },
|
||||
defaultSize: { w: GRID_SIZE * 8, h: GRID_SIZE * 6 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: ServiceLinkWidget,
|
||||
});
|
||||
|
||||
export { ServiceLinkWidget };
|
||||
@@ -1,167 +0,0 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { PluginComponent } from "@/plugin-host/component-registry";
|
||||
import { Terminal as TerminalIcon, Play } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
SshTerminalConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import type { TabHandle as TerminalHandle } from "@termix/plugin-sdk/frontend";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function SshTerminalWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<SshTerminalConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const [host, setHost] = useState<SSHHostWithStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [started, setStarted] = useState(false);
|
||||
const terminalRef = useRef<TerminalHandle>(null);
|
||||
const terminalWrapRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = terminalWrapRef.current;
|
||||
if (!el || !started) return;
|
||||
const stopWheel = (e: WheelEvent) => e.stopPropagation();
|
||||
el.addEventListener("wheel", stopWheel, { capture: true, passive: true });
|
||||
return () => el.removeEventListener("wheel", stopWheel, { capture: true });
|
||||
}, [started]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!started) return;
|
||||
const blurOnOutsideClick = (e: MouseEvent) => {
|
||||
if (!terminalWrapRef.current?.contains(e.target as Node)) {
|
||||
const textarea = terminalWrapRef.current?.querySelector("textarea");
|
||||
textarea?.blur();
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", blurOnOutsideClick, {
|
||||
capture: true,
|
||||
});
|
||||
return () =>
|
||||
document.removeEventListener("mousedown", blurOnOutsideClick, {
|
||||
capture: true,
|
||||
});
|
||||
}, [started]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config.hostId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
getSSHHosts()
|
||||
.then((hosts) => {
|
||||
const found = hosts.find((h) => h.id === config.hostId);
|
||||
setHost(found ?? null);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [config.hostId]);
|
||||
|
||||
// auto-connect after host loads if configured
|
||||
useEffect(() => {
|
||||
if (!loading && host && config.autoConnect) setStarted(true);
|
||||
}, [loading, host, config.autoConnect]);
|
||||
|
||||
if (!config.hostId) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/60">
|
||||
<TerminalIcon size={20} />
|
||||
<span className="text-xs">{t("homepage.sshTerminalNoHost")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!host) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60">
|
||||
{t("homepage.sshTerminalNoHost")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hostConfig = {
|
||||
id: host.id as unknown as number,
|
||||
ip: host.ip,
|
||||
port: host.sshPort ?? host.port ?? 22,
|
||||
username: host.username,
|
||||
authType: host.authType,
|
||||
terminalConfig: host.terminalConfig,
|
||||
};
|
||||
|
||||
if (!started) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center w-full h-full gap-3"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<TerminalIcon size={16} className="text-muted-foreground/60" />
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{host.name || host.ip}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/50">
|
||||
{host.username}@{host.ip}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-accent-brand text-white text-xs font-medium hover:opacity-90 transition-opacity"
|
||||
onClick={() => setStarted(true)}
|
||||
>
|
||||
<Play size={11} />
|
||||
{t("homepage.sshTerminalConnect")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={terminalWrapRef}
|
||||
className="flex flex-col w-full h-full overflow-hidden"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<WidgetTitle title={widget.title} icon={<TerminalIcon size={11} />} />
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<PluginComponent
|
||||
id="terminal.view"
|
||||
handleRef={terminalRef}
|
||||
hostConfig={hostConfig}
|
||||
isVisible={true}
|
||||
showTitle={false}
|
||||
splitScreen={false}
|
||||
disableAutoFocus={true}
|
||||
onClose={() => setStarted(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<SshTerminalConfig>({
|
||||
id: "ssh_terminal",
|
||||
name: "SSH Terminal",
|
||||
description: "An inline SSH terminal connected to a configured host",
|
||||
category: "system",
|
||||
icon: <TerminalIcon size={14} />,
|
||||
defaultConfig: {
|
||||
hostId: 0,
|
||||
autoConnect: false,
|
||||
},
|
||||
defaultSize: { w: GRID_SIZE * 16, h: GRID_SIZE * 12 },
|
||||
minSize: { w: GRID_SIZE * 8, h: GRID_SIZE * 6 },
|
||||
component: SshTerminalWidget,
|
||||
});
|
||||
@@ -1,142 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Server } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
SystemOverviewConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { getVersionInfo, getDatabaseHealth } from "@/api/system-status-api";
|
||||
import { getUptime } from "@/api/dashboard-api";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
import { runVisibleInterval } from "../use-visible-interval";
|
||||
|
||||
interface InfoRowProps {
|
||||
label: string;
|
||||
value: string | null | undefined;
|
||||
valueColor?: string;
|
||||
}
|
||||
|
||||
function InfoRow({ label, value, valueColor }: InfoRowProps) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="flex justify-between text-[10px] gap-2">
|
||||
<span className="text-muted-foreground shrink-0">{label}</span>
|
||||
<span
|
||||
className="text-foreground truncate text-right font-medium"
|
||||
style={valueColor ? { color: valueColor } : undefined}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemOverviewWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<SystemOverviewConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { showVersion, showDbHealth, showUptime } = config;
|
||||
const [version, setVersion] = useState<string | null>(null);
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||
const [dbStatus, setDbStatus] = useState<string | null>(null);
|
||||
const [uptimeFormatted, setUptimeFormatted] = useState<string | null>(null);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
if (showVersion) {
|
||||
// `checkRemote=false` makes /version return early with just
|
||||
// {localVersion, status: "update_check_disabled"}, and the response has
|
||||
// no `updateAvailable` field in either mode -- so the previous read was
|
||||
// false twice over. `status` is what the endpoint actually answers with,
|
||||
// and what the dashboard and profile badges already read.
|
||||
const info = await getVersionInfo();
|
||||
setVersion((info?.localVersion as string) ?? null);
|
||||
setUpdateAvailable(info?.status === "requires_update");
|
||||
}
|
||||
if (showDbHealth) {
|
||||
const health = await getDatabaseHealth();
|
||||
setDbStatus((health?.status as string) ?? "unknown");
|
||||
}
|
||||
if (showUptime) {
|
||||
const up = await getUptime();
|
||||
setUptimeFormatted(up.formatted);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
return runVisibleInterval(() => {
|
||||
void fetchData();
|
||||
}, 300_000);
|
||||
}, [showVersion, showDbHealth, showUptime]);
|
||||
|
||||
const accent =
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle
|
||||
title={widget.title}
|
||||
icon={<Server size={11} />}
|
||||
fallback="Termix"
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5 p-3 flex-1 overflow-auto">
|
||||
{showVersion && (
|
||||
<InfoRow
|
||||
label={t("homepage.overviewVersion")}
|
||||
value={version ?? t("homepage.loading")}
|
||||
valueColor={updateAvailable ? "#f97316" : undefined}
|
||||
/>
|
||||
)}
|
||||
{showVersion && updateAvailable && (
|
||||
<InfoRow
|
||||
label={t("homepage.overviewUpdateLabel")}
|
||||
value={t("homepage.overviewUpdateAvailable")}
|
||||
valueColor="#f97316"
|
||||
/>
|
||||
)}
|
||||
{showDbHealth && (
|
||||
<InfoRow
|
||||
label={t("homepage.overviewDatabase")}
|
||||
value={dbStatus ? dbStatus.toUpperCase() : t("homepage.loading")}
|
||||
valueColor={
|
||||
dbStatus === "ok" || dbStatus === "healthy"
|
||||
? accent
|
||||
: dbStatus
|
||||
? "#ef4444"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showUptime && (
|
||||
<InfoRow
|
||||
label={t("homepage.overviewUptime")}
|
||||
value={uptimeFormatted ?? t("homepage.loading")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<SystemOverviewConfig>({
|
||||
id: "system_overview",
|
||||
name: "System Overview",
|
||||
description: "Termix version, database health, and uptime at a glance",
|
||||
category: "system",
|
||||
icon: <Server size={14} />,
|
||||
defaultConfig: { showVersion: true, showDbHealth: true, showUptime: true },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 6 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
|
||||
component: SystemOverviewWidget,
|
||||
});
|
||||
|
||||
export { SystemOverviewWidget };
|
||||
@@ -1,144 +0,0 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { Clock4 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
TermixUptimeConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { getUptime } from "@/api/dashboard-api";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
import { runVisibleInterval } from "../use-visible-interval";
|
||||
|
||||
function formatUptime(seconds: number): {
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
secs: number;
|
||||
} {
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return { days, hours, minutes, secs };
|
||||
}
|
||||
|
||||
function TermixUptimeWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<TermixUptimeConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { showDetailed } = config;
|
||||
const [seconds, setSeconds] = useState<number | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const baseRef = useRef<number | null>(null);
|
||||
const startRef = useRef<number>(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
getUptime()
|
||||
.then((info) => {
|
||||
baseRef.current = info.uptimeSeconds;
|
||||
startRef.current = Date.now();
|
||||
setSeconds(info.uptimeSeconds);
|
||||
})
|
||||
.catch(() => setError(true));
|
||||
|
||||
return runVisibleInterval(() => {
|
||||
if (baseRef.current !== null) {
|
||||
const elapsed = (Date.now() - startRef.current) / 1000;
|
||||
setSeconds(Math.floor(baseRef.current + elapsed));
|
||||
}
|
||||
}, 1000);
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.uptimeUnavailable")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (seconds === null) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { days, hours, minutes, secs } = formatUptime(seconds);
|
||||
const accent =
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<Clock4 size={11} />} />
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-1 p-2">
|
||||
<Clock4 size={14} className="text-muted-foreground" />
|
||||
<div className="flex items-baseline gap-1 flex-wrap justify-center">
|
||||
{days > 0 && (
|
||||
<span
|
||||
className="text-xl font-bold tabular-nums"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
{days}
|
||||
<span className="text-xs font-normal text-muted-foreground ml-0.5">
|
||||
d
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="text-xl font-bold tabular-nums"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
{String(hours).padStart(2, "0")}
|
||||
<span className="text-xs font-normal text-muted-foreground ml-0.5">
|
||||
h
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="text-xl font-bold tabular-nums"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
{String(minutes).padStart(2, "0")}
|
||||
<span className="text-xs font-normal text-muted-foreground ml-0.5">
|
||||
m
|
||||
</span>
|
||||
</span>
|
||||
{showDetailed && (
|
||||
<span
|
||||
className="text-xl font-bold tabular-nums"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
{String(secs).padStart(2, "0")}
|
||||
<span className="text-xs font-normal text-muted-foreground ml-0.5">
|
||||
s
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("homepage.uptimeLabel")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<TermixUptimeConfig>({
|
||||
id: "termix_uptime",
|
||||
name: "Termix Uptime",
|
||||
description: "Shows how long the Termix server has been running",
|
||||
category: "system",
|
||||
icon: <Clock4 size={14} />,
|
||||
defaultConfig: { showDetailed: false },
|
||||
defaultSize: { w: GRID_SIZE * 8, h: GRID_SIZE * 4 },
|
||||
minSize: { w: GRID_SIZE * 3, h: GRID_SIZE * 2 },
|
||||
component: TermixUptimeWidget,
|
||||
});
|
||||
|
||||
export { TermixUptimeWidget };
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Type } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
TextBannerConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
|
||||
const FONT_SIZE_MAP = {
|
||||
sm: "text-xs",
|
||||
md: "text-sm",
|
||||
lg: "text-base",
|
||||
xl: "text-xl",
|
||||
};
|
||||
|
||||
const FONT_WEIGHT_MAP = {
|
||||
normal: "font-normal",
|
||||
semibold: "font-semibold",
|
||||
bold: "font-bold",
|
||||
};
|
||||
|
||||
const TEXT_ALIGN_MAP = {
|
||||
left: "text-left",
|
||||
center: "text-center",
|
||||
right: "text-right",
|
||||
};
|
||||
|
||||
function TextBannerWidget({ config }: WidgetComponentProps<TextBannerConfig>) {
|
||||
const { text, fontSize, textAlign, fontWeight, backgroundColor } = config;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center w-full h-full px-3 py-2 overflow-hidden ${FONT_SIZE_MAP[fontSize]} ${FONT_WEIGHT_MAP[fontWeight]} ${TEXT_ALIGN_MAP[textAlign]}`}
|
||||
style={backgroundColor ? { background: backgroundColor } : undefined}
|
||||
>
|
||||
<span className="whitespace-pre-wrap break-words w-full text-foreground leading-tight">
|
||||
{text || "Banner text"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<TextBannerConfig>({
|
||||
id: "text_banner",
|
||||
name: "Text Banner",
|
||||
description: "A styled text label for sections and headings",
|
||||
category: "info",
|
||||
icon: <Type size={14} />,
|
||||
defaultConfig: {
|
||||
text: "",
|
||||
fontSize: "lg",
|
||||
textAlign: "center",
|
||||
fontWeight: "semibold",
|
||||
},
|
||||
defaultSize: { w: GRID_SIZE * 14, h: GRID_SIZE * 3 },
|
||||
minSize: { w: GRID_SIZE * 3, h: GRID_SIZE * 2 },
|
||||
component: TextBannerWidget,
|
||||
});
|
||||
|
||||
export { TextBannerWidget };
|
||||
@@ -1,201 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Cloud,
|
||||
CloudRain,
|
||||
Sun,
|
||||
CloudSnow,
|
||||
Wind,
|
||||
Thermometer,
|
||||
} from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
WeatherConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
import { runVisibleInterval } from "../use-visible-interval";
|
||||
|
||||
interface WttrCurrent {
|
||||
temp_C: string;
|
||||
FeelsLikeC: string;
|
||||
weatherDesc: { value: string }[];
|
||||
weatherCode: string;
|
||||
}
|
||||
|
||||
interface WttrDay {
|
||||
mintempC: string;
|
||||
maxtempC: string;
|
||||
hourly: { weatherDesc: { value: string }[]; weatherCode: string }[];
|
||||
}
|
||||
|
||||
interface WttrResponse {
|
||||
nearest_area: {
|
||||
areaName: { value: string }[];
|
||||
country: { value: string }[];
|
||||
}[];
|
||||
current_condition: WttrCurrent[];
|
||||
weather: WttrDay[];
|
||||
}
|
||||
|
||||
function weatherIcon(code: string, size = 20) {
|
||||
const n = Number(code);
|
||||
if (n === 113) return <Sun size={size} className="text-yellow-400" />;
|
||||
if (n <= 119) return <Cloud size={size} className="text-gray-400" />;
|
||||
if (n <= 143) return <Cloud size={size} className="text-gray-400" />;
|
||||
if (n <= 266) return <CloudRain size={size} className="text-blue-400" />;
|
||||
if (n <= 335) return <CloudSnow size={size} className="text-blue-200" />;
|
||||
if (n <= 389) return <CloudRain size={size} className="text-blue-500" />;
|
||||
return <Wind size={size} className="text-muted-foreground" />;
|
||||
}
|
||||
|
||||
function celsiusToF(c: number) {
|
||||
return Math.round((c * 9) / 5 + 32);
|
||||
}
|
||||
|
||||
function WeatherWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<WeatherConfig>) {
|
||||
const { location, unit, showForecast } = config;
|
||||
const [data, setData] = useState<WttrResponse | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!location) return;
|
||||
let cancelled = false;
|
||||
const fetchWeather = async () => {
|
||||
try {
|
||||
setError(false);
|
||||
const res = await fetch(
|
||||
`https://wttr.in/${encodeURIComponent(location)}?format=j1`,
|
||||
);
|
||||
if (!res.ok) throw new Error("bad response");
|
||||
const json: WttrResponse = await res.json();
|
||||
if (!cancelled) setData(json);
|
||||
} catch {
|
||||
if (!cancelled) setError(true);
|
||||
}
|
||||
};
|
||||
fetchWeather();
|
||||
const stop = runVisibleInterval(
|
||||
() => {
|
||||
void fetchWeather();
|
||||
},
|
||||
10 * 60 * 1000,
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
stop();
|
||||
};
|
||||
}, [location]);
|
||||
|
||||
if (!location) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60 p-3 text-center">
|
||||
Configure a location in widget settings
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-destructive/70 p-3 text-center">
|
||||
Could not load weather for "{location}"
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/50">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const current = data.current_condition[0];
|
||||
const area = data.nearest_area[0];
|
||||
const cityName = area?.areaName[0]?.value ?? location;
|
||||
const tempC = Number(current.temp_C);
|
||||
const feelsC = Number(current.FeelsLikeC);
|
||||
const displayTemp = unit === "F" ? `${celsiusToF(tempC)}°F` : `${tempC}°C`;
|
||||
const displayFeels = unit === "F" ? `${celsiusToF(feelsC)}°F` : `${feelsC}°C`;
|
||||
const desc = current.weatherDesc[0]?.value ?? "";
|
||||
const code = current.weatherCode;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<Sun size={11} />} />
|
||||
<div className="flex flex-col flex-1 p-3 gap-2 overflow-auto">
|
||||
{/* Current conditions */}
|
||||
<div className="flex items-center gap-3 flex-1 min-h-0">
|
||||
<div className="shrink-0">{weatherIcon(code, 32)}</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-2xl font-bold text-foreground leading-none">
|
||||
{displayTemp}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground truncate">
|
||||
{desc}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/70">
|
||||
Feels like {displayFeels}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Thermometer
|
||||
size={10}
|
||||
className="text-muted-foreground/60 shrink-0"
|
||||
/>
|
||||
<span className="text-[11px] font-semibold text-foreground truncate">
|
||||
{cityName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 3-day forecast */}
|
||||
{showForecast && data.weather.length >= 3 && (
|
||||
<div className="flex gap-1 border-t border-border pt-2 mt-auto shrink-0">
|
||||
{data.weather.slice(0, 3).map((day, i) => {
|
||||
const hourly = day.hourly[4] ?? day.hourly[0];
|
||||
const minC = Number(day.mintempC);
|
||||
const maxC = Number(day.maxtempC);
|
||||
const lo = unit === "F" ? `${celsiusToF(minC)}°` : `${minC}°`;
|
||||
const hi = unit === "F" ? `${celsiusToF(maxC)}°` : `${maxC}°`;
|
||||
const dayLabel = i === 0 ? "Today" : i === 1 ? "Tmrw" : "+2";
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 flex flex-col items-center gap-0.5"
|
||||
>
|
||||
<span className="text-[9px] text-muted-foreground/70">
|
||||
{dayLabel}
|
||||
</span>
|
||||
{weatherIcon(hourly?.weatherCode ?? "113", 12)}
|
||||
<span className="text-[9px] text-foreground">{hi}</span>
|
||||
<span className="text-[9px] text-muted-foreground/60">
|
||||
{lo}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<WeatherConfig>({
|
||||
id: "weather",
|
||||
name: "Weather",
|
||||
description: "Live weather for any location",
|
||||
category: "info",
|
||||
icon: <Sun size={14} />,
|
||||
defaultConfig: { location: "", unit: "C", showForecast: true },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 7 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: WeatherWidget,
|
||||
});
|
||||
|
||||
export { WeatherWidget };
|
||||
@@ -1,24 +0,0 @@
|
||||
import type {
|
||||
WidgetTypeDefinition,
|
||||
WidgetTypeId,
|
||||
} from "@/types/homepage-types";
|
||||
import { createRegistry } from "@/lib/registry";
|
||||
|
||||
/** Core widgets register on import; plugin widgets while their plugin runs. */
|
||||
const registry = createRegistry<WidgetTypeDefinition>();
|
||||
|
||||
export function registerWidget<C>(def: WidgetTypeDefinition<C>): () => void {
|
||||
return registry.register(def as unknown as WidgetTypeDefinition);
|
||||
}
|
||||
|
||||
export function getWidgetType(
|
||||
id: WidgetTypeId,
|
||||
): WidgetTypeDefinition | undefined {
|
||||
return registry.get(id);
|
||||
}
|
||||
|
||||
export function getAllWidgetTypes(): WidgetTypeDefinition[] {
|
||||
return registry.list();
|
||||
}
|
||||
|
||||
export const useWidgetTypes = registry.useList;
|
||||
@@ -1,169 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Pencil, Trash2, GripVertical } from "lucide-react";
|
||||
import type { CanvasWidget } from "@/types/homepage-types";
|
||||
import { getWidgetType, useWidgetTypes } from "./WidgetRegistry";
|
||||
import { PluginViewPlaceholder } from "@/plugin-host/PluginViewPlaceholder";
|
||||
|
||||
interface WidgetShellProps {
|
||||
widget: CanvasWidget;
|
||||
isLocked?: boolean;
|
||||
isReadOnly?: boolean;
|
||||
isDragging?: boolean;
|
||||
isResizing?: boolean;
|
||||
onStartDrag?: (e: React.MouseEvent, widget: CanvasWidget) => void;
|
||||
onStartResize?: (e: React.MouseEvent, widget: CanvasWidget) => void;
|
||||
onDelete?: (id: number) => void;
|
||||
onEdit?: (id: number) => void;
|
||||
onConfigUpdate?: (config: Record<string, unknown>) => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function WidgetShell({
|
||||
widget,
|
||||
isLocked,
|
||||
isReadOnly,
|
||||
isDragging,
|
||||
isResizing,
|
||||
onStartDrag,
|
||||
onStartResize,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onConfigUpdate,
|
||||
children,
|
||||
}: WidgetShellProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
// A plugin widget appears once its plugin starts.
|
||||
useWidgetTypes();
|
||||
const typeDef = getWidgetType(widget.typeId);
|
||||
const showControls =
|
||||
hovered && !isLocked && !isReadOnly && !isDragging && !isResizing;
|
||||
|
||||
const WidgetComponent = typeDef?.component;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-widget={widget.id}
|
||||
className={`absolute overflow-hidden select-none transition-shadow duration-150 ${
|
||||
hovered && !isLocked && !isReadOnly
|
||||
? "ring-1 ring-accent-brand/30 shadow-md"
|
||||
: "ring-1 ring-border/60"
|
||||
}`}
|
||||
style={{
|
||||
left: widget.x,
|
||||
top: widget.y,
|
||||
width: widget.w,
|
||||
height: widget.h,
|
||||
background: "var(--color-card)",
|
||||
opacity: isDragging ? 0.85 : 1,
|
||||
transform: isDragging ? "scale(1.02)" : undefined,
|
||||
boxShadow:
|
||||
isDragging || isResizing ? "0 8px 32px rgba(0,0,0,0.25)" : undefined,
|
||||
cursor:
|
||||
isLocked || isReadOnly ? "default" : isDragging ? "grabbing" : "grab",
|
||||
}}
|
||||
onWheel={(e) => {
|
||||
// Find the nearest scrollable ancestor within this widget
|
||||
let el = e.target as HTMLElement | null;
|
||||
while (el && el !== e.currentTarget) {
|
||||
const canScrollY = el.scrollHeight > el.clientHeight;
|
||||
const canScrollX = el.scrollWidth > el.clientWidth;
|
||||
if (canScrollY || canScrollX) {
|
||||
e.stopPropagation();
|
||||
break;
|
||||
}
|
||||
el = el.parentElement;
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onMouseDown={
|
||||
!isLocked && !isReadOnly ? (e) => onStartDrag?.(e, widget) : undefined
|
||||
}
|
||||
>
|
||||
{/* Widget content */}
|
||||
{children ??
|
||||
(WidgetComponent ? (
|
||||
<WidgetComponent
|
||||
widget={widget}
|
||||
config={widget.config as Record<string, unknown>}
|
||||
isReadOnly={isReadOnly}
|
||||
onConfigUpdate={onConfigUpdate}
|
||||
/>
|
||||
) : (
|
||||
<PluginViewPlaceholder kind="card" viewId={widget.typeId} compact />
|
||||
))}
|
||||
|
||||
{/* Hover controls */}
|
||||
{showControls && (
|
||||
<>
|
||||
{/* Top-right action bar */}
|
||||
<div
|
||||
className="absolute top-1 right-1 flex items-center gap-0.5 z-10"
|
||||
style={{ opacity: hovered ? 1 : 0, transition: "opacity 0.1s" }}
|
||||
>
|
||||
<div className="p-1 text-muted-foreground/40 cursor-grab">
|
||||
<GripVertical size={10} />
|
||||
</div>
|
||||
<button
|
||||
className="p-1 bg-background/90 hover:bg-background border border-border text-muted-foreground hover:text-foreground transition-colors"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit?.(widget.id);
|
||||
}}
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil size={10} />
|
||||
</button>
|
||||
<button
|
||||
className="p-1 bg-background/90 hover:bg-destructive border border-border text-muted-foreground hover:text-destructive-foreground transition-colors"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete?.(widget.id);
|
||||
}}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Resize handle — bottom right */}
|
||||
<div
|
||||
className="absolute bottom-0 right-0 w-5 h-5 cursor-se-resize z-10 flex items-end justify-end pb-0.5 pr-0.5"
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
onStartResize?.(e, widget);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
className="text-muted-foreground/50"
|
||||
>
|
||||
<line
|
||||
x1="2"
|
||||
y1="10"
|
||||
x2="10"
|
||||
y2="2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="6"
|
||||
y1="10"
|
||||
x2="10"
|
||||
y2="6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { PRESETS, type UiPreset } from "@/types/ui-preferences";
|
||||
import { sanitizeHostSidebarPreferences } from "@/types/host-sidebar-preferences";
|
||||
import { sanitizeCredentialSidebarPreferences } from "@/types/credential-sidebar-preferences";
|
||||
import { getRegisteredDashboardCard } from "@/dashboard/dashboard-cards-registry";
|
||||
|
||||
/**
|
||||
* Seeding a preset into the stores that already own their settings.
|
||||
@@ -25,31 +26,36 @@ import { sanitizeCredentialSidebarPreferences } from "@/types/credential-sidebar
|
||||
* overwrites layouts the user may have arranged by hand.
|
||||
*/
|
||||
|
||||
const DASHBOARD_SLOT_HEIGHTS: Record<string, number | null> = {
|
||||
// Core's own cards. A plugin's card carries its own height and panel on its
|
||||
// registration (registerDashboardCard's defaultHeight/defaultPanel), read
|
||||
// below, so this preset never has to spell the plugin's card id itself.
|
||||
const CORE_DASHBOARD_SLOT_HEIGHTS: Record<string, number | null> = {
|
||||
stats_bar: 96,
|
||||
counters_bar: 48,
|
||||
quick_actions: 160,
|
||||
host_status: null,
|
||||
recent_activity: null,
|
||||
network_graph: 360,
|
||||
service_links: 200,
|
||||
homepage_preview: 320,
|
||||
};
|
||||
|
||||
/** Cards that belong in the narrower side column rather than the main one. */
|
||||
const DASHBOARD_SIDE_CARDS = new Set(["recent_activity", "service_links"]);
|
||||
/** Core cards that belong in the narrower side column rather than the main one. */
|
||||
const CORE_DASHBOARD_SIDE_CARDS = new Set(["recent_activity"]);
|
||||
|
||||
function buildDashboardSlots(cardIds: string[]) {
|
||||
export function buildDashboardSlots(cardIds: string[]) {
|
||||
let mainOrder = 0;
|
||||
let sideOrder = 0;
|
||||
return cardIds.map((id) => {
|
||||
const panel = DASHBOARD_SIDE_CARDS.has(id) ? "side" : "main";
|
||||
const registered = getRegisteredDashboardCard(id);
|
||||
const panel =
|
||||
registered?.defaultPanel ??
|
||||
(CORE_DASHBOARD_SIDE_CARDS.has(id) ? "side" : "main");
|
||||
const height =
|
||||
registered?.defaultHeight ?? CORE_DASHBOARD_SLOT_HEIGHTS[id] ?? null;
|
||||
return {
|
||||
key: `${id}_0`,
|
||||
id,
|
||||
panel,
|
||||
order: panel === "side" ? sideOrder++ : mainOrder++,
|
||||
height: DASHBOARD_SLOT_HEIGHTS[id] ?? null,
|
||||
height,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
export function normalizeServiceLinkUrl(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "";
|
||||
return /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)
|
||||
? trimmed
|
||||
: `http://${trimmed}`;
|
||||
}
|
||||
|
||||
export function isValidServiceLinkUrl(value: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(normalizeServiceLinkUrl(value));
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -36,18 +36,6 @@ export const DASHBOARD_CARDS: DashboardCardConfig[] = [
|
||||
description: "Feed of recent connection events",
|
||||
defaultEnabled: true,
|
||||
},
|
||||
{
|
||||
id: "service_links",
|
||||
label: "Service Links",
|
||||
description: "Clickable buttons linking to services on your servers",
|
||||
defaultEnabled: false,
|
||||
},
|
||||
{
|
||||
id: "homepage_preview",
|
||||
label: "Homepage",
|
||||
description: "Scaled preview of your Homepage canvas",
|
||||
defaultEnabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const ACCENT_PRESET_COLORS = [
|
||||
|
||||
File renamed without changes.
File renamed without changes.
+3
-261
@@ -194,255 +194,6 @@
|
||||
"arrangeUnlockedSwitchedToManual": "Switched to manual sort so your order sticks",
|
||||
"arrangeUnlocked": "Unlock drag to rearrange"
|
||||
},
|
||||
"homepage": {
|
||||
"title": "Homepage",
|
||||
"addWidget": "Add Widget",
|
||||
"editWidget": "Edit Widget",
|
||||
"deleteWidget": "Delete",
|
||||
"widgetTypes": "Widget Types",
|
||||
"serviceLink": "Service Link",
|
||||
"folder": "Folder",
|
||||
"clock": "Clock",
|
||||
"notes": "Notes",
|
||||
"hostStatus": "Host Status",
|
||||
"bookmarkList": "Bookmarks",
|
||||
"zoomIn": "Zoom In",
|
||||
"zoomOut": "Zoom Out",
|
||||
"resetView": "Reset View",
|
||||
"lockLayout": "Lock Layout",
|
||||
"unlockLayout": "Unlock Layout",
|
||||
"noWidgets": "Right-click or click + to add your first widget",
|
||||
"openFullView": "Open Full View",
|
||||
"previewTitle": "Homepage Preview",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"title_label": "Title",
|
||||
"widgetTitlePlaceholder": "Widget title (optional)",
|
||||
"url": "URL",
|
||||
"imageUrl": "Custom Image URL",
|
||||
"imageUrlHint": "Leave blank to use the site favicon automatically",
|
||||
"showImage": "Show Image",
|
||||
"description": "Description",
|
||||
"color": "Color",
|
||||
"icon": "Icon",
|
||||
"expanded": "Expanded by default",
|
||||
"timezone": "Timezone",
|
||||
"invalidTimezone": "Invalid timezone. Use an IANA name such as America/New_York",
|
||||
"showSeconds": "Show seconds",
|
||||
"format12h": "12-hour",
|
||||
"format24h": "24-hour",
|
||||
"content": "Content",
|
||||
"backgroundColor": "Background Color",
|
||||
"host": "Host",
|
||||
"showMetrics": "Show Metrics",
|
||||
"links": "Links",
|
||||
"addLink": "Add Link",
|
||||
"linkLabel": "Label",
|
||||
"linkUrl": "URL",
|
||||
"removeLink": "Remove",
|
||||
"categoryLinks": "Links",
|
||||
"categoryInfo": "Info",
|
||||
"categorySystem": "System",
|
||||
"widgetServiceLinkDesc": "A clickable tile linking to a service URL",
|
||||
"widgetFolderDesc": "A container to group related widgets",
|
||||
"widgetClockDesc": "A live clock with configurable timezone",
|
||||
"widgetNotesDesc": "A markdown notes widget",
|
||||
"widgetHostStatusDesc": "Shows live CPU, memory and disk for an SSH host",
|
||||
"widgetBookmarkListDesc": "A list of quick links",
|
||||
"copyLink": "Copy Link",
|
||||
"linkCopied": "Link copied!",
|
||||
"location": "Location",
|
||||
"temperatureUnit": "Temperature Unit",
|
||||
"showForecast": "Show 3-day forecast",
|
||||
"scrolling": "Allow Scrolling",
|
||||
"feedUrl": "Feed URL",
|
||||
"maxItems": "Max Items",
|
||||
"showDescription": "Show Description",
|
||||
"widgetWeatherName": "Weather",
|
||||
"widgetWeatherDesc": "Live weather for any location",
|
||||
"widgetIframeName": "iFrame Embed",
|
||||
"widgetIframeDesc": "Embed any URL in an iframe",
|
||||
"widgetRssName": "RSS Feed",
|
||||
"widgetRssDesc": "Display items from an RSS or Atom feed",
|
||||
"dragToFolder": "Drag widgets here or use + to add",
|
||||
"showDisk": "Show Disk Usage",
|
||||
"addToFolder": "Add widget to folder",
|
||||
"noHostSelected": "No host selected",
|
||||
"selectHost": "Select a host...",
|
||||
"displayedMetrics": "Displayed Metrics",
|
||||
"metricCpu": "CPU",
|
||||
"metricMemory": "Memory",
|
||||
"metricDisk": "Disk",
|
||||
"metricUptime": "Uptime",
|
||||
"metricSystem": "System",
|
||||
"metricNetwork": "Network",
|
||||
"metricProcesses": "Processes",
|
||||
"categoryMonitoring": "Monitoring",
|
||||
"loading": "Loading...",
|
||||
"allClear": "All clear",
|
||||
"noPingUrls": "No URLs configured",
|
||||
"pingLabel": "Label",
|
||||
"addPingUrl": "Add URL",
|
||||
"showLatency": "Show Latency",
|
||||
"refreshInterval": "Refresh Interval",
|
||||
"seconds": "seconds",
|
||||
"filterActivityTypes": "Filter Types",
|
||||
"filterTypesHint": "Leave empty to show all",
|
||||
"showTimestamp": "Show Timestamp",
|
||||
"uptimeUnavailable": "Uptime unavailable",
|
||||
"uptimeLabel": "Uptime",
|
||||
"overviewVersion": "Version",
|
||||
"overviewUpdate": "Up to date",
|
||||
"overviewUpdateLabel": "Update",
|
||||
"overviewUpdateAvailable": "Update available",
|
||||
"overviewDatabase": "Database",
|
||||
"overviewUptime": "Uptime",
|
||||
"noHosts": "No hosts configured",
|
||||
"hostGridHosts": "Hosts",
|
||||
"hostGridAllHint": "Leave empty to show all hosts",
|
||||
"columns": "Columns",
|
||||
"showIp": "Show IP Address",
|
||||
"connectionType": "Connection Type",
|
||||
"layout": "Layout",
|
||||
"showStatus": "Show Status",
|
||||
"showHostName": "Show Host Name",
|
||||
"noDockerActivity": "No Docker activity",
|
||||
"noActivity": "No activity",
|
||||
"showAcknowledged": "Show Acknowledged",
|
||||
"widgetMetricsChartName": "Metrics Chart",
|
||||
"widgetMetricsChartDesc": "Historical CPU, memory, disk or network chart for a host",
|
||||
"widgetHostGridName": "Host Grid",
|
||||
"widgetHostGridDesc": "Grid view of SSH host statuses",
|
||||
"widgetPingStatusName": "Ping Status",
|
||||
"widgetPingStatusDesc": "HTTP ping status for one or more URLs",
|
||||
"widgetRecentActivityName": "Recent Activity",
|
||||
"widgetRecentActivityDesc": "Scrollable feed of recent Termix activity",
|
||||
"widgetTermixUptimeName": "Termix Uptime",
|
||||
"widgetTermixUptimeDesc": "Live uptime counter for the Termix server",
|
||||
"widgetSystemOverviewName": "System Overview",
|
||||
"widgetSystemOverviewDesc": "Termix version, database health and uptime at a glance",
|
||||
"widgetSshQuickConnectName": "SSH Quick Connect",
|
||||
"widgetSshQuickConnectDesc": "One-click buttons to open SSH sessions",
|
||||
"widgetDockerActivityName": "Docker Activity",
|
||||
"widgetDockerActivityDesc": "Recent Docker container events across all hosts",
|
||||
"widgetCalendarName": "Calendar",
|
||||
"widgetCalendarDesc": "A monthly calendar with today highlighted",
|
||||
"widgetCountdownName": "Countdown",
|
||||
"widgetCountdownDesc": "Countdown timer to a target date",
|
||||
"widgetSearchBarName": "Search Bar",
|
||||
"widgetSearchBarDesc": "Quick web search widget",
|
||||
"widgetTextBannerName": "Text Banner",
|
||||
"widgetTextBannerDesc": "A bold label or section header for the canvas",
|
||||
"widgetImageWidgetName": "Image",
|
||||
"widgetImageWidgetDesc": "Display an image from a URL",
|
||||
"widgetMarkdownNotesName": "Markdown Notes",
|
||||
"widgetMarkdownNotesDesc": "Rich notes with inline markdown rendering",
|
||||
"widgetCustomApiName": "Custom API",
|
||||
"widgetCustomApiDesc": "Fetch and display data from any JSON API",
|
||||
"widgetServiceGridName": "Service Grid",
|
||||
"widgetServiceGridDesc": "A configurable grid of service tile links",
|
||||
"widgetDashboardLinksName": "Dashboard Links",
|
||||
"widgetDashboardLinksDesc": "Display your configured service links from the dashboard",
|
||||
"widgetSearchLinksName": "Search Shortcuts",
|
||||
"widgetSearchLinksDesc": "Quick search shortcut buttons with inline input",
|
||||
"widgetLinkTreeName": "Link Tree",
|
||||
"widgetLinkTreeDesc": "Grouped sections of links with headings",
|
||||
"calMon": "Mo",
|
||||
"calTue": "Tu",
|
||||
"calWed": "We",
|
||||
"calThu": "Th",
|
||||
"calFri": "Fr",
|
||||
"calSat": "Sa",
|
||||
"calSun": "Su",
|
||||
"startOnMonday": "Start week on Monday",
|
||||
"countdownNoDate": "No target date set",
|
||||
"countdownPast": "Event has passed",
|
||||
"countdownDays": "days",
|
||||
"countdownHours": "hrs",
|
||||
"countdownMinutes": "min",
|
||||
"countdownSeconds": "sec",
|
||||
"countdownLabel": "Label",
|
||||
"countdownLabelPlaceholder": "e.g. Launch Day",
|
||||
"countdownShowDays": "Show Days",
|
||||
"countdownShowHours": "Show Hours",
|
||||
"targetDate": "Target Date",
|
||||
"searchEngine": "Search Engine",
|
||||
"customSearchUrl": "Custom Search URL",
|
||||
"searchPlaceholder": "Search...",
|
||||
"searchGo": "Go",
|
||||
"searchPlaceholderLabel": "Placeholder Text",
|
||||
"searchPlaceholderHint": "Text shown inside the search input",
|
||||
"openInNewTab": "Open in New Tab",
|
||||
"searchQueryPlaceholder": "Enter query...",
|
||||
"noSearchShortcuts": "No shortcuts configured",
|
||||
"addSearchShortcut": "Add Shortcut",
|
||||
"fontSize": "Font Size",
|
||||
"textAlign": "Text Align",
|
||||
"fontWeight": "Font Weight",
|
||||
"clearColor": "Clear Color",
|
||||
"imageFit": "Image Fit",
|
||||
"imageLinkUrl": "Link URL",
|
||||
"noImage": "No image URL set",
|
||||
"altText": "Alt Text",
|
||||
"altTextPlaceholder": "Describe the image",
|
||||
"renderMarkdown": "Render Markdown",
|
||||
"displayMode": "Display Mode",
|
||||
"displayField": "Display Field",
|
||||
"jsonPath": "JSON Path",
|
||||
"customApiLabel": "Label",
|
||||
"customApiLabelPlaceholder": "e.g. Temperature",
|
||||
"customApiUnit": "Unit",
|
||||
"customApiNoUrl": "No API URL configured",
|
||||
"customApiError": "Failed to fetch",
|
||||
"customApiNotArray": "Response is not an array",
|
||||
"addService": "Add Service",
|
||||
"showLabels": "Show Labels",
|
||||
"iconSize": "Icon Size",
|
||||
"noDashboardLinks": "No dashboard links configured",
|
||||
"noLimit": "No limit",
|
||||
"sectionHeading": "Section Heading",
|
||||
"addSection": "Add Section",
|
||||
"compactMode": "Compact Mode",
|
||||
"showDetailed": "Show Detailed",
|
||||
"selectHosts": "Select Hosts",
|
||||
"allHosts": "All hosts",
|
||||
"listLayout": "List",
|
||||
"gridLayout": "Grid",
|
||||
"terminal": "Terminal",
|
||||
"files": "Files",
|
||||
"docker": "Docker",
|
||||
"range15m": "15 minutes",
|
||||
"range1h": "1 hour",
|
||||
"range6h": "6 hours",
|
||||
"range24h": "24 hours",
|
||||
"metricNetRx": "Net Download",
|
||||
"metricNetTx": "Net Upload",
|
||||
"severityFilter": "Severity Filter",
|
||||
"filterAll": "All",
|
||||
"accentColor": "Accent Color",
|
||||
"widgetSshTerminalName": "SSH Terminal",
|
||||
"widgetSshTerminalDesc": "An inline SSH terminal connected to a configured host",
|
||||
"sshTerminalNoHost": "No host configured",
|
||||
"sshTerminalConnect": "Connect",
|
||||
"sshTerminalAutoConnect": "Auto-connect on load",
|
||||
"widgetQuickConnectName": "Quick Connect",
|
||||
"widgetQuickConnectDesc": "One-click launch buttons for any connection type across your hosts",
|
||||
"connectionTypes": "Connection Types",
|
||||
"connType_terminal": "Terminal",
|
||||
"connType_files": "File Manager",
|
||||
"connType_docker": "Docker",
|
||||
"connType_host-metrics": "Host Metrics",
|
||||
"connType_rdp": "RDP",
|
||||
"connType_vnc": "VNC",
|
||||
"connType_telnet": "Telnet",
|
||||
"widgetFileManagerName": "File Manager",
|
||||
"widgetFileManagerDesc": "Embedded SFTP file manager for a configured host",
|
||||
"widgetDockerName": "Docker Manager",
|
||||
"widgetDockerDesc": "Embedded Docker container manager for a configured host",
|
||||
"widgetTunnelName": "Tunnel Manager",
|
||||
"widgetTunnelDesc": "Embedded SSH tunnel manager for a configured host",
|
||||
"widgetNoHostSelected": "No host configured"
|
||||
},
|
||||
"serverConfig": {
|
||||
"title": "Server Configuration",
|
||||
"description": "Configure the Termix server URL to connect to your backend services",
|
||||
@@ -539,6 +290,9 @@
|
||||
"dismiss": "Dismiss",
|
||||
"more": "More",
|
||||
"loading": "Loading...",
|
||||
"host": "Host",
|
||||
"selectHost": "Select a host...",
|
||||
"noHostConfigured": "No host configured",
|
||||
"optional": "Optional",
|
||||
"connect": "Connect",
|
||||
"copied": "Copied",
|
||||
@@ -682,7 +436,6 @@
|
||||
"renameTab": "Rename tab",
|
||||
"openFileManager": "Open File Manager",
|
||||
"dashboard": "Dashboard",
|
||||
"homepage": "Homepage",
|
||||
"quickConnect": "Quick Connect",
|
||||
"sshTools": "SSH Tools",
|
||||
"history": "History",
|
||||
@@ -1904,8 +1657,6 @@
|
||||
"panelMain": "Main",
|
||||
"panelSide": "Side",
|
||||
"justNow": "just now",
|
||||
"serviceLinks": "Service Links",
|
||||
"homepagePreview": "Homepage Preview",
|
||||
"activityFileManager": "File manager"
|
||||
},
|
||||
"donation": {
|
||||
@@ -1935,15 +1686,6 @@
|
||||
"editModeInstructions": "Drag cards to reorder · Drag the column divider to resize columns · Drag the bottom edge of a card to resize its height · Trash to remove",
|
||||
"empty": "Empty",
|
||||
"clear": "Clear",
|
||||
"serviceLinksTitle": "Service Links",
|
||||
"serviceLinksEmpty": "No service links yet",
|
||||
"serviceLinksAddLabel": "Label",
|
||||
"serviceLinksAddUrl": "URL",
|
||||
"serviceLinksAdd": "Add",
|
||||
"serviceLinksLabelPlaceholder": "My Service",
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Enter a valid web address",
|
||||
"serviceLinksAddFailed": "Failed to add service link",
|
||||
"viewServerDetails": "View server details"
|
||||
},
|
||||
"admin": {
|
||||
|
||||
@@ -195,9 +195,6 @@
|
||||
"arrangeUnlocked": "Ontsluit sleep om te herrangskik"
|
||||
},
|
||||
"homepage": {
|
||||
"title": "Tuisblad",
|
||||
"addWidget": "Voeg Widget by",
|
||||
"editWidget": "Wysig Widget",
|
||||
"deleteWidget": "Vee uit",
|
||||
"widgetTypes": "Widgettipes",
|
||||
"serviceLink": "Diens skakel",
|
||||
@@ -206,116 +203,40 @@
|
||||
"notes": "Notas",
|
||||
"hostStatus": "Gasheerstatus",
|
||||
"bookmarkList": "Boekmerke",
|
||||
"zoomIn": "Inzoem",
|
||||
"zoomOut": "Uitzoom",
|
||||
"resetView": "Herstel aansig",
|
||||
"lockLayout": "Sluituitleg",
|
||||
"unlockLayout": "Ontsluit uitleg",
|
||||
"noWidgets": "Regskliek of klik + om jou eerste widget by te voeg",
|
||||
"openFullView": "Maak volle aansig oop",
|
||||
"previewTitle": "Tuisbladvoorskou",
|
||||
"cancel": "Kanselleer",
|
||||
"save": "Stoor",
|
||||
"title_label": "Titel",
|
||||
"widgetTitlePlaceholder": "Widgettitel (opsioneel)",
|
||||
"url": "URL",
|
||||
"imageUrl": "Pasgemaakte beeld-URL",
|
||||
"imageUrlHint": "Los leeg om die webwerf se gunstelingikoon outomaties te gebruik",
|
||||
"showImage": "Wys Beeld",
|
||||
"description": "Beskrywing",
|
||||
"color": "Kleur",
|
||||
"icon": "Ikoon",
|
||||
"expanded": "Uitgebreid by verstek",
|
||||
"timezone": "Tydsone",
|
||||
"invalidTimezone": "Ongeldige tydsone. Gebruik 'n IANA-naam soos Amerika/New_York",
|
||||
"showSeconds": "Wys sekondes",
|
||||
"format12h": "12-uur",
|
||||
"format24h": "24-uur",
|
||||
"content": "Inhoud",
|
||||
"backgroundColor": "Agtergrondkleur",
|
||||
"host": "Gasheer",
|
||||
"showMetrics": "Wys Metrieke",
|
||||
"links": "Skakels",
|
||||
"addLink": "Voeg skakel by",
|
||||
"linkLabel": "Etiket",
|
||||
"linkUrl": "URL",
|
||||
"removeLink": "Verwyder",
|
||||
"categoryLinks": "Skakels",
|
||||
"categoryInfo": "Inligting",
|
||||
"categorySystem": "Stelsel",
|
||||
"widgetServiceLinkDesc": "'n Klikbare teël wat na 'n diens-URL skakel",
|
||||
"widgetFolderDesc": "'n Houer om verwante widgets te groepeer",
|
||||
"widgetClockDesc": "'n Regstreekse horlosie met konfigureerbare tydsone",
|
||||
"widgetNotesDesc": "'n Notas-widget vir afmerkings",
|
||||
"widgetHostStatusDesc": "Toon lewendige SVE, geheue en skyf vir 'n SSH-gasheer",
|
||||
"widgetBookmarkListDesc": "'n Lys van vinnige skakels",
|
||||
"copyLink": "Kopieer skakel",
|
||||
"linkCopied": "Skakel gekopieer!",
|
||||
"location": "Ligging",
|
||||
"temperatureUnit": "Temperatuureenheid",
|
||||
"showForecast": "Wys 3-dae voorspelling",
|
||||
"scrolling": "Laat blaai toe",
|
||||
"feedUrl": "Voer-URL",
|
||||
"maxItems": "Maksimum items",
|
||||
"showDescription": "Wys Beskrywing",
|
||||
"widgetWeatherName": "Weer",
|
||||
"widgetWeatherDesc": "Regstreekse weer vir enige plek",
|
||||
"widgetIframeName": "iFrame-inbedding",
|
||||
"widgetIframeDesc": "Voeg enige URL in 'n iframe in",
|
||||
"widgetRssName": "RSS-voer",
|
||||
"widgetRssDesc": "Wys items vanaf 'n RSS- of Atom-voer",
|
||||
"dragToFolder": "Sleep widgets hierheen of gebruik + om by te voeg",
|
||||
"showDisk": "Wys skyfgebruik",
|
||||
"addToFolder": "Voeg widget by vouer",
|
||||
"noHostSelected": "Geen gasheer gekies nie",
|
||||
"selectHost": "Kies 'n gasheer...",
|
||||
"displayedMetrics": "Vertoonde Metrieke",
|
||||
"metricCpu": "SVE",
|
||||
"metricMemory": "Geheue",
|
||||
"metricDisk": "Skyf",
|
||||
"metricUptime": "Optyd",
|
||||
"metricSystem": "Stelsel",
|
||||
"metricNetwork": "Netwerk",
|
||||
"metricProcesses": "Prosesse",
|
||||
"categoryMonitoring": "Monitering",
|
||||
"loading": "Laai tans...",
|
||||
"allClear": "Alles duidelik",
|
||||
"noPingUrls": "Geen URL'e gekonfigureer nie",
|
||||
"pingLabel": "Etiket",
|
||||
"addPingUrl": "Voeg URL by",
|
||||
"showLatency": "Wys Latensie",
|
||||
"refreshInterval": "Verversingsinterval",
|
||||
"seconds": "sekondes",
|
||||
"filterActivityTypes": "Filtertipes",
|
||||
"filterTypesHint": "Los leeg om alles te wys",
|
||||
"showTimestamp": "Wys Tydstempel",
|
||||
"uptimeUnavailable": "Beskikbaar vir bedryfstyd",
|
||||
"uptimeLabel": "Optyd",
|
||||
"overviewVersion": "Weergawe",
|
||||
"overviewUpdate": "Op datum",
|
||||
"overviewUpdateLabel": "Opdatering",
|
||||
"overviewUpdateAvailable": "Opdatering beskikbaar",
|
||||
"overviewDatabase": "Databasis",
|
||||
"overviewUptime": "Optyd",
|
||||
"noHosts": "Geen gashere gekonfigureer nie",
|
||||
"hostGridHosts": "Gashere",
|
||||
"hostGridAllHint": "Los leeg om alle gashere te wys",
|
||||
"columns": "Kolomme",
|
||||
"showIp": "Wys IP-adres",
|
||||
"connectionType": "Verbindingstipe",
|
||||
"layout": "Uitleg",
|
||||
"showStatus": "Wys Status",
|
||||
"showHostName": "Wys gasheernaam",
|
||||
"noDockerActivity": "Geen Docker-aktiwiteit nie",
|
||||
"noActivity": "Geen aktiwiteit nie",
|
||||
"showAcknowledged": "Wys Erken",
|
||||
"widgetMetricsChartName": "Metrieke Grafiek",
|
||||
"widgetMetricsChartDesc": "Historiese SVE-, geheue-, skyf- of netwerkgrafiek vir 'n gasheer",
|
||||
"widgetHostGridName": "Gasheerrooster",
|
||||
"widgetHostGridDesc": "Roosteraansig van SSH-gasheerstatusse",
|
||||
"widgetPingStatusName": "Ping-status",
|
||||
"widgetPingStatusDesc": "HTTP-pingstatus vir een of meer URL'e",
|
||||
"widgetRecentActivityName": "Onlangse Aktiwiteit",
|
||||
"widgetRecentActivityDesc": "Blaaibare stroom van onlangse Termix-aktiwiteit",
|
||||
"widgetTermixUptimeName": "Termix-optyd",
|
||||
"widgetTermixUptimeDesc": "Regstreekse optydteller vir die Termix-bediener",
|
||||
@@ -347,67 +268,8 @@
|
||||
"widgetSearchLinksDesc": "Vinnige soektog-kortpadknoppies met inlyn-invoer",
|
||||
"widgetLinkTreeName": "Skakelboom",
|
||||
"widgetLinkTreeDesc": "Gegroepeerde afdelings van skakels met opskrifte",
|
||||
"calMon": "Ma",
|
||||
"calTue": "Di",
|
||||
"calWed": "Ons",
|
||||
"calThu": "Do",
|
||||
"calFri": "Vr",
|
||||
"calSat": "Sa",
|
||||
"calSun": "So",
|
||||
"startOnMonday": "Begin week op Maandag",
|
||||
"countdownNoDate": "Geen teikendatum gestel nie",
|
||||
"countdownPast": "Gebeurtenis het verbygegaan",
|
||||
"countdownDays": "dae",
|
||||
"countdownHours": "ure",
|
||||
"countdownMinutes": "min",
|
||||
"countdownSeconds": "sek",
|
||||
"countdownLabel": "Etiket",
|
||||
"countdownLabelPlaceholder": "bv. Bekendstellingsdag",
|
||||
"countdownShowDays": "Skou Dae",
|
||||
"countdownShowHours": "Wys Ure",
|
||||
"targetDate": "Teikendatum",
|
||||
"searchEngine": "Soekenjin",
|
||||
"customSearchUrl": "Pasgemaakte soektog-URL",
|
||||
"searchPlaceholder": "Soek...",
|
||||
"searchGo": "Gaan",
|
||||
"searchPlaceholderLabel": "Plekhouerteks",
|
||||
"searchPlaceholderHint": "Teks wat binne die soekinvoer gewys word",
|
||||
"openInNewTab": "Maak oop in Nuwe Oortjie",
|
||||
"searchQueryPlaceholder": "Voer navraag in...",
|
||||
"noSearchShortcuts": "Geen kortpaaie gekonfigureer nie",
|
||||
"addSearchShortcut": "Voeg kortpad by",
|
||||
"fontSize": "Lettergrootte",
|
||||
"textAlign": "Teksbelyning",
|
||||
"fontWeight": "Lettertipe Gewig",
|
||||
"clearColor": "Duidelike Kleur",
|
||||
"imageFit": "Beeldpassing",
|
||||
"imageLinkUrl": "Skakel-URL",
|
||||
"noImage": "Geen beeld-URL gestel nie",
|
||||
"altText": "Alt-teks",
|
||||
"altTextPlaceholder": "Beskryf die beeld",
|
||||
"renderMarkdown": "Lewer Markdown",
|
||||
"displayMode": "Vertoonmodus",
|
||||
"displayField": "Vertoonveld",
|
||||
"jsonPath": "JSON-pad",
|
||||
"customApiLabel": "Etiket",
|
||||
"customApiLabelPlaceholder": "bv. Temperatuur",
|
||||
"customApiUnit": "Eenheid",
|
||||
"customApiNoUrl": "Geen API-URL gekonfigureer nie",
|
||||
"customApiError": "Kon nie haal nie",
|
||||
"customApiNotArray": "Antwoord is nie 'n skikking nie",
|
||||
"addService": "Voeg Diens by",
|
||||
"showLabels": "Wys etikette",
|
||||
"iconSize": "Ikoongrootte",
|
||||
"noDashboardLinks": "Geen dashboard-skakels gekonfigureer nie",
|
||||
"noLimit": "Geen limiet nie",
|
||||
"sectionHeading": "Afdelingsopskrif",
|
||||
"addSection": "Voeg Afdeling by",
|
||||
"compactMode": "Kompakte modus",
|
||||
"showDetailed": "Wys Gedetailleerde",
|
||||
"selectHosts": "Kies gashere",
|
||||
"allHosts": "Alle gashere",
|
||||
"listLayout": "Lys",
|
||||
"gridLayout": "Rooster",
|
||||
"terminal": "Terminaal",
|
||||
"files": "Lêers",
|
||||
"docker": "Docker",
|
||||
@@ -422,12 +284,8 @@
|
||||
"accentColor": "Aksentkleur",
|
||||
"widgetSshTerminalName": "SSH-terminaal",
|
||||
"widgetSshTerminalDesc": "'n Inlyn SSH-terminaal gekoppel aan 'n gekonfigureerde gasheer",
|
||||
"sshTerminalNoHost": "Geen gasheer gekonfigureer nie",
|
||||
"sshTerminalConnect": "Verbind",
|
||||
"sshTerminalAutoConnect": "Koppel outomaties tydens laai",
|
||||
"widgetQuickConnectName": "Vinnige verbinding",
|
||||
"widgetQuickConnectDesc": "Een-klik-bekendstellingsknoppies vir enige verbindingstipe oor jou gashere",
|
||||
"connectionTypes": "Verbindingstipes",
|
||||
"connType_terminal": "Terminaal",
|
||||
"connType_files": "Lêerbestuurder",
|
||||
"connType_docker": "Docker",
|
||||
@@ -685,7 +543,6 @@
|
||||
"renameTab": "Hernoem oortjie",
|
||||
"openFileManager": "Maak Lêerbestuurder oop",
|
||||
"dashboard": "Dashboard",
|
||||
"homepage": "Tuisblad",
|
||||
"quickConnect": "Vinnige verbinding",
|
||||
"sshTools": "SSH-gereedskap",
|
||||
"history": "Geskiedenis",
|
||||
@@ -1895,9 +1752,7 @@
|
||||
"serverStatsCard": "Bedienerstatistieke",
|
||||
"panelMain": "Hoof",
|
||||
"panelSide": "Kant",
|
||||
"justNow": "nou net",
|
||||
"serviceLinks": "Diens skakels",
|
||||
"homepagePreview": "Tuisbladvoorskou"
|
||||
"justNow": "nou net"
|
||||
},
|
||||
"donation": {
|
||||
"title": "Geniet jy Termix?",
|
||||
@@ -1926,15 +1781,8 @@
|
||||
"editModeInstructions": "Sleep kaarte om te herrangskik · Sleep die kolomverdeler om kolomme se grootte te verander · Sleep die onderste rand van 'n kaart om die hoogte daarvan te verander · Asblik om te verwyder",
|
||||
"empty": "Leeg",
|
||||
"clear": "Duidelik",
|
||||
"serviceLinksTitle": "Diens skakels",
|
||||
"serviceLinksEmpty": "Geen diens skakels nog nie",
|
||||
"serviceLinksAddLabel": "Etiket",
|
||||
"serviceLinksAddUrl": "URL",
|
||||
"serviceLinksAdd": "Voeg by",
|
||||
"serviceLinksLabelPlaceholder": "My Diens",
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Voer 'n geldige webadres in",
|
||||
"serviceLinksAddFailed": "Kon nie diensskakel byvoeg nie",
|
||||
"viewServerDetails": "Bekyk bedienerbesonderhede"
|
||||
},
|
||||
"admin": {
|
||||
|
||||
@@ -195,9 +195,6 @@
|
||||
"arrangeUnlocked": "قم بإلغاء القفل واسحب لإعادة الترتيب"
|
||||
},
|
||||
"homepage": {
|
||||
"title": "الصفحة الرئيسية",
|
||||
"addWidget": "إضافة ويدجت",
|
||||
"editWidget": "تعديل ويدجت",
|
||||
"deleteWidget": "حذف",
|
||||
"widgetTypes": "أنواع الأدوات",
|
||||
"serviceLink": "رابط الخدمة",
|
||||
@@ -206,116 +203,40 @@
|
||||
"notes": "ملاحظات",
|
||||
"hostStatus": "حالة المضيف",
|
||||
"bookmarkList": "علامات مرجعية",
|
||||
"zoomIn": "تكبير",
|
||||
"zoomOut": "تصغير",
|
||||
"resetView": "إعادة تعيين العرض",
|
||||
"lockLayout": "قفل التخطيط",
|
||||
"unlockLayout": "إلغاء قفل التخطيط",
|
||||
"noWidgets": "انقر بزر الماوس الأيمن أو انقر على + لإضافة أداتك الأولى",
|
||||
"openFullView": "فتح العرض الكامل",
|
||||
"previewTitle": "معاينة الصفحة الرئيسية",
|
||||
"cancel": "إلغاء",
|
||||
"save": "حفظ",
|
||||
"title_label": "العنوان",
|
||||
"widgetTitlePlaceholder": "عنوان الأداة (اختياري)",
|
||||
"url": "URL",
|
||||
"imageUrl": "URL صورة مخصصة",
|
||||
"imageUrlHint": "اتركه فارغًا لاستخدام أيقونة الموقع تلقائيًا",
|
||||
"showImage": "إظهار الصورة",
|
||||
"description": "الوصف",
|
||||
"color": "اللون",
|
||||
"icon": "الأيقونة",
|
||||
"expanded": "موسَّع بشكل افتراضي",
|
||||
"timezone": "المنطقة الزمنية",
|
||||
"invalidTimezone": "المنطقة الزمنية غير صالحة. استخدم اسمًا من IANA مثل America/New_York",
|
||||
"showSeconds": "عرض الثواني",
|
||||
"format12h": "12 ساعة",
|
||||
"format24h": "24 ساعة",
|
||||
"content": "المحتوى",
|
||||
"backgroundColor": "لون الخلفية",
|
||||
"host": "المضيف",
|
||||
"showMetrics": "عرض المقاييس",
|
||||
"links": "روابط",
|
||||
"addLink": "إضافة رابط",
|
||||
"linkLabel": "التسمية",
|
||||
"linkUrl": "URL",
|
||||
"removeLink": "إزالة",
|
||||
"categoryLinks": "روابط",
|
||||
"categoryInfo": "معلومات",
|
||||
"categorySystem": "النظام",
|
||||
"widgetServiceLinkDesc": "بلاطة قابلة للنقر تؤدي إلى رابط خدمة",
|
||||
"widgetFolderDesc": "حاوية لتجميع الأدوات ذات الصلة",
|
||||
"widgetClockDesc": "ساعة مباشرة مع منطقة زمنية قابلة للتكوين",
|
||||
"widgetNotesDesc": "أداة ملاحظات بتنسيق Markdown",
|
||||
"widgetHostStatusDesc": "يعرض بيانات المعالج والذاكرة والقرص لخادم SSH مباشرة",
|
||||
"widgetBookmarkListDesc": "قائمة بالروابط السريعة",
|
||||
"copyLink": "نسخ الرابط",
|
||||
"linkCopied": "تم نسخ الرابط!",
|
||||
"location": "الموقع",
|
||||
"temperatureUnit": "وحدة درجة الحرارة",
|
||||
"showForecast": "إظهار توقعات 3 أيام",
|
||||
"scrolling": "السماح بالتمرير",
|
||||
"feedUrl": "رابط التغذية",
|
||||
"maxItems": "الحد الأقصى للعناصر",
|
||||
"showDescription": "إظهار الوصف",
|
||||
"widgetWeatherName": "الطقس",
|
||||
"widgetWeatherDesc": "حالة الطقس المباشرة لأي موقع",
|
||||
"widgetIframeName": "تضمين iFrame",
|
||||
"widgetIframeDesc": "تضمين أي رابط في إطار iFrame",
|
||||
"widgetRssName": "تغذية RSS",
|
||||
"widgetRssDesc": "عرض عناصر من تغذية RSS أو Atom",
|
||||
"dragToFolder": "اسحب الأدوات هنا أو استخدم + للإضافة",
|
||||
"showDisk": "إظهار استخدام القرص",
|
||||
"addToFolder": "إضافة أداة إلى المجلد",
|
||||
"noHostSelected": "لم يتم تحديد أي مضيف",
|
||||
"selectHost": "اختيار مضيف...",
|
||||
"displayedMetrics": "المقاييس المعروضة",
|
||||
"metricCpu": "CPU",
|
||||
"metricMemory": "الذاكرة",
|
||||
"metricDisk": "القرص",
|
||||
"metricUptime": "وقت التشغيل",
|
||||
"metricSystem": "النظام",
|
||||
"metricNetwork": "الشبكة",
|
||||
"metricProcesses": "العمليات",
|
||||
"categoryMonitoring": "المراقبة",
|
||||
"loading": "جار التحميل...",
|
||||
"allClear": "كل شيء طبيعي",
|
||||
"noPingUrls": "لم يتم تكوين عناوين URL",
|
||||
"pingLabel": "التسمية",
|
||||
"addPingUrl": "إضافة URL",
|
||||
"showLatency": "إظهار زمن الانتقال",
|
||||
"refreshInterval": "فترة التحديث",
|
||||
"seconds": "ثانية",
|
||||
"filterActivityTypes": "أنواع التصفية",
|
||||
"filterTypesHint": "اترك فارغاً لإظهار الكل",
|
||||
"showTimestamp": "إظهار الطابع الزمني",
|
||||
"uptimeUnavailable": "وقت التشغيل غير متوفر",
|
||||
"uptimeLabel": "وقت التشغيل",
|
||||
"overviewVersion": "الإصدار",
|
||||
"overviewUpdate": "محدّث",
|
||||
"overviewUpdateLabel": "تحديث",
|
||||
"overviewUpdateAvailable": "تحديث متوفر",
|
||||
"overviewDatabase": "قاعدة البيانات",
|
||||
"overviewUptime": "وقت التشغيل",
|
||||
"noHosts": "لا توجد مضيفات مُهيأة",
|
||||
"hostGridHosts": "المضيفات",
|
||||
"hostGridAllHint": "اتركه فارغًا لعرض جميع المضيفات",
|
||||
"columns": "الأعمدة",
|
||||
"showIp": "إظهار عنوان IP",
|
||||
"connectionType": "نوع الاتصال",
|
||||
"layout": "التخطيط",
|
||||
"showStatus": "إظهار الحالة",
|
||||
"showHostName": "إظهار اسم المضيف",
|
||||
"noDockerActivity": "لا يوجد نشاط Docker",
|
||||
"noActivity": "لا يوجد نشاط",
|
||||
"showAcknowledged": "إظهار المُقر بها",
|
||||
"widgetMetricsChartName": "مخطط المقاييس",
|
||||
"widgetMetricsChartDesc": "مخطط تاريخي لوحدة المعالجة المركزية أو الذاكرة أو القرص أو الشبكة لمضيف",
|
||||
"widgetHostGridName": "شبكة المضيفات",
|
||||
"widgetHostGridDesc": "عرض شبكي لحالات مضيفات SSH",
|
||||
"widgetPingStatusName": "حالة Ping",
|
||||
"widgetPingStatusDesc": "حالة ping HTTP لعنوان URL واحد أو أكثر",
|
||||
"widgetRecentActivityName": "النشاط الأخير",
|
||||
"widgetRecentActivityDesc": "موجز قابل للتمرير للنشاط الأخير في Termix",
|
||||
"widgetTermixUptimeName": "مدة تشغيل Termix",
|
||||
"widgetTermixUptimeDesc": "عداد مباشر لمدة تشغيل خادم Termix",
|
||||
@@ -347,67 +268,8 @@
|
||||
"widgetSearchLinksDesc": "أزرار اختصارات بحث سريعة مع إدخال مضمن",
|
||||
"widgetLinkTreeName": "شجرة الروابط",
|
||||
"widgetLinkTreeDesc": "أقسام مجمّعة من الروابط مع عناوين",
|
||||
"calMon": "إثنين",
|
||||
"calTue": "ثلاثاء",
|
||||
"calWed": "أربعاء",
|
||||
"calThu": "خميس",
|
||||
"calFri": "جمعة",
|
||||
"calSat": "سبت",
|
||||
"calSun": "أحد",
|
||||
"startOnMonday": "بدء الأسبوع يوم الإثنين",
|
||||
"countdownNoDate": "لم يتم تحديد تاريخ مستهدف",
|
||||
"countdownPast": "انقضى الحدث",
|
||||
"countdownDays": "أيام",
|
||||
"countdownHours": "ساعات",
|
||||
"countdownMinutes": "دقائق",
|
||||
"countdownSeconds": "ثوانٍ",
|
||||
"countdownLabel": "تسمية",
|
||||
"countdownLabelPlaceholder": "مثال: يوم الإطلاق",
|
||||
"countdownShowDays": "إظهار الأيام",
|
||||
"countdownShowHours": "إظهار الساعات",
|
||||
"targetDate": "التاريخ المستهدف",
|
||||
"searchEngine": "محرك البحث",
|
||||
"customSearchUrl": "عنوان URL بحث مخصص",
|
||||
"searchPlaceholder": "بحث...",
|
||||
"searchGo": "تنفيذ",
|
||||
"searchPlaceholderLabel": "نص العنصر النائب",
|
||||
"searchPlaceholderHint": "النص المعروض داخل حقل البحث",
|
||||
"openInNewTab": "فتح في علامة تبويب جديدة",
|
||||
"searchQueryPlaceholder": "أدخل الاستعلام...",
|
||||
"noSearchShortcuts": "لا توجد اختصارات مكونة",
|
||||
"addSearchShortcut": "إضافة اختصار",
|
||||
"fontSize": "حجم الخط",
|
||||
"textAlign": "محاذاة النص",
|
||||
"fontWeight": "وزن الخط",
|
||||
"clearColor": "مسح اللون",
|
||||
"imageFit": "ملاءمة الصورة",
|
||||
"imageLinkUrl": "رابط URL",
|
||||
"noImage": "لم يتم تعيين عنوان URL للصورة",
|
||||
"altText": "نص بديل",
|
||||
"altTextPlaceholder": "وصف الصورة",
|
||||
"renderMarkdown": "عرض Markdown",
|
||||
"displayMode": "وضع العرض",
|
||||
"displayField": "حقل العرض",
|
||||
"jsonPath": "مسار JSON",
|
||||
"customApiLabel": "تسمية",
|
||||
"customApiLabelPlaceholder": "مثال: درجة الحرارة",
|
||||
"customApiUnit": "وحدة",
|
||||
"customApiNoUrl": "لم يتم تكوين عنوان URL API",
|
||||
"customApiError": "فشل في الجلب",
|
||||
"customApiNotArray": "الاستجابة ليست مصفوفة",
|
||||
"addService": "إضافة خدمة",
|
||||
"showLabels": "إظهار التسميات",
|
||||
"iconSize": "حجم الأيقونة",
|
||||
"noDashboardLinks": "لا توجد روابط لوحة تحكم مُعدة",
|
||||
"noLimit": "غير محدود",
|
||||
"sectionHeading": "عنوان القسم",
|
||||
"addSection": "إضافة قسم",
|
||||
"compactMode": "الوضع المضغوط",
|
||||
"showDetailed": "إظهار التفاصيل",
|
||||
"selectHosts": "اختيار المضيفين",
|
||||
"allHosts": "جميع المضيفين",
|
||||
"listLayout": "قائمة",
|
||||
"gridLayout": "شبكة",
|
||||
"terminal": "الطرفية",
|
||||
"files": "الملفات",
|
||||
"docker": "Docker",
|
||||
@@ -422,12 +284,8 @@
|
||||
"accentColor": "لون التمييز",
|
||||
"widgetSshTerminalName": "طرفية SSH",
|
||||
"widgetSshTerminalDesc": "طرفية SSH مضمنة متصلة بمضيف مُعد",
|
||||
"sshTerminalNoHost": "لم يتم إعداد مضيف",
|
||||
"sshTerminalConnect": "اتصل",
|
||||
"sshTerminalAutoConnect": "اتصال تلقائي عند التحميل",
|
||||
"widgetQuickConnectName": "اتصال سريع",
|
||||
"widgetQuickConnectDesc": "أزرار تشغيل بنقرة واحدة لأي نوع اتصال عبر مضيفيك",
|
||||
"connectionTypes": "أنواع الاتصال",
|
||||
"connType_terminal": "الطرفية",
|
||||
"connType_files": "مدير الملفات",
|
||||
"connType_docker": "Docker",
|
||||
@@ -685,7 +543,6 @@
|
||||
"renameTab": "إعادة تسمية علامة التبويب",
|
||||
"openFileManager": "فتح مدير الملفات",
|
||||
"dashboard": "لوحة المعلومات",
|
||||
"homepage": "الصفحة الرئيسية",
|
||||
"quickConnect": "اتصال سريع",
|
||||
"sshTools": "أدوات SSH",
|
||||
"history": "السجل",
|
||||
@@ -1895,9 +1752,7 @@
|
||||
"serverStatsCard": "إحصائيات الخادم",
|
||||
"panelMain": "الرئيسية",
|
||||
"panelSide": "الجانبية",
|
||||
"justNow": "للتو",
|
||||
"serviceLinks": "روابط الخدمات",
|
||||
"homepagePreview": "معاينة الصفحة الرئيسية"
|
||||
"justNow": "للتو"
|
||||
},
|
||||
"donation": {
|
||||
"title": "هل تستمتع بـ Termix؟",
|
||||
@@ -1926,15 +1781,8 @@
|
||||
"editModeInstructions": "اسحب البطاقات لإعادة الترتيب · اسحب مقسم العمود لتغيير حجم الأعمدة · اسحب الحافة السفلية للبطاقة لتغيير ارتفاعها · اسحب إلى سلة المهملات للإزالة",
|
||||
"empty": "فارغ",
|
||||
"clear": "مسح",
|
||||
"serviceLinksTitle": "روابط الخدمات",
|
||||
"serviceLinksEmpty": "لا توجد روابط خدمات بعد",
|
||||
"serviceLinksAddLabel": "التسمية",
|
||||
"serviceLinksAddUrl": "URL",
|
||||
"serviceLinksAdd": "إضافة",
|
||||
"serviceLinksLabelPlaceholder": "خدمتي",
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "أدخل عنوان ويب صالح",
|
||||
"serviceLinksAddFailed": "فشل في إضافة رابط الخدمة",
|
||||
"viewServerDetails": "عرض تفاصيل الخادم"
|
||||
},
|
||||
"admin": {
|
||||
|
||||
@@ -195,9 +195,6 @@
|
||||
"arrangeUnlocked": "Отключете плъзгането, за да пренаредите"
|
||||
},
|
||||
"homepage": {
|
||||
"title": "Начална страница",
|
||||
"addWidget": "Добавяне на джаджа",
|
||||
"editWidget": "Редактиране на уиджет",
|
||||
"deleteWidget": "Изтриване",
|
||||
"widgetTypes": "Видове джаджи",
|
||||
"serviceLink": "Връзка към услугата",
|
||||
@@ -206,116 +203,40 @@
|
||||
"notes": "Бележки",
|
||||
"hostStatus": "Състояние на хоста",
|
||||
"bookmarkList": "Отметки",
|
||||
"zoomIn": "Увеличаване на мащаба",
|
||||
"zoomOut": "Намаляване на мащаба",
|
||||
"resetView": "Нулиране на изгледа",
|
||||
"lockLayout": "Заключване на оформлението",
|
||||
"unlockLayout": "Отключване на оформлението",
|
||||
"noWidgets": "Щракнете с десния бутон или щракнете върху +, за да добавите първия си уиджет",
|
||||
"openFullView": "Отваряне на пълен изглед",
|
||||
"previewTitle": "Преглед на началната страница",
|
||||
"cancel": "Отказ",
|
||||
"save": "Запазване",
|
||||
"title_label": "Заглавие",
|
||||
"widgetTitlePlaceholder": "Заглавие на уиджета (по избор)",
|
||||
"url": "URL адрес",
|
||||
"imageUrl": "URL адрес на персонализирано изображение",
|
||||
"imageUrlHint": "Оставете празно, за да използвате автоматично фавиконката на сайта",
|
||||
"showImage": "Показване на изображение",
|
||||
"description": "Описание",
|
||||
"color": "Цвят",
|
||||
"icon": "Икона",
|
||||
"expanded": "Разширено по подразбиране",
|
||||
"timezone": "Часова зона",
|
||||
"invalidTimezone": "Невалидна часова зона. Използвайте име от IANA, като например America/New_York.",
|
||||
"showSeconds": "Показване на секунди",
|
||||
"format12h": "12-часов",
|
||||
"format24h": "24-часово",
|
||||
"content": "Съдържание",
|
||||
"backgroundColor": "Цвят на фона",
|
||||
"host": "Домакин",
|
||||
"showMetrics": "Показване на показатели",
|
||||
"links": "Връзки",
|
||||
"addLink": "Добавяне на връзка",
|
||||
"linkLabel": "Етикет",
|
||||
"linkUrl": "URL адрес",
|
||||
"removeLink": "Премахване",
|
||||
"categoryLinks": "Връзки",
|
||||
"categoryInfo": "Информация",
|
||||
"categorySystem": "Система",
|
||||
"widgetServiceLinkDesc": "Плочка, върху която може да се кликва, препращаща към URL адрес на услуга",
|
||||
"widgetFolderDesc": "Контейнер за групиране на свързани джаджи",
|
||||
"widgetClockDesc": "Жив часовник с конфигурируема часова зона",
|
||||
"widgetNotesDesc": "Уиджет за бележки в Markdown",
|
||||
"widgetHostStatusDesc": "Показва процесора, паметта и диска в реално време за SSH хост",
|
||||
"widgetBookmarkListDesc": "Списък с бързи връзки",
|
||||
"copyLink": "Копиране на връзката",
|
||||
"linkCopied": "Линкът е копиран!",
|
||||
"location": "Местоположение",
|
||||
"temperatureUnit": "Температурна единица",
|
||||
"showForecast": "Показване на 3-дневна прогноза",
|
||||
"scrolling": "Разрешаване на превъртане",
|
||||
"feedUrl": "URL адрес на емисията",
|
||||
"maxItems": "Максимален брой елементи",
|
||||
"showDescription": "Покажи описание",
|
||||
"widgetWeatherName": "Времето",
|
||||
"widgetWeatherDesc": "Времето на живо за всяко място",
|
||||
"widgetIframeName": "Вграждане на iFrame",
|
||||
"widgetIframeDesc": "Вграждане на произволен URL адрес във вложена рамка",
|
||||
"widgetRssName": "RSS емисия",
|
||||
"widgetRssDesc": "Показване на елементи от RSS или Atom емисия",
|
||||
"dragToFolder": "Плъзнете джаджи тук или използвайте +, за да добавите",
|
||||
"showDisk": "Показване на използването на диска",
|
||||
"addToFolder": "Добавяне на джаджа към папката",
|
||||
"noHostSelected": "Няма избран хост",
|
||||
"selectHost": "Изберете хост...",
|
||||
"displayedMetrics": "Показани показатели",
|
||||
"metricCpu": "Процесор",
|
||||
"metricMemory": "Памет",
|
||||
"metricDisk": "Диск",
|
||||
"metricUptime": "Време на работа",
|
||||
"metricSystem": "Система",
|
||||
"metricNetwork": "Мрежа",
|
||||
"metricProcesses": "Процеси",
|
||||
"categoryMonitoring": "Мониторинг",
|
||||
"loading": "Зареждане...",
|
||||
"allClear": "Всичко е чисто",
|
||||
"noPingUrls": "Няма конфигурирани URL адреси",
|
||||
"pingLabel": "Етикет",
|
||||
"addPingUrl": "Добавяне на URL адрес",
|
||||
"showLatency": "Покажи латентност",
|
||||
"refreshInterval": "Интервал на обновяване",
|
||||
"seconds": "секунди",
|
||||
"filterActivityTypes": "Видове филтри",
|
||||
"filterTypesHint": "Оставете празно, за да се покажат всички",
|
||||
"showTimestamp": "Показване на времева маркировка",
|
||||
"uptimeUnavailable": "Не е налично време за работа",
|
||||
"uptimeLabel": "Време на работа",
|
||||
"overviewVersion": "Версия",
|
||||
"overviewUpdate": "Актуално",
|
||||
"overviewUpdateLabel": "Актуализация",
|
||||
"overviewUpdateAvailable": "Налична е актуализация",
|
||||
"overviewDatabase": "База данни",
|
||||
"overviewUptime": "Време на работа",
|
||||
"noHosts": "Няма конфигурирани хостове",
|
||||
"hostGridHosts": "Домакини",
|
||||
"hostGridAllHint": "Оставете празно, за да се покажат всички хостове",
|
||||
"columns": "Колони",
|
||||
"showIp": "Показване на IP адрес",
|
||||
"connectionType": "Тип връзка",
|
||||
"layout": "Оформление",
|
||||
"showStatus": "Покажи състоянието",
|
||||
"showHostName": "Покажи името на хоста",
|
||||
"noDockerActivity": "Няма активност в Docker",
|
||||
"noActivity": "Няма активност",
|
||||
"showAcknowledged": "Покажи потвърдено",
|
||||
"widgetMetricsChartName": "Диаграма с показатели",
|
||||
"widgetMetricsChartDesc": "Историческа диаграма на процесора, паметта, диска или мрежата за хост",
|
||||
"widgetHostGridName": "Хост мрежа",
|
||||
"widgetHostGridDesc": "Табелен изглед на състоянията на SSH хоста",
|
||||
"widgetPingStatusName": "Състояние на пинг",
|
||||
"widgetPingStatusDesc": "HTTP ping статус за един или повече URL адреси",
|
||||
"widgetRecentActivityName": "Последна активност",
|
||||
"widgetRecentActivityDesc": "Превъртаща се емисия от скорошна активност на Termix",
|
||||
"widgetTermixUptimeName": "Време на работа на Termix",
|
||||
"widgetTermixUptimeDesc": "Брояч на времето за работа в реално време за Termix сървъра",
|
||||
@@ -347,67 +268,8 @@
|
||||
"widgetSearchLinksDesc": "Бутони за бързо търсене с вградено въвеждане",
|
||||
"widgetLinkTreeName": "Дърво на връзките",
|
||||
"widgetLinkTreeDesc": "Групирани секции от връзки със заглавия",
|
||||
"calMon": "Мо",
|
||||
"calTue": "Вт",
|
||||
"calWed": "Ние",
|
||||
"calThu": "Четвъртък",
|
||||
"calFri": "Пт",
|
||||
"calSat": "Съ",
|
||||
"calSun": "Су",
|
||||
"startOnMonday": "Започнете седмицата в понеделник",
|
||||
"countdownNoDate": "Няма определена целева дата",
|
||||
"countdownPast": "Събитието е отминало",
|
||||
"countdownDays": "дни",
|
||||
"countdownHours": "часа",
|
||||
"countdownMinutes": "мин.",
|
||||
"countdownSeconds": "сек",
|
||||
"countdownLabel": "Етикет",
|
||||
"countdownLabelPlaceholder": "напр. Ден на стартиране",
|
||||
"countdownShowDays": "Дни на шоуто",
|
||||
"countdownShowHours": "Работно време на шоуто",
|
||||
"targetDate": "Целева дата",
|
||||
"searchEngine": "Търсачка",
|
||||
"customSearchUrl": "URL адрес за персонализирано търсене",
|
||||
"searchPlaceholder": "Търсене...",
|
||||
"searchGo": "Отиди",
|
||||
"searchPlaceholderLabel": "Текст на заместител",
|
||||
"searchPlaceholderHint": "Текст, показан във полето за търсене",
|
||||
"openInNewTab": "Отваряне в нов раздел",
|
||||
"searchQueryPlaceholder": "Въведете заявка...",
|
||||
"noSearchShortcuts": "Няма конфигурирани преки пътища",
|
||||
"addSearchShortcut": "Добавяне на пряк път",
|
||||
"fontSize": "Размер на шрифта",
|
||||
"textAlign": "Подравняване на текста",
|
||||
"fontWeight": "Тегло на шрифта",
|
||||
"clearColor": "Ясен цвят",
|
||||
"imageFit": "Напасване на изображението",
|
||||
"imageLinkUrl": "URL адрес на връзката",
|
||||
"noImage": "Няма зададен URL адрес на изображението",
|
||||
"altText": "Алтернативен текст",
|
||||
"altTextPlaceholder": "Опишете изображението",
|
||||
"renderMarkdown": "Рендиране на маркиране",
|
||||
"displayMode": "Режим на показване",
|
||||
"displayField": "Поле за показване",
|
||||
"jsonPath": "JSON път",
|
||||
"customApiLabel": "Етикет",
|
||||
"customApiLabelPlaceholder": "напр. Температура",
|
||||
"customApiUnit": "Единица",
|
||||
"customApiNoUrl": "Няма конфигуриран URL адрес на API",
|
||||
"customApiError": "Неуспешно извличане",
|
||||
"customApiNotArray": "Отговорът не е масив",
|
||||
"addService": "Добавяне на услуга",
|
||||
"showLabels": "Показване на етикети",
|
||||
"iconSize": "Размер на иконата",
|
||||
"noDashboardLinks": "Няма конфигурирани връзки към таблото за управление",
|
||||
"noLimit": "Без ограничение",
|
||||
"sectionHeading": "Заглавие на раздел",
|
||||
"addSection": "Добавяне на раздел",
|
||||
"compactMode": "Компактен режим",
|
||||
"showDetailed": "Покажи подробности",
|
||||
"selectHosts": "Изберете хостове",
|
||||
"allHosts": "Всички хостове",
|
||||
"listLayout": "Списък",
|
||||
"gridLayout": "Решетка",
|
||||
"terminal": "Терминал",
|
||||
"files": "Файлове",
|
||||
"docker": "Докер",
|
||||
@@ -422,12 +284,8 @@
|
||||
"accentColor": "Акцентен цвят",
|
||||
"widgetSshTerminalName": "SSH терминал",
|
||||
"widgetSshTerminalDesc": "Вграден SSH терминал, свързан с конфигуриран хост",
|
||||
"sshTerminalNoHost": "Няма конфигуриран хост",
|
||||
"sshTerminalConnect": "Свържете се",
|
||||
"sshTerminalAutoConnect": "Автоматично свързване при зареждане",
|
||||
"widgetQuickConnectName": "Бързо свързване",
|
||||
"widgetQuickConnectDesc": "Бутони за стартиране с едно щракване за всеки тип връзка между вашите хостове",
|
||||
"connectionTypes": "Видове връзки",
|
||||
"connType_terminal": "Терминал",
|
||||
"connType_files": "Файлов мениджър",
|
||||
"connType_docker": "Докер",
|
||||
@@ -685,7 +543,6 @@
|
||||
"renameTab": "Преименуване на раздел",
|
||||
"openFileManager": "Отворете файловия мениджър",
|
||||
"dashboard": "Табло за управление",
|
||||
"homepage": "Начална страница",
|
||||
"quickConnect": "Бързо свързване",
|
||||
"sshTools": "SSH инструменти",
|
||||
"history": "История",
|
||||
@@ -1895,9 +1752,7 @@
|
||||
"serverStatsCard": "Статистика на сървъра",
|
||||
"panelMain": "Главно",
|
||||
"panelSide": "Страна",
|
||||
"justNow": "точно сега",
|
||||
"serviceLinks": "Връзки към услуги",
|
||||
"homepagePreview": "Преглед на началната страница"
|
||||
"justNow": "точно сега"
|
||||
},
|
||||
"donation": {
|
||||
"title": "Харесва ли ви Termix?",
|
||||
@@ -1926,15 +1781,8 @@
|
||||
"editModeInstructions": "Плъзнете картите, за да ги пренаредите · Плъзнете разделителя на колоните, за да промените размера на колоните · Плъзнете долния ръб на картата, за да промените размера на височината ѝ · Изтрийте картата от кошчето",
|
||||
"empty": "Празно",
|
||||
"clear": "Изчисти",
|
||||
"serviceLinksTitle": "Връзки към услуги",
|
||||
"serviceLinksEmpty": "Все още няма връзки към услуги",
|
||||
"serviceLinksAddLabel": "Етикет",
|
||||
"serviceLinksAddUrl": "URL адрес",
|
||||
"serviceLinksAdd": "Добавяне",
|
||||
"serviceLinksLabelPlaceholder": "Моята услуга",
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Въведете валиден уеб адрес",
|
||||
"serviceLinksAddFailed": "Добавянето на линк към услугата не бе успешно",
|
||||
"viewServerDetails": "Преглед на подробности за сървъра"
|
||||
},
|
||||
"admin": {
|
||||
|
||||
@@ -195,9 +195,6 @@
|
||||
"arrangeUnlocked": "পুনর্বিন্যাস করতে ড্র্যাগ আনলক করুন"
|
||||
},
|
||||
"homepage": {
|
||||
"title": "হোমপেজ",
|
||||
"addWidget": "উইজেট যোগ করুন",
|
||||
"editWidget": "উইজেট সম্পাদনা করুন",
|
||||
"deleteWidget": "মুছে ফেলুন",
|
||||
"widgetTypes": "উইজেটের প্রকারভেদ",
|
||||
"serviceLink": "সার্ভিস লিঙ্ক",
|
||||
@@ -206,116 +203,40 @@
|
||||
"notes": "নোট",
|
||||
"hostStatus": "হোস্ট স্ট্যাটাস",
|
||||
"bookmarkList": "বুকমার্ক",
|
||||
"zoomIn": "জুম ইন",
|
||||
"zoomOut": "জুম আউট",
|
||||
"resetView": "দৃশ্য রিসেট করুন",
|
||||
"lockLayout": "লক লেআউট",
|
||||
"unlockLayout": "লেআউট আনলক করুন",
|
||||
"noWidgets": "আপনার প্রথম উইজেট যোগ করতে ডান-ক্লিক করুন অথবা + এ ক্লিক করুন।",
|
||||
"openFullView": "সম্পূর্ণ দৃশ্য খুলুন",
|
||||
"previewTitle": "হোমপেজ প্রিভিউ",
|
||||
"cancel": "বাতিল করুন",
|
||||
"save": "সংরক্ষণ করুন",
|
||||
"title_label": "শিরোনাম",
|
||||
"widgetTitlePlaceholder": "উইজেটের শিরোনাম (ঐচ্ছিক)",
|
||||
"url": "ইউআরএল",
|
||||
"imageUrl": "কাস্টম ইমেজ ইউআরএল",
|
||||
"imageUrlHint": "সাইটের ফেভিকন স্বয়ংক্রিয়ভাবে ব্যবহার করতে খালি রাখুন।",
|
||||
"showImage": "ছবি দেখান",
|
||||
"description": "বর্ণনা",
|
||||
"color": "রঙ",
|
||||
"icon": "আইকন",
|
||||
"expanded": "ডিফল্টরূপে প্রসারিত",
|
||||
"timezone": "সময় অঞ্চল",
|
||||
"invalidTimezone": "অবৈধ টাইমজোন। America/New_York-এর মতো একটি IANA নাম ব্যবহার করুন।",
|
||||
"showSeconds": "সেকেন্ড দেখান",
|
||||
"format12h": "১২ ঘন্টা",
|
||||
"format24h": "২৪ ঘন্টা",
|
||||
"content": "বিষয়বস্তু",
|
||||
"backgroundColor": "পটভূমির রঙ",
|
||||
"host": "হোস্ট",
|
||||
"showMetrics": "মেট্রিক্স দেখান",
|
||||
"links": "লিঙ্ক",
|
||||
"addLink": "লিঙ্ক যোগ করুন",
|
||||
"linkLabel": "লেবেল",
|
||||
"linkUrl": "ইউআরএল",
|
||||
"removeLink": "অপসারণ করুন",
|
||||
"categoryLinks": "লিঙ্ক",
|
||||
"categoryInfo": "তথ্য",
|
||||
"categorySystem": "সিস্টেম",
|
||||
"widgetServiceLinkDesc": "একটি পরিষেবা URL-এর সাথে লিঙ্কযুক্ত একটি ক্লিকযোগ্য টাইল।",
|
||||
"widgetFolderDesc": "সম্পর্কিত উইজেটগুলিকে একত্রিত করার জন্য একটি ধারক।",
|
||||
"widgetClockDesc": "পরিবর্তনযোগ্য টাইমজোন সহ একটি লাইভ ঘড়ি",
|
||||
"widgetNotesDesc": "একটি মার্কডাউন নোট উইজেট",
|
||||
"widgetHostStatusDesc": "একটি SSH হোস্টের লাইভ সিপিইউ, মেমরি এবং ডিস্ক দেখায়।",
|
||||
"widgetBookmarkListDesc": "দ্রুত লিঙ্কের একটি তালিকা",
|
||||
"copyLink": "লিঙ্ক কপি করুন",
|
||||
"linkCopied": "লিঙ্ক কপি করা হয়েছে!",
|
||||
"location": "অবস্থান",
|
||||
"temperatureUnit": "তাপমাত্রা ইউনিট",
|
||||
"showForecast": "তিন দিনের পূর্বাভাস দেখান",
|
||||
"scrolling": "স্ক্রোল করার অনুমতি দিন",
|
||||
"feedUrl": "ফিড ইউআরএল",
|
||||
"maxItems": "সর্বোচ্চ আইটেম",
|
||||
"showDescription": "বিবরণ দেখান",
|
||||
"widgetWeatherName": "আবহাওয়া",
|
||||
"widgetWeatherDesc": "যেকোনো স্থানের সরাসরি আবহাওয়ার খবর",
|
||||
"widgetIframeName": "আইফ্রেম এমবেড",
|
||||
"widgetIframeDesc": "যেকোনো URL একটি iframe-এ এমবেড করুন।",
|
||||
"widgetRssName": "আরএসএস ফিড",
|
||||
"widgetRssDesc": "আরএসএস বা অ্যাটম ফিড থেকে আইটেমগুলি প্রদর্শন করুন",
|
||||
"dragToFolder": "উইজেটগুলো এখানে টেনে আনুন অথবা যোগ করতে + ব্যবহার করুন।",
|
||||
"showDisk": "ডিস্ক ব্যবহার দেখান",
|
||||
"addToFolder": "ফোল্ডারে উইজেট যোগ করুন",
|
||||
"noHostSelected": "কোন হোস্ট নির্বাচন করা হয়নি",
|
||||
"selectHost": "একজন হোস্ট নির্বাচন করুন...",
|
||||
"displayedMetrics": "প্রদর্শিত মেট্রিক্স",
|
||||
"metricCpu": "সিপিইউ",
|
||||
"metricMemory": "স্মৃতি",
|
||||
"metricDisk": "ডিস্ক",
|
||||
"metricUptime": "আপটাইম",
|
||||
"metricSystem": "সিস্টেম",
|
||||
"metricNetwork": "নেটওয়ার্ক",
|
||||
"metricProcesses": "প্রক্রিয়া",
|
||||
"categoryMonitoring": "পর্যবেক্ষণ",
|
||||
"loading": "লোড হচ্ছে...",
|
||||
"allClear": "সব পরিষ্কার",
|
||||
"noPingUrls": "কোন URL কনফিগার করা নেই",
|
||||
"pingLabel": "লেবেল",
|
||||
"addPingUrl": "URL যোগ করুন",
|
||||
"showLatency": "লেটেন্সি দেখান",
|
||||
"refreshInterval": "রিফ্রেশ ব্যবধান",
|
||||
"seconds": "সেকেন্ড",
|
||||
"filterActivityTypes": "ফিল্টারের প্রকারভেদ",
|
||||
"filterTypesHint": "সবগুলো দেখাতে খালি রাখুন",
|
||||
"showTimestamp": "টাইমস্ট্যাম্প দেখান",
|
||||
"uptimeUnavailable": "আপটাইম অনুপলব্ধ",
|
||||
"uptimeLabel": "আপটাইম",
|
||||
"overviewVersion": "সংস্করণ",
|
||||
"overviewUpdate": "হালনাগাদ",
|
||||
"overviewUpdateLabel": "আপডেট",
|
||||
"overviewUpdateAvailable": "আপডেট উপলব্ধ",
|
||||
"overviewDatabase": "ডাটাবেস",
|
||||
"overviewUptime": "আপটাইম",
|
||||
"noHosts": "কোন হোস্ট কনফিগার করা নেই",
|
||||
"hostGridHosts": "হোস্টরা",
|
||||
"hostGridAllHint": "সমস্ত হোস্ট দেখানোর জন্য খালি রাখুন",
|
||||
"columns": "কলাম",
|
||||
"showIp": "আইপি ঠিকানা দেখান",
|
||||
"connectionType": "সংযোগের ধরণ",
|
||||
"layout": "লেআউট",
|
||||
"showStatus": "অবস্থা দেখান",
|
||||
"showHostName": "হোস্টের নাম দেখান",
|
||||
"noDockerActivity": "কোন ডকার কার্যকলাপ নেই",
|
||||
"noActivity": "কোন কার্যকলাপ নেই",
|
||||
"showAcknowledged": "স্বীকৃতি প্রদর্শন করুন",
|
||||
"widgetMetricsChartName": "মেট্রিক্স চার্ট",
|
||||
"widgetMetricsChartDesc": "একটি হোস্টের ঐতিহাসিক সিপিইউ, মেমরি, ডিস্ক বা নেটওয়ার্ক চার্ট",
|
||||
"widgetHostGridName": "হোস্ট গ্রিড",
|
||||
"widgetHostGridDesc": "SSH হোস্ট স্ট্যাটাসের গ্রিড ভিউ",
|
||||
"widgetPingStatusName": "পিং স্ট্যাটাস",
|
||||
"widgetPingStatusDesc": "এক বা একাধিক URL-এর জন্য HTTP পিং স্ট্যাটাস",
|
||||
"widgetRecentActivityName": "সাম্প্রতিক কার্যকলাপ",
|
||||
"widgetRecentActivityDesc": "সাম্প্রতিক টারমিক্স কার্যকলাপের স্ক্রোলযোগ্য ফিড",
|
||||
"widgetTermixUptimeName": "টার্মিক্স আপটাইম",
|
||||
"widgetTermixUptimeDesc": "টারমিক্স সার্ভারের লাইভ আপটাইম কাউন্টার",
|
||||
@@ -347,67 +268,8 @@
|
||||
"widgetSearchLinksDesc": "ইনলাইন ইনপুট সহ দ্রুত অনুসন্ধানের শর্টকাট বাটন",
|
||||
"widgetLinkTreeName": "লিঙ্ক ট্রি",
|
||||
"widgetLinkTreeDesc": "শিরোনাম সহ লিঙ্কের দলবদ্ধ বিভাগ",
|
||||
"calMon": "মো",
|
||||
"calTue": "তু",
|
||||
"calWed": "আমরা",
|
||||
"calThu": "থ",
|
||||
"calFri": "ফ্র",
|
||||
"calSat": "সা",
|
||||
"calSun": "সু",
|
||||
"startOnMonday": "সোমবার থেকে সপ্তাহ শুরু করুন",
|
||||
"countdownNoDate": "কোনো নির্দিষ্ট তারিখ নির্ধারণ করা হয়নি।",
|
||||
"countdownPast": "অনুষ্ঠানটি শেষ হয়ে গেছে।",
|
||||
"countdownDays": "দিন",
|
||||
"countdownHours": "ঘন্টা",
|
||||
"countdownMinutes": "মিনিট",
|
||||
"countdownSeconds": "সেকেন্ড",
|
||||
"countdownLabel": "লেবেল",
|
||||
"countdownLabelPlaceholder": "যেমন লঞ্চের দিন",
|
||||
"countdownShowDays": "শো ডেজ",
|
||||
"countdownShowHours": "শো আওয়ার্স",
|
||||
"targetDate": "লক্ষ্য তারিখ",
|
||||
"searchEngine": "সার্চ ইঞ্জিন",
|
||||
"customSearchUrl": "কাস্টম সার্চ ইউআরএল",
|
||||
"searchPlaceholder": "অনুসন্ধান করুন...",
|
||||
"searchGo": "যান",
|
||||
"searchPlaceholderLabel": "স্থানধারক পাঠ্য",
|
||||
"searchPlaceholderHint": "সার্চ ইনপুটের ভিতরে দেখানো টেক্সট",
|
||||
"openInNewTab": "নতুন ট্যাবে খুলুন",
|
||||
"searchQueryPlaceholder": "প্রশ্ন লিখুন...",
|
||||
"noSearchShortcuts": "কোন শর্টকাট কনফিগার করা নেই",
|
||||
"addSearchShortcut": "শর্টকাট যোগ করুন",
|
||||
"fontSize": "ফন্ট সাইজ",
|
||||
"textAlign": "টেক্সট অ্যালাইন",
|
||||
"fontWeight": "ফন্ট ওজন",
|
||||
"clearColor": "পরিষ্কার রঙ",
|
||||
"imageFit": "ইমেজ ফিট",
|
||||
"imageLinkUrl": "লিঙ্ক ইউআরএল",
|
||||
"noImage": "কোন ছবির ইউআরএল সেট করা হয়নি",
|
||||
"altText": "বিকল্প পাঠ্য",
|
||||
"altTextPlaceholder": "ছবিটি বর্ণনা করুন",
|
||||
"renderMarkdown": "রেন্ডার মার্কডাউন",
|
||||
"displayMode": "ডিসপ্লে মোড",
|
||||
"displayField": "ডিসপ্লে ফিল্ড",
|
||||
"jsonPath": "JSON পাথ",
|
||||
"customApiLabel": "লেবেল",
|
||||
"customApiLabelPlaceholder": "যেমন তাপমাত্রা",
|
||||
"customApiUnit": "ইউনিট",
|
||||
"customApiNoUrl": "কোন API URL কনফিগার করা নেই",
|
||||
"customApiError": "আনতে ব্যর্থ হয়েছে",
|
||||
"customApiNotArray": "প্রতিক্রিয়া একটি অ্যারে নয়",
|
||||
"addService": "পরিষেবা যোগ করুন",
|
||||
"showLabels": "লেবেল দেখান",
|
||||
"iconSize": "আইকনের আকার",
|
||||
"noDashboardLinks": "কোন ড্যাশবোর্ড লিঙ্ক কনফিগার করা নেই",
|
||||
"noLimit": "কোনো সীমা নেই",
|
||||
"sectionHeading": "বিভাগ শিরোনাম",
|
||||
"addSection": "বিভাগ যোগ করুন",
|
||||
"compactMode": "কমপ্যাক্ট মোড",
|
||||
"showDetailed": "বিস্তারিত দেখুন",
|
||||
"selectHosts": "হোস্ট নির্বাচন করুন",
|
||||
"allHosts": "সকল হোস্ট",
|
||||
"listLayout": "তালিকা",
|
||||
"gridLayout": "গ্রিড",
|
||||
"terminal": "টার্মিনাল",
|
||||
"files": "ফাইল",
|
||||
"docker": "ডকার",
|
||||
@@ -422,12 +284,8 @@
|
||||
"accentColor": "অ্যাকসেন্ট রঙ",
|
||||
"widgetSshTerminalName": "এসএসএইচ টার্মিনাল",
|
||||
"widgetSshTerminalDesc": "একটি কনফিগার করা হোস্টের সাথে সংযুক্ত একটি ইনলাইন SSH টার্মিনাল",
|
||||
"sshTerminalNoHost": "কোন হোস্ট কনফিগার করা নেই",
|
||||
"sshTerminalConnect": "সংযোগ করুন",
|
||||
"sshTerminalAutoConnect": "লোড হলে স্বয়ংক্রিয়ভাবে সংযোগ স্থাপন",
|
||||
"widgetQuickConnectName": "দ্রুত সংযোগ",
|
||||
"widgetQuickConnectDesc": "আপনার হোস্ট জুড়ে যেকোনো সংযোগ প্রকারের জন্য এক-ক্লিকে চালু করার বাটন।",
|
||||
"connectionTypes": "সংযোগের প্রকারভেদ",
|
||||
"connType_terminal": "টার্মিনাল",
|
||||
"connType_files": "ফাইল ম্যানেজার",
|
||||
"connType_docker": "ডকার",
|
||||
@@ -685,7 +543,6 @@
|
||||
"renameTab": "ট্যাবের নাম পরিবর্তন করুন",
|
||||
"openFileManager": "ফাইল ম্যানেজার খুলুন",
|
||||
"dashboard": "ড্যাশবোর্ড",
|
||||
"homepage": "হোমপেজ",
|
||||
"quickConnect": "দ্রুত সংযোগ",
|
||||
"sshTools": "SSH টুলস",
|
||||
"history": "ইতিহাস",
|
||||
@@ -1895,9 +1752,7 @@
|
||||
"serverStatsCard": "সার্ভার পরিসংখ্যান",
|
||||
"panelMain": "প্রধান",
|
||||
"panelSide": "পাশ",
|
||||
"justNow": "এইমাত্র",
|
||||
"serviceLinks": "পরিষেবা লিঙ্ক",
|
||||
"homepagePreview": "হোমপেজ প্রিভিউ"
|
||||
"justNow": "এইমাত্র"
|
||||
},
|
||||
"donation": {
|
||||
"title": "টারমিক্স উপভোগ করছেন?",
|
||||
@@ -1926,15 +1781,8 @@
|
||||
"editModeInstructions": "কার্ডগুলোর ক্রম পরিবর্তন করতে টেনে আনুন · কলামের আকার পরিবর্তন করতে কলাম বিভাজকটি টেনে আনুন · কার্ডের উচ্চতার পরিবর্তন করতে এর নিচের প্রান্তটি টেনে আনুন · মুছে ফেলতে ট্র্যাশ ব্যবহার করুন",
|
||||
"empty": "খালি",
|
||||
"clear": "পরিষ্কার",
|
||||
"serviceLinksTitle": "পরিষেবা লিঙ্ক",
|
||||
"serviceLinksEmpty": "এখনও কোনো পরিষেবা লিঙ্ক নেই",
|
||||
"serviceLinksAddLabel": "লেবেল",
|
||||
"serviceLinksAddUrl": "ইউআরএল",
|
||||
"serviceLinksAdd": "যোগ করুন",
|
||||
"serviceLinksLabelPlaceholder": "আমার পরিষেবা",
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "একটি বৈধ ওয়েব ঠিকানা লিখুন",
|
||||
"serviceLinksAddFailed": "পরিষেবা লিঙ্ক যোগ করতে ব্যর্থ হয়েছে",
|
||||
"viewServerDetails": "সার্ভারের বিবরণ দেখুন"
|
||||
},
|
||||
"admin": {
|
||||
|
||||
@@ -195,9 +195,6 @@
|
||||
"arrangeUnlocked": "Desbloqueja arrossega per reorganitzar"
|
||||
},
|
||||
"homepage": {
|
||||
"title": "Pàgina d'inici",
|
||||
"addWidget": "Afegeix un widget",
|
||||
"editWidget": "Edita el widget",
|
||||
"deleteWidget": "Suprimeix",
|
||||
"widgetTypes": "Tipus de widgets",
|
||||
"serviceLink": "Enllaç de servei",
|
||||
@@ -206,116 +203,40 @@
|
||||
"notes": "Notes",
|
||||
"hostStatus": "Estat de l'amfitrió",
|
||||
"bookmarkList": "Adreces d'interès",
|
||||
"zoomIn": "Ampliar zoom",
|
||||
"zoomOut": "Allunyar",
|
||||
"resetView": "Restableix la vista",
|
||||
"lockLayout": "Disseny de bloqueig",
|
||||
"unlockLayout": "Desbloqueja el disseny",
|
||||
"noWidgets": "Feu clic amb el botó dret o feu clic a + per afegir el primer widget",
|
||||
"openFullView": "Obre la vista completa",
|
||||
"previewTitle": "Vista prèvia de la pàgina d'inici",
|
||||
"cancel": "Cancel·la",
|
||||
"save": "Desa",
|
||||
"title_label": "Títol",
|
||||
"widgetTitlePlaceholder": "Títol del widget (opcional)",
|
||||
"url": "URL",
|
||||
"imageUrl": "URL d'imatge personalitzada",
|
||||
"imageUrlHint": "Deixeu-ho en blanc per utilitzar la icona de favorits del lloc web automàticament",
|
||||
"showImage": "Mostra la imatge",
|
||||
"description": "Descripció",
|
||||
"color": "Color",
|
||||
"icon": "Icona",
|
||||
"expanded": "Expandit per defecte",
|
||||
"timezone": "Zona horària",
|
||||
"invalidTimezone": "Fus horari no vàlid. Feu servir un nom IANA com ara Amèrica/Nova_York.",
|
||||
"showSeconds": "Mostra els segons",
|
||||
"format12h": "12 hores",
|
||||
"format24h": "24 hores",
|
||||
"content": "Contingut",
|
||||
"backgroundColor": "Color de fons",
|
||||
"host": "Amfitrió",
|
||||
"showMetrics": "Mostra les mètriques",
|
||||
"links": "Enllaços",
|
||||
"addLink": "Afegeix un enllaç",
|
||||
"linkLabel": "Etiqueta",
|
||||
"linkUrl": "URL",
|
||||
"removeLink": "Elimina",
|
||||
"categoryLinks": "Enllaços",
|
||||
"categoryInfo": "Informació",
|
||||
"categorySystem": "Sistema",
|
||||
"widgetServiceLinkDesc": "Una tessel·la clicable que enllaça a una URL de servei",
|
||||
"widgetFolderDesc": "Un contenidor per agrupar widgets relacionats",
|
||||
"widgetClockDesc": "Un rellotge en directe amb zona horària configurable",
|
||||
"widgetNotesDesc": "Un widget de notes de rebaixa",
|
||||
"widgetHostStatusDesc": "Mostra la CPU, la memòria i el disc en directe per a un host SSH",
|
||||
"widgetBookmarkListDesc": "Una llista d'enllaços ràpids",
|
||||
"copyLink": "Copia l'enllaç",
|
||||
"linkCopied": "Enllaç copiat!",
|
||||
"location": "Ubicació",
|
||||
"temperatureUnit": "Unitat de temperatura",
|
||||
"showForecast": "Mostra la previsió de 3 dies",
|
||||
"scrolling": "Permet el desplaçament",
|
||||
"feedUrl": "URL del canal",
|
||||
"maxItems": "Màxim d'elements",
|
||||
"showDescription": "Mostra la descripció",
|
||||
"widgetWeatherName": "El temps",
|
||||
"widgetWeatherDesc": "El temps en directe per a qualsevol ubicació",
|
||||
"widgetIframeName": "Incrustació d'iFrame",
|
||||
"widgetIframeDesc": "Incrustar qualsevol URL en un iframe",
|
||||
"widgetRssName": "Canal RSS",
|
||||
"widgetRssDesc": "Mostrar elements d'un canal RSS o Atom",
|
||||
"dragToFolder": "Arrossega els widgets aquí o fes servir + per afegir-los",
|
||||
"showDisk": "Mostra l'ús del disc",
|
||||
"addToFolder": "Afegeix el widget a la carpeta",
|
||||
"noHostSelected": "No s'ha seleccionat cap amfitrió",
|
||||
"selectHost": "Seleccioneu un amfitrió...",
|
||||
"displayedMetrics": "Mètriques mostrades",
|
||||
"metricCpu": "CPU",
|
||||
"metricMemory": "Memòria",
|
||||
"metricDisk": "Disc",
|
||||
"metricUptime": "Temps de funcionament",
|
||||
"metricSystem": "Sistema",
|
||||
"metricNetwork": "Xarxa",
|
||||
"metricProcesses": "Processos",
|
||||
"categoryMonitoring": "Monitorització",
|
||||
"loading": "S'està carregant...",
|
||||
"allClear": "Tot clar",
|
||||
"noPingUrls": "No s'han configurat URL",
|
||||
"pingLabel": "Etiqueta",
|
||||
"addPingUrl": "Afegeix URL",
|
||||
"showLatency": "Mostra la latència",
|
||||
"refreshInterval": "Interval d'actualització",
|
||||
"seconds": "segons",
|
||||
"filterActivityTypes": "Tipus de filtre",
|
||||
"filterTypesHint": "Deixeu-ho en blanc per mostrar-ho tot",
|
||||
"showTimestamp": "Mostra la marca de temps",
|
||||
"uptimeUnavailable": "Temps de funcionament no disponible",
|
||||
"uptimeLabel": "Temps de funcionament",
|
||||
"overviewVersion": "Versió",
|
||||
"overviewUpdate": "Actualitzat",
|
||||
"overviewUpdateLabel": "Actualització",
|
||||
"overviewUpdateAvailable": "Actualització disponible",
|
||||
"overviewDatabase": "Base de dades",
|
||||
"overviewUptime": "Temps de funcionament",
|
||||
"noHosts": "No hi ha cap amfitrió configurat",
|
||||
"hostGridHosts": "Amfitrions",
|
||||
"hostGridAllHint": "Deixeu-ho en blanc per mostrar tots els amfitrions",
|
||||
"columns": "Columnes",
|
||||
"showIp": "Mostra l'adreça IP",
|
||||
"connectionType": "Tipus de connexió",
|
||||
"layout": "Disseny",
|
||||
"showStatus": "Mostra l'estat",
|
||||
"showHostName": "Mostra el nom de l'amfitrió",
|
||||
"noDockerActivity": "Sense activitat de Docker",
|
||||
"noActivity": "Sense activitat",
|
||||
"showAcknowledged": "Mostra Reconeixement",
|
||||
"widgetMetricsChartName": "Gràfic de mètriques",
|
||||
"widgetMetricsChartDesc": "Gràfic històric de CPU, memòria, disc o xarxa per a un host",
|
||||
"widgetHostGridName": "Graella d'amfitrió",
|
||||
"widgetHostGridDesc": "Vista de quadrícula dels estats dels hosts SSH",
|
||||
"widgetPingStatusName": "Estat del ping",
|
||||
"widgetPingStatusDesc": "Estat del ping HTTP per a una o més URL",
|
||||
"widgetRecentActivityName": "Activitat recent",
|
||||
"widgetRecentActivityDesc": "Canal desplaçable de l'activitat recent de Termix",
|
||||
"widgetTermixUptimeName": "Temps de funcionament de Termix",
|
||||
"widgetTermixUptimeDesc": "Comptador de temps de funcionament en directe per al servidor Termix",
|
||||
@@ -347,67 +268,8 @@
|
||||
"widgetSearchLinksDesc": "Botons de drecera de cerca ràpida amb entrada en línia",
|
||||
"widgetLinkTreeName": "Arbre d'enllaços",
|
||||
"widgetLinkTreeDesc": "Seccions agrupades d'enllaços amb encapçalaments",
|
||||
"calMon": "Mo",
|
||||
"calTue": "Dimarts",
|
||||
"calWed": "Nosaltres",
|
||||
"calThu": "Dj.",
|
||||
"calFri": "Div.",
|
||||
"calSat": "Sa",
|
||||
"calSun": "Dg.",
|
||||
"startOnMonday": "Comença la setmana dilluns",
|
||||
"countdownNoDate": "No hi ha cap data objectiu fixada",
|
||||
"countdownPast": "L'esdeveniment ha passat",
|
||||
"countdownDays": "dies",
|
||||
"countdownHours": "hores",
|
||||
"countdownMinutes": "min",
|
||||
"countdownSeconds": "segons",
|
||||
"countdownLabel": "Etiqueta",
|
||||
"countdownLabelPlaceholder": "p. ex. Dia del llançament",
|
||||
"countdownShowDays": "Dies d'espectacle",
|
||||
"countdownShowHours": "Horari de la mostra",
|
||||
"targetDate": "Data objectiu",
|
||||
"searchEngine": "Motor de cerca",
|
||||
"customSearchUrl": "URL de cerca personalitzada",
|
||||
"searchPlaceholder": "Cerca...",
|
||||
"searchGo": "Anar",
|
||||
"searchPlaceholderLabel": "Text de marcador de posició",
|
||||
"searchPlaceholderHint": "Text que es mostra dins del camp de cerca",
|
||||
"openInNewTab": "Obre en una pestanya nova",
|
||||
"searchQueryPlaceholder": "Introduïu la consulta...",
|
||||
"noSearchShortcuts": "No hi ha dreceres configurades",
|
||||
"addSearchShortcut": "Afegeix una drecera",
|
||||
"fontSize": "Mida de la lletra",
|
||||
"textAlign": "Alineació del text",
|
||||
"fontWeight": "Pes de la lletra",
|
||||
"clearColor": "Color clar",
|
||||
"imageFit": "Ajust d'imatge",
|
||||
"imageLinkUrl": "URL de l'enllaç",
|
||||
"noImage": "No s'ha definit cap URL d'imatge",
|
||||
"altText": "Text alternatiu",
|
||||
"altTextPlaceholder": "Descriu la imatge",
|
||||
"renderMarkdown": "Renderització Markdown",
|
||||
"displayMode": "Mode de visualització",
|
||||
"displayField": "Camp de visualització",
|
||||
"jsonPath": "Camí JSON",
|
||||
"customApiLabel": "Etiqueta",
|
||||
"customApiLabelPlaceholder": "p. ex. Temperatura",
|
||||
"customApiUnit": "Unitat",
|
||||
"customApiNoUrl": "No s'ha configurat cap URL de l'API",
|
||||
"customApiError": "No s'ha pogut obtenir",
|
||||
"customApiNotArray": "La resposta no és una matriu",
|
||||
"addService": "Afegeix un servei",
|
||||
"showLabels": "Mostra les etiquetes",
|
||||
"iconSize": "Mida de la icona",
|
||||
"noDashboardLinks": "No hi ha cap enllaç configurat al tauler de control",
|
||||
"noLimit": "Sense límit",
|
||||
"sectionHeading": "Encapçalament de secció",
|
||||
"addSection": "Afegeix secció",
|
||||
"compactMode": "Mode compacte",
|
||||
"showDetailed": "Mostra detallat",
|
||||
"selectHosts": "Selecciona els amfitrions",
|
||||
"allHosts": "Tots els amfitrions",
|
||||
"listLayout": "Llista",
|
||||
"gridLayout": "Graella",
|
||||
"terminal": "Terminal",
|
||||
"files": "Fitxers",
|
||||
"docker": "Docker",
|
||||
@@ -422,12 +284,8 @@
|
||||
"accentColor": "Color d'accent",
|
||||
"widgetSshTerminalName": "Terminal SSH",
|
||||
"widgetSshTerminalDesc": "Un terminal SSH en línia connectat a un host configurat",
|
||||
"sshTerminalNoHost": "No hi ha cap amfitrió configurat",
|
||||
"sshTerminalConnect": "Connecta",
|
||||
"sshTerminalAutoConnect": "Connexió automàtica en càrrega",
|
||||
"widgetQuickConnectName": "Connexió ràpida",
|
||||
"widgetQuickConnectDesc": "Botons d'inici amb un sol clic per a qualsevol tipus de connexió entre els vostres amfitrions",
|
||||
"connectionTypes": "Tipus de connexió",
|
||||
"connType_terminal": "Terminal",
|
||||
"connType_files": "Gestor de fitxers",
|
||||
"connType_docker": "Docker",
|
||||
@@ -685,7 +543,6 @@
|
||||
"renameTab": "Canvia el nom de la pestanya",
|
||||
"openFileManager": "Obre el gestor de fitxers",
|
||||
"dashboard": "Tauler de control",
|
||||
"homepage": "Pàgina d'inici",
|
||||
"quickConnect": "Connexió ràpida",
|
||||
"sshTools": "Eines SSH",
|
||||
"history": "Història",
|
||||
@@ -1895,9 +1752,7 @@
|
||||
"serverStatsCard": "Estadístiques del servidor",
|
||||
"panelMain": "Principal",
|
||||
"panelSide": "Costat",
|
||||
"justNow": "ara mateix",
|
||||
"serviceLinks": "Enllaços de servei",
|
||||
"homepagePreview": "Vista prèvia de la pàgina d'inici"
|
||||
"justNow": "ara mateix"
|
||||
},
|
||||
"donation": {
|
||||
"title": "T'agrada Termix?",
|
||||
@@ -1926,15 +1781,8 @@
|
||||
"editModeInstructions": "Arrossegueu les targetes per reordenar · Arrossegueu el divisor de columnes per canviar la mida de les columnes · Arrossegueu la vora inferior d'una targeta per canviar-ne l'alçada · Paperera per eliminar-la",
|
||||
"empty": "Buit",
|
||||
"clear": "Clar",
|
||||
"serviceLinksTitle": "Enllaços de servei",
|
||||
"serviceLinksEmpty": "Encara no hi ha enllaços de servei",
|
||||
"serviceLinksAddLabel": "Etiqueta",
|
||||
"serviceLinksAddUrl": "URL",
|
||||
"serviceLinksAdd": "Afegeix",
|
||||
"serviceLinksLabelPlaceholder": "El meu servei",
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Introduïu una adreça web vàlida",
|
||||
"serviceLinksAddFailed": "No s'ha pogut afegir l'enllaç del servei",
|
||||
"viewServerDetails": "Veure els detalls del servidor"
|
||||
},
|
||||
"admin": {
|
||||
|
||||
@@ -195,9 +195,6 @@
|
||||
"arrangeUnlocked": "Odemkněte přetažením pro změnu uspořádání"
|
||||
},
|
||||
"homepage": {
|
||||
"title": "Domovská stránka",
|
||||
"addWidget": "Přidat widget",
|
||||
"editWidget": "Upravit widget",
|
||||
"deleteWidget": "Vymazat",
|
||||
"widgetTypes": "Typy widgetů",
|
||||
"serviceLink": "Odkaz na službu",
|
||||
@@ -206,116 +203,40 @@
|
||||
"notes": "Poznámky",
|
||||
"hostStatus": "Stav hostitele",
|
||||
"bookmarkList": "Záložky",
|
||||
"zoomIn": "Přiblížit",
|
||||
"zoomOut": "Oddálit",
|
||||
"resetView": "Obnovit zobrazení",
|
||||
"lockLayout": "Rozložení zámku",
|
||||
"unlockLayout": "Odemknout rozvržení",
|
||||
"noWidgets": "Klikněte pravým tlačítkem myši nebo kliknutím na + přidejte svůj první widget",
|
||||
"openFullView": "Otevřít celé zobrazení",
|
||||
"previewTitle": "Náhled domovské stránky",
|
||||
"cancel": "Zrušit",
|
||||
"save": "Uložit",
|
||||
"title_label": "Titul",
|
||||
"widgetTitlePlaceholder": "Název widgetu (volitelné)",
|
||||
"url": "URL",
|
||||
"imageUrl": "URL vlastního obrázku",
|
||||
"imageUrlHint": "Nechte pole prázdné, pokud chcete favicon webu používat automaticky.",
|
||||
"showImage": "Zobrazit obrázek",
|
||||
"description": "Popis",
|
||||
"color": "Barva",
|
||||
"icon": "Ikona",
|
||||
"expanded": "Ve výchozím nastavení rozšířeno",
|
||||
"timezone": "Časové pásmo",
|
||||
"invalidTimezone": "Neplatné časové pásmo. Použijte název IANA, například America/New_York.",
|
||||
"showSeconds": "Zobrazit sekundy",
|
||||
"format12h": "12 hodin",
|
||||
"format24h": "24 hodin denně",
|
||||
"content": "Obsah",
|
||||
"backgroundColor": "Barva pozadí",
|
||||
"host": "Hostitel",
|
||||
"showMetrics": "Zobrazit metriky",
|
||||
"links": "Odkazy",
|
||||
"addLink": "Přidat odkaz",
|
||||
"linkLabel": "Označení",
|
||||
"linkUrl": "URL",
|
||||
"removeLink": "Odstranit",
|
||||
"categoryLinks": "Odkazy",
|
||||
"categoryInfo": "Informace",
|
||||
"categorySystem": "Systém",
|
||||
"widgetServiceLinkDesc": "Klikatelná dlaždice odkazující na URL adresu služby",
|
||||
"widgetFolderDesc": "Kontejner pro seskupování souvisejících widgetů",
|
||||
"widgetClockDesc": "Živé hodiny s konfigurovatelným časovým pásmem",
|
||||
"widgetNotesDesc": "Widget Markdown Notes",
|
||||
"widgetHostStatusDesc": "Zobrazuje aktuální stav CPU, paměti a disku pro hostitele SSH",
|
||||
"widgetBookmarkListDesc": "Seznam rychlých odkazů",
|
||||
"copyLink": "Kopírovat odkaz",
|
||||
"linkCopied": "Odkaz zkopírován!",
|
||||
"location": "Umístění",
|
||||
"temperatureUnit": "Jednotka teploty",
|
||||
"showForecast": "Zobrazit třídenní předpověď",
|
||||
"scrolling": "Povolit rolování",
|
||||
"feedUrl": "URL kanálu",
|
||||
"maxItems": "Maximální počet položek",
|
||||
"showDescription": "Zobrazit popis",
|
||||
"widgetWeatherName": "Počasí",
|
||||
"widgetWeatherDesc": "Živé počasí pro jakékoli místo",
|
||||
"widgetIframeName": "Vložení iFrame",
|
||||
"widgetIframeDesc": "Vložte libovolnou URL do prvku iframe",
|
||||
"widgetRssName": "RSS kanál",
|
||||
"widgetRssDesc": "Zobrazení položek z RSS nebo Atom kanálu",
|
||||
"dragToFolder": "Přetáhněte widgety sem nebo je přidejte pomocí +",
|
||||
"showDisk": "Zobrazit využití disku",
|
||||
"addToFolder": "Přidat widget do složky",
|
||||
"noHostSelected": "Nebyl vybrán žádný hostitel",
|
||||
"selectHost": "Vyberte hostitele...",
|
||||
"displayedMetrics": "Zobrazené metriky",
|
||||
"metricCpu": "Procesor",
|
||||
"metricMemory": "Paměť",
|
||||
"metricDisk": "Disk",
|
||||
"metricUptime": "Doba provozuschopnosti",
|
||||
"metricSystem": "Systém",
|
||||
"metricNetwork": "Síť",
|
||||
"metricProcesses": "Procesy",
|
||||
"categoryMonitoring": "Monitorování",
|
||||
"loading": "Načítání...",
|
||||
"allClear": "Všechno čisté",
|
||||
"noPingUrls": "Žádné adresy URL nejsou nakonfigurovány",
|
||||
"pingLabel": "Označení",
|
||||
"addPingUrl": "Přidat URL",
|
||||
"showLatency": "Zobrazit latenci",
|
||||
"refreshInterval": "Interval aktualizace",
|
||||
"seconds": "sekundy",
|
||||
"filterActivityTypes": "Typy filtrů",
|
||||
"filterTypesHint": "Pro zobrazení všech ponechte prázdné",
|
||||
"showTimestamp": "Zobrazit časové razítko",
|
||||
"uptimeUnavailable": "Dostupnost není k dispozici",
|
||||
"uptimeLabel": "Doba provozuschopnosti",
|
||||
"overviewVersion": "Verze",
|
||||
"overviewUpdate": "Aktuální",
|
||||
"overviewUpdateLabel": "Aktualizovat",
|
||||
"overviewUpdateAvailable": "Aktualizace k dispozici",
|
||||
"overviewDatabase": "Databáze",
|
||||
"overviewUptime": "Doba provozuschopnosti",
|
||||
"noHosts": "Žádní hostitelé nakonfigurováni",
|
||||
"hostGridHosts": "Hostitelé",
|
||||
"hostGridAllHint": "Nechte prázdné pro zobrazení všech hostitelů",
|
||||
"columns": "Sloupce",
|
||||
"showIp": "Zobrazit IP adresu",
|
||||
"connectionType": "Typ připojení",
|
||||
"layout": "Rozvržení",
|
||||
"showStatus": "Zobrazit stav",
|
||||
"showHostName": "Zobrazit název hostitele",
|
||||
"noDockerActivity": "Žádná aktivita Dockeru",
|
||||
"noActivity": "Žádná aktivita",
|
||||
"showAcknowledged": "Zobrazit potvrzeno",
|
||||
"widgetMetricsChartName": "Graf metrik",
|
||||
"widgetMetricsChartDesc": "Historický graf CPU, paměti, disku nebo sítě pro hostitele",
|
||||
"widgetHostGridName": "Hostitelská mřížka",
|
||||
"widgetHostGridDesc": "Mřížkové zobrazení stavů hostitelů SSH",
|
||||
"widgetPingStatusName": "Stav pingu",
|
||||
"widgetPingStatusDesc": "Stav HTTP pingu pro jednu nebo více URL adres",
|
||||
"widgetRecentActivityName": "Nedávná aktivita",
|
||||
"widgetRecentActivityDesc": "Posouvatelný kanál nedávné aktivity Termixu",
|
||||
"widgetTermixUptimeName": "Doba provozuschopnosti Termixu",
|
||||
"widgetTermixUptimeDesc": "Čítač provozuschopnosti serveru Termix v reálném čase",
|
||||
@@ -347,67 +268,8 @@
|
||||
"widgetSearchLinksDesc": "Tlačítka zkratek pro rychlé vyhledávání s vloženým vstupem",
|
||||
"widgetLinkTreeName": "Strom odkazů",
|
||||
"widgetLinkTreeDesc": "Seskupené sekce odkazů s nadpisy",
|
||||
"calMon": "Po",
|
||||
"calTue": "Út",
|
||||
"calWed": "My",
|
||||
"calThu": "Čt",
|
||||
"calFri": "Pá",
|
||||
"calSat": "So",
|
||||
"calSun": "Ne",
|
||||
"startOnMonday": "Začátek týdne v pondělí",
|
||||
"countdownNoDate": "Není stanoveno žádné cílové datum",
|
||||
"countdownPast": "Událost proběhla",
|
||||
"countdownDays": "dny",
|
||||
"countdownHours": "hodin",
|
||||
"countdownMinutes": "min.",
|
||||
"countdownSeconds": "sekunda",
|
||||
"countdownLabel": "Označení",
|
||||
"countdownLabelPlaceholder": "např. den spuštění",
|
||||
"countdownShowDays": "Dny výstav",
|
||||
"countdownShowHours": "Otevírací doba",
|
||||
"targetDate": "Cílové datum",
|
||||
"searchEngine": "Vyhledávač",
|
||||
"customSearchUrl": "URL pro vlastní vyhledávání",
|
||||
"searchPlaceholder": "Vyhledávání...",
|
||||
"searchGo": "Jít",
|
||||
"searchPlaceholderLabel": "Zástupný text",
|
||||
"searchPlaceholderHint": "Text zobrazený uvnitř vyhledávacího pole",
|
||||
"openInNewTab": "Otevřít v nové kartě",
|
||||
"searchQueryPlaceholder": "Zadejte dotaz...",
|
||||
"noSearchShortcuts": "Žádné zkratky nakonfigurované",
|
||||
"addSearchShortcut": "Přidat zkratku",
|
||||
"fontSize": "Velikost písma",
|
||||
"textAlign": "Zarovnání textu",
|
||||
"fontWeight": "Tloušťka písma",
|
||||
"clearColor": "Jasná barva",
|
||||
"imageFit": "Přizpůsobení obrazu",
|
||||
"imageLinkUrl": "URL odkazu",
|
||||
"noImage": "Není nastavena adresa URL obrázku",
|
||||
"altText": "Alternativní text",
|
||||
"altTextPlaceholder": "Popište obrázek",
|
||||
"renderMarkdown": "Renderování Markdownu",
|
||||
"displayMode": "Režim zobrazení",
|
||||
"displayField": "Pole zobrazení",
|
||||
"jsonPath": "Cesta JSON",
|
||||
"customApiLabel": "Označení",
|
||||
"customApiLabelPlaceholder": "např. teplota",
|
||||
"customApiUnit": "Jednotka",
|
||||
"customApiNoUrl": "Není nakonfigurována žádná adresa URL API",
|
||||
"customApiError": "Načtení se nezdařilo",
|
||||
"customApiNotArray": "Odpověď není pole",
|
||||
"addService": "Přidat službu",
|
||||
"showLabels": "Zobrazit štítky",
|
||||
"iconSize": "Velikost ikony",
|
||||
"noDashboardLinks": "Nejsou nakonfigurovány žádné odkazy na řídicí panel",
|
||||
"noLimit": "Bez omezení",
|
||||
"sectionHeading": "Nadpis sekce",
|
||||
"addSection": "Přidat sekci",
|
||||
"compactMode": "Kompaktní režim",
|
||||
"showDetailed": "Zobrazit podrobnosti",
|
||||
"selectHosts": "Vyberte hostitele",
|
||||
"allHosts": "Všichni hostitelé",
|
||||
"listLayout": "Seznam",
|
||||
"gridLayout": "Mřížka",
|
||||
"terminal": "Terminál",
|
||||
"files": "Soubory",
|
||||
"docker": "Přístavní dělník",
|
||||
@@ -422,12 +284,8 @@
|
||||
"accentColor": "Zvýrazňující barva",
|
||||
"widgetSshTerminalName": "SSH terminál",
|
||||
"widgetSshTerminalDesc": "Vložený SSH terminál připojený k nakonfigurovanému hostiteli",
|
||||
"sshTerminalNoHost": "Žádný hostitel není nakonfigurován",
|
||||
"sshTerminalConnect": "Připojit",
|
||||
"sshTerminalAutoConnect": "Automatické připojení při načtení",
|
||||
"widgetQuickConnectName": "Rychlé připojení",
|
||||
"widgetQuickConnectDesc": "Tlačítka pro spuštění jedním kliknutím pro jakýkoli typ připojení napříč vašimi hostiteli",
|
||||
"connectionTypes": "Typy připojení",
|
||||
"connType_terminal": "Terminál",
|
||||
"connType_files": "Správce souborů",
|
||||
"connType_docker": "Přístavní dělník",
|
||||
@@ -685,7 +543,6 @@
|
||||
"renameTab": "Přejmenovat kartu",
|
||||
"openFileManager": "Otevřít Správce souborů",
|
||||
"dashboard": "Dashboard",
|
||||
"homepage": "Domovská stránka",
|
||||
"quickConnect": "Rychlé připojení",
|
||||
"sshTools": "SSH nástroje",
|
||||
"history": "Dějiny",
|
||||
@@ -1895,9 +1752,7 @@
|
||||
"serverStatsCard": "Statistiky serveru",
|
||||
"panelMain": "Hlavní",
|
||||
"panelSide": "Strana",
|
||||
"justNow": "právě teď",
|
||||
"serviceLinks": "Odkazy na služby",
|
||||
"homepagePreview": "Náhled domovské stránky"
|
||||
"justNow": "právě teď"
|
||||
},
|
||||
"donation": {
|
||||
"title": "Líbí se vám Termix?",
|
||||
@@ -1926,15 +1781,8 @@
|
||||
"editModeInstructions": "Přetažením karet změníte pořadí · Přetažením děliče sloupců změníte velikost sloupců · Přetažením spodního okraje karty změníte její výšku · Odebráním z koše",
|
||||
"empty": "Prázdný",
|
||||
"clear": "Jasný",
|
||||
"serviceLinksTitle": "Odkazy na služby",
|
||||
"serviceLinksEmpty": "Zatím žádné odkazy na služby",
|
||||
"serviceLinksAddLabel": "Označení",
|
||||
"serviceLinksAddUrl": "URL",
|
||||
"serviceLinksAdd": "Přidat",
|
||||
"serviceLinksLabelPlaceholder": "Moje služba",
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Zadejte platnou webovou adresu",
|
||||
"serviceLinksAddFailed": "Nepodařilo se přidat odkaz na službu",
|
||||
"viewServerDetails": "Zobrazit podrobnosti o serveru"
|
||||
},
|
||||
"admin": {
|
||||
|
||||
@@ -195,9 +195,6 @@
|
||||
"arrangeUnlocked": "Lås op, træk for at omarrangere"
|
||||
},
|
||||
"homepage": {
|
||||
"title": "Hjemmeside",
|
||||
"addWidget": "Tilføj widget",
|
||||
"editWidget": "Rediger widget",
|
||||
"deleteWidget": "Slet",
|
||||
"widgetTypes": "Widgettyper",
|
||||
"serviceLink": "Servicelink",
|
||||
@@ -206,116 +203,40 @@
|
||||
"notes": "Noter",
|
||||
"hostStatus": "Værtsstatus",
|
||||
"bookmarkList": "Bogmærker",
|
||||
"zoomIn": "Zoom ind",
|
||||
"zoomOut": "Zoom ud",
|
||||
"resetView": "Nulstil visning",
|
||||
"lockLayout": "Låselayout",
|
||||
"unlockLayout": "Lås layout op",
|
||||
"noWidgets": "Højreklik eller klik på + for at tilføje din første widget",
|
||||
"openFullView": "Åbn fuld visning",
|
||||
"previewTitle": "Forhåndsvisning af hjemmeside",
|
||||
"cancel": "Ophæve",
|
||||
"save": "Spare",
|
||||
"title_label": "Titel",
|
||||
"widgetTitlePlaceholder": "Widgettitel (valgfrit)",
|
||||
"url": "URL",
|
||||
"imageUrl": "Brugerdefineret billed-URL",
|
||||
"imageUrlHint": "Lad feltet stå tomt for at bruge webstedets favicon automatisk",
|
||||
"showImage": "Vis billede",
|
||||
"description": "Beskrivelse",
|
||||
"color": "Farve",
|
||||
"icon": "Ikon",
|
||||
"expanded": "Udvidet som standard",
|
||||
"timezone": "Tidszone",
|
||||
"invalidTimezone": "Ugyldig tidszone. Brug et IANA-navn, f.eks. America/New_York.",
|
||||
"showSeconds": "Vis sekunder",
|
||||
"format12h": "12-timers",
|
||||
"format24h": "24-timers",
|
||||
"content": "Tilfreds",
|
||||
"backgroundColor": "Baggrundsfarve",
|
||||
"host": "Vært",
|
||||
"showMetrics": "Vis metrikker",
|
||||
"links": "Links",
|
||||
"addLink": "Tilføj link",
|
||||
"linkLabel": "Mærke",
|
||||
"linkUrl": "URL",
|
||||
"removeLink": "Fjerne",
|
||||
"categoryLinks": "Links",
|
||||
"categoryInfo": "Info",
|
||||
"categorySystem": "System",
|
||||
"widgetServiceLinkDesc": "En klikbar flise, der linker til en tjeneste-URL",
|
||||
"widgetFolderDesc": "En container til at gruppere relaterede widgets",
|
||||
"widgetClockDesc": "Et live-ur med konfigurerbar tidszone",
|
||||
"widgetNotesDesc": "En widget med nedskæringsnoter",
|
||||
"widgetHostStatusDesc": "Viser live CPU, hukommelse og disk for en SSH-vært",
|
||||
"widgetBookmarkListDesc": "En liste over hurtige links",
|
||||
"copyLink": "Kopiér link",
|
||||
"linkCopied": "Link kopieret!",
|
||||
"location": "Beliggenhed",
|
||||
"temperatureUnit": "Temperaturenhed",
|
||||
"showForecast": "Vis 3-dages vejrudsigt",
|
||||
"scrolling": "Tillad rulning",
|
||||
"feedUrl": "Feed-URL",
|
||||
"maxItems": "Maks. antal varer",
|
||||
"showDescription": "Vis beskrivelse",
|
||||
"widgetWeatherName": "Vejr",
|
||||
"widgetWeatherDesc": "Live vejrudsigt for enhver lokation",
|
||||
"widgetIframeName": "iFrame-integrering",
|
||||
"widgetIframeDesc": "Integrer en hvilken som helst URL i en iframe",
|
||||
"widgetRssName": "RSS-feed",
|
||||
"widgetRssDesc": "Vis elementer fra et RSS- eller Atom-feed",
|
||||
"dragToFolder": "Træk widgets hertil, eller brug + for at tilføje",
|
||||
"showDisk": "Vis diskforbrug",
|
||||
"addToFolder": "Tilføj widget til mappe",
|
||||
"noHostSelected": "Ingen vært valgt",
|
||||
"selectHost": "Vælg en vært...",
|
||||
"displayedMetrics": "Viste målinger",
|
||||
"metricCpu": "CPU",
|
||||
"metricMemory": "Hukommelse",
|
||||
"metricDisk": "Disk",
|
||||
"metricUptime": "Oppetid",
|
||||
"metricSystem": "System",
|
||||
"metricNetwork": "Netværk",
|
||||
"metricProcesses": "Processer",
|
||||
"categoryMonitoring": "Overvågning",
|
||||
"loading": "Indlæser...",
|
||||
"allClear": "Alt klart",
|
||||
"noPingUrls": "Ingen URL'er konfigureret",
|
||||
"pingLabel": "Mærke",
|
||||
"addPingUrl": "Tilføj URL",
|
||||
"showLatency": "Vis latenstid",
|
||||
"refreshInterval": "Opdateringsinterval",
|
||||
"seconds": "sekunder",
|
||||
"filterActivityTypes": "Filtertyper",
|
||||
"filterTypesHint": "Lad stå tomt for at vise alle",
|
||||
"showTimestamp": "Vis tidsstempel",
|
||||
"uptimeUnavailable": "Oppetid utilgængelig",
|
||||
"uptimeLabel": "Oppetid",
|
||||
"overviewVersion": "Version",
|
||||
"overviewUpdate": "Opdateret",
|
||||
"overviewUpdateLabel": "Opdatering",
|
||||
"overviewUpdateAvailable": "Opdatering tilgængelig",
|
||||
"overviewDatabase": "Database",
|
||||
"overviewUptime": "Oppetid",
|
||||
"noHosts": "Ingen værter konfigureret",
|
||||
"hostGridHosts": "Værter",
|
||||
"hostGridAllHint": "Lad stå tomt for at vise alle værter",
|
||||
"columns": "Kolonner",
|
||||
"showIp": "Vis IP-adresse",
|
||||
"connectionType": "Forbindelsestype",
|
||||
"layout": "Layout",
|
||||
"showStatus": "Vis status",
|
||||
"showHostName": "Vis værtsnavn",
|
||||
"noDockerActivity": "Ingen Docker-aktivitet",
|
||||
"noActivity": "Ingen aktivitet",
|
||||
"showAcknowledged": "Vis anerkendt",
|
||||
"widgetMetricsChartName": "Metrikdiagram",
|
||||
"widgetMetricsChartDesc": "Historisk CPU-, hukommelses-, disk- eller netværksdiagram for en vært",
|
||||
"widgetHostGridName": "Værtgitter",
|
||||
"widgetHostGridDesc": "Gittervisning af SSH-værtsstatusser",
|
||||
"widgetPingStatusName": "Ping-status",
|
||||
"widgetPingStatusDesc": "HTTP-pingstatus for en eller flere URL'er",
|
||||
"widgetRecentActivityName": "Seneste aktivitet",
|
||||
"widgetRecentActivityDesc": "Rulbart feed af den seneste Termix-aktivitet",
|
||||
"widgetTermixUptimeName": "Termix Oppetid",
|
||||
"widgetTermixUptimeDesc": "Live oppetidstæller for Termix-serveren",
|
||||
@@ -347,67 +268,8 @@
|
||||
"widgetSearchLinksDesc": "Genvejsknapper til hurtigsøgning med indbygget input",
|
||||
"widgetLinkTreeName": "Linktræ",
|
||||
"widgetLinkTreeDesc": "Grupperede sektioner af links med overskrifter",
|
||||
"calMon": "Må",
|
||||
"calTue": "Ti",
|
||||
"calWed": "Vi",
|
||||
"calThu": "To",
|
||||
"calFri": "Fr",
|
||||
"calSat": "Lør",
|
||||
"calSun": "Sø",
|
||||
"startOnMonday": "Start ugen på mandag",
|
||||
"countdownNoDate": "Ingen måldato fastsat",
|
||||
"countdownPast": "Begivenheden er overstået",
|
||||
"countdownDays": "dage",
|
||||
"countdownHours": "timer",
|
||||
"countdownMinutes": "min.",
|
||||
"countdownSeconds": "sek.",
|
||||
"countdownLabel": "Mærke",
|
||||
"countdownLabelPlaceholder": "f.eks. lanceringsdag",
|
||||
"countdownShowDays": "Vis dage",
|
||||
"countdownShowHours": "Åbningstider",
|
||||
"targetDate": "Måldato",
|
||||
"searchEngine": "Søgemaskine",
|
||||
"customSearchUrl": "Tilpasset søge-URL",
|
||||
"searchPlaceholder": "Søge...",
|
||||
"searchGo": "Gå",
|
||||
"searchPlaceholderLabel": "Pladsholdertekst",
|
||||
"searchPlaceholderHint": "Tekst vist i søgefeltet",
|
||||
"openInNewTab": "Åbn i ny fane",
|
||||
"searchQueryPlaceholder": "Indtast forespørgsel...",
|
||||
"noSearchShortcuts": "Ingen genveje konfigureret",
|
||||
"addSearchShortcut": "Tilføj genvej",
|
||||
"fontSize": "Skriftstørrelse",
|
||||
"textAlign": "Tekstjustering",
|
||||
"fontWeight": "Skriftvægt",
|
||||
"clearColor": "Klar farve",
|
||||
"imageFit": "Billedtilpasning",
|
||||
"imageLinkUrl": "Link-URL",
|
||||
"noImage": "Ingen billed-URL angivet",
|
||||
"altText": "Alternativ tekst",
|
||||
"altTextPlaceholder": "Beskriv billedet",
|
||||
"renderMarkdown": "Render Markdown",
|
||||
"displayMode": "Visningstilstand",
|
||||
"displayField": "Visningsfelt",
|
||||
"jsonPath": "JSON-sti",
|
||||
"customApiLabel": "Mærke",
|
||||
"customApiLabelPlaceholder": "f.eks. Temperatur",
|
||||
"customApiUnit": "Enhed",
|
||||
"customApiNoUrl": "Ingen API-URL konfigureret",
|
||||
"customApiError": "Kunne ikke hente",
|
||||
"customApiNotArray": "Svaret er ikke et array",
|
||||
"addService": "Tilføj tjeneste",
|
||||
"showLabels": "Vis etiketter",
|
||||
"iconSize": "Ikonstørrelse",
|
||||
"noDashboardLinks": "Ingen dashboard-links konfigureret",
|
||||
"noLimit": "Ingen grænse",
|
||||
"sectionHeading": "Sektionsoverskrift",
|
||||
"addSection": "Tilføj sektion",
|
||||
"compactMode": "Kompakt tilstand",
|
||||
"showDetailed": "Vis detaljeret",
|
||||
"selectHosts": "Vælg værter",
|
||||
"allHosts": "Alle værter",
|
||||
"listLayout": "Liste",
|
||||
"gridLayout": "Gitter",
|
||||
"terminal": "Terminal",
|
||||
"files": "Filer",
|
||||
"docker": "Docker",
|
||||
@@ -422,12 +284,8 @@
|
||||
"accentColor": "Accentfarve",
|
||||
"widgetSshTerminalName": "SSH-terminal",
|
||||
"widgetSshTerminalDesc": "En indbygget SSH-terminal forbundet til en konfigureret vært",
|
||||
"sshTerminalNoHost": "Ingen vært konfigureret",
|
||||
"sshTerminalConnect": "Forbinde",
|
||||
"sshTerminalAutoConnect": "Automatisk forbindelse ved indlæsning",
|
||||
"widgetQuickConnectName": "Hurtig forbindelse",
|
||||
"widgetQuickConnectDesc": "Startknapper med ét klik til enhver forbindelsestype på tværs af dine værter",
|
||||
"connectionTypes": "Forbindelsestyper",
|
||||
"connType_terminal": "Terminal",
|
||||
"connType_files": "Filhåndtering",
|
||||
"connType_docker": "Docker",
|
||||
@@ -685,7 +543,6 @@
|
||||
"renameTab": "Omdøb faneblad",
|
||||
"openFileManager": "Åbn Filhåndtering",
|
||||
"dashboard": "Dashboard",
|
||||
"homepage": "Hjemmeside",
|
||||
"quickConnect": "Hurtig forbindelse",
|
||||
"sshTools": "SSH-værktøjer",
|
||||
"history": "Historie",
|
||||
@@ -1895,9 +1752,7 @@
|
||||
"serverStatsCard": "Serverstatistik",
|
||||
"panelMain": "Hoved",
|
||||
"panelSide": "Side",
|
||||
"justNow": "lige nu",
|
||||
"serviceLinks": "Servicelinks",
|
||||
"homepagePreview": "Forhåndsvisning af hjemmeside"
|
||||
"justNow": "lige nu"
|
||||
},
|
||||
"donation": {
|
||||
"title": "Kan du lide Termix?",
|
||||
@@ -1926,15 +1781,8 @@
|
||||
"editModeInstructions": "Træk kort for at ændre rækkefølgen · Træk kolonneopdeleren for at ændre størrelsen på kolonnerne · Træk i den nederste kant af et kort for at ændre størrelsen på dets højde · Fjern i papirkurven",
|
||||
"empty": "Tom",
|
||||
"clear": "Klar",
|
||||
"serviceLinksTitle": "Servicelinks",
|
||||
"serviceLinksEmpty": "Ingen servicelinks endnu",
|
||||
"serviceLinksAddLabel": "Mærke",
|
||||
"serviceLinksAddUrl": "URL",
|
||||
"serviceLinksAdd": "Tilføje",
|
||||
"serviceLinksLabelPlaceholder": "Min tjeneste",
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Indtast en gyldig webadresse",
|
||||
"serviceLinksAddFailed": "Kunne ikke tilføje tjenestelink",
|
||||
"viewServerDetails": "Se serveroplysninger"
|
||||
},
|
||||
"admin": {
|
||||
|
||||
@@ -195,9 +195,6 @@
|
||||
"arrangeUnlocked": "Entsperren und per Drag & Drop neu anordnen"
|
||||
},
|
||||
"homepage": {
|
||||
"title": "Startseite",
|
||||
"addWidget": "Widget hinzufügen",
|
||||
"editWidget": "Widget bearbeiten",
|
||||
"deleteWidget": "Löschen",
|
||||
"widgetTypes": "Widget-Typen",
|
||||
"serviceLink": "Service-Link",
|
||||
@@ -206,116 +203,40 @@
|
||||
"notes": "Notizen",
|
||||
"hostStatus": "Host-Status",
|
||||
"bookmarkList": "Lesezeichen",
|
||||
"zoomIn": "Vergrößern",
|
||||
"zoomOut": "Verkleinern",
|
||||
"resetView": "Ansicht zurücksetzen",
|
||||
"lockLayout": "Layout sperren",
|
||||
"unlockLayout": "Layout entsperren",
|
||||
"noWidgets": "Klicken Sie mit der rechten Maustaste oder auf +, um Ihr erstes Widget hinzuzufügen",
|
||||
"openFullView": "Vollansicht öffnen",
|
||||
"previewTitle": "Homepage-Vorschau",
|
||||
"cancel": "Abbrechen",
|
||||
"save": "Speichern",
|
||||
"title_label": "Titel",
|
||||
"widgetTitlePlaceholder": "Widget-Titel (optional)",
|
||||
"url": "URL",
|
||||
"imageUrl": "Benutzerdefinierte Bild-URL",
|
||||
"imageUrlHint": "Leer lassen, um automatisch das Favicon der Website zu verwenden",
|
||||
"showImage": "Bild anzeigen",
|
||||
"description": "Beschreibung",
|
||||
"color": "Farbe",
|
||||
"icon": "Symbol",
|
||||
"expanded": "Standardmäßig erweitert",
|
||||
"timezone": "Zeitzone",
|
||||
"invalidTimezone": "Ungültige Zeitzone. Verwenden Sie einen IANA-Namen wie z. B. America/New_York.",
|
||||
"showSeconds": "Sekunden anzeigen",
|
||||
"format12h": "12-Stunden",
|
||||
"format24h": "24-Stunden",
|
||||
"content": "Inhalt",
|
||||
"backgroundColor": "Hintergrundfarbe",
|
||||
"host": "Host",
|
||||
"showMetrics": "Metriken anzeigen",
|
||||
"links": "Links",
|
||||
"addLink": "Link hinzufügen",
|
||||
"linkLabel": "Bezeichnung",
|
||||
"linkUrl": "URL",
|
||||
"removeLink": "Entfernen",
|
||||
"categoryLinks": "Links",
|
||||
"categoryInfo": "Info",
|
||||
"categorySystem": "System",
|
||||
"widgetServiceLinkDesc": "Anklickbare Kachel, die auf eine Service-URL verlinkt",
|
||||
"widgetFolderDesc": "Ein Container, um verwandte Widgets zu gruppieren",
|
||||
"widgetClockDesc": "Eine Live-Uhr mit konfigurierbarer Zeitzone",
|
||||
"widgetNotesDesc": "Ein Markdown-Notizen-Widget",
|
||||
"widgetHostStatusDesc": "Zeigt Live-CPU-, Arbeitsspeicher- und Festplattenauslastung für einen SSH-Host an",
|
||||
"widgetBookmarkListDesc": "Eine Liste von Schnelllinks",
|
||||
"copyLink": "Link kopieren",
|
||||
"linkCopied": "Link kopiert!",
|
||||
"location": "Standort",
|
||||
"temperatureUnit": "Temperatureinheit",
|
||||
"showForecast": "3-Tage-Vorhersage anzeigen",
|
||||
"scrolling": "Scrollen erlauben",
|
||||
"feedUrl": "Feed-URL",
|
||||
"maxItems": "Max. Einträge",
|
||||
"showDescription": "Beschreibung anzeigen",
|
||||
"widgetWeatherName": "Wetter",
|
||||
"widgetWeatherDesc": "Live-Wetter für jeden Standort",
|
||||
"widgetIframeName": "iFrame-Einbettung",
|
||||
"widgetIframeDesc": "Bettet eine beliebige URL in einen iFrame ein",
|
||||
"widgetRssName": "RSS-Feed",
|
||||
"widgetRssDesc": "Zeigt Einträge aus einem RSS- oder Atom-Feed an",
|
||||
"dragToFolder": "Widgets hierher ziehen oder + zum Hinzufügen verwenden",
|
||||
"showDisk": "Festplattennutzung anzeigen",
|
||||
"addToFolder": "Widget zum Ordner hinzufügen",
|
||||
"noHostSelected": "Kein Host ausgewählt",
|
||||
"selectHost": "Host auswählen...",
|
||||
"displayedMetrics": "Angezeigte Metriken",
|
||||
"metricCpu": "CPU",
|
||||
"metricMemory": "Arbeitsspeicher",
|
||||
"metricDisk": "Festplatte",
|
||||
"metricUptime": "Betriebszeit",
|
||||
"metricSystem": "System",
|
||||
"metricNetwork": "Netzwerk",
|
||||
"metricProcesses": "Prozesse",
|
||||
"categoryMonitoring": "Überwachung",
|
||||
"loading": "Wird geladen...",
|
||||
"allClear": "Alles in Ordnung",
|
||||
"noPingUrls": "Keine URLs konfiguriert",
|
||||
"pingLabel": "Bezeichnung",
|
||||
"addPingUrl": "URL hinzufügen",
|
||||
"showLatency": "Latenz anzeigen",
|
||||
"refreshInterval": "Aktualisierungsintervall",
|
||||
"seconds": "Sekunden",
|
||||
"filterActivityTypes": "Filtertypen",
|
||||
"filterTypesHint": "Leer lassen, um alle anzuzeigen",
|
||||
"showTimestamp": "Zeitstempel anzeigen",
|
||||
"uptimeUnavailable": "Betriebszeit nicht verfügbar",
|
||||
"uptimeLabel": "Betriebszeit",
|
||||
"overviewVersion": "Version",
|
||||
"overviewUpdate": "Aktuell",
|
||||
"overviewUpdateLabel": "Aktualisieren",
|
||||
"overviewUpdateAvailable": "Update verfügbar",
|
||||
"overviewDatabase": "Datenbank",
|
||||
"overviewUptime": "Betriebszeit",
|
||||
"noHosts": "Keine Hosts konfiguriert",
|
||||
"hostGridHosts": "Hosts",
|
||||
"hostGridAllHint": "Leer lassen, um alle Hosts anzuzeigen",
|
||||
"columns": "Spalten",
|
||||
"showIp": "IP-Adresse anzeigen",
|
||||
"connectionType": "Verbindungstyp",
|
||||
"layout": "Layout",
|
||||
"showStatus": "Status anzeigen",
|
||||
"showHostName": "Hostname anzeigen",
|
||||
"noDockerActivity": "Keine Docker-Aktivität",
|
||||
"noActivity": "Keine Aktivität",
|
||||
"showAcknowledged": "Bestätigte anzeigen",
|
||||
"widgetMetricsChartName": "Metrikdiagramm",
|
||||
"widgetMetricsChartDesc": "Historisches CPU-, Arbeitsspeicher-, Festplatten- oder Netzwerkdiagramm für einen Host",
|
||||
"widgetHostGridName": "Host-Raster",
|
||||
"widgetHostGridDesc": "Rasteransicht der SSH-Host-Status",
|
||||
"widgetPingStatusName": "Ping-Status",
|
||||
"widgetPingStatusDesc": "HTTP-Ping-Status für eine oder mehrere URLs",
|
||||
"widgetRecentActivityName": "Letzte Aktivität",
|
||||
"widgetRecentActivityDesc": "Scrollbarer Feed der letzten Termix-Aktivitäten",
|
||||
"widgetTermixUptimeName": "Termix-Betriebszeit",
|
||||
"widgetTermixUptimeDesc": "Live-Betriebszeitzähler für den Termix-Server",
|
||||
@@ -347,67 +268,8 @@
|
||||
"widgetSearchLinksDesc": "Schnelle Such-Verknüpfungsschaltflächen mit Inline-Eingabe",
|
||||
"widgetLinkTreeName": "Link-Baum",
|
||||
"widgetLinkTreeDesc": "Gruppierte Link-Abschnitte mit Überschriften",
|
||||
"calMon": "Mo",
|
||||
"calTue": "Di",
|
||||
"calWed": "Mi",
|
||||
"calThu": "Do",
|
||||
"calFri": "Fr",
|
||||
"calSat": "Sa",
|
||||
"calSun": "So",
|
||||
"startOnMonday": "Woche am Montag beginnen",
|
||||
"countdownNoDate": "Kein Zieldatum festgelegt",
|
||||
"countdownPast": "Ereignis ist vorbei",
|
||||
"countdownDays": "Tage",
|
||||
"countdownHours": "Std.",
|
||||
"countdownMinutes": "Min.",
|
||||
"countdownSeconds": "Sek.",
|
||||
"countdownLabel": "Label",
|
||||
"countdownLabelPlaceholder": "z.B. Starttag",
|
||||
"countdownShowDays": "Tage anzeigen",
|
||||
"countdownShowHours": "Stunden anzeigen",
|
||||
"targetDate": "Zieldatum",
|
||||
"searchEngine": "Suchmaschine",
|
||||
"customSearchUrl": "Benutzerdefinierte Such-URL",
|
||||
"searchPlaceholder": "Suchen...",
|
||||
"searchGo": "Los",
|
||||
"searchPlaceholderLabel": "Platzhaltertext",
|
||||
"searchPlaceholderHint": "Text, der im Suchfeld angezeigt wird",
|
||||
"openInNewTab": "In neuem Tab öffnen",
|
||||
"searchQueryPlaceholder": "Suchbegriff eingeben...",
|
||||
"noSearchShortcuts": "Keine Shortcuts konfiguriert",
|
||||
"addSearchShortcut": "Shortcut hinzufügen",
|
||||
"fontSize": "Schriftgröße",
|
||||
"textAlign": "Textausrichtung",
|
||||
"fontWeight": "Schriftstärke",
|
||||
"clearColor": "Farbe löschen",
|
||||
"imageFit": "Bildanpassung",
|
||||
"imageLinkUrl": "Link-URL",
|
||||
"noImage": "Keine Bild-URL festgelegt",
|
||||
"altText": "Alternativtext",
|
||||
"altTextPlaceholder": "Bild beschreiben",
|
||||
"renderMarkdown": "Markdown rendern",
|
||||
"displayMode": "Anzeigemodus",
|
||||
"displayField": "Anzeigefeld",
|
||||
"jsonPath": "JSON-Pfad",
|
||||
"customApiLabel": "Bezeichnung",
|
||||
"customApiLabelPlaceholder": "z.B. Temperatur",
|
||||
"customApiUnit": "Einheit",
|
||||
"customApiNoUrl": "Keine API-URL konfiguriert",
|
||||
"customApiError": "Fehler beim Abrufen",
|
||||
"customApiNotArray": "Antwort ist kein Array",
|
||||
"addService": "Dienst hinzufügen",
|
||||
"showLabels": "Beschriftungen anzeigen",
|
||||
"iconSize": "Symbolgröße",
|
||||
"noDashboardLinks": "Keine Dashboard-Links konfiguriert",
|
||||
"noLimit": "Keine Begrenzung",
|
||||
"sectionHeading": "Abschnittsüberschrift",
|
||||
"addSection": "Abschnitt hinzufügen",
|
||||
"compactMode": "Kompaktmodus",
|
||||
"showDetailed": "Details anzeigen",
|
||||
"selectHosts": "Hosts auswählen",
|
||||
"allHosts": "Alle Hosts",
|
||||
"listLayout": "Liste",
|
||||
"gridLayout": "Raster",
|
||||
"terminal": "Terminal",
|
||||
"files": "Dateien",
|
||||
"docker": "Docker",
|
||||
@@ -422,12 +284,8 @@
|
||||
"accentColor": "Akzentfarbe",
|
||||
"widgetSshTerminalName": "SSH-Terminal",
|
||||
"widgetSshTerminalDesc": "Ein eingebettetes SSH-Terminal, das mit einem konfigurierten Host verbunden ist",
|
||||
"sshTerminalNoHost": "Kein Host konfiguriert",
|
||||
"sshTerminalConnect": "Verbinden",
|
||||
"sshTerminalAutoConnect": "Beim Laden automatisch verbinden",
|
||||
"widgetQuickConnectName": "Schnellverbindung",
|
||||
"widgetQuickConnectDesc": "Ein-Klick-Schaltflächen für alle Verbindungstypen auf Ihren Hosts",
|
||||
"connectionTypes": "Verbindungstypen",
|
||||
"connType_terminal": "Terminal",
|
||||
"connType_files": "Dateimanager",
|
||||
"connType_docker": "Docker",
|
||||
@@ -685,7 +543,6 @@
|
||||
"renameTab": "Registerkarte umbenennen",
|
||||
"openFileManager": "Dateimanager öffnen",
|
||||
"dashboard": "Dashboard",
|
||||
"homepage": "Startseite",
|
||||
"quickConnect": "Schnellverbindung",
|
||||
"sshTools": "SSH-Tools",
|
||||
"history": "Verlauf",
|
||||
@@ -1895,9 +1752,7 @@
|
||||
"serverStatsCard": "Serverstatistiken",
|
||||
"panelMain": "Hauptbereich",
|
||||
"panelSide": "Seitenbereich",
|
||||
"justNow": "gerade eben",
|
||||
"serviceLinks": "Service-Links",
|
||||
"homepagePreview": "Homepage-Vorschau"
|
||||
"justNow": "gerade eben"
|
||||
},
|
||||
"donation": {
|
||||
"title": "Gefällt Ihnen Termix?",
|
||||
@@ -1926,15 +1781,8 @@
|
||||
"editModeInstructions": "Karten ziehen, um sie neu anzuordnen · Spaltentrenner ziehen, um Spalten zu skalieren · Untere Kante einer Karte ziehen, um die Höhe anzupassen · Papierkorb zum Entfernen",
|
||||
"empty": "Leer",
|
||||
"clear": "Leeren",
|
||||
"serviceLinksTitle": "Service-Links",
|
||||
"serviceLinksEmpty": "Noch keine Service-Links",
|
||||
"serviceLinksAddLabel": "Bezeichnung",
|
||||
"serviceLinksAddUrl": "URL",
|
||||
"serviceLinksAdd": "Hinzufügen",
|
||||
"serviceLinksLabelPlaceholder": "Mein Dienst",
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Geben Sie eine gültige Webadresse ein",
|
||||
"serviceLinksAddFailed": "Hinzufügen des Service-Links fehlgeschlagen",
|
||||
"viewServerDetails": "Serverdetails anzeigen"
|
||||
},
|
||||
"admin": {
|
||||
|
||||
@@ -195,9 +195,6 @@
|
||||
"arrangeUnlocked": "Ξεκλειδώστε και σύρετε για αναδιάταξη."
|
||||
},
|
||||
"homepage": {
|
||||
"title": "Αρχική σελίδα",
|
||||
"addWidget": "Προσθήκη γραφικού στοιχείου",
|
||||
"editWidget": "Επεξεργασία γραφικού στοιχείου",
|
||||
"deleteWidget": "Διαγράφω",
|
||||
"widgetTypes": "Τύποι γραφικών στοιχείων",
|
||||
"serviceLink": "Σύνδεσμος υπηρεσίας",
|
||||
@@ -206,116 +203,40 @@
|
||||
"notes": "Σημειώσεις",
|
||||
"hostStatus": "Κατάσταση οικοδεσπότη",
|
||||
"bookmarkList": "Σελιδοδείκτες",
|
||||
"zoomIn": "Μεγέθυνση",
|
||||
"zoomOut": "Σμίκρυνση",
|
||||
"resetView": "Επαναφορά προβολής",
|
||||
"lockLayout": "Διάταξη κλειδαριάς",
|
||||
"unlockLayout": "Ξεκλείδωμα διάταξης",
|
||||
"noWidgets": "Κάντε δεξί κλικ ή κάντε κλικ στο + για να προσθέσετε το πρώτο σας γραφικό στοιχείο",
|
||||
"openFullView": "Άνοιγμα πλήρους προβολής",
|
||||
"previewTitle": "Προεπισκόπηση αρχικής σελίδας",
|
||||
"cancel": "Ματαίωση",
|
||||
"save": "Εκτός",
|
||||
"title_label": "Τίτλος",
|
||||
"widgetTitlePlaceholder": "Τίτλος γραφικού στοιχείου (προαιρετικό)",
|
||||
"url": "URL",
|
||||
"imageUrl": "URL προσαρμοσμένης εικόνας",
|
||||
"imageUrlHint": "Αφήστε το κενό για να χρησιμοποιήσετε αυτόματα το favicon του ιστότοπου",
|
||||
"showImage": "Εμφάνιση εικόνας",
|
||||
"description": "Περιγραφή",
|
||||
"color": "Χρώμα",
|
||||
"icon": "Εικόνισμα",
|
||||
"expanded": "Αναπτυγμένο από προεπιλογή",
|
||||
"timezone": "Ζώνη ώρας",
|
||||
"invalidTimezone": "Μη έγκυρη ζώνη ώρας. Χρησιμοποιήστε ένα όνομα IANA, όπως America/New_York.",
|
||||
"showSeconds": "Εμφάνιση δευτερολέπτων",
|
||||
"format12h": "12ωρο",
|
||||
"format24h": "24ωρο",
|
||||
"content": "Περιεχόμενο",
|
||||
"backgroundColor": "Χρώμα φόντου",
|
||||
"host": "Πλήθος",
|
||||
"showMetrics": "Εμφάνιση μετρήσεων",
|
||||
"links": "Εδαφος διά παιγνίδι γκολφ",
|
||||
"addLink": "Προσθήκη συνδέσμου",
|
||||
"linkLabel": "Επιγραφή",
|
||||
"linkUrl": "URL",
|
||||
"removeLink": "Αφαιρώ",
|
||||
"categoryLinks": "Εδαφος διά παιγνίδι γκολφ",
|
||||
"categoryInfo": "Πληροφορίες",
|
||||
"categorySystem": "Σύστημα",
|
||||
"widgetServiceLinkDesc": "Ένα πλακίδιο με δυνατότητα κλικ που συνδέεται με μια διεύθυνση URL υπηρεσίας",
|
||||
"widgetFolderDesc": "Ένα κοντέινερ για την ομαδοποίηση σχετικών γραφικών στοιχείων",
|
||||
"widgetClockDesc": "Ένα ζωντανό ρολόι με ρυθμιζόμενη ζώνη ώρας",
|
||||
"widgetNotesDesc": "Ένα γραφικό στοιχείο σημειώσεων με έκπτωση",
|
||||
"widgetHostStatusDesc": "Εμφανίζει ζωντανή CPU, μνήμη και δίσκο για έναν κεντρικό υπολογιστή SSH",
|
||||
"widgetBookmarkListDesc": "Μια λίστα με γρήγορους συνδέσμους",
|
||||
"copyLink": "Αντιγραφή συνδέσμου",
|
||||
"linkCopied": "Ο σύνδεσμος αντιγράφηκε!",
|
||||
"location": "Τοποθεσία",
|
||||
"temperatureUnit": "Μονάδα θερμοκρασίας",
|
||||
"showForecast": "Εμφάνιση τριήμερης πρόγνωσης",
|
||||
"scrolling": "Επιτρέπεται η κύλιση",
|
||||
"feedUrl": "URL ροής",
|
||||
"maxItems": "Μέγιστος αριθμός στοιχείων",
|
||||
"showDescription": "Εμφάνιση περιγραφής",
|
||||
"widgetWeatherName": "Καιρός",
|
||||
"widgetWeatherDesc": "Ζωντανός καιρός για οποιαδήποτε τοποθεσία",
|
||||
"widgetIframeName": "Ενσωμάτωση iFrame",
|
||||
"widgetIframeDesc": "Ενσωματώστε οποιαδήποτε διεύθυνση URL σε ένα iframe",
|
||||
"widgetRssName": "Ροή RSS",
|
||||
"widgetRssDesc": "Εμφάνιση στοιχείων από μια ροή RSS ή Atom",
|
||||
"dragToFolder": "Σύρετε τα γραφικά στοιχεία εδώ ή χρησιμοποιήστε το + για προσθήκη",
|
||||
"showDisk": "Εμφάνιση χρήσης δίσκου",
|
||||
"addToFolder": "Προσθήκη γραφικού στοιχείου σε φάκελο",
|
||||
"noHostSelected": "Δεν έχει επιλεγεί κεντρικός υπολογιστής",
|
||||
"selectHost": "Επιλέξτε έναν οικοδεσπότη...",
|
||||
"displayedMetrics": "Εμφανιζόμενες μετρήσεις",
|
||||
"metricCpu": "ΚΜΕ",
|
||||
"metricMemory": "Μνήμη",
|
||||
"metricDisk": "Δίσκος",
|
||||
"metricUptime": "Χρόνος λειτουργίας",
|
||||
"metricSystem": "Σύστημα",
|
||||
"metricNetwork": "Δίκτυο",
|
||||
"metricProcesses": "Διαδικασίες",
|
||||
"categoryMonitoring": "Παρακολούθηση",
|
||||
"loading": "Φόρτωση...",
|
||||
"allClear": "Ορίζοντας είναι σαφής",
|
||||
"noPingUrls": "Δεν έχουν ρυθμιστεί διευθύνσεις URL",
|
||||
"pingLabel": "Επιγραφή",
|
||||
"addPingUrl": "Προσθήκη διεύθυνσης URL",
|
||||
"showLatency": "Εμφάνιση καθυστέρησης",
|
||||
"refreshInterval": "Διάστημα ανανέωσης",
|
||||
"seconds": "δευτερόλεπτα",
|
||||
"filterActivityTypes": "Τύποι φίλτρων",
|
||||
"filterTypesHint": "Αφήστε κενό για να εμφανιστούν όλα",
|
||||
"showTimestamp": "Εμφάνιση χρονικής σήμανσης",
|
||||
"uptimeUnavailable": "Ο χρόνος λειτουργίας δεν είναι διαθέσιμος",
|
||||
"uptimeLabel": "Χρόνος λειτουργίας",
|
||||
"overviewVersion": "Εκδοχή",
|
||||
"overviewUpdate": "Σύγχρονος",
|
||||
"overviewUpdateLabel": "Εκσυγχρονίζω",
|
||||
"overviewUpdateAvailable": "Διαθέσιμη ενημέρωση",
|
||||
"overviewDatabase": "Βάση δεδομένων",
|
||||
"overviewUptime": "Χρόνος λειτουργίας",
|
||||
"noHosts": "Δεν έχουν ρυθμιστεί κεντρικοί υπολογιστές",
|
||||
"hostGridHosts": "Οικοδεσπότες",
|
||||
"hostGridAllHint": "Αφήστε κενό για να εμφανιστούν όλοι οι κεντρικοί υπολογιστές",
|
||||
"columns": "Στήλες",
|
||||
"showIp": "Εμφάνιση διεύθυνσης IP",
|
||||
"connectionType": "Τύπος σύνδεσης",
|
||||
"layout": "Σχέδιο",
|
||||
"showStatus": "Εμφάνιση κατάστασης",
|
||||
"showHostName": "Εμφάνιση ονόματος κεντρικού υπολογιστή",
|
||||
"noDockerActivity": "Καμία δραστηριότητα Docker",
|
||||
"noActivity": "Καμία δραστηριότητα",
|
||||
"showAcknowledged": "Εμφάνιση Επιβεβαιωμένων",
|
||||
"widgetMetricsChartName": "Γράφημα μετρήσεων",
|
||||
"widgetMetricsChartDesc": "Ιστορικό διάγραμμα CPU, μνήμης, δίσκου ή δικτύου για έναν κεντρικό υπολογιστή",
|
||||
"widgetHostGridName": "Πλέγμα κεντρικού υπολογιστή",
|
||||
"widgetHostGridDesc": "Προβολή πλέγματος των καταστάσεων κεντρικού υπολογιστή SSH",
|
||||
"widgetPingStatusName": "Κατάσταση Ping",
|
||||
"widgetPingStatusDesc": "Κατάσταση ping HTTP για μία ή περισσότερες διευθύνσεις URL",
|
||||
"widgetRecentActivityName": "Πρόσφατη δραστηριότητα",
|
||||
"widgetRecentActivityDesc": "Ροή με δυνατότητα κύλισης για την πρόσφατη δραστηριότητα του Termix",
|
||||
"widgetTermixUptimeName": "Χρόνος λειτουργίας Termix",
|
||||
"widgetTermixUptimeDesc": "Μετρητής χρόνου λειτουργίας σε πραγματικό χρόνο για τον διακομιστή Termix",
|
||||
@@ -347,67 +268,8 @@
|
||||
"widgetSearchLinksDesc": "Κουμπιά συντόμευσης γρήγορης αναζήτησης με ενσωματωμένη εισαγωγή",
|
||||
"widgetLinkTreeName": "Δέντρο συνδέσμων",
|
||||
"widgetLinkTreeDesc": "Ομαδοποιημένες ενότητες συνδέσμων με επικεφαλίδες",
|
||||
"calMon": "Μο",
|
||||
"calTue": "Τρ",
|
||||
"calWed": "Εμείς",
|
||||
"calThu": "Πέμ",
|
||||
"calFri": "Πατέρας",
|
||||
"calSat": "Σάβ",
|
||||
"calSun": "Κυρ",
|
||||
"startOnMonday": "Έναρξη εβδομάδας τη Δευτέρα",
|
||||
"countdownNoDate": "Δεν έχει οριστεί ημερομηνία-στόχος",
|
||||
"countdownPast": "Η εκδήλωση πέρασε",
|
||||
"countdownDays": "ημέρες",
|
||||
"countdownHours": "ώρες",
|
||||
"countdownMinutes": "λεπτά",
|
||||
"countdownSeconds": "δευτ.",
|
||||
"countdownLabel": "Επιγραφή",
|
||||
"countdownLabelPlaceholder": "π.χ. Ημέρα Εκκίνησης",
|
||||
"countdownShowDays": "Ημέρες προβολής",
|
||||
"countdownShowHours": "Ώρες προβολής",
|
||||
"targetDate": "Ημερομηνία-στόχος",
|
||||
"searchEngine": "Μηχανή αναζήτησης",
|
||||
"customSearchUrl": "URL προσαρμοσμένης αναζήτησης",
|
||||
"searchPlaceholder": "Ερευνα...",
|
||||
"searchGo": "Πάω",
|
||||
"searchPlaceholderLabel": "Κείμενο κράτησης θέσης",
|
||||
"searchPlaceholderHint": "Κείμενο που εμφανίζεται μέσα στην εισαγωγή αναζήτησης",
|
||||
"openInNewTab": "Άνοιγμα σε νέα καρτέλα",
|
||||
"searchQueryPlaceholder": "Εισαγωγή ερωτήματος...",
|
||||
"noSearchShortcuts": "Δεν έχουν ρυθμιστεί συντομεύσεις",
|
||||
"addSearchShortcut": "Προσθήκη συντόμευσης",
|
||||
"fontSize": "Μέγεθος γραμματοσειράς",
|
||||
"textAlign": "Στοίχιση κειμένου",
|
||||
"fontWeight": "Πάχος γραμματοσειράς",
|
||||
"clearColor": "Διαυγές χρώμα",
|
||||
"imageFit": "Προσαρμογή εικόνας",
|
||||
"imageLinkUrl": "URL συνδέσμου",
|
||||
"noImage": "Δεν έχει οριστεί URL εικόνας",
|
||||
"altText": "Εναλλακτικό κείμενο",
|
||||
"altTextPlaceholder": "Περιγράψτε την εικόνα",
|
||||
"renderMarkdown": "Απόδοση Markdown",
|
||||
"displayMode": "Λειτουργία εμφάνισης",
|
||||
"displayField": "Πεδίο εμφάνισης",
|
||||
"jsonPath": "Διαδρομή JSON",
|
||||
"customApiLabel": "Επιγραφή",
|
||||
"customApiLabelPlaceholder": "π.χ. Θερμοκρασία",
|
||||
"customApiUnit": "Μονάδα",
|
||||
"customApiNoUrl": "Δεν έχει ρυθμιστεί URL API",
|
||||
"customApiError": "Η ανάκτηση απέτυχε",
|
||||
"customApiNotArray": "Η απάντηση δεν είναι πίνακας",
|
||||
"addService": "Προσθήκη υπηρεσίας",
|
||||
"showLabels": "Εμφάνιση ετικετών",
|
||||
"iconSize": "Μέγεθος εικονιδίου",
|
||||
"noDashboardLinks": "Δεν έχουν ρυθμιστεί σύνδεσμοι πίνακα ελέγχου",
|
||||
"noLimit": "Χωρίς όριο",
|
||||
"sectionHeading": "Επικεφαλίδα Ενότητας",
|
||||
"addSection": "Προσθήκη ενότητας",
|
||||
"compactMode": "Συμπαγής λειτουργία",
|
||||
"showDetailed": "Εμφάνιση λεπτομερειών",
|
||||
"selectHosts": "Επιλέξτε κεντρικούς υπολογιστές",
|
||||
"allHosts": "Όλοι οι οικοδεσπότες",
|
||||
"listLayout": "Λίστα",
|
||||
"gridLayout": "Πλέγμα",
|
||||
"terminal": "Τερματικό",
|
||||
"files": "Αρχεία",
|
||||
"docker": "Λιμενεργάτης",
|
||||
@@ -422,12 +284,8 @@
|
||||
"accentColor": "Χρώμα έμφασης",
|
||||
"widgetSshTerminalName": "Τερματικό SSH",
|
||||
"widgetSshTerminalDesc": "Ένα ενσωματωμένο τερματικό SSH συνδεδεμένο σε έναν διαμορφωμένο κεντρικό υπολογιστή",
|
||||
"sshTerminalNoHost": "Δεν έχει ρυθμιστεί κεντρικός υπολογιστής",
|
||||
"sshTerminalConnect": "Συνδέω",
|
||||
"sshTerminalAutoConnect": "Αυτόματη σύνδεση κατά τη φόρτωση",
|
||||
"widgetQuickConnectName": "Γρήγορη σύνδεση",
|
||||
"widgetQuickConnectDesc": "Κουμπιά εκκίνησης με ένα κλικ για οποιονδήποτε τύπο σύνδεσης σε όλους τους κεντρικούς υπολογιστές σας",
|
||||
"connectionTypes": "Τύποι σύνδεσης",
|
||||
"connType_terminal": "Τερματικό",
|
||||
"connType_files": "Διαχειριστής αρχείων",
|
||||
"connType_docker": "Λιμενεργάτης",
|
||||
@@ -685,7 +543,6 @@
|
||||
"renameTab": "Μετονομασία καρτέλας",
|
||||
"openFileManager": "Άνοιγμα Διαχείρισης Αρχείων",
|
||||
"dashboard": "Ταμπλό",
|
||||
"homepage": "Αρχική σελίδα",
|
||||
"quickConnect": "Γρήγορη σύνδεση",
|
||||
"sshTools": "Εργαλεία SSH",
|
||||
"history": "Ιστορία",
|
||||
@@ -1895,9 +1752,7 @@
|
||||
"serverStatsCard": "Στατιστικά διακομιστή",
|
||||
"panelMain": "Κύριος",
|
||||
"panelSide": "Πλευρά",
|
||||
"justNow": "μόλις τώρα",
|
||||
"serviceLinks": "Σύνδεσμοι υπηρεσιών",
|
||||
"homepagePreview": "Προεπισκόπηση αρχικής σελίδας"
|
||||
"justNow": "μόλις τώρα"
|
||||
},
|
||||
"donation": {
|
||||
"title": "Σας αρέσει το Termix;",
|
||||
@@ -1926,15 +1781,8 @@
|
||||
"editModeInstructions": "Σύρετε τις κάρτες για αναδιάταξη · Σύρετε το διαχωριστικό στηλών για αλλαγή μεγέθους στηλών · Σύρετε το κάτω άκρο μιας κάρτας για αλλαγή μεγέθους στο ύψος της · Κάδος απορριμμάτων για αφαίρεση",
|
||||
"empty": "Αδειάζω",
|
||||
"clear": "Σαφής",
|
||||
"serviceLinksTitle": "Σύνδεσμοι υπηρεσιών",
|
||||
"serviceLinksEmpty": "Δεν υπάρχουν ακόμη σύνδεσμοι υπηρεσίας",
|
||||
"serviceLinksAddLabel": "Επιγραφή",
|
||||
"serviceLinksAddUrl": "URL",
|
||||
"serviceLinksAdd": "Προσθέτω",
|
||||
"serviceLinksLabelPlaceholder": "Η Υπηρεσία μου",
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Εισαγάγετε μια έγκυρη διεύθυνση ιστού",
|
||||
"serviceLinksAddFailed": "Αποτυχία προσθήκης συνδέσμου υπηρεσίας",
|
||||
"viewServerDetails": "Προβολή λεπτομερειών διακομιστή"
|
||||
},
|
||||
"admin": {
|
||||
|
||||
@@ -195,9 +195,6 @@
|
||||
"arrangeUnlocked": "Desbloquea arrastrar para reorganizar"
|
||||
},
|
||||
"homepage": {
|
||||
"title": "Inicio",
|
||||
"addWidget": "Añadir widget",
|
||||
"editWidget": "Editar widget",
|
||||
"deleteWidget": "Eliminar",
|
||||
"widgetTypes": "Tipos de widgets",
|
||||
"serviceLink": "Enlace de servicio",
|
||||
@@ -206,116 +203,40 @@
|
||||
"notes": "Notas",
|
||||
"hostStatus": "Estado del host",
|
||||
"bookmarkList": "Marcadores",
|
||||
"zoomIn": "Acercar",
|
||||
"zoomOut": "Alejar",
|
||||
"resetView": "Restablecer vista",
|
||||
"lockLayout": "Bloquear diseño",
|
||||
"unlockLayout": "Desbloquear diseño",
|
||||
"noWidgets": "Haz clic derecho o pulsa + para añadir tu primer widget",
|
||||
"openFullView": "Abrir vista completa",
|
||||
"previewTitle": "Vista previa de la página de inicio",
|
||||
"cancel": "Cancelar",
|
||||
"save": "Guardar",
|
||||
"title_label": "Título",
|
||||
"widgetTitlePlaceholder": "Título del widget (opcional)",
|
||||
"url": "URL",
|
||||
"imageUrl": "URL de imagen personalizada",
|
||||
"imageUrlHint": "Déjalo en blanco para usar el favicon del sitio automáticamente",
|
||||
"showImage": "Mostrar imagen",
|
||||
"description": "Descripción",
|
||||
"color": "Color",
|
||||
"icon": "Icono",
|
||||
"expanded": "Expandido por defecto",
|
||||
"timezone": "Zona horaria",
|
||||
"invalidTimezone": "Zona horaria no válida. Utilice un nombre IANA como America/New_York.",
|
||||
"showSeconds": "Mostrar segundos",
|
||||
"format12h": "12 horas",
|
||||
"format24h": "24 horas",
|
||||
"content": "Contenido",
|
||||
"backgroundColor": "Color de fondo",
|
||||
"host": "Host",
|
||||
"showMetrics": "Mostrar métricas",
|
||||
"links": "Enlaces",
|
||||
"addLink": "Añadir enlace",
|
||||
"linkLabel": "Etiqueta",
|
||||
"linkUrl": "URL",
|
||||
"removeLink": "Eliminar",
|
||||
"categoryLinks": "Enlaces",
|
||||
"categoryInfo": "Información",
|
||||
"categorySystem": "Sistema",
|
||||
"widgetServiceLinkDesc": "Un mosaico interactivo que enlaza a una URL de servicio",
|
||||
"widgetFolderDesc": "Un contenedor para agrupar widgets relacionados",
|
||||
"widgetClockDesc": "Un reloj en vivo con zona horaria configurable",
|
||||
"widgetNotesDesc": "Un widget de notas en Markdown",
|
||||
"widgetHostStatusDesc": "Muestra uso de CPU, memoria y disco en vivo de un host SSH",
|
||||
"widgetBookmarkListDesc": "Una lista de enlaces rápidos",
|
||||
"copyLink": "Copiar enlace",
|
||||
"linkCopied": "¡Enlace copiado!",
|
||||
"location": "Ubicación",
|
||||
"temperatureUnit": "Unidad de temperatura",
|
||||
"showForecast": "Mostrar pronóstico de 3 días",
|
||||
"scrolling": "Permitir desplazamiento",
|
||||
"feedUrl": "URL del feed",
|
||||
"maxItems": "Máx. de elementos",
|
||||
"showDescription": "Mostrar descripción",
|
||||
"widgetWeatherName": "Tiempo",
|
||||
"widgetWeatherDesc": "El tiempo en vivo para cualquier ubicación",
|
||||
"widgetIframeName": "Incrustar iFrame",
|
||||
"widgetIframeDesc": "Incrusta cualquier URL en un iframe",
|
||||
"widgetRssName": "Fuente RSS",
|
||||
"widgetRssDesc": "Muestra elementos de una fuente RSS o Atom",
|
||||
"dragToFolder": "Arrastra widgets aquí o usa + para añadir",
|
||||
"showDisk": "Mostrar uso de disco",
|
||||
"addToFolder": "Añadir widget a la carpeta",
|
||||
"noHostSelected": "Ningún host seleccionado",
|
||||
"selectHost": "Seleccionar un host...",
|
||||
"displayedMetrics": "Métricas mostradas",
|
||||
"metricCpu": "CPU",
|
||||
"metricMemory": "Memoria",
|
||||
"metricDisk": "Disco",
|
||||
"metricUptime": "Tiempo de actividad",
|
||||
"metricSystem": "Sistema",
|
||||
"metricNetwork": "Red",
|
||||
"metricProcesses": "Procesos",
|
||||
"categoryMonitoring": "Monitorización",
|
||||
"loading": "Cargando...",
|
||||
"allClear": "Todo correcto",
|
||||
"noPingUrls": "No hay URLs configuradas",
|
||||
"pingLabel": "Etiqueta",
|
||||
"addPingUrl": "Añadir URL",
|
||||
"showLatency": "Mostrar latencia",
|
||||
"refreshInterval": "Intervalo de actualización",
|
||||
"seconds": "segundos",
|
||||
"filterActivityTypes": "Filtrar tipos",
|
||||
"filterTypesHint": "Dejar vacío para mostrar todo",
|
||||
"showTimestamp": "Mostrar marca de tiempo",
|
||||
"uptimeUnavailable": "Tiempo de actividad no disponible",
|
||||
"uptimeLabel": "Tiempo de actividad",
|
||||
"overviewVersion": "Versión",
|
||||
"overviewUpdate": "Actualizado",
|
||||
"overviewUpdateLabel": "Actualizar",
|
||||
"overviewUpdateAvailable": "Actualización disponible",
|
||||
"overviewDatabase": "Base de datos",
|
||||
"overviewUptime": "Tiempo de actividad",
|
||||
"noHosts": "Sin hosts configurados",
|
||||
"hostGridHosts": "Hosts",
|
||||
"hostGridAllHint": "Dejar vacío para mostrar todos los hosts",
|
||||
"columns": "Columnas",
|
||||
"showIp": "Mostrar dirección IP",
|
||||
"connectionType": "Tipo de conexión",
|
||||
"layout": "Diseño",
|
||||
"showStatus": "Mostrar estado",
|
||||
"showHostName": "Mostrar nombre de host",
|
||||
"noDockerActivity": "Sin actividad de Docker",
|
||||
"noActivity": "Sin actividad",
|
||||
"showAcknowledged": "Mostrar reconocidos",
|
||||
"widgetMetricsChartName": "Gráfico de métricas",
|
||||
"widgetMetricsChartDesc": "Gráfico histórico de CPU, memoria, disco o red para un host",
|
||||
"widgetHostGridName": "Cuadrícula de hosts",
|
||||
"widgetHostGridDesc": "Vista en cuadrícula de los estados de los hosts SSH",
|
||||
"widgetPingStatusName": "Estado de ping",
|
||||
"widgetPingStatusDesc": "Estado de ping HTTP para una o más URLs",
|
||||
"widgetRecentActivityName": "Actividad reciente",
|
||||
"widgetRecentActivityDesc": "Feed desplazable de actividad reciente de Termix",
|
||||
"widgetTermixUptimeName": "Tiempo de actividad de Termix",
|
||||
"widgetTermixUptimeDesc": "Contador en tiempo real del tiempo de actividad del servidor Termix",
|
||||
@@ -347,67 +268,8 @@
|
||||
"widgetSearchLinksDesc": "Botones de atajo de búsqueda rápida con entrada inline",
|
||||
"widgetLinkTreeName": "Árbol de enlaces",
|
||||
"widgetLinkTreeDesc": "Secciones agrupadas de enlaces con encabezados",
|
||||
"calMon": "Lu",
|
||||
"calTue": "Ma",
|
||||
"calWed": "Mi",
|
||||
"calThu": "Ju",
|
||||
"calFri": "Vi",
|
||||
"calSat": "Sa",
|
||||
"calSun": "Do",
|
||||
"startOnMonday": "Comenzar la semana el lunes",
|
||||
"countdownNoDate": "No se ha fijado una fecha objetivo",
|
||||
"countdownPast": "El evento ya ha pasado",
|
||||
"countdownDays": "días",
|
||||
"countdownHours": "h",
|
||||
"countdownMinutes": "min",
|
||||
"countdownSeconds": "s",
|
||||
"countdownLabel": "Etiqueta",
|
||||
"countdownLabelPlaceholder": "p. ej. Día de lanzamiento",
|
||||
"countdownShowDays": "Mostrar días",
|
||||
"countdownShowHours": "Mostrar horas",
|
||||
"targetDate": "Fecha objetivo",
|
||||
"searchEngine": "Motor de búsqueda",
|
||||
"customSearchUrl": "URL de búsqueda personalizada",
|
||||
"searchPlaceholder": "Buscar...",
|
||||
"searchGo": "Ir",
|
||||
"searchPlaceholderLabel": "Texto del marcador de posición",
|
||||
"searchPlaceholderHint": "Texto mostrado dentro del campo de búsqueda",
|
||||
"openInNewTab": "Abrir en nueva pestaña",
|
||||
"searchQueryPlaceholder": "Introducir consulta...",
|
||||
"noSearchShortcuts": "No hay accesos directos configurados",
|
||||
"addSearchShortcut": "Añadir acceso directo",
|
||||
"fontSize": "Tamaño de fuente",
|
||||
"textAlign": "Alineación del texto",
|
||||
"fontWeight": "Grosor de la fuente",
|
||||
"clearColor": "Limpiar color",
|
||||
"imageFit": "Ajuste de imagen",
|
||||
"imageLinkUrl": "URL del enlace",
|
||||
"noImage": "No se ha establecido una URL de imagen",
|
||||
"altText": "Texto alternativo",
|
||||
"altTextPlaceholder": "Describir la imagen",
|
||||
"renderMarkdown": "Procesar Markdown",
|
||||
"displayMode": "Modo de visualización",
|
||||
"displayField": "Campo de visualización",
|
||||
"jsonPath": "Ruta JSON",
|
||||
"customApiLabel": "Etiqueta",
|
||||
"customApiLabelPlaceholder": "p. ej. Temperatura",
|
||||
"customApiUnit": "Unidad",
|
||||
"customApiNoUrl": "No se ha configurado ninguna URL de API",
|
||||
"customApiError": "Error al obtener",
|
||||
"customApiNotArray": "La respuesta no es un array",
|
||||
"addService": "Añadir servicio",
|
||||
"showLabels": "Mostrar etiquetas",
|
||||
"iconSize": "Tamaño del icono",
|
||||
"noDashboardLinks": "Sin enlaces de panel configurados",
|
||||
"noLimit": "Sin límite",
|
||||
"sectionHeading": "Encabezado de sección",
|
||||
"addSection": "Añadir sección",
|
||||
"compactMode": "Modo compacto",
|
||||
"showDetailed": "Mostrar detallado",
|
||||
"selectHosts": "Seleccionar hosts",
|
||||
"allHosts": "Todos los hosts",
|
||||
"listLayout": "Lista",
|
||||
"gridLayout": "Cuadrícula",
|
||||
"terminal": "Terminal",
|
||||
"files": "Archivos",
|
||||
"docker": "Docker",
|
||||
@@ -422,12 +284,8 @@
|
||||
"accentColor": "Color de acento",
|
||||
"widgetSshTerminalName": "Terminal SSH",
|
||||
"widgetSshTerminalDesc": "Un terminal SSH integrado conectado a un host configurado",
|
||||
"sshTerminalNoHost": "Sin host configurado",
|
||||
"sshTerminalConnect": "Conectar",
|
||||
"sshTerminalAutoConnect": "Conectar automáticamente al cargar",
|
||||
"widgetQuickConnectName": "Conexión rápida",
|
||||
"widgetQuickConnectDesc": "Botones de inicio con un clic para cualquier tipo de conexión en tus hosts",
|
||||
"connectionTypes": "Tipos de conexión",
|
||||
"connType_terminal": "Terminal",
|
||||
"connType_files": "Gestor de archivos",
|
||||
"connType_docker": "Docker",
|
||||
@@ -685,7 +543,6 @@
|
||||
"renameTab": "Renombrar pestaña",
|
||||
"openFileManager": "Abrir gestor de archivos",
|
||||
"dashboard": "Panel de control",
|
||||
"homepage": "Inicio",
|
||||
"quickConnect": "Conexión rápida",
|
||||
"sshTools": "Herramientas SSH",
|
||||
"history": "Historial",
|
||||
@@ -1895,9 +1752,7 @@
|
||||
"serverStatsCard": "Estadísticas del servidor",
|
||||
"panelMain": "Principal",
|
||||
"panelSide": "Lateral",
|
||||
"justNow": "ahora mismo",
|
||||
"serviceLinks": "Enlaces de servicio",
|
||||
"homepagePreview": "Vista previa de la página de inicio"
|
||||
"justNow": "ahora mismo"
|
||||
},
|
||||
"donation": {
|
||||
"title": "¿Disfrutando de Termix?",
|
||||
@@ -1926,15 +1781,8 @@
|
||||
"editModeInstructions": "Arrastra las tarjetas para reordenar · Arrastra el divisor de columnas para redimensionar las columnas · Arrastra el borde inferior de una tarjeta para cambiar su altura · Arrastra a la papelera para eliminar",
|
||||
"empty": "Vacío",
|
||||
"clear": "Limpiar",
|
||||
"serviceLinksTitle": "Enlaces de servicio",
|
||||
"serviceLinksEmpty": "Aún no hay enlaces de servicio",
|
||||
"serviceLinksAddLabel": "Etiqueta",
|
||||
"serviceLinksAddUrl": "URL",
|
||||
"serviceLinksAdd": "Añadir",
|
||||
"serviceLinksLabelPlaceholder": "Mi servicio",
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Introduce una dirección web válida",
|
||||
"serviceLinksAddFailed": "Error al añadir el enlace de servicio",
|
||||
"viewServerDetails": "Ver detalles del servidor"
|
||||
},
|
||||
"admin": {
|
||||
|
||||
@@ -195,9 +195,6 @@
|
||||
"arrangeUnlocked": "Avaa vetämällä järjestelläksesi uudelleen"
|
||||
},
|
||||
"homepage": {
|
||||
"title": "Etusivu",
|
||||
"addWidget": "Lisää widget",
|
||||
"editWidget": "Muokkaa widgetiä",
|
||||
"deleteWidget": "Poistaa",
|
||||
"widgetTypes": "Widget-tyypit",
|
||||
"serviceLink": "Palvelulinkki",
|
||||
@@ -206,116 +203,40 @@
|
||||
"notes": "Muistiinpanoja",
|
||||
"hostStatus": "Isännän tila",
|
||||
"bookmarkList": "Kirjanmerkit",
|
||||
"zoomIn": "Lähennä",
|
||||
"zoomOut": "Loitonna",
|
||||
"resetView": "Palauta näkymä",
|
||||
"lockLayout": "Lukituksen asettelu",
|
||||
"unlockLayout": "Avaa asettelu",
|
||||
"noWidgets": "Lisää ensimmäinen widget napsauttamalla hiiren kakkospainikkeella tai napsauttamalla +",
|
||||
"openFullView": "Avaa koko näkymä",
|
||||
"previewTitle": "Etusivun esikatselu",
|
||||
"cancel": "Peruuttaa",
|
||||
"save": "Tallentaa",
|
||||
"title_label": "Otsikko",
|
||||
"widgetTitlePlaceholder": "Widgetin otsikko (valinnainen)",
|
||||
"url": "URL-osoite",
|
||||
"imageUrl": "Mukautetun kuvan URL-osoite",
|
||||
"imageUrlHint": "Jätä tyhjäksi, jos haluat käyttää sivuston suosikkikuvaketta automaattisesti",
|
||||
"showImage": "Näytä kuva",
|
||||
"description": "Kuvaus",
|
||||
"color": "Väri",
|
||||
"icon": "Kuvake",
|
||||
"expanded": "Oletusarvoisesti laajennettu",
|
||||
"timezone": "Aikavyöhyke",
|
||||
"invalidTimezone": "Virheellinen aikavyöhyke. Käytä IANA-nimeä, kuten Amerikka/New_York",
|
||||
"showSeconds": "Näytä sekunnit",
|
||||
"format12h": "12 tunnin",
|
||||
"format24h": "24 tunnin",
|
||||
"content": "Sisältö",
|
||||
"backgroundColor": "Taustaväri",
|
||||
"host": "Isäntä",
|
||||
"showMetrics": "Näytä mittarit",
|
||||
"links": "Linkit",
|
||||
"addLink": "Lisää linkki",
|
||||
"linkLabel": "Tarra",
|
||||
"linkUrl": "URL-osoite",
|
||||
"removeLink": "Poistaa",
|
||||
"categoryLinks": "Linkit",
|
||||
"categoryInfo": "Tiedot",
|
||||
"categorySystem": "Järjestelmä",
|
||||
"widgetServiceLinkDesc": "Klikattava laatta, joka linkittää palvelun URL-osoitteeseen",
|
||||
"widgetFolderDesc": "Säiliö toisiinsa liittyvien widgetien ryhmittelyyn",
|
||||
"widgetClockDesc": "Live-kello, jossa on konfiguroitava aikavyöhyke",
|
||||
"widgetNotesDesc": "Markdown-muistiinpano-widget",
|
||||
"widgetHostStatusDesc": "Näyttää reaaliaikaisen suorittimen, muistin ja levyn SSH-isännälle",
|
||||
"widgetBookmarkListDesc": "Pikalinkkien luettelo",
|
||||
"copyLink": "Kopioi linkki",
|
||||
"linkCopied": "Linkki kopioitu!",
|
||||
"location": "Sijainti",
|
||||
"temperatureUnit": "Lämpötilayksikkö",
|
||||
"showForecast": "Näytä 3 päivän ennuste",
|
||||
"scrolling": "Salli vieritys",
|
||||
"feedUrl": "Syötteen URL-osoite",
|
||||
"maxItems": "Kohteiden enimmäismäärä",
|
||||
"showDescription": "Näytä kuvaus",
|
||||
"widgetWeatherName": "Sää",
|
||||
"widgetWeatherDesc": "Live-sää missä tahansa paikassa",
|
||||
"widgetIframeName": "iFrame-upotus",
|
||||
"widgetIframeDesc": "Upota mikä tahansa URL-osoite iframe-kehykseen",
|
||||
"widgetRssName": "RSS-syöte",
|
||||
"widgetRssDesc": "Näytä RSS- tai Atom-syötteen kohteita",
|
||||
"dragToFolder": "Vedä widgetit tähän tai lisää ne käyttämällä +-merkkiä",
|
||||
"showDisk": "Näytä levyn käyttö",
|
||||
"addToFolder": "Lisää widget kansioon",
|
||||
"noHostSelected": "Ei valittua isäntää",
|
||||
"selectHost": "Valitse isäntä...",
|
||||
"displayedMetrics": "Näytetyt mittarit",
|
||||
"metricCpu": "Suoritin",
|
||||
"metricMemory": "Muisti",
|
||||
"metricDisk": "Levy",
|
||||
"metricUptime": "Käyttöaika",
|
||||
"metricSystem": "Järjestelmä",
|
||||
"metricNetwork": "Verkko",
|
||||
"metricProcesses": "Prosessit",
|
||||
"categoryMonitoring": "Seuranta",
|
||||
"loading": "Ladataan...",
|
||||
"allClear": "Kaikki selvää",
|
||||
"noPingUrls": "Ei määritettyjä URL-osoitteita",
|
||||
"pingLabel": "Tarra",
|
||||
"addPingUrl": "Lisää URL-osoite",
|
||||
"showLatency": "Näytä latenssi",
|
||||
"refreshInterval": "Päivitysväli",
|
||||
"seconds": "sekuntia",
|
||||
"filterActivityTypes": "Suodatintyypit",
|
||||
"filterTypesHint": "Jätä tyhjäksi näyttääksesi kaikki",
|
||||
"showTimestamp": "Näytä aikaleima",
|
||||
"uptimeUnavailable": "Käyttöaika ei saatavilla",
|
||||
"uptimeLabel": "Käyttöaika",
|
||||
"overviewVersion": "Versio",
|
||||
"overviewUpdate": "Ajan tasalla",
|
||||
"overviewUpdateLabel": "Päivittää",
|
||||
"overviewUpdateAvailable": "Päivitys saatavilla",
|
||||
"overviewDatabase": "Tietokanta",
|
||||
"overviewUptime": "Käyttöaika",
|
||||
"noHosts": "Ei määritettyjä isäntiä",
|
||||
"hostGridHosts": "Isännät",
|
||||
"hostGridAllHint": "Jätä tyhjäksi, jos haluat näyttää kaikki isännät",
|
||||
"columns": "Sarakkeet",
|
||||
"showIp": "Näytä IP-osoite",
|
||||
"connectionType": "Yhteystyyppi",
|
||||
"layout": "Layout",
|
||||
"showStatus": "Näytä tila",
|
||||
"showHostName": "Näytä isäntänimi",
|
||||
"noDockerActivity": "Ei Docker-toimintaa",
|
||||
"noActivity": "Ei toimintaa",
|
||||
"showAcknowledged": "Näytä kuitattu",
|
||||
"widgetMetricsChartName": "Mittarikaavio",
|
||||
"widgetMetricsChartDesc": "Isännän historiallinen suorittimen, muistin, levyn tai verkon kaavio",
|
||||
"widgetHostGridName": "Isäntäverkko",
|
||||
"widgetHostGridDesc": "SSH-isäntätilojen ruudukkonäkymä",
|
||||
"widgetPingStatusName": "Ping-tila",
|
||||
"widgetPingStatusDesc": "Yhden tai useamman URL-osoitteen HTTP-ping-tila",
|
||||
"widgetRecentActivityName": "Viimeaikaiset toiminnot",
|
||||
"widgetRecentActivityDesc": "Vieritettävä syöte viimeaikaisesta Termix-toiminnasta",
|
||||
"widgetTermixUptimeName": "Termixin käyttöaika",
|
||||
"widgetTermixUptimeDesc": "Termix-palvelimen reaaliaikainen käyttöaikalaskuri",
|
||||
@@ -347,67 +268,8 @@
|
||||
"widgetSearchLinksDesc": "Pikahaun pikanäppäimet, joissa on rivikohtainen syöttö",
|
||||
"widgetLinkTreeName": "Linkkipuu",
|
||||
"widgetLinkTreeDesc": "Ryhmitellyt linkkiosiot otsikoineen",
|
||||
"calMon": "Mo",
|
||||
"calTue": "Ti",
|
||||
"calWed": "Me",
|
||||
"calThu": "To",
|
||||
"calFri": "Pe",
|
||||
"calSat": "La",
|
||||
"calSun": "Su",
|
||||
"startOnMonday": "Viikko alkaa maanantaina",
|
||||
"countdownNoDate": "Ei asetettua tavoitepäivämäärää",
|
||||
"countdownPast": "Tapahtuma on ohi",
|
||||
"countdownDays": "päivää",
|
||||
"countdownHours": "tuntia",
|
||||
"countdownMinutes": "min",
|
||||
"countdownSeconds": "sek",
|
||||
"countdownLabel": "Tarra",
|
||||
"countdownLabelPlaceholder": "esim. julkaisupäivä",
|
||||
"countdownShowDays": "Näyttelypäivät",
|
||||
"countdownShowHours": "Näytä aukioloajat",
|
||||
"targetDate": "Tavoitepäivämäärä",
|
||||
"searchEngine": "Hakukone",
|
||||
"customSearchUrl": "Mukautetun haun URL-osoite",
|
||||
"searchPlaceholder": "Haku...",
|
||||
"searchGo": "Mennä",
|
||||
"searchPlaceholderLabel": "Paikkamerkkiteksti",
|
||||
"searchPlaceholderHint": "Hakusyötteen sisällä näkyvä teksti",
|
||||
"openInNewTab": "Avaa uudessa välilehdessä",
|
||||
"searchQueryPlaceholder": "Syötä kysely...",
|
||||
"noSearchShortcuts": "Ei määritettyjä pikanäppäimiä",
|
||||
"addSearchShortcut": "Lisää pikakuvake",
|
||||
"fontSize": "Fonttikoko",
|
||||
"textAlign": "Tekstin tasaus",
|
||||
"fontWeight": "Fontin paino",
|
||||
"clearColor": "Kirkas väri",
|
||||
"imageFit": "Kuvan sovitus",
|
||||
"imageLinkUrl": "Linkin URL-osoite",
|
||||
"noImage": "Kuvan URL-osoitetta ei ole asetettu",
|
||||
"altText": "Vaihtoehtoinen teksti",
|
||||
"altTextPlaceholder": "Kuvaile kuvaa",
|
||||
"renderMarkdown": "Renderöi Markdown",
|
||||
"displayMode": "Näyttötila",
|
||||
"displayField": "Näyttökenttä",
|
||||
"jsonPath": "JSON-polku",
|
||||
"customApiLabel": "Tarra",
|
||||
"customApiLabelPlaceholder": "esim. Lämpötila",
|
||||
"customApiUnit": "Yksikkö",
|
||||
"customApiNoUrl": "API-URL-osoitetta ei ole määritetty",
|
||||
"customApiError": "Nouto epäonnistui",
|
||||
"customApiNotArray": "Vastaus ei ole taulukko",
|
||||
"addService": "Lisää palvelu",
|
||||
"showLabels": "Näytä otsikot",
|
||||
"iconSize": "Kuvakkeen koko",
|
||||
"noDashboardLinks": "Ei määritettyjä kojelaudan linkkejä",
|
||||
"noLimit": "Ei rajaa",
|
||||
"sectionHeading": "Osion otsikko",
|
||||
"addSection": "Lisää osio",
|
||||
"compactMode": "Kompakti tila",
|
||||
"showDetailed": "Näytä yksityiskohtainen",
|
||||
"selectHosts": "Valitse isännät",
|
||||
"allHosts": "Kaikki isännät",
|
||||
"listLayout": "Lista",
|
||||
"gridLayout": "Ruudukko",
|
||||
"terminal": "Terminaali",
|
||||
"files": "Tiedostot",
|
||||
"docker": "Satamatyöläinen",
|
||||
@@ -422,12 +284,8 @@
|
||||
"accentColor": "Korostusväri",
|
||||
"widgetSshTerminalName": "SSH-terminaali",
|
||||
"widgetSshTerminalDesc": "Sisäänrakennettu SSH-pääte, joka on yhdistetty konfiguroituun isäntään",
|
||||
"sshTerminalNoHost": "Ei määritettyä isäntää",
|
||||
"sshTerminalConnect": "Yhdistä",
|
||||
"sshTerminalAutoConnect": "Automaattinen yhteys kuormituksen yhteydessä",
|
||||
"widgetQuickConnectName": "Pikayhteys",
|
||||
"widgetQuickConnectDesc": "Yhden napsautuksen käynnistyspainikkeet mille tahansa yhteystyypille isäntätietokoneissasi",
|
||||
"connectionTypes": "Yhteystyypit",
|
||||
"connType_terminal": "Terminaali",
|
||||
"connType_files": "Tiedostonhallinta",
|
||||
"connType_docker": "Satamatyöläinen",
|
||||
@@ -685,7 +543,6 @@
|
||||
"renameTab": "Nimeä välilehti uudelleen",
|
||||
"openFileManager": "Avaa tiedostonhallinta",
|
||||
"dashboard": "Kojelauta",
|
||||
"homepage": "Etusivu",
|
||||
"quickConnect": "Pikayhteys",
|
||||
"sshTools": "SSH-työkalut",
|
||||
"history": "Historia",
|
||||
@@ -1895,9 +1752,7 @@
|
||||
"serverStatsCard": "Palvelimen tilastot",
|
||||
"panelMain": "Pääasiallinen",
|
||||
"panelSide": "Sivu",
|
||||
"justNow": "juuri nyt",
|
||||
"serviceLinks": "Palvelulinkit",
|
||||
"homepagePreview": "Etusivun esikatselu"
|
||||
"justNow": "juuri nyt"
|
||||
},
|
||||
"donation": {
|
||||
"title": "Pidätkö Termixistä?",
|
||||
@@ -1926,15 +1781,8 @@
|
||||
"editModeInstructions": "Vedä kortteja järjestelläksesi uudelleen · Vedä sarakeväliä muuttaaksesi sarakkeiden kokoa · Vedä kortin alareunaa muuttaaksesi sen korkeutta · Roskakori poistaaksesi",
|
||||
"empty": "Tyhjä",
|
||||
"clear": "Tyhjennä",
|
||||
"serviceLinksTitle": "Palvelulinkit",
|
||||
"serviceLinksEmpty": "Ei vielä palvelulinkkejä",
|
||||
"serviceLinksAddLabel": "Tarra",
|
||||
"serviceLinksAddUrl": "URL-osoite",
|
||||
"serviceLinksAdd": "Lisätä",
|
||||
"serviceLinksLabelPlaceholder": "Palveluni",
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Anna kelvollinen verkko-osoite",
|
||||
"serviceLinksAddFailed": "Palvelulinkin lisääminen epäonnistui",
|
||||
"viewServerDetails": "Näytä palvelimen tiedot"
|
||||
},
|
||||
"admin": {
|
||||
|
||||
@@ -195,9 +195,6 @@
|
||||
"arrangeUnlocked": "Déverrouillez et faites glisser pour réorganiser"
|
||||
},
|
||||
"homepage": {
|
||||
"title": "Accueil",
|
||||
"addWidget": "Ajouter un widget",
|
||||
"editWidget": "Modifier le widget",
|
||||
"deleteWidget": "Supprimer",
|
||||
"widgetTypes": "Types de widgets",
|
||||
"serviceLink": "Lien de service",
|
||||
@@ -206,116 +203,40 @@
|
||||
"notes": "Notes",
|
||||
"hostStatus": "Statut de l'hôte",
|
||||
"bookmarkList": "Favoris",
|
||||
"zoomIn": "Zoom avant",
|
||||
"zoomOut": "Zoom arrière",
|
||||
"resetView": "Réinitialiser la vue",
|
||||
"lockLayout": "Verrouiller la disposition",
|
||||
"unlockLayout": "Déverrouiller la disposition",
|
||||
"noWidgets": "Faites un clic droit ou cliquez sur + pour ajouter votre premier widget",
|
||||
"openFullView": "Ouvrir la vue complète",
|
||||
"previewTitle": "Aperçu de la page d'accueil",
|
||||
"cancel": "Annuler",
|
||||
"save": "Enregistrer",
|
||||
"title_label": "Titre",
|
||||
"widgetTitlePlaceholder": "Titre du widget (facultatif)",
|
||||
"url": "URL",
|
||||
"imageUrl": "URL d'image personnalisée",
|
||||
"imageUrlHint": "Laissez vide pour utiliser automatiquement le favicon du site",
|
||||
"showImage": "Afficher l'image",
|
||||
"description": "Description",
|
||||
"color": "Couleur",
|
||||
"icon": "Icône",
|
||||
"expanded": "Développé par défaut",
|
||||
"timezone": "Fuseau horaire",
|
||||
"invalidTimezone": "Fuseau horaire invalide. Veuillez utiliser un nom IANA tel que America/New_York.",
|
||||
"showSeconds": "Afficher les secondes",
|
||||
"format12h": "Format 12 heures",
|
||||
"format24h": "Format 24 heures",
|
||||
"content": "Contenu",
|
||||
"backgroundColor": "Couleur d'arrière-plan",
|
||||
"host": "Hôte",
|
||||
"showMetrics": "Afficher les métriques",
|
||||
"links": "Liens",
|
||||
"addLink": "Ajouter un lien",
|
||||
"linkLabel": "Libellé",
|
||||
"linkUrl": "URL",
|
||||
"removeLink": "Supprimer",
|
||||
"categoryLinks": "Liens",
|
||||
"categoryInfo": "Info",
|
||||
"categorySystem": "Système",
|
||||
"widgetServiceLinkDesc": "Une tuile cliquable renvoyant vers une URL de service",
|
||||
"widgetFolderDesc": "Un conteneur pour regrouper des widgets liés",
|
||||
"widgetClockDesc": "Une horloge en temps réel avec fuseau horaire configurable",
|
||||
"widgetNotesDesc": "Un widget de notes Markdown",
|
||||
"widgetHostStatusDesc": "Affiche en direct le CPU, la mémoire et le disque d'un hôte SSH",
|
||||
"widgetBookmarkListDesc": "Une liste de liens rapides",
|
||||
"copyLink": "Copier le lien",
|
||||
"linkCopied": "Lien copié !",
|
||||
"location": "Localisation",
|
||||
"temperatureUnit": "Unité de température",
|
||||
"showForecast": "Afficher les prévisions sur 3 jours",
|
||||
"scrolling": "Autoriser le défilement",
|
||||
"feedUrl": "URL du flux",
|
||||
"maxItems": "Nombre max d'éléments",
|
||||
"showDescription": "Afficher la description",
|
||||
"widgetWeatherName": "Météo",
|
||||
"widgetWeatherDesc": "Météo en direct pour n'importe quel lieu",
|
||||
"widgetIframeName": "Intégration iFrame",
|
||||
"widgetIframeDesc": "Intègre n'importe quelle URL dans une iframe",
|
||||
"widgetRssName": "Flux RSS",
|
||||
"widgetRssDesc": "Affiche les éléments d'un flux RSS ou Atom",
|
||||
"dragToFolder": "Faites glisser les widgets ici ou utilisez + pour ajouter",
|
||||
"showDisk": "Afficher l'utilisation du disque",
|
||||
"addToFolder": "Ajouter le widget au dossier",
|
||||
"noHostSelected": "Aucun hôte sélectionné",
|
||||
"selectHost": "Sélectionner un hôte…",
|
||||
"displayedMetrics": "Métriques affichées",
|
||||
"metricCpu": "CPU",
|
||||
"metricMemory": "Mémoire",
|
||||
"metricDisk": "Disque",
|
||||
"metricUptime": "Uptime",
|
||||
"metricSystem": "Système",
|
||||
"metricNetwork": "Réseau",
|
||||
"metricProcesses": "Processus",
|
||||
"categoryMonitoring": "Surveillance",
|
||||
"loading": "Chargement…",
|
||||
"allClear": "Aucune alerte",
|
||||
"noPingUrls": "Aucune URL configurée",
|
||||
"pingLabel": "Libellé",
|
||||
"addPingUrl": "Ajouter une URL",
|
||||
"showLatency": "Afficher la latence",
|
||||
"refreshInterval": "Intervalle de rafraîchissement",
|
||||
"seconds": "secondes",
|
||||
"filterActivityTypes": "Filtrer les types",
|
||||
"filterTypesHint": "Laisser vide pour tout afficher",
|
||||
"showTimestamp": "Afficher l’horodatage",
|
||||
"uptimeUnavailable": "Uptime non disponible",
|
||||
"uptimeLabel": "Uptime",
|
||||
"overviewVersion": "Version",
|
||||
"overviewUpdate": "À jour",
|
||||
"overviewUpdateLabel": "Mise à jour",
|
||||
"overviewUpdateAvailable": "Mise à jour disponible",
|
||||
"overviewDatabase": "Base de données",
|
||||
"overviewUptime": "Uptime",
|
||||
"noHosts": "Aucun hôte configuré",
|
||||
"hostGridHosts": "Hôtes",
|
||||
"hostGridAllHint": "Laissez vide pour afficher tous les hôtes",
|
||||
"columns": "Colonnes",
|
||||
"showIp": "Afficher l'adresse IP",
|
||||
"connectionType": "Type de connexion",
|
||||
"layout": "Disposition",
|
||||
"showStatus": "Afficher le statut",
|
||||
"showHostName": "Afficher le nom d'hôte",
|
||||
"noDockerActivity": "Aucune activité Docker",
|
||||
"noActivity": "Aucune activité",
|
||||
"showAcknowledged": "Afficher les acquittés",
|
||||
"widgetMetricsChartName": "Graphique de métriques",
|
||||
"widgetMetricsChartDesc": "Graphique historique CPU, mémoire, disque ou réseau pour un hôte",
|
||||
"widgetHostGridName": "Grille d'hôtes",
|
||||
"widgetHostGridDesc": "Vue en grille des statuts des hôtes SSH",
|
||||
"widgetPingStatusName": "Statut du ping",
|
||||
"widgetPingStatusDesc": "Statut du ping HTTP pour une ou plusieurs URL",
|
||||
"widgetRecentActivityName": "Activité récente",
|
||||
"widgetRecentActivityDesc": "Flux défilant de l'activité récente de Termix",
|
||||
"widgetTermixUptimeName": "Temps de disponibilité de Termix",
|
||||
"widgetTermixUptimeDesc": "Compteur en direct du temps de disponibilité du serveur Termix",
|
||||
@@ -347,67 +268,8 @@
|
||||
"widgetSearchLinksDesc": "Boutons de raccourci de recherche rapide avec saisie intégrée",
|
||||
"widgetLinkTreeName": "Arborescence de liens",
|
||||
"widgetLinkTreeDesc": "Sections groupées de liens avec des titres",
|
||||
"calMon": "Lu",
|
||||
"calTue": "Ma",
|
||||
"calWed": "Me",
|
||||
"calThu": "Je",
|
||||
"calFri": "Ve",
|
||||
"calSat": "Sa",
|
||||
"calSun": "Di",
|
||||
"startOnMonday": "Commencer la semaine le lundi",
|
||||
"countdownNoDate": "Aucune date cible définie",
|
||||
"countdownPast": "L'événement est passé",
|
||||
"countdownDays": "jours",
|
||||
"countdownHours": "h",
|
||||
"countdownMinutes": "min",
|
||||
"countdownSeconds": "s",
|
||||
"countdownLabel": "Libellé",
|
||||
"countdownLabelPlaceholder": "ex. : Jour de lancement",
|
||||
"countdownShowDays": "Afficher les jours",
|
||||
"countdownShowHours": "Afficher les heures",
|
||||
"targetDate": "Date cible",
|
||||
"searchEngine": "Moteur de recherche",
|
||||
"customSearchUrl": "URL de recherche personnalisée",
|
||||
"searchPlaceholder": "Rechercher...",
|
||||
"searchGo": "OK",
|
||||
"searchPlaceholderLabel": "Texte indicatif",
|
||||
"searchPlaceholderHint": "Texte affiché dans le champ de recherche",
|
||||
"openInNewTab": "Ouvrir dans un nouvel onglet",
|
||||
"searchQueryPlaceholder": "Saisir une requête...",
|
||||
"noSearchShortcuts": "Aucun raccourci configuré",
|
||||
"addSearchShortcut": "Ajouter un raccourci",
|
||||
"fontSize": "Taille de la police",
|
||||
"textAlign": "Alignement du texte",
|
||||
"fontWeight": "Graisse de la police",
|
||||
"clearColor": "Effacer la couleur",
|
||||
"imageFit": "Ajustement de l'image",
|
||||
"imageLinkUrl": "URL du lien",
|
||||
"noImage": "Aucune URL d'image définie",
|
||||
"altText": "Texte alternatif",
|
||||
"altTextPlaceholder": "Décrire l'image",
|
||||
"renderMarkdown": "Rendu Markdown",
|
||||
"displayMode": "Mode d'affichage",
|
||||
"displayField": "Champ d'affichage",
|
||||
"jsonPath": "Chemin JSON",
|
||||
"customApiLabel": "Étiquette",
|
||||
"customApiLabelPlaceholder": "ex. : Température",
|
||||
"customApiUnit": "Unité",
|
||||
"customApiNoUrl": "Aucune URL d'API configurée",
|
||||
"customApiError": "Échec de la récupération",
|
||||
"customApiNotArray": "La réponse n'est pas un tableau",
|
||||
"addService": "Ajouter un service",
|
||||
"showLabels": "Afficher les étiquettes",
|
||||
"iconSize": "Taille des icônes",
|
||||
"noDashboardLinks": "Aucun lien de tableau de bord configuré",
|
||||
"noLimit": "Aucune limite",
|
||||
"sectionHeading": "Titre de la section",
|
||||
"addSection": "Ajouter une section",
|
||||
"compactMode": "Mode compact",
|
||||
"showDetailed": "Afficher les détails",
|
||||
"selectHosts": "Sélectionner les hôtes",
|
||||
"allHosts": "Tous les hôtes",
|
||||
"listLayout": "Liste",
|
||||
"gridLayout": "Grille",
|
||||
"terminal": "Terminal",
|
||||
"files": "Fichiers",
|
||||
"docker": "Docker",
|
||||
@@ -422,12 +284,8 @@
|
||||
"accentColor": "Couleur d'accent",
|
||||
"widgetSshTerminalName": "Terminal SSH",
|
||||
"widgetSshTerminalDesc": "Un terminal SSH intégré connecté à un hôte configuré",
|
||||
"sshTerminalNoHost": "Aucun hôte configuré",
|
||||
"sshTerminalConnect": "Connecter",
|
||||
"sshTerminalAutoConnect": "Connexion automatique au chargement",
|
||||
"widgetQuickConnectName": "Connexion rapide",
|
||||
"widgetQuickConnectDesc": "Boutons de lancement en un clic pour tout type de connexion à vos hôtes",
|
||||
"connectionTypes": "Types de connexion",
|
||||
"connType_terminal": "Terminal",
|
||||
"connType_files": "Gestionnaire de fichiers",
|
||||
"connType_docker": "Docker",
|
||||
@@ -685,7 +543,6 @@
|
||||
"renameTab": "Renommer l'onglet",
|
||||
"openFileManager": "Ouvrir le gestionnaire de fichiers",
|
||||
"dashboard": "Tableau de bord",
|
||||
"homepage": "Accueil",
|
||||
"quickConnect": "Connexion rapide",
|
||||
"sshTools": "Outils SSH",
|
||||
"history": "Historique",
|
||||
@@ -1895,9 +1752,7 @@
|
||||
"serverStatsCard": "Statistiques du serveur",
|
||||
"panelMain": "Principal",
|
||||
"panelSide": "Latéral",
|
||||
"justNow": "à l'instant",
|
||||
"serviceLinks": "Liens de service",
|
||||
"homepagePreview": "Aperçu de la page d'accueil"
|
||||
"justNow": "à l'instant"
|
||||
},
|
||||
"donation": {
|
||||
"title": "Vous appréciez Termix ?",
|
||||
@@ -1926,15 +1781,8 @@
|
||||
"editModeInstructions": "Faites glisser les cartes pour les réorganiser · Faites glisser le séparateur de colonne pour redimensionner les colonnes · Faites glisser le bord inférieur d’une carte pour redimensionner sa hauteur · Corbeille pour supprimer",
|
||||
"empty": "Vide",
|
||||
"clear": "Effacer",
|
||||
"serviceLinksTitle": "Liens de service",
|
||||
"serviceLinksEmpty": "Aucun lien de service pour l’instant",
|
||||
"serviceLinksAddLabel": "Libellé",
|
||||
"serviceLinksAddUrl": "URL",
|
||||
"serviceLinksAdd": "Ajouter",
|
||||
"serviceLinksLabelPlaceholder": "Mon service",
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Saisissez une adresse Web valide",
|
||||
"serviceLinksAddFailed": "Échec de l’ajout du lien de service",
|
||||
"viewServerDetails": "Afficher les détails du serveur"
|
||||
},
|
||||
"admin": {
|
||||
|
||||
@@ -195,9 +195,6 @@
|
||||
"arrangeUnlocked": "גרור כדי לסדר מחדש"
|
||||
},
|
||||
"homepage": {
|
||||
"title": "דף הבית",
|
||||
"addWidget": "הוסף ווידג'ט",
|
||||
"editWidget": "עריכת ווידג'ט",
|
||||
"deleteWidget": "לִמְחוֹק",
|
||||
"widgetTypes": "סוגי ווידג'טים",
|
||||
"serviceLink": "קישור שירות",
|
||||
@@ -206,116 +203,40 @@
|
||||
"notes": "הערות",
|
||||
"hostStatus": "סטטוס מארח",
|
||||
"bookmarkList": "סימניות",
|
||||
"zoomIn": "לְהִתְמַקֵד",
|
||||
"zoomOut": "התקרבות",
|
||||
"resetView": "איפוס תצוגה",
|
||||
"lockLayout": "פריסת נעילה",
|
||||
"unlockLayout": "נעילת פריסה",
|
||||
"noWidgets": "לחץ לחיצה ימנית או לחץ על + כדי להוסיף את הווידג'ט הראשון שלך",
|
||||
"openFullView": "פתח תצוגה מלאה",
|
||||
"previewTitle": "תצוגה מקדימה של דף הבית",
|
||||
"cancel": "לְבַטֵל",
|
||||
"save": "לְהַצִיל",
|
||||
"title_label": "כּוֹתֶרֶת",
|
||||
"widgetTitlePlaceholder": "כותרת הווידג'ט (אופציונלי)",
|
||||
"url": "כתובת אתר",
|
||||
"imageUrl": "כתובת אתר של תמונה מותאמת אישית",
|
||||
"imageUrlHint": "השאר ריק כדי להשתמש בסמל האתר באופן אוטומטי",
|
||||
"showImage": "הצג תמונה",
|
||||
"description": "תֵאוּר",
|
||||
"color": "צֶבַע",
|
||||
"icon": "אייקון",
|
||||
"expanded": "מורחב כברירת מחדל",
|
||||
"timezone": "אזור זמן",
|
||||
"invalidTimezone": "אזור זמן לא חוקי. השתמש בשם IANA כגון America/New_York",
|
||||
"showSeconds": "הצג שניות",
|
||||
"format12h": "12 שעות",
|
||||
"format24h": "24 שעות ביממה",
|
||||
"content": "תוֹכֶן",
|
||||
"backgroundColor": "צבע רקע",
|
||||
"host": "מְאָרֵחַ",
|
||||
"showMetrics": "הצג מדדים",
|
||||
"links": "קישורים",
|
||||
"addLink": "הוסף קישור",
|
||||
"linkLabel": "מַדבֵּקָה",
|
||||
"linkUrl": "כתובת אתר",
|
||||
"removeLink": "לְהַסִיר",
|
||||
"categoryLinks": "קישורים",
|
||||
"categoryInfo": "מידע",
|
||||
"categorySystem": "מַעֲרֶכֶת",
|
||||
"widgetServiceLinkDesc": "אריח הניתן ללחיצה המקשר לכתובת URL של שירות",
|
||||
"widgetFolderDesc": "מיכל לקיבוץ ווידג'טים קשורים",
|
||||
"widgetClockDesc": "שעון חי עם אזור זמן הניתן להגדרה",
|
||||
"widgetNotesDesc": "ווידג'ט הערות הנחה",
|
||||
"widgetHostStatusDesc": "מציג את המעבד, הזיכרון והדיסק בזמן אמת עבור מארח SSH",
|
||||
"widgetBookmarkListDesc": "רשימה של קישורים מהירים",
|
||||
"copyLink": "העתק קישור",
|
||||
"linkCopied": "קישור הועתק!",
|
||||
"location": "מִקוּם",
|
||||
"temperatureUnit": "יחידת טמפרטורה",
|
||||
"showForecast": "הצג תחזית ל-3 ימים",
|
||||
"scrolling": "אפשר גלילה",
|
||||
"feedUrl": "כתובת אתר של הזנה",
|
||||
"maxItems": "מקסימום פריטים",
|
||||
"showDescription": "הצג תיאור",
|
||||
"widgetWeatherName": "מֶזֶג אֲוִיר",
|
||||
"widgetWeatherDesc": "מזג אוויר חי לכל מקום",
|
||||
"widgetIframeName": "הטמעת iFrame",
|
||||
"widgetIframeDesc": "הטמע כל כתובת URL ב-iframe",
|
||||
"widgetRssName": "עדכון RSS",
|
||||
"widgetRssDesc": "הצגת פריטים מפיד RSS או Atom",
|
||||
"dragToFolder": "גררו ווידג'טים לכאן או השתמשו ב-+ כדי להוסיף",
|
||||
"showDisk": "הצג שימוש בדיסק",
|
||||
"addToFolder": "הוסף ווידג'ט לתיקייה",
|
||||
"noHostSelected": "לא נבחר מארח",
|
||||
"selectHost": "בחר מארח...",
|
||||
"displayedMetrics": "מדדים מוצגים",
|
||||
"metricCpu": "מעבד",
|
||||
"metricMemory": "זֵכֶר",
|
||||
"metricDisk": "דִיסק",
|
||||
"metricUptime": "זמן פעולה",
|
||||
"metricSystem": "מַעֲרֶכֶת",
|
||||
"metricNetwork": "רֶשֶׁת",
|
||||
"metricProcesses": "תהליכים",
|
||||
"categoryMonitoring": "ניטור",
|
||||
"loading": "טְעִינָה...",
|
||||
"allClear": "הכל ברור",
|
||||
"noPingUrls": "לא הוגדרו כתובות URL",
|
||||
"pingLabel": "מַדבֵּקָה",
|
||||
"addPingUrl": "הוסף כתובת URL",
|
||||
"showLatency": "הצג זמן השהייה",
|
||||
"refreshInterval": "מרווח רענון",
|
||||
"seconds": "שניות",
|
||||
"filterActivityTypes": "סוגי סינון",
|
||||
"filterTypesHint": "השאר ריק כדי להציג הכל",
|
||||
"showTimestamp": "הצג חותמת זמן",
|
||||
"uptimeUnavailable": "זמן פעולה לא זמין",
|
||||
"uptimeLabel": "זמן פעולה",
|
||||
"overviewVersion": "גִרְסָה",
|
||||
"overviewUpdate": "מעודכן",
|
||||
"overviewUpdateLabel": "לְעַדְכֵּן",
|
||||
"overviewUpdateAvailable": "עדכון זמין",
|
||||
"overviewDatabase": "מסד נתונים",
|
||||
"overviewUptime": "זמן פעולה",
|
||||
"noHosts": "לא הוגדרו מארחים",
|
||||
"hostGridHosts": "מארחים",
|
||||
"hostGridAllHint": "השאר ריק כדי להציג את כל המארחים",
|
||||
"columns": "עמודות",
|
||||
"showIp": "הצג כתובת IP",
|
||||
"connectionType": "סוג חיבור",
|
||||
"layout": "מַעֲרָך",
|
||||
"showStatus": "הצג סטטוס",
|
||||
"showHostName": "הצג שם מארח",
|
||||
"noDockerActivity": "אין פעילות של דוקר",
|
||||
"noActivity": "אין פעילות",
|
||||
"showAcknowledged": "הצג אישור",
|
||||
"widgetMetricsChartName": "תרשים מדדים",
|
||||
"widgetMetricsChartDesc": "תרשים היסטורי של מעבד, זיכרון, דיסק או רשת עבור מחשב מארח",
|
||||
"widgetHostGridName": "רשת מארחים",
|
||||
"widgetHostGridDesc": "תצוגת רשת של סטטוסי מארח SSH",
|
||||
"widgetPingStatusName": "סטטוס פינג",
|
||||
"widgetPingStatusDesc": "סטטוס פינג HTTP עבור כתובת URL אחת או יותר",
|
||||
"widgetRecentActivityName": "פעילות אחרונה",
|
||||
"widgetRecentActivityDesc": "הזנה גלילה של פעילות טרמיקס האחרונה",
|
||||
"widgetTermixUptimeName": "זמן פעילות של טרמיקס",
|
||||
"widgetTermixUptimeDesc": "מונה זמן פעולה בזמן אמת עבור שרת Termix",
|
||||
@@ -347,67 +268,8 @@
|
||||
"widgetSearchLinksDesc": "כפתורי קיצור דרך לחיפוש מהיר עם קלט מובנה",
|
||||
"widgetLinkTreeName": "עץ קישורים",
|
||||
"widgetLinkTreeDesc": "מקטעים מקובצים של קישורים עם כותרות",
|
||||
"calMon": "מו",
|
||||
"calTue": "טו",
|
||||
"calWed": "אָנוּ",
|
||||
"calThu": "ה'",
|
||||
"calFri": "פר",
|
||||
"calSat": "סא",
|
||||
"calSun": "סו",
|
||||
"startOnMonday": "תחילת השבוע ביום שני",
|
||||
"countdownNoDate": "לא נקבע תאריך יעד",
|
||||
"countdownPast": "האירוע עבר",
|
||||
"countdownDays": "ימים",
|
||||
"countdownHours": "שעות",
|
||||
"countdownMinutes": "דקות",
|
||||
"countdownSeconds": "שניות",
|
||||
"countdownLabel": "מַדבֵּקָה",
|
||||
"countdownLabelPlaceholder": "למשל יום ההשקה",
|
||||
"countdownShowDays": "ימי המופע",
|
||||
"countdownShowHours": "שעות המופע",
|
||||
"targetDate": "תאריך יעד",
|
||||
"searchEngine": "מנוע חיפוש",
|
||||
"customSearchUrl": "כתובת אתר לחיפוש מותאמת אישית",
|
||||
"searchPlaceholder": "לְחַפֵּשׂ...",
|
||||
"searchGo": "לָלֶכֶת",
|
||||
"searchPlaceholderLabel": "טקסט מציין מיקום",
|
||||
"searchPlaceholderHint": "טקסט המוצג בתוך קלט החיפוש",
|
||||
"openInNewTab": "פתח בכרטיסייה חדשה",
|
||||
"searchQueryPlaceholder": "הזן שאילתה...",
|
||||
"noSearchShortcuts": "לא הוגדרו קיצורי דרך",
|
||||
"addSearchShortcut": "הוסף קיצור דרך",
|
||||
"fontSize": "גודל גופן",
|
||||
"textAlign": "יישור טקסט",
|
||||
"fontWeight": "משקל גופן",
|
||||
"clearColor": "צבע שקוף",
|
||||
"imageFit": "התאמת תמונה",
|
||||
"imageLinkUrl": "כתובת אתר של קישור",
|
||||
"noImage": "לא הוגדרה כתובת URL של תמונה",
|
||||
"altText": "טקסט חלופי",
|
||||
"altTextPlaceholder": "תאר את התמונה",
|
||||
"renderMarkdown": "רינדור סימון למטה",
|
||||
"displayMode": "מצב תצוגה",
|
||||
"displayField": "שדה תצוגה",
|
||||
"jsonPath": "נתיב JSON",
|
||||
"customApiLabel": "מַדבֵּקָה",
|
||||
"customApiLabelPlaceholder": "לדוגמה טמפרטורה",
|
||||
"customApiUnit": "יְחִידָה",
|
||||
"customApiNoUrl": "לא הוגדרה כתובת URL של API",
|
||||
"customApiError": "נכשלה האחזור",
|
||||
"customApiNotArray": "התגובה אינה מערך",
|
||||
"addService": "הוסף שירות",
|
||||
"showLabels": "הצג תוויות",
|
||||
"iconSize": "גודל סמל",
|
||||
"noDashboardLinks": "לא הוגדרו קישורי לוח מחוונים",
|
||||
"noLimit": "אין הגבלה",
|
||||
"sectionHeading": "כותרת סעיף",
|
||||
"addSection": "הוסף מדור",
|
||||
"compactMode": "מצב קומפקטי",
|
||||
"showDetailed": "הצג פרטים",
|
||||
"selectHosts": "בחר מארחים",
|
||||
"allHosts": "כל המארחים",
|
||||
"listLayout": "רְשִׁימָה",
|
||||
"gridLayout": "רֶשֶׁת",
|
||||
"terminal": "מָסוֹף",
|
||||
"files": "קבצים",
|
||||
"docker": "דוקר",
|
||||
@@ -422,12 +284,8 @@
|
||||
"accentColor": "צבע מבטא",
|
||||
"widgetSshTerminalName": "מסוף SSH",
|
||||
"widgetSshTerminalDesc": "מסוף SSH מובנה המחובר למארח מוגדר",
|
||||
"sshTerminalNoHost": "לא הוגדר מארח",
|
||||
"sshTerminalConnect": "לְחַבֵּר",
|
||||
"sshTerminalAutoConnect": "חיבור אוטומטי בעת טעינה",
|
||||
"widgetQuickConnectName": "חיבור מהיר",
|
||||
"widgetQuickConnectDesc": "כפתורי הפעלה בלחיצה אחת עבור כל סוג חיבור בין המארחים שלך",
|
||||
"connectionTypes": "סוגי חיבור",
|
||||
"connType_terminal": "מָסוֹף",
|
||||
"connType_files": "מנהל הקבצים",
|
||||
"connType_docker": "דוקר",
|
||||
@@ -685,7 +543,6 @@
|
||||
"renameTab": "שינוי שם הכרטיסייה",
|
||||
"openFileManager": "פתח את מנהל הקבצים",
|
||||
"dashboard": "לוּחַ מַחווָנִים",
|
||||
"homepage": "דף הבית",
|
||||
"quickConnect": "חיבור מהיר",
|
||||
"sshTools": "כלי SSH",
|
||||
"history": "הִיסטוֹרִיָה",
|
||||
@@ -1895,9 +1752,7 @@
|
||||
"serverStatsCard": "סטטיסטיקות שרת",
|
||||
"panelMain": "רָאשִׁי",
|
||||
"panelSide": "צַד",
|
||||
"justNow": "זֶה עַתָה",
|
||||
"serviceLinks": "קישורי שירות",
|
||||
"homepagePreview": "תצוגה מקדימה של דף הבית"
|
||||
"justNow": "זֶה עַתָה"
|
||||
},
|
||||
"donation": {
|
||||
"title": "נהנים מ-Termix?",
|
||||
@@ -1926,15 +1781,8 @@
|
||||
"editModeInstructions": "גרור כרטיסים כדי לשנות את הסדר · גרור את מפריד העמודות כדי לשנות את גודל העמודות · גרור את הקצה התחתון של הכרטיס כדי לשנות את גודל הגובה שלו · אשפה כדי להסיר",
|
||||
"empty": "רֵיק",
|
||||
"clear": "בָּרוּר",
|
||||
"serviceLinksTitle": "קישורי שירות",
|
||||
"serviceLinksEmpty": "אין עדיין קישורים לשירותים",
|
||||
"serviceLinksAddLabel": "מַדבֵּקָה",
|
||||
"serviceLinksAddUrl": "כתובת אתר",
|
||||
"serviceLinksAdd": "לְהוֹסִיף",
|
||||
"serviceLinksLabelPlaceholder": "השירות שלי",
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "הזן כתובת אינטרנט חוקית",
|
||||
"serviceLinksAddFailed": "נכשל הוספת קישור לשירות",
|
||||
"viewServerDetails": "הצג פרטי שרת"
|
||||
},
|
||||
"admin": {
|
||||
|
||||
@@ -195,9 +195,6 @@
|
||||
"arrangeUnlocked": "पुनः व्यवस्थित करने के लिए अनलॉक करके ड्रैग करें"
|
||||
},
|
||||
"homepage": {
|
||||
"title": "होमपेज",
|
||||
"addWidget": "विजेट जोड़ें",
|
||||
"editWidget": "विजेट संपादित करें",
|
||||
"deleteWidget": "डिलीट करें",
|
||||
"widgetTypes": "विजेट प्रकार",
|
||||
"serviceLink": "सेवा लिंक",
|
||||
@@ -206,116 +203,40 @@
|
||||
"notes": "नोट्स",
|
||||
"hostStatus": "होस्ट स्थिति",
|
||||
"bookmarkList": "बुकमार्क",
|
||||
"zoomIn": "ज़ूम इन",
|
||||
"zoomOut": "ज़ूम आउट",
|
||||
"resetView": "दृश्य रीसेट करें",
|
||||
"lockLayout": "लेआउट लॉक करें",
|
||||
"unlockLayout": "लेआउट अनलॉक करें",
|
||||
"noWidgets": "अपना पहला विजेट जोड़ने के लिए राइट-क्लिक करें या + पर क्लिक करें",
|
||||
"openFullView": "पूर्ण दृश्य खोलें",
|
||||
"previewTitle": "होमपेज पूर्वावलोकन",
|
||||
"cancel": "रद्द करें",
|
||||
"save": "सहेजें",
|
||||
"title_label": "शीर्षक",
|
||||
"widgetTitlePlaceholder": "विजेट शीर्षक (वैकल्पिक)",
|
||||
"url": "URL",
|
||||
"imageUrl": "कस्टम इमेज URL",
|
||||
"imageUrlHint": "साइट फ़ेविकॉन का स्वचालित रूप से उपयोग करने के लिए खाली छोड़ें",
|
||||
"showImage": "इमेज दिखाएँ",
|
||||
"description": "विवरण",
|
||||
"color": "रंग",
|
||||
"icon": "आइकन",
|
||||
"expanded": "डिफ़ॉल्ट रूप से विस्तारित",
|
||||
"timezone": "समय क्षेत्र",
|
||||
"invalidTimezone": "समय क्षेत्र अमान्य है। कृपया IANA नाम का उपयोग करें, जैसे कि America/New_York",
|
||||
"showSeconds": "सेकंड दिखाएँ",
|
||||
"format12h": "12-घंटे",
|
||||
"format24h": "24-घंटे",
|
||||
"content": "सामग्री",
|
||||
"backgroundColor": "पृष्ठभूमि रंग",
|
||||
"host": "होस्ट",
|
||||
"showMetrics": "मेट्रिक्स दिखाएं",
|
||||
"links": "लिंक",
|
||||
"addLink": "लिंक जोड़ें",
|
||||
"linkLabel": "लेबल",
|
||||
"linkUrl": "URL",
|
||||
"removeLink": "हटाएं",
|
||||
"categoryLinks": "लिंक",
|
||||
"categoryInfo": "जानकारी",
|
||||
"categorySystem": "सिस्टम",
|
||||
"widgetServiceLinkDesc": "एक क्लिक करने योग्य टाइल जो सेवा URL से जुड़ता है",
|
||||
"widgetFolderDesc": "संबंधित विजेट को समूहित करने का कंटेनर",
|
||||
"widgetClockDesc": "कॉन्फ़िगर करने योग्य समय क्षेत्र वाली लाइव घड़ी",
|
||||
"widgetNotesDesc": "मार्कडाउन नोट्स विजेट",
|
||||
"widgetHostStatusDesc": "SSH होस्ट के लिए लाइव CPU, मेमोरी और डिस्क दिखाता है",
|
||||
"widgetBookmarkListDesc": "त्वरित लिंक की सूची",
|
||||
"copyLink": "लिंक कॉपी करें",
|
||||
"linkCopied": "लिंक कॉपी हो गया!",
|
||||
"location": "स्थान",
|
||||
"temperatureUnit": "तापमान इकाई",
|
||||
"showForecast": "3-दिन का पूर्वानुमान दिखाएं",
|
||||
"scrolling": "स्क्रॉलिंग की अनुमति दें",
|
||||
"feedUrl": "फ़ीड URL",
|
||||
"maxItems": "अधिकतम आइटम",
|
||||
"showDescription": "विवरण दिखाएं",
|
||||
"widgetWeatherName": "मौसम",
|
||||
"widgetWeatherDesc": "किसी भी स्थान का लाइव मौसम",
|
||||
"widgetIframeName": "iFrame एम्बेड",
|
||||
"widgetIframeDesc": "किसी भी URL को आइफ्रेम में एम्बेड करें",
|
||||
"widgetRssName": "RSS फ़ीड",
|
||||
"widgetRssDesc": "RSS या एटम फ़ीड से आइटम प्रदर्शित करें",
|
||||
"dragToFolder": "विजेट यहाँ खींचें या + का उपयोग करके जोड़ें",
|
||||
"showDisk": "डिस्क उपयोग दिखाएं",
|
||||
"addToFolder": "फ़ोल्डर में विजेट जोड़ें",
|
||||
"noHostSelected": "कोई होस्ट चयनित नहीं",
|
||||
"selectHost": "होस्ट चुनें...",
|
||||
"displayedMetrics": "प्रदर्शित मेट्रिक्स",
|
||||
"metricCpu": "CPU",
|
||||
"metricMemory": "मेमोरी",
|
||||
"metricDisk": "डिस्क",
|
||||
"metricUptime": "अपटाइम",
|
||||
"metricSystem": "सिस्टम",
|
||||
"metricNetwork": "नेटवर्क",
|
||||
"metricProcesses": "प्रोसेस",
|
||||
"categoryMonitoring": "निगरानी",
|
||||
"loading": "लोड हो रहा है...",
|
||||
"allClear": "सब ठीक है",
|
||||
"noPingUrls": "कोई URL कॉन्फ़िगर नहीं किया गया",
|
||||
"pingLabel": "लेबल",
|
||||
"addPingUrl": "URL जोड़ें",
|
||||
"showLatency": "लेटेंसी दिखाएं",
|
||||
"refreshInterval": "रीफ़्रेश अंतराल",
|
||||
"seconds": "सेकंड",
|
||||
"filterActivityTypes": "प्रकार फ़िल्टर करें",
|
||||
"filterTypesHint": "सभी दिखाने के लिए खाली छोड़ें",
|
||||
"showTimestamp": "टाइमस्टैम्प दिखाएं",
|
||||
"uptimeUnavailable": "अपटाइम अनुपलब्ध",
|
||||
"uptimeLabel": "अपटाइम",
|
||||
"overviewVersion": "संस्करण",
|
||||
"overviewUpdate": "अद्यतित",
|
||||
"overviewUpdateLabel": "अद्यतन",
|
||||
"overviewUpdateAvailable": "अपडेट उपलब्ध है",
|
||||
"overviewDatabase": "डेटाबेस",
|
||||
"overviewUptime": "अपटाइम",
|
||||
"noHosts": "कोई होस्ट कॉन्फ़िगर नहीं किए गए",
|
||||
"hostGridHosts": "होस्ट",
|
||||
"hostGridAllHint": "सभी होस्ट दिखाने के लिए खाली छोड़ें",
|
||||
"columns": "कॉलम",
|
||||
"showIp": "IP पता दिखाएं",
|
||||
"connectionType": "कनेक्शन प्रकार",
|
||||
"layout": "लेआउट",
|
||||
"showStatus": "स्थिति दिखाएं",
|
||||
"showHostName": "होस्ट का नाम दिखाएं",
|
||||
"noDockerActivity": "कोई Docker गतिविधि नहीं",
|
||||
"noActivity": "कोई गतिविधि नहीं",
|
||||
"showAcknowledged": "स्वीकृत दिखाएं",
|
||||
"widgetMetricsChartName": "मीट्रिक चार्ट",
|
||||
"widgetMetricsChartDesc": "होस्ट के लिए ऐतिहासिक CPU, मेमोरी, डिस्क या नेटवर्क चार्ट",
|
||||
"widgetHostGridName": "होस्ट ग्रिड",
|
||||
"widgetHostGridDesc": "SSH होस्ट की स्थितियों का ग्रिड दृश्य",
|
||||
"widgetPingStatusName": "पिंग स्थिति",
|
||||
"widgetPingStatusDesc": "एक या अधिक URLs के लिए HTTP पिंग स्थिति",
|
||||
"widgetRecentActivityName": "हाल की गतिविधि",
|
||||
"widgetRecentActivityDesc": "Termix की हाल की गतिविधियों का स्क्रॉल करने योग्य फीड",
|
||||
"widgetTermixUptimeName": "Termix अपटाइम",
|
||||
"widgetTermixUptimeDesc": "Termix सर्वर के लिए लाइव अपटाइम काउंटर",
|
||||
@@ -347,67 +268,8 @@
|
||||
"widgetSearchLinksDesc": "इनलाइन इनपुट के साथ त्वरित खोज शॉर्टकट बटन",
|
||||
"widgetLinkTreeName": "लिंक ट्री",
|
||||
"widgetLinkTreeDesc": "शीर्षकों के साथ लिंक के समूहीकृत अनुभाग",
|
||||
"calMon": "सोम",
|
||||
"calTue": "मंगल",
|
||||
"calWed": "बुध",
|
||||
"calThu": "गुरु",
|
||||
"calFri": "शुक्र",
|
||||
"calSat": "शनि",
|
||||
"calSun": "रवि",
|
||||
"startOnMonday": "सप्ताह सोमवार से शुरू करें",
|
||||
"countdownNoDate": "कोई लक्ष्य तिथि निर्धारित नहीं",
|
||||
"countdownPast": "ईवेंट बीत चुका है",
|
||||
"countdownDays": "दिन",
|
||||
"countdownHours": "घंटे",
|
||||
"countdownMinutes": "मिनट",
|
||||
"countdownSeconds": "सेकंड",
|
||||
"countdownLabel": "लेबल",
|
||||
"countdownLabelPlaceholder": "उदा. लॉन्च दिवस",
|
||||
"countdownShowDays": "दिन दिखाएं",
|
||||
"countdownShowHours": "घंटे दिखाएं",
|
||||
"targetDate": "लक्ष्य तिथि",
|
||||
"searchEngine": "खोज इंजन",
|
||||
"customSearchUrl": "कस्टम खोज URL",
|
||||
"searchPlaceholder": "खोजें...",
|
||||
"searchGo": "जाएं",
|
||||
"searchPlaceholderLabel": "प्लेसहोल्डर टेक्स्ट",
|
||||
"searchPlaceholderHint": "खोज इनपुट के अंदर दिखाया जाने वाला पाठ",
|
||||
"openInNewTab": "नए टैब में खोलें",
|
||||
"searchQueryPlaceholder": "क्वेरी दर्ज करें...",
|
||||
"noSearchShortcuts": "कोई शॉर्टकट कॉन्फ़िगर नहीं है",
|
||||
"addSearchShortcut": "शॉर्टकट जोड़ें",
|
||||
"fontSize": "फ़ॉन्ट आकार",
|
||||
"textAlign": "पाठ संरेखण",
|
||||
"fontWeight": "फ़ॉन्ट मोटाई",
|
||||
"clearColor": "रंग साफ़ करें",
|
||||
"imageFit": "छवि फ़िट",
|
||||
"imageLinkUrl": "लिंक URL",
|
||||
"noImage": "कोई छवि URL सेट नहीं है",
|
||||
"altText": "Alt टेक्स्ट",
|
||||
"altTextPlaceholder": "छवि का विवरण दें",
|
||||
"renderMarkdown": "मार्कडाउन रेंडर करें",
|
||||
"displayMode": "प्रदर्शन मोड",
|
||||
"displayField": "प्रदर्शन फ़ील्ड",
|
||||
"jsonPath": "JSON पथ",
|
||||
"customApiLabel": "लेबल",
|
||||
"customApiLabelPlaceholder": "उदा. तापमान",
|
||||
"customApiUnit": "इकाई",
|
||||
"customApiNoUrl": "कोई API URL कॉन्फ़िगर नहीं है",
|
||||
"customApiError": "फ़ेच करने में विफल",
|
||||
"customApiNotArray": "प्रतिक्रिया एक ऐरे नहीं है",
|
||||
"addService": "सेवा जोड़ें",
|
||||
"showLabels": "लेबल दिखाएं",
|
||||
"iconSize": "आइकन का आकार",
|
||||
"noDashboardLinks": "कोई डैशबोर्ड लिंक कॉन्फ़िगर नहीं किया गया",
|
||||
"noLimit": "कोई सीमा नहीं",
|
||||
"sectionHeading": "अनुभाग शीर्षक",
|
||||
"addSection": "अनुभाग जोड़ें",
|
||||
"compactMode": "संक्षिप्त मोड",
|
||||
"showDetailed": "विस्तृत दिखाएं",
|
||||
"selectHosts": "होस्ट चुनें",
|
||||
"allHosts": "सभी होस्ट",
|
||||
"listLayout": "सूची",
|
||||
"gridLayout": "ग्रिड",
|
||||
"terminal": "टर्मिनल",
|
||||
"files": "फ़ाइलें",
|
||||
"docker": "Docker",
|
||||
@@ -422,12 +284,8 @@
|
||||
"accentColor": "एक्सेंट रंग",
|
||||
"widgetSshTerminalName": "SSH टर्मिनल",
|
||||
"widgetSshTerminalDesc": "कॉन्फ़िगर किए गए होस्ट से जुड़ा एक इनलाइन SSH टर्मिनल",
|
||||
"sshTerminalNoHost": "कोई होस्ट कॉन्फ़िगर नहीं किया गया",
|
||||
"sshTerminalConnect": "कनेक्ट करें",
|
||||
"sshTerminalAutoConnect": "लोड पर स्वतः कनेक्ट",
|
||||
"widgetQuickConnectName": "त्वरित कनेक्ट",
|
||||
"widgetQuickConnectDesc": "आपके होस्ट पर किसी भी कनेक्शन प्रकार के लिए एक-क्लिक लॉन्च बटन",
|
||||
"connectionTypes": "कनेक्शन प्रकार",
|
||||
"connType_terminal": "टर्मिनल",
|
||||
"connType_files": "फ़ाइल प्रबंधक",
|
||||
"connType_docker": "Docker",
|
||||
@@ -685,7 +543,6 @@
|
||||
"renameTab": "टैब का नाम बदलें",
|
||||
"openFileManager": "फ़ाइल प्रबंधक खोलें",
|
||||
"dashboard": "डैशबोर्ड",
|
||||
"homepage": "होमपेज",
|
||||
"quickConnect": "त्वरित कनेक्ट",
|
||||
"sshTools": "SSH टूल्स",
|
||||
"history": "इतिहास",
|
||||
@@ -1895,9 +1752,7 @@
|
||||
"serverStatsCard": "सर्वर आँकड़े",
|
||||
"panelMain": "मुख्य",
|
||||
"panelSide": "साइड",
|
||||
"justNow": "अभी-अभी",
|
||||
"serviceLinks": "सेवा लिंक",
|
||||
"homepagePreview": "मुखपृष्ठ पूर्वावलोकन"
|
||||
"justNow": "अभी-अभी"
|
||||
},
|
||||
"donation": {
|
||||
"title": "टर्मिक्स का आनंद ले रहे हैं?",
|
||||
@@ -1926,15 +1781,8 @@
|
||||
"editModeInstructions": "कार्ड्स को पुन: क्रमित करने के लिए खींचें · कॉलम का आकार बदलने के लिए कॉलम विभाजक को खींचें · कार्ड की ऊंचाई बदलने के लिए उसके निचले किनारे को खींचें · हटाने के लिए ट्रैश में डालें",
|
||||
"empty": "खाली",
|
||||
"clear": "साफ़ करें",
|
||||
"serviceLinksTitle": "सर्विस लिंक्स",
|
||||
"serviceLinksEmpty": "अभी तक कोई सर्विस लिंक नहीं",
|
||||
"serviceLinksAddLabel": "लेबल",
|
||||
"serviceLinksAddUrl": "URL",
|
||||
"serviceLinksAdd": "जोड़ें",
|
||||
"serviceLinksLabelPlaceholder": "मेरी सर्विस",
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "एक मान्य वेब पता दर्ज करें",
|
||||
"serviceLinksAddFailed": "सर्विस लिंक जोड़ने में विफल",
|
||||
"viewServerDetails": "सर्वर विवरण देखें"
|
||||
},
|
||||
"admin": {
|
||||
|
||||
@@ -195,9 +195,6 @@
|
||||
"arrangeUnlocked": "Húzással átrendezhető"
|
||||
},
|
||||
"homepage": {
|
||||
"title": "Kezdőlap",
|
||||
"addWidget": "Widget hozzáadása",
|
||||
"editWidget": "Widget szerkesztése",
|
||||
"deleteWidget": "Töröl",
|
||||
"widgetTypes": "Widget típusok",
|
||||
"serviceLink": "Szolgáltatási hivatkozás",
|
||||
@@ -206,116 +203,40 @@
|
||||
"notes": "Megjegyzések",
|
||||
"hostStatus": "Gazdagép állapota",
|
||||
"bookmarkList": "Könyvjelzők",
|
||||
"zoomIn": "Nagyítás",
|
||||
"zoomOut": "Kicsinyítés",
|
||||
"resetView": "Nézet visszaállítása",
|
||||
"lockLayout": "Zár elrendezése",
|
||||
"unlockLayout": "Elrendezés feloldása",
|
||||
"noWidgets": "Kattintson jobb gombbal, vagy kattintson a + ikonra az első widget hozzáadásához",
|
||||
"openFullView": "Teljes nézet megnyitása",
|
||||
"previewTitle": "Kezdőlap előnézete",
|
||||
"cancel": "Mégsem",
|
||||
"save": "Megtakarítás",
|
||||
"title_label": "Cím",
|
||||
"widgetTitlePlaceholder": "Widget címe (opcionális)",
|
||||
"url": "URL-cím",
|
||||
"imageUrl": "Egyéni kép URL-címe",
|
||||
"imageUrlHint": "Hagyja üresen a webhely faviconjának automatikus használatához",
|
||||
"showImage": "Kép megjelenítése",
|
||||
"description": "Leírás",
|
||||
"color": "Szín",
|
||||
"icon": "Ikon",
|
||||
"expanded": "Alapértelmezés szerint kibontva",
|
||||
"timezone": "Időzóna",
|
||||
"invalidTimezone": "Érvénytelen időzóna. Használjon IANA nevet, például Amerika/New_York",
|
||||
"showSeconds": "Másodpercek megjelenítése",
|
||||
"format12h": "12 órás",
|
||||
"format24h": "24 órás",
|
||||
"content": "Tartalom",
|
||||
"backgroundColor": "Háttérszín",
|
||||
"host": "Házigazda",
|
||||
"showMetrics": "Metrikák megjelenítése",
|
||||
"links": "Linkek",
|
||||
"addLink": "Link hozzáadása",
|
||||
"linkLabel": "Címke",
|
||||
"linkUrl": "URL-cím",
|
||||
"removeLink": "Eltávolítás",
|
||||
"categoryLinks": "Linkek",
|
||||
"categoryInfo": "Információ",
|
||||
"categorySystem": "Rendszer",
|
||||
"widgetServiceLinkDesc": "Egy kattintható csempe, amely egy szolgáltatás URL-címére mutat",
|
||||
"widgetFolderDesc": "Egy tároló a kapcsolódó widgetek csoportosításához",
|
||||
"widgetClockDesc": "Élő óra konfigurálható időzónával",
|
||||
"widgetNotesDesc": "Egy Markdown jegyzetek widget",
|
||||
"widgetHostStatusDesc": "Élő CPU, memória és lemezterület megjelenítése egy SSH hoszton",
|
||||
"widgetBookmarkListDesc": "Gyorslinkek listája",
|
||||
"copyLink": "Link másolása",
|
||||
"linkCopied": "Link másolva!",
|
||||
"location": "Elhelyezkedés",
|
||||
"temperatureUnit": "Hőmérséklet egység",
|
||||
"showForecast": "3 napos előrejelzés megjelenítése",
|
||||
"scrolling": "Görgetés engedélyezése",
|
||||
"feedUrl": "Hírcsatorna URL-címe",
|
||||
"maxItems": "Max. tételek",
|
||||
"showDescription": "Leírás megjelenítése",
|
||||
"widgetWeatherName": "Időjárás",
|
||||
"widgetWeatherDesc": "Élő időjárás bármely helyszínre",
|
||||
"widgetIframeName": "iFrame beágyazás",
|
||||
"widgetIframeDesc": "Bármely URL beágyazása iframe-be",
|
||||
"widgetRssName": "RSS-hírcsatorna",
|
||||
"widgetRssDesc": "RSS- vagy Atom-hírcsatorna elemeinek megjelenítése",
|
||||
"dragToFolder": "Húzd ide a widgeteket, vagy használd a + jelet a hozzáadáshoz",
|
||||
"showDisk": "Lemezhasználat megjelenítése",
|
||||
"addToFolder": "Widget hozzáadása mappához",
|
||||
"noHostSelected": "Nincs kiválasztva gazdagép",
|
||||
"selectHost": "Válasszon egy hosztot...",
|
||||
"displayedMetrics": "Megjelenített mutatók",
|
||||
"metricCpu": "CPU",
|
||||
"metricMemory": "Memória",
|
||||
"metricDisk": "Korong",
|
||||
"metricUptime": "Üzemidő",
|
||||
"metricSystem": "Rendszer",
|
||||
"metricNetwork": "Hálózat",
|
||||
"metricProcesses": "Folyamatok",
|
||||
"categoryMonitoring": "Monitoring",
|
||||
"loading": "Terhelés...",
|
||||
"allClear": "Minden tiszta",
|
||||
"noPingUrls": "Nincsenek konfigurált URL-ek",
|
||||
"pingLabel": "Címke",
|
||||
"addPingUrl": "URL hozzáadása",
|
||||
"showLatency": "Késleltetés megjelenítése",
|
||||
"refreshInterval": "Frissítési intervallum",
|
||||
"seconds": "másodperc",
|
||||
"filterActivityTypes": "Szűrőtípusok",
|
||||
"filterTypesHint": "Hagyja üresen az összes megjelenítéséhez",
|
||||
"showTimestamp": "Időbélyeg megjelenítése",
|
||||
"uptimeUnavailable": "Üzemidő nem elérhető",
|
||||
"uptimeLabel": "Üzemidő",
|
||||
"overviewVersion": "Változat",
|
||||
"overviewUpdate": "Naprakész",
|
||||
"overviewUpdateLabel": "Frissítés",
|
||||
"overviewUpdateAvailable": "Frissítés elérhető",
|
||||
"overviewDatabase": "Adatbázis",
|
||||
"overviewUptime": "Üzemidő",
|
||||
"noHosts": "Nincsenek konfigurált gazdagépek",
|
||||
"hostGridHosts": "Házigazdák",
|
||||
"hostGridAllHint": "Hagyja üresen az összes hoszt megjelenítéséhez",
|
||||
"columns": "Oszlopok",
|
||||
"showIp": "IP-cím megjelenítése",
|
||||
"connectionType": "Kapcsolat típusa",
|
||||
"layout": "Elrendezés",
|
||||
"showStatus": "Állapot megjelenítése",
|
||||
"showHostName": "Gazdagép nevének megjelenítése",
|
||||
"noDockerActivity": "Nincs Docker-tevékenység",
|
||||
"noActivity": "Nincs aktivitás",
|
||||
"showAcknowledged": "Elismert megjelenítése",
|
||||
"widgetMetricsChartName": "Metrikadiagram",
|
||||
"widgetMetricsChartDesc": "Egy gazdagép CPU-, memória-, lemez- vagy hálózati előzménydiagramja",
|
||||
"widgetHostGridName": "Host Grid",
|
||||
"widgetHostGridDesc": "SSH-gazdagépek állapotainak rácsnézete",
|
||||
"widgetPingStatusName": "Ping állapot",
|
||||
"widgetPingStatusDesc": "HTTP ping állapot egy vagy több URL-hez",
|
||||
"widgetRecentActivityName": "Legutóbbi tevékenységek",
|
||||
"widgetRecentActivityDesc": "Görgethető hírfolyam a legutóbbi Termix-tevékenységekről",
|
||||
"widgetTermixUptimeName": "Termix üzemidő",
|
||||
"widgetTermixUptimeDesc": "Élő üzemidő-számláló a Termix szerverhez",
|
||||
@@ -347,67 +268,8 @@
|
||||
"widgetSearchLinksDesc": "Gyorskeresési parancsikonok beágyazott beviteli lehetőséggel",
|
||||
"widgetLinkTreeName": "Linkfa",
|
||||
"widgetLinkTreeDesc": "Csoportosított linkek címsorokkal",
|
||||
"calMon": "Mo",
|
||||
"calTue": "Kedd",
|
||||
"calWed": "Mi",
|
||||
"calThu": "Cs",
|
||||
"calFri": "Péntek",
|
||||
"calSat": "Sa",
|
||||
"calSun": "Su",
|
||||
"startOnMonday": "Hétfőn kezdjük a hetet",
|
||||
"countdownNoDate": "Nincs kitűzött céldátum",
|
||||
"countdownPast": "Az esemény elmúlt",
|
||||
"countdownDays": "napok",
|
||||
"countdownHours": "óra",
|
||||
"countdownMinutes": "perc",
|
||||
"countdownSeconds": "másodperc",
|
||||
"countdownLabel": "Címke",
|
||||
"countdownLabelPlaceholder": "pl. Indítási nap",
|
||||
"countdownShowDays": "Kiállítási napok",
|
||||
"countdownShowHours": "Nyitvatartási idő megjelenítése",
|
||||
"targetDate": "Céldátum",
|
||||
"searchEngine": "Keresőmotor",
|
||||
"customSearchUrl": "Egyéni keresési URL",
|
||||
"searchPlaceholder": "Keresés...",
|
||||
"searchGo": "Megy",
|
||||
"searchPlaceholderLabel": "Helyőrző szöveg",
|
||||
"searchPlaceholderHint": "A keresési mezőben megjelenített szöveg",
|
||||
"openInNewTab": "Megnyitás új lapon",
|
||||
"searchQueryPlaceholder": "Írja be a lekérdezést...",
|
||||
"noSearchShortcuts": "Nincsenek konfigurált gyorsbillentyűk",
|
||||
"addSearchShortcut": "Parancsikon hozzáadása",
|
||||
"fontSize": "Betűméret",
|
||||
"textAlign": "Szöveg igazítása",
|
||||
"fontWeight": "Betűvastagság",
|
||||
"clearColor": "Tiszta szín",
|
||||
"imageFit": "Kép illesztése",
|
||||
"imageLinkUrl": "Link URL-címe",
|
||||
"noImage": "Nincs beállítva kép URL-címe",
|
||||
"altText": "Alt szöveg",
|
||||
"altTextPlaceholder": "Írd le a képet",
|
||||
"renderMarkdown": "Markdown renderelés",
|
||||
"displayMode": "Kijelző mód",
|
||||
"displayField": "Kijelző mező",
|
||||
"jsonPath": "JSON-útvonal",
|
||||
"customApiLabel": "Címke",
|
||||
"customApiLabelPlaceholder": "pl. hőmérséklet",
|
||||
"customApiUnit": "Egység",
|
||||
"customApiNoUrl": "Nincs konfigurálva API URL",
|
||||
"customApiError": "Nem sikerült lekérni",
|
||||
"customApiNotArray": "A válasz nem tömb",
|
||||
"addService": "Szolgáltatás hozzáadása",
|
||||
"showLabels": "Címkék megjelenítése",
|
||||
"iconSize": "Ikonméret",
|
||||
"noDashboardLinks": "Nincsenek konfigurált irányítópult-hivatkozások",
|
||||
"noLimit": "Nincs korlát",
|
||||
"sectionHeading": "Szakasz címsora",
|
||||
"addSection": "Szakasz hozzáadása",
|
||||
"compactMode": "Kompakt mód",
|
||||
"showDetailed": "Részletes megjelenítése",
|
||||
"selectHosts": "Válasszon hosztokat",
|
||||
"allHosts": "Minden házigazda",
|
||||
"listLayout": "Lista",
|
||||
"gridLayout": "Rács",
|
||||
"terminal": "Terminál",
|
||||
"files": "Fájlok",
|
||||
"docker": "Dokkmunkás",
|
||||
@@ -422,12 +284,8 @@
|
||||
"accentColor": "hangsúlyos szín",
|
||||
"widgetSshTerminalName": "SSH terminál",
|
||||
"widgetSshTerminalDesc": "Egy beágyazott SSH terminál, amely egy konfigurált gazdagéphez csatlakozik",
|
||||
"sshTerminalNoHost": "Nincs konfigurálva gazdagép",
|
||||
"sshTerminalConnect": "Csatlakozás",
|
||||
"sshTerminalAutoConnect": "Automatikus csatlakozás terheléskor",
|
||||
"widgetQuickConnectName": "Gyors csatlakozás",
|
||||
"widgetQuickConnectDesc": "Egy kattintásos indítógombok bármilyen kapcsolattípushoz a hosztokon keresztül",
|
||||
"connectionTypes": "Kapcsolattípusok",
|
||||
"connType_terminal": "Terminál",
|
||||
"connType_files": "Fájlkezelő",
|
||||
"connType_docker": "Dokkmunkás",
|
||||
@@ -685,7 +543,6 @@
|
||||
"renameTab": "Fül átnevezése",
|
||||
"openFileManager": "Fájlkezelő megnyitása",
|
||||
"dashboard": "Műszerfal",
|
||||
"homepage": "Kezdőlap",
|
||||
"quickConnect": "Gyors csatlakozás",
|
||||
"sshTools": "SSH eszközök",
|
||||
"history": "Történelem",
|
||||
@@ -1895,9 +1752,7 @@
|
||||
"serverStatsCard": "Szerver statisztikák",
|
||||
"panelMain": "Fő",
|
||||
"panelSide": "Oldal",
|
||||
"justNow": "éppen most",
|
||||
"serviceLinks": "Szolgáltatási linkek",
|
||||
"homepagePreview": "Kezdőlap előnézete"
|
||||
"justNow": "éppen most"
|
||||
},
|
||||
"donation": {
|
||||
"title": "Élvezed a Termixet?",
|
||||
@@ -1926,15 +1781,8 @@
|
||||
"editModeInstructions": "Kártyák húzásával átrendezheted a sorrendet · Az oszlopelválasztó húzásával átméretezheted az oszlopokat · A kártya alsó szélének húzásával átméretezheted a magasságát · A kukába helyezéssel eltávolíthatod",
|
||||
"empty": "Üres",
|
||||
"clear": "Világos",
|
||||
"serviceLinksTitle": "Szolgáltatási linkek",
|
||||
"serviceLinksEmpty": "Még nincsenek szolgáltatáslinkek",
|
||||
"serviceLinksAddLabel": "Címke",
|
||||
"serviceLinksAddUrl": "URL-cím",
|
||||
"serviceLinksAdd": "Hozzáadás",
|
||||
"serviceLinksLabelPlaceholder": "Szolgáltatásom",
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Adjon meg egy érvényes webcímet",
|
||||
"serviceLinksAddFailed": "Nem sikerült hozzáadni a szolgáltatás linkjét",
|
||||
"viewServerDetails": "Szerver részleteinek megtekintése"
|
||||
},
|
||||
"admin": {
|
||||
|
||||
@@ -195,9 +195,6 @@
|
||||
"arrangeUnlocked": "Buka kunci dengan menyeret untuk mengatur ulang"
|
||||
},
|
||||
"homepage": {
|
||||
"title": "Beranda",
|
||||
"addWidget": "Tambahkan Widget",
|
||||
"editWidget": "Edit Widget",
|
||||
"deleteWidget": "Menghapus",
|
||||
"widgetTypes": "Jenis Widget",
|
||||
"serviceLink": "Tautan Layanan",
|
||||
@@ -206,116 +203,40 @@
|
||||
"notes": "Catatan",
|
||||
"hostStatus": "Status Host",
|
||||
"bookmarkList": "Penanda buku",
|
||||
"zoomIn": "Perbesar",
|
||||
"zoomOut": "Perkecil tampilan",
|
||||
"resetView": "Atur Ulang Tampilan",
|
||||
"lockLayout": "Tata Letak Kunci",
|
||||
"unlockLayout": "Buka Tata Letak",
|
||||
"noWidgets": "Klik kanan atau klik + untuk menambahkan widget pertama Anda.",
|
||||
"openFullView": "Buka Tampilan Penuh",
|
||||
"previewTitle": "Pratinjau Halaman Utama",
|
||||
"cancel": "Membatalkan",
|
||||
"save": "Menyimpan",
|
||||
"title_label": "Judul",
|
||||
"widgetTitlePlaceholder": "Judul widget (opsional)",
|
||||
"url": "URL",
|
||||
"imageUrl": "URL Gambar Kustom",
|
||||
"imageUrlHint": "Biarkan kosong untuk menggunakan favicon situs secara otomatis.",
|
||||
"showImage": "Tampilkan Gambar",
|
||||
"description": "Keterangan",
|
||||
"color": "Warna",
|
||||
"icon": "Ikon",
|
||||
"expanded": "Diperluas secara default",
|
||||
"timezone": "Zona waktu",
|
||||
"invalidTimezone": "Zona waktu tidak valid. Gunakan nama IANA seperti America/New_York.",
|
||||
"showSeconds": "Tampilkan detik",
|
||||
"format12h": "12 jam",
|
||||
"format24h": "24 jam",
|
||||
"content": "Isi",
|
||||
"backgroundColor": "Warna Latar Belakang",
|
||||
"host": "Tuan rumah",
|
||||
"showMetrics": "Tampilkan Metrik",
|
||||
"links": "Tautan",
|
||||
"addLink": "Tambahkan Tautan",
|
||||
"linkLabel": "Label",
|
||||
"linkUrl": "URL",
|
||||
"removeLink": "Menghapus",
|
||||
"categoryLinks": "Tautan",
|
||||
"categoryInfo": "Informasi",
|
||||
"categorySystem": "Sistem",
|
||||
"widgetServiceLinkDesc": "Ubin yang dapat diklik yang mengarah ke URL layanan.",
|
||||
"widgetFolderDesc": "Sebuah wadah untuk mengelompokkan widget yang terkait.",
|
||||
"widgetClockDesc": "Jam digital dengan zona waktu yang dapat dikonfigurasi.",
|
||||
"widgetNotesDesc": "Widget catatan markdown",
|
||||
"widgetHostStatusDesc": "Menampilkan penggunaan CPU, memori, dan disk secara langsung untuk host SSH.",
|
||||
"widgetBookmarkListDesc": "Daftar tautan cepat",
|
||||
"copyLink": "Salin Tautan",
|
||||
"linkCopied": "Tautan berhasil disalin!",
|
||||
"location": "Lokasi",
|
||||
"temperatureUnit": "Satuan Suhu",
|
||||
"showForecast": "Tampilkan ramalan cuaca 3 hari ke depan",
|
||||
"scrolling": "Izinkan Pengguliran",
|
||||
"feedUrl": "URL Umpan",
|
||||
"maxItems": "Jumlah Item Maksimum",
|
||||
"showDescription": "Tampilkan Deskripsi",
|
||||
"widgetWeatherName": "Cuaca",
|
||||
"widgetWeatherDesc": "Informasi cuaca terkini untuk lokasi mana pun.",
|
||||
"widgetIframeName": "Penyematan iFrame",
|
||||
"widgetIframeDesc": "Sematkan URL apa pun di dalam iframe.",
|
||||
"widgetRssName": "Umpan RSS",
|
||||
"widgetRssDesc": "Menampilkan item dari feed RSS atau Atom.",
|
||||
"dragToFolder": "Seret widget ke sini atau gunakan tanda + untuk menambahkan",
|
||||
"showDisk": "Tampilkan Penggunaan Disk",
|
||||
"addToFolder": "Tambahkan widget ke folder",
|
||||
"noHostSelected": "Tidak ada pembawa acara yang dipilih.",
|
||||
"selectHost": "Pilih tuan rumah...",
|
||||
"displayedMetrics": "Metrik yang Ditampilkan",
|
||||
"metricCpu": "CPU",
|
||||
"metricMemory": "Ingatan",
|
||||
"metricDisk": "Disk",
|
||||
"metricUptime": "Waktu aktif",
|
||||
"metricSystem": "Sistem",
|
||||
"metricNetwork": "Jaringan",
|
||||
"metricProcesses": "Proses",
|
||||
"categoryMonitoring": "Pemantauan",
|
||||
"loading": "Memuat...",
|
||||
"allClear": "Semuanya aman.",
|
||||
"noPingUrls": "Tidak ada URL yang dikonfigurasi",
|
||||
"pingLabel": "Label",
|
||||
"addPingUrl": "Tambahkan URL",
|
||||
"showLatency": "Tampilkan Latensi",
|
||||
"refreshInterval": "Interval Penyegaran",
|
||||
"seconds": "detik",
|
||||
"filterActivityTypes": "Jenis Filter",
|
||||
"filterTypesHint": "Biarkan kosong untuk menampilkan semua",
|
||||
"showTimestamp": "Tampilkan Cap Waktu",
|
||||
"uptimeUnavailable": "Waktu aktif tidak tersedia",
|
||||
"uptimeLabel": "Waktu aktif",
|
||||
"overviewVersion": "Versi",
|
||||
"overviewUpdate": "Terkini",
|
||||
"overviewUpdateLabel": "Memperbarui",
|
||||
"overviewUpdateAvailable": "Pembaruan tersedia",
|
||||
"overviewDatabase": "Basis data",
|
||||
"overviewUptime": "Waktu aktif",
|
||||
"noHosts": "Tidak ada host yang dikonfigurasi.",
|
||||
"hostGridHosts": "Tuan rumah",
|
||||
"hostGridAllHint": "Biarkan kosong untuk menampilkan semua host",
|
||||
"columns": "Kolom",
|
||||
"showIp": "Tampilkan Alamat IP",
|
||||
"connectionType": "Jenis Koneksi",
|
||||
"layout": "Tata Letak",
|
||||
"showStatus": "Tampilkan Status",
|
||||
"showHostName": "Nama Pembawa Acara",
|
||||
"noDockerActivity": "Tidak ada aktivitas Docker",
|
||||
"noActivity": "Tidak ada kegiatan",
|
||||
"showAcknowledged": "Pertunjukan Diterima",
|
||||
"widgetMetricsChartName": "Bagan Metrik",
|
||||
"widgetMetricsChartDesc": "Grafik historis CPU, memori, disk, atau jaringan untuk sebuah host.",
|
||||
"widgetHostGridName": "Jaringan Host",
|
||||
"widgetHostGridDesc": "Tampilan grid status host SSH",
|
||||
"widgetPingStatusName": "Status Ping",
|
||||
"widgetPingStatusDesc": "Status ping HTTP untuk satu atau lebih URL",
|
||||
"widgetRecentActivityName": "Aktivitas Terkini",
|
||||
"widgetRecentActivityDesc": "Tampilan aktivitas Termix terkini yang dapat digulir.",
|
||||
"widgetTermixUptimeName": "Waktu Operasional Termix",
|
||||
"widgetTermixUptimeDesc": "Penghitung waktu aktif langsung untuk server Termix",
|
||||
@@ -347,67 +268,8 @@
|
||||
"widgetSearchLinksDesc": "Tombol pintasan pencarian cepat dengan input sebaris",
|
||||
"widgetLinkTreeName": "Pohon Tautan",
|
||||
"widgetLinkTreeDesc": "Bagian-bagian tautan yang dikelompokkan dengan judul.",
|
||||
"calMon": "Mo",
|
||||
"calTue": "Tu",
|
||||
"calWed": "Kami",
|
||||
"calThu": "Th",
|
||||
"calFri": "Fr",
|
||||
"calSat": "Sa",
|
||||
"calSun": "Su",
|
||||
"startOnMonday": "Minggu dimulai pada hari Senin.",
|
||||
"countdownNoDate": "Tidak ada tanggal target yang ditetapkan.",
|
||||
"countdownPast": "Acara telah berlalu",
|
||||
"countdownDays": "hari",
|
||||
"countdownHours": "jam",
|
||||
"countdownMinutes": "menit",
|
||||
"countdownSeconds": "detik",
|
||||
"countdownLabel": "Label",
|
||||
"countdownLabelPlaceholder": "misalnya Hari Peluncuran",
|
||||
"countdownShowDays": "Hari Pertunjukan",
|
||||
"countdownShowHours": "Jam Pertunjukan",
|
||||
"targetDate": "Tanggal Target",
|
||||
"searchEngine": "Mesin Pencari",
|
||||
"customSearchUrl": "URL Pencarian Kustom",
|
||||
"searchPlaceholder": "Mencari...",
|
||||
"searchGo": "Pergi",
|
||||
"searchPlaceholderLabel": "Teks Placeholder",
|
||||
"searchPlaceholderHint": "Teks yang ditampilkan di dalam kolom pencarian",
|
||||
"openInNewTab": "Buka di Tab Baru",
|
||||
"searchQueryPlaceholder": "Masukkan pertanyaan...",
|
||||
"noSearchShortcuts": "Tidak ada pintasan yang dikonfigurasi.",
|
||||
"addSearchShortcut": "Tambahkan Pintasan",
|
||||
"fontSize": "Ukuran Huruf",
|
||||
"textAlign": "Perataan Teks",
|
||||
"fontWeight": "Ketebalan Huruf",
|
||||
"clearColor": "Warna Bening",
|
||||
"imageFit": "Pencocokan Gambar",
|
||||
"imageLinkUrl": "Tautan URL",
|
||||
"noImage": "Tidak ada URL gambar yang ditetapkan.",
|
||||
"altText": "Teks Alternatif",
|
||||
"altTextPlaceholder": "Jelaskan gambar tersebut",
|
||||
"renderMarkdown": "Render Markdown",
|
||||
"displayMode": "Mode Tampilan",
|
||||
"displayField": "Bidang Tampilan",
|
||||
"jsonPath": "Jalur JSON",
|
||||
"customApiLabel": "Label",
|
||||
"customApiLabelPlaceholder": "misalnya Suhu",
|
||||
"customApiUnit": "Satuan",
|
||||
"customApiNoUrl": "Tidak ada URL API yang dikonfigurasi.",
|
||||
"customApiError": "Gagal mengambil",
|
||||
"customApiNotArray": "Respons bukanlah sebuah array.",
|
||||
"addService": "Tambahkan Layanan",
|
||||
"showLabels": "Tampilkan Label",
|
||||
"iconSize": "Ukuran Ikon",
|
||||
"noDashboardLinks": "Tidak ada tautan dasbor yang dikonfigurasi.",
|
||||
"noLimit": "Tidak ada batasan",
|
||||
"sectionHeading": "Judul Bagian",
|
||||
"addSection": "Tambahkan Bagian",
|
||||
"compactMode": "Mode Ringkas",
|
||||
"showDetailed": "Tampilkan Detail",
|
||||
"selectHosts": "Pilih Host",
|
||||
"allHosts": "Semua tuan rumah",
|
||||
"listLayout": "Daftar",
|
||||
"gridLayout": "Jaringan",
|
||||
"terminal": "Terminal",
|
||||
"files": "Berkas",
|
||||
"docker": "Buruh pelabuhan",
|
||||
@@ -422,12 +284,8 @@
|
||||
"accentColor": "Warna Aksen",
|
||||
"widgetSshTerminalName": "Terminal SSH",
|
||||
"widgetSshTerminalDesc": "Terminal SSH inline yang terhubung ke host yang telah dikonfigurasi.",
|
||||
"sshTerminalNoHost": "Tidak ada host yang dikonfigurasi.",
|
||||
"sshTerminalConnect": "Menghubungkan",
|
||||
"sshTerminalAutoConnect": "Terhubung otomatis saat dimuat",
|
||||
"widgetQuickConnectName": "Koneksi Cepat",
|
||||
"widgetQuickConnectDesc": "Tombol peluncuran sekali klik untuk semua jenis koneksi di seluruh host Anda.",
|
||||
"connectionTypes": "Jenis Koneksi",
|
||||
"connType_terminal": "Terminal",
|
||||
"connType_files": "Pengelola File",
|
||||
"connType_docker": "Buruh pelabuhan",
|
||||
@@ -685,7 +543,6 @@
|
||||
"renameTab": "Ganti nama tab",
|
||||
"openFileManager": "Buka Pengelola File",
|
||||
"dashboard": "Dasbor",
|
||||
"homepage": "Beranda",
|
||||
"quickConnect": "Koneksi Cepat",
|
||||
"sshTools": "Alat SSH",
|
||||
"history": "Sejarah",
|
||||
@@ -1895,9 +1752,7 @@
|
||||
"serverStatsCard": "Statistik Server",
|
||||
"panelMain": "Utama",
|
||||
"panelSide": "Samping",
|
||||
"justNow": "baru saja",
|
||||
"serviceLinks": "Tautan Layanan",
|
||||
"homepagePreview": "Pratinjau Halaman Utama"
|
||||
"justNow": "baru saja"
|
||||
},
|
||||
"donation": {
|
||||
"title": "Apakah Anda menikmati Termix?",
|
||||
@@ -1926,15 +1781,8 @@
|
||||
"editModeInstructions": "Seret kartu untuk mengubah urutan · Seret pembatas kolom untuk mengubah ukuran kolom · Seret tepi bawah kartu untuk mengubah ukuran tingginya · Gunakan tombol Sampah untuk menghapus",
|
||||
"empty": "Kosong",
|
||||
"clear": "Jernih",
|
||||
"serviceLinksTitle": "Tautan Layanan",
|
||||
"serviceLinksEmpty": "Belum ada tautan layanan.",
|
||||
"serviceLinksAddLabel": "Label",
|
||||
"serviceLinksAddUrl": "URL",
|
||||
"serviceLinksAdd": "Menambahkan",
|
||||
"serviceLinksLabelPlaceholder": "Layanan Saya",
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Masukkan alamat web yang valid",
|
||||
"serviceLinksAddFailed": "Gagal menambahkan tautan layanan",
|
||||
"viewServerDetails": "Lihat detail server"
|
||||
},
|
||||
"admin": {
|
||||
|
||||
@@ -195,9 +195,6 @@
|
||||
"arrangeUnlocked": "Sblocca trascina per riorganizzare"
|
||||
},
|
||||
"homepage": {
|
||||
"title": "Pagina iniziale",
|
||||
"addWidget": "Aggiungi widget",
|
||||
"editWidget": "Modifica widget",
|
||||
"deleteWidget": "Elimina",
|
||||
"widgetTypes": "Tipi di widget",
|
||||
"serviceLink": "Collegamento servizio",
|
||||
@@ -206,116 +203,40 @@
|
||||
"notes": "Note",
|
||||
"hostStatus": "Stato host",
|
||||
"bookmarkList": "Segnalibri",
|
||||
"zoomIn": "Ingrandisci",
|
||||
"zoomOut": "Rimpicciolisci",
|
||||
"resetView": "Reimposta vista",
|
||||
"lockLayout": "Blocca layout",
|
||||
"unlockLayout": "Sblocca layout",
|
||||
"noWidgets": "Fai clic con il tasto destro o clicca su + per aggiungere il tuo primo widget",
|
||||
"openFullView": "Apri vista completa",
|
||||
"previewTitle": "Anteprima homepage",
|
||||
"cancel": "Annulla",
|
||||
"save": "Salva",
|
||||
"title_label": "Titolo",
|
||||
"widgetTitlePlaceholder": "Titolo widget (opzionale)",
|
||||
"url": "URL",
|
||||
"imageUrl": "URL immagine personalizzata",
|
||||
"imageUrlHint": "Lascia vuoto per utilizzare automaticamente la favicon del sito",
|
||||
"showImage": "Mostra immagine",
|
||||
"description": "Descrizione",
|
||||
"color": "Colore",
|
||||
"icon": "Icona",
|
||||
"expanded": "Espanso per impostazione predefinita",
|
||||
"timezone": "Fuso orario",
|
||||
"invalidTimezone": "Fuso orario non valido. Utilizzare un nome IANA come America/New_York",
|
||||
"showSeconds": "Mostra secondi",
|
||||
"format12h": "12 ore",
|
||||
"format24h": "24 ore",
|
||||
"content": "Contenuto",
|
||||
"backgroundColor": "Colore sfondo",
|
||||
"host": "Host",
|
||||
"showMetrics": "Mostra Metriche",
|
||||
"links": "Collegamenti",
|
||||
"addLink": "Aggiungi Collegamento",
|
||||
"linkLabel": "Etichetta",
|
||||
"linkUrl": "URL",
|
||||
"removeLink": "Rimuovi",
|
||||
"categoryLinks": "Collegamenti",
|
||||
"categoryInfo": "Info",
|
||||
"categorySystem": "Sistema",
|
||||
"widgetServiceLinkDesc": "Un riquadro cliccabile che collega a un URL di servizio",
|
||||
"widgetFolderDesc": "Un contenitore per raggruppare widget correlati",
|
||||
"widgetClockDesc": "Un orologio in tempo reale con fuso orario configurabile",
|
||||
"widgetNotesDesc": "Un widget per note in formato markdown",
|
||||
"widgetHostStatusDesc": "Mostra CPU, memoria e disco in tempo reale per un host SSH",
|
||||
"widgetBookmarkListDesc": "Un elenco di collegamenti rapidi",
|
||||
"copyLink": "Copia Collegamento",
|
||||
"linkCopied": "Collegamento copiato!",
|
||||
"location": "Località",
|
||||
"temperatureUnit": "Unità di temperatura",
|
||||
"showForecast": "Mostra previsioni a 3 giorni",
|
||||
"scrolling": "Consenti scorrimento",
|
||||
"feedUrl": "URL del feed",
|
||||
"maxItems": "Numero massimo di elementi",
|
||||
"showDescription": "Mostra descrizione",
|
||||
"widgetWeatherName": "Meteo",
|
||||
"widgetWeatherDesc": "Meteo in tempo reale per qualsiasi località",
|
||||
"widgetIframeName": "Incorporamento iFrame",
|
||||
"widgetIframeDesc": "Incorpora qualsiasi URL in un iframe",
|
||||
"widgetRssName": "Feed RSS",
|
||||
"widgetRssDesc": "Visualizza elementi da un feed RSS o Atom",
|
||||
"dragToFolder": "Trascina i widget qui o usa + per aggiungere",
|
||||
"showDisk": "Mostra utilizzo disco",
|
||||
"addToFolder": "Aggiungi widget alla cartella",
|
||||
"noHostSelected": "Nessun host selezionato",
|
||||
"selectHost": "Seleziona un host...",
|
||||
"displayedMetrics": "Metriche visualizzate",
|
||||
"metricCpu": "CPU",
|
||||
"metricMemory": "Memoria",
|
||||
"metricDisk": "Disco",
|
||||
"metricUptime": "Uptime",
|
||||
"metricSystem": "Sistema",
|
||||
"metricNetwork": "Rete",
|
||||
"metricProcesses": "Processi",
|
||||
"categoryMonitoring": "Monitoraggio",
|
||||
"loading": "Caricamento...",
|
||||
"allClear": "Tutto a posto",
|
||||
"noPingUrls": "Nessun URL configurato",
|
||||
"pingLabel": "Etichetta",
|
||||
"addPingUrl": "Aggiungi URL",
|
||||
"showLatency": "Mostra latenza",
|
||||
"refreshInterval": "Intervallo di aggiornamento",
|
||||
"seconds": "secondi",
|
||||
"filterActivityTypes": "Filtra tipi",
|
||||
"filterTypesHint": "Lascia vuoto per mostrare tutto",
|
||||
"showTimestamp": "Mostra timestamp",
|
||||
"uptimeUnavailable": "Uptime non disponibile",
|
||||
"uptimeLabel": "Uptime",
|
||||
"overviewVersion": "Versione",
|
||||
"overviewUpdate": "Aggiornato",
|
||||
"overviewUpdateLabel": "Aggiornamento",
|
||||
"overviewUpdateAvailable": "Aggiornamento disponibile",
|
||||
"overviewDatabase": "Database",
|
||||
"overviewUptime": "Uptime",
|
||||
"noHosts": "Nessun host configurato",
|
||||
"hostGridHosts": "Host",
|
||||
"hostGridAllHint": "Lascia vuoto per mostrare tutti gli host",
|
||||
"columns": "Colonne",
|
||||
"showIp": "Mostra indirizzo IP",
|
||||
"connectionType": "Tipo di connessione",
|
||||
"layout": "Layout",
|
||||
"showStatus": "Mostra stato",
|
||||
"showHostName": "Mostra nome host",
|
||||
"noDockerActivity": "Nessuna attività Docker",
|
||||
"noActivity": "Nessuna attività",
|
||||
"showAcknowledged": "Mostra riconosciuti",
|
||||
"widgetMetricsChartName": "Grafico metriche",
|
||||
"widgetMetricsChartDesc": "Grafico storico di CPU, memoria, disco o rete per un host",
|
||||
"widgetHostGridName": "Griglia host",
|
||||
"widgetHostGridDesc": "Vista a griglia degli stati degli host SSH",
|
||||
"widgetPingStatusName": "Stato ping",
|
||||
"widgetPingStatusDesc": "Stato del ping HTTP per uno o più URL",
|
||||
"widgetRecentActivityName": "Attività recente",
|
||||
"widgetRecentActivityDesc": "Feed scorrevole dell'attività recente di Termix",
|
||||
"widgetTermixUptimeName": "Uptime di Termix",
|
||||
"widgetTermixUptimeDesc": "Contatore di uptime in tempo reale per il server Termix",
|
||||
@@ -347,67 +268,8 @@
|
||||
"widgetSearchLinksDesc": "Pulsanti di ricerca rapida con input inline",
|
||||
"widgetLinkTreeName": "Albero dei collegamenti",
|
||||
"widgetLinkTreeDesc": "Sezioni raggruppate di collegamenti con intestazioni",
|
||||
"calMon": "Lu",
|
||||
"calTue": "Ma",
|
||||
"calWed": "Me",
|
||||
"calThu": "Gi",
|
||||
"calFri": "Ve",
|
||||
"calSat": "Sa",
|
||||
"calSun": "Do",
|
||||
"startOnMonday": "Inizia la settimana il lunedì",
|
||||
"countdownNoDate": "Nessuna data obiettivo impostata",
|
||||
"countdownPast": "Evento passato",
|
||||
"countdownDays": "giorni",
|
||||
"countdownHours": "ore",
|
||||
"countdownMinutes": "min",
|
||||
"countdownSeconds": "sec",
|
||||
"countdownLabel": "Etichetta",
|
||||
"countdownLabelPlaceholder": "es. Giorno di lancio",
|
||||
"countdownShowDays": "Mostra giorni",
|
||||
"countdownShowHours": "Mostra ore",
|
||||
"targetDate": "Data obiettivo",
|
||||
"searchEngine": "Motore di ricerca",
|
||||
"customSearchUrl": "URL di ricerca personalizzato",
|
||||
"searchPlaceholder": "Cerca...",
|
||||
"searchGo": "Vai",
|
||||
"searchPlaceholderLabel": "Testo segnaposto",
|
||||
"searchPlaceholderHint": "Testo mostrato nel campo di ricerca",
|
||||
"openInNewTab": "Apri in nuova scheda",
|
||||
"searchQueryPlaceholder": "Inserisci query...",
|
||||
"noSearchShortcuts": "Nessuna scorciatoia configurata",
|
||||
"addSearchShortcut": "Aggiungi scorciatoia",
|
||||
"fontSize": "Dimensione carattere",
|
||||
"textAlign": "Allineamento testo",
|
||||
"fontWeight": "Spessore carattere",
|
||||
"clearColor": "Rimuovi colore",
|
||||
"imageFit": "Adatta immagine",
|
||||
"imageLinkUrl": "URL collegamento",
|
||||
"noImage": "Nessun URL immagine impostato",
|
||||
"altText": "Testo alternativo",
|
||||
"altTextPlaceholder": "Descrivi l'immagine",
|
||||
"renderMarkdown": "Visualizza Markdown",
|
||||
"displayMode": "Modalità visualizzazione",
|
||||
"displayField": "Campo visualizzazione",
|
||||
"jsonPath": "Percorso JSON",
|
||||
"customApiLabel": "Etichetta",
|
||||
"customApiLabelPlaceholder": "es. Temperatura",
|
||||
"customApiUnit": "Unità",
|
||||
"customApiNoUrl": "Nessun URL API configurato",
|
||||
"customApiError": "Recupero fallito",
|
||||
"customApiNotArray": "La risposta non è un array",
|
||||
"addService": "Aggiungi servizio",
|
||||
"showLabels": "Mostra etichette",
|
||||
"iconSize": "Dimensioni icone",
|
||||
"noDashboardLinks": "Nessun collegamento dashboard configurato",
|
||||
"noLimit": "Nessun limite",
|
||||
"sectionHeading": "Intestazione sezione",
|
||||
"addSection": "Aggiungi sezione",
|
||||
"compactMode": "Modalità compatta",
|
||||
"showDetailed": "Mostra dettagli",
|
||||
"selectHosts": "Seleziona host",
|
||||
"allHosts": "Tutti gli host",
|
||||
"listLayout": "Elenco",
|
||||
"gridLayout": "Griglia",
|
||||
"terminal": "Terminale",
|
||||
"files": "File",
|
||||
"docker": "Docker",
|
||||
@@ -422,12 +284,8 @@
|
||||
"accentColor": "Colore accento",
|
||||
"widgetSshTerminalName": "Terminale SSH",
|
||||
"widgetSshTerminalDesc": "Un terminale SSH inline connesso a un host configurato",
|
||||
"sshTerminalNoHost": "Nessun host configurato",
|
||||
"sshTerminalConnect": "Connetti",
|
||||
"sshTerminalAutoConnect": "Connessione automatica al caricamento",
|
||||
"widgetQuickConnectName": "Connessione rapida",
|
||||
"widgetQuickConnectDesc": "Pulsanti di avvio con un clic per qualsiasi tipo di connessione ai tuoi host",
|
||||
"connectionTypes": "Tipi di connessione",
|
||||
"connType_terminal": "Terminale",
|
||||
"connType_files": "Gestione file",
|
||||
"connType_docker": "Docker",
|
||||
@@ -685,7 +543,6 @@
|
||||
"renameTab": "Rinomina scheda",
|
||||
"openFileManager": "Apri Gestione file",
|
||||
"dashboard": "Dashboard",
|
||||
"homepage": "Homepage",
|
||||
"quickConnect": "Connessione rapida",
|
||||
"sshTools": "Strumenti SSH",
|
||||
"history": "Cronologia",
|
||||
@@ -1895,9 +1752,7 @@
|
||||
"serverStatsCard": "Statistiche server",
|
||||
"panelMain": "Principale",
|
||||
"panelSide": "Laterale",
|
||||
"justNow": "adesso",
|
||||
"serviceLinks": "Link ai servizi",
|
||||
"homepagePreview": "Anteprima home page"
|
||||
"justNow": "adesso"
|
||||
},
|
||||
"donation": {
|
||||
"title": "Ti piace Termix?",
|
||||
@@ -1926,15 +1781,8 @@
|
||||
"editModeInstructions": "Trascina le schede per riordinarle · Trascina il divisore di colonna per ridimensionare le colonne · Trascina il bordo inferiore di una scheda per ridimensionarne l'altezza · Cestino per rimuovere",
|
||||
"empty": "Vuoto",
|
||||
"clear": "Pulisci",
|
||||
"serviceLinksTitle": "Collegamenti ai servizi",
|
||||
"serviceLinksEmpty": "Nessun collegamento ai servizi",
|
||||
"serviceLinksAddLabel": "Etichetta",
|
||||
"serviceLinksAddUrl": "URL",
|
||||
"serviceLinksAdd": "Aggiungi",
|
||||
"serviceLinksLabelPlaceholder": "Il mio servizio",
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Inserisci un indirizzo web valido",
|
||||
"serviceLinksAddFailed": "Impossibile aggiungere il collegamento al servizio",
|
||||
"viewServerDetails": "Visualizza dettagli server"
|
||||
},
|
||||
"admin": {
|
||||
|
||||
@@ -195,9 +195,6 @@
|
||||
"arrangeUnlocked": "ロックを解除してドラッグして並べ替える"
|
||||
},
|
||||
"homepage": {
|
||||
"title": "ホームページ",
|
||||
"addWidget": "ウィジェットを追加",
|
||||
"editWidget": "ウィジェットを編集",
|
||||
"deleteWidget": "削除",
|
||||
"widgetTypes": "ウィジェットタイプ",
|
||||
"serviceLink": "サービスリンク",
|
||||
@@ -206,116 +203,40 @@
|
||||
"notes": "メモ",
|
||||
"hostStatus": "ホストステータス",
|
||||
"bookmarkList": "ブックマーク",
|
||||
"zoomIn": "拡大",
|
||||
"zoomOut": "縮小",
|
||||
"resetView": "表示をリセット",
|
||||
"lockLayout": "レイアウトをロック",
|
||||
"unlockLayout": "レイアウトのロックを解除",
|
||||
"noWidgets": "右クリックまたは「+」をクリックして最初のウィジェットを追加してください",
|
||||
"openFullView": "全画面表示を開く",
|
||||
"previewTitle": "ホームページプレビュー",
|
||||
"cancel": "キャンセル",
|
||||
"save": "保存",
|
||||
"title_label": "タイトル",
|
||||
"widgetTitlePlaceholder": "ウィジェットのタイトル(任意)",
|
||||
"url": "URL",
|
||||
"imageUrl": "カスタム画像URL",
|
||||
"imageUrlHint": "空白のままにすると、サイトのファビコンが自動的に使用されます",
|
||||
"showImage": "画像を表示",
|
||||
"description": "説明",
|
||||
"color": "色",
|
||||
"icon": "アイコン",
|
||||
"expanded": "デフォルトで展開",
|
||||
"timezone": "タイムゾーン",
|
||||
"invalidTimezone": "無効なタイムゾーンです。IANA名(例:America/New_York)を使用してください。",
|
||||
"showSeconds": "秒を表示",
|
||||
"format12h": "12時間表示",
|
||||
"format24h": "24時間表示",
|
||||
"content": "内容",
|
||||
"backgroundColor": "背景色",
|
||||
"host": "ホスト",
|
||||
"showMetrics": "メトリクス表示",
|
||||
"links": "リンク",
|
||||
"addLink": "リンクを追加",
|
||||
"linkLabel": "ラベル",
|
||||
"linkUrl": "URL",
|
||||
"removeLink": "削除",
|
||||
"categoryLinks": "リンク",
|
||||
"categoryInfo": "情報",
|
||||
"categorySystem": "システム",
|
||||
"widgetServiceLinkDesc": "サービスURLへリンクするクリック可能なタイル",
|
||||
"widgetFolderDesc": "関連するウィジェットをグループ化するコンテナ",
|
||||
"widgetClockDesc": "設定可能なタイムゾーンを備えた現在時刻の時計",
|
||||
"widgetNotesDesc": "Markdownメモウィジェット",
|
||||
"widgetHostStatusDesc": "SSHホストのCPU、メモリ、ディスク使用状況をライブ表示",
|
||||
"widgetBookmarkListDesc": "クイックリンクのリスト",
|
||||
"copyLink": "リンクをコピー",
|
||||
"linkCopied": "リンクがコピーされました!",
|
||||
"location": "場所",
|
||||
"temperatureUnit": "温度単位",
|
||||
"showForecast": "3日間の予報を表示",
|
||||
"scrolling": "スクロール許可",
|
||||
"feedUrl": "フィードURL",
|
||||
"maxItems": "最大アイテム数",
|
||||
"showDescription": "説明を表示",
|
||||
"widgetWeatherName": "天気",
|
||||
"widgetWeatherDesc": "任意の場所の現在の天気",
|
||||
"widgetIframeName": "iFrame埋め込み",
|
||||
"widgetIframeDesc": "任意のURLをiFrameで埋め込み",
|
||||
"widgetRssName": "RSSフィード",
|
||||
"widgetRssDesc": "RSSまたはAtomフィードのアイテムを表示",
|
||||
"dragToFolder": "ウィジェットをここにドラッグ、または+で追加",
|
||||
"showDisk": "ディスク使用量を表示",
|
||||
"addToFolder": "ウィジェットをフォルダに追加",
|
||||
"noHostSelected": "ホストが選択されていません",
|
||||
"selectHost": "ホストを選択...",
|
||||
"displayedMetrics": "表示メトリクス",
|
||||
"metricCpu": "CPU",
|
||||
"metricMemory": "メモリ",
|
||||
"metricDisk": "ディスク",
|
||||
"metricUptime": "稼働時間",
|
||||
"metricSystem": "システム",
|
||||
"metricNetwork": "ネットワーク",
|
||||
"metricProcesses": "プロセス",
|
||||
"categoryMonitoring": "モニタリング",
|
||||
"loading": "読み込み中...",
|
||||
"allClear": "すべて正常",
|
||||
"noPingUrls": "URLが設定されていません",
|
||||
"pingLabel": "ラベル",
|
||||
"addPingUrl": "URLを追加",
|
||||
"showLatency": "レイテンシを表示",
|
||||
"refreshInterval": "更新間隔",
|
||||
"seconds": "秒",
|
||||
"filterActivityTypes": "アクティビティタイプフィルター",
|
||||
"filterTypesHint": "すべてを表示するには空のままにします",
|
||||
"showTimestamp": "タイムスタンプを表示",
|
||||
"uptimeUnavailable": "稼働時間は利用できません",
|
||||
"uptimeLabel": "稼働時間",
|
||||
"overviewVersion": "バージョン",
|
||||
"overviewUpdate": "最新",
|
||||
"overviewUpdateLabel": "アップデート",
|
||||
"overviewUpdateAvailable": "アップデートあり",
|
||||
"overviewDatabase": "データベース",
|
||||
"overviewUptime": "稼働時間",
|
||||
"noHosts": "ホストが設定されていません",
|
||||
"hostGridHosts": "ホスト",
|
||||
"hostGridAllHint": "空のままにするとすべてのホストを表示します",
|
||||
"columns": "列",
|
||||
"showIp": "IPアドレスを表示",
|
||||
"connectionType": "接続タイプ",
|
||||
"layout": "レイアウト",
|
||||
"showStatus": "ステータスを表示",
|
||||
"showHostName": "ホスト名を表示",
|
||||
"noDockerActivity": "Dockerアクティビティはありません",
|
||||
"noActivity": "アクティビティはありません",
|
||||
"showAcknowledged": "確認済みを表示",
|
||||
"widgetMetricsChartName": "メトリックチャート",
|
||||
"widgetMetricsChartDesc": "ホストのCPU、メモリ、ディスク、またはネットワークの履歴チャート",
|
||||
"widgetHostGridName": "ホストグリッド",
|
||||
"widgetHostGridDesc": "SSHホストステータスのグリッドビュー",
|
||||
"widgetPingStatusName": "Pingステータス",
|
||||
"widgetPingStatusDesc": "1つ以上のURLのHTTP pingステータス",
|
||||
"widgetRecentActivityName": "最近のアクティビティ",
|
||||
"widgetRecentActivityDesc": "スクロール可能なTermixの最近のアクティビティフィード",
|
||||
"widgetTermixUptimeName": "Termixアップタイム",
|
||||
"widgetTermixUptimeDesc": "Termixサーバーのライブアップタイムカウンター",
|
||||
@@ -347,67 +268,8 @@
|
||||
"widgetSearchLinksDesc": "インライン入力対応のクイック検索ショートカット",
|
||||
"widgetLinkTreeName": "リンクツリー",
|
||||
"widgetLinkTreeDesc": "見出し付きのリンクグループ",
|
||||
"calMon": "月",
|
||||
"calTue": "火",
|
||||
"calWed": "水",
|
||||
"calThu": "木",
|
||||
"calFri": "金",
|
||||
"calSat": "土",
|
||||
"calSun": "日",
|
||||
"startOnMonday": "週の始まりを月曜に",
|
||||
"countdownNoDate": "目標日が設定されていません",
|
||||
"countdownPast": "イベントは終了しました",
|
||||
"countdownDays": "日",
|
||||
"countdownHours": "時間",
|
||||
"countdownMinutes": "分",
|
||||
"countdownSeconds": "秒",
|
||||
"countdownLabel": "ラベル",
|
||||
"countdownLabelPlaceholder": "例: リリース日",
|
||||
"countdownShowDays": "日数を表示",
|
||||
"countdownShowHours": "時間を表示",
|
||||
"targetDate": "目標日",
|
||||
"searchEngine": "検索エンジン",
|
||||
"customSearchUrl": "カスタム検索URL",
|
||||
"searchPlaceholder": "検索...",
|
||||
"searchGo": "検索",
|
||||
"searchPlaceholderLabel": "プレースホルダーテキスト",
|
||||
"searchPlaceholderHint": "検索入力欄に表示されるテキスト",
|
||||
"openInNewTab": "新しいタブで開く",
|
||||
"searchQueryPlaceholder": "クエリを入力...",
|
||||
"noSearchShortcuts": "設定済みのショートカットはありません",
|
||||
"addSearchShortcut": "ショートカットを追加",
|
||||
"fontSize": "フォントサイズ",
|
||||
"textAlign": "テキスト配置",
|
||||
"fontWeight": "フォントの太さ",
|
||||
"clearColor": "色をクリア",
|
||||
"imageFit": "画像フィット",
|
||||
"imageLinkUrl": "リンクURL",
|
||||
"noImage": "画像URLが設定されていません",
|
||||
"altText": "代替テキスト",
|
||||
"altTextPlaceholder": "画像の説明を入力",
|
||||
"renderMarkdown": "Markdownをレンダリング",
|
||||
"displayMode": "表示モード",
|
||||
"displayField": "表示フィールド",
|
||||
"jsonPath": "JSONパス",
|
||||
"customApiLabel": "ラベル",
|
||||
"customApiLabelPlaceholder": "例: 温度",
|
||||
"customApiUnit": "単位",
|
||||
"customApiNoUrl": "API URLが設定されていません",
|
||||
"customApiError": "取得に失敗しました",
|
||||
"customApiNotArray": "レスポンスが配列ではありません",
|
||||
"addService": "サービスを追加",
|
||||
"showLabels": "ラベルを表示",
|
||||
"iconSize": "アイコンサイズ",
|
||||
"noDashboardLinks": "ダッシュボードリンクが設定されていません",
|
||||
"noLimit": "制限なし",
|
||||
"sectionHeading": "セクション見出し",
|
||||
"addSection": "セクションを追加",
|
||||
"compactMode": "コンパクトモード",
|
||||
"showDetailed": "詳細を表示",
|
||||
"selectHosts": "ホストを選択",
|
||||
"allHosts": "すべてのホスト",
|
||||
"listLayout": "リスト",
|
||||
"gridLayout": "グリッド",
|
||||
"terminal": "ターミナル",
|
||||
"files": "ファイル",
|
||||
"docker": "Docker",
|
||||
@@ -422,12 +284,8 @@
|
||||
"accentColor": "アクセントカラー",
|
||||
"widgetSshTerminalName": "SSHターミナル",
|
||||
"widgetSshTerminalDesc": "設定済みホストに接続するインラインSSHターミナル",
|
||||
"sshTerminalNoHost": "ホストが設定されていません",
|
||||
"sshTerminalConnect": "接続",
|
||||
"sshTerminalAutoConnect": "読み込み時に自動接続",
|
||||
"widgetQuickConnectName": "クイック接続",
|
||||
"widgetQuickConnectDesc": "ホスト上のあらゆる接続タイプをワンクリックで起動するボタン",
|
||||
"connectionTypes": "接続タイプ",
|
||||
"connType_terminal": "ターミナル",
|
||||
"connType_files": "ファイルマネージャー",
|
||||
"connType_docker": "Docker",
|
||||
@@ -685,7 +543,6 @@
|
||||
"renameTab": "タブ名を変更",
|
||||
"openFileManager": "ファイルマネージャーを開く",
|
||||
"dashboard": "ダッシュボード",
|
||||
"homepage": "ホームページ",
|
||||
"quickConnect": "クイック接続",
|
||||
"sshTools": "SSHツール",
|
||||
"history": "履歴",
|
||||
@@ -1895,9 +1752,7 @@
|
||||
"serverStatsCard": "サーバー統計",
|
||||
"panelMain": "メイン",
|
||||
"panelSide": "サイド",
|
||||
"justNow": "たった今",
|
||||
"serviceLinks": "サービスリンク",
|
||||
"homepagePreview": "ホームページプレビュー"
|
||||
"justNow": "たった今"
|
||||
},
|
||||
"donation": {
|
||||
"title": "Termixをお楽しみいただけていますか?",
|
||||
@@ -1926,15 +1781,8 @@
|
||||
"editModeInstructions": "カードをドラッグして並べ替え · 列の仕切りをドラッグして列幅を調整 · カードの下端をドラッグして高さを調整 · ゴミ箱にドラッグして削除",
|
||||
"empty": "空",
|
||||
"clear": "クリア",
|
||||
"serviceLinksTitle": "サービスリンク",
|
||||
"serviceLinksEmpty": "サービスリンクはまだありません",
|
||||
"serviceLinksAddLabel": "ラベル",
|
||||
"serviceLinksAddUrl": "URL",
|
||||
"serviceLinksAdd": "追加",
|
||||
"serviceLinksLabelPlaceholder": "マイサービス",
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "有効なWebアドレスを入力してください",
|
||||
"serviceLinksAddFailed": "サービスリンクの追加に失敗しました",
|
||||
"viewServerDetails": "サーバーの詳細を表示"
|
||||
},
|
||||
"admin": {
|
||||
|
||||
Loaded 100 of 256 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user