Merge pull request #159 from sqkkyzx/develop
feat(i18n): Refactored i18n function to support multiple parameter combinations
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
|
import {useLanguage} from "@/contexts/LanguageContext.tsx";
|
||||||
|
|
||||||
export function LoadingState() {
|
export function LoadingState() {
|
||||||
|
const { t } = useLanguage();
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-screen bg-background text-foreground">
|
<div className="flex items-center justify-center h-screen bg-background text-foreground">
|
||||||
<div className="flex flex-col items-center text-center py-8 px-4 rounded-lg shadow-lg bg-card animate-fade-in">
|
<div className="flex flex-col items-center text-center py-8 px-4 rounded-lg shadow-lg bg-card animate-fade-in">
|
||||||
@@ -7,8 +9,8 @@ export function LoadingState() {
|
|||||||
<div className="absolute w-12 h-12 rounded-full border-4 border-primary/20"></div>
|
<div className="absolute w-12 h-12 rounded-full border-4 border-primary/20"></div>
|
||||||
<div className="w-12 h-12 rounded-full border-4 border-t-primary border-r-transparent border-b-transparent border-l-transparent animate-spin"></div>
|
<div className="w-12 h-12 rounded-full border-4 border-t-primary border-r-transparent border-b-transparent border-l-transparent animate-spin"></div>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-medium mb-1">Loading server data</h3>
|
<h3 className="text-xl font-medium mb-1">{t("loadingServerData")}</h3>
|
||||||
<p className="text-muted-foreground">Please wait while we retrieve your information...</p>
|
<p className="text-muted-foreground">{t("retrievingYourInformation")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -80,9 +80,9 @@ export function ServicesPagination({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
{totalItems > 0
|
{totalItems > 0
|
||||||
? `${startItem}-${endItem} of ${totalItems} ${t("services") || "services"}`
|
? t("servicesPagination", {"startItem": startItem, "endItem": endItem, "totalItems": totalItems})
|
||||||
: `0 ${t("services") || "services"}`
|
: t("servicesPaginationNoService")
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -194,9 +194,7 @@ const TestEmailDialog: React.FC<TestEmailDialogProps> = ({
|
|||||||
{/* Info message */}
|
{/* Info message */}
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<AlertDescription>
|
<AlertDescription>{t("testEmailAlert", "settings")}</AlertDescription>
|
||||||
This will send a test email using your configured SMTP settings. Make sure SMTP is properly configured first.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -214,7 +212,7 @@ const TestEmailDialog: React.FC<TestEmailDialogProps> = ({
|
|||||||
) : (
|
) : (
|
||||||
<Mail className="h-4 w-4" />
|
<Mail className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
{isLoading ? t("sending", "settings") : t("send", "common")}
|
{isLoading ? t("sending", "settings") : t("send", "settings")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
import React, { createContext, useContext, useState, ReactNode, useCallback } from "react";
|
import React, { createContext, useContext, useState, ReactNode, useCallback } from "react";
|
||||||
import { translations, Language, TranslationModule, TranslationKey } from "@/translations";
|
import { translations, Language, TranslationModule, TranslationKey } from "@/translations";
|
||||||
|
|
||||||
|
type TranslationVars = Record<string, string | number>;
|
||||||
|
|
||||||
type LanguageContextType = {
|
type LanguageContextType = {
|
||||||
language: Language;
|
language: Language;
|
||||||
setLanguage: (language: Language) => void;
|
setLanguage: (language: Language) => void;
|
||||||
t: <M extends TranslationModule>(key: string, module?: M) => string;
|
t: {
|
||||||
|
(key: string): string;
|
||||||
|
(key: string, vars: TranslationVars): string;
|
||||||
|
<M extends TranslationModule>(key: string, module: M): string;
|
||||||
|
<M extends TranslationModule>(key: string, module: M, vars: TranslationVars): string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// ❗ Create the context with `undefined` to enforce provider usage
|
|
||||||
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
|
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
|
||||||
|
|
||||||
// ✅ Stable custom hook
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const useLanguage = () => {
|
export const useLanguage = () => {
|
||||||
const context = useContext(LanguageContext);
|
const context = useContext(LanguageContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
@@ -23,33 +29,76 @@ export const LanguageProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
const [language, setLanguage] = useState<Language>("en");
|
const [language, setLanguage] = useState<Language>("en");
|
||||||
const fallbackLanguage: Language = "en";
|
const fallbackLanguage: Language = "en";
|
||||||
|
|
||||||
const t = useCallback(<M extends TranslationModule>(key: string, module?: M): string => {
|
const interpolate = (template: string, vars: TranslationVars): string => {
|
||||||
|
return template.replace(/{(\w+)}/g, (match, key) => {
|
||||||
|
return vars[key] !== undefined ? String(vars[key]) : match;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const t = useCallback(((
|
||||||
|
key: string,
|
||||||
|
moduleOrVars?: TranslationModule | TranslationVars,
|
||||||
|
vars?: TranslationVars
|
||||||
|
): string => {
|
||||||
|
let module: TranslationModule | undefined;
|
||||||
|
let variables: TranslationVars | undefined;
|
||||||
|
|
||||||
|
if (typeof moduleOrVars === "string") {
|
||||||
|
module = moduleOrVars;
|
||||||
|
variables = vars;
|
||||||
|
} else if (typeof moduleOrVars === "object") {
|
||||||
|
variables = moduleOrVars;
|
||||||
|
}
|
||||||
|
|
||||||
const langPack = translations[language];
|
const langPack = translations[language];
|
||||||
const fallbackPack = translations[fallbackLanguage];
|
const fallbackPack = translations[fallbackLanguage];
|
||||||
|
let translatedText = "";
|
||||||
|
|
||||||
if (module) {
|
if (module) {
|
||||||
const valCur = langPack?.[module]?.[key as TranslationKey<M>];
|
const moduleKey = key as TranslationKey<typeof module>;
|
||||||
if (typeof valCur === "string") return valCur;
|
const valCur = langPack?.[module]?.[moduleKey];
|
||||||
|
if (typeof valCur === "string") {
|
||||||
|
translatedText = valCur;
|
||||||
|
} else {
|
||||||
|
const valEn = fallbackPack?.[module]?.[moduleKey];
|
||||||
|
translatedText = typeof valEn === "string" ? valEn : key;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let found = false;
|
||||||
|
|
||||||
const valEn = fallbackPack?.[module]?.[key as TranslationKey<M>];
|
for (const mod of Object.keys(langPack) as TranslationModule[]) {
|
||||||
if (typeof valEn === "string") return valEn;
|
const moduleTranslations = langPack[mod];
|
||||||
|
if (moduleTranslations && key in moduleTranslations) {
|
||||||
|
const val = moduleTranslations[key as keyof typeof moduleTranslations];
|
||||||
|
if (typeof val === "string") {
|
||||||
|
translatedText = val;
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return key;
|
if (!found) {
|
||||||
|
for (const mod of Object.keys(fallbackPack) as TranslationModule[]) {
|
||||||
|
const moduleTranslations = fallbackPack[mod];
|
||||||
|
if (moduleTranslations && key in moduleTranslations) {
|
||||||
|
const val = moduleTranslations[key as keyof typeof moduleTranslations];
|
||||||
|
if (typeof val === "string") {
|
||||||
|
translatedText = val;
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!found) {
|
||||||
|
translatedText = key;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const mod in langPack) {
|
return variables ? interpolate(translatedText, variables) : translatedText;
|
||||||
const m = mod as TranslationModule;
|
}) as LanguageContextType['t'], [language]);
|
||||||
const val = langPack[m]?.[key as any];
|
|
||||||
if (typeof val === "string") return val;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const mod in fallbackPack) {
|
|
||||||
const m = mod as TranslationModule;
|
|
||||||
const val = fallbackPack[m]?.[key as any];
|
|
||||||
if (typeof val === "string") return val;
|
|
||||||
}
|
|
||||||
|
|
||||||
return key;
|
|
||||||
}, [language]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LanguageContext.Provider value={{ language, setLanguage, t }}>
|
<LanguageContext.Provider value={{ language, setLanguage, t }}>
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ import { User } from "@/services/userService";
|
|||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useSidebar } from "@/contexts/SidebarContext";
|
import { useSidebar } from "@/contexts/SidebarContext";
|
||||||
|
import {useLanguage} from "@/contexts/LanguageContext.tsx";
|
||||||
|
|
||||||
const Profile = () => {
|
const Profile = () => {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
|
||||||
// Use shared sidebar state
|
// Use shared sidebar state
|
||||||
const { sidebarCollapsed, toggleSidebar } = useSidebar();
|
const { sidebarCollapsed, toggleSidebar } = useSidebar();
|
||||||
|
|
||||||
@@ -84,12 +87,12 @@ const Profile = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-screen">
|
<div className="flex items-center justify-center h-screen">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p>Please login to view your profile</p>
|
<p>{t("loginToViewProfile")}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate("/login")}
|
onClick={() => navigate("/login")}
|
||||||
className="mt-4 px-4 py-2 bg-primary text-white rounded"
|
className="mt-4 px-4 py-2 bg-primary text-white rounded"
|
||||||
>
|
>
|
||||||
Go to Login
|
{t("goToLogin")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -110,7 +113,7 @@ const Profile = () => {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
||||||
<span className="ml-2">Loading user data...</span>
|
<span className="ml-2">{t("loadingUserData")}</span>
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="flex flex-col items-center justify-center h-full">
|
<div className="flex flex-col items-center justify-center h-full">
|
||||||
@@ -121,7 +124,7 @@ const Profile = () => {
|
|||||||
onClick={() => fetchUserData()}
|
onClick={() => fetchUserData()}
|
||||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-md"
|
className="px-4 py-2 bg-primary text-primary-foreground rounded-md"
|
||||||
>
|
>
|
||||||
Retry
|
{t("retry")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -61,4 +61,5 @@ export const incidentTranslations: IncidentTranslations = {
|
|||||||
affectedSystems: 'Affected Systems',
|
affectedSystems: 'Affected Systems',
|
||||||
enterAffectedSystems: 'Enter Affected Systems',
|
enterAffectedSystems: 'Enter Affected Systems',
|
||||||
separateSystemsWithComma: 'Enter affected systems seperated by comma',
|
separateSystemsWithComma: 'Enter affected systems seperated by comma',
|
||||||
|
createIncidentDesc: 'Create a new incident',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -78,4 +78,12 @@ export const maintenanceTranslations: MaintenanceTranslations = {
|
|||||||
selectNotificationChannel: 'Add a notification channel',
|
selectNotificationChannel: 'Add a notification channel',
|
||||||
enableNotificationsFirst: 'Enable notifications first to select channel',
|
enableNotificationsFirst: 'Enable notifications first to select channel',
|
||||||
updateMaintenance: 'Update Maintenance',
|
updateMaintenance: 'Update Maintenance',
|
||||||
|
loginToViewProfile: 'Please login to view your profile',
|
||||||
|
goToLogin: 'Go to Login',
|
||||||
|
loadingUserData: 'Loading user data...',
|
||||||
|
retry: 'Retry',
|
||||||
|
loadingServerData: 'Loading server data...',
|
||||||
|
retrievingYourInformation: 'Please wait while we retrieve your information...',
|
||||||
|
servicesPagination: '{startItem}-{endItem} of {totalItems} services',
|
||||||
|
servicesPaginationNoService: '0 services',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -37,7 +37,11 @@ export const settingsTranslations: SettingsTranslations = {
|
|||||||
selectCollection: "Select collection",
|
selectCollection: "Select collection",
|
||||||
toEmailAddress: "To email address",
|
toEmailAddress: "To email address",
|
||||||
enterEmailAddress: "Enter email address",
|
enterEmailAddress: "Enter email address",
|
||||||
|
send: "Send",
|
||||||
sending: "Sending...",
|
sending: "Sending...",
|
||||||
|
testEmailSettings: "Test Email Settings",
|
||||||
|
testEmailDescription: "Test whether the current Email Settings are available",
|
||||||
|
testEmailAlert: "This will send a test email using your configured SMTP settings. Make sure SMTP is properly configured first.",
|
||||||
|
|
||||||
// General Settings - Actions and status
|
// General Settings - Actions and status
|
||||||
save: "Save Changes",
|
save: "Save Changes",
|
||||||
|
|||||||
@@ -59,5 +59,5 @@ export interface IncidentTranslations {
|
|||||||
affectedSystems: string;
|
affectedSystems: string;
|
||||||
enterAffectedSystems: string;
|
enterAffectedSystems: string;
|
||||||
separateSystemsWithComma: string;
|
separateSystemsWithComma: string;
|
||||||
|
createIncidentDesc: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,4 +76,12 @@ export interface MaintenanceTranslations {
|
|||||||
selectNotificationChannel: string;
|
selectNotificationChannel: string;
|
||||||
enableNotificationsFirst: string;
|
enableNotificationsFirst: string;
|
||||||
updateMaintenance: string;
|
updateMaintenance: string;
|
||||||
|
loginToViewProfile: string;
|
||||||
|
goToLogin: string;
|
||||||
|
loadingUserData: string;
|
||||||
|
retry: string;
|
||||||
|
loadingServerData: string;
|
||||||
|
retrievingYourInformation: string;
|
||||||
|
servicesPagination: string;
|
||||||
|
servicesPaginationNoService: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,11 @@ export interface SettingsTranslations {
|
|||||||
selectCollection: string;
|
selectCollection: string;
|
||||||
toEmailAddress: string;
|
toEmailAddress: string;
|
||||||
enterEmailAddress: string;
|
enterEmailAddress: string;
|
||||||
|
send: string;
|
||||||
sending: string;
|
sending: string;
|
||||||
|
testEmailSettings: string;
|
||||||
|
testEmailDescription: string;
|
||||||
|
testEmailAlert: string;
|
||||||
|
|
||||||
// General Settings - Actions and status
|
// General Settings - Actions and status
|
||||||
save: string;
|
save: string;
|
||||||
|
|||||||
@@ -53,4 +53,13 @@ export const incidentTranslations: IncidentTranslations = {
|
|||||||
failedToUpdateStatus: '更新状态失败',
|
failedToUpdateStatus: '更新状态失败',
|
||||||
inProgress: '进行中',
|
inProgress: '进行中',
|
||||||
enterRootCause: '输入故障根源',
|
enterRootCause: '输入故障根源',
|
||||||
|
enterIncidentTitle: '输入事件标题',
|
||||||
|
enterIncidentDescription: '输入事件描述',
|
||||||
|
enterServiceId: '输入以逗号分隔的服务ID',
|
||||||
|
selectAssignedUser: '选择分配用户',
|
||||||
|
noAssignedUser: '该事件当前未分配给任何用户',
|
||||||
|
affectedSystems: '受影响系统',
|
||||||
|
enterAffectedSystems: '受影响系统',
|
||||||
|
separateSystemsWithComma: '输入受影响的系统,以逗号分隔',
|
||||||
|
createIncidentDesc: '创建一个新的事件',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -65,4 +65,25 @@ export const maintenanceTranslations: MaintenanceTranslations = {
|
|||||||
noScheduledMaintenance: '没有计划的维护',
|
noScheduledMaintenance: '没有计划的维护',
|
||||||
noMaintenanceWindows: '没有此时间段的维护窗口。通过点击“创建维护”按钮创建一个。',
|
noMaintenanceWindows: '没有此时间段的维护窗口。通过点击“创建维护”按钮创建一个。',
|
||||||
maintenanceCreatedSuccess: '维护窗口创建成功',
|
maintenanceCreatedSuccess: '维护窗口创建成功',
|
||||||
|
editMaintenanceWindow: '编辑维护',
|
||||||
|
editMaintenanceDesc: '编辑维护详情',
|
||||||
|
priorityDescription: '选择维护优先级',
|
||||||
|
statusDescription: '选择维护状态',
|
||||||
|
impactLevel: '影响级别',
|
||||||
|
impactLevelDescription: '选择影响级别',
|
||||||
|
selectAssignedUsers: '选择分配的用户',
|
||||||
|
assignedPersonnelDescription: '当前分配的用户',
|
||||||
|
mutedNotifications: '通知已禁用',
|
||||||
|
notificationsAreMuted: '此维护期间的通知当前已静音',
|
||||||
|
selectNotificationChannel: '添加通知渠道',
|
||||||
|
enableNotificationsFirst: '首先启用通知以选择频道',
|
||||||
|
updateMaintenance: '更新维护',
|
||||||
|
loginToViewProfile: '请登录查看您的个人资料',
|
||||||
|
goToLogin: '立即登录',
|
||||||
|
loadingUserData: '正在加载用户数据...',
|
||||||
|
retry: '重试',
|
||||||
|
loadingServerData: '正在加载服务器数据...',
|
||||||
|
retrievingYourInformation: '正在获取您的信息,请稍候...',
|
||||||
|
servicesPagination: '{startItem}-{endItem}, 共 {totalItems} 个服务',
|
||||||
|
servicesPaginationNoService: '0 服务',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -37,7 +37,11 @@ export const settingsTranslations: SettingsTranslations = {
|
|||||||
selectCollection: "选择集合",
|
selectCollection: "选择集合",
|
||||||
toEmailAddress: "收件人邮箱地址",
|
toEmailAddress: "收件人邮箱地址",
|
||||||
enterEmailAddress: "输入收件人邮箱地址",
|
enterEmailAddress: "输入收件人邮箱地址",
|
||||||
|
send: "发送",
|
||||||
sending: "发送中...",
|
sending: "发送中...",
|
||||||
|
testEmailSettings: "测试邮箱配置",
|
||||||
|
testEmailDescription: "测试当前的邮箱设置是否可用",
|
||||||
|
testEmailAlert: "这将使用您配置的SMTP设置发送一封测试邮件。请确保SMTP已正确配置。",
|
||||||
|
|
||||||
// General Settings - Actions and status
|
// General Settings - Actions and status
|
||||||
save: "保存变更",
|
save: "保存变更",
|
||||||
|
|||||||
+21
-17
@@ -117,7 +117,7 @@ docker run -d \
|
|||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
|
|
||||||
## 🌟 面向社区的 CheckCle
|
## 🌟 面向社区的 CheckCle
|
||||||
@@ -129,26 +129,30 @@ docker run -d \
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 赞助商
|
## 赞助商
|
||||||
🙏 我们非常感谢我们的赞助商。你们的贡献使我们能够维护基础设施(托管、域名)、运行测试,并继续开发有价值的功能。
|
我们将不再接受赞助形式。从今以后,仅接受以生态系统和社区合作形式提供的支持,例如云服务器、域名或托管资源等基础设施支持。
|
||||||
|
|
||||||
### 🥈 白银在线保障合作伙伴
|
如果您是科技公司且有意支持 CheckCle ,请直接通过邮箱 tolaleng@checkcle.io 联系作者。
|
||||||
|
|
||||||
<a href="https://github.com/michaelortnerit">
|
|
||||||
<img src="https://avatars.githubusercontent.com/u/135371107?v=4" width="75" height="75" style="border-radius: 50%; display: block;" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
### 🧡 支持者
|
### 🧡 支持者
|
||||||
|
<div style="display: flex; align-items: center; gap: 10px;">
|
||||||
|
<a href="https://github.com/gitbookio">
|
||||||
|
<img src="https://avatars.githubusercontent.com/u/7111340?s=200&v=4"
|
||||||
|
width="75" height="75"
|
||||||
|
style="border-radius: 50%;"
|
||||||
|
alt="GitBook Logo" />
|
||||||
|
</a>
|
||||||
|
|
||||||
<a href="https://github.com/samang-dauth">
|
<a href="https://www.cloudflare.com">
|
||||||
<img src="https://avatars.githubusercontent.com/u/4575656?v=4" width="75" height="75" style="border-radius: 50%; display: block;" />
|
<img src="https://cdn.checkcle.io/images/sponsor/cloudflare-checkcle_logo.png"
|
||||||
</a>
|
height="60"
|
||||||
|
alt="Cloudflare Logo" />
|
||||||
### 🤝 生态系统与社区合作伙伴
|
</a>
|
||||||
<a href="https://github.com/gitbookio">
|
<a href="https://m.do.co/c/0c27ef82475f">
|
||||||
<img src="https://avatars.githubusercontent.com/u/7111340?s=200&v=4"
|
<img src="https://cdn.checkcle.io/images/sponsor/digitalocean_checkcle.png"
|
||||||
width="75" height="75"
|
height="50"
|
||||||
style="border-radius: 50%; display: block;" />
|
alt="DigitalOcean Logo" />
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user