From 480d65157561ea60a8ad7a659493f3f2a76a69e7 Mon Sep 17 00:00:00 2001 From: jackiexiao <707610215@qq.com> Date: Mon, 25 May 2026 14:17:37 +0800 Subject: [PATCH 1/4] feat: add MakerJackie fork baseline --- .gitmodules | 3 - docs/makerjackie-fork-requirements.md | 176 +++++++++++++++++ src-tauri/src/cloudflare_auth.rs | 7 +- src-tauri/src/lib.rs | 1 + src-tauri/src/setup.rs | 22 +-- src-tauri/src/shell_env.rs | 26 +++ src/components/Layout.tsx | 113 ++++++----- src/components/SettingsView.tsx | 69 ++++--- src/components/SetupWizard.tsx | 43 ++--- src/lib/i18n.ts | 135 +++++++++++++ src/pro_modules | 1 - src/pro_modules/frontend/AuditZoneContext.tsx | 5 + .../frontend/IndexManagerDialog.tsx | 16 ++ src/pro_modules/frontend/ProFeatureGate.tsx | 24 +++ src/pro_modules/frontend/PurchaseScreen.tsx | 16 ++ src/pro_modules/frontend/R2BucketsView.tsx | 178 ++++++++++++++++++ src/pro_modules/frontend/useRemoteConfig.ts | 29 +++ src/pro_modules/hooks/useD1TrackerLogic.ts | 9 + src/pro_modules/rust/domain_audit.rs | 44 +++++ src/pro_modules/rust/history.rs | 30 +++ src/pro_modules/rust/r2_pro.rs | 84 +++++++++ src/pro_modules/rust/r2_worker_proxy.rs | 2 + src/pro_modules/ui/ActivityDashboard.tsx | 7 + .../ui/audits/AuditPreferences.tsx | 3 + src/pro_modules/ui/audits/DnsEmailPosture.tsx | 3 + src/pro_modules/ui/audits/DomainScanner.tsx | 3 + src/pro_modules/ui/audits/Overview.tsx | 3 + .../ui/audits/PerformancePosture.tsx | 3 + src/pro_modules/ui/audits/SecurityPosture.tsx | 3 + src/store/useAppStore.ts | 7 + 30 files changed, 932 insertions(+), 133 deletions(-) delete mode 100644 .gitmodules create mode 100644 docs/makerjackie-fork-requirements.md create mode 100644 src-tauri/src/shell_env.rs create mode 100644 src/lib/i18n.ts delete mode 160000 src/pro_modules create mode 100644 src/pro_modules/frontend/AuditZoneContext.tsx create mode 100644 src/pro_modules/frontend/IndexManagerDialog.tsx create mode 100644 src/pro_modules/frontend/ProFeatureGate.tsx create mode 100644 src/pro_modules/frontend/PurchaseScreen.tsx create mode 100644 src/pro_modules/frontend/R2BucketsView.tsx create mode 100644 src/pro_modules/frontend/useRemoteConfig.ts create mode 100644 src/pro_modules/hooks/useD1TrackerLogic.ts create mode 100644 src/pro_modules/rust/domain_audit.rs create mode 100644 src/pro_modules/rust/history.rs create mode 100644 src/pro_modules/rust/r2_pro.rs create mode 100644 src/pro_modules/rust/r2_worker_proxy.rs create mode 100644 src/pro_modules/ui/ActivityDashboard.tsx create mode 100644 src/pro_modules/ui/audits/AuditPreferences.tsx create mode 100644 src/pro_modules/ui/audits/DnsEmailPosture.tsx create mode 100644 src/pro_modules/ui/audits/DomainScanner.tsx create mode 100644 src/pro_modules/ui/audits/Overview.tsx create mode 100644 src/pro_modules/ui/audits/PerformancePosture.tsx create mode 100644 src/pro_modules/ui/audits/SecurityPosture.tsx diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 3e69e81..0000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "src/pro_modules"] - path = src/pro_modules - url = https://github.com/mubashardev/cf-studio-pro.git diff --git a/docs/makerjackie-fork-requirements.md b/docs/makerjackie-fork-requirements.md new file mode 100644 index 0000000..34af661 --- /dev/null +++ b/docs/makerjackie-fork-requirements.md @@ -0,0 +1,176 @@ +# MakerJackie CF Studio Fork Requirements + +## Goal + +Create a lightweight MakerJackie fork of CF Studio for daily Cloudflare D1 and R2 work. + +This fork is not trying to replace the Cloudflare dashboard. It should make repeated local desktop workflows faster, especially when the dashboard is slow or too heavy for a quick D1/R2 check. + +## Scope for this first pass + +1. Make the app reliably detect Node.js, npm, npx, and Wrangler when they are installed through nvm on macOS. +2. Add a basic language system with English and Simplified Chinese support. +3. Keep the public clone runnable without private Pro modules. +4. Start the local Tauri app and verify the setup screen no longer blocks when nvm provides the tools. + +## Non-goals + +- Do not build a full Cloudflare dashboard clone. +- Do not depend on the new `cf` CLI as the backend. +- Do not add full KV management in this pass. +- Do not implement Local Explorer integration in this pass. +- Do not rewrite the UI architecture. + +## Background + +Cloudflare's current tool split is: + +- Wrangler remains the mature developer CLI. +- Cloudflare Dashboard remains the complete remote management surface. +- Local Explorer is useful for local Wrangler dev state, including local D1, R2, KV, Durable Objects SQLite, and Workflows. +- The new `cf` CLI is promising but still a technical preview. +- CF Studio is useful as a focused remote D1/R2 desktop client. + +The fork should lean into the focused desktop-client role. + +## Requirement 1, nvm-aware dependency detection + +### Problem + +On macOS, Tauri apps launched from Finder do not reliably inherit the interactive shell PATH. + +The user's terminal can resolve: + +```bash +which node +which wrangler +``` + +But the app may still report Node.js or Wrangler as missing. + +### Expected behavior + +When the app checks dependencies, it should detect binaries installed through nvm: + +```txt +~/.nvm/versions/node/*/bin/node +~/.nvm/versions/node/*/bin/npm +~/.nvm/versions/node/*/bin/npx +~/.nvm/versions/node/*/bin/wrangler +``` + +It should also check: + +```txt +~/.npm-global/bin +/opt/homebrew/bin +/usr/local/bin +``` + +### Implementation direction + +In Rust: + +- Add a reusable shell environment helper. +- Load `~/.nvm/nvm.sh` when present. +- Prepend common nvm, npm-global, and Homebrew bin folders before probing commands. +- Use the same shell bootstrap for silent Wrangler refresh commands. + +### Acceptance checks + +From a normal terminal: + +```bash +npm run tauri dev +``` + +The setup wizard should mark Node.js / npm and Cloudflare Wrangler as installed when they exist under nvm. + +## Requirement 2, basic i18n + +### Problem + +The app is English-only. For personal daily use, the main navigation and setup/settings surfaces should support Chinese. + +### Expected behavior + +The app should include: + +- English, `en-US` +- Simplified Chinese, `zh-CN` +- A language selector in Settings +- Persisted language preference in localStorage + +### Initial translation scope + +Translate only high-frequency surfaces in this pass: + +- Setup wizard +- Sidebar navigation +- Top title labels +- Settings page headings and tabs +- Common empty-state and coming-soon labels where easy + +Do not try to translate every D1/R2 table cell and every Pro/hidden feature in the first pass. + +## Requirement 3, public clone runnable + +### Problem + +The upstream repository imports `src/pro_modules`, but that folder is ignored and absent in a public clone. + +### Expected behavior + +The MakerJackie fork should run locally without private Pro modules. + +### Implementation direction + +Add public fallback modules under `src/pro_modules` and adjust `.gitignore` so these fallback files are tracked. + +Fallback behavior: + +- Remote config defaults to disabled paid features. +- R2 Buckets view remains usable for bucket listing and object listing with the public backend commands. +- Pro-only actions show disabled state or a clear message. +- Audit and query-history views render placeholder screens. + +## Requirement 4, keep changes small + +This first fork should stay close to upstream. + +Avoid: + +- Big design rewrites +- Deep R2 upload/download refactors +- New auth systems +- `cf` CLI integration +- Local Explorer integration + +## Future ideas + +Potential follow-up work: + +- R2 image hosting workflow, upload, compress, copy Markdown URL +- D1 local/remote diff +- D1 seed and backup helpers +- KV JSON search and editor +- Cloudflare API token permission checker +- Local Explorer API companion view + +## Verification + +Run: + +```bash +npm install +npm run build +npm run tauri dev +``` + +Expected result: + +- TypeScript build passes. +- Rust build passes or surfaces only environment-specific toolchain issues. +- App starts locally. +- Setup wizard sees nvm-provided Node/npm/Wrangler. +- Settings exposes language selection. diff --git a/src-tauri/src/cloudflare_auth.rs b/src-tauri/src/cloudflare_auth.rs index 61d55b3..6efcf89 100644 --- a/src-tauri/src/cloudflare_auth.rs +++ b/src-tauri/src/cloudflare_auth.rs @@ -9,6 +9,7 @@ use std::fs; use std::path::PathBuf; use crate::cloudflare_client::{CfError, CfResponse, CloudflareClient}; +use crate::shell_env::{login_shell, with_user_path}; // ── Error type ───────────────────────────────────────────────────────────────── @@ -268,8 +269,10 @@ pub async fn refresh_wrangler_token() -> Result (&'static str, &'static str) { - if cfg!(target_os = "macos") { - // macOS: use zsh with login flag to load ~/.zshrc / ~/.zprofile - ("zsh", "-l") - } else { - // Linux: honour $SHELL, fall back to bash - ("bash", "-l") - } -} - /// Returns `true` when the given binary is reachable on PATH. async fn is_available(bin: &str) -> bool { let cmd = if cfg!(target_os = "windows") { @@ -64,11 +53,7 @@ async fn is_available(bin: &str) -> bool { .await } else { let (shell, login_flag) = login_shell(); - // Prepend the user-local npm-global bin dir so we can discover - // binaries installed via our custom npm prefix without a shell restart. - let probe = format!( - "export PATH=\"$HOME/.npm-global/bin:$PATH\" && {bin} --version" - ); + let probe = probe_command(bin); Command::new(shell) .args([login_flag, "-c", &probe]) .stdout(std::process::Stdio::null()) @@ -101,8 +86,9 @@ async fn run_shell(command: &str) -> Result { .await? } else { let (shell, login_flag) = login_shell(); + let command = with_user_path(command); Command::new(shell) - .args([login_flag, "-c", command]) + .args([login_flag, "-c", &command]) .output() .await? }; diff --git a/src-tauri/src/shell_env.rs b/src-tauri/src/shell_env.rs new file mode 100644 index 0000000..a259566 --- /dev/null +++ b/src-tauri/src/shell_env.rs @@ -0,0 +1,26 @@ +pub fn login_shell() -> (&'static str, &'static str) { + if cfg!(target_os = "macos") { + ("zsh", "-l") + } else { + ("bash", "-l") + } +} + +pub fn user_path_prefix() -> &'static str { + r#"export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"; +if [ -s "$NVM_DIR/nvm.sh" ]; then . "$NVM_DIR/nvm.sh" >/dev/null 2>&1; fi; +for d in "$HOME"/.nvm/versions/node/*/bin "$HOME"/.npm-global/bin /opt/homebrew/bin /usr/local/bin; do + if [ -d "$d" ]; then PATH="$d:$PATH"; fi; +done; +export PATH"# +} + +pub fn with_user_path(command: &str) -> String { + format!("{}; {}", user_path_prefix(), command) +} + +pub fn probe_command(bin: &str) -> String { + with_user_path(&format!( + "command -v {bin} >/dev/null 2>&1 && {bin} --version >/dev/null 2>&1" + )) +} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index eded2f8..4930836 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -34,6 +34,7 @@ import { } from "@/store/useAppStore"; import { invokeCloudflare, useCloudflareAccounts } from "@/hooks/useCloudflare"; import { useRemoteConfig } from "@/pro_modules/frontend/useRemoteConfig"; +import { useI18n } from "@/lib/i18n"; import { AuditZoneProvider } from "@/pro_modules/frontend/AuditZoneContext"; import { SecurityPosture } from "@/pro_modules/ui/audits/SecurityPosture"; import { PerformancePosture } from "@/pro_modules/ui/audits/PerformancePosture"; @@ -57,52 +58,6 @@ interface NavItem { badge?: string; } -const NAV_GROUPS: NavGroup[] = [ - { - label: "Storage & Data", - items: [ - { - id: "r2", - label: "R2 Buckets", - icon: Box, - }, - { id: "d1", label: "Databases (D1)", icon: Database }, - { - id: "kv", - label: "KV Namespaces", - icon: KeyRound, - disabled: true, - badge: "Soon", - }, - ], - }, - { - label: "Compute", - items: [ - { - id: "vectorize", - label: "Vectorize", - icon: Activity, - disabled: true, - badge: "Soon", - }, - ], - }, - { - label: "System", - items: [ - { - id: "logs", - label: "Workers Logs", - icon: ScrollText, - disabled: true, - badge: "Soon", - }, - { id: "settings", label: "Settings", icon: Settings }, - ], - }, -]; - const THEME_OPTIONS: { value: Theme; icon: React.ElementType; @@ -388,6 +343,7 @@ function TitleBar({ collapsed, onToggle, title, onNavigate }: TitleBarProps) { // ── Simple page router ──────────────────────────────────────────────────────── function PageContent({ activeId, onNavigate }: { activeId: string; onNavigate: (id: string) => void }) { + const { t } = useI18n(); if (activeId === "d1") return ; if (activeId === "r2") return ; if (activeId === "settings") return ; @@ -401,15 +357,15 @@ function PageContent({ activeId, onNavigate }: { activeId: string; onNavigate: ( if (activeId.startsWith("audit")) { return (
-

