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/.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/README.md b/README.md index af8a00a..548052e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # CF Studio +Simplified Chinese README: [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/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..f0dc1ea 100644 --- a/src-tauri/src/cloudflare_auth.rs +++ b/src-tauri/src/cloudflare_auth.rs @@ -1,14 +1,15 @@ // 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; use std::path::PathBuf; use crate::cloudflare_client::{CfError, CfResponse, CloudflareClient}; +use crate::shell_env::{login_shell, with_user_path}; // ── Error type ───────────────────────────────────────────────────────────────── @@ -26,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}")] @@ -168,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); @@ -262,14 +277,20 @@ 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"); c.args(["/c", "npx", "wrangler", "d1", "list"]); c } else { - let mut c = std::process::Command::new("npx"); - c.args(["wrangler", "d1", "list"]); + let (shell, login_flag) = login_shell(); + let mut c = std::process::Command::new(shell); + let command = with_user_path("npx wrangler d1 list"); + c.args([login_flag, "-c", &command]); c }; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e7cebf3..65d84ed 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ pub mod cloudflare_client; pub mod d1; pub mod r2; pub mod setup; +pub mod shell_env; pub mod user; pub mod db; diff --git a/src-tauri/src/setup.rs b/src-tauri/src/setup.rs index 3df6c03..9ecdc26 100644 --- a/src-tauri/src/setup.rs +++ b/src-tauri/src/setup.rs @@ -3,6 +3,7 @@ use serde::Serialize; use tauri::{AppHandle, Emitter}; use tokio::process::Command; +use crate::shell_env::{login_shell, probe_command, with_user_path}; // ── Types ────────────────────────────────────────────────────────────────────── @@ -41,18 +42,6 @@ impl Serialize for SetupError { // ── Helpers ──────────────────────────────────────────────────────────────────── -/// Resolve the correct login shell for the current platform. -/// macOS defaults to zsh; Linux falls back to $SHELL or /bin/bash. -fn login_shell() -> (&'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/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")}