Audit view coming soon

+

{t("common.auditComingSoon")}

); } // KV and Settings views will be added in subsequent steps return ( -
-

Coming soon

+
+

{t("common.comingSoon")}

); } @@ -417,6 +373,7 @@ function PageContent({ activeId, onNavigate }: { activeId: string; onNavigate: ( // ── Layout ───────────────────────────────────────────────────────────────────── export function Layout() { + const { t } = useI18n(); const [collapsed, setCollapsed] = useState(false); const [activeId, setActiveId] = useState("r2"); const userProfile = useAppStore((s) => s.userProfile); @@ -425,22 +382,62 @@ export function Layout() { const { data: config } = useRemoteConfig(); const navGroups = useMemo(() => { - const groups = [...NAV_GROUPS]; + const groups: NavGroup[] = [ + { + label: t("nav.storageData"), + items: [ + { id: "r2", label: t("nav.r2"), icon: Box }, + { id: "d1", label: t("nav.d1"), icon: Database }, + { + id: "kv", + label: t("nav.kv"), + icon: KeyRound, + disabled: true, + badge: t("common.soon"), + }, + ], + }, + { + label: t("nav.compute"), + items: [ + { + id: "vectorize", + label: t("nav.vectorize"), + icon: Activity, + disabled: true, + badge: t("common.soon"), + }, + ], + }, + { + label: t("nav.system"), + items: [ + { + id: "logs", + label: t("nav.workersLogs"), + icon: ScrollText, + disabled: true, + badge: t("common.soon"), + }, + { id: "settings", label: t("nav.settings"), icon: Settings }, + ], + }, + ]; if (config?.enable_audits) { groups.splice(1, 0, { - label: "Audit & Optimization", + label: t("nav.audit"), items: [ - { id: "audit", label: "Overview", icon: Globe }, - { id: "audit-scanner", label: "Domain Scanner", icon: ScanSearch }, - { id: "audit-security", label: "Security Posture", icon: Shield }, - { id: "audit-performance", label: "Performance", icon: Zap }, - { id: "audit-dns", label: "DNS & Email", icon: Mail }, - { id: "audit-preferences", label: "Preferences", icon: Settings }, + { id: "audit", label: t("nav.auditOverview"), icon: Globe }, + { id: "audit-scanner", label: t("nav.domainScanner"), icon: ScanSearch }, + { id: "audit-security", label: t("nav.securityPosture"), icon: Shield }, + { id: "audit-performance", label: t("nav.performance"), icon: Zap }, + { id: "audit-dns", label: t("nav.dnsEmail"), icon: Mail }, + { id: "audit-preferences", label: t("nav.preferences"), icon: Settings }, ], }); } return groups; - }, [config?.enable_audits]); + }, [config?.enable_audits, t]); useCloudflareAccounts(); diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index 6dd7955..a436cec 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -48,8 +48,11 @@ import { DialogDescription, DialogTrigger } from "@/components/ui/dialog"; +import { useI18n } from "@/lib/i18n"; +import type { AppLanguage } from "@/store/useAppStore"; export function SettingsView() { + const { language, setLanguage, t } = useI18n(); const { theme, setTheme } = useTheme(); const userProfile = useAppStore(s => s.userProfile); const cloudflareAccountId = useAppStore(s => s.cloudflareAccountId); @@ -153,38 +156,38 @@ export function SettingsView() {
-

Settings

-

Manage your application preferences and Cloudflare connection.

+

{t("settings.title")}

+

{t("settings.subtitle")}

- General + {t("settings.general")} - Appearance + {t("settings.appearance")} - D1 Database + {t("settings.d1")} - Privacy + {t("settings.privacy")} - Updates + {t("settings.updates")} {status === "available" && ( )} - About + {t("settings.about")} @@ -195,9 +198,9 @@ export function SettingsView() {
- Cloudflare Account + {t("settings.cloudflareAccount")}
- Configure how CF Studio connects to your Cloudflare infrastructure. + {t("settings.cloudflareAccountDesc")}
@@ -206,13 +209,13 @@ export function SettingsView() {
-

Wrangler Session

-

Connected via local CLI configuration

+

{t("settings.wranglerSession")}

+

{t("settings.wranglerSessionDesc")}

@@ -220,19 +223,19 @@ export function SettingsView() {
- +
- {activeAccount?.id || cloudflareAccountId || "Not available"} + {activeAccount?.id || cloudflareAccountId || t("settings.notAvailable")}
- +
- {userProfile?.email || "Fetching..."} + {userProfile?.email || t("settings.fetching")}
@@ -241,13 +244,27 @@ export function SettingsView() { - App Behavior + {t("settings.appBehavior")} - + +
+
+ +

{t("settings.languageDesc")}

+
+ +
- -

Download and install updates automatically on startup.

+ +

{t("settings.autoUpdatesDesc")}

@@ -258,15 +275,15 @@ export function SettingsView() {
- Danger Zone + {t("settings.dangerZone")}
- Actions that affect your session and local data. + {t("settings.dangerZoneDesc")}
-

Sign Out

-

Log out from Cloudflare and clear all local cache.

+

{t("settings.signOut")}

+

{t("settings.signOutDesc")}

diff --git a/src/components/SetupWizard.tsx b/src/components/SetupWizard.tsx index c87935f..bf68877 100644 --- a/src/components/SetupWizard.tsx +++ b/src/components/SetupWizard.tsx @@ -11,6 +11,7 @@ import { } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; +import { useI18n } from "@/lib/i18n"; // ── Types ────────────────────────────────────────────────────────────────────── @@ -33,6 +34,7 @@ interface SetupWizardProps { } export function SetupWizard({ children }: SetupWizardProps) { + const { t } = useI18n(); const [phase, setPhase] = useState("checking"); const [status, setStatus] = useState(null); const [progress, setProgress] = useState(0); @@ -117,14 +119,14 @@ export function SetupWizard({ children }: SetupWizardProps) {
-
-

- CF Studio Setup -

-

- Required tools for full functionality -

-
+
+

+ {t("setup.title")} +

+

+ {t("setup.subtitle")} +

+
{/* ── Phase: Checking ─────────────────────────────────────── */} @@ -136,7 +138,7 @@ export function SetupWizard({ children }: SetupWizardProps) { strokeWidth={2} />

- Checking installed dependencies… + {t("setup.checking")}

)} @@ -147,12 +149,12 @@ export function SetupWizard({ children }: SetupWizardProps) { {/* Dependency list */}
@@ -164,20 +166,11 @@ export function SetupWizard({ children }: SetupWizardProps) { onClick={handleInstall} > - Install Required Tools + {t("setup.install")}

- On macOS this requires{" "} - - Homebrew - - . On Windows it uses winget. + {t("setup.installNote")}

)} @@ -214,7 +207,7 @@ export function SetupWizard({ children }: SetupWizardProps) { strokeWidth={2} /> - This may take a few minutes — please don't close the app. + {t("setup.installingNote")}
@@ -225,7 +218,7 @@ export function SetupWizard({ children }: SetupWizardProps) {

- Installation failed + {t("setup.failed")}

{errorMsg} @@ -238,7 +231,7 @@ export function SetupWizard({ children }: SetupWizardProps) { onClick={handleInstall} > - Retry Installation + {t("setup.retry")}

)} diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts new file mode 100644 index 0000000..55f1269 --- /dev/null +++ b/src/lib/i18n.ts @@ -0,0 +1,135 @@ +import { useAppStore, type AppLanguage } from "@/store/useAppStore"; + +const translations = { + "en-US": { + "nav.storageData": "Storage & Data", + "nav.r2": "R2 Buckets", + "nav.d1": "Databases (D1)", + "nav.kv": "KV Namespaces", + "nav.compute": "Compute", + "nav.vectorize": "Vectorize", + "nav.system": "System", + "nav.workersLogs": "Workers Logs", + "nav.settings": "Settings", + "nav.audit": "Audit & Optimization", + "nav.auditOverview": "Overview", + "nav.domainScanner": "Domain Scanner", + "nav.securityPosture": "Security Posture", + "nav.performance": "Performance", + "nav.dnsEmail": "DNS & Email", + "nav.preferences": "Preferences", + "common.soon": "Soon", + "common.comingSoon": "Coming soon", + "common.auditComingSoon": "Audit view coming soon", + "setup.title": "CF Studio Setup", + "setup.subtitle": "Required tools for full functionality", + "setup.checking": "Checking installed dependencies...", + "setup.nodeNpm": "Node.js / npm", + "setup.wrangler": "Cloudflare Wrangler", + "setup.install": "Install Required Tools", + "setup.installNote": "On macOS this checks Homebrew, nvm, npm-global, and common shell paths. On Windows it uses winget.", + "setup.installingNote": "This may take a few minutes. Please keep the app open.", + "setup.failed": "Installation failed", + "setup.retry": "Retry Installation", + "settings.title": "Settings", + "settings.subtitle": "Manage your application preferences and Cloudflare connection.", + "settings.general": "General", + "settings.appearance": "Appearance", + "settings.d1": "D1 Database", + "settings.privacy": "Privacy", + "settings.updates": "Updates", + "settings.about": "About", + "settings.language": "Language", + "settings.languageDesc": "Choose the interface language for this local fork.", + "settings.english": "English", + "settings.chinese": "Simplified Chinese", + "settings.cloudflareAccount": "Cloudflare Account", + "settings.cloudflareAccountDesc": "Configure how CF Studio connects to your Cloudflare infrastructure.", + "settings.wranglerSession": "Wrangler Session", + "settings.wranglerSessionDesc": "Connected via local CLI configuration", + "settings.refreshToken": "Refresh Token", + "settings.accountId": "Account ID", + "settings.userEmail": "User Email", + "settings.notAvailable": "Not available", + "settings.fetching": "Fetching...", + "settings.appBehavior": "App Behavior", + "settings.autoUpdates": "Automatic Updates", + "settings.autoUpdatesDesc": "Download and install updates automatically on startup.", + "settings.dangerZone": "Danger Zone", + "settings.dangerZoneDesc": "Actions that affect your session and local data.", + "settings.signOut": "Sign Out", + "settings.signOutDesc": "Log out from Cloudflare and clear all local cache.", + }, + "zh-CN": { + "nav.storageData": "存储和数据", + "nav.r2": "R2 存储桶", + "nav.d1": "D1 数据库", + "nav.kv": "KV 命名空间", + "nav.compute": "计算", + "nav.vectorize": "Vectorize", + "nav.system": "系统", + "nav.workersLogs": "Workers 日志", + "nav.settings": "设置", + "nav.audit": "审计和优化", + "nav.auditOverview": "总览", + "nav.domainScanner": "域名扫描", + "nav.securityPosture": "安全状态", + "nav.performance": "性能", + "nav.dnsEmail": "DNS 和邮件", + "nav.preferences": "偏好设置", + "common.soon": "即将支持", + "common.comingSoon": "即将支持", + "common.auditComingSoon": "审计视图即将支持", + "setup.title": "CF Studio 设置", + "setup.subtitle": "检查完整功能所需的本地工具", + "setup.checking": "正在检查已安装的依赖...", + "setup.nodeNpm": "Node.js / npm", + "setup.wrangler": "Cloudflare Wrangler", + "setup.install": "安装所需工具", + "setup.installNote": "macOS 会检查 Homebrew、nvm、npm-global 和常见 shell 路径。Windows 使用 winget。", + "setup.installingNote": "这可能需要几分钟,请保持应用打开。", + "setup.failed": "安装失败", + "setup.retry": "重试安装", + "settings.title": "设置", + "settings.subtitle": "管理应用偏好和 Cloudflare 连接。", + "settings.general": "通用", + "settings.appearance": "外观", + "settings.d1": "D1 数据库", + "settings.privacy": "隐私", + "settings.updates": "更新", + "settings.about": "关于", + "settings.language": "语言", + "settings.languageDesc": "选择这个本地 fork 的界面语言。", + "settings.english": "英文", + "settings.chinese": "简体中文", + "settings.cloudflareAccount": "Cloudflare 账号", + "settings.cloudflareAccountDesc": "配置 CF Studio 如何连接你的 Cloudflare 基础设施。", + "settings.wranglerSession": "Wrangler 登录态", + "settings.wranglerSessionDesc": "通过本地 CLI 配置连接", + "settings.refreshToken": "刷新 Token", + "settings.accountId": "账号 ID", + "settings.userEmail": "用户邮箱", + "settings.notAvailable": "不可用", + "settings.fetching": "获取中...", + "settings.appBehavior": "应用行为", + "settings.autoUpdates": "自动更新", + "settings.autoUpdatesDesc": "启动时自动下载并安装更新。", + "settings.dangerZone": "危险操作", + "settings.dangerZoneDesc": "这些操作会影响你的登录态和本地数据。", + "settings.signOut": "退出登录", + "settings.signOutDesc": "退出 Cloudflare 并清空本地缓存。", + }, +} satisfies Record>; + +export type TranslationKey = keyof typeof translations["en-US"]; + +export function useI18n() { + const language = useAppStore((state) => state.language); + const setLanguage = useAppStore((state) => state.setLanguage); + + return { + language, + setLanguage, + t: (key: TranslationKey) => translations[language][key] ?? translations["en-US"][key] ?? key, + }; +} diff --git a/src/pro_modules b/src/pro_modules deleted file mode 160000 index 42131a1..0000000 --- a/src/pro_modules +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 42131a18ebb9d487ab462cc55f860aa425df70e7 diff --git a/src/pro_modules/frontend/AuditZoneContext.tsx b/src/pro_modules/frontend/AuditZoneContext.tsx new file mode 100644 index 0000000..2d36d34 --- /dev/null +++ b/src/pro_modules/frontend/AuditZoneContext.tsx @@ -0,0 +1,5 @@ +import type { ReactNode } from "react"; + +export function AuditZoneProvider({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/src/pro_modules/frontend/IndexManagerDialog.tsx b/src/pro_modules/frontend/IndexManagerDialog.tsx new file mode 100644 index 0000000..5baaaa4 --- /dev/null +++ b/src/pro_modules/frontend/IndexManagerDialog.tsx @@ -0,0 +1,16 @@ +import type { D1TableSchema } from "@/hooks/useCloudflare"; + +export function IndexManagerDialog({ + open, + onOpenChange, +}: { + databaseId: string; + open: boolean; + onOpenChange: (open: boolean) => void; + allTables: D1TableSchema[]; +}) { + if (open) { + queueMicrotask(() => onOpenChange(false)); + } + return null; +} diff --git a/src/pro_modules/frontend/ProFeatureGate.tsx b/src/pro_modules/frontend/ProFeatureGate.tsx new file mode 100644 index 0000000..e1373d9 --- /dev/null +++ b/src/pro_modules/frontend/ProFeatureGate.tsx @@ -0,0 +1,24 @@ +import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; + +export function ProFeatureGate({ + isOpen, + onClose, + featureName, +}: { + isOpen: boolean; + onClose: () => void; + featureName?: string; +}) { + return ( + !open && onClose()}> + + Feature unavailable + + {featureName ? `${featureName} is not included in this public fork yet.` : "This feature is not included in this public fork yet."} + + + + + ); +} diff --git a/src/pro_modules/frontend/PurchaseScreen.tsx b/src/pro_modules/frontend/PurchaseScreen.tsx new file mode 100644 index 0000000..f20d0ec --- /dev/null +++ b/src/pro_modules/frontend/PurchaseScreen.tsx @@ -0,0 +1,16 @@ +import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; + +export function PurchaseScreen({ onClose }: { onClose: () => void }) { + return ( + !open && onClose()}> + + Feature unavailable + + This public fork ships without the original private Pro module. Add your own implementation here when the workflow is worth keeping. + + + + + ); +} diff --git a/src/pro_modules/frontend/R2BucketsView.tsx b/src/pro_modules/frontend/R2BucketsView.tsx new file mode 100644 index 0000000..d5cb07d --- /dev/null +++ b/src/pro_modules/frontend/R2BucketsView.tsx @@ -0,0 +1,178 @@ +import { useEffect, useState } from "react"; +import { Box, File, Folder, RefreshCw, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { useR2Buckets } from "@/hooks/useCloudflare"; +import { deleteR2Object, listR2Objects, type FolderListing, type R2Bucket } from "@/lib/r2"; +import { cn, formatBytes } from "@/lib/utils"; + +function BucketRow({ + bucket, + active, + onClick, +}: { + bucket: R2Bucket; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +export function R2BucketsView() { + const { state, refresh } = useR2Buckets(); + const [selectedBucket, setSelectedBucket] = useState(null); + const [prefix, setPrefix] = useState(""); + const [listing, setListing] = useState(null); + const [objectsState, setObjectsState] = useState<"idle" | "loading" | "error">("idle"); + const [error, setError] = useState(null); + + const buckets = state.status === "success" ? state.data : []; + + useEffect(() => { + if (!selectedBucket && buckets.length > 0) { + setSelectedBucket(buckets[0]); + } + }, [buckets, selectedBucket]); + + useEffect(() => { + if (!selectedBucket) return; + + let cancelled = false; + setObjectsState("loading"); + setError(null); + + listR2Objects(selectedBucket.name, prefix) + .then((nextListing) => { + if (cancelled) return; + setListing(nextListing); + setObjectsState("idle"); + }) + .catch((err) => { + if (cancelled) return; + setError(String(err)); + setObjectsState("error"); + }); + + return () => { + cancelled = true; + }; + }, [selectedBucket, prefix]); + + const goUp = () => { + const trimmed = prefix.replace(/\/$/, ""); + const parent = trimmed.includes("/") ? `${trimmed.slice(0, trimmed.lastIndexOf("/") + 1)}` : ""; + setPrefix(parent); + }; + + const handleDeleteObject = async (key: string) => { + if (!selectedBucket) return; + await deleteR2Object(selectedBucket.name, key); + const nextListing = await listR2Objects(selectedBucket.name, prefix); + setListing(nextListing); + }; + + return ( +
+
+
+

R2 Buckets

+

+ Public fallback view for listing buckets and objects in this fork. +

+
+ +
+ +
+ + +
+
+
+

+ {selectedBucket?.name ?? "Select a bucket"} +

+

/{prefix}

+
+ +
+ + {objectsState === "loading" &&

Loading objects...

} + {objectsState === "error" &&

{error}

} + {objectsState === "idle" && selectedBucket && listing && ( +
+ {listing.folders.map((folder) => ( + + ))} + {listing.files.map((file) => ( +
+
+ +
+

{file.key.replace(prefix, "")}

+

{formatBytes(file.size)}

+
+
+ +
+ ))} + {listing.folders.length === 0 && listing.files.length === 0 && ( +

No objects in this prefix.

+ )} +
+ )} +
+
+
+ ); +} diff --git a/src/pro_modules/frontend/useRemoteConfig.ts b/src/pro_modules/frontend/useRemoteConfig.ts new file mode 100644 index 0000000..9bfbff1 --- /dev/null +++ b/src/pro_modules/frontend/useRemoteConfig.ts @@ -0,0 +1,29 @@ +export interface RemoteConfig { + is_export_free?: boolean; + current_version?: string; + enable_audits?: boolean; + max_r2_upload_size?: number; + enable_r2_bucket_settings?: boolean; + enable_r2_upload?: boolean; + enable_r2_bucket_mgmt?: boolean; + enable_d1_index_management?: boolean; + enable_d1_query_history?: boolean; +} + +const PUBLIC_FALLBACK_CONFIG: RemoteConfig = { + is_export_free: false, + enable_audits: false, + enable_r2_bucket_settings: false, + enable_r2_upload: false, + enable_r2_bucket_mgmt: false, + enable_d1_index_management: false, + enable_d1_query_history: false, +}; + +export function useRemoteConfig() { + return { + data: PUBLIC_FALLBACK_CONFIG, + isLoading: false, + error: null, + }; +} diff --git a/src/pro_modules/hooks/useD1TrackerLogic.ts b/src/pro_modules/hooks/useD1TrackerLogic.ts new file mode 100644 index 0000000..ca4ff97 --- /dev/null +++ b/src/pro_modules/hooks/useD1TrackerLogic.ts @@ -0,0 +1,9 @@ +import type { D1QueryResult } from "@/hooks/useCloudflare"; + +export async function executeTrackedQueryWithLogic( + _options: unknown, + executeNetworkCall: () => Promise, + _context?: unknown, +): Promise { + return executeNetworkCall(); +} diff --git a/src/pro_modules/rust/domain_audit.rs b/src/pro_modules/rust/domain_audit.rs new file mode 100644 index 0000000..ce9acd0 --- /dev/null +++ b/src/pro_modules/rust/domain_audit.rs @@ -0,0 +1,44 @@ +use serde_json::Value; + +fn unavailable() -> String { + "Domain audit is not included in this public fork yet.".to_string() +} + +#[tauri::command] +pub async fn list_cf_zones() -> Result { Err(unavailable()) } + +#[tauri::command] +pub async fn get_zone_security_settings() -> Result { Err(unavailable()) } + +#[tauri::command] +pub async fn update_zone_setting() -> Result { Err(unavailable()) } + +#[tauri::command] +pub async fn validate_zone_token() -> Result { Err(unavailable()) } + +#[tauri::command] +pub async fn verify_global_token() -> Result { Err(unavailable()) } + +#[tauri::command] +pub async fn save_zone_token() -> Result<(), String> { Err(unavailable()) } + +#[tauri::command] +pub async fn delete_zone_token() -> Result<(), String> { Ok(()) } + +#[tauri::command] +pub async fn has_zone_token() -> Result { Ok(false) } + +#[tauri::command] +pub async fn get_zone_performance_settings() -> Result { Err(unavailable()) } + +#[tauri::command] +pub async fn get_zone_dns_health() -> Result { Err(unavailable()) } + +#[tauri::command] +pub async fn add_dns_record() -> Result { Err(unavailable()) } + +#[tauri::command] +pub async fn check_active_token() -> Result { Err(unavailable()) } + +#[tauri::command] +pub async fn analyze_domain() -> Result { Err(unavailable()) } diff --git a/src/pro_modules/rust/history.rs b/src/pro_modules/rust/history.rs new file mode 100644 index 0000000..d92fb43 --- /dev/null +++ b/src/pro_modules/rust/history.rs @@ -0,0 +1,30 @@ +use serde_json::Value; + +fn unavailable() -> String { + "Query history is not included in this public fork yet.".to_string() +} + +#[tauri::command] +pub async fn save_query_history() -> Result { + Err(unavailable()) +} + +#[tauri::command] +pub async fn get_paginated_history() -> Result { + Err(unavailable()) +} + +#[tauri::command] +pub async fn get_global_stats() -> Result { + Err(unavailable()) +} + +#[tauri::command] +pub async fn clear_query_history() -> Result<(), String> { + Err(unavailable()) +} + +#[tauri::command] +pub async fn get_history_debug_status() -> Result { + Err(unavailable()) +} diff --git a/src/pro_modules/rust/r2_pro.rs b/src/pro_modules/rust/r2_pro.rs new file mode 100644 index 0000000..73e755d --- /dev/null +++ b/src/pro_modules/rust/r2_pro.rs @@ -0,0 +1,84 @@ +use serde_json::{json, Value}; + +fn unavailable() -> String { + "This R2 action is not included in this public fork yet.".to_string() +} + +#[tauri::command] +pub async fn fetch_cloudflare_zones() -> Result, String> { + Ok(Vec::new()) +} + +#[tauri::command] +pub async fn create_r2_bucket(_bucket_name: String) -> Result<(), String> { + Err(unavailable()) +} + +#[tauri::command] +pub async fn delete_r2_bucket(_bucket_name: String) -> Result<(), String> { + Err(unavailable()) +} + +#[tauri::command] +pub async fn empty_r2_bucket(_bucket_name: String) -> Result<(), String> { + Err(unavailable()) +} + +#[tauri::command] +pub async fn upload_r2_object( + _bucket_name: String, + _key: String, + _local_path: String, + _upload_id: String, +) -> Result<(), String> { + Err(unavailable()) +} + +#[tauri::command] +pub async fn cancel_upload_r2_object( + _upload_id: String, + _bucket_name: String, + _key: String, +) -> Result<(), String> { + Ok(()) +} + +#[tauri::command] +pub async fn download_r2_object( + _bucket_name: String, + _key: String, + _destination_path: String, +) -> Result<(), String> { + Err(unavailable()) +} + +#[tauri::command] +pub async fn update_r2_bucket_managed_domain( + _bucket_name: String, + _enabled: bool, +) -> Result<(), String> { + Err(unavailable()) +} + +#[tauri::command] +pub async fn add_r2_bucket_custom_domain( + _bucket_name: String, + _domain: String, + _zone_id: String, + _zone_name: String, +) -> Result<(), String> { + Err(unavailable()) +} + +#[tauri::command] +pub async fn remove_r2_bucket_custom_domain( + _bucket_name: String, + _domain: String, +) -> Result<(), String> { + Err(unavailable()) +} + +#[tauri::command] +pub async fn get_r2_bucket_domains_list(_bucket_name: String) -> Result { + Ok(json!({ "managed": null, "custom": [] })) +} diff --git a/src/pro_modules/rust/r2_worker_proxy.rs b/src/pro_modules/rust/r2_worker_proxy.rs new file mode 100644 index 0000000..24813a2 --- /dev/null +++ b/src/pro_modules/rust/r2_worker_proxy.rs @@ -0,0 +1,2 @@ +// Public fork placeholder. The original project keeps private R2 proxy helpers +// under src/pro_modules. This file keeps the public Tauri build self-contained. diff --git a/src/pro_modules/ui/ActivityDashboard.tsx b/src/pro_modules/ui/ActivityDashboard.tsx new file mode 100644 index 0000000..c3c1da8 --- /dev/null +++ b/src/pro_modules/ui/ActivityDashboard.tsx @@ -0,0 +1,7 @@ +export function ActivityDashboard() { + return ( +
+ Query history is not included in this public fork yet. +
+ ); +} diff --git a/src/pro_modules/ui/audits/AuditPreferences.tsx b/src/pro_modules/ui/audits/AuditPreferences.tsx new file mode 100644 index 0000000..bc93100 --- /dev/null +++ b/src/pro_modules/ui/audits/AuditPreferences.tsx @@ -0,0 +1,3 @@ +export function AuditPreferences() { + return
Audit preferences are not included in this public fork yet.
; +} diff --git a/src/pro_modules/ui/audits/DnsEmailPosture.tsx b/src/pro_modules/ui/audits/DnsEmailPosture.tsx new file mode 100644 index 0000000..f7bba9d --- /dev/null +++ b/src/pro_modules/ui/audits/DnsEmailPosture.tsx @@ -0,0 +1,3 @@ +export function DnsEmailPosture() { + return
DNS and email audit is not included in this public fork yet.
; +} diff --git a/src/pro_modules/ui/audits/DomainScanner.tsx b/src/pro_modules/ui/audits/DomainScanner.tsx new file mode 100644 index 0000000..363103b --- /dev/null +++ b/src/pro_modules/ui/audits/DomainScanner.tsx @@ -0,0 +1,3 @@ +export function DomainScanner(_props: { onNavigate?: (id: string) => void }) { + return
Domain scanner is not included in this public fork yet.
; +} diff --git a/src/pro_modules/ui/audits/Overview.tsx b/src/pro_modules/ui/audits/Overview.tsx new file mode 100644 index 0000000..613a179 --- /dev/null +++ b/src/pro_modules/ui/audits/Overview.tsx @@ -0,0 +1,3 @@ +export function Overview(_props: { onNavigate?: (id: string) => void }) { + return
Audit overview is not included in this public fork yet.
; +} diff --git a/src/pro_modules/ui/audits/PerformancePosture.tsx b/src/pro_modules/ui/audits/PerformancePosture.tsx new file mode 100644 index 0000000..6cc4877 --- /dev/null +++ b/src/pro_modules/ui/audits/PerformancePosture.tsx @@ -0,0 +1,3 @@ +export function PerformancePosture() { + return
Performance audit is not included in this public fork yet.
; +} diff --git a/src/pro_modules/ui/audits/SecurityPosture.tsx b/src/pro_modules/ui/audits/SecurityPosture.tsx new file mode 100644 index 0000000..85f950d --- /dev/null +++ b/src/pro_modules/ui/audits/SecurityPosture.tsx @@ -0,0 +1,3 @@ +export function SecurityPosture() { + return
Security posture audit is not included in this public fork yet.
; +} diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index d59f208..e99a5b4 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -30,6 +30,8 @@ export interface PrivacySettings { blurAmount: number; } +export type AppLanguage = "en-US" | "zh-CN"; + // ── KV placeholder type (populated in a future step) ───────────────────────── export interface KVNamespace { @@ -66,6 +68,7 @@ interface AppState { autoUpdate: boolean; isRefreshingSession: boolean; privacySettings: PrivacySettings; + language: AppLanguage; saveQueryResultsEnabled: boolean; saveQueryResultsRowLimit: number | null; @@ -104,6 +107,7 @@ interface AppState { setShowTableColumnCounts: (show: boolean) => void; setAutoUpdate: (enabled: boolean) => void; setPrivacySettings: (settings: Partial) => void; + setLanguage: (language: AppLanguage) => void; setSaveQueryResultsEnabled: (enabled: boolean) => void; setSaveQueryResultsRowLimit: (limit: number | null) => void; setSessionId: (id: string) => void; @@ -162,6 +166,7 @@ export const useAppStore = create()( r2FileNames: true, blurAmount: 5, }, + language: "en-US", saveQueryResultsEnabled: false, saveQueryResultsRowLimit: 50, updateStatus: "idle", @@ -189,6 +194,7 @@ export const useAppStore = create()( setShowTableColumnCounts: (show) => set({ showTableColumnCounts: show }), setAutoUpdate: (enabled) => set({ autoUpdate: enabled }), setPrivacySettings: (settings) => set((s) => ({ privacySettings: { ...s.privacySettings, ...settings } })), + setLanguage: (language) => set({ language }), setSaveQueryResultsEnabled: (enabled) => set({ saveQueryResultsEnabled: enabled }), setSaveQueryResultsRowLimit: (limit) => set({ saveQueryResultsRowLimit: limit }), setSessionId: (id) => set({ sessionId: id }), @@ -281,6 +287,7 @@ export const useAppStore = create()( tableDensity: state.tableDensity, autoUpdate: state.autoUpdate, privacySettings: state.privacySettings, + language: state.language, saveQueryResultsEnabled: state.saveQueryResultsEnabled, saveQueryResultsRowLimit: state.saveQueryResultsRowLimit, databases: state.databases, From 32cfc7f0527312c2d1d9ad8d228215c6c3e2dad0 Mon Sep 17 00:00:00 2001 From: jackiexiao <707610215@qq.com> Date: Mon, 25 May 2026 14:21:31 +0800 Subject: [PATCH 2/4] fix: prefer Cloudflare API token env --- src-tauri/src/cloudflare_auth.rs | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/cloudflare_auth.rs b/src-tauri/src/cloudflare_auth.rs index 6efcf89..f0dc1ea 100644 --- a/src-tauri/src/cloudflare_auth.rs +++ b/src-tauri/src/cloudflare_auth.rs @@ -1,8 +1,8 @@ // cloudflare_auth.rs // // Reads the local Wrangler OAuth config to provide zero-touch authentication. -// No API tokens are ever stored in CF Studio — we reuse the session that -// `wrangler login` already created on the user's machine. +// When the user already exports CLOUDFLARE_API_TOKEN for Wrangler, use that +// in-memory value first. No API tokens are stored by CF Studio. use serde::{Deserialize, Serialize}; use std::fs; @@ -27,7 +27,7 @@ pub enum AuthError { #[error("Failed to parse Wrangler config TOML: {0}")] TomlParse(#[from] toml::de::Error), - #[error("No oauth_token found in Wrangler config. Run `wrangler login` first.")] + #[error("No oauth_token found in Wrangler config and CLOUDFLARE_API_TOKEN is not set. Run `wrangler login` first.")] NoToken, #[error("Command execution failed: {0}")] @@ -169,8 +169,22 @@ pub fn wrangler_config_path() -> Result { // ── Core parsing logic ───────────────────────────────────────────────────────── +fn env_api_token() -> Option { + std::env::var("CLOUDFLARE_API_TOKEN") + .ok() + .map(|token| token.trim().to_string()) + .filter(|token| !token.is_empty()) +} + /// Reads and parses the Wrangler config, returning the extracted credentials. pub fn read_credentials() -> Result { + if let Some(api_token) = env_api_token() { + return Ok(CloudflareCredentials { + oauth_token: api_token, + account_id: std::env::var("CLOUDFLARE_ACCOUNT_ID").ok(), + }); + } + let candidates = wrangler_candidate_paths(); if candidates.is_empty() { return Err(AuthError::ConfigDirNotFound); @@ -263,6 +277,10 @@ pub fn read_credentials() -> Result { /// re-read the configuration file and return the fresh token. #[tauri::command] pub async fn refresh_wrangler_token() -> Result { + if env_api_token().is_some() { + return read_credentials(); + } + let output = tokio::task::spawn_blocking(|| { let mut cmd = if cfg!(target_os = "windows") { let mut c = std::process::Command::new("cmd"); From cd52e10a1a93b5b2767ac2123bea58ce92bc39b5 Mon Sep 17 00:00:00 2001 From: jackiexiao <707610215@qq.com> Date: Mon, 25 May 2026 14:46:44 +0800 Subject: [PATCH 3/4] feat: expand Chinese localization --- .gitignore | 1 + README.md | 2 + README.zh-CN.md | 92 ++++ src/components/DatabaseExplorer.tsx | 111 ++--- src/components/DatabasesView.tsx | 59 +-- src/components/EditColumnDialog.tsx | 151 +++--- src/components/ExportWrapper.tsx | 4 +- src/components/FreeExportDialog.tsx | 8 +- src/components/IntelligencePanel.tsx | 20 +- src/components/Layout.tsx | 44 +- src/components/QueryEditor.tsx | 80 ++-- src/components/R2ProGate.tsx | 32 +- src/components/SchemaVisualizer.tsx | 9 +- src/lib/i18n.ts | 480 +++++++++++++++++++- src/pro_modules/frontend/ProFeatureGate.tsx | 8 +- src/pro_modules/frontend/PurchaseScreen.tsx | 8 +- src/pro_modules/frontend/R2BucketsView.tsx | 18 +- 17 files changed, 882 insertions(+), 245 deletions(-) create mode 100644 README.zh-CN.md diff --git a/.gitignore b/.gitignore index 760ce74..9c262ad 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ dist-ssr others/ src-tauri/.wrangler .env +LOCAL_DEV.md diff --git a/README.md b/README.md index af8a00a..6478efa 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # CF Studio +中文说明见 [README.zh-CN.md](README.zh-CN.md). + A blazing-fast, native desktop client for Cloudflare D1 and R2. [Website](https://cfstudio.dev) • [Portfolio](https://mubashar.dev) • [YouTube](https://youtube.com/@mubashardev) diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..3f56cc2 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,92 @@ +# CF Studio MakerJackie Fork + +这是 `makerjackie/cf-studio` 的中文说明。这个 fork 基于 `mubashardev/cf-studio`,目标是评估它是否适合作为本地 Cloudflare 管理器的基础。 + +## 这个 fork 改了什么 + +- 修复 macOS GUI 启动时检测不到 nvm 中 `node` / `wrangler` 的问题。 +- 增加基础中英文界面切换。 +- D1 / R2 / KV 主要页面已接入中文文案。 +- 优先读取 `CLOUDFLARE_API_TOKEN` 环境变量,再回退到 Wrangler OAuth 配置。 +- 用公开 fallback 替换上游私有 `src/pro_modules` submodule,让仓库可以直接 clone、安装、构建。 + +## 功能范围 + +当前公开 fork 适合用来评估这些工作流: + +- D1 数据库列表 +- D1 表结构查看 +- D1 表数据浏览 +- D1 SQL 查询编辑器 +- D1 可视化结构图 +- R2 存储桶和对象列表 +- KV 占位页 + +注意:这个 fork 不包含上游私有 Pro 模块。高级导出、完整 R2 上传下载、审计能力等功能需要单独实现或继续接入。 + +## 本地开发 + +准备环境: + +```bash +brew install bun +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +npm install -g wrangler +wrangler login +``` + +启动: + +```bash +git clone git@github.com:makerjackie/cf-studio.git +cd cf-studio +export PATH="$HOME/.bun/bin:$PATH" +source "$HOME/.cargo/env" +bun install --frozen-lockfile +bun run tauri dev +``` + +如果你使用 API Token,而不是 `wrangler login`: + +```bash +export CLOUDFLARE_API_TOKEN="your-token" +export CLOUDFLARE_ACCOUNT_ID="your-account-id" +bun run tauri dev +``` + +## 构建本机 App + +```bash +export PATH="$HOME/.bun/bin:$PATH" +source "$HOME/.cargo/env" +bun run tauri build +``` + +macOS app 通常会生成在: + +```bash +src-tauri/target/release/bundle/macos/CF-Studio.app +``` + +安装到 `/Applications`: + +```bash +cp -R "src-tauri/target/release/bundle/macos/CF-Studio.app" /Applications/ +``` + +如果 macOS 阻止打开本地构建版本,可以移除 quarantine 标记: + +```bash +xattr -dr com.apple.quarantine /Applications/CF-Studio.app +``` + +## 当前判断 + +这个 fork 更适合作为“Cloudflare Dashboard / Wrangler / cf CLI 的本地伴侣”,而不是完整替代官网。短期更值得投入的方向是: + +- 让 D1 表格浏览和 SQL 编辑更稳定。 +- 补齐 R2 上传、下载、预览、复制公开 URL。 +- 做 Cloudflare Token 权限检测。 +- 增加本地 Wrangler / Local Explorer / 远程资源对比。 +- 把中文和英文文案整理成更完整的 i18n 结构。 + diff --git a/src/components/DatabaseExplorer.tsx b/src/components/DatabaseExplorer.tsx index 2bc15ea..92e0396 100644 --- a/src/components/DatabaseExplorer.tsx +++ b/src/components/DatabaseExplorer.tsx @@ -49,6 +49,7 @@ import { } from "@/components/ui/tooltip"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; +import { useI18n } from "@/lib/i18n"; import { useD1Schema, useD1TableData, @@ -132,6 +133,7 @@ function TableListSkeleton() { // ── Schema tab content ──────────────────────────────────────────────────────── function SchemaTab({ table }: { table: D1TableSchema }) { + const { t } = useI18n(); const privacySettings = useAppStore(s => s.privacySettings); const blurTable = privacySettings.enabled && privacySettings.tableNames; @@ -139,8 +141,8 @@ function SchemaTab({ table }: { table: D1TableSchema }) { return ( ); } @@ -191,6 +193,7 @@ interface DataTabProps { } function DataTab({ databaseId, table, allTables, onTableSelect }: DataTabProps) { + const { t } = useI18n(); const privacySettings = useAppStore(s => s.privacySettings); const blurTable = privacySettings.enabled && privacySettings.tableNames; const [offset, setOffset] = useState(0); @@ -276,11 +279,11 @@ function DataTab({ databaseId, table, allTables, onTableSelect }: DataTabProps) variant="secondary" className="h-5 px-1.5 text-[10px] font-mono bg-muted/40 hover:bg-muted/60 transition-colors cursor-help border-transparent" > - {table.columnsCount ?? 0} {table.columnsCount === 1 ? "col" : "cols"} + {table.columnsCount ?? 0} {t((table.columnsCount ?? 0) === 1 ? "d1.colShort" : "d1.colsShort")} - Total {table.columnsCount ?? 0} columns exist in {table.name} + {t("d1.totalColumnsTooltip", { count: table.columnsCount ?? 0, table: table.name })} @@ -290,11 +293,11 @@ function DataTab({ databaseId, table, allTables, onTableSelect }: DataTabProps) variant="secondary" className="h-5 px-1.5 text-[10px] font-mono bg-primary/10 hover:bg-primary/20 text-primary border-primary/20 transition-colors cursor-help" > - {state.data.rows.length} row{state.data.rows.length !== 1 ? "s" : ""} + {t(state.data.rows.length === 1 ? "common.rowsSingular" : "common.rows", { count: state.data.rows.length })} - {state.data.rows.length} rows fetched + {t("d1.rowsFetchedTooltip", { count: state.data.rows.length })}
@@ -310,8 +313,8 @@ function DataTab({ databaseId, table, allTables, onTableSelect }: DataTabProps) > {selectedRowIndices.length === 0 || (state.status === "success" && selectedRowIndices.length === state.data.rows.length) - ? "Export All" - : `Export ${selectedRowIndices.length} Rows`} + ? t("d1.exportAll") + : t("d1.exportRows", { count: selectedRowIndices.length })} @@ -325,7 +328,7 @@ function DataTab({ databaseId, table, allTables, onTableSelect }: DataTabProps) {state.status === "error" && ( @@ -334,8 +337,8 @@ function DataTab({ databaseId, table, allTables, onTableSelect }: DataTabProps) {state.status === "success" && state.data.rows.length === 0 && ( )} @@ -362,7 +365,7 @@ function DataTab({ databaseId, table, allTables, onTableSelect }: DataTabProps) setSelectedRowIndices([]); } }} - aria-label="Select all rows" + aria-label={t("common.selectAllRows")} /> {/* Row number gutter */} @@ -406,7 +409,7 @@ function DataTab({ databaseId, table, allTables, onTableSelect }: DataTabProps) onPointerDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} > -

Primary Key

+

{t("d1.primaryKey")}

@@ -432,7 +435,7 @@ function DataTab({ databaseId, table, allTables, onTableSelect }: DataTabProps) onClick={(e) => e.stopPropagation()} >
- {outFks.length > 0 &&

References

} + {outFks.length > 0 &&

{t("d1.references")}

} {outFks.map((fk, idx) => (
{table.name}.{col.name} @@ -445,7 +448,7 @@ function DataTab({ databaseId, table, allTables, onTableSelect }: DataTabProps)
))} - {inFks.length > 0 &&

Referenced By

} + {inFks.length > 0 &&

{t("d1.referencedBy")}

} {inFks.map((fk, idx) => (
@@ -604,14 +607,14 @@ function DataTab({ databaseId, table, allTables, onTableSelect }: DataTabProps) className={cn("cursor-pointer", pageSize >= 1000000 && "bg-accent text-accent-foreground")} onClick={() => { setPageSize(1000000); setOffset(0); }} > - All + {t("common.all")} setIsCustomLimitOpen(true)} > - Custom... + {t("common.custom")} @@ -619,14 +622,14 @@ function DataTab({ databaseId, table, allTables, onTableSelect }: DataTabProps)
- Page {page} + {t("common.page", { page })}
@@ -635,7 +638,7 @@ function DataTab({ databaseId, table, allTables, onTableSelect }: DataTabProps) className="h-6 w-6" disabled={!hasNext} onClick={() => setOffset(offset + pageSize)} - aria-label="Next page" + aria-label={t("common.nextPage")} > @@ -647,10 +650,10 @@ function DataTab({ databaseId, table, allTables, onTableSelect }: DataTabProps) {/* Custom page size dialog */} - Custom Rows Per Page + {t("d1.customRowsPerPage")}
- +
- Apply Limit + {t("d1.applyLimit")}
@@ -704,6 +707,7 @@ function TableListItem({ active: boolean; onClick: () => void; }) { + const { t } = useI18n(); const privacySettings = useAppStore((s) => s.privacySettings); const showTableColumnCounts = useAppStore((s) => s.showTableColumnCounts); const blurTable = privacySettings.enabled && privacySettings.tableNames; @@ -732,7 +736,7 @@ function TableListItem({ variant="secondary" className="px-1.5 py-0 h-4 text-[9px] font-mono shrink-0 bg-muted/40 text-muted-foreground/40 group-hover:bg-muted group-hover:text-muted-foreground transition-colors" > - {table.columnsCount} {table.columnsCount === 1 ? "col" : "cols"} + {table.columnsCount} {t(table.columnsCount === 1 ? "d1.colShort" : "d1.colsShort")} )} {active && } @@ -748,6 +752,7 @@ interface DatabaseExplorerProps { } export function DatabaseExplorer({ database, onBack }: DatabaseExplorerProps) { + const { t } = useI18n(); const [selectedTable, setSelectedTable] = useState(null); const [systemOpen, setSystemOpen] = useState(false); const [isVisualSchemaOpen, setIsVisualSchemaOpen] = useState(false); @@ -793,7 +798,7 @@ export function DatabaseExplorer({ database, onBack }: DatabaseExplorerProps) { className="gap-1.5 text-muted-foreground hover:text-foreground -ml-2" > - Databases + {t("d1.back")} @@ -812,7 +817,7 @@ export function DatabaseExplorer({ database, onBack }: DatabaseExplorerProps) { onClick={() => setIsQueryEditorOpen(true)} > - SQL Editor + {t("d1.sqlEditor")} setIsVisualSchemaOpen(true)} > - Visual Schema + {t("d1.visualSchema")} - Indexes + {t("d1.indexes")} {configData?.enable_d1_index_management === false && ( - PRO + {t("common.pro")} )} @@ -846,7 +851,7 @@ export function DatabaseExplorer({ database, onBack }: DatabaseExplorerProps) { @@ -859,7 +864,7 @@ export function DatabaseExplorer({ database, onBack }: DatabaseExplorerProps) {
- Tables + {t("d1.tables")} {state.status === "success" && ( {userTables.length} @@ -877,7 +882,7 @@ export function DatabaseExplorer({ database, onBack }: DatabaseExplorerProps) { )} {state.status === "success" && userTables.length === 0 && sysTables.length === 0 && ( -

No tables found

+

{t("d1.noTables")}

)} {/* User tables */} @@ -906,7 +911,7 @@ export function DatabaseExplorer({ database, onBack }: DatabaseExplorerProps) { strokeWidth={2} className={cn("transition-transform", systemOpen && "rotate-90")} /> - System ({sysTables.length}) + {t("d1.systemTables", { count: sysTables.length })} {systemOpen && sysTables.map((table) => ( {([ - { value: "data", Icon: Sheet, label: "Data" }, - { value: "schema", Icon: Code2, label: "Schema" }, + { value: "data", Icon: Sheet, label: t("d1.tab.data") }, + { value: "schema", Icon: Code2, label: t("d1.tab.schema") }, ] as const).map(({ value, Icon, label }) => ( - : } + : } {/* Schema — requires a selected table */} {selectedTable ? - : } + : }
@@ -975,10 +980,10 @@ export function DatabaseExplorer({ database, onBack }: DatabaseExplorerProps) { - Visual Schema + {t("d1.visualSchema")}
- Visual Schema — {database.name} + {t("d1.visualSchema")} — {database.name}
@@ -988,10 +993,10 @@ export function DatabaseExplorer({ database, onBack }: DatabaseExplorerProps) { - SQL Editor + {t("d1.sqlEditor")}
- SQL Editor — {database.name} + {t("d1.sqlEditor")} — {database.name}
diff --git a/src/components/DatabasesView.tsx b/src/components/DatabasesView.tsx index 7fc3ba1..3efbdb1 100644 --- a/src/components/DatabasesView.tsx +++ b/src/components/DatabasesView.tsx @@ -23,6 +23,7 @@ import { DatabaseExplorer } from "@/components/DatabaseExplorer"; import { WebviewWindow } from "@tauri-apps/api/webviewWindow"; import { useToast } from "@/components/ui/use-toast"; import { ProFeatureGate } from "@/pro_modules/frontend/ProFeatureGate"; +import { useI18n } from "@/lib/i18n"; // ── Helpers ──────────────────────────────────────────────────────────────────── @@ -79,12 +80,13 @@ interface EmptyStateProps { function EmptyState({ variant, message, onRefresh, accountId }: EmptyStateProps) { const { toast } = useToast(); + const { t } = useI18n(); const handleCopyCommand = async () => { try { await navigator.clipboard.writeText("npx wrangler login"); toast({ - title: "Copied", - description: "Login command copied to clipboard.", + title: t("common.copied"), + description: t("d1.toast.loginCopied"), }); } catch (e) { console.error(e); @@ -93,14 +95,14 @@ function EmptyState({ variant, message, onRefresh, accountId }: EmptyStateProps) const handleRunCommand = async () => { toast({ - title: "Opening Terminal", - description: "Launching wrangler login in a new terminal window...", + title: t("d1.empty.openingTerminal"), + description: t("d1.empty.openingTerminalDesc"), }); try { await invokeCloudflare("run_wrangler_login"); } catch (e) { toast({ - title: "Launch Failed", + title: t("d1.empty.launchFailed"), description: String(e), variant: "destructive", }); @@ -112,21 +114,19 @@ function EmptyState({ variant, message, onRefresh, accountId }: EmptyStateProps) "no-auth": { icon: Terminal, iconColor: "text-amber-400", - title: "Wrangler session not found", + title: t("d1.empty.noAuthTitle"), body: ( <> - CF Studio reads your local Wrangler session for zero-touch auth. - Run the command below in your terminal or click the button below. - The page will automatically refresh once you are logged in. + {t("d1.empty.noAuthBody")}
npx wrangler login -
{message && (

@@ -139,10 +139,10 @@ function EmptyState({ variant, message, onRefresh, accountId }: EmptyStateProps) "no-databases": { icon: Database, iconColor: "text-muted-foreground", - title: "No D1 databases found", + title: t("d1.empty.noDatabasesTitle"), body: ( <> - This Cloudflare account has no D1 databases yet. Create one with: + {t("d1.empty.noDatabasesBody")}

wrangler d1 create my-database
@@ -152,10 +152,10 @@ function EmptyState({ variant, message, onRefresh, accountId }: EmptyStateProps) "not-enabled": { icon: Database, iconColor: "text-blue-500", - title: "Cloudflare D1 Not Enabled", + title: t("d1.empty.notEnabledTitle"), body: ( <> - It looks like Cloudflare D1 Serverless SQL is not yet enabled for this account. You must enable it in the dashboard before creating databases. + {t("d1.empty.notEnabledBody")}
@@ -172,10 +172,10 @@ function EmptyState({ variant, message, onRefresh, accountId }: EmptyStateProps) "api-error": { icon: AlertCircle, iconColor: "text-destructive", - title: "Failed to load databases", + title: t("d1.empty.apiErrorTitle"), body: (

- {message ?? "An unknown API error occurred."} + {message ?? t("d1.empty.unknownApiError")}

), }, @@ -194,7 +194,7 @@ function EmptyState({ variant, message, onRefresh, accountId }: EmptyStateProps)
); @@ -278,6 +278,7 @@ interface DatabaseListProps { } function DatabaseList({ onSelect }: DatabaseListProps) { + const { t } = useI18n(); const { state, refresh } = useD1Databases(); const activeAccount = useAppStore((s) => s.activeAccount); const enableD1History = useAppStore((s) => s.enableD1History); @@ -298,9 +299,9 @@ function DatabaseList({ onSelect }: DatabaseListProps) { {/* Header */}
-

Databases

+

{t("d1.title")}

- D1 databases attached to your Cloudflare account + {t("d1.subtitle")}

@@ -316,8 +317,8 @@ function DatabaseList({ onSelect }: DatabaseListProps) { if (!hasProHistory) { toast({ - title: "History Feature Required", - description: "This feature is only available in the official CF Studio Pro version.", + title: t("d1.historyRequired"), + description: t("d1.historyRequiredDesc"), variant: "destructive", }); return; @@ -327,7 +328,7 @@ function DatabaseList({ onSelect }: DatabaseListProps) { const historyUrl = `index.html?theme=${encodeURIComponent(storedTheme)}`; const webview = new WebviewWindow("history", { url: historyUrl, - title: "Query History Dashboard", + title: t("d1.queryHistory"), width: 1200, height: 800, minWidth: 600, @@ -350,14 +351,14 @@ function DatabaseList({ onSelect }: DatabaseListProps) { }); }); }} - title={enableD1History && hasProHistory ? "Query History" : "Query History (Pro)"} + title={enableD1History && hasProHistory ? t("d1.queryHistory") : t("d1.queryHistoryPro")} className="text-muted-foreground hover:text-foreground" > {(!enableD1History || !hasProHistory) && ( - Pro + {t("common.pro")} )}
@@ -366,7 +367,7 @@ function DatabaseList({ onSelect }: DatabaseListProps) { size="icon" onClick={refresh} disabled={isLoading} - aria-label="Refresh databases" + aria-label={t("common.refresh")} className="text-muted-foreground hover:text-foreground" > @@ -410,7 +411,7 @@ function DatabaseList({ onSelect }: DatabaseListProps) { - {["Name", "Database ID", "Created At", "Tables", "Size", ""].map((h) => ( + {[t("d1.table.name"), t("d1.table.id"), t("d1.table.createdAt"), t("d1.table.tables"), t("d1.table.size"), ""].map((h) => ( - {state.data.length} database{state.data.length !== 1 ? "s" : ""} — click a row to explore + {t(state.data.length === 1 ? "d1.listFooterSingular" : "d1.listFooter", { count: state.data.length })} diff --git a/src/components/EditColumnDialog.tsx b/src/components/EditColumnDialog.tsx index 7effa40..0c3e8d9 100644 --- a/src/components/EditColumnDialog.tsx +++ b/src/components/EditColumnDialog.tsx @@ -33,6 +33,7 @@ import { type D1Column, D1TableSchema, D1ForeignKey, D1QueryResult, invokeCloudf import { useToast } from "@/components/ui/use-toast"; import { useAppStore } from "@/store/useAppStore"; import { useD1Tracker } from "@/hooks/useD1Tracker"; +import { useI18n } from "@/lib/i18n"; type DraftForeignKey = D1ForeignKey & { isNew: boolean; @@ -126,6 +127,7 @@ export function EditColumnDialog({ existingPrimaryKeyColumn, onSuccess, }: EditColumnDialogProps) { + const { t } = useI18n(); const { toast } = useToast(); const [draftColumn, setDraftColumn] = useState({ name: "", @@ -147,6 +149,17 @@ export function EditColumnDialog({ const [isApplying, setIsApplying] = useState(false); const activeAccount = useAppStore(state => state.activeAccount); const { executeTrackedQuery } = useD1Tracker(); + const fkActions = ["No action", "Cascade", "Restrict", "Set NULL", "Set default"]; + const fkActionLabel = (action: string) => { + const labels: Record> = { + "No action": t("d1.edit.actionNoAction"), + Cascade: t("d1.edit.actionCascade"), + Restrict: t("d1.edit.actionRestrict"), + "Set NULL": t("d1.edit.actionSetNull"), + "Set default": t("d1.edit.actionSetDefault"), + }; + return labels[action] ?? action; + }; useEffect(() => { if (column && open) { @@ -173,20 +186,20 @@ export function EditColumnDialog({ const diffs: string[] = []; if (draftColumn.name !== column.name) { - diffs.push(`Renamed column from "${column.name}" to "${draftColumn.name}"`); + diffs.push(t("d1.edit.diffRenamed", { from: column.name, to: draftColumn.name })); } if (draftColumn.type !== (column.type || "text")) { - diffs.push(`Changed type from "${column.type || "text"}" to "${draftColumn.type}"`); + diffs.push(t("d1.edit.diffType", { from: column.type || "text", to: draftColumn.type })); } if (draftColumn.isNullable !== !!column.isNullable) { - diffs.push(`Changed nullable from ${!!column.isNullable ? "TRUE" : "FALSE"} to ${draftColumn.isNullable ? "TRUE" : "FALSE"}`); + diffs.push(t("d1.edit.diffNullable", { from: !!column.isNullable ? "TRUE" : "FALSE", to: draftColumn.isNullable ? "TRUE" : "FALSE" })); } if (draftColumn.isPrimary !== !!column.isPrimary) { - if (draftColumn.isPrimary) diffs.push(`Added Primary Key constraint`); - else diffs.push(`Removed Primary Key constraint`); + if (draftColumn.isPrimary) diffs.push(t("d1.edit.diffAddedPrimary")); + else diffs.push(t("d1.edit.diffRemovedPrimary")); } if (draftColumn.defaultValue !== (column.defaultValue || "")) { - diffs.push(`Changed default value from "${column.defaultValue || "NULL"}" to "${draftColumn.defaultValue || "NULL"}"`); + diffs.push(t("d1.edit.diffDefault", { from: column.defaultValue || "NULL", to: draftColumn.defaultValue || "NULL" })); } const originalFks = column.foreignKeys || []; @@ -195,14 +208,14 @@ export function EditColumnDialog({ draftFks.forEach(newFk => { const exists = originalFks.some(oldFk => oldFk.table === newFk.table && oldFk.column === newFk.column); if (!exists) { - diffs.push(`Added Foreign Key referencing ${newFk.table}.${newFk.column}`); + diffs.push(t("d1.edit.diffAddedForeign", { table: newFk.table, column: newFk.column })); } }); originalFks.forEach(oldFk => { const exists = draftFks.some(newFk => newFk.table === oldFk.table && newFk.column === oldFk.column); if (!exists) { - diffs.push(`Removed Foreign Key referencing ${oldFk.table}.${oldFk.column}`); + diffs.push(t("d1.edit.diffRemovedForeign", { table: oldFk.table, column: oldFk.column })); } }); @@ -218,7 +231,7 @@ export function EditColumnDialog({ if (!column) return; setIsApplying(true); try { - if (!activeAccount?.id) throw new Error("No active account selected."); + if (!activeAccount?.id) throw new Error(t("d1.edit.noActiveAccount")); const statements = generateTableRecreationSQL(tableName, tableColumns, column.name, draftColumn, draftColumn.draftRelations.filter(fk => !fk.isDeleted)); @@ -243,13 +256,13 @@ export function EditColumnDialog({ const failed = results.find(r => !r.success); if (failed) { - throw new Error(failed.error || "Query failed to execute successfully."); + throw new Error(failed.error || t("d1.edit.queryFailed")); } } toast({ - title: "Success", - description: "Schema updated successfully.", + title: t("common.success"), + description: t("d1.edit.toastSuccess"), }); setConfirmOpen(false); @@ -257,7 +270,7 @@ export function EditColumnDialog({ } catch (error: any) { console.error(error); toast({ - title: "Error applying changes", + title: t("d1.edit.toastError"), description: error.message || String(error), variant: "destructive" }); @@ -274,36 +287,36 @@ export function EditColumnDialog({ - Update column {column.name} from {tableName} + {t("d1.edit.title", { column: column.name, table: tableName })}

- Column editing is preview-only right now. Support for real schema changes is coming soon. + {t("d1.edit.previewOnly")}

{/* General Section */}
-
General
+
{t("d1.edit.general")}
- + setDraftColumn({ ...draftColumn, name: e.target.value })} className="font-mono h-9 text-sm" />

- Recommended to use lowercase and use an underscore to separate words e.g. column_name + {t("d1.edit.nameHelp")}

- - Optional + + {t("common.optional")}