feat: multi-language UI (EN+TR) + storage/privacy + linux rpm + file association

This commit is contained in:
kemalai committed 2026-05-19 14:51:46 +03:00
1 parent c014058c4e
commit 6a70cdbe2d
42 files changed
+3452 -681

No files matched your search

+8
View File
@@ -36,6 +36,14 @@ jobs:
apps/desktop/release/*.dmg.blockmap
apps/desktop/release/*.zip
apps/desktop/release/latest-mac.yml
- os: ubuntu-latest
build_script: build:linux
artifact_name: linux-installer
artifact_paths: |
apps/desktop/release/*.AppImage
apps/desktop/release/*.deb
apps/desktop/release/*.rpm
apps/desktop/release/latest-linux.yml
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/cli",
"version": "0.4.1",
"version": "0.5.0",
"private": true,
"type": "module",
"bin": {
+11
View File
@@ -22,11 +22,16 @@ win:
- target: nsis
arch:
- x64
- target: portable
arch:
- x64
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
createDesktopShortcut: true
createStartMenuShortcut: true
portable:
artifactName: ${productName}-${version}-portable.${ext}
mac:
target:
- target: dmg
@@ -38,4 +43,10 @@ linux:
target:
- AppImage
- deb
- rpm
category: Development
fileAssociations:
- ext: seoproject
name: FreeCrawl SEO Project
description: FreeCrawl SEO crawl project
role: Editor
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/desktop",
"version": "0.4.1",
"version": "0.5.0",
"private": true,
"description": "FreeCrawl SEO — desktop application",
"main": "./out/main/index.js",
@@ -24,9 +24,11 @@
"@types/cytoscape": "^3.21.9",
"clsx": "^2.1.1",
"cytoscape": "^3.33.2",
"i18next": "^26.2.0",
"lucide-react": "^0.544.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-i18next": "^17.0.8",
"react-resizable-panels": "^2.1.9",
"zustand": "^5.0.2"
},
+151 -8
View File
@@ -106,6 +106,7 @@ import {
} from '@freecrawl/core';
import { ProjectDb } from '@freecrawl/db';
import { buildAppMenu } from './menu.js';
import { isMenuLang, type MenuLang } from './menu-i18n.js';
import * as logger from './logger.js';
import { dbReaderPool, callReaderOrFallback } from './db-reader-pool.js';
import { dbWriterPool } from './db-writer-pool.js';
@@ -715,9 +716,15 @@ function clearRecentProjects(): void {
rebuildMenu();
}
function getMenuLang(): MenuLang {
const raw = prefsCache['uiLanguage'];
return isMenuLang(raw) ? raw : 'en';
}
function rebuildMenu(): void {
Menu.setApplicationMenu(
buildAppMenu({
lang: getMenuLang(),
onOpenLogs: openLogsWindow,
onOpenProject: () => void promptOpenProject(),
onOpenRecent: (path) => {
@@ -1266,6 +1273,28 @@ function createWindow(): void {
mainWindow.on('ready-to-show', () => mainWindow?.show());
// V1 Faz 8 — Custom CSS override. Lets users tweak colours / spacing
// / typography without rebuilding the app. The file is read once per
// window lifetime; users have to relaunch (or refocus) to pick up
// edits. Best-effort: any read / inject error is logged and ignored
// so a typo'd CSS file never bricks startup.
mainWindow.webContents.on('did-finish-load', () => {
try {
const themePath = join(app.getPath('userData'), 'custom-theme.css');
if (!existsSync(themePath)) return;
const css = readFileSync(themePath, 'utf8');
if (!css.trim()) return;
void mainWindow?.webContents.insertCSS(css);
logger.log('info', 'main', `Custom theme CSS injected from ${themePath}`);
} catch (err) {
logger.log(
'warn',
'main',
`Custom theme CSS load failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
});
// Keep the versioned title — prevent the renderer's <title> from overriding it.
mainWindow.on('page-title-updated', (e) => e.preventDefault());
@@ -1777,6 +1806,37 @@ function registerIpc(): void {
return setSchedule(schedulerStore, currentProjectPath, spec);
});
// V1 Faz 9 — directory picker for Settings → Storage. Returns the chosen
// absolute path, or null if the user cancelled.
ipcMain.handle(
IPC.pickDirectory,
async (
_e,
input?: { title?: string; defaultPath?: string },
): Promise<string | null> => {
const win = mainWindow;
if (!win) return null;
const res = await dialog.showOpenDialog(win, {
title: input?.title ?? 'Choose Folder',
defaultPath: input?.defaultPath,
properties: ['openDirectory', 'createDirectory'],
});
if (res.canceled || res.filePaths.length === 0) return null;
return res.filePaths[0]!;
},
);
// V1 Faz 9 — resolved default project save dir. Returns the user's
// configured `projectSaveDir` pref when set & non-empty, otherwise the
// OS Documents folder. Used by Settings → Storage to display the active
// value, and by `projectSaveAs` as the dialog's starting directory.
ipcMain.handle(IPC.defaultProjectDir, (): string => {
loadPrefs();
const raw = prefsCache['projectSaveDir'];
if (typeof raw === 'string' && raw.trim().length > 0) return raw;
return app.getPath('documents');
});
// Stream new entries to the logs window in coalesced batches. A heavy
// crawl emits 100–300 logs/s; sending each as its own IPC message
// saturated the renderer's event loop and caused visible UI stutters
@@ -1813,6 +1873,12 @@ function registerIpc(): void {
loadPrefs();
prefsCache[key] = value;
schedulePrefsWrite();
// V1 Faz 8 Phase 2 — when the renderer switches language, rebuild the
// app menu so File/View/Help/etc. labels follow immediately. Cheap;
// rebuildMenu is also the recent-projects refresh path.
if (key === 'uiLanguage' && isMenuLang(value)) {
rebuildMenu();
}
});
ipcMain.handle(IPC.prefsDelete, (_e, key: string) => {
loadPrefs();
@@ -2016,9 +2082,17 @@ function registerIpc(): void {
async (): Promise<{ filePath: string; bytesWritten: number } | null> => {
const win = mainWindow;
if (!win) return null;
// Honour the user-configured default save directory (Settings →
// Storage). Falls back to the OS Documents folder when unset.
loadPrefs();
const baseDir = (() => {
const raw = prefsCache['projectSaveDir'];
if (typeof raw === 'string' && raw.trim().length > 0) return raw;
return app.getPath('documents');
})();
const res = await dialog.showSaveDialog(win, {
title: 'Save Project As…',
defaultPath: 'crawl.seoproject',
defaultPath: join(baseDir, 'crawl.seoproject'),
filters: [
{ name: 'FreeCrawl Project', extensions: ['seoproject'] },
{ name: 'All Files', extensions: ['*'] },
@@ -2099,16 +2173,18 @@ function registerIpc(): void {
ipcMain.handle(IPC.confirmClear, async (): Promise<ConfirmClearResult> => {
const win = mainWindow;
if (!win) return { confirmed: false, skipNext: false };
const isTr = getMenuLang() === 'tr';
const res = await dialog.showMessageBox(win, {
type: 'warning',
buttons: ['Clear', 'Cancel'],
buttons: isTr ? ['Temizle', 'İptal'] : ['Clear', 'Cancel'],
defaultId: 1,
cancelId: 1,
title: 'Clear Crawl Data',
message: 'Clear all crawl data?',
detail:
'This will remove all discovered URLs, links, and crawl metadata for this project. This action cannot be undone.',
checkboxLabel: "Don't ask me again",
title: isTr ? 'Crawl Verilerini Temizle' : 'Clear Crawl Data',
message: isTr ? 'Tüm crawl verileri temizlensin mi?' : 'Clear all crawl data?',
detail: isTr
? 'Bu, bu projedeki tüm keşfedilmiş URL\'leri, linkleri ve crawl meta verilerini siler. Bu işlem geri alınamaz.'
: 'This will remove all discovered URLs, links, and crawl metadata for this project. This action cannot be undone.',
checkboxLabel: isTr ? 'Bana bir daha sorma' : "Don't ask me again",
checkboxChecked: false,
noLink: true,
});
@@ -3073,15 +3149,62 @@ const gotSingleInstanceLock = app.requestSingleInstanceLock();
if (!gotSingleInstanceLock) {
app.quit();
} else {
app.on('second-instance', () => {
app.on('second-instance', (_e, argv) => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
}
// V1 Faz 8 — File association on Windows/Linux. A double-click on
// a `.seoproject` while the app is already running spawns a second
// process whose `argv` carries the path; this handler opens it in
// the existing window.
const path = pickProjectPathFromArgv(argv);
if (path) {
try {
openProjectAtPath(path);
} catch (err) {
logger.log(
'warn',
'main',
`Open project from second-instance argv failed: ${(err as Error).message}`,
);
}
}
});
}
// V1 Faz 8 — macOS file-association entry point. `open-file` fires
// before `whenReady` on cold-start double-clicks, so we stash the path
// and replay it after the main window is created.
let pendingOpenFilePath: string | null = null;
app.on('open-file', (event, filePath) => {
event.preventDefault();
if (mainWindow) {
try {
openProjectAtPath(filePath);
} catch (err) {
logger.log('warn', 'main', `Open project from open-file event failed: ${(err as Error).message}`);
}
} else {
pendingOpenFilePath = filePath;
}
});
/**
* Extract a `.seoproject` (or .sqlite / .db) path from a process argv
* array. Returns null when nothing usable was passed (the common
* desktop-shortcut launch). The first matching path wins.
*/
function pickProjectPathFromArgv(argv: readonly string[]): string | null {
for (const a of argv) {
if (typeof a !== 'string') continue;
if (a.startsWith('-')) continue;
if (/\.(seoproject|sqlite|db)$/i.test(a) && existsSync(a)) return a;
}
return null;
}
// Suppress Chromium's GPU disk cache. Windows users whose %APPDATA%
// is synced by OneDrive / Dropbox / antivirus real-time scanners hit
// transient ACCESS_DENIED errors when Chromium tries to rotate or
@@ -3209,6 +3332,26 @@ void app.whenReady().then(async () => {
// dialog, so it's safe to leave running for the lifetime of the app.
startScheduler();
// V1 Faz 8 — Cold-start file association. On Windows/Linux the OS
// appends the double-clicked file path to argv; on macOS it arrived
// via the `open-file` event before this point and is parked in
// `pendingOpenFilePath`. Either way, open it now that the window and
// DB layer are ready.
const coldStartPath =
pendingOpenFilePath ?? pickProjectPathFromArgv(process.argv);
pendingOpenFilePath = null;
if (coldStartPath) {
try {
openProjectAtPath(coldStartPath);
} catch (err) {
logger.log(
'warn',
'main',
`Open project from cold-start argv failed: ${(err as Error).message}`,
);
}
}
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
+168
View File
@@ -0,0 +1,168 @@
/**
* V1 Faz 8 Phase 2 — menu labels mirrored for main-process consumption.
*
* Why duplicate the strings instead of importing the renderer JSON?
* The renderer locale files live under `src/renderer/src/i18n/locales/`
* and electron-vite bundles them into the renderer chunk only. The main
* process is its own bundle (no renderer dependencies), so we keep an
* independent copy here. Menu strings are < 40 entries; the duplication
* cost is tiny compared with cross-bundle import plumbing.
*
* When updating menu translations: edit both this file and the
* renderer's `en.json` / `tr.json` (under the `menu.*` namespace) so
* the two surfaces stay in sync.
*/
export type MenuLang = 'en' | 'tr';
export interface MenuLabels {
file: string;
newProject: string;
openProject: string;
openRecent: string;
clearRecent: string;
emptyRecent: string;
clearCrawlData: string;
exportCsv: string;
exportJson: string;
exportXml: string;
generateSitemap: string;
exportHtmlReport: string;
bulkExport: string;
compareWith: string;
scheduledCrawl: string;
scheduledCrawlTooltip: string;
saveProjectAs: string;
settings: string;
view: string;
overviewSidebar: string;
detailPanel: string;
showVisualization: string;
fullscreen: string;
reports: string;
reportsItem: string;
help: string;
documentation: string;
showLogs: string;
openLogsFolder: string;
openLogsFolderTooltip: string;
robotsTester: string;
sitemapValidator: string;
resetDiagnostics: string;
resetDiagnosticsTooltip: string;
deleteDomainData: string;
deleteDomainDataTooltip: string;
clearAllData: string;
clearAllDataTooltip: string;
checkForUpdates: string;
checkForUpdatesTooltip: string;
about: string;
}
const MENU_EN: MenuLabels = {
file: 'File',
newProject: 'New Project',
openProject: 'Open Project…',
openRecent: 'Open Recent',
clearRecent: 'Clear Recent',
emptyRecent: '(empty)',
clearCrawlData: 'Clear Crawl Data',
exportCsv: 'Export Current View as CSV',
exportJson: 'Export Current View as JSON…',
exportXml: 'Export Current View as XML…',
generateSitemap: 'Generate XML Sitemap…',
exportHtmlReport: 'Export HTML Report…',
bulkExport: 'Bulk Export…',
compareWith: 'Compare With Project…',
scheduledCrawl: 'Scheduled Crawl…',
scheduledCrawlTooltip:
'Set up an in-app recurring crawl for the currently-open project. Fires only while FreeCrawl is open; use the CLI + OS scheduler for triggers that survive restarts.',
saveProjectAs: 'Save Project As…',
settings: 'Settings…',
view: 'View',
overviewSidebar: 'Overview Sidebar',
detailPanel: 'Detail Panel',
showVisualization: 'Show Visualization Tab',
fullscreen: 'Fullscreen',
reports: 'Reports',
reportsItem: 'Reports…',
help: 'Help',
documentation: 'Documentation',
showLogs: 'Show Logs…',
openLogsFolder: 'Open Logs Folder',
openLogsFolderTooltip:
'Open the directory where rotated log files are persisted on disk',
robotsTester: 'Robots.txt Tester…',
sitemapValidator: 'Sitemap Validator…',
resetDiagnostics: 'Reset Diagnostic Warnings',
resetDiagnosticsTooltip:
'Re-enable popup warnings you previously dismissed with "Don\'t show again"',
deleteDomainData: 'Delete Domain Data…',
deleteDomainDataTooltip:
'GDPR-aligned per-domain wipe. Removes every URL row whose host matches the entered domain plus every dependent record (links, headers, images, source snapshots).',
clearAllData: 'Clear All Data…',
clearAllDataTooltip:
'Wipe the entire active project (URLs, links, images, headers, source snapshots, sitemaps). Cannot be undone — Save Project As… first if you want a backup.',
checkForUpdates: 'Check for Updates…',
checkForUpdatesTooltip:
'Fetch the latest GitHub release and compare it with your installed version. No background polling — runs only when you click.',
about: 'About FreeCrawl SEO',
};
const MENU_TR: MenuLabels = {
file: 'Dosya',
newProject: 'Yeni Proje',
openProject: 'Proje Aç…',
openRecent: 'Son Açılanlar',
clearRecent: 'Son Açılanları Temizle',
emptyRecent: '(boş)',
clearCrawlData: 'Crawl Verilerini Temizle',
exportCsv: 'Mevcut Görünümü CSV Olarak Dışa Aktar',
exportJson: 'Mevcut Görünümü JSON Olarak Dışa Aktar…',
exportXml: 'Mevcut Görünümü XML Olarak Dışa Aktar…',
generateSitemap: 'XML Sitemap Oluştur…',
exportHtmlReport: 'HTML Rapor Dışa Aktar…',
bulkExport: 'Toplu Dışa Aktarım…',
compareWith: 'Projeyle Karşılaştır…',
scheduledCrawl: 'Zamanlanmış Crawl…',
scheduledCrawlTooltip:
'Şu an açık olan proje için uygulama içi tekrarlayan crawl kur. Yalnızca FreeCrawl açıkken çalışır; yeniden başlatmaya dayanan tetikler için CLI + OS zamanlayıcısını kullan.',
saveProjectAs: 'Projeyi Farklı Kaydet…',
settings: 'Ayarlar…',
view: 'Görünüm',
overviewSidebar: 'Genel Bakış Kenar Çubuğu',
detailPanel: 'Detay Paneli',
showVisualization: 'Görselleştirme Sekmesini Göster',
fullscreen: 'Tam Ekran',
reports: 'Raporlar',
reportsItem: 'Raporlar…',
help: 'Yardım',
documentation: 'Dokümantasyon',
showLogs: 'Logları Göster…',
openLogsFolder: 'Log Klasörünü Aç',
openLogsFolderTooltip:
'Diske kayıtlı, dönen log dosyalarının olduğu dizini aç',
robotsTester: 'Robots.txt Test Aracı…',
sitemapValidator: 'Sitemap Doğrulayıcı…',
resetDiagnostics: 'Tanı Uyarılarını Sıfırla',
resetDiagnosticsTooltip:
'Daha önce "Bir daha gösterme" ile kapattığınız popup uyarılarını yeniden etkinleştir',
deleteDomainData: 'Alan Verilerini Sil…',
deleteDomainDataTooltip:
'GDPR uyumlu, alan başına temizleme. Girdiğiniz alana ait her URL satırını + bağlı kayıtları (linkler, başlıklar, görseller, kaynak snapshot\'ları) siler.',
clearAllData: 'Tüm Veriyi Temizle…',
clearAllDataTooltip:
'Aktif projenin tamamını sil (URL\'ler, linkler, görseller, başlıklar, kaynak snapshot\'ları, sitemap\'ler). Geri alınamaz — yedek istiyorsanız önce Projeyi Farklı Kaydet.',
checkForUpdates: 'Güncellemeleri Kontrol Et…',
checkForUpdatesTooltip:
'En son GitHub release\'ini çek ve kurulu sürümünüzle karşılaştır. Arka planda yoklama yok — yalnızca tıkladığınızda çalışır.',
about: 'FreeCrawl SEO Hakkında',
};
export function getMenuLabels(lang: MenuLang): MenuLabels {
return lang === 'tr' ? MENU_TR : MENU_EN;
}
export function isMenuLang(value: unknown): value is MenuLang {
return value === 'en' || value === 'tr';
}
+45 -45
View File
@@ -1,6 +1,7 @@
import { Menu, BrowserWindow, app, shell, type MenuItemConstructorOptions } from 'electron';
import { basename } from 'node:path';
import { IPC, type MenuEvent } from '@freecrawl/shared-types';
import { getMenuLabels, type MenuLang } from './menu-i18n.js';
function send(event: MenuEvent): void {
const win = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0];
@@ -27,19 +28,22 @@ export interface AppMenuHandlers {
* update available with "Open release page" button / network error).
*/
onCheckForUpdates: () => void;
/** UI language for menu labels. Falls back to `en` when missing. */
lang?: MenuLang;
}
export function buildAppMenu(handlers: AppMenuHandlers): Menu {
const L = getMenuLabels(handlers.lang ?? 'en');
const recentSubmenu: MenuItemConstructorOptions[] =
handlers.recentProjects.length === 0
? [{ label: '(empty)', enabled: false }]
? [{ label: L.emptyRecent, enabled: false }]
: [
...handlers.recentProjects.slice(0, 10).map<MenuItemConstructorOptions>((p) => ({
label: `${basename(p)} — ${p}`,
click: () => handlers.onOpenRecent(p),
})),
{ type: 'separator' as const },
{ label: 'Clear Recent', click: () => handlers.onClearRecent() },
{ label: L.clearRecent, click: () => handlers.onClearRecent() },
];
const isMac = process.platform === 'darwin';
@@ -63,62 +67,61 @@ export function buildAppMenu(handlers: AppMenuHandlers): Menu {
] as MenuItemConstructorOptions[])
: []),
{
label: 'File',
label: L.file,
submenu: [
{ label: 'New Project', accelerator: 'CmdOrCtrl+N', click: () => send('new-project') },
{ label: L.newProject, accelerator: 'CmdOrCtrl+N', click: () => send('new-project') },
{
label: 'Open Project…',
label: L.openProject,
accelerator: 'CmdOrCtrl+O',
click: () => handlers.onOpenProject(),
},
{ label: 'Open Recent', submenu: recentSubmenu },
{ label: 'Clear Crawl Data', click: () => send('clear-crawl') },
{ label: L.openRecent, submenu: recentSubmenu },
{ label: L.clearCrawlData, click: () => send('clear-crawl') },
{ type: 'separator' },
{
label: 'Export Current View as CSV',
label: L.exportCsv,
accelerator: 'CmdOrCtrl+E',
click: () => send('export-csv'),
},
{
label: 'Export Current View as JSON…',
label: L.exportJson,
accelerator: 'CmdOrCtrl+Shift+E',
click: () => send('export-json'),
},
{
label: 'Export Current View as XML…',
label: L.exportXml,
click: () => send('export-xml'),
},
{
label: 'Generate XML Sitemap…',
label: L.generateSitemap,
click: () => send('generate-sitemap'),
},
{
label: 'Export HTML Report…',
label: L.exportHtmlReport,
click: () => send('export-html-report'),
},
{
label: 'Bulk Export…',
label: L.bulkExport,
click: () => send('export-bulk'),
},
{ type: 'separator' },
{
label: 'Compare With Project…',
label: L.compareWith,
click: () => send('compare-with-project'),
},
{
label: 'Scheduled Crawl…',
toolTip:
'Set up an in-app recurring crawl for the currently-open project. Fires only while FreeCrawl is open; use the CLI + OS scheduler for triggers that survive restarts.',
label: L.scheduledCrawl,
toolTip: L.scheduledCrawlTooltip,
click: () => send('open-scheduled-crawl'),
},
{
label: 'Save Project As…',
label: L.saveProjectAs,
accelerator: 'CmdOrCtrl+Shift+S',
click: () => send('save-project-as'),
},
{ type: 'separator' },
{
label: 'Settings…',
label: L.settings,
accelerator: 'CmdOrCtrl+,',
click: () => send('open-settings'),
},
@@ -127,21 +130,21 @@ export function buildAppMenu(handlers: AppMenuHandlers): Menu {
],
},
{
label: 'View',
label: L.view,
submenu: [
{
label: 'Overview Sidebar',
label: L.overviewSidebar,
accelerator: 'CmdOrCtrl+B',
click: () => send('toggle-sidebar'),
},
{
label: 'Detail Panel',
label: L.detailPanel,
accelerator: 'CmdOrCtrl+D',
click: () => send('toggle-detail-panel'),
},
{ type: 'separator' },
{
label: 'Show Visualization Tab',
label: L.showVisualization,
accelerator: 'CmdOrCtrl+G',
click: () => send('open-visualization'),
},
@@ -152,73 +155,70 @@ export function buildAppMenu(handlers: AppMenuHandlers): Menu {
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen', label: 'Fullscreen' },
{ role: 'togglefullscreen', label: L.fullscreen },
],
},
{
label: 'Reports',
label: L.reports,
submenu: [
{
label: 'Reports…',
label: L.reportsItem,
accelerator: 'CmdOrCtrl+R',
click: () => send('open-reports'),
},
],
},
{
label: 'Help',
label: L.help,
submenu: [
{
label: 'Documentation',
label: L.documentation,
click: () => void shell.openExternal('https://github.com/kemalai/FreeCrawl-SEO-Tool'),
},
{ type: 'separator' },
{
label: 'Show Logs…',
label: L.showLogs,
accelerator: 'CmdOrCtrl+L',
click: () => handlers.onOpenLogs(),
},
{
label: 'Open Logs Folder',
toolTip: 'Open the directory where rotated log files are persisted on disk',
label: L.openLogsFolder,
toolTip: L.openLogsFolderTooltip,
click: () => handlers.onOpenLogsFolder(),
},
{
label: 'Robots.txt Tester…',
label: L.robotsTester,
click: () => send('open-robots-tester'),
},
{
label: 'Sitemap Validator…',
label: L.sitemapValidator,
click: () => send('open-sitemap-validator'),
},
{ type: 'separator' },
{
label: 'Reset Diagnostic Warnings',
toolTip: 'Re-enable popup warnings you previously dismissed with "Don\'t show again"',
label: L.resetDiagnostics,
toolTip: L.resetDiagnosticsTooltip,
click: () => handlers.onResetDiagnosticDialogs(),
},
{ type: 'separator' },
{
label: 'Delete Domain Data…',
toolTip:
'GDPR-aligned per-domain wipe. Removes every URL row whose host matches the entered domain plus every dependent record (links, headers, images, source snapshots).',
label: L.deleteDomainData,
toolTip: L.deleteDomainDataTooltip,
click: () => send('delete-domain-data'),
},
{
label: 'Clear All Data…',
toolTip:
'Wipe the entire active project (URLs, links, images, headers, source snapshots, sitemaps). Cannot be undone — Save Project As… first if you want a backup.',
label: L.clearAllData,
toolTip: L.clearAllDataTooltip,
click: () => send('clear-all-data'),
},
{ type: 'separator' },
{
label: 'Check for Updates…',
toolTip:
'Fetch the latest GitHub release and compare it with your installed version. No background polling — runs only when you click.',
label: L.checkForUpdates,
toolTip: L.checkForUpdatesTooltip,
click: () => handlers.onCheckForUpdates(),
},
{ type: 'separator' },
{ label: 'About FreeCrawl SEO', click: () => send('about') },
{ label: L.about, click: () => send('about') },
],
},
];
+3
View File
@@ -239,6 +239,9 @@ const api: FreeCrawlApi = {
scheduleGet: (): Promise<ScheduleEntry | null> => ipcRenderer.invoke(IPC.scheduleGet),
scheduleSet: (spec: ScheduleSpec | null): Promise<ScheduleEntry | null> =>
ipcRenderer.invoke(IPC.scheduleSet, spec),
pickDirectory: (input?: { title?: string; defaultPath?: string }): Promise<string | null> =>
ipcRenderer.invoke(IPC.pickDirectory, input),
defaultProjectDir: (): Promise<string> => ipcRenderer.invoke(IPC.defaultProjectDir),
onLogEntry: (cb) => subscribe<LogEntry>(IPC.logsEntry, cb),
onLogsBatch: (cb) => subscribe<LogEntry[]>(IPC.logsBatch, cb),
onLogsBusy: (cb) => subscribe<boolean>(IPC.logsBusy, cb),
+42 -19
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { PanelGroup, Panel, PanelResizeHandle } from 'react-resizable-panels';
import { useTranslation } from 'react-i18next';
import { TopBar } from './components/TopBar.js';
import { StatsBar } from './components/StatsBar.js';
import { TabsBar } from './components/TabsBar.js';
@@ -21,6 +22,7 @@ import type { MenuEvent } from '@freecrawl/shared-types';
import { clearCrawlWithConfirm } from './utils/clearCrawl.js';
export function App() {
const { t } = useTranslation();
const activeTab = useAppStore((s) => s.activeTab);
const sidebarOpen = useAppStore((s) => s.sidebarOpen);
const detailPanelOpen = useAppStore((s) => s.detailPanelOpen);
@@ -70,16 +72,19 @@ export function App() {
const filePath = fileWithPath.path;
if (!filePath) {
setDropFlash(
`Couldn't read the dropped path for ${file.name}. Use File → Open Project instead.`,
t('app.dropMissingPath', {
defaultValue: "Couldn't read the dropped path for {{name}}. Use File → Open Project instead.",
name: file.name,
}),
);
setTimeout(() => setDropFlash(null), 4000);
return;
}
const result = await window.freecrawl.projectOpen(filePath);
if (result) {
setDropFlash(`Opened project: ${result.filePath}`);
setDropFlash(t('app.dropOpenedProject', { defaultValue: 'Opened project: {{path}}', path: result.filePath }));
} else {
setDropFlash(`Failed to open project: ${file.name}`);
setDropFlash(t('app.dropFailedProject', { defaultValue: 'Failed to open project: {{name}}', name: file.name }));
}
setTimeout(() => setDropFlash(null), 4000);
return;
@@ -99,13 +104,17 @@ export function App() {
else if (u) urls.push('https://' + u);
}
if (urls.length === 0) {
setDropFlash(`No URLs found in ${file.name}.`);
setDropFlash(t('app.dropNoUrls', { defaultValue: 'No URLs found in {{name}}.', name: file.name }));
setTimeout(() => setDropFlash(null), 4000);
return;
}
setConfig({ mode: 'list', urlList: urls, startUrl: urls[0] ?? '' });
setSettingsOpen(true);
setDropFlash(`Loaded ${urls.length} URLs from ${file.name} into List mode.`);
setDropFlash(t('app.dropLoadedUrls', {
defaultValue: 'Loaded {{count}} URLs from {{name}} into List mode.',
count: urls.length,
name: file.name,
}));
setTimeout(() => setDropFlash(null), 4000);
}
const dropHandler = (e: DragEvent): void => {
@@ -117,7 +126,7 @@ export function App() {
window.removeEventListener('dragover', onDragOver);
window.removeEventListener('drop', dropHandler);
};
}, [setConfig, setSettingsOpen]);
}, [t, setConfig, setSettingsOpen]);
// Redirect react-resizable-panels' persistence away from localStorage and
// into our JSON prefs file so layout survives Clear (which wipes crawl
@@ -152,11 +161,12 @@ export function App() {
if (status.pendingCount === 0) return;
window.setTimeout(() => {
const proceed = window.confirm(
`FreeCrawl detected a previous crawl that didn't finish cleanly:\n\n` +
` Start URL: ${status.seedUrl}\n` +
` Pending URLs: ${status.pendingCount.toLocaleString()}\n\n` +
`Resume the crawl from where it stopped?\n\n` +
`Click OK to resume, Cancel to discard the recovery state and start fresh.`,
t('app.crashRecovery', {
defaultValue:
"FreeCrawl detected a previous crawl that didn't finish cleanly:\n\n Start URL: {{seed}}\n Pending URLs: {{pending}}\n\nResume the crawl from where it stopped?\n\nClick OK to resume, Cancel to discard the recovery state and start fresh.",
seed: status.seedUrl,
pending: status.pendingCount.toLocaleString(),
}),
);
if (proceed) {
void window.freecrawl.crashRecoveryResume();
@@ -216,25 +226,34 @@ export function App() {
break;
case 'delete-domain-data': {
const domain = window.prompt(
'GDPR Domain Wipe — enter the host to delete (case-insensitive, exact match).\n\nExamples: example.com · blog.example.com\n\nThis cannot be undone. Save Project As… first to keep a backup.',
t('app.deleteDomainPrompt', {
defaultValue:
'GDPR Domain Wipe — enter the host to delete (case-insensitive, exact match).\n\nExamples: example.com · blog.example.com\n\nThis cannot be undone. Save Project As… first to keep a backup.',
}),
'',
);
if (!domain || !domain.trim()) break;
const target = domain.trim();
if (
!window.confirm(
`Delete every URL hosted on "${target}", along with its links, headers, images, and source snapshots?\n\nThis action cannot be undone.`,
t('app.deleteDomainConfirm', {
defaultValue:
'Delete every URL hosted on "{{host}}", along with its links, headers, images, and source snapshots?\n\nThis action cannot be undone.',
host: target,
}),
)
) {
break;
}
void window.freecrawl.dataDeleteByDomain({ domain: target }).then((res) => {
window.alert(
`Deleted ${res.urlsDeleted.toLocaleString()} URL row${
res.urlsDeleted === 1 ? '' : 's'
} from ${target} (and ${res.linksDeleted.toLocaleString()} associated link${
res.linksDeleted === 1 ? '' : 's'
}).`,
t('app.deleteDomainResult', {
defaultValue:
'Deleted {{urls}} URL row(s) from {{host}} (and {{links}} associated link(s)).',
urls: res.urlsDeleted.toLocaleString(),
host: target,
links: res.linksDeleted.toLocaleString(),
}),
);
});
break;
@@ -242,7 +261,10 @@ export function App() {
case 'clear-all-data': {
if (
!window.confirm(
'Clear ALL data in the active project?\n\nEvery URL, link, image, header, source snapshot, sitemap, and issue will be wiped. This cannot be undone — Save Project As… first if you want a backup.',
t('app.clearAllConfirm', {
defaultValue:
'Clear ALL data in the active project?\n\nEvery URL, link, image, header, source snapshot, sitemap, and issue will be wiped. This cannot be undone — Save Project As… first if you want a backup.',
}),
)
) {
break;
@@ -293,6 +315,7 @@ export function App() {
offData();
};
}, [
t,
setProgress,
setSummary,
setError,
+18 -11
View File
@@ -9,6 +9,7 @@ import {
} from 'react';
import clsx from 'clsx';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useTranslation } from 'react-i18next';
import type { LogEntry, LogLevel } from '@freecrawl/shared-types';
const LEVEL_ORDER: LogLevel[] = ['debug', 'info', 'warn', 'error'];
@@ -87,6 +88,7 @@ const Row = memo(
);
export function LogsView() {
const { t } = useTranslation();
const [entries, setEntries] = useState<LogEntry[]>([]);
const [minLevel, setMinLevel] = useState<LogLevel>('info');
const [filter, setFilter] = useState('');
@@ -220,11 +222,11 @@ export function LogsView() {
<div className="flex h-screen flex-col bg-surface-950 text-surface-100">
<div className="flex items-center gap-2 border-b border-surface-800 bg-surface-900 px-3 py-2">
<div className="text-xs font-semibold uppercase tracking-wide text-surface-400">
Logs
{t('logs.title', { defaultValue: 'Logs' })}
</div>
<div className="mx-2 h-5 w-px bg-surface-800" />
<label className="flex items-center gap-1.5 text-[11px] text-surface-400">
Level
{t('logs.level', { defaultValue: 'Level' })}
<select
className="rounded border border-surface-700 bg-surface-900 px-2 py-1 text-[11px] text-surface-100"
value={minLevel}
@@ -239,7 +241,7 @@ export function LogsView() {
</label>
<input
className="flex-1 rounded border border-surface-700 bg-surface-900 px-2 py-1 text-[11px]"
placeholder="Filter messages / sources…"
placeholder={t('logs.filterPlaceholder', { defaultValue: 'Filter messages / sources…' })}
value={filter}
onChange={(e) => setFilter(e.target.value)}
spellCheck={false}
@@ -250,20 +252,20 @@ export function LogsView() {
checked={autoScroll}
onChange={(e) => setAutoScroll(e.target.checked)}
/>
Auto-scroll
{t('logs.autoScroll', { defaultValue: 'Auto-scroll' })}
</label>
<button
className="rounded border border-surface-700 px-2 py-1 text-[11px] hover:bg-surface-800"
onClick={copyAll}
title="Copy visible entries to clipboard"
title={t('logs.copyTitle', { defaultValue: 'Copy visible entries to clipboard' })}
>
Copy
{t('logs.copy', { defaultValue: 'Copy' })}
</button>
<button
className="rounded border border-red-800/60 px-2 py-1 text-[11px] text-red-300 hover:bg-red-900/30"
onClick={clearAll}
>
Clear
{t('logs.clear', { defaultValue: 'Clear' })}
</button>
</div>
@@ -273,7 +275,7 @@ export function LogsView() {
style={{ contain: 'strict' }}
>
{visible.length === 0 ? (
<div className="p-6 text-center text-surface-500">No log entries.</div>
<div className="p-6 text-center text-surface-500">{t('logs.empty', { defaultValue: 'No log entries.' })}</div>
) : (
<div
style={{
@@ -294,11 +296,16 @@ export function LogsView() {
<div className="flex shrink-0 items-center gap-3 border-t border-surface-800 bg-surface-900/50 px-3 py-1.5 text-[11px] text-surface-500">
<span>
Showing <span className="font-mono text-surface-100">{visible.length}</span> /{' '}
<span className="font-mono text-surface-100">{entries.length}</span> entries
{t('logs.showingPrefix', { defaultValue: 'Showing' })}{' '}
<span className="font-mono text-surface-100">{visible.length}</span> /{' '}
<span className="font-mono text-surface-100">{entries.length}</span>{' '}
{t('logs.entriesSuffix', { defaultValue: 'entries' })}
</span>
<span className="ml-auto text-surface-600">
Live tail keeps the last {MAX_ENTRIES.toLocaleString()} entries — full history persisted to disk (Help → Open Logs Folder).
{t('logs.tailHint', {
defaultValue: 'Live tail keeps the last {{n}} entries — full history persisted to disk (Help → Open Logs Folder).',
n: MAX_ENTRIES.toLocaleString(),
})}
</span>
</div>
</div>
@@ -1,6 +1,8 @@
import { useEffect, useState } from 'react';
import { Plus, Trash2, X } from 'lucide-react';
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
import { translateLabel } from '../i18n/labels.js';
import type {
AdvancedFilter,
FilterClause,
@@ -84,6 +86,8 @@ export function AdvancedFilterDialog({
onClose: () => void;
onApply: (filter: AdvancedFilter | null) => void;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const [groups, setGroups] = useState<FilterGroup[]>(() =>
initial && initial.groups.length > 0 ? clone(initial.groups) : [emptyGroup()],
);
@@ -174,11 +178,11 @@ export function AdvancedFilterDialog({
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-surface-800 px-4 py-2.5">
<div className="text-sm font-semibold text-surface-100">Advanced Table Search</div>
<div className="text-sm font-semibold text-surface-100">{t('filter.title', { defaultValue: 'Advanced Table Search' })}</div>
<button
className="rounded p-1 text-surface-400 hover:bg-surface-800 hover:text-surface-100"
onClick={onClose}
title="Close"
title={t('common.close', { defaultValue: 'Close' })}
>
<X className="h-4 w-4" />
</button>
@@ -189,13 +193,13 @@ export function AdvancedFilterDialog({
<div key={gi} className="rounded border border-surface-800 bg-surface-950/50 p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-[10px] font-semibold uppercase tracking-wide text-surface-500">
{gi === 0 ? 'Where' : 'Or Where'}
{gi === 0 ? t('filter.where', { defaultValue: 'Where' }) : t('filter.orWhere', { defaultValue: 'Or Where' })}
</span>
{groups.length > 1 && (
<button
className="rounded p-1 text-surface-500 hover:bg-surface-800 hover:text-red-300"
onClick={() => deleteGroup(gi)}
title="Remove group"
title={t('filter.removeGroup', { defaultValue: 'Remove group' })}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
@@ -207,6 +211,7 @@ export function AdvancedFilterDialog({
key={ci}
clause={clause}
showAndLabel={ci > 0}
lang={lang}
onChange={(patch) => updateClause(gi, ci, patch)}
onDelete={() => deleteClause(gi, ci)}
/>
@@ -216,9 +221,9 @@ export function AdvancedFilterDialog({
<button
className="inline-flex items-center gap-1 rounded border border-accent-500/50 bg-accent-500/10 px-2 py-1 text-[11px] text-accent-300 hover:bg-accent-500/20"
onClick={() => addClause(gi)}
title="Add another AND condition to this group"
title={t('filter.addAnd', { defaultValue: 'Add another AND condition to this group' })}
>
<Plus className="h-3 w-3" /> AND
<Plus className="h-3 w-3" /> {t('filter.and', { defaultValue: 'AND' })}
</button>
</div>
</div>
@@ -228,9 +233,9 @@ export function AdvancedFilterDialog({
<button
className="inline-flex items-center gap-1 rounded border border-emerald-500/50 bg-emerald-500/10 px-2.5 py-1 text-[11px] text-emerald-300 hover:bg-emerald-500/20"
onClick={addGroup}
title="Add an OR group"
title={t('filter.addOr', { defaultValue: 'Add an OR group' })}
>
<Plus className="h-3 w-3" /> OR
<Plus className="h-3 w-3" /> {t('filter.or', { defaultValue: 'OR' })}
</button>
</div>
</div>
@@ -240,20 +245,20 @@ export function AdvancedFilterDialog({
className="inline-flex items-center gap-1 rounded border border-red-700/50 bg-red-900/30 px-2.5 py-1 text-[11px] text-red-300 hover:bg-red-900/50"
onClick={reset}
>
Reset
{t('common.reset', { defaultValue: 'Reset' })}
</button>
<div className="flex items-center gap-2">
<button
className="rounded border border-surface-700 px-3 py-1 text-[11px] text-surface-300 hover:bg-surface-800"
onClick={onClose}
>
Cancel
{t('common.cancel', { defaultValue: 'Cancel' })}
</button>
<button
className="rounded bg-accent-500 px-3 py-1 text-[11px] font-medium text-white hover:bg-accent-600"
onClick={apply}
>
OK
{t('common.ok', { defaultValue: 'OK' })}
</button>
</div>
</div>
@@ -265,14 +270,17 @@ export function AdvancedFilterDialog({
function ClauseRow({
clause,
showAndLabel,
lang,
onChange,
onDelete,
}: {
clause: FilterClause;
showAndLabel: boolean;
lang: string;
onChange: (patch: Partial<FilterClause>) => void;
onDelete: () => void;
}) {
const { t } = useTranslation();
const numeric = isNumericField(clause.field);
const operators = numeric ? NUMERIC_OPS : TEXT_OPS;
const needsValue = !NO_VALUE_OPS.includes(clause.operator);
@@ -285,7 +293,7 @@ function ClauseRow({
showAndLabel ? 'text-surface-500' : 'text-transparent',
)}
>
{showAndLabel ? 'And' : '—'}
{showAndLabel ? t('filter.andRow', { defaultValue: 'And' }) : '—'}
</span>
<select
className="input w-52"
@@ -304,7 +312,7 @@ function ClauseRow({
>
{FIELDS.map((f) => (
<option key={f.value} value={f.value}>
{f.label}
{translateLabel(f.label, lang)}
</option>
))}
</select>
@@ -315,7 +323,7 @@ function ClauseRow({
>
{operators.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
{translateLabel(o.label, lang)}
</option>
))}
</select>
@@ -323,18 +331,18 @@ function ClauseRow({
<input
className="input flex-1"
type={numeric ? 'number' : 'text'}
placeholder={numeric ? '0' : 'Enter search query'}
placeholder={numeric ? '0' : t('filter.searchPlaceholder', { defaultValue: 'Enter search query' })}
value={clause.value}
onChange={(e) => onChange({ value: e.target.value })}
spellCheck={false}
/>
) : (
<div className="flex-1 text-[11px] text-surface-600">(no value required)</div>
<div className="flex-1 text-[11px] text-surface-600">{t('filter.noValue', { defaultValue: '(no value required)' })}</div>
)}
<button
className="rounded p-1 text-surface-500 hover:bg-surface-800 hover:text-red-300"
onClick={onDelete}
title="Remove condition"
title={t('filter.removeCondition', { defaultValue: 'Remove condition' })}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
@@ -1,5 +1,7 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
import { translateLabel } from '../i18n/labels.js';
import type {
CrawlConfig,
CrawlUrlRow,
@@ -68,6 +70,8 @@ const SINGLE_URL_ONLY_TABS: ReadonlySet<SubTab> = new Set([
]);
export function BottomDetailPanel() {
const { t: tx, i18n } = useTranslation();
const lang = i18n.language;
const selectedUrlId = useAppStore((s) => s.selectedUrlId);
const selectedUrlIds = useAppStore((s) => s.selectedUrlIds);
const [detail, setDetail] = useState<UrlDetail | null>(null);
@@ -230,25 +234,25 @@ export function BottomDetailPanel() {
return (
<div className="flex h-full flex-col bg-surface-950">
<div className="flex items-center border-b border-surface-800 bg-surface-900">
{SUB_TABS.map((t) => (
{SUB_TABS.map((tab) => (
<button
key={t.key}
disabled={t.disabled}
key={tab.key}
disabled={tab.disabled}
className={clsx(
'tab',
subTab === t.key && 'tab-active',
t.disabled && 'cursor-not-allowed opacity-40',
subTab === tab.key && 'tab-active',
tab.disabled && 'cursor-not-allowed opacity-40',
)}
onClick={() => !t.disabled && setSubTab(t.key)}
title={t.disabled ? 'Coming soon' : undefined}
onClick={() => !tab.disabled && setSubTab(tab.key)}
title={tab.disabled ? tx('detail.comingSoon', { defaultValue: 'Coming soon' }) : undefined}
>
{t.label}
{t.key === 'inlinks' && inlinksCountLabel !== undefined && (
{translateLabel(tab.label, lang)}
{tab.key === 'inlinks' && inlinksCountLabel !== undefined && (
<span className="ml-1 text-surface-500">
({inlinksCountLabel.toLocaleString()})
</span>
)}
{t.key === 'outlinks' && outlinksCountLabel !== undefined && (
{tab.key === 'outlinks' && outlinksCountLabel !== undefined && (
<span className="ml-1 text-surface-500">
({outlinksCountLabel.toLocaleString()})
</span>
@@ -260,10 +264,10 @@ export function BottomDetailPanel() {
<span className="font-mono text-accent-300">
{effectiveIds.length.toLocaleString()}
</span>{' '}
URLs selected
{tx('detail.urlsSelected', { defaultValue: 'URLs selected' })}
{truncated && (
<span className="ml-2 text-amber-400">
· aggregating first {MULTI_DETAIL_LIMIT}
· {tx('detail.aggregatingFirst', { defaultValue: 'aggregating first {{n}}', n: MULTI_DETAIL_LIMIT })}
</span>
)}
</div>
@@ -272,20 +276,20 @@ export function BottomDetailPanel() {
{showSingleScopeBanner && detail && (
<div className="shrink-0 border-b border-surface-800 bg-surface-900/40 px-3 py-1 text-[10.5px] text-surface-400">
This tab is per-page — showing data for{' '}
{tx('detail.perPagePrefix', { defaultValue: 'This tab is per-page — showing data for' })}{' '}
<span className="font-mono text-surface-200">{detail.row.url}</span>{' '}
(primary URL of the selection).
{tx('detail.perPageSuffix', { defaultValue: '(primary URL of the selection).' })}
</div>
)}
<div className="flex-1 overflow-auto">
{effectiveIds.length === 0 && (
<div className="flex h-full items-center justify-center text-xs text-surface-500">
Select a URL from the table to see details.
{tx('detail.selectUrl', { defaultValue: 'Select a URL from the table to see details.' })}
</div>
)}
{effectiveIds.length > 0 && !detail && loading && (
<div className="p-4 text-xs text-surface-500">Loading…</div>
<div className="p-4 text-xs text-surface-500">{tx('common.loading', { defaultValue: 'Loading…' })}</div>
)}
{detail && subTab === 'url-details' && <NameValueView row={detail.row} />}
{subTab === 'inlinks' &&
@@ -412,6 +416,7 @@ function ViewSourceView({
urlId: number | null;
pageUrl: string;
}) {
const { t } = useTranslation();
const [src, setSrc] = useState<UrlSourceResult | null>(null);
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState('');
@@ -450,15 +455,16 @@ function ViewSourceView({
}, [search, urlId]);
if (loading && !src) {
return <div className="p-4 text-[11px] text-surface-500">Loading source…</div>;
return <div className="p-4 text-[11px] text-surface-500">{t('viewSource.loadingSource', { defaultValue: 'Loading source…' })}</div>;
}
if (!src || src.body === null) {
return (
<div className="p-4 text-[11px] text-surface-500">
No HTML body stored for this URL.
{t('viewSource.noBody', { defaultValue: 'No HTML body stored for this URL.' })}
<div className="mt-1 text-[10px] text-surface-600">
View Source is only captured for HTML pages crawled with the
<span className="font-mono">storeBodySnapshots</span> setting enabled.
{t('viewSource.noBodyHint1', { defaultValue: 'View Source is only captured for HTML pages crawled with the' })}{' '}
<span className="font-mono">storeBodySnapshots</span>{' '}
{t('viewSource.noBodyHint2', { defaultValue: 'setting enabled.' })}
</div>
</div>
);
@@ -517,7 +523,7 @@ function ViewSourceView({
<input
type="text"
className="ml-3 w-48 rounded border border-surface-700 bg-surface-950 px-2 py-0.5 text-[11px] text-surface-100 focus:border-blue-500 focus:outline-none"
placeholder="Search source…"
placeholder={t('viewSource.searchPlaceholder', { defaultValue: 'Search source…' })}
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
@@ -537,13 +543,13 @@ function ViewSourceView({
{search && matches > 0 && (
<>
<span className="text-surface-500">
{safeActive + 1} of {matches}
{t('viewSource.matchOf', { defaultValue: '{{n}} of {{total}}', n: safeActive + 1, total: matches })}
</span>
<button
className="rounded border border-surface-700 px-1.5 py-0.5 text-surface-300 hover:bg-surface-800 disabled:opacity-40"
onClick={() => step(-1)}
disabled={matches < 2}
title="Previous match (Shift+Enter)"
title={t('viewSource.prevMatch', { defaultValue: 'Previous match (Shift+Enter)' })}
>
↑
</button>
@@ -551,14 +557,14 @@ function ViewSourceView({
className="rounded border border-surface-700 px-1.5 py-0.5 text-surface-300 hover:bg-surface-800 disabled:opacity-40"
onClick={() => step(1)}
disabled={matches < 2}
title="Next match (Enter)"
title={t('viewSource.nextMatch', { defaultValue: 'Next match (Enter)' })}
>
↓
</button>
</>
)}
{search && matches === 0 && (
<span className="text-surface-500">No matches</span>
<span className="text-surface-500">{t('viewSource.noMatches', { defaultValue: 'No matches' })}</span>
)}
<label className="flex items-center gap-1 text-surface-400">
<input
@@ -567,20 +573,20 @@ function ViewSourceView({
onChange={(e) => setWrap(e.target.checked)}
className="h-3 w-3"
/>
Wrap
{t('viewSource.wrap', { defaultValue: 'Wrap' })}
</label>
<div className="ml-auto flex gap-1.5">
<button
className="rounded border border-surface-700 px-2 py-0.5 text-[10px] hover:bg-surface-800"
onClick={copy}
>
Copy
{t('logs.copy', { defaultValue: 'Copy' })}
</button>
<button
className="rounded border border-surface-700 px-2 py-0.5 text-[10px] hover:bg-surface-800"
onClick={download}
>
Download
{t('viewSource.download', { defaultValue: 'Download' })}
</button>
</div>
</div>
@@ -637,6 +643,8 @@ function ViewSourceScrollEffect({
}
function DuplicatesView({ urlId, row }: { urlId: number; row: CrawlUrlRow }) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const [members, setMembers] = useState<UrlClusterMember[] | null>(null);
const [loading, setLoading] = useState(false);
@@ -658,21 +666,19 @@ function DuplicatesView({ urlId, row }: { urlId: number; row: CrawlUrlRow }) {
}, [urlId]);
if (loading) {
return <div className="p-3 text-[11px] text-surface-400">Loading…</div>;
return <div className="p-3 text-[11px] text-surface-400">{t('common.loading', { defaultValue: 'Loading…' })}</div>;
}
if (row.clusterId === 0 || (row.clusterSize ?? 1) <= 1) {
return (
<div className="p-3 text-[11px] text-surface-500">
This URL is not part of a near-duplicate cluster. Run Crawl Analysis on
a finished crawl, or lower the Hamming threshold in Settings →
Duplicates, to widen what counts as a near-duplicate.
{t('duplicates.notInCluster', { defaultValue: 'This URL is not part of a near-duplicate cluster. Run Crawl Analysis on a finished crawl, or lower the Hamming threshold in Settings → Duplicates, to widen what counts as a near-duplicate.' })}
</div>
);
}
if (!members || members.length === 0) {
return (
<div className="p-3 text-[11px] text-surface-500">
No other members in this cluster. (Cluster size {row.clusterSize}.)
{t('duplicates.noOtherMembers', { defaultValue: 'No other members in this cluster. (Cluster size {{n}}.)', n: row.clusterSize })}
</div>
);
}
@@ -681,26 +687,26 @@ function DuplicatesView({ urlId, row }: { urlId: number; row: CrawlUrlRow }) {
<div className="flex h-full flex-col">
<div className="flex items-center gap-3 border-b border-surface-800 bg-surface-900 px-3 py-1.5 text-[10px] text-surface-400">
<span>
Cluster <span className="text-surface-200">#{row.clusterId}</span>
{t('duplicates.cluster', { defaultValue: 'Cluster' })} <span className="text-surface-200">#{row.clusterId}</span>
</span>
<span>
{members.length + 1} member{members.length + 1 === 1 ? '' : 's'} total
{t('duplicates.membersTotal', { defaultValue: '{{n}} member(s) total', n: members.length + 1 })}
</span>
<span className="text-surface-500">
(Hamming distance from this URL — lower means more similar)
{t('duplicates.hammingHint', { defaultValue: '(Hamming distance from this URL — lower means more similar)' })}
</span>
</div>
<div className="flex-1 overflow-auto">
<table className="w-full text-[11px]">
<thead className="sticky top-0 bg-surface-900 text-left text-[10px] text-surface-400">
<tr>
<th className="px-3 py-1 font-normal">Hamming</th>
<th className="px-3 py-1 font-normal">{translateLabel('Hamming', lang)}</th>
<th className="px-3 py-1 font-normal">URL</th>
<th className="px-3 py-1 font-normal">Status</th>
<th className="px-3 py-1 font-normal">Indexability</th>
<th className="px-3 py-1 font-normal">Words</th>
<th className="px-3 py-1 font-normal">Inlinks</th>
<th className="px-3 py-1 font-normal">Title</th>
<th className="px-3 py-1 font-normal">{translateLabel('Status', lang)}</th>
<th className="px-3 py-1 font-normal">{translateLabel('Indexability', lang)}</th>
<th className="px-3 py-1 font-normal">{translateLabel('Words', lang)}</th>
<th className="px-3 py-1 font-normal">{translateLabel('Inlinks', lang)}</th>
<th className="px-3 py-1 font-normal">{translateLabel('Title', lang)}</th>
</tr>
</thead>
<tbody>
@@ -807,6 +813,8 @@ function HttpHeadersView({
headers: { name: string; value: string }[];
row: CrawlUrlRow;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const config = useAppStore((s) => s.config);
const [side, setSide] = useState<'response' | 'request'>('response');
// Request headers reconstructed from the active crawl config — the
@@ -875,8 +883,8 @@ function HttpHeadersView({
<table className="w-full text-[11px]">
<thead className="sticky top-0 bg-surface-900">
<tr className="text-surface-400">
<th className="w-64 py-1 pr-3 text-left font-medium">Header</th>
<th className="py-1 text-left font-medium">Value</th>
<th className="w-64 py-1 pr-3 text-left font-medium">{translateLabel('Header', lang)}</th>
<th className="py-1 text-left font-medium">{translateLabel('Value', lang)}</th>
</tr>
</thead>
<tbody>
@@ -898,6 +906,7 @@ function HttpHeadersView({
}
function StatusDiagnosisBanner({ diag }: { diag: StatusDiagnosis }) {
const { t } = useTranslation();
const tone =
diag.severity === 'error'
? { border: 'border-red-700/50', bg: 'bg-red-900/15', text: 'text-red-200', head: 'text-red-300' }
@@ -911,7 +920,7 @@ function StatusDiagnosisBanner({ diag }: { diag: StatusDiagnosis }) {
{diag.signals.length > 0 && (
<div className="mt-2">
<div className="mb-0.5 text-[10px] font-medium uppercase tracking-wider text-surface-400">
From the response headers
{t('diag.fromHeaders', { defaultValue: 'From the response headers' })}
</div>
<ul className="list-disc space-y-0.5 pl-4 text-surface-200">
{diag.signals.map((s, i) => (
@@ -923,7 +932,7 @@ function StatusDiagnosisBanner({ diag }: { diag: StatusDiagnosis }) {
{diag.causes.length > 0 && (
<div className="mt-2">
<div className="mb-0.5 text-[10px] font-medium uppercase tracking-wider text-surface-400">
Likely causes
{t('diag.likelyCauses', { defaultValue: 'Likely causes' })}
</div>
<ul className="list-disc space-y-0.5 pl-4 text-surface-300">
{diag.causes.map((c, i) => (
@@ -935,7 +944,7 @@ function StatusDiagnosisBanner({ diag }: { diag: StatusDiagnosis }) {
{diag.whatToDo.length > 0 && (
<div className="mt-2">
<div className="mb-0.5 text-[10px] font-medium uppercase tracking-wider text-surface-400">
What to do
{t('diag.whatToDo', { defaultValue: 'What to do' })}
</div>
<ul className="list-disc space-y-0.5 pl-4 text-surface-200">
{diag.whatToDo.map((w, i) => (
@@ -1037,6 +1046,8 @@ function fleschBand(score: number): string {
}
function NameValueView({ row }: { row: CrawlUrlRow }) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
// Server-side pixel-width is the source of truth (drives the issue
// filters); fall back to the renderer estimate only when the column is
// legitimately 0 because the title/desc is empty.
@@ -1276,14 +1287,14 @@ function NameValueView({ row }: { row: CrawlUrlRow }) {
<table className="w-full text-[11px]">
<thead className="sticky top-0 bg-surface-900">
<tr className="text-surface-400">
<th className="w-64 py-1 pr-3 text-left font-medium">Name</th>
<th className="py-1 text-left font-medium">Value</th>
<th className="w-64 py-1 pr-3 text-left font-medium">{translateLabel('Name', lang)}</th>
<th className="py-1 text-left font-medium">{translateLabel('Value', lang)}</th>
</tr>
</thead>
<tbody>
{fields.map(([label, value]) => (
<tr key={label} className="border-b border-surface-900 last:border-0">
<td className="py-1.5 pr-3 align-top text-surface-400">{label}</td>
<td className="py-1.5 pr-3 align-top text-surface-400">{translateLabel(label, lang)}</td>
<td
className="break-all py-1.5 font-mono text-surface-100"
title={value !== null && value !== undefined ? String(value) : ''}
@@ -1416,6 +1427,8 @@ function LinksView({
columns: LinksColumn[];
rows: string[][];
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const prefsKey = LINKS_PREFS_PREFIX + tableId;
const [colWidths, setColWidths] = useState<Record<string, number>>(() => {
const v = window.freecrawl.prefsGet(prefsKey);
@@ -1694,11 +1707,13 @@ function LinksView({
return (
<div ref={rootRef} className="relative flex h-full select-none flex-col">
<div className="shrink-0 px-3 pt-2 text-[11px] text-surface-500">
Showing <span className="font-mono text-surface-200">{shown.toLocaleString()}</span> of{' '}
{t('links.showing', { defaultValue: 'Showing' })}{' '}
<span className="font-mono text-surface-200">{shown.toLocaleString()}</span>{' '}
{t('links.of', { defaultValue: 'of' })}{' '}
<span className="font-mono text-surface-200">{total.toLocaleString()}</span>
</div>
{rows.length === 0 ? (
<div className="py-8 text-center text-xs text-surface-500">No links.</div>
<div className="py-8 text-center text-xs text-surface-500">{t('links.noLinks', { defaultValue: 'No links.' })}</div>
) : (
<div className="mt-2 flex-1 overflow-auto">
<div style={{ minWidth: totalWidth, width: '100%' }}>
@@ -1721,9 +1736,9 @@ function LinksView({
onMouseEnter={() => {
if (dragRef.current?.kind === 'column') applyColumnDrag(ci);
}}
title="Click to select column · drag across headers to select multiple · drag right edge to resize"
title={t('links.columnHint', { defaultValue: 'Click to select column · drag across headers to select multiple · drag right edge to resize' })}
>
<span className="truncate">{c.header}</span>
<span className="truncate">{translateLabel(c.header, lang)}</span>
<div
className="absolute -right-1 top-0 bottom-0 z-20 w-2 cursor-col-resize hover:bg-accent-500/40"
onMouseDown={(e) => {
@@ -1737,7 +1752,7 @@ function LinksView({
resetColumn(c.id);
}}
onClick={(e) => e.stopPropagation()}
title="Drag to resize · double-click to reset"
title={t('links.dragResize', { defaultValue: 'Drag to resize · double-click to reset' })}
/>
</div>
);
@@ -1865,32 +1880,29 @@ function CellContextMenu({
onOpen: () => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const items: { label: string; action: () => void; disabled?: boolean }[] = [
{
label:
selectionSize > 1
? `Copy ${selectionSize.toLocaleString()} Cells`
: 'Copy Cell',
? t('cellMenu.copyCells', { defaultValue: 'Copy {{n}} Cells', n: selectionSize.toLocaleString() })
: t('cellMenu.copyCell', { defaultValue: 'Copy Cell' }),
action: onCopy,
disabled: selectionSize === 0,
},
];
if (clickedIsUrl) {
// When the right-clicked URL is part of a multi-cell selection
// that spans multiple URL-looking cells, "Copy URL" copies ALL of
// them (one per line). Single-cell selection collapses to the
// legacy "copy this one URL" behaviour.
if (urlCountInSelection > 1) {
items.push({
label: `Copy ${urlCountInSelection.toLocaleString()} URLs`,
label: t('cellMenu.copyUrls', { defaultValue: 'Copy {{n}} URLs', n: urlCountInSelection.toLocaleString() }),
action: onCopyUrls,
});
} else {
items.push({ label: 'Copy URL', action: onCopyValue });
items.push({ label: t('cellMenu.copyUrl', { defaultValue: 'Copy URL' }), action: onCopyValue });
}
items.push({ label: 'Open in Browser', action: onOpen });
items.push({ label: t('cellMenu.openInBrowser', { defaultValue: 'Open in Browser' }), action: onOpen });
} else if (clickedValue) {
items.push({ label: 'Copy Value', action: onCopyValue });
items.push({ label: t('cellMenu.copyValue', { defaultValue: 'Copy Value' }), action: onCopyValue });
}
return (
@@ -2035,12 +2047,14 @@ async function copyCellsToClipboard(
}
function SerpSnippet({ row }: { row: CrawlUrlRow }) {
const title = row.title ?? '(no title)';
const desc = row.metaDescription ?? '(no meta description)';
const { t } = useTranslation();
const title = row.title ?? t('serp.noTitle', { defaultValue: '(no title)' });
const desc = row.metaDescription ?? t('serp.noMeta', { defaultValue: '(no meta description)' });
const titlePx = row.title ? measurePixelWidth(row.title, 15) : 0;
const descPx = row.metaDescription ? measurePixelWidth(row.metaDescription, 13) : 0;
const titleLimit = 600;
const descLimit = 990;
const charsSuffix = t('serp.chars', { defaultValue: 'chars' });
return (
<div className="p-5">
@@ -2057,16 +2071,16 @@ function SerpSnippet({ row }: { row: CrawlUrlRow }) {
</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-3 text-[11px]">
<InfoLine label="Title pixel width" value={`${titlePx}px / ${titleLimit}px`} warn={titlePx > titleLimit} />
<InfoLine label="Title length" value={String(row.titleLength ?? 0) + ' chars'} />
<InfoLine label={t('serp.titlePixelWidth', { defaultValue: 'Title pixel width' })} value={`${titlePx}px / ${titleLimit}px`} warn={titlePx > titleLimit} />
<InfoLine label={t('serp.titleLength', { defaultValue: 'Title length' })} value={String(row.titleLength ?? 0) + ' ' + charsSuffix} />
<InfoLine
label="Description pixel width"
label={t('serp.descriptionPixelWidth', { defaultValue: 'Description pixel width' })}
value={`${descPx}px / ${descLimit}px`}
warn={descPx > descLimit}
/>
<InfoLine
label="Description length"
value={String(row.metaDescriptionLength ?? 0) + ' chars'}
label={t('serp.descriptionLength', { defaultValue: 'Description length' })}
value={String(row.metaDescriptionLength ?? 0) + ' ' + charsSuffix}
/>
</div>
</div>
@@ -2291,6 +2305,8 @@ function CookiesView({
row: CrawlUrlRow;
headers: { name: string; value: string }[];
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const cookies: ParsedCookie[] = [];
for (const h of headers) {
if (h.name.toLowerCase() !== 'set-cookie') continue;
@@ -2303,11 +2319,11 @@ function CookiesView({
if (cookies.length === 0) {
return (
<div className="p-4 text-[11px] text-surface-500">
This page did not set any cookies (no <span className="font-mono">Set-Cookie</span> response headers).
{t('cookies.emptyPrefix', { defaultValue: 'This page did not set any cookies (no' })}{' '}
<span className="font-mono">Set-Cookie</span>{' '}
{t('cookies.emptySuffix', { defaultValue: 'response headers).' })}
<div className="mt-2 text-[10px] text-surface-600">
Note: only first-party cookies set by the page itself are listed here.
Cookies set by third-party scripts (analytics, ads) are set in the
browser at runtime and are not visible to a static crawler.
{t('cookies.emptyNote', { defaultValue: 'Note: only first-party cookies set by the page itself are listed here. Cookies set by third-party scripts (analytics, ads) are set in the browser at runtime and are not visible to a static crawler.' })}
</div>
</div>
);
@@ -2317,31 +2333,31 @@ function CookiesView({
<div className="p-3">
<div className="mb-2 flex flex-wrap gap-3 text-[11px] text-surface-400">
<span>
<span className="font-medium text-surface-200">{cookies.length}</span> cookies set
<span className="font-medium text-surface-200">{cookies.length}</span> {t('cookies.cookiesSet', { defaultValue: 'cookies set' })}
</span>
{row.cookiesInsecure > 0 && (
<span className="text-amber-400">
{row.cookiesInsecure} missing <code>Secure</code>
{t('cookies.missing', { defaultValue: '{{n}} missing', n: row.cookiesInsecure })} <code>Secure</code>
</span>
)}
{row.cookiesNoHttpOnly > 0 && (
<span className="text-amber-400">
{row.cookiesNoHttpOnly} missing <code>HttpOnly</code>
{t('cookies.missing', { defaultValue: '{{n}} missing', n: row.cookiesNoHttpOnly })} <code>HttpOnly</code>
</span>
)}
{row.cookiesNoSameSite > 0 && (
<span className="text-amber-400">
{row.cookiesNoSameSite} missing <code>SameSite</code>
{t('cookies.missing', { defaultValue: '{{n}} missing', n: row.cookiesNoSameSite })} <code>SameSite</code>
</span>
)}
</div>
<table className="w-full text-[11px]">
<thead className="sticky top-0 bg-surface-900">
<tr className="text-surface-400">
<th className="py-1 pr-3 text-left font-medium">Name</th>
<th className="py-1 pr-3 text-left font-medium">Domain</th>
<th className="py-1 pr-3 text-left font-medium">Path</th>
<th className="py-1 pr-3 text-left font-medium">Expires</th>
<th className="py-1 pr-3 text-left font-medium">{translateLabel('Name', lang)}</th>
<th className="py-1 pr-3 text-left font-medium">{translateLabel('Domain', lang)}</th>
<th className="py-1 pr-3 text-left font-medium">{translateLabel('Path', lang)}</th>
<th className="py-1 pr-3 text-left font-medium">{translateLabel('Expires', lang)}</th>
<th className="py-1 pr-3 text-center font-medium">Secure</th>
<th className="py-1 pr-3 text-center font-medium">HttpOnly</th>
<th className="py-1 text-left font-medium">SameSite</th>
@@ -2428,6 +2444,7 @@ function StructuredDataView({
urlId: number | null;
row: CrawlUrlRow;
}) {
const { t } = useTranslation();
const [src, setSrc] = useState<UrlSourceResult | null>(null);
const [loading, setLoading] = useState(false);
@@ -2467,31 +2484,31 @@ function StructuredDataView({
<div className="flex h-full flex-col">
<div className="flex flex-wrap items-center gap-3 border-b border-surface-800 bg-surface-900/50 px-3 py-1.5 text-[11px] text-surface-400">
<span>
<span className="font-medium text-surface-200">{row.schemaBlockCount}</span> JSON-LD
{row.schemaBlockCount === 1 ? ' block' : ' blocks'}
<span className="font-medium text-surface-200">{row.schemaBlockCount}</span> JSON-LD{' '}
{t('structured.blocks', { defaultValue: 'block(s)' })}
</span>
{row.schemaInvalidCount > 0 && (
<span className="text-amber-400">{row.schemaInvalidCount} invalid</span>
<span className="text-amber-400">{t('structured.invalid', { defaultValue: '{{n}} invalid', n: row.schemaInvalidCount })}</span>
)}
<span>
<span className="font-medium text-surface-200">{row.microdataCount}</span> microdata items
<span className="font-medium text-surface-200">{row.microdataCount}</span> {t('structured.microdataItems', { defaultValue: 'microdata items' })}
</span>
<span>
<span className="font-medium text-surface-200">{row.rdfaCount}</span> RDFa attrs
<span className="font-medium text-surface-200">{row.rdfaCount}</span> {t('structured.rdfaAttrs', { defaultValue: 'RDFa attrs' })}
</span>
</div>
<div className="flex-1 overflow-auto p-3">
{!hasAnyData && (
<div className="text-[11px] text-surface-500">
No structured data declared on this page (no JSON-LD, microdata or RDFa).
{t('structured.empty', { defaultValue: 'No structured data declared on this page (no JSON-LD, microdata or RDFa).' })}
</div>
)}
{types.length > 0 && (
<div className="mb-3">
<div className="mb-1 text-[10px] uppercase tracking-wide text-surface-500">
Schema types
{t('structured.schemaTypes', { defaultValue: 'Schema types' })}
</div>
<div className="flex flex-wrap gap-1.5">
{types.map((t) => (
@@ -2507,13 +2524,13 @@ function StructuredDataView({
)}
{loading && blocks.length === 0 && (
<div className="text-[11px] text-surface-500">Loading source…</div>
<div className="text-[11px] text-surface-500">{t('structured.loadingSource', { defaultValue: 'Loading source…' })}</div>
)}
{blocks.length > 0 && (
<div className="space-y-3">
<div className="text-[10px] uppercase tracking-wide text-surface-500">
JSON-LD blocks ({blocks.length})
{t('structured.jsonLdBlocks', { defaultValue: 'JSON-LD blocks' })} ({blocks.length})
</div>
{blocks.map((b) => (
<div
@@ -2521,11 +2538,11 @@ function StructuredDataView({
className="rounded border border-surface-800 bg-surface-900/40"
>
<div className="flex items-center gap-2 border-b border-surface-800 px-2 py-1 text-[10px] text-surface-400">
<span className="font-mono">Block #{b.index + 1}</span>
<span className="font-mono">{t('structured.blockN', { defaultValue: 'Block #{{n}}', n: b.index + 1 })}</span>
{b.ok ? (
<span className="text-emerald-400">parsed OK</span>
<span className="text-emerald-400">{t('structured.parsedOk', { defaultValue: 'parsed OK' })}</span>
) : (
<span className="text-amber-400">parse failed</span>
<span className="text-amber-400">{t('structured.parseFailed', { defaultValue: 'parse failed' })}</span>
)}
</div>
<pre className="overflow-auto p-2 font-mono text-[10.5px] leading-[14px] text-surface-200">
@@ -2540,10 +2557,9 @@ function StructuredDataView({
{!loading && blocks.length === 0 && row.schemaBlockCount > 0 && (
<div className="mt-2 text-[10px] text-surface-600">
JSON-LD blocks were detected during crawl but the page body
snapshot is unavailable. Re-crawl with{' '}
<span className="font-mono">storeBodySnapshots</span> enabled to
view the raw payload.
{t('structured.snapshotUnavailable1', { defaultValue: 'JSON-LD blocks were detected during crawl but the page body snapshot is unavailable. Re-crawl with' })}{' '}
<span className="font-mono">storeBodySnapshots</span>{' '}
{t('structured.snapshotUnavailable2', { defaultValue: 'enabled to view the raw payload.' })}
</div>
)}
</div>
@@ -2558,6 +2574,8 @@ function ImagesView({
urlId: number | null;
row: CrawlUrlRow;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const [rows, setRows] = useState<UrlPageImageRow[]>([]);
const [loading, setLoading] = useState(false);
@@ -2582,12 +2600,14 @@ function ImagesView({
}, [urlId]);
if (loading && rows.length === 0) {
return <div className="p-4 text-[11px] text-surface-500">Loading images…</div>;
return <div className="p-4 text-[11px] text-surface-500">{t('imagesView.loading', { defaultValue: 'Loading images…' })}</div>;
}
if (rows.length === 0) {
return (
<div className="p-4 text-[11px] text-surface-500">
No <code>&lt;img&gt;</code> tags discovered on this page.
{t('imagesView.emptyPrefix', { defaultValue: 'No' })}{' '}
<code>&lt;img&gt;</code>{' '}
{t('imagesView.emptySuffix', { defaultValue: 'tags discovered on this page.' })}
</div>
);
}
@@ -2604,22 +2624,22 @@ function ImagesView({
<div className="flex h-full flex-col">
<div className="flex flex-wrap items-center gap-3 border-b border-surface-800 bg-surface-900/50 px-3 py-1.5 text-[11px] text-surface-400">
<span>
<span className="font-medium text-surface-200">{rows.length}</span> images
<span className="font-medium text-surface-200">{rows.length}</span> {t('imagesView.images', { defaultValue: 'images' })}
</span>
{missingAlt > 0 && (
<span className="text-amber-400">{missingAlt} missing alt</span>
<span className="text-amber-400">{t('imagesView.missingAlt', { defaultValue: '{{n}} missing alt', n: missingAlt })}</span>
)}
{emptyAlt > 0 && (
<span className="text-surface-300">{emptyAlt} empty alt (decorative)</span>
<span className="text-surface-300">{t('imagesView.emptyAlt', { defaultValue: '{{n}} empty alt (decorative)', n: emptyAlt })}</span>
)}
{externalCount > 0 && (
<span>
<span className="font-medium text-surface-200">{externalCount}</span> external
<span className="font-medium text-surface-200">{externalCount}</span> {t('imagesView.external', { defaultValue: 'external' })}
</span>
)}
{largeCount > 0 && (
<span className="text-amber-400">
{largeCount} &gt; 100&nbsp;KB
{t('imagesView.largeCount', { defaultValue: '{{n}} > 100 KB', n: largeCount })}
</span>
)}
{row.imagesCount > rows.length && (
@@ -2632,12 +2652,12 @@ function ImagesView({
<table className="w-full text-[11px]">
<thead className="sticky top-0 bg-surface-900">
<tr className="text-surface-400">
<th className="py-1 pr-3 text-left font-medium">Source</th>
<th className="py-1 pr-3 text-left font-medium">Alt</th>
<th className="py-1 pr-3 text-left font-medium">{translateLabel('Source', lang)}</th>
<th className="py-1 pr-3 text-left font-medium">{translateLabel('Alt', lang)}</th>
<th className="py-1 pr-3 text-right font-medium">W</th>
<th className="py-1 pr-3 text-right font-medium">H</th>
<th className="py-1 pr-3 text-right font-medium">Size</th>
<th className="py-1 text-left font-medium">Scope</th>
<th className="py-1 pr-3 text-right font-medium">{translateLabel('Size', lang)}</th>
<th className="py-1 text-left font-medium">{translateLabel('Scope', lang)}</th>
</tr>
</thead>
<tbody>
@@ -2806,6 +2826,8 @@ function ResourcesView({
urlId: number | null;
row: CrawlUrlRow;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const [src, setSrc] = useState<UrlSourceResult | null>(null);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<
@@ -2833,14 +2855,16 @@ function ResourcesView({
}, [urlId]);
if (loading && !src) {
return <div className="p-4 text-[11px] text-surface-500">Loading source…</div>;
return <div className="p-4 text-[11px] text-surface-500">{t('resources.loadingSource', { defaultValue: 'Loading source…' })}</div>;
}
if (!src || src.body === null) {
return (
<div className="p-4 text-[11px] text-surface-500">
Resources view requires a stored HTML body snapshot.
{t('resources.requiresSnapshot', { defaultValue: 'Resources view requires a stored HTML body snapshot.' })}
<div className="mt-1 text-[10px] text-surface-600">
Re-crawl with the <span className="font-mono">storeBodySnapshots</span> setting enabled.
{t('resources.recrawlPrefix', { defaultValue: 'Re-crawl with the' })}{' '}
<span className="font-mono">storeBodySnapshots</span>{' '}
{t('resources.recrawlSuffix', { defaultValue: 'setting enabled.' })}
</div>
</div>
);
@@ -2885,21 +2909,21 @@ function ResourcesView({
: 'border-surface-700 text-surface-300 hover:bg-surface-800',
)}
>
{f.label} <span className="text-surface-500">({f.count})</span>
{translateLabel(f.label, lang)} <span className="text-surface-500">({f.count})</span>
</button>
))}
</div>
<div className="flex-1 overflow-auto p-3">
{filtered.length === 0 ? (
<div className="text-[11px] text-surface-500">No resources match this filter.</div>
<div className="text-[11px] text-surface-500">{t('resources.noMatch', { defaultValue: 'No resources match this filter.' })}</div>
) : (
<table className="w-full text-[11px]">
<thead className="sticky top-0 bg-surface-900">
<tr className="text-surface-400">
<th className="w-24 py-1 pr-3 text-left font-medium">Type</th>
<th className="w-24 py-1 pr-3 text-left font-medium">{translateLabel('Type', lang)}</th>
<th className="py-1 pr-3 text-left font-medium">URL</th>
<th className="w-16 py-1 pr-3 text-center font-medium">3rd-party</th>
<th className="py-1 text-left font-medium">Hints</th>
<th className="py-1 text-left font-medium">{translateLabel('Hints', lang)}</th>
</tr>
</thead>
<tbody>
@@ -2972,6 +2996,8 @@ interface MultiPage {
* source URL of each image is unambiguous.
*/
function MultiImagesView({ pages }: { pages: MultiPage[] }) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const [byPage, setByPage] = useState<Map<number, UrlPageImageRow[]>>(new Map());
const [loading, setLoading] = useState(false);
const key = pages.map((p) => p.id).join(',');
@@ -3004,7 +3030,7 @@ function MultiImagesView({ pages }: { pages: MultiPage[] }) {
}, [key]);
if (loading && byPage.size === 0) {
return <div className="p-4 text-[11px] text-surface-500">Loading images…</div>;
return <div className="p-4 text-[11px] text-surface-500">{t('imagesView.loading', { defaultValue: 'Loading images…' })}</div>;
}
let total = 0;
let missingAlt = 0;
@@ -3023,7 +3049,7 @@ function MultiImagesView({ pages }: { pages: MultiPage[] }) {
if (total === 0) {
return (
<div className="p-4 text-[11px] text-surface-500">
No <code>&lt;img&gt;</code> tags discovered across the {pages.length} selected pages.
{t('imagesView.multiEmpty', { defaultValue: 'No <img> tags discovered across the {{n}} selected pages.', n: pages.length })}
</div>
);
}
@@ -3033,30 +3059,29 @@ function MultiImagesView({ pages }: { pages: MultiPage[] }) {
<div className="flex flex-wrap items-center gap-3 border-b border-surface-800 bg-surface-900/50 px-3 py-1.5 text-[11px] text-surface-400">
<span>
<span className="font-medium text-surface-200">{total.toLocaleString()}</span>{' '}
images across <span className="font-medium text-surface-200">{pages.length}</span>{' '}
pages
{t('imagesView.multiSummary', { defaultValue: 'images across {{n}} pages', n: pages.length })}
</span>
{missingAlt > 0 && <span className="text-amber-400">{missingAlt} missing alt</span>}
{missingAlt > 0 && <span className="text-amber-400">{t('imagesView.missingAlt', { defaultValue: '{{n}} missing alt', n: missingAlt })}</span>}
{externalCount > 0 && (
<span>
<span className="font-medium text-surface-200">{externalCount}</span> external
<span className="font-medium text-surface-200">{externalCount}</span> {t('imagesView.external', { defaultValue: 'external' })}
</span>
)}
{largeCount > 0 && (
<span className="text-amber-400">{largeCount} &gt; 100&nbsp;KB</span>
<span className="text-amber-400">{t('imagesView.largeCount', { defaultValue: '{{n}} > 100 KB', n: largeCount })}</span>
)}
</div>
<div className="flex-1 overflow-auto p-3">
<table className="w-full text-[11px]">
<thead className="sticky top-0 bg-surface-900">
<tr className="text-surface-400">
<th className="py-1 pr-3 text-left font-medium">Page</th>
<th className="py-1 pr-3 text-left font-medium">Source</th>
<th className="py-1 pr-3 text-left font-medium">Alt</th>
<th className="py-1 pr-3 text-left font-medium">{translateLabel('Page', lang)}</th>
<th className="py-1 pr-3 text-left font-medium">{translateLabel('Source', lang)}</th>
<th className="py-1 pr-3 text-left font-medium">{translateLabel('Alt', lang)}</th>
<th className="py-1 pr-3 text-right font-medium">W</th>
<th className="py-1 pr-3 text-right font-medium">H</th>
<th className="py-1 pr-3 text-right font-medium">Size</th>
<th className="py-1 text-left font-medium">Scope</th>
<th className="py-1 pr-3 text-right font-medium">{translateLabel('Size', lang)}</th>
<th className="py-1 text-left font-medium">{translateLabel('Scope', lang)}</th>
</tr>
</thead>
<tbody>
@@ -3133,6 +3158,8 @@ function MultiImagesView({ pages }: { pages: MultiPage[] }) {
* everything into one flat table with a leading Page column.
*/
function MultiResourcesView({ pages }: { pages: MultiPage[] }) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const [byPage, setByPage] = useState<Map<number, ResourceEntry[]>>(new Map());
const [missingSnapshots, setMissingSnapshots] = useState<number>(0);
const [loading, setLoading] = useState(false);
@@ -3179,7 +3206,7 @@ function MultiResourcesView({ pages }: { pages: MultiPage[] }) {
}, [key]);
if (loading && byPage.size === 0 && missingSnapshots === 0) {
return <div className="p-4 text-[11px] text-surface-500">Loading resources…</div>;
return <div className="p-4 text-[11px] text-surface-500">{t('resources.loadingResources', { defaultValue: 'Loading resources…' })}</div>;
}
const allEntries: { page: MultiPage; entry: ResourceEntry }[] = [];
@@ -3215,9 +3242,11 @@ function MultiResourcesView({ pages }: { pages: MultiPage[] }) {
if (allEntries.length === 0 && missingSnapshots === pages.length) {
return (
<div className="p-4 text-[11px] text-surface-500">
Resources view requires a stored HTML body snapshot for each page.
{t('resources.multiRequiresSnapshot', { defaultValue: 'Resources view requires a stored HTML body snapshot for each page.' })}
<div className="mt-1 text-[10px] text-surface-600">
Re-crawl with the <span className="font-mono">storeBodySnapshots</span> setting enabled.
{t('resources.recrawlPrefix', { defaultValue: 'Re-crawl with the' })}{' '}
<span className="font-mono">storeBodySnapshots</span>{' '}
{t('resources.recrawlSuffix', { defaultValue: 'setting enabled.' })}
</div>
</div>
);
@@ -3237,24 +3266,24 @@ function MultiResourcesView({ pages }: { pages: MultiPage[] }) {
: 'border-surface-700 text-surface-300 hover:bg-surface-800',
)}
>
{f.label} <span className="text-surface-500">({f.count})</span>
{translateLabel(f.label, lang)} <span className="text-surface-500">({f.count})</span>
</button>
))}
{missingSnapshots > 0 && (
<span className="ml-2 text-[10px] text-amber-400">
{missingSnapshots} of {pages.length} pages have no stored body
{t('resources.missingSnapshots', { defaultValue: '{{missing}} of {{total}} pages have no stored body', missing: missingSnapshots, total: pages.length })}
</span>
)}
</div>
<div className="flex-1 overflow-auto p-3">
{filtered.length === 0 ? (
<div className="text-[11px] text-surface-500">No resources match this filter.</div>
<div className="text-[11px] text-surface-500">{t('resources.noMatch', { defaultValue: 'No resources match this filter.' })}</div>
) : (
<table className="w-full text-[11px]">
<thead className="sticky top-0 bg-surface-900">
<tr className="text-surface-400">
<th className="py-1 pr-3 text-left font-medium">Page</th>
<th className="w-24 py-1 pr-3 text-left font-medium">Type</th>
<th className="py-1 pr-3 text-left font-medium">{translateLabel('Page', lang)}</th>
<th className="w-24 py-1 pr-3 text-left font-medium">{translateLabel('Type', lang)}</th>
<th className="py-1 pr-3 text-left font-medium">URL</th>
<th className="w-16 py-1 pr-3 text-center font-medium">3rd-party</th>
</tr>
@@ -3304,6 +3333,8 @@ function MultiResourcesView({ pages }: { pages: MultiPage[] }) {
}
function ExtractedDataView({ row }: { row: CrawlUrlRow }) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const extraction = row.extractionResults
? safeJsonParse(row.extractionResults)
: null;
@@ -3321,10 +3352,12 @@ function ExtractedDataView({ row }: { row: CrawlUrlRow }) {
if (!hasExtraction && !hasSearch) {
return (
<div className="p-4 text-[11px] text-surface-500">
No custom extraction rules or search terms have produced data for this page.
{t('extraction.empty1', { defaultValue: 'No custom extraction rules or search terms have produced data for this page.' })}
<div className="mt-2 text-[10px] text-surface-600">
Configure rules in <span className="font-mono">Settings → Extraction</span> or search
terms in <span className="font-mono">Settings → Custom Search</span>, then re-crawl.
{t('extraction.empty2', { defaultValue: 'Configure rules in' })}{' '}
<span className="font-mono">Settings → Extraction</span>{' '}
{t('extraction.empty3', { defaultValue: 'or search terms in' })}{' '}
<span className="font-mono">Settings → Custom Search</span>, {t('extraction.empty4', { defaultValue: 'then re-crawl.' })}
</div>
</div>
);
@@ -3335,13 +3368,13 @@ function ExtractedDataView({ row }: { row: CrawlUrlRow }) {
{hasExtraction && (
<section>
<div className="mb-2 text-[10px] uppercase tracking-wide text-surface-500">
Custom Extraction
{t('extraction.customExtraction', { defaultValue: 'Custom Extraction' })}
</div>
<table className="w-full text-[11px]">
<thead className="bg-surface-900">
<tr className="text-surface-400">
<th className="w-64 py-1 pr-3 text-left font-medium">Rule</th>
<th className="py-1 text-left font-medium">Value</th>
<th className="w-64 py-1 pr-3 text-left font-medium">{translateLabel('Rule', lang)}</th>
<th className="py-1 text-left font-medium">{translateLabel('Value', lang)}</th>
</tr>
</thead>
<tbody>
@@ -3361,13 +3394,13 @@ function ExtractedDataView({ row }: { row: CrawlUrlRow }) {
{hasSearch && (
<section>
<div className="mb-2 text-[10px] uppercase tracking-wide text-surface-500">
Custom Search hits
{t('extraction.customSearchHits', { defaultValue: 'Custom Search hits' })}
</div>
<table className="w-full text-[11px]">
<thead className="bg-surface-900">
<tr className="text-surface-400">
<th className="w-64 py-1 pr-3 text-left font-medium">Term</th>
<th className="py-1 text-right font-medium">Hits</th>
<th className="w-64 py-1 pr-3 text-left font-medium">{translateLabel('Term', lang)}</th>
<th className="py-1 text-right font-medium">{translateLabel('Hits', lang)}</th>
</tr>
</thead>
<tbody>
@@ -3440,28 +3473,24 @@ function parseHeadings(json: string | null): HeadingEntry[] {
}
function OutlineView({ row }: { row: CrawlUrlRow }) {
const { t } = useTranslation();
const outline = parseHeadings(row.headings);
if (outline.length === 0) {
return (
<div className="p-4 text-[11px] text-surface-500">
This page has no detected headings (no <code>&lt;h1&gt;</code>–
<code>&lt;h6&gt;</code> elements). Pages without headings are
harder for screen readers to navigate and may rank poorly for
long-form queries.
{t('outline.emptyPrefix', { defaultValue: 'This page has no detected headings (no' })}{' '}
<code>&lt;h1&gt;</code>–<code>&lt;h6&gt;</code>{' '}
{t('outline.emptySuffix', { defaultValue: 'elements). Pages without headings are harder for screen readers to navigate and may rank poorly for long-form queries.' })}
</div>
);
}
// Skip-detection: a heading skips a level when its level is more than
// one greater than the previous heading's level (h1 → h3 etc.). The
// very first heading isn't checked because the spec doesn't require
// a strict h1 start (<main>-scoped outlines are valid).
let prevLevel: number | null = null;
const annotated = outline.map((h) => {
const skipped =
prevLevel !== null && h.level > prevLevel + 1
? `Skipped: previous was h${prevLevel}`
? t('outline.skipped', { defaultValue: 'Skipped: previous was h{{n}}', n: prevLevel })
: null;
prevLevel = h.level;
return { ...h, skipped };
@@ -3478,7 +3507,7 @@ function OutlineView({ row }: { row: CrawlUrlRow }) {
<div className="flex h-full flex-col">
<div className="flex flex-wrap items-center gap-3 border-b border-surface-800 bg-surface-900/50 px-3 py-1.5 text-[11px] text-surface-400">
<span>
<span className="font-medium text-surface-200">{outline.length}</span> headings
<span className="font-medium text-surface-200">{outline.length}</span> {t('outline.headings', { defaultValue: 'headings' })}
</span>
{(['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as const).map((k) =>
counts[k] && counts[k] > 0 ? (
@@ -3489,11 +3518,11 @@ function OutlineView({ row }: { row: CrawlUrlRow }) {
)}
{skippedCount > 0 && (
<span className="text-amber-400">
{skippedCount} skipped level{skippedCount === 1 ? '' : 's'}
{t('outline.skippedCount', { defaultValue: '{{n}} skipped level(s)', n: skippedCount })}
</span>
)}
{outline.length === 200 && (
<span className="text-surface-500">(capped at 200)</span>
<span className="text-surface-500">{t('outline.capped', { defaultValue: '(capped at 200)' })}</span>
)}
</div>
<div className="flex-1 overflow-auto p-3">
@@ -3524,7 +3553,7 @@ function OutlineView({ row }: { row: CrawlUrlRow }) {
</span>
<span className="flex-1 break-words text-surface-100">
{h.text || (
<span className="italic text-surface-600">(empty heading)</span>
<span className="italic text-surface-600">{t('outline.emptyHeading', { defaultValue: '(empty heading)' })}</span>
)}
{h.skipped && (
<span className="ml-2 text-[10px] text-amber-400">
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { X, FolderOpen, RefreshCw } from 'lucide-react';
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
import type {
CompareCategory,
CompareDiffRow,
@@ -14,22 +15,24 @@ interface Props {
interface CategoryDef {
key: CompareCategory;
label: string;
labelKey: string;
fallback: string;
}
const CATEGORIES: CategoryDef[] = [
{ key: 'added', label: 'Added' },
{ key: 'removed', label: 'Removed' },
{ key: 'status', label: 'Status Changed' },
{ key: 'title', label: 'Title Changed' },
{ key: 'meta', label: 'Meta Description Changed' },
{ key: 'h1', label: 'H1 Changed' },
{ key: 'canonical', label: 'Canonical Changed' },
{ key: 'indexability', label: 'Indexability Changed' },
{ key: 'response_time', label: 'Response Time Δ' },
{ key: 'added', labelKey: 'compare.added', fallback: 'Added' },
{ key: 'removed', labelKey: 'compare.removed', fallback: 'Removed' },
{ key: 'status', labelKey: 'compare.statusChanged', fallback: 'Status Changed' },
{ key: 'title', labelKey: 'compare.titleChanged', fallback: 'Title Changed' },
{ key: 'meta', labelKey: 'compare.metaChanged', fallback: 'Meta Description Changed' },
{ key: 'h1', labelKey: 'compare.h1Changed', fallback: 'H1 Changed' },
{ key: 'canonical', labelKey: 'compare.canonicalChanged', fallback: 'Canonical Changed' },
{ key: 'indexability', labelKey: 'compare.indexabilityChanged', fallback: 'Indexability Changed' },
{ key: 'response_time', labelKey: 'compare.responseTimeDelta', fallback: 'Response Time Δ' },
];
export function CompareDialog({ open, onClose }: Props) {
const { t } = useTranslation();
const [result, setResult] = useState<CompareLoadResult | null>(null);
const [loading, setLoading] = useState(false);
const [active, setActive] = useState<CompareCategory>('added');
@@ -89,14 +92,14 @@ export function CompareDialog({ open, onClose }: Props) {
>
<div className="flex items-center border-b border-surface-800 px-4 py-2.5">
<div className="text-sm font-semibold tracking-wide text-surface-100">
Compare Crawls
{t('compare.title', { defaultValue: 'Compare Crawls' })}
</div>
{result && (
<div className="ml-3 truncate text-[11px] text-surface-400">
<span className="text-surface-500">A (current)</span>{' '}
{result.totalA.toLocaleString()} URLs ·{' '}
<span className="text-surface-500">{t('compare.aCurrent', { defaultValue: 'A (current)' })}</span>{' '}
{result.totalA.toLocaleString()} {t('compare.urlsUnit', { defaultValue: 'URLs' })} ·{' '}
<span className="text-surface-500">B</span>{' '}
{result.totalB.toLocaleString()} URLs · {result.filePath}
{result.totalB.toLocaleString()} {t('compare.urlsUnit', { defaultValue: 'URLs' })} · {result.filePath}
</div>
)}
<div className="ml-auto flex items-center gap-1">
@@ -104,19 +107,19 @@ export function CompareDialog({ open, onClose }: Props) {
className="flex items-center gap-1 rounded border border-surface-700 px-2 py-1 text-[11px] text-surface-200 hover:border-blue-500 hover:bg-surface-800"
onClick={pick}
disabled={loading}
title="Pick a different project file"
title={t('compare.pickDifferent', { defaultValue: 'Pick a different project file' })}
>
{loading ? (
<RefreshCw className="h-3 w-3 animate-spin" />
) : (
<FolderOpen className="h-3 w-3" />
)}
{loading ? 'Comparing…' : 'Open Other Project'}
{loading ? t('compare.comparing', { defaultValue: 'Comparing…' }) : t('compare.openOther', { defaultValue: 'Open Other Project' })}
</button>
<button
className="rounded p-1 text-surface-400 hover:bg-surface-800 hover:text-surface-100"
onClick={onClose}
title="Close (Esc)"
title={t('scheduled.closeEsc', { defaultValue: 'Close (Esc)' })}
>
<X className="h-4 w-4" />
</button>
@@ -131,7 +134,7 @@ export function CompareDialog({ open, onClose }: Props) {
{!result && !error && !loading && (
<div className="flex flex-1 items-center justify-center text-[12px] text-surface-500">
Pick a `.seoproject` file to diff against the current crawl.
{t('compare.pickPrompt', { defaultValue: 'Pick a `.seoproject` file to diff against the current crawl.' })}
</div>
)}
@@ -154,7 +157,7 @@ export function CompareDialog({ open, onClose }: Props) {
)}
onClick={() => setActive(c.key)}
>
<span>{c.label}</span>
<span>{t(c.labelKey, { defaultValue: c.fallback })}</span>
<span className="font-mono text-[10px] text-surface-400">
{count.toLocaleString()}
</span>
@@ -166,11 +169,14 @@ export function CompareDialog({ open, onClose }: Props) {
<div className="flex flex-1 flex-col min-w-0">
<div className="border-b border-surface-800 px-4 py-2 text-[11px] text-surface-400">
{sample.length.toLocaleString()} of{' '}
{(result.counts[active] ?? 0).toLocaleString()} entries shown
{t('compare.entriesShown', {
defaultValue: '{{shown}} of {{total}} entries shown',
shown: sample.length.toLocaleString(),
total: (result.counts[active] ?? 0).toLocaleString(),
})}
{result.counts[active] > sample.length && (
<span className="ml-1 text-surface-500">
(truncated — export CSV for the full set)
{t('compare.truncated', { defaultValue: '(truncated — export CSV for the full set)' })}
</span>
)}
</div>
@@ -178,9 +184,9 @@ export function CompareDialog({ open, onClose }: Props) {
<table className="w-full text-[11px]">
<thead className="sticky top-0 bg-surface-900">
<tr className="text-surface-400">
<th className="w-1/2 py-1.5 px-3 text-left font-medium">URL</th>
<th className="w-1/4 py-1.5 px-3 text-left font-medium">Before</th>
<th className="w-1/4 py-1.5 px-3 text-left font-medium">After</th>
<th className="w-1/2 py-1.5 px-3 text-left font-medium">{t('compare.urlHeader', { defaultValue: 'URL' })}</th>
<th className="w-1/4 py-1.5 px-3 text-left font-medium">{t('compare.beforeHeader', { defaultValue: 'Before' })}</th>
<th className="w-1/4 py-1.5 px-3 text-left font-medium">{t('compare.afterHeader', { defaultValue: 'After' })}</th>
</tr>
</thead>
<tbody>
@@ -190,7 +196,7 @@ export function CompareDialog({ open, onClose }: Props) {
colSpan={3}
className="px-3 py-3 text-center text-[11px] italic text-surface-500"
>
No diffs in this category.
{t('compare.noDiffs', { defaultValue: 'No diffs in this category.' })}
</td>
</tr>
)}
@@ -1,9 +1,11 @@
import { useEffect, useMemo, useState } from 'react';
import { X, Loader2 } from 'lucide-react';
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
import type { CrawlUrlRow, ExportTabularSection, UrlCategory } from '@freecrawl/shared-types';
import { TAB_ORDER, type TabKey } from '../store.js';
import { COLUMN_SPECS, columnId } from '../tabs/columns.js';
import { translateLabel } from '../i18n/labels.js';
type Format = 'csv' | 'xlsx';
@@ -76,6 +78,8 @@ export function ExportDialog({
defaultTab,
selectedIds,
}: ExportDialogProps) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const tabs = useMemo(() => buildExportableTabs(), []);
const initialTabKey: TabKey = useMemo(() => {
return tabs.find((t) => t.key === defaultTab)?.key ?? tabs[0]?.key ?? 'internal';
@@ -178,19 +182,19 @@ export function ExportDialog({
const submit = async () => {
if (orderedColumnList.length === 0) {
setStatus('Pick at least one column.');
setStatus(t('export.errorNoColumn', { defaultValue: 'Pick at least one column.' }));
return;
}
if (pickedTabs.size === 0) {
setStatus('Pick at least one table.');
setStatus(t('export.errorNoTable', { defaultValue: 'Pick at least one table.' }));
return;
}
setBusy(true);
setStatus('');
try {
const sections: ExportTabularSection[] = tabs
.filter((t) => pickedTabs.has(t.key))
.map((t) => ({ label: t.label, category: t.category }));
.filter((tab) => pickedTabs.has(tab.key))
.map((tab) => ({ label: tab.label, category: tab.category }));
const result = await window.freecrawl.exportTabular({
format,
sections,
@@ -206,13 +210,17 @@ export function ExportDialog({
return;
}
setStatus(
`Exported ${result.rowsWritten.toLocaleString()} row${result.rowsWritten === 1 ? '' : 's'} to ${result.filePath}`,
t('export.successMsg', {
defaultValue: 'Exported {{count}} row(s) to {{path}}',
count: result.rowsWritten.toLocaleString(),
path: result.filePath,
}),
);
setBusy(false);
setTimeout(() => onClose(), 700);
} catch (err) {
setBusy(false);
setStatus(`Export failed: ${err instanceof Error ? err.message : String(err)}`);
setStatus(t('export.failedPrefix', { defaultValue: 'Export failed' }) + ': ' + (err instanceof Error ? err.message : String(err)));
}
};
@@ -226,11 +234,11 @@ export function ExportDialog({
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-surface-800 px-4 py-2.5">
<div className="text-sm font-semibold text-surface-100">Export</div>
<div className="text-sm font-semibold text-surface-100">{t('export.title', { defaultValue: 'Export' })}</div>
<button
className="rounded p-1 text-surface-400 hover:bg-surface-800 hover:text-surface-100"
onClick={onClose}
title="Close"
title={t('common.close', { defaultValue: 'Close' })}
>
<X className="h-4 w-4" />
</button>
@@ -240,16 +248,16 @@ export function ExportDialog({
<div className="grid grid-cols-2 gap-4">
<section>
<div className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-surface-400">
Tables
{t('export.tables', { defaultValue: 'Tables' })}
</div>
<div className="rounded border border-surface-800 bg-surface-950/50 p-2">
<div className="grid grid-cols-1 gap-1">
{tabs.map((t) => {
const checked = pickedTabs.has(t.key);
const isDefault = t.key === initialTabKey;
{tabs.map((tab) => {
const checked = pickedTabs.has(tab.key);
const isDefault = tab.key === initialTabKey;
return (
<label
key={t.key}
key={tab.key}
className={clsx(
'flex cursor-pointer items-center gap-2 rounded px-1.5 py-1 text-[12px]',
checked
@@ -261,12 +269,12 @@ export function ExportDialog({
type="checkbox"
className="h-3.5 w-3.5"
checked={checked}
onChange={() => toggleTab(t.key)}
onChange={() => toggleTab(tab.key)}
/>
<span className="truncate">{t.label}</span>
<span className="truncate">{translateLabel(tab.label, lang)}</span>
{isDefault && (
<span className="ml-auto rounded bg-surface-800 px-1 text-[9px] uppercase tracking-wide text-surface-400">
Active
{t('export.active', { defaultValue: 'Active' })}
</span>
)}
</label>
@@ -279,7 +287,7 @@ export function ExportDialog({
<section className="flex min-h-0 flex-col">
<div className="mb-1.5 flex items-center justify-between">
<div className="text-[11px] font-semibold uppercase tracking-wide text-surface-400">
Columns
{t('urlsTab.columns', { defaultValue: 'Columns' })}
</div>
<div className="flex gap-1 text-[10px]">
<button
@@ -287,14 +295,14 @@ export function ExportDialog({
className="rounded border border-surface-700 px-1.5 py-0.5 text-surface-300 hover:bg-surface-800"
onClick={checkAllColumns}
>
All
{t('export.all', { defaultValue: 'All' })}
</button>
<button
type="button"
className="rounded border border-surface-700 px-1.5 py-0.5 text-surface-300 hover:bg-surface-800"
onClick={uncheckAllColumns}
>
None
{t('export.none', { defaultValue: 'None' })}
</button>
</div>
</div>
@@ -318,7 +326,7 @@ export function ExportDialog({
checked={checked}
onChange={() => toggleColumn(c.key)}
/>
<span className="truncate">{c.header}</span>
<span className="truncate">{translateLabel(c.header, lang)}</span>
<span className="ml-auto truncate font-mono text-[10px] text-surface-500">
{c.key}
</span>
@@ -333,7 +341,7 @@ export function ExportDialog({
<div className="mt-4 flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-surface-400">
Format
{t('export.format', { defaultValue: 'Format' })}
</span>
<label className="flex cursor-pointer items-center gap-1 text-[12px] text-surface-200">
<input
@@ -363,7 +371,7 @@ export function ExportDialog({
onChange={(e) => setUseSelection(e.target.checked)}
/>
<span>
Selected rows only{' '}
{t('export.selectedOnly', { defaultValue: 'Selected rows only' })}{' '}
<span className="text-surface-500">
({selectionCount.toLocaleString()})
</span>
@@ -374,7 +382,7 @@ export function ExportDialog({
{format === 'csv' && pickedTabs.size > 1 && (
<div className="mt-2 rounded border border-amber-700/40 bg-amber-900/20 px-2 py-1 text-[11px] text-amber-200">
Multiple tables + CSV → you'll be asked to pick a folder; one file is written per table.
{t('export.multiCsvNote', { defaultValue: 'Multiple tables + CSV → you\'ll be asked to pick a folder; one file is written per table.' })}
</div>
)}
@@ -392,7 +400,7 @@ export function ExportDialog({
className="rounded border border-surface-700 px-3 py-1 text-[12px] text-surface-300 hover:bg-surface-800"
disabled={busy}
>
Cancel
{t('common.cancel', { defaultValue: 'Cancel' })}
</button>
<button
type="button"
@@ -405,7 +413,7 @@ export function ExportDialog({
)}
>
{busy && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Export
{t('export.title', { defaultValue: 'Export' })}
</button>
</div>
</div>
@@ -1,4 +1,5 @@
import { Info } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export interface FieldInfo {
/** One-line description of what the field/column means. */
@@ -15,20 +16,21 @@ export interface FieldInfo {
* column-header sort/resize controls underneath.
*/
export function InfoTip({ info, example }: FieldInfo) {
const { t } = useTranslation();
if (!info && !example) return null;
return (
<span className="group relative inline-flex">
<Info
className="h-3 w-3 cursor-help text-surface-500 transition-colors group-hover:text-surface-200"
tabIndex={0}
aria-label={info ?? 'More info'}
aria-label={info ?? t('infoTip.moreInfo', { defaultValue: 'More info' })}
/>
<span className="pointer-events-none invisible absolute left-4 top-0 z-50 w-64 rounded border border-surface-700 bg-surface-900 p-2 text-[10px] leading-relaxed text-surface-200 opacity-0 shadow-xl transition-opacity duration-150 group-hover:visible group-hover:opacity-100 group-focus-within:visible group-focus-within:opacity-100">
{info && <span className="block">{info}</span>}
{example && (
<>
<span className="mt-1.5 block text-[9px] font-semibold uppercase tracking-wider text-surface-500">
Example
{t('infoTip.example', { defaultValue: 'Example' })}
</span>
<span className="mt-0.5 block break-words font-mono text-[10px] text-surface-300">
{example}
@@ -1,8 +1,10 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react';
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
import type { OverviewCounts, UrlCategory } from '@freecrawl/shared-types';
import { useAppStore } from '../store.js';
import { translateLabel } from '../i18n/labels.js';
interface Node {
key: string;
@@ -13,6 +15,8 @@ interface Node {
}
export function OverviewSidebar() {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const overview = useAppStore((s) => s.overview);
const setOverview = useAppStore((s) => s.setOverview);
const dataVersion = useAppStore((s) => s.dataVersion);
@@ -130,11 +134,11 @@ export function OverviewSidebar() {
<div className="flex h-full flex-col bg-surface-900">
<div className="flex items-center border-b border-surface-800 bg-surface-850 px-2 py-1.5">
<div className="text-[10px] font-semibold uppercase tracking-wide text-surface-400">
Overview
{t('sidebar.overview', { defaultValue: 'Overview' })}
</div>
<div className="ml-auto flex items-center gap-3 text-[10px] text-surface-500">
<span>URLs</span>
<span>% of Total</span>
<span>{t('sidebar.urls', { defaultValue: 'URLs' })}</span>
<span>{t('sidebar.percentOfTotal', { defaultValue: '% of Total' })}</span>
</div>
</div>
<div className="flex-1 overflow-auto">
@@ -149,6 +153,7 @@ export function OverviewSidebar() {
activeCategory={activeCategory}
onClick={navigateToCategory}
total={totalForPercent}
lang={lang}
/>
))}
</div>
@@ -165,6 +170,7 @@ const TreeNode = memo(function TreeNode({
activeCategory,
onClick,
total,
lang,
}: {
node: Node;
depth: number;
@@ -173,6 +179,7 @@ const TreeNode = memo(function TreeNode({
activeCategory: UrlCategory;
onClick: (c: UrlCategory) => void;
total: number;
lang: string;
}) {
const isExpanded = expanded.has(node.key);
const isActive = node.category && node.category === activeCategory;
@@ -200,7 +207,7 @@ const TreeNode = memo(function TreeNode({
) : (
<span className="w-3 shrink-0" />
)}
<span className="min-w-0 flex-1 truncate text-surface-200">{node.label}</span>
<span className="min-w-0 flex-1 truncate text-surface-200">{translateLabel(node.label, lang)}</span>
{node.count !== undefined && (
<>
<span className="font-mono tabular-nums text-surface-300">
@@ -223,6 +230,7 @@ const TreeNode = memo(function TreeNode({
activeCategory={activeCategory}
onClick={onClick}
total={total}
lang={lang}
/>
))}
</>
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type {
PagesPerDirectoryRow,
StatusCodeHistogramRow,
@@ -174,11 +175,65 @@ const VALUE_FORMAT: Record<ReportKind, (v: number | null) => string> = {
};
export function ReportsDialog({ open, onClose }: Props) {
const { t } = useTranslation();
const [kind, setKind] = useState<ReportKind>('pages-per-dir');
const [depth, setDepth] = useState(1);
const [rows, setRows] = useState<ReportRow[]>([]);
const [loading, setLoading] = useState(false);
// Localised label maps. Memoised on `t` so they only rebuild when the
// language changes, not on every render.
const reportLabels = useMemo<Record<ReportKind, string>>(() => ({
'pages-per-dir': t('reports.report.pagesPerDir', { defaultValue: REPORT_LABELS['pages-per-dir'] }),
'status-codes': t('reports.report.statusCodes', { defaultValue: REPORT_LABELS['status-codes'] }),
'indexability-distribution': t('reports.report.indexabilityDistribution', { defaultValue: REPORT_LABELS['indexability-distribution'] }),
'content-kind-distribution': t('reports.report.contentKindDistribution', { defaultValue: REPORT_LABELS['content-kind-distribution'] }),
depth: t('reports.report.depth', { defaultValue: REPORT_LABELS.depth }),
'response-time': t('reports.report.responseTime', { defaultValue: REPORT_LABELS['response-time'] }),
'slowest-urls': t('reports.report.slowestUrls', { defaultValue: REPORT_LABELS['slowest-urls'] }),
'most-inlinks': t('reports.report.mostInlinks', { defaultValue: REPORT_LABELS['most-inlinks'] }),
'least-inlinks': t('reports.report.leastInlinks', { defaultValue: REPORT_LABELS['least-inlinks'] }),
'most-outlinks': t('reports.report.mostOutlinks', { defaultValue: REPORT_LABELS['most-outlinks'] }),
'biggest-pages': t('reports.report.biggestPages', { defaultValue: REPORT_LABELS['biggest-pages'] }),
'deepest-urls': t('reports.report.deepestUrls', { defaultValue: REPORT_LABELS['deepest-urls'] }),
'external-domain-health': t('reports.report.externalDomainHealth', { defaultValue: REPORT_LABELS['external-domain-health'] }),
'analytics-coverage': t('reports.report.analyticsCoverage', { defaultValue: REPORT_LABELS['analytics-coverage'] }),
'link-positions': t('reports.report.linkPositions', { defaultValue: REPORT_LABELS['link-positions'] }),
'image-weight': t('reports.report.imageWeight', { defaultValue: REPORT_LABELS['image-weight'] }),
'inlinks-histogram': t('reports.report.inlinksHistogram', { defaultValue: REPORT_LABELS['inlinks-histogram'] }),
'word-count-histogram': t('reports.report.wordCountHistogram', { defaultValue: REPORT_LABELS['word-count-histogram'] }),
'url-length-histogram': t('reports.report.urlLengthHistogram', { defaultValue: REPORT_LABELS['url-length-histogram'] }),
'word-count-per-dir': t('reports.report.wordCountPerDir', { defaultValue: REPORT_LABELS['word-count-per-dir'] }),
'sitemap-orphans': t('reports.report.sitemapOrphans', { defaultValue: REPORT_LABELS['sitemap-orphans'] }),
'server-headers': t('reports.report.serverHeaders', { defaultValue: REPORT_LABELS['server-headers'] }),
'top-words': t('reports.report.topWords', { defaultValue: REPORT_LABELS['top-words'] }),
}), [t]);
const keyLabels = useMemo<Record<ReportKind, string>>(() => ({
'pages-per-dir': t('reports.keyDirectory', { defaultValue: 'Directory' }),
'status-codes': t('reports.keyStatus', { defaultValue: 'Status' }),
'indexability-distribution': t('reports.keyIndexability', { defaultValue: 'Indexability' }),
'content-kind-distribution': t('reports.keyContentType', { defaultValue: 'Content Type' }),
depth: t('reports.keyDepth', { defaultValue: 'Depth' }),
'response-time': t('reports.keyBucket', { defaultValue: 'Bucket' }),
'slowest-urls': 'URL',
'most-inlinks': 'URL',
'least-inlinks': 'URL',
'most-outlinks': 'URL',
'biggest-pages': 'URL',
'deepest-urls': 'URL',
'external-domain-health': t('reports.keyDomain', { defaultValue: 'Domain' }),
'analytics-coverage': t('reports.keyTracker', { defaultValue: 'Tracker' }),
'link-positions': t('reports.keyPosition', { defaultValue: 'Position' }),
'image-weight': 'URL',
'inlinks-histogram': t('reports.keyBucket', { defaultValue: 'Bucket' }),
'word-count-histogram': t('reports.keyBucket', { defaultValue: 'Bucket' }),
'url-length-histogram': t('reports.keyBucket', { defaultValue: 'Bucket' }),
'word-count-per-dir': t('reports.keyDirectory', { defaultValue: 'Directory' }),
'sitemap-orphans': 'URL',
'server-headers': t('reports.keyServer', { defaultValue: 'Server' }),
'top-words': t('reports.keyWord', { defaultValue: 'Word' }),
}), [t]);
useEffect(() => {
if (!open) return;
let cancelled = false;
@@ -409,12 +464,12 @@ export function ReportsDialog({ open, onClose }: Props) {
>
<div className="flex items-center border-b border-surface-800 px-4 py-2.5">
<div className="text-sm font-semibold tracking-wide text-surface-100">
{REPORT_LABELS[kind]}
{reportLabels[kind]}
</div>
<button
className="ml-auto rounded p-1 text-surface-400 hover:bg-surface-800 hover:text-surface-100"
onClick={onClose}
title="Close (Esc)"
title={t('scheduled.closeEsc', { defaultValue: 'Close (Esc)' })}
>
<X className="h-4 w-4" />
</button>
@@ -422,44 +477,26 @@ export function ReportsDialog({ open, onClose }: Props) {
<div className="flex flex-wrap items-center gap-3 border-b border-surface-800 bg-surface-900/50 px-4 py-2 text-[11px]">
<label className="flex items-center gap-1.5">
<span className="text-surface-400">Report</span>
<span className="text-surface-400">{t('reports.reportLabel', { defaultValue: 'Report' })}</span>
<select
className="rounded border border-surface-700 bg-surface-950 px-2 py-0.5 text-[11px] text-surface-100 focus:border-blue-500 focus:outline-none"
value={kind}
onChange={(e) => setKind(e.target.value as ReportKind)}
>
<option value="pages-per-dir">Pages per Directory</option>
<option value="status-codes">Status Code Histogram</option>
<option value="depth">Depth Histogram</option>
<option value="response-time">Response Time Histogram</option>
<option value="slowest-urls">Slowest URLs (Top 25)</option>
<option value="most-inlinks">Most-Linked URLs (Top 25)</option>
<option value="least-inlinks">Least-Linked URLs (Bottom 25, indexable)</option>
<option value="most-outlinks">Most-Outlinking URLs (Top 25)</option>
<option value="biggest-pages">Biggest Pages (Top 25)</option>
<option value="deepest-urls">Deepest URLs (Top 25)</option>
<option value="external-domain-health">External Domain Health</option>
<option value="analytics-coverage">Analytics Tracker Coverage</option>
<option value="link-positions">Internal Link Positions</option>
<option value="image-weight">Image Weight per Page (Top 25)</option>
<option value="inlinks-histogram">Inlinks Histogram</option>
<option value="word-count-histogram">Word Count Histogram</option>
<option value="url-length-histogram">URL Length Histogram</option>
<option value="word-count-per-dir">Word Count per Directory</option>
<option value="sitemap-orphans">Sitemap Orphans (Top 1000)</option>
<option value="server-headers">Server Stack</option>
<option value="top-words">Top Words (Title + Meta + H1)</option>
{(Object.keys(reportLabels) as ReportKind[]).map((k) => (
<option key={k} value={k}>{reportLabels[k]}</option>
))}
</select>
</label>
{(kind === 'pages-per-dir' || kind === 'word-count-per-dir') && (
<label className="flex items-center gap-1.5">
<span className="text-surface-400">Group at depth</span>
<span className="text-surface-400">{t('reports.groupAtDepth', { defaultValue: 'Group at depth' })}</span>
<select
className="rounded border border-surface-700 bg-surface-950 px-2 py-0.5 text-[11px] text-surface-100 focus:border-blue-500 focus:outline-none"
value={depth}
onChange={(e) => setDepth(Number.parseInt(e.target.value, 10))}
>
<option value={1}>1 (top-level)</option>
<option value={1}>{t('reports.depthTopLevel', { defaultValue: '1 (top-level)' })}</option>
<option value={2}>2</option>
<option value={3}>3</option>
<option value={4}>4</option>
@@ -468,24 +505,28 @@ export function ReportsDialog({ open, onClose }: Props) {
)}
<span className="ml-auto text-surface-500">
{loading
? 'Loading…'
: `${rows.length.toLocaleString()} rows · ${total.toLocaleString()} URLs`}
? t('common.loading', { defaultValue: 'Loading…' })
: t('reports.summary', {
defaultValue: '{{rows}} rows · {{urls}} URLs',
rows: rows.length.toLocaleString(),
urls: total.toLocaleString(),
})}
</span>
</div>
<div className="flex-1 overflow-auto px-4 py-3 text-[11px]">
{rows.length === 0 && !loading && (
<div className="p-6 text-center text-surface-500">
No data — run a crawl first.
{t('reports.noData', { defaultValue: 'No data — run a crawl first.' })}
</div>
)}
{rows.length > 0 && (
<table className="w-full">
<thead className="sticky top-0 bg-surface-900">
<tr className="text-surface-400">
<th className="w-2/3 py-1 pr-3 text-left font-medium">{KEY_LABELS[kind]}</th>
<th className="w-24 py-1 pr-3 text-right font-medium">Count</th>
<th className="py-1 text-left font-medium">Share</th>
<th className="w-2/3 py-1 pr-3 text-left font-medium">{keyLabels[kind]}</th>
<th className="w-24 py-1 pr-3 text-right font-medium">{t('reports.count', { defaultValue: 'Count' })}</th>
<th className="py-1 text-left font-medium">{t('reports.share', { defaultValue: 'Share' })}</th>
</tr>
</thead>
<tbody>
@@ -534,7 +575,7 @@ export function ReportsDialog({ open, onClose }: Props) {
className="rounded border border-surface-700 px-3 py-1 text-[11px] hover:bg-surface-800"
onClick={onClose}
>
Close
{t('common.close', { defaultValue: 'Close' })}
</button>
</div>
</div>
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { X } from 'lucide-react';
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
import type { RobotsTestResult, RobotsValidationIssue } from '@freecrawl/shared-types';
import { useAppStore } from '../store.js';
@@ -10,6 +11,7 @@ interface Props {
}
export function RobotsTesterDialog({ open, onClose }: Props) {
const { t } = useTranslation();
const config = useAppStore((s) => s.config);
const [url, setUrl] = useState('');
const [userAgent, setUserAgent] = useState(config.userAgent);
@@ -110,12 +112,12 @@ export function RobotsTesterDialog({ open, onClose }: Props) {
>
<div className="flex items-center border-b border-surface-800 px-4 py-2.5">
<div className="text-sm font-semibold tracking-wide text-surface-100">
Robots.txt Tester
{t('robots.title', { defaultValue: 'Robots.txt Tester' })}
</div>
<button
className="ml-auto rounded p-1 text-surface-400 hover:bg-surface-800 hover:text-surface-100"
onClick={onClose}
title="Close (Esc)"
title={t('scheduled.closeEsc', { defaultValue: 'Close (Esc)' })}
>
<X className="h-4 w-4" />
</button>
@@ -123,7 +125,7 @@ export function RobotsTesterDialog({ open, onClose }: Props) {
<div className="flex-1 overflow-auto px-5 py-4 text-[12px]">
<label className="mb-3 flex flex-col gap-1">
<span className="text-[10px] text-surface-400">URL to test</span>
<span className="text-[10px] text-surface-400">{t('robots.urlToTest', { defaultValue: 'URL to test' })}</span>
<input
type="text"
className="rounded border border-surface-700 bg-surface-950 px-2 py-1.5 text-[12px] text-surface-100 focus:border-blue-500 focus:outline-none"
@@ -139,7 +141,7 @@ export function RobotsTesterDialog({ open, onClose }: Props) {
</label>
<label className="mb-3 flex flex-col gap-1">
<span className="text-[10px] text-surface-400">User-Agent</span>
<span className="text-[10px] text-surface-400">{t('robots.userAgent', { defaultValue: 'User-Agent' })}</span>
<input
type="text"
className="rounded border border-surface-700 bg-surface-950 px-2 py-1.5 text-[12px] text-surface-100 focus:border-blue-500 focus:outline-none"
@@ -157,7 +159,7 @@ export function RobotsTesterDialog({ open, onClose }: Props) {
onChange={(e) => setUseCustom(e.target.checked)}
className="h-3 w-3"
/>
Test against a custom robots.txt (draft mode)
{t('robots.customMode', { defaultValue: 'Test against a custom robots.txt (draft mode)' })}
</label>
<button
type="button"
@@ -165,7 +167,7 @@ export function RobotsTesterDialog({ open, onClose }: Props) {
onClick={() => void loadFromFile()}
disabled={running}
>
Load from file…
{t('robots.loadFromFile', { defaultValue: 'Load from file…' })}
</button>
</div>
@@ -173,7 +175,7 @@ export function RobotsTesterDialog({ open, onClose }: Props) {
<>
<label className="mb-3 flex flex-col gap-1">
<span className="text-[10px] text-surface-400">
Custom robots.txt body (parsed — no fetch)
{t('robots.customBodyLabel', { defaultValue: 'Custom robots.txt body (parsed — no fetch)' })}
</span>
<textarea
className="h-40 w-full resize-y rounded border border-surface-700 bg-surface-950 px-2 py-1.5 font-mono text-[11px] text-surface-100 focus:border-blue-500 focus:outline-none"
@@ -193,7 +195,7 @@ export function RobotsTesterDialog({ open, onClose }: Props) {
onClick={runTest}
disabled={running || !url.trim()}
>
{running ? 'Testing…' : useCustom ? 'Test (custom)' : 'Test'}
{running ? t('robots.testing', { defaultValue: 'Testing…' }) : useCustom ? t('robots.testCustom', { defaultValue: 'Test (custom)' }) : t('robots.test', { defaultValue: 'Test' })}
</button>
{result?.robotsUrl && (
<span className="text-[10px] text-surface-500">
@@ -217,12 +219,12 @@ export function RobotsTesterDialog({ open, onClose }: Props) {
>
{result.allowed ? (
<span>
✓ <strong>Allowed</strong> by robots.txt for User-Agent{' '}
✓ <strong>{t('robots.allowed', { defaultValue: 'Allowed' })}</strong> {t('robots.byRobotsFor', { defaultValue: 'by robots.txt for User-Agent' })}{' '}
<code className="font-mono">{userAgent}</code>
</span>
) : (
<span>
✗ <strong>Disallowed</strong> by robots.txt for User-Agent{' '}
✗ <strong>{t('robots.disallowed', { defaultValue: 'Disallowed' })}</strong> {t('robots.byRobotsFor', { defaultValue: 'by robots.txt for User-Agent' })}{' '}
<code className="font-mono">{userAgent}</code>
</span>
)}
@@ -235,14 +237,14 @@ export function RobotsTesterDialog({ open, onClose }: Props) {
)}
<div className="grid grid-cols-2 gap-2 text-[11px]">
<Stat label="Crawl-Delay" value={result.crawlDelay !== null ? `${result.crawlDelay}s` : '—'} />
<Stat label="Sitemaps declared" value={String(result.sitemaps.length)} />
<Stat label={t('robots.crawlDelay', { defaultValue: 'Crawl-Delay' })} value={result.crawlDelay !== null ? `${result.crawlDelay}s` : '—'} />
<Stat label={t('robots.sitemapsDeclared', { defaultValue: 'Sitemaps declared' })} value={String(result.sitemaps.length)} />
</div>
{result.sitemaps.length > 0 && (
<details className="text-[11px] text-surface-300">
<summary className="cursor-pointer text-surface-400 hover:text-surface-100">
Sitemaps ({result.sitemaps.length})
{t('robots.sitemapsHeader', { defaultValue: 'Sitemaps' })} ({result.sitemaps.length})
</summary>
<ul className="mt-1 space-y-0.5 pl-4 font-mono">
{result.sitemaps.map((s) => (
@@ -257,7 +259,7 @@ export function RobotsTesterDialog({ open, onClose }: Props) {
{result.body !== null && (
<details className="text-[11px] text-surface-300">
<summary className="cursor-pointer text-surface-400 hover:text-surface-100">
robots.txt body ({result.body.length} chars)
{t('robots.bodyHeader', { defaultValue: 'robots.txt body' })} ({t('robots.charsCount', { defaultValue: '{{n}} chars', n: result.body.length })})
</summary>
<pre className="mt-1 max-h-72 overflow-auto whitespace-pre-wrap rounded border border-surface-800 bg-surface-950 p-2 font-mono text-[10px] text-surface-200">
{result.body}
@@ -273,7 +275,7 @@ export function RobotsTesterDialog({ open, onClose }: Props) {
className="rounded border border-surface-700 px-3 py-1 text-[11px] hover:bg-surface-800"
onClick={onClose}
>
Close
{t('common.close', { defaultValue: 'Close' })}
</button>
</div>
</div>
@@ -297,17 +299,18 @@ function ValidationPanel({
issues: RobotsValidationIssue[];
bodyEmpty: boolean;
}) {
const { t } = useTranslation();
if (bodyEmpty) {
return (
<div className="mb-3 rounded border border-surface-800 bg-surface-950 px-2 py-1.5 text-[10px] text-surface-500">
Paste a draft robots.txt above to lint syntax.
{t('robots.lintPrompt', { defaultValue: 'Paste a draft robots.txt above to lint syntax.' })}
</div>
);
}
if (issues.length === 0) {
return (
<div className="mb-3 rounded border border-emerald-700/60 bg-emerald-900/20 px-2 py-1.5 text-[11px] text-emerald-200">
✓ No syntax issues detected.
✓ {t('robots.noIssues', { defaultValue: 'No syntax issues detected.' })}
</div>
);
}
@@ -317,11 +320,11 @@ function ValidationPanel({
<div className="mb-3 rounded border border-amber-700/60 bg-amber-900/15">
<div className="flex items-center gap-3 border-b border-amber-700/30 px-2 py-1 text-[10px] text-amber-300">
<span>
{errors > 0 && <span className="font-medium">{errors} error{errors === 1 ? '' : 's'}</span>}
{errors > 0 && <span className="font-medium">{t('robots.errorCount', { defaultValue: '{{n}} error(s)', n: errors })}</span>}
{errors > 0 && warnings > 0 && <span> · </span>}
{warnings > 0 && (
<span className="font-medium text-amber-200/80">
{warnings} warning{warnings === 1 ? '' : 's'}
{t('robots.warningCount', { defaultValue: '{{n}} warning(s)', n: warnings })}
</span>
)}
</span>
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { X, Clock, AlertTriangle } from 'lucide-react';
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
import type { ScheduleEntry, ScheduleSpec } from '@freecrawl/shared-types';
interface Props {
@@ -77,6 +78,16 @@ function formatDateTime(ms: number | null): string {
}
export function ScheduledCrawlDialog({ open, onClose }: Props) {
const { t } = useTranslation();
const dayNames = [
t('scheduled.daySun', { defaultValue: 'Sun' }),
t('scheduled.dayMon', { defaultValue: 'Mon' }),
t('scheduled.dayTue', { defaultValue: 'Tue' }),
t('scheduled.dayWed', { defaultValue: 'Wed' }),
t('scheduled.dayThu', { defaultValue: 'Thu' }),
t('scheduled.dayFri', { defaultValue: 'Fri' }),
t('scheduled.daySat', { defaultValue: 'Sat' }),
];
const [entry, setEntry] = useState<ScheduleEntry | null>(null);
const [form, setForm] = useState<FormState>(DEFAULT_FORM);
const [saving, setSaving] = useState(false);
@@ -129,7 +140,7 @@ export function ScheduledCrawlDialog({ open, onClose }: Props) {
async function clearSchedule() {
if (!entry) return;
if (!window.confirm('Remove the scheduled crawl for this project?')) return;
if (!window.confirm(t('scheduled.confirmRemove', { defaultValue: 'Remove the scheduled crawl for this project?' }))) return;
setSaving(true);
setError(null);
try {
@@ -149,9 +160,9 @@ export function ScheduledCrawlDialog({ open, onClose }: Props) {
const showDayOfWeek = form.cadence === 'weekly';
const nextFiresHint = useMemo(() => {
if (!form.enabled) return 'Disabled — set "Enabled" and Save to arm the schedule.';
return 'Save to compute the next fire time. The schedule only fires while FreeCrawl is open.';
}, [form.enabled]);
if (!form.enabled) return t('scheduled.hintDisabled', { defaultValue: 'Disabled — set "Enabled" and Save to arm the schedule.' });
return t('scheduled.hintEnabled', { defaultValue: 'Save to compute the next fire time. The schedule only fires while FreeCrawl is open.' });
}, [form.enabled, t]);
if (!open) return null;
@@ -167,12 +178,12 @@ export function ScheduledCrawlDialog({ open, onClose }: Props) {
<div className="flex items-center border-b border-surface-800 px-4 py-2.5">
<Clock className="h-4 w-4 text-surface-300" />
<div className="ml-2 text-sm font-semibold tracking-wide text-surface-100">
Scheduled Crawl
{t('scheduled.title', { defaultValue: 'Scheduled Crawl' })}
</div>
<button
className="ml-auto rounded p-1 text-surface-400 hover:bg-surface-800 hover:text-surface-100"
onClick={onClose}
title="Close (Esc)"
title={t('scheduled.closeEsc', { defaultValue: 'Close (Esc)' })}
>
<X className="h-4 w-4" />
</button>
@@ -183,10 +194,9 @@ export function ScheduledCrawlDialog({ open, onClose }: Props) {
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<div>
The schedule fires only while FreeCrawl is open on this
project. For triggers that survive an app restart, run
{t('scheduled.warningPrefix', { defaultValue: 'The schedule fires only while FreeCrawl is open on this project. For triggers that survive an app restart, run' })}
<code className="mx-1 rounded bg-surface-950 px-1 font-mono">freecrawl</code>
(the CLI) from Windows Task Scheduler / macOS launchd / cron.
{t('scheduled.warningSuffix', { defaultValue: '(the CLI) from Windows Task Scheduler / macOS launchd / cron.' })}
</div>
</div>
</div>
@@ -198,28 +208,28 @@ export function ScheduledCrawlDialog({ open, onClose }: Props) {
onChange={(e) => update('enabled', e.target.checked)}
className="h-3.5 w-3.5 accent-blue-500"
/>
<span className="font-medium text-surface-100">Enabled</span>
<span className="font-medium text-surface-100">{t('scheduled.enabled', { defaultValue: 'Enabled' })}</span>
</label>
<div className="mt-3 grid grid-cols-2 gap-3">
<label className="flex flex-col gap-1">
<span className="text-[11px] text-surface-400">Cadence</span>
<span className="text-[11px] text-surface-400">{t('scheduled.cadence', { defaultValue: 'Cadence' })}</span>
<select
className="h-7 rounded border border-surface-700 bg-surface-950 px-2 text-[12px] text-surface-100 focus:border-blue-500 focus:outline-none"
value={form.cadence}
onChange={(e) => update('cadence', e.target.value as Cadence)}
>
<option value="hourly">Hourly</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="custom">Custom interval</option>
<option value="hourly">{t('scheduled.hourly', { defaultValue: 'Hourly' })}</option>
<option value="daily">{t('scheduled.daily', { defaultValue: 'Daily' })}</option>
<option value="weekly">{t('scheduled.weekly', { defaultValue: 'Weekly' })}</option>
<option value="custom">{t('scheduled.customInterval', { defaultValue: 'Custom interval' })}</option>
</select>
</label>
{showInterval && (
<label className="flex flex-col gap-1">
<span className="text-[11px] text-surface-400">
Interval (minutes, min {MIN_CUSTOM_INTERVAL})
{t('scheduled.intervalMinutes', { defaultValue: 'Interval (minutes, min {{min}})', min: MIN_CUSTOM_INTERVAL })}
</span>
<input
type="number"
@@ -235,7 +245,7 @@ export function ScheduledCrawlDialog({ open, onClose }: Props) {
{showTimeOfDay && (
<>
<label className="flex flex-col gap-1">
<span className="text-[11px] text-surface-400">Hour (0–23)</span>
<span className="text-[11px] text-surface-400">{t('scheduled.hour', { defaultValue: 'Hour (0–23)' })}</span>
<input
type="number"
min={0}
@@ -246,7 +256,7 @@ export function ScheduledCrawlDialog({ open, onClose }: Props) {
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-[11px] text-surface-400">Minute (0–59)</span>
<span className="text-[11px] text-surface-400">{t('scheduled.minute', { defaultValue: 'Minute (0–59)' })}</span>
<input
type="number"
min={0}
@@ -261,9 +271,9 @@ export function ScheduledCrawlDialog({ open, onClose }: Props) {
{showDayOfWeek && (
<label className="col-span-2 flex flex-col gap-1">
<span className="text-[11px] text-surface-400">Day of week</span>
<span className="text-[11px] text-surface-400">{t('scheduled.dayOfWeek', { defaultValue: 'Day of week' })}</span>
<div className="flex flex-wrap gap-1">
{DAY_NAMES.map((label, idx) => (
{dayNames.map((label, idx) => (
<button
key={label}
type="button"
@@ -285,15 +295,15 @@ export function ScheduledCrawlDialog({ open, onClose }: Props) {
<div className="mt-3 rounded border border-surface-800 bg-surface-950/60 px-3 py-2">
<div className="grid grid-cols-2 gap-y-1 text-[11px]">
<span className="text-surface-400">Next fire</span>
<span className="text-surface-400">{t('scheduled.nextFire', { defaultValue: 'Next fire' })}</span>
<span className="font-mono text-surface-200">
{entry ? formatDateTime(entry.status.nextFiresAt) : '—'}
</span>
<span className="text-surface-400">Last fired</span>
<span className="text-surface-400">{t('scheduled.lastFired', { defaultValue: 'Last fired' })}</span>
<span className="font-mono text-surface-200">
{entry ? formatDateTime(entry.status.lastFiredAt) : '—'}
</span>
<span className="text-surface-400">Last status</span>
<span className="text-surface-400">{t('scheduled.lastStatus', { defaultValue: 'Last status' })}</span>
<span
className={clsx(
'font-mono',
@@ -324,7 +334,7 @@ export function ScheduledCrawlDialog({ open, onClose }: Props) {
disabled={saving}
className="rounded border border-red-700/60 px-2.5 py-1 text-[11px] text-red-300 hover:bg-red-900/20 disabled:cursor-not-allowed disabled:opacity-50"
>
Remove
{t('common.remove', { defaultValue: 'Remove' })}
</button>
)}
<div className="ml-auto flex items-center gap-2">
@@ -334,7 +344,7 @@ export function ScheduledCrawlDialog({ open, onClose }: Props) {
disabled={saving}
className="rounded border border-surface-700 px-2.5 py-1 text-[11px] text-surface-300 hover:bg-surface-800 disabled:cursor-not-allowed disabled:opacity-50"
>
Cancel
{t('common.cancel', { defaultValue: 'Cancel' })}
</button>
<button
type="button"
@@ -342,7 +352,7 @@ export function ScheduledCrawlDialog({ open, onClose }: Props) {
disabled={!dirty || saving}
className="rounded bg-blue-600 px-3 py-1 text-[11px] font-medium text-white hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? 'Saving…' : 'Save'}
{saving ? t('scheduled.saving', { defaultValue: 'Saving…' }) : t('common.save', { defaultValue: 'Save' })}
</button>
</div>
</div>
@@ -1,4 +1,11 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
SUPPORTED_LANGUAGES,
changeLanguage,
type SupportedLanguage,
} from '../i18n/index.js';
import { translateLabel } from '../i18n/labels.js';
import {
X,
ListChecks,
@@ -21,6 +28,9 @@ import {
FileText,
AlertTriangle,
Wrench,
FolderOpen,
Lock,
Languages,
type LucideIcon,
} from 'lucide-react';
import clsx from 'clsx';
@@ -145,7 +155,10 @@ type SectionKey =
| 'issues'
| 'advanced'
| 'cookies'
| 'per-host-ua';
| 'per-host-ua'
| 'storage'
| 'privacy'
| 'language';
interface SectionDef {
key: SectionKey;
@@ -282,6 +295,24 @@ const SECTIONS: SectionDef[] = [
icon: Send,
keywords: 'per host user agent subdomain mobile desktop pattern wildcard',
},
{
key: 'storage',
label: 'Storage',
icon: FolderOpen,
keywords: 'storage save folder directory project location path documents disk',
},
{
key: 'privacy',
label: 'Privacy',
icon: Lock,
keywords: 'privacy telemetry analytics anonymous opt in out tracking data',
},
{
key: 'language',
label: 'Language',
icon: Languages,
keywords: 'language dil locale i18n internationalization english turkish ingilizce türkçe tr en',
},
];
function configToForm(c: CrawlConfig): FormState {
@@ -701,6 +732,9 @@ export function SettingsDialog({ open, onClose }: Props) {
{active === 'per-host-ua' && (
<PerHostUaPanel form={form} update={update} />
)}
{active === 'storage' && <StoragePanel />}
{active === 'privacy' && <PrivacyPanel />}
{active === 'language' && <LanguagePanel />}
</div>
</div>
</div>
@@ -841,13 +875,13 @@ function PresetsPanel({
exportSettings: () => Promise<void>;
importSettings: () => Promise<void>;
}) {
const { t } = useTranslation();
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState<string | null>(null);
return (
<>
<p className="mb-3 text-[11px] text-surface-400">
One-click profiles for common crawl scenarios. Clicking a preset overwrites the affected
fields only — your URL list, custom rules, filters, and extraction rules are preserved.
{t('settingsPanels.presets.intro', { defaultValue: 'One-click profiles for common crawl scenarios. Clicking a preset overwrites the affected fields only — your URL list, custom rules, filters, and extraction rules are preserved.' })}
</p>
<div className="space-y-2">
{PRESETS.map((p) => (
@@ -870,18 +904,16 @@ function PresetsPanel({
className="rounded border border-blue-700/60 bg-blue-900/30 px-3 py-1 text-[11px] text-blue-200 hover:bg-blue-900/50"
onClick={() => apply(p)}
>
Apply
{t('common.apply', { defaultValue: 'Apply' })}
</button>
</div>
))}
</div>
<div className="mt-5 border-t border-surface-800 pt-4">
<div className="mb-2 text-[12px] font-medium text-surface-100">Import / Export</div>
<div className="mb-2 text-[12px] font-medium text-surface-100">{t('settingsPanels.presets.importExport', { defaultValue: 'Import / Export' })}</div>
<p className="mb-2 text-[11px] text-surface-400">
Save the current settings to a JSON file (e.g. for sharing with a teammate or version
control), or load a previously-exported settings file. Importing replaces the current
form with the file's contents — press Save at the bottom to persist.
{t('settingsPanels.presets.importExportIntro', { defaultValue: "Save the current settings to a JSON file (e.g. for sharing with a teammate or version control), or load a previously-exported settings file. Importing replaces the current form with the file's contents — press Save at the bottom to persist." })}
</p>
<div className="flex gap-2">
<button
@@ -891,16 +923,16 @@ function PresetsPanel({
setMessage(null);
try {
await exportSettings();
setMessage('Settings exported.');
setMessage(t('settingsPanels.presets.exported', { defaultValue: 'Settings exported.' }));
} catch (e) {
setMessage(`Export failed: ${(e as Error).message}`);
setMessage(t('settingsPanels.presets.exportFailed', { defaultValue: 'Export failed: {{msg}}', msg: (e as Error).message }));
} finally {
setBusy(false);
}
}}
disabled={busy}
>
Export…
{t('settingsPanels.presets.exportBtn', { defaultValue: 'Export…' })}
</button>
<button
className="rounded border border-surface-700 px-3 py-1 text-[11px] hover:bg-surface-800 disabled:opacity-50"
@@ -909,34 +941,34 @@ function PresetsPanel({
setMessage(null);
try {
await importSettings();
setMessage('Settings imported.');
setMessage(t('settingsPanels.presets.imported', { defaultValue: 'Settings imported.' }));
} catch (e) {
setMessage(`Import failed: ${(e as Error).message}`);
setMessage(t('settingsPanels.presets.importFailed', { defaultValue: 'Import failed: {{msg}}', msg: (e as Error).message }));
} finally {
setBusy(false);
}
}}
disabled={busy}
>
Import…
{t('settingsPanels.presets.importBtn', { defaultValue: 'Import…' })}
</button>
{message && <span className="self-center text-[10px] text-surface-400">{message}</span>}
</div>
</div>
<p className="mt-3 text-[10px] text-surface-500">
After clicking Apply or Import, review each panel to verify the values, then press Save at
the bottom to persist.
{t('settingsPanels.presets.outro', { defaultValue: 'After clicking Apply or Import, review each panel to verify the values, then press Save at the bottom to persist.' })}
</p>
</>
);
}
function ModePanel({ form, update }: PanelProps) {
const { t } = useTranslation();
return (
<>
<p className="mb-3 text-[11px] text-surface-400">
Choose how the crawler discovers URLs. Spider follows links from a start URL; List fetches a fixed set.
{t('settingsPanels.mode.intro', { defaultValue: 'Choose how the crawler discovers URLs. Spider follows links from a start URL; List fetches a fixed set.' })}
</p>
<label className="mb-2 flex flex-col gap-1">
<FieldLabel
@@ -969,18 +1001,18 @@ function ModePanel({ form, update }: PanelProps) {
}
function CrawlerPanel({ form, update }: PanelProps) {
const { t } = useTranslation();
return (
<>
<p className="mb-3 text-[11px] text-surface-400">
Traversal limits and crawl-scope toggles. Screaming Frog equivalents shown in
parentheses.
{t('settingsPanels.crawler.intro', { defaultValue: 'Traversal limits and crawl-scope toggles. Screaming Frog equivalents shown in parentheses.' })}
</p>
<div className="mb-4 rounded border border-blue-700/40 bg-blue-900/15 px-3 py-2 text-[11px] text-blue-200">
<span className="font-medium">Looking for crawl speed?</span> Thread count, requests
per second, per-request delay, and retries live in the{' '}
<span className="font-medium">Speed</span> section — that&apos;s where you cap how fast
the crawler hits a server.
<span className="font-medium">{t('settingsPanels.crawler.speedHintPrefix', { defaultValue: 'Looking for crawl speed?' })}</span>{' '}
{t('settingsPanels.crawler.speedHintBody', { defaultValue: 'Thread count, requests per second, per-request delay, and retries live in the' })}{' '}
<span className="font-medium">{t('settingsPanels.crawler.speedHintLink', { defaultValue: 'Speed' })}</span>{' '}
{t('settingsPanels.crawler.speedHintSuffix', { defaultValue: "section — that's where you cap how fast the crawler hits a server." })}
</div>
<div className="grid grid-cols-2 gap-3">
@@ -1051,9 +1083,7 @@ function CrawlerPanel({ form, update }: PanelProps) {
}
function SpeedPanel({ form, update }: PanelProps) {
// Mirror inputs of the values for the helper-text math; if they aren't
// valid numbers we surface "—" instead of NaN. Cheap to recompute on
// every render — these strings are short.
const { t } = useTranslation();
const conc = Number.parseInt(form.maxConcurrency, 10);
const rps = Number.parseInt(form.maxRps, 10);
const delay = Number.parseInt(form.crawlDelayMs, 10);
@@ -1063,13 +1093,11 @@ function SpeedPanel({ form, update }: PanelProps) {
return (
<>
<p className="mb-3 text-[11px] text-surface-400">
Control crawl throughput. Increasing parallelism speeds the crawl, but every extra request
adds load on the target server — pick numbers a host that you don't own can absorb without
rate-limiting / 429s.
{t('settingsPanels.speed.intro', { defaultValue: "Control crawl throughput. Increasing parallelism speeds the crawl, but every extra request adds load on the target server — pick numbers a host that you don't own can absorb without rate-limiting / 429s." })}
</p>
<div className="mb-4 rounded border border-blue-700/40 bg-blue-900/15 px-3 py-2 text-[11px] text-blue-200">
<div className="mb-0.5 font-medium">Effective ceiling</div>
<div className="mb-0.5 font-medium">{t('settingsPanels.speed.effectiveCeiling', { defaultValue: 'Effective ceiling' })}</div>
<div className="text-blue-300/90">
{effectiveRps !== null ? (
<>
@@ -1153,10 +1181,11 @@ function SpeedPanel({ form, update }: PanelProps) {
}
function RequestsPanel({ form, update }: PanelProps) {
const { t } = useTranslation();
return (
<>
<p className="mb-3 text-[11px] text-surface-400">
HTTP headers sent with every request.
{t('settingsPanels.requests.intro', { defaultValue: 'HTTP headers sent with every request.' })}
</p>
<Text
label="User-Agent"
@@ -1186,10 +1215,11 @@ function RequestsPanel({ form, update }: PanelProps) {
}
function FiltersPanel({ form, update }: PanelProps) {
const { t } = useTranslation();
return (
<>
<p className="mb-3 text-[11px] text-surface-400">
URL allowlist/blocklist. Patterns are JavaScript regex tested against the full URL.
{t('settingsPanels.filters.intro', { defaultValue: 'URL allowlist/blocklist. Patterns are JavaScript regex tested against the full URL.' })}
</p>
<Area
label="Include Patterns (regex, one per line — empty = all allowed)"
@@ -1214,15 +1244,15 @@ function FiltersPanel({ form, update }: PanelProps) {
}
function CustomSearchPanel({ form, update }: PanelProps) {
const { t } = useTranslation();
return (
<>
<p className="mb-3 text-[11px] text-surface-400">
Flag pages whose body contains any of these terms. Two modes per
line:
{t('settingsPanels.customSearch.intro1', { defaultValue: 'Flag pages whose body contains any of these terms. Two modes per line:' })}
<span className="ml-1 font-mono text-surface-200">/pattern/flags</span>{' '}
for regex,
<span className="ml-1 font-mono text-surface-200">anything else</span>{' '}
for literal case-insensitive substring.
{t('settingsPanels.customSearch.intro2', { defaultValue: 'for regex,' })}
<span className="ml-1 font-mono text-surface-200">{t('settingsPanels.customSearch.anythingElse', { defaultValue: 'anything else' })}</span>{' '}
{t('settingsPanels.customSearch.intro3', { defaultValue: 'for literal case-insensitive substring.' })}
</p>
<Area
label="Search Terms (one per line; /…/ = regex, plain = literal)"
@@ -1238,6 +1268,7 @@ function CustomSearchPanel({ form, update }: PanelProps) {
}
function UrlRewritingPanel({ form, update }: PanelProps) {
const { t } = useTranslation();
const [previewState, setPreviewState] = useState<{
pending: boolean;
result?: string | null;
@@ -1307,7 +1338,7 @@ function UrlRewritingPanel({ form, update }: PanelProps) {
return (
<>
<p className="mb-3 text-[11px] text-surface-400">
Normalisation applied before URLs are deduplicated and queued.
{t('settingsPanels.urlRewriting.intro', { defaultValue: 'Normalisation applied before URLs are deduplicated and queued.' })}
</p>
<div className="grid grid-cols-2 gap-2">
<Bool
@@ -1485,6 +1516,7 @@ const DEFAULT_RULE: CustomExtractionRule = {
};
function CustomExtractionPanel({ form, update }: PanelProps) {
const { t } = useTranslation();
const rules = form.customExtractionRules;
const setRules = (next: CustomExtractionRule[]) => update('customExtractionRules', next);
const updateRule = (i: number, patch: Partial<CustomExtractionRule>) => {
@@ -1495,13 +1527,12 @@ function CustomExtractionPanel({ form, update }: PanelProps) {
return (
<>
<p className="mb-3 text-[11px] text-surface-400">
Up to 10 custom extraction rules. Each runs against every crawled
HTML page; results are stored on the URL row and visible in the
URL Details panel under <strong>Extraction</strong>.
{t('settingsPanels.customExtraction.intro1', { defaultValue: 'Up to 10 custom extraction rules. Each runs against every crawled HTML page; results are stored on the URL row and visible in the URL Details panel under' })}{' '}
<strong>{t('settingsPanels.customExtraction.extractionLabel', { defaultValue: 'Extraction' })}</strong>.
</p>
{rules.length === 0 && (
<p className="mb-3 text-[11px] italic text-surface-500">No rules — click "Add Rule" to start.</p>
<p className="mb-3 text-[11px] italic text-surface-500">{t('settingsPanels.customExtraction.empty', { defaultValue: 'No rules — click "Add Rule" to start.' })}</p>
)}
{rules.map((r, i) => (
@@ -1511,7 +1542,7 @@ function CustomExtractionPanel({ form, update }: PanelProps) {
>
<div className="mb-2 flex items-center justify-between">
<div className="text-[10px] font-semibold uppercase tracking-wider text-surface-400">
Rule #{i + 1}
{t('settingsGroups.ruleN', { defaultValue: 'Rule #{{n}}', n: i + 1 })}
</div>
<button
className="rounded p-1 text-surface-500 hover:bg-surface-800 hover:text-red-400"
@@ -1669,12 +1700,13 @@ function CustomExtractionPanel({ form, update }: PanelProps) {
}
function WebhookPanel({ form, update }: PanelProps) {
const { t } = useTranslation();
return (
<>
<p className="mb-3 text-[11px] text-surface-400">
Webhook fired once when each crawl finishes. Single <code>POST</code>{' '}
with a JSON summary (start URL, duration, total URLs, status mix,
every non-zero issue count). Empty disables.
{t('settingsPanels.webhook.intro1', { defaultValue: 'Webhook fired once when each crawl finishes. Single' })}{' '}
<code>POST</code>{' '}
{t('settingsPanels.webhook.intro2', { defaultValue: 'with a JSON summary (start URL, duration, total URLs, status mix, every non-zero issue count). Empty disables.' })}
</p>
<div className="mb-4 rounded border border-surface-800 bg-surface-950/40 p-3">
@@ -1686,9 +1718,7 @@ function WebhookPanel({ form, update }: PanelProps) {
example="https://hooks.slack.com/services/T0/B0/abc, https://your-server.example/freecrawl-hook"
/>
<p className="mt-1 text-[10px] text-surface-500">
Compatible with Slack incoming webhooks (the JSON shape is rich
enough for Slack to render plain text), Zapier "Catch Hook"
triggers, Discord webhooks, and custom HTTP endpoints.
{t('settingsPanels.webhook.outro', { defaultValue: 'Compatible with Slack incoming webhooks (the JSON shape is rich enough for Slack to render plain text), Zapier "Catch Hook" triggers, Discord webhooks, and custom HTTP endpoints.' })}
</p>
</div>
</>
@@ -1696,15 +1726,14 @@ function WebhookPanel({ form, update }: PanelProps) {
}
function AuthPanel({ form, update }: PanelProps) {
const { t } = useTranslation();
const auth = form.auth;
const setAuth = (patch: Partial<HttpAuth>) =>
update('auth', { ...auth, ...patch });
return (
<>
<p className="mb-3 text-[11px] text-surface-400">
HTTP authentication applied on every request. Useful for staging
environments behind Basic auth, or APIs that require a Bearer
token. Digest is not supported (challenge-response state machine).
{t('settingsPanels.auth.intro', { defaultValue: 'HTTP authentication applied on every request. Useful for staging environments behind Basic auth, or APIs that require a Bearer token. Digest is not supported (challenge-response state machine).' })}
</p>
<div className="mb-4 rounded border border-surface-800 bg-surface-950/40 p-3">
@@ -1761,11 +1790,11 @@ function AuthPanel({ form, update }: PanelProps) {
}
function NetworkPanel({ form, update }: PanelProps) {
const { t } = useTranslation();
return (
<>
<p className="mb-3 text-[11px] text-surface-400">
Network-level controls: proxy override, file-extension exclusion,
redirect hop cap.
{t('settingsPanels.network.intro', { defaultValue: 'Network-level controls: proxy override, file-extension exclusion, redirect hop cap.' })}
</p>
<div className="mb-4 rounded border border-surface-800 bg-surface-950/40 p-3">
@@ -1780,7 +1809,7 @@ function NetworkPanel({ form, update }: PanelProps) {
<div className="mb-4 rounded border border-surface-800 bg-surface-950/40 p-3">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-surface-400">
Saved Proxy Profiles
{t('settingsGroups.savedProxyProfiles', { defaultValue: 'Saved Proxy Profiles' })}
</div>
<p className="mb-2 text-[10px] text-surface-500">
Save multiple `(name, URL)` pairs and switch between them via
@@ -1884,18 +1913,17 @@ function NetworkPanel({ form, update }: PanelProps) {
}
function DuplicatesPanel({ form, update }: PanelProps) {
const { t } = useTranslation();
return (
<>
<p className="mb-3 text-[11px] text-surface-400">
Near-duplicate detection. After every crawl, body text is hashed
with a 64-bit SimHash, and pages whose hashes lie within the
configured Hamming distance of each other are clustered as
near-duplicates. Surfaced under <strong>Issues → Content → Near-Duplicate</strong>.
{t('settingsPanels.duplicates.intro', { defaultValue: 'Near-duplicate detection. After every crawl, body text is hashed with a 64-bit SimHash, and pages whose hashes lie within the configured Hamming distance of each other are clustered as near-duplicates. Surfaced under' })}{' '}
<strong>{t('settingsPanels.duplicates.path', { defaultValue: 'Issues → Content → Near-Duplicate' })}</strong>.
</p>
<div className="mb-4 rounded border border-surface-800 bg-surface-950/40 p-3">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-surface-400">
Threshold
{t('settingsGroups.threshold', { defaultValue: 'Threshold' })}
</div>
<Num
label="Max Hamming distance (0 = exact only, 12 = very loose, 0 disables)"
@@ -1913,7 +1941,7 @@ function DuplicatesPanel({ form, update }: PanelProps) {
<div className="mb-4 rounded border border-surface-800 bg-surface-950/40 p-3">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-surface-400">
Scope
{t('settingsGroups.scope', { defaultValue: 'Scope' })}
</div>
<Bool
label="Only cluster indexable pages"
@@ -1941,16 +1969,16 @@ function DuplicatesPanel({ form, update }: PanelProps) {
}
function HardwarePanel({ form, update }: PanelProps) {
const { t } = useTranslation();
return (
<>
<p className="mb-3 text-[11px] text-surface-400">
Resource caps for the crawler process. Useful for keeping the
machine usable while crawling large sites (1M+ URLs).
{t('settingsPanels.hardware.intro', { defaultValue: 'Resource caps for the crawler process. Useful for keeping the machine usable while crawling large sites (1M+ URLs).' })}
</p>
<div className="mb-4 rounded border border-surface-800 bg-surface-950/40 p-3">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-surface-400">
Memory
{t('settingsGroups.memory', { defaultValue: 'Memory' })}
</div>
<Num
label="Memory soft limit (MB) — 0 = unlimited"
@@ -1968,7 +1996,7 @@ function HardwarePanel({ form, update }: PanelProps) {
<div className="mb-4 rounded border border-surface-800 bg-surface-950/40 p-3">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-surface-400">
Queue
{t('settingsGroups.queue', { defaultValue: 'Queue' })}
</div>
<Num
label="Max in-memory queue size — 0 = unlimited"
@@ -1987,7 +2015,7 @@ function HardwarePanel({ form, update }: PanelProps) {
<div className="mb-4 rounded border border-surface-800 bg-surface-950/40 p-3">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-surface-400">
CPU
{t('settingsGroups.cpu', { defaultValue: 'CPU' })}
</div>
<label className="mb-2 flex flex-col gap-1">
<FieldLabel
@@ -2023,17 +2051,16 @@ function HardwarePanel({ form, update }: PanelProps) {
}
function ContentPanel({ form, update }: PanelProps) {
const { t } = useTranslation();
return (
<>
<p className="mb-3 text-[11px] text-surface-400">
How crawled HTML is stored on disk. Disable body snapshots to keep
the project file small when you don't need the View Source detail
tab; tighten the cap for sites with adversarially-large pages.
{t('settingsPanels.content.intro', { defaultValue: "How crawled HTML is stored on disk. Disable body snapshots to keep the project file small when you don't need the View Source detail tab; tighten the cap for sites with adversarially-large pages." })}
</p>
<div className="mb-4 rounded border border-surface-800 bg-surface-950/40 p-3">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-surface-400">
Body Snapshots
{t('settingsGroups.bodySnapshots', { defaultValue: 'Body Snapshots' })}
</div>
<Bool
label="Store raw HTML body per page"
@@ -2057,13 +2084,11 @@ function ContentPanel({ form, update }: PanelProps) {
}
function CrawlAnalysisPanel({ form, update }: PanelProps) {
const { t } = useTranslation();
return (
<>
<p className="mb-3 text-[11px] text-surface-400">
Per-pass post-crawl analysis toggles. Each pass runs after the
HTTP fetch phase finishes and feeds different issue filters. Skip
passes you don't need to shave wall-clock on large crawls; the
related issue counters quietly read as 0 until the pass runs.
{t('settingsPanels.crawlAnalysis.intro', { defaultValue: "Per-pass post-crawl analysis toggles. Each pass runs after the HTTP fetch phase finishes and feeds different issue filters. Skip passes you don't need to shave wall-clock on large crawls; the related issue counters quietly read as 0 until the pass runs." })}
</p>
<div className="mb-4 rounded border border-surface-800 bg-surface-950/40 p-3 space-y-2">
@@ -2109,35 +2134,31 @@ function CrawlAnalysisPanel({ form, update }: PanelProps) {
}
function IssuesPanel() {
const { t } = useTranslation();
return (
<>
<p className="mb-3 text-[11px] text-surface-400">
Per-issue check on/off toggles.
{t('settingsPanels.issues.intro', { defaultValue: 'Per-issue check on/off toggles.' })}
</p>
<div className="rounded border border-amber-700/40 bg-amber-900/10 p-3 text-[11px] text-amber-200">
<strong>Coming in V2.</strong> Today every issue check runs
unconditionally and surfaces in the sidebar. The plan is to let
you silence specific checks per-project (e.g. disable
"Description = Title" on a CMS that's known to do it
intentionally). Until that ships, hide rows you don't care about
by collapsing the sidebar group, or filter them out via the
Advanced filter on each tab.
<strong>{t('settingsPanels.issues.v2Prefix', { defaultValue: 'Coming in V2.' })}</strong>{' '}
{t('settingsPanels.issues.v2Body', { defaultValue: 'Today every issue check runs unconditionally and surfaces in the sidebar. The plan is to let you silence specific checks per-project (e.g. disable "Description = Title" on a CMS that\'s known to do it intentionally). Until that ships, hide rows you don\'t care about by collapsing the sidebar group, or filter them out via the Advanced filter on each tab.' })}
</div>
</>
);
}
function AdvancedPanel({ form, update }: PanelProps) {
const { t } = useTranslation();
return (
<>
<p className="mb-3 text-[11px] text-surface-400">
Lower-level caps and link-follow toggles. Defaults are tuned for
typical SEO audits — only touch these if you know why.
{t('settingsPanels.advanced.intro', { defaultValue: 'Lower-level caps and link-follow toggles. Defaults are tuned for typical SEO audits — only touch these if you know why.' })}
</p>
<div className="mb-4 rounded border border-surface-800 bg-surface-950/40 p-3">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-surface-400">
Link & Response Caps
{t('settingsGroups.linkResponseCaps', { defaultValue: 'Link & Response Caps' })}
</div>
<Num
label="Max links per page (issue threshold)"
@@ -2164,7 +2185,7 @@ function AdvancedPanel({ form, update }: PanelProps) {
<div className="mb-4 rounded border border-surface-800 bg-surface-950/40 p-3">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-surface-400">
URL Structure Thresholds
{t('settingsGroups.urlStructureThresholds', { defaultValue: 'URL Structure Thresholds' })}
</div>
<Num
label="Max URL length (chars)"
@@ -2191,7 +2212,7 @@ function AdvancedPanel({ form, update }: PanelProps) {
<div className="mb-4 rounded border border-surface-800 bg-surface-950/40 p-3 space-y-2">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-surface-400">
Link Follow Behaviour
{t('settingsGroups.linkFollowBehaviour', { defaultValue: 'Link Follow Behaviour' })}
</div>
<Bool
label="Follow canonical targets"
@@ -2223,13 +2244,11 @@ function AdvancedPanel({ form, update }: PanelProps) {
}
function CookiesPanel({ form, update }: PanelProps) {
const { t } = useTranslation();
return (
<>
<p className="mb-3 text-[11px] text-surface-400">
Cookie policy applied to every fetch. The crawler is otherwise
stateless across requests; this setting controls whether
Set-Cookie response headers are recorded for the cookie-flag
issue checks (Missing Secure / HttpOnly / SameSite).
{t('settingsPanels.cookies.intro', { defaultValue: 'Cookie policy applied to every fetch. The crawler is otherwise stateless across requests; this setting controls whether Set-Cookie response headers are recorded for the cookie-flag issue checks (Missing Secure / HttpOnly / SameSite).' })}
</p>
<div className="mb-4 rounded border border-surface-800 bg-surface-950/40 p-3">
@@ -2265,26 +2284,24 @@ function CookiesPanel({ form, update }: PanelProps) {
}
function PerHostUaPanel({ form, update }: PanelProps) {
const { t } = useTranslation();
const rules = form.perHostUserAgents;
return (
<>
<p className="mb-3 text-[11px] text-surface-400">
Override the User-Agent on a per-host basis. Useful when crawling
a mobile subdomain with the mobile-Googlebot UA in the same run
as the desktop site, or when a CDN serves a different page based
on the requester's UA. The first matching pattern wins; the
global User-Agent (Requests tab) is the fallback.
{t('settingsPanels.perHostUa.intro', { defaultValue: "Override the User-Agent on a per-host basis. Useful when crawling a mobile subdomain with the mobile-Googlebot UA in the same run as the desktop site, or when a CDN serves a different page based on the requester's UA. The first matching pattern wins; the global User-Agent (Requests tab) is the fallback." })}
</p>
<div className="mb-3 rounded border border-surface-800 bg-surface-950/40 p-3">
<p className="mb-2 text-[10px] text-surface-500">
Pattern syntax: exact host (<code>m.example.com</code>) or
leading wildcard (<code>*.example.com</code>) — the wildcard
form matches any subdomain but <em>not</em> the apex.
{t('settingsPanels.perHostUa.syntax1', { defaultValue: 'Pattern syntax: exact host' })}{' '}(<code>m.example.com</code>){' '}
{t('settingsPanels.perHostUa.syntax2', { defaultValue: 'or leading wildcard' })}{' '}(<code>*.example.com</code>) {' '}
{t('settingsPanels.perHostUa.syntax3', { defaultValue: '— the wildcard form matches any subdomain but' })}{' '}
<em>{t('settingsPanels.perHostUa.notApex', { defaultValue: 'not' })}</em>{' '}{t('settingsPanels.perHostUa.theApex', { defaultValue: 'the apex.' })}
</p>
<div className="space-y-2">
{rules.length === 0 && (
<div className="rounded border border-dashed border-surface-700 px-3 py-4 text-center text-[11px] text-surface-500">
No per-host overrides yet — add one below.
{t('settingsPanels.perHostUa.empty', { defaultValue: 'No per-host overrides yet — add one below.' })}
</div>
)}
{rules.map((r, i) => (
@@ -2350,9 +2367,10 @@ function FieldLabel({
example?: string;
className?: string;
}) {
const { i18n } = useTranslation();
return (
<span className={clsx('flex items-center gap-1 text-[10px] text-surface-400', className)}>
<span>{label}</span>
<span>{translateLabel(label, i18n.language)}</span>
<InfoTip info={info} example={example} />
</span>
);
@@ -2458,13 +2476,222 @@ function Bool({
onChange={(e) => onChange(e.target.checked)}
className="mt-0.5"
/>
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-1">
<span className="text-[12px] text-surface-100">{label}</span>
<InfoTip info={info} example={example} />
</span>
{hint && <span className="text-[10px] text-surface-500">{hint}</span>}
</div>
<BoolLabel label={label} info={info} example={example} hint={hint} />
</label>
);
}
function BoolLabel({
label,
info,
example,
hint,
}: {
label: string;
info?: string;
example?: string;
hint?: string;
}) {
const { i18n } = useTranslation();
return (
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-1">
<span className="text-[12px] text-surface-100">{translateLabel(label, i18n.language)}</span>
<InfoTip info={info} example={example} />
</span>
{hint && <span className="text-[10px] text-surface-500">{hint}</span>}
</div>
);
}
/**
* V1 Faz 9 — Storage panel. Lets the user pick a default folder for new
* `.seoproject` files. Saved immediately to app prefs (not the per-crawl
* `CrawlConfig`), so there's no Save/Cancel cycle and no dependency on
* the surrounding form state.
*
* When the override is blank the OS Documents folder is used. The panel
* shows the resolved path so the user always sees where new projects
* will land, regardless of whether they've customised it.
*/
function StoragePanel() {
const { t } = useTranslation();
const [override, setOverride] = useState<string>(() => {
const raw = window.freecrawl.prefsGet('projectSaveDir');
return typeof raw === 'string' ? raw : '';
});
const [resolved, setResolved] = useState<string>('');
useEffect(() => {
let cancelled = false;
void window.freecrawl.defaultProjectDir().then((p) => {
if (!cancelled) setResolved(p);
});
return () => {
cancelled = true;
};
}, [override]);
async function browse() {
const chosen = await window.freecrawl.pickDirectory({
title: 'Choose Default Project Folder',
defaultPath: resolved || undefined,
});
if (!chosen) return;
setOverride(chosen);
window.freecrawl.prefsSet('projectSaveDir', chosen);
}
function reset() {
setOverride('');
window.freecrawl.prefsDelete('projectSaveDir');
}
const isCustom = override.trim().length > 0;
return (
<>
<p className="mb-3 text-[11px] text-surface-400">
{t('settings.storage.intro')}
</p>
<div className="mb-4 rounded border border-surface-800 bg-surface-950/40 p-3">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-surface-400">
{t('settings.storage.defaultSaveFolder')}
</div>
<div className="flex items-center gap-2">
<input
className="flex-1 rounded border border-surface-700 bg-surface-950 px-2 py-1 text-[12px] text-surface-100 focus:border-blue-500 focus:outline-none"
placeholder={t('settings.storage.placeholder')}
value={override}
readOnly
spellCheck={false}
/>
<button
className="rounded border border-surface-700 px-3 py-1 text-[11px] text-surface-200 hover:bg-surface-800 hover:text-surface-100"
onClick={browse}
>
{t('common.browse')}
</button>
{isCustom && (
<button
className="rounded border border-surface-700 px-3 py-1 text-[11px] text-surface-400 hover:bg-surface-800 hover:text-surface-100"
onClick={reset}
title={t('settings.storage.resetTitle')}
>
{t('common.reset')}
</button>
)}
</div>
<p className="mt-2 text-[10px] text-surface-500">
{t('settings.storage.resolvedPath')}:{' '}
<span className="text-surface-300">{resolved || '…'}</span>
</p>
</div>
<div className="rounded border border-surface-800 bg-surface-950/40 p-3 text-[11px] text-surface-400">
{t('settings.storage.note')}
</div>
</>
);
}
/**
* V1 Faz 9 — Privacy panel. Single toggle for anonymous telemetry opt-in.
* No telemetry backend is wired up yet (default off, no events fire);
* this preference is kept so future opt-in metrics ship with consent
* already gathered and the default remains opt-out.
*/
function PrivacyPanel() {
const { t } = useTranslation();
const [telemetryOptIn, setTelemetryOptIn] = useState<boolean>(() => {
return window.freecrawl.prefsGet('telemetryOptIn') === true;
});
function toggle(v: boolean) {
setTelemetryOptIn(v);
window.freecrawl.prefsSet('telemetryOptIn', v);
}
return (
<>
<p className="mb-3 text-[11px] text-surface-400">
{t('settings.privacy.intro')}
</p>
<div className="mb-4 rounded border border-surface-800 bg-surface-950/40 p-3">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-surface-400">
{t('settings.privacy.telemetryHeading')}
</div>
<label className="flex items-start gap-2">
<input
type="checkbox"
checked={telemetryOptIn}
onChange={(e) => toggle(e.target.checked)}
className="mt-0.5"
/>
<div className="flex flex-col gap-0.5">
<span className="text-[12px] text-surface-100">
{t('settings.privacy.telemetryLabel')}
</span>
<span className="text-[10px] text-surface-500">
{t('settings.privacy.telemetryHelp')}
</span>
</div>
</label>
</div>
<div className="rounded border border-amber-700/40 bg-amber-900/10 p-3 text-[11px] text-amber-200">
{t('settings.privacy.statusNote')}
</div>
</>
);
}
/**
* V1 Faz 8 — UI language switcher. Persists the choice under the
* `uiLanguage` app pref via `changeLanguage` and applies it instantly
* through react-i18next.
*/
function LanguagePanel() {
const { t, i18n } = useTranslation();
const current = (i18n.language?.split('-')[0] ?? 'en') as SupportedLanguage;
function pick(lng: SupportedLanguage) {
if (lng === current) return;
changeLanguage(lng);
}
return (
<>
<p className="mb-3 text-[11px] text-surface-400">
{t('settings.language.intro')}
</p>
<div className="mb-4 rounded border border-surface-800 bg-surface-950/40 p-3">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-surface-400">
{t('settings.language.label')}
</div>
<div className="flex flex-col gap-2">
{SUPPORTED_LANGUAGES.map((lng) => (
<label key={lng} className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="ui-language"
checked={current === lng}
onChange={() => pick(lng)}
/>
<span className="text-[12px] text-surface-100">
{t(`settings.language.${lng}`)}
</span>
</label>
))}
</div>
</div>
<div className="rounded border border-amber-700/40 bg-amber-900/10 p-3 text-[11px] text-amber-200">
{t('settings.language.note')}
</div>
</>
);
}
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { X } from 'lucide-react';
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
import type { SitemapValidateResult } from '@freecrawl/shared-types';
import { useAppStore } from '../store.js';
@@ -10,6 +11,7 @@ interface Props {
}
export function SitemapValidatorDialog({ open, onClose }: Props) {
const { t } = useTranslation();
const config = useAppStore((s) => s.config);
const [url, setUrl] = useState('');
const [running, setRunning] = useState(false);
@@ -18,9 +20,6 @@ export function SitemapValidatorDialog({ open, onClose }: Props) {
useEffect(() => {
if (!open) return;
// Pre-fill with the start URL's likely sitemap so a single-click run
// works for the "did the site I just crawled have a valid sitemap?"
// path that brings most users into this dialog.
let suggested = '';
if (config.startUrl) {
try {
@@ -74,12 +73,12 @@ export function SitemapValidatorDialog({ open, onClose }: Props) {
>
<div className="flex items-center border-b border-surface-800 px-4 py-2.5">
<div className="text-sm font-semibold tracking-wide text-surface-100">
Sitemap Validator
{t('sitemap.title', { defaultValue: 'Sitemap Validator' })}
</div>
<button
className="ml-auto rounded p-1 text-surface-400 hover:bg-surface-800 hover:text-surface-100"
onClick={onClose}
title="Close (Esc)"
title={t('scheduled.closeEsc', { defaultValue: 'Close (Esc)' })}
>
<X className="h-4 w-4" />
</button>
@@ -87,7 +86,7 @@ export function SitemapValidatorDialog({ open, onClose }: Props) {
<div className="flex-1 overflow-auto px-5 py-4 text-[12px]">
<label className="mb-3 flex flex-col gap-1">
<span className="text-[10px] text-surface-400">Sitemap URL</span>
<span className="text-[10px] text-surface-400">{t('sitemap.urlLabel', { defaultValue: 'Sitemap URL' })}</span>
<input
type="text"
className="rounded border border-surface-700 bg-surface-950 px-2 py-1.5 text-[12px] text-surface-100 focus:border-blue-500 focus:outline-none"
@@ -108,10 +107,10 @@ export function SitemapValidatorDialog({ open, onClose }: Props) {
onClick={runValidate}
disabled={running || !url.trim()}
>
{running ? 'Validating…' : 'Validate'}
{running ? t('sitemap.validating', { defaultValue: 'Validating…' }) : t('sitemap.validate', { defaultValue: 'Validate' })}
</button>
<span className="text-[10px] text-surface-500">
Walks nested sitemap-index entries up to depth 3 / 100K URLs.
{t('sitemap.walkInfo', { defaultValue: 'Walks nested sitemap-index entries up to depth 3 / 100K URLs.' })}
</span>
</div>
@@ -133,15 +132,14 @@ export function SitemapValidatorDialog({ open, onClose }: Props) {
>
{result.findings.length === 0 && result.errors.length === 0 ? (
<span>
✓ <strong>Valid</strong> — {result.urlCount.toLocaleString()} URL
{result.urlCount === 1 ? '' : 's'}
{result.truncated ? ' (truncated)' : ''}
✓ <strong>{t('sitemap.valid', { defaultValue: 'Valid' })}</strong> — {result.urlCount.toLocaleString()} {t('compare.urlsUnit', { defaultValue: 'URLs' })}
{result.truncated ? ' ' + t('sitemap.truncated', { defaultValue: '(truncated)' }) : ''}
</span>
) : (
<span>
⚠ <strong>{result.findings.length + result.errors.length} finding(s)</strong>
⚠ <strong>{t('sitemap.findingsCount', { defaultValue: '{{n}} finding(s)', n: result.findings.length + result.errors.length })}</strong>
{result.urlCount > 0
? ` — ${result.urlCount.toLocaleString()} URL${result.urlCount === 1 ? '' : 's'} parsed`
? ` — ${result.urlCount.toLocaleString()} ${t('sitemap.urlsParsed', { defaultValue: 'URL(s) parsed' })}`
: ''}
</span>
)}
@@ -149,20 +147,20 @@ export function SitemapValidatorDialog({ open, onClose }: Props) {
<div className="grid grid-cols-3 gap-2 text-[11px]">
<Stat
label="Sitemaps tried"
label={t('sitemap.sitemapsTried', { defaultValue: 'Sitemaps tried' })}
value={String(result.sitemapsTried.length)}
/>
<Stat
label="Sitemaps parsed"
label={t('sitemap.sitemapsParsed', { defaultValue: 'Sitemaps parsed' })}
value={String(result.sitemapsParsed.length)}
/>
<Stat label="URL entries" value={result.urlCount.toLocaleString()} />
<Stat label={t('sitemap.urlEntries', { defaultValue: 'URL entries' })} value={result.urlCount.toLocaleString()} />
</div>
{result.findings.length > 0 && (
<div>
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-amber-300">
Findings
{t('sitemap.findings', { defaultValue: 'Findings' })}
</div>
<ul className="space-y-1 rounded border border-surface-800 bg-surface-950 p-2 font-mono text-[10px] text-amber-100">
{result.findings.map((f, i) => (
@@ -175,7 +173,7 @@ export function SitemapValidatorDialog({ open, onClose }: Props) {
{result.errors.length > 0 && (
<div>
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-red-300">
Fetch errors
{t('sitemap.fetchErrors', { defaultValue: 'Fetch errors' })}
</div>
<ul className="space-y-1 rounded border border-surface-800 bg-surface-950 p-2 font-mono text-[10px] text-red-100">
{result.errors.map((e, i) => (
@@ -190,7 +188,7 @@ export function SitemapValidatorDialog({ open, onClose }: Props) {
{result.lastmodSamples.length > 0 && (
<details className="text-[11px] text-surface-300">
<summary className="cursor-pointer text-surface-400 hover:text-surface-100">
Sample lastmod values ({result.lastmodSamples.length})
{t('sitemap.lastmodSamples', { defaultValue: 'Sample lastmod values' })} ({result.lastmodSamples.length})
</summary>
<ul className="mt-1 space-y-0.5 pl-4 font-mono text-[10px]">
{result.lastmodSamples.map((s, i) => (
@@ -205,7 +203,7 @@ export function SitemapValidatorDialog({ open, onClose }: Props) {
{result.sitemapsTried.length > 0 && (
<details className="text-[11px] text-surface-300">
<summary className="cursor-pointer text-surface-400 hover:text-surface-100">
Sitemaps walked ({result.sitemapsTried.length})
{t('sitemap.sitemapsWalked', { defaultValue: 'Sitemaps walked' })} ({result.sitemapsTried.length})
</summary>
<ul className="mt-1 space-y-0.5 pl-4 font-mono text-[10px]">
{result.sitemapsTried.map((s) => (
@@ -225,7 +223,7 @@ export function SitemapValidatorDialog({ open, onClose }: Props) {
className="rounded border border-surface-700 px-3 py-1 text-[11px] hover:bg-surface-800"
onClick={onClose}
>
Close
{t('common.close', { defaultValue: 'Close' })}
</button>
</div>
</div>
@@ -1,4 +1,5 @@
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
import { useAppStore } from '../store.js';
import { usePerfMeter } from '../hooks/usePerfMeter.js';
import { useMemoryMonitor } from '../hooks/useMemoryMonitor.js';
@@ -101,6 +102,7 @@ function lagClass(lagMs: number): string {
}
export function StatsBar() {
const { t } = useTranslation();
const progress = useAppStore((s) => s.progress);
const error = useAppStore((s) => s.error);
const setError = useAppStore((s) => s.setError);
@@ -123,15 +125,15 @@ export function StatsBar() {
return (
<div className="flex shrink-0 items-center gap-5 border-t border-surface-800 bg-surface-900/50 px-3 py-1.5 text-[11px]">
<Stat label="Discovered" value={progress?.discovered ?? 0} />
<Stat label="Crawled" value={progress?.crawled ?? 0} />
<Stat label="Pending" value={progress?.pending ?? 0} />
<Stat label="Failed" value={progress?.failed ?? 0} />
<Stat label="URL/s" value={progress?.urlsPerSecond?.toFixed(1) ?? '0.0'} />
<Stat label="Avg resp" value={`${progress?.avgResponseTimeMs ?? 0}ms`} />
<Stat label="Elapsed" value={elapsedStr} />
<Stat label={t('stats.discovered')} value={progress?.discovered ?? 0} />
<Stat label={t('stats.crawled')} value={progress?.crawled ?? 0} />
<Stat label={t('stats.pending')} value={progress?.pending ?? 0} />
<Stat label={t('stats.failed')} value={progress?.failed ?? 0} />
<Stat label={t('stats.urlPerSec')} value={progress?.urlsPerSecond?.toFixed(1) ?? '0.0'} />
<Stat label={t('stats.avgResp')} value={`${progress?.avgResponseTimeMs ?? 0}ms`} />
<Stat label={t('stats.elapsed')} value={elapsedStr} />
<Stat
label="FPS"
label={t('stats.fps')}
value={perf.fps}
valueClassName={fpsClass(perf.fps)}
title={
@@ -144,7 +146,7 @@ export function StatsBar() {
/>
{perf.heapMb !== null && (
<Stat
label="Heap"
label={t('stats.heap')}
value={`${perf.heapMb} MB`}
valueClassName={heapClass(perf.heapMb)}
title="Renderer JS heap. >500 MB = warm, >1 GB = likely a listener / cache leak"
@@ -153,27 +155,27 @@ export function StatsBar() {
{mem && (
<>
<Stat
label="RSS"
label={t('stats.rss')}
value={formatBytes(mem.rss)}
valueClassName={rssClass(mem.rss, mem.systemTotal)}
title={`Main-process resident set size (Electron + workers). System total: ${formatBytes(mem.systemTotal)}.`}
/>
<Stat
label="Sys Free"
label={t('stats.sysFree')}
value={formatBytes(mem.systemFree)}
valueClassName={systemFreeClass(mem.systemFree, mem.systemTotal)}
title={`OS-reported free memory. < 10% of system total triggers swap; pause the crawl before that. System total: ${formatBytes(mem.systemTotal)}.`}
/>
{perUrlCost !== null && (
<Stat
label="Per URL"
label={t('stats.perUrl')}
value={formatBytes(perUrlCost)}
title={`Average bytes of RSS per crawled URL (${mem.urlsCrawled.toLocaleString()} URLs). Includes Electron overhead, so the marginal cost on real-world large crawls is typically lower.`}
/>
)}
{capacity !== null && (
<Stat
label="Capacity"
label={t('stats.capacity')}
value={formatCount(capacity)}
title={`Estimated additional URLs that fit in remaining system memory at the current per-URL cost. Calc: systemFree / perUrlCost. Treat as an upper bound — sustained throughput is usually CPU-bound first.`}
/>
@@ -181,7 +183,7 @@ export function StatsBar() {
</>
)}
<Stat
label="Lag"
label={t('stats.lag')}
value={`${perf.inputLagMs}ms`}
valueClassName={lagClass(perf.inputLagMs)}
title={
@@ -198,18 +200,18 @@ export function StatsBar() {
progress.paused ? (
<span className="inline-flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-amber-500" />
<span className="text-amber-400">Paused</span>
<span className="text-amber-400">{t('stats.paused')}</span>
</span>
) : (
<span className="inline-flex items-center gap-1.5">
<span className="h-2 w-2 animate-pulse rounded-full bg-emerald-500" />
<span className="text-emerald-400">Running</span>
<span className="text-emerald-400">{t('stats.running')}</span>
</span>
)
) : (
<span className="inline-flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-surface-600" />
<span className="text-surface-500">Idle</span>
<span className="text-surface-500">{t('stats.idle')}</span>
</span>
)}
{error && (
@@ -218,7 +220,7 @@ export function StatsBar() {
onClick={() => setError(null)}
title={error}
>
⚠ {error.length > 60 ? error.slice(0, 60) + '…' : error} (dismiss)
⚠ {error.length > 60 ? error.slice(0, 60) + '…' : error} ({t('stats.dismiss')})
</button>
)}
</div>
@@ -1,22 +1,31 @@
import clsx from 'clsx';
import { TAB_ORDER, useAppStore } from '../store.js';
import { useTranslation } from 'react-i18next';
import { TAB_ORDER, useAppStore, type TabKey } from '../store.js';
/** Convert dashed TabKey ('response-codes') to the camelCase key used in
* the locale JSON ('responseCodes'). Lets the JSON keep flat, dot-safe
* paths under the `tabs` namespace. */
function tabI18nKey(key: TabKey): string {
return key.replace(/-(\w)/g, (_, c: string) => c.toUpperCase());
}
export function TabsBar() {
const { t } = useTranslation();
const activeTab = useAppStore((s) => s.activeTab);
const setActiveTab = useAppStore((s) => s.setActiveTab);
return (
<div className="flex items-center overflow-x-auto border-b border-surface-800 bg-surface-900/30">
{TAB_ORDER.map((t) => (
{TAB_ORDER.map((tab) => (
<button
key={t.key}
key={tab.key}
className={clsx(
'whitespace-nowrap px-3 py-2 text-[11px] font-medium border-b-2 border-transparent transition text-surface-400 hover:text-surface-100',
activeTab === t.key && 'border-accent-500 text-surface-50 bg-surface-900/60',
activeTab === tab.key && 'border-accent-500 text-surface-50 bg-surface-900/60',
)}
onClick={() => setActiveTab(t.key)}
onClick={() => setActiveTab(tab.key)}
>
{t.label}
{t(`tabs.${tabI18nKey(tab.key)}`, { defaultValue: tab.label })}
</button>
))}
</div>
@@ -1,18 +1,19 @@
import { useState } from 'react';
import { Play, Square, Pause, Eraser, ChevronDown, Settings, History, Plus } from 'lucide-react';
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
import type { CrawlScope } from '@freecrawl/shared-types';
import { useAppStore } from '../store.js';
import { clearCrawlWithConfirm } from '../utils/clearCrawl.js';
const SCOPE_OPTIONS: { value: CrawlScope; label: string; hint: string }[] = [
{ value: 'subdomain', label: 'Subdomain', hint: 'Same subdomain only' },
{ value: 'subfolder', label: 'Subfolder', hint: 'Only under the start URL path' },
{ value: 'all-subdomains', label: 'All Subdomains', hint: '*.example.com' },
{ value: 'exact-url', label: 'Exact URL', hint: 'Single URL, no link following' },
];
export function TopBar() {
const { t } = useTranslation();
const scopeOptions: { value: CrawlScope; label: string; hint: string }[] = [
{ value: 'subdomain', label: t('topbar.scope.subdomain'), hint: t('topbar.scope.subdomainHint') },
{ value: 'subfolder', label: t('topbar.scope.subfolder'), hint: t('topbar.scope.subfolderHint') },
{ value: 'all-subdomains', label: t('topbar.scope.allSubdomains'), hint: t('topbar.scope.allSubdomainsHint') },
{ value: 'exact-url', label: t('topbar.scope.exactUrl'), hint: t('topbar.scope.exactUrlHint') },
];
const config = useAppStore((s) => s.config);
const setConfig = useAppStore((s) => s.setConfig);
// Scalar subscriptions instead of the full `progress` object — TopBar
@@ -49,12 +50,12 @@ export function TopBar() {
summaryTotal > 0 ||
overviewInternalTotal > 0 ||
overviewExternalTotal > 0;
const activeScope = SCOPE_OPTIONS.find((o) => o.value === config.scope)!;
const activeScope = scopeOptions.find((o) => o.value === config.scope)!;
async function start() {
const trimmed = config.startUrl.trim();
if (!trimmed) {
setError('Please enter a starting URL.');
setError(t('topbar.errorEmptyUrl'));
return;
}
addRecentUrl(trimmed);
@@ -101,13 +102,13 @@ export function TopBar() {
}
async function addManualUrl() {
const raw = window.prompt('Add URL to queue:', '');
const raw = window.prompt(t('topbar.addUrlPrompt'), '');
if (!raw) return;
const trimmed = raw.trim();
if (!trimmed) return;
const r = await window.freecrawl.crawlAddUrl(trimmed);
if (!r.accepted) {
setError('URL not accepted (invalid format, already crawled at full depth, or queue full).');
setError(t('topbar.errorAddUrl'));
}
}
@@ -120,7 +121,7 @@ export function TopBar() {
<div className="relative flex-1">
<input
className="input w-full"
placeholder="https://example.com"
placeholder={t('topbar.urlPlaceholder')}
value={config.startUrl}
onChange={(e) => setConfig({ startUrl: e.target.value })}
onFocus={() => {
@@ -142,7 +143,7 @@ export function TopBar() {
<div className="absolute left-0 right-0 top-full z-20 mt-1 overflow-hidden rounded border border-surface-700 bg-surface-900 shadow-xl">
<div className="flex items-center gap-1.5 border-b border-surface-800 px-3 py-1.5 text-[10px] uppercase tracking-wider text-surface-500">
<History className="h-3 w-3" />
Recent URLs
{t('topbar.recentUrls')}
</div>
{recentUrls.map((url) => (
<button
@@ -177,7 +178,7 @@ export function TopBar() {
<>
<div className="fixed inset-0 z-10" onClick={() => setScopeOpen(false)} />
<div className="absolute right-0 top-full z-20 mt-1 w-56 rounded border border-surface-700 bg-surface-900 shadow-xl">
{SCOPE_OPTIONS.map((opt) => (
{scopeOptions.map((opt) => (
<button
key={opt.value}
className={clsx(
@@ -204,47 +205,47 @@ export function TopBar() {
<button
className="btn btn-ghost border border-amber-700/60 text-amber-300"
onClick={resumeCrawl}
title="Resume crawl"
title={t('topbar.resumeTooltip')}
>
<Play className="h-3.5 w-3.5" /> Resume
<Play className="h-3.5 w-3.5" /> {t('topbar.resume')}
</button>
) : (
<button
className="btn btn-ghost border border-surface-700"
onClick={pauseCrawl}
title="Pause crawl (in-flight requests will finish)"
title={t('topbar.pauseTooltip')}
>
<Pause className="h-3.5 w-3.5" /> Pause
<Pause className="h-3.5 w-3.5" /> {t('topbar.pause')}
</button>
)}
<button className="btn btn-ghost border border-red-700/50 text-red-300" onClick={stop}>
<Square className="h-3.5 w-3.5" /> Stop
<Square className="h-3.5 w-3.5" /> {t('topbar.stop')}
</button>
<button
className="btn btn-ghost border border-surface-700"
onClick={addManualUrl}
title="Inject a URL into the running queue"
title={t('topbar.addUrlTooltip')}
>
<Plus className="h-3.5 w-3.5" /> Add URL
<Plus className="h-3.5 w-3.5" /> {t('topbar.addUrl')}
</button>
</>
) : (
<button className="btn btn-primary" onClick={start}>
<Play className="h-3.5 w-3.5" /> Start
<Play className="h-3.5 w-3.5" /> {t('topbar.start')}
</button>
)}
<button
className="btn btn-ghost border border-surface-700"
onClick={clearCrawl}
disabled={running || !hasData}
title={!hasData ? 'Nothing to clear' : undefined}
title={!hasData ? t('topbar.nothingToClear') : undefined}
>
<Eraser className="h-3.5 w-3.5" /> Clear
<Eraser className="h-3.5 w-3.5" /> {t('topbar.clear')}
</button>
<button
className="btn btn-ghost border border-surface-700 px-2 py-1.5"
onClick={() => setSettingsOpen(true)}
title="Settings"
title={t('topbar.settings')}
disabled={running}
>
<Settings className="h-3.5 w-3.5" />
@@ -0,0 +1,49 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import en from './locales/en.json';
import tr from './locales/tr.json';
/**
* V1 Faz 8 — UI internationalisation.
*
* Persisted under the `uiLanguage` app pref. The renderer hydrates the
* choice synchronously from the preload bridge, so there's no flash of
* untranslated content on cold start. Falls back to `en` when no pref
* is set OR when the saved code isn't one of the supported locales.
*/
export const SUPPORTED_LANGUAGES = ['en', 'tr'] as const;
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
export function resolveInitialLanguage(): SupportedLanguage {
try {
const raw = window.freecrawl.prefsGet('uiLanguage');
if (typeof raw === 'string' && (SUPPORTED_LANGUAGES as readonly string[]).includes(raw)) {
return raw as SupportedLanguage;
}
} catch {
/* ignore — preload not ready yet, default to en */
}
return 'en';
}
void i18n.use(initReactI18next).init({
resources: {
en: { translation: en },
tr: { translation: tr },
},
lng: resolveInitialLanguage(),
fallbackLng: 'en',
interpolation: { escapeValue: false },
returnNull: false,
});
export function changeLanguage(lng: SupportedLanguage): void {
void i18n.changeLanguage(lng);
try {
window.freecrawl.prefsSet('uiLanguage', lng);
} catch {
/* ignore */
}
}
export default i18n;
@@ -0,0 +1,560 @@
/**
* V1 Faz 8 Phase 2 — flat lookup table for the OverviewSidebar's ~200
* labels.
*
* Why not put these in the main `en.json` / `tr.json` resources? The
* sidebar tree builder produces label strings in plain English and we
* want a one-call lookup at render time without restructuring the
* tree-building code. A flat dictionary keyed by the English label
* keeps the source unchanged (the existing `label: 'Summary'`
* declarations don't have to be replaced with key strings) and lets
* us drop in Turkish translations side-by-side with the canonical
* English text. Columns and a few dialog labels use the same pattern.
*
* When adding a new sidebar/column/issue label: drop the English text
* verbatim as the key, and the Turkish translation as the value.
* Anything not present here renders in English regardless of the
* active language (graceful degradation).
*/
const TR_LABELS: Record<string, string> = {
// Top-level groups
Summary: 'Özet',
'Crawl Data': 'Crawl Verisi',
'Response Codes': 'Yanıt Kodları',
Security: 'Güvenlik',
Indexability: 'İndekslenebilirlik',
Issues: 'Sorunlar',
Sitemap: 'Sitemap',
Cookies: 'Çerezler',
Analytics: 'Analitik',
// Summary nodes
'Total Internal URLs': 'Toplam Dahili URL',
'Internal Indexable': 'İndekslenebilir Dahili',
'Internal Non-Indexable': 'İndekslenemeyen Dahili',
// Internal sub-categories
Internal: 'Dahili',
HTML: 'HTML',
JavaScript: 'JavaScript',
CSS: 'CSS',
Images: 'Görseller',
PDF: 'PDF',
Fonts: 'Yazı Tipleri',
Other: 'Diğer',
// Response codes
All: 'Tümü',
'Blocked by Robots': 'Robots Tarafından Engellendi',
'No Response': 'Yanıt Yok',
'Success (2xx)': 'Başarılı (2xx)',
'Redirection (3xx)': 'Yönlendirme (3xx)',
'Client Error (4xx)': 'İstemci Hatası (4xx)',
'Server Error (5xx)': 'Sunucu Hatası (5xx)',
// Security
'HTTPS URLs': 'HTTPS URL\'leri',
'HTTP URLs': 'HTTP URL\'leri',
'HTTP (not HTTPS)': 'HTTP (HTTPS değil)',
'Mixed Content (any)': 'Karışık İçerik (herhangi)',
'Mixed Content Active (Blocked)': 'Karışık İçerik Aktif (Engellendi)',
'Mixed Content Passive (Warning)': 'Karışık İçerik Pasif (Uyarı)',
'Missing SRI (3rd-party)': 'SRI Eksik (3. parti)',
'Insecure Form Action': 'Güvensiz Form Action',
'HSTS Missing': 'HSTS Eksik',
'HSTS Max-Age <1y': 'HSTS Max-Age <1y',
'HSTS Missing includeSubDomains': 'HSTS includeSubDomains Eksik',
'HSTS Missing Preload': 'HSTS Preload Eksik',
'CSP Missing': 'CSP Eksik',
'X-Frame-Options Missing': 'X-Frame-Options Eksik',
'X-Content-Type-Options Missing': 'X-Content-Type-Options Eksik',
'Pixel Without Permissions-Policy': 'Pixel İzin Politikası Olmadan',
'CORS Wildcard Origin': 'CORS Wildcard Origin',
'CORS Wildcard + Credentials': 'CORS Wildcard + Credentials',
'target=_blank without noopener': 'target=_blank noopener olmadan',
'Certificate Expired': 'Sertifika Süresi Dolmuş',
'Certificate Expiring (≤30d)': 'Sertifika Süresi Dolacak (≤30g)',
'Deprecated TLS Protocol': 'Eskimiş TLS Protokolü',
'Weak Signature Algorithm': 'Zayıf İmza Algoritması',
'SSL / TLS': 'SSL / TLS',
'Security Headers': 'Güvenlik Başlıkları',
// Indexability
Indexable: 'İndekslenebilir',
Noindex: 'Noindex',
Canonicalised: 'Canonicalised',
'Canonicalised (→ other)': 'Canonicalised (→ başka)',
// Issues parent
'Page Titles': 'Sayfa Başlıkları',
'Meta Descriptions': 'Meta Açıklamalar',
H1: 'H1',
Content: 'İçerik',
Response: 'Yanıt',
Page: 'Sayfa',
Links: 'Linkler',
Canonicals: 'Canonical\'lar',
Hreflang: 'Hreflang',
Pagination: 'Sayfalama',
Mobile: 'Mobil',
Accessibility: 'Erişilebilirlik',
'Structured Data': 'Yapısal Veri',
Performance: 'Performans',
Social: 'Sosyal',
Technical: 'Teknik',
URL: 'URL',
'PWA / Discovery': 'PWA / Keşif',
// Title issues
Missing: 'Eksik',
'Over 60 Characters': '60 Karakterden Uzun',
'Below 30 Characters': '30 Karakterden Kısa',
Duplicate: 'Yinelenen',
'Multiple <title> Tags': 'Birden Çok <title> Etiketi',
'Pixel Width Truncated (>600px)': 'Pixel Genişliği Kesildi (>600px)',
'Placeholder Title': 'Yer Tutucu Başlık',
'Single-Word Title': 'Tek Kelime Başlık',
// Meta description
'Over 160 Characters': '160 Karakterden Uzun',
'Below 120 Characters': '120 Karakterden Kısa',
'Pixel Width Truncated (>990px)': 'Pixel Genişliği Kesildi (>990px)',
'Description = Title': 'Açıklama = Başlık',
'Description = H1': 'Açıklama = H1',
// H1 / H2
'H1 = Title': 'H1 = Başlık',
'Over 70 Characters': '70 Karakterden Uzun',
Multiple: 'Birden Çok',
Empty: 'Boş',
// Content
'Empty Page (<30 words)': 'Boş Sayfa (<30 kelime)',
'Thin Content (<300 words)': 'İnce İçerik (<300 kelime)',
'Near-Duplicate Content': 'Yakın Yinelenen İçerik',
'Duplicate Content (exact)': 'Yinelenen İçerik (birebir)',
'Low Text/Code Ratio (<10%)': 'Düşük Metin/Kod Oranı (<%10)',
'Hard to Read (Flesch <30)': 'Okuması Zor (Flesch <30)',
'Gunning Fog >17': 'Gunning Fog >17',
'Lang Attribute Missing': 'Lang Niteliği Eksik',
'Charset Missing': 'Charset Eksik',
// Response performance
'Slow (>1s)': 'Yavaş (>1sn)',
'Very Slow (>3s)': 'Çok Yavaş (>3sn)',
'TTFB Slow (>600ms)': 'TTFB Yavaş (>600ms)',
'TTFB Very Slow (>1.8s)': 'TTFB Çok Yavaş (>1.8sn)',
'Large (>1MB)': 'Büyük (>1MB)',
'Critical Size (>3MB)': 'Kritik Boyut (>3MB)',
'Render-Blocking Head (>5)': 'Render-Blocking Head (>5)',
'Render-Blocking Head (>20, critical)': 'Render-Blocking Head (>20, kritik)',
'Compression Missing': 'Sıkıştırma Eksik',
'Keep-Alive Disabled': 'Keep-Alive Devre Dışı',
'HTTP/2 Not Advertised': 'HTTP/2 İlan Edilmedi',
'Too Many Requests (>100 subresources)': 'Çok Fazla İstek (>100 alt kaynak)',
// URL issues
'Too Long (>2048 chars)': 'Çok Uzun (>2048 karakter)',
'Contains Underscore': 'Alt Çizgi İçeriyor',
'Contains Uppercase': 'Büyük Harf İçeriyor',
'Long Query String (>100 chars)': 'Uzun Sorgu Dizesi (>100 karakter)',
'Many Query Params (>5)': 'Çok Sorgu Parametresi (>5)',
'Fragment (#) in URL': 'URL\'de Fragment (#)',
'Spaces in URL': 'URL\'de Boşluk',
'Multiple Slashes': 'Çoklu Eğik Çizgi',
'Non-ASCII Characters': 'ASCII Dışı Karakterler',
'Malformed URL': 'Bozuk URL',
'Folder Depth >4': 'Klasör Derinliği >4',
'Duplicate URL (post-norm)': 'Yinelenen URL (normalleştirme sonrası)',
'HTTP vs HTML Mismatch': 'HTTP vs HTML Uyumsuzluğu',
// Links
'Empty Anchor Text': 'Boş Anchor Metni',
'Generic Anchor Text': 'Genel Anchor Metni',
'Anchor Text Too Long (>100)': 'Anchor Metni Çok Uzun (>100)',
'Total Links per Page > 100': 'Sayfa Başına Toplam Link > 100',
'External Links > 100': 'Harici Link > 100',
'No Outlinks (Dead-End)': 'Dış Link Yok (Çıkmaz Sokak)',
'JS-Only Navigation': 'Yalnızca JS Navigasyonu',
'Internal Link → Redirect': 'Dahili Link → Yönlendirme',
'Broken (All)': 'Kırık (Tümü)',
'Broken Internal': 'Kırık Dahili',
'Broken External': 'Kırık Harici',
'Dead External Domain': 'Ölü Harici Alan',
// Redirects
'Redirect Loop': 'Yönlendirme Döngüsü',
'Long Chain (>3 hops)': 'Uzun Zincir (>3 hop)',
'Self-Redirect': 'Kendine Yönlendirme',
Redirects: 'Yönlendirmeler',
// Canonicals
'Canonical Missing': 'Canonical Eksik',
'Canonical Not Absolute': 'Canonical Mutlak Değil',
'Self-Referencing': 'Kendine Referans',
'Canonical Chain (Multi-hop)': 'Canonical Zinciri (Çok-hop)',
'Canonical → Non-200': 'Canonical → Non-200',
'Canonical → Noindex': 'Canonical → Noindex',
'Canonical → Redirect': 'Canonical → Yönlendirme',
'Multiple Canonicals': 'Birden Çok Canonical',
'Canonical Conflict': 'Canonical Çatışması',
// Hreflang
'Self-Ref Missing': 'Self-Ref Eksik',
'x-default Missing': 'x-default Eksik',
'Invalid Code': 'Geçersiz Kod',
'Reciprocity Missing': 'Karşılıklılık Eksik',
'Inconsistent Lang (same lang, two hrefs)': 'Tutarsız Lang (aynı dil, iki href)',
'Target Issues': 'Hedef Sorunları',
// Pagination
'Broken Next/Prev Target': 'Bozuk Next/Prev Hedefi',
'Sequence Break (gap in numbering)': 'Sıra Kopukluğu (numaralandırmada boşluk)',
'Meta Refresh Used': 'Meta Refresh Kullanılmış',
// Images
'Missing Alt': 'Alt Eksik',
'Empty Alt': 'Boş Alt',
'Broken Image Src': 'Bozuk Görsel Src',
'Large Image (>100KB)': 'Büyük Görsel (>100KB)',
'Slow-Loading Image (>200KB, no lazy)': 'Yavaş Yüklenen Görsel (>200KB, lazy yok)',
'Low Lazy-Loading Adoption': 'Düşük Lazy-Loading Benimseme',
'Low Responsive Image Adoption': 'Düşük Responsive Görsel Benimseme',
// Structured data
'No Structured Data': 'Yapısal Veri Yok',
'Invalid JSON-LD': 'Geçersiz JSON-LD',
'Duplicate @id': 'Yinelenen @id',
'Malformed @type': 'Bozuk @type',
'Missing Required Property': 'Gerekli Özellik Eksik',
// Social / OG
'OpenGraph Tags Missing': 'OpenGraph Etiketleri Eksik',
'OG Image Not Absolute': 'OG Image Mutlak Değil',
'OG Image >5MB': 'OG Image >5MB',
'Twitter Card Missing': 'Twitter Card Eksik',
'Twitter Image Not Absolute': 'Twitter Image Mutlak Değil',
'Twitter Image >5MB': 'Twitter Image >5MB',
// PWA / icons
'Viewport Meta Missing': 'Viewport Meta Eksik',
'Favicon Missing': 'Favicon Eksik',
'Apple Touch Icon Missing': 'Apple Touch Icon Eksik',
'Web Manifest Missing': 'Web Manifest Eksik',
'RSS/Atom Feed Missing': 'RSS/Atom Feed Eksik',
// Sitemap
'Crawled, Not in Sitemap': 'Tarandı, Sitemap\'te Yok',
'Non-200 in Sitemap': 'Sitemap\'te Non-200',
'Non-Indexable in Sitemap': 'Sitemap\'te İndekslenemeyen',
'Redirect in Sitemap': 'Sitemap\'te Yönlendirme',
// Accessibility
'Invalid ARIA Role': 'Geçersiz ARIA Role',
'Main Landmark Missing': '<main> Landmark Eksik',
'Skip Link Missing': 'Skip Link Eksik',
'Skipped Heading Level': 'Atlanan Başlık Seviyesi',
'Form Inputs Missing Label': 'Form Inputları Label Eksik',
// Analytics
'No Analytics Detected': 'Analitik Tespit Edilmedi',
'Multiple GA4 IDs': 'Birden Çok GA4 ID',
'Universal Analytics (Sunset)': 'Universal Analytics (Sunset)',
// Cookies
'Missing Secure (HTTPS)': 'Secure Eksik (HTTPS)',
'Missing HttpOnly': 'HttpOnly Eksik',
'Missing SameSite': 'SameSite Eksik',
// Document tab
Document: 'Belge',
// Column headers (table grid)
Type: 'Tip',
'Final URL': 'Son URL',
'Redirect URL': 'Yönlendirme URL',
Status: 'Durum',
'Content Type': 'İçerik Türü',
'Indexability Status': 'İndekslenebilirlik Durumu',
'Crawl Depth': 'Crawl Derinliği',
'Response Time': 'Yanıt Süresi',
'Size (Bytes)': 'Boyut (Bayt)',
Inlinks: 'Gelen Linkler',
Outlinks: 'Giden Linkler',
'Title 1': 'Başlık 1',
'Title 1 Length': 'Başlık 1 Uzunluğu',
'Meta Description 1': 'Meta Açıklama 1',
'Meta Description 1 Length': 'Meta Açıklama 1 Uzunluğu',
'H1-1': 'H1-1',
'H1-1 Length': 'H1-1 Uzunluğu',
'H1 Count': 'H1 Sayısı',
'H2 Count': 'H2 Sayısı',
'Word Count': 'Kelime Sayısı',
'Canonical Link Element 1': 'Canonical Link Element 1',
'Canonical Count': 'Canonical Sayısı',
'Meta Robots 1': 'Meta Robots 1',
'X-Robots-Tag 1': 'X-Robots-Tag 1',
'Meta Refresh': 'Meta Refresh',
'Meta Refresh URL': 'Meta Refresh URL',
'Hreflang Count': 'Hreflang Sayısı',
'Hreflang Targets': 'Hreflang Hedefleri',
'AMP HTML': 'AMP HTML',
'JSON-LD Blocks': 'JSON-LD Blokları',
'Schema Types': 'Şema Türleri',
'Empty Anchors': 'Boş Anchor\'lar',
'JS-Only Links': 'Yalnızca-JS Linkler',
'rel=next': 'rel=next',
'rel=prev': 'rel=prev',
'Sequence Break': 'Sıra Kopukluğu',
'Chain Length': 'Zincir Uzunluğu',
Loop: 'Döngü',
'Cluster Size': 'Küme Boyutu',
Cluster: 'Küme',
'Content Hash': 'İçerik Hash',
SimHash: 'SimHash',
HSTS: 'HSTS',
CSP: 'CSP',
'X-Frame-Options': 'X-Frame-Options',
'X-Content-Type-Options': 'X-Content-Type-Options',
'Mixed Active': 'Karışık Aktif',
'Mixed Passive': 'Karışık Pasif',
'Missing SRI': 'SRI Eksik',
'Custom Search Hits': 'Özel Arama Sonuçları',
'Extracted Data': 'Çıkarılan Veri',
'Imgs Missing Alt': 'Alt Eksik Görseller',
// Advanced filter field labels
'Address (URL)': 'Adres (URL)',
'Status Code': 'Durum Kodu',
'Title Length': 'Başlık Uzunluğu',
'Meta Description': 'Meta Açıklama',
'Meta Description Length': 'Meta Açıklama Uzunluğu',
'H1 Length': 'H1 Uzunluğu',
Title: 'Başlık',
Canonical: 'Canonical',
'Meta Robots': 'Meta Robots',
'X-Robots-Tag': 'X-Robots-Tag',
'Images Count': 'Görsel Sayısı',
'Response Time (ms)': 'Yanıt Süresi (ms)',
// Filter operator labels
'Contains (~)': 'İçeriyor (~)',
'Does Not Contain (≠~)': 'İçermiyor (≠~)',
'Equals (=)': 'Eşittir (=)',
'Does Not Equal (≠)': 'Eşit Değil (≠)',
'Not Equal (≠)': 'Eşit Değil (≠)',
'Starts With': 'İle Başlıyor',
'Ends With': 'İle Bitiyor',
'Is Empty': 'Boş',
'Is Not Empty': 'Boş Değil',
'Greater Than (>)': 'Büyüktür (>)',
'Greater or Equal (≥)': 'Büyük veya Eşit (≥)',
'Less Than (<)': 'Küçüktür (<)',
'Less or Equal (≤)': 'Küçük veya Eşit (≤)',
// Detail panel sub-tabs
'URL Details': 'URL Detayları',
Outline: 'Anahat',
Resources: 'Kaynaklar',
'SERP Snippet': 'SERP Snippet',
'HTTP Headers': 'HTTP Başlıkları',
'View Source': 'Kaynağı Göster',
// Detail panel resource categories
Scripts: 'Scriptler',
Stylesheets: 'Stil Sayfaları',
Iframes: 'Iframe\'ler',
'External (3rd-party)': 'Harici (3. parti)',
// ImagesTab column headers
'Image URL': 'Görsel URL',
Alt: 'Alt',
Width: 'Genişlik',
Height: 'Yükseklik',
Pages: 'Sayfalar',
// VisualizationTab layout names + hints
'Force-Directed': 'Force-Directed',
'Compound spring embedder': 'Bileşik yay yerleştirici',
'Tree (BFS)': 'Ağaç (BFS)',
'Roots-to-leaves layered': 'Kök-yaprak katmanlı',
Circle: 'Çember',
'Equal radial spacing': 'Eşit radyal boşluk',
Concentric: 'Konsantrik',
'By inlinks (centre = most-linked)': 'Inlinks\'e göre (merkez = en çok linklenen)',
// BrokenLinksTab column headers
Source: 'Kaynak',
Target: 'Hedef',
'HTTP Status': 'HTTP Durumu',
'Link Type': 'Link Türü',
'Anchor Text': 'Anchor Metin',
// Settings field labels (FieldLabel + Bool + Num + Text + Area + ...)
'Accept-Language': 'Accept-Language',
'Active profile': 'Aktif profil',
'Always Follow Redirects': 'Yönlendirmeleri Her Zaman Takip Et',
Attribute: 'Nitelik',
'Auth scheme': 'Auth şeması',
'Auto-Discover XML Sitemaps': 'XML Sitemap\'leri Otomatik Keşfet',
'Body cap per page (bytes) — 0 = unbounded': 'Sayfa başına gövde sınırı (bayt) — 0 = sınırsız',
'Check External Links': 'Harici Linkleri Kontrol Et',
'Cookie policy': 'Çerez politikası',
'Crawl Depth Limit (Max Depth)': 'Crawl Derinlik Limiti (Max Depth)',
'Crawl Mode': 'Crawl Modu',
'Crawl Total Limit (Max URLs)': 'Toplam Crawl Limiti (Max URL)',
'Custom Headers (one per line, ': 'Özel Başlıklar (satır başına bir tane, ',
'Exclude Patterns (regex, one per line)': 'Hariç Tutma Kalıpları (regex, satır başına bir tane)',
'Exclude extensions (comma-separated)': 'Hariç tutulan uzantılar (virgülle ayrılmış)',
'Follow / Store Nofollow Links': 'Nofollow Linkleri Takip Et / Sakla',
'Follow JS / meta-refresh redirects': 'JS / meta-refresh yönlendirmelerini takip et',
'Follow canonical targets': 'Canonical hedeflerini takip et',
'Follow nofollow links (override ': 'Nofollow linkleri takip et (geçersiz kıl ',
'Follow rel=next / rel=prev': 'rel=next / rel=prev takip et',
'Force HTTPS': 'HTTPS\'i Zorla',
'Hreflang reciprocity + inconsistent lang': 'Hreflang karşılıklılığı + tutarsız dil',
'Include Patterns (regex, one per line — empty = all allowed)': 'Dahil Etme Kalıpları (regex, satır başına bir tane — boş = tümü)',
'Initial Retry Delay (ms — exponential backoff)': 'Başlangıç Yeniden Deneme Gecikmesi (ms — üstel backoff)',
'Keep query parameters (whitelist; one per line)': 'Tutulacak sorgu parametreleri (beyaz liste; satır başına bir tane)',
'Lowercase path': 'Yolu küçük harfe çevir',
'Materialise heavy issue counters': 'Ağır sorun sayaçlarını materyalize et',
'Max Concurrency (parallel workers)': 'Maks Eşzamanlılık (paralel işçi)',
'Max Hamming distance (0 = exact only, 12 = very loose, 0 disables)': 'Max Hamming mesafesi (0 = sadece tam eşleşme, 12 = çok gevşek, 0 devre dışı)',
'Max URL length (chars)': 'Max URL uzunluğu (karakter)',
'Max URL/s (rate limit)': 'Max URL/sn (hız sınırı)',
'Max file size (bytes) — 0 = disabled': 'Max dosya boyutu (bayt) — 0 = devre dışı',
'Max folder depth — 0 = disabled': 'Max klasör derinliği — 0 = devre dışı',
'Max in-memory queue size — 0 = unlimited': 'Bellek içi kuyruk maks boyutu — 0 = sınırsız',
'Max links per page (issue threshold)': 'Sayfa başına max link (sorun eşiği)',
'Max query string length (chars) — 0 = disabled': 'Max sorgu dizesi uzunluğu (karakter) — 0 = devre dışı',
'Max redirect hops': 'Max yönlendirme hop',
'Max response time (ms) — 0 = disabled': 'Max yanıt süresi (ms) — 0 = devre dışı',
'Memory soft limit (MB) — 0 = unlimited': 'Yumuşak bellek limiti (MB) — 0 = sınırsız',
'Multi-Match': 'Çoklu Eşleşme',
Name: 'İsim',
'Near-duplicate clustering': 'Yakın-yinelenen kümeleme',
'Normalise URLs before duplicate-URL check': 'Yinelenen-URL kontrolünden önce URL\'leri normalleştir',
'Only cluster indexable pages': 'Sadece indekslenebilir sayfaları kümele',
Output: 'Çıktı',
'Pagination ordinal-gap detection': 'Sayfalama sıra-boşluğu tespiti',
Password: 'Parola',
'Per-Worker Delay (ms after each request)': 'İşçi Başına Gecikme (her istek sonrası ms)',
'Preview — test a URL against current settings': 'Önizleme — bir URL\'yi mevcut ayarlara karşı test et',
'Process priority': 'Süreç önceliği',
'Proxy URL (overrides HTTPS_PROXY)': 'Proxy URL (HTTPS_PROXY geçersiz kılınır)',
'Recompute inlinks': 'Inlinks yeniden hesapla',
'Recompute redirect chains': 'Yönlendirme zincirlerini yeniden hesapla',
'Regex rewrites (applied to the final URL string in order)': 'Regex yeniden yazımları (son URL dizesine sırayla uygulanır)',
'Remove profile': 'Profili kaldır',
'Remove rule': 'Kuralı kaldır',
'Respect robots.txt': 'robots.txt\'ye saygı göster',
'Response Timeout (ms)': 'Yanıt Zaman Aşımı (ms)',
'Retry Attempts (per URL on transient errors)': 'Yeniden Deneme Sayısı (geçici hatalarda URL başına)',
'Search Terms (one per line; /…/ = regex, plain = literal)': 'Arama Terimleri (satır başına bir tane; /…/ = regex, düz = literal)',
'Store raw HTML body per page': 'Sayfa başına ham HTML gövdeyi sakla',
'Strip www.': 'www. ön ekini kaldır',
Token: 'Token',
'Trailing slash policy': 'Sondaki eğik çizgi politikası',
'URL List (one URL per line)': 'URL Listesi (satır başına bir URL)',
'User-Agent': 'User-Agent',
Username: 'Kullanıcı adı',
'Webhook URL': 'Webhook URL',
// BottomDetailPanel table headers (Alt, Size, Pages, Source, Path already covered above)
Header: 'Başlık',
Value: 'Değer',
Domain: 'Alan',
Expires: 'Bitiş',
Scope: 'Kapsam',
Hints: 'İpuçları',
Rule: 'Kural',
Term: 'Terim',
Hits: 'Eşleşme',
Hamming: 'Hamming',
Words: 'Kelime',
// URL Details (NameValueView) field labels
Address: 'Adres',
'Indexability Reason': 'İndekslenebilirlik Sebebi',
'Content Kind': 'İçerik Sınıfı',
'TTFB (ms)': 'TTFB (ms)',
'HTTP Protocol': 'HTTP Protokolü',
Server: 'Sunucu',
'Query String Length': 'Sorgu Dizesi Uzunluğu',
'Render-Blocking (head)': 'Render-Engelleyici (head)',
'Keep-Alive': 'Keep-Alive',
'Title 1 Pixel Width': 'Başlık 1 Pixel Genişliği',
'Meta Description 1 Pixel Width': 'Meta Açıklama 1 Pixel Genişliği',
'H3 Count': 'H3 Sayısı',
'H4 Count': 'H4 Sayısı',
'H5 Count': 'H5 Sayısı',
'H6 Count': 'H6 Sayısı',
'Sentence Count': 'Cümle Sayısı',
'Flesch Reading Ease': 'Flesch Okunabilirlik',
'Flesch–Kincaid Grade': 'Flesch–Kincaid Sınıfı',
'Gunning Fog Index': 'Gunning Fog İndeksi',
'Complex Words': 'Karmaşık Kelimeler',
'Canonical HTTP Header': 'Canonical HTTP Başlığı',
'HTML Lang': 'HTML Lang',
Viewport: 'Viewport',
'OG Title': 'OG Başlık',
'OG Description': 'OG Açıklama',
'OG Image': 'OG Görsel',
'OG Type': 'OG Tür',
'OG URL': 'OG URL',
'OG Site Name': 'OG Site Adı',
'OG Locale': 'OG Locale',
'Twitter Card': 'Twitter Card',
'Twitter Title': 'Twitter Başlık',
'Twitter Description': 'Twitter Açıklama',
'Twitter Image': 'Twitter Görsel',
'Meta Keywords': 'Meta Keywords',
'Meta Author': 'Meta Yazar',
'Meta Generator': 'Meta Generator',
'Theme Color': 'Tema Rengi',
Charset: 'Charset',
'TLS Protocol': 'TLS Protokolü',
'TLS Cert Issuer': 'TLS Sertifika Veren',
'TLS Cert Subject': 'TLS Sertifika Konusu',
'TLS Cert Signature Alg': 'TLS Sertifika İmza Alg',
'TLS Cert Chain': 'TLS Sertifika Zinciri',
'TLS Cert Valid From': 'TLS Sertifika Geçerli',
'TLS Cert Valid To': 'TLS Sertifika Bitiş',
'TLS Cert Status': 'TLS Sertifika Durumu',
'Strict-Transport-Security': 'Strict-Transport-Security',
'Content-Security-Policy': 'Content-Security-Policy',
'Referrer-Policy': 'Referrer-Policy',
'Permissions-Policy': 'Permissions-Policy',
'Access-Control-Allow-Origin': 'Access-Control-Allow-Origin',
'Access-Control-Allow-Credentials': 'Access-Control-Allow-Credentials',
'Mixed Content Active (blocked)': 'Karışık İçerik Aktif (engellendi)',
'Mixed Content Passive (warning)': 'Karışık İçerik Pasif (uyarı)',
'Redirect Chain Length': 'Yönlendirme Zinciri Uzunluğu',
'Redirect Final URL': 'Son Yönlendirme URL',
'Folder Depth': 'Klasör Derinliği',
'Query Param Count': 'Sorgu Param Sayısı',
'Last Crawled': 'Son Crawl',
// LinkFactsRow column headers
From: 'Kimden',
To: 'Kime',
'Alt Text': 'Alt Metin',
Follow: 'Follow',
Rel: 'Rel',
'Path Type': 'Yol Türü',
'Link Path': 'Link Yolu',
'Link Position': 'Link Konumu',
'Link Origin': 'Link Kaynağı',
Transferred: 'Aktarılan',
};
/**
* Translate a sidebar/column label. Returns the English string verbatim
* when no translation is registered for the active language — keeps
* partial coverage graceful.
*/
export function translateLabel(label: string, lang: string): string {
if (lang.startsWith('tr') && TR_LABELS[label]) return TR_LABELS[label]!;
return label;
}
@@ -0,0 +1,656 @@
{
"sidebar": {
"overview": "Overview",
"urls": "URLs",
"percentOfTotal": "% of Total"
},
"common": {
"save": "Save",
"cancel": "Cancel",
"close": "Close",
"apply": "Apply",
"ok": "OK",
"remove": "Remove",
"reset": "Reset",
"browse": "Browse…",
"add": "Add",
"delete": "Delete",
"loading": "Loading…",
"noData": "No data",
"search": "Search…",
"language": "Language"
},
"topbar": {
"urlPlaceholder": "https://example.com",
"recentUrls": "Recent URLs",
"start": "Start",
"stop": "Stop",
"pause": "Pause",
"resume": "Resume",
"clear": "Clear",
"addUrl": "Add URL",
"settings": "Settings",
"pauseTooltip": "Pause crawl (in-flight requests will finish)",
"resumeTooltip": "Resume crawl",
"addUrlTooltip": "Inject a URL into the running queue",
"nothingToClear": "Nothing to clear",
"scope": {
"subdomain": "Subdomain",
"subdomainHint": "Same subdomain only",
"subfolder": "Subfolder",
"subfolderHint": "Only under the start URL path",
"allSubdomains": "All Subdomains",
"allSubdomainsHint": "*.example.com",
"exactUrl": "Exact URL",
"exactUrlHint": "Single URL, no link following"
},
"errorEmptyUrl": "Please enter a starting URL.",
"errorAddUrl": "URL not accepted (invalid format, already crawled at full depth, or queue full).",
"addUrlPrompt": "Add URL to queue:"
},
"stats": {
"discovered": "Discovered",
"crawled": "Crawled",
"pending": "Pending",
"failed": "Failed",
"urlPerSec": "URL/s",
"avgResp": "Avg resp",
"elapsed": "Elapsed",
"fps": "FPS",
"heap": "Heap",
"rss": "RSS",
"sysFree": "Sys Free",
"perUrl": "Per URL",
"capacity": "Capacity",
"lag": "Lag",
"running": "Running",
"paused": "Paused",
"idle": "Idle",
"dismiss": "dismiss"
},
"menu": {
"file": "File",
"newProject": "New Project",
"openProject": "Open Project…",
"openRecent": "Open Recent",
"clearRecent": "Clear Recent",
"emptyRecent": "(empty)",
"clearCrawlData": "Clear Crawl Data",
"exportCsv": "Export Current View as CSV",
"exportJson": "Export Current View as JSON…",
"exportXml": "Export Current View as XML…",
"generateSitemap": "Generate XML Sitemap…",
"exportHtmlReport": "Export HTML Report…",
"bulkExport": "Bulk Export…",
"compareWith": "Compare With Project…",
"scheduledCrawl": "Scheduled Crawl…",
"saveProjectAs": "Save Project As…",
"settings": "Settings…",
"view": "View",
"overviewSidebar": "Overview Sidebar",
"detailPanel": "Detail Panel",
"showVisualization": "Show Visualization Tab",
"fullscreen": "Fullscreen",
"reports": "Reports",
"reportsItem": "Reports…",
"help": "Help",
"documentation": "Documentation",
"showLogs": "Show Logs…",
"openLogsFolder": "Open Logs Folder",
"robotsTester": "Robots.txt Tester…",
"sitemapValidator": "Sitemap Validator…",
"resetDiagnostics": "Reset Diagnostic Warnings",
"deleteDomainData": "Delete Domain Data…",
"clearAllData": "Clear All Data…",
"checkForUpdates": "Check for Updates…",
"about": "About FreeCrawl SEO"
},
"tabs": {
"internal": "Internal",
"external": "External",
"responseCodes": "Response Codes",
"url": "URL",
"pageTitles": "Page Titles",
"metaDescription": "Meta Description",
"h1": "H1",
"h2": "H2",
"content": "Content",
"images": "Images",
"canonicals": "Canonicals",
"directives": "Directives",
"redirects": "Redirects",
"pagination": "Pagination",
"hreflang": "Hreflang",
"amp": "AMP",
"structuredData": "Structured Data",
"metaRefresh": "Meta Refresh",
"customExtraction": "Custom Extraction",
"customSearch": "Custom Search",
"security": "Security",
"duplicates": "Duplicates",
"links": "Links",
"brokenLinks": "Broken Links",
"serp": "SERP",
"visualization": "Visualization"
},
"export": {
"title": "Export",
"tables": "Tables",
"active": "Active",
"all": "All",
"none": "None",
"format": "Format",
"selectedOnly": "Selected rows only",
"multiCsvNote": "Multiple tables + CSV → you'll be asked to pick a folder; one file is written per table.",
"errorNoColumn": "Pick at least one column.",
"errorNoTable": "Pick at least one table.",
"successMsg": "Exported {{count}} row(s) to {{path}}",
"failedPrefix": "Export failed"
},
"app": {
"dropMissingPath": "Couldn't read the dropped path for {{name}}. Use File → Open Project instead.",
"dropOpenedProject": "Opened project: {{path}}",
"dropFailedProject": "Failed to open project: {{name}}",
"dropNoUrls": "No URLs found in {{name}}.",
"dropLoadedUrls": "Loaded {{count}} URLs from {{name}} into List mode.",
"crashRecovery": "FreeCrawl detected a previous crawl that didn't finish cleanly:\n\n Start URL: {{seed}}\n Pending URLs: {{pending}}\n\nResume the crawl from where it stopped?\n\nClick OK to resume, Cancel to discard the recovery state and start fresh.",
"deleteDomainPrompt": "GDPR Domain Wipe — enter the host to delete (case-insensitive, exact match).\n\nExamples: example.com · blog.example.com\n\nThis cannot be undone. Save Project As… first to keep a backup.",
"deleteDomainConfirm": "Delete every URL hosted on \"{{host}}\", along with its links, headers, images, and source snapshots?\n\nThis action cannot be undone.",
"deleteDomainResult": "Deleted {{urls}} URL row(s) from {{host}} (and {{links}} associated link(s)).",
"clearAllConfirm": "Clear ALL data in the active project?\n\nEvery URL, link, image, header, source snapshot, sitemap, and issue will be wiped. This cannot be undone — Save Project As… first if you want a backup."
},
"infoTip": {
"moreInfo": "More info",
"example": "Example"
},
"detail": {
"comingSoon": "Coming soon",
"urlsSelected": "URLs selected",
"aggregatingFirst": "aggregating first {{n}}",
"perPagePrefix": "This tab is per-page — showing data for",
"perPageSuffix": "(primary URL of the selection).",
"selectUrl": "Select a URL from the table to see details."
},
"filter": {
"title": "Advanced Table Search",
"where": "Where",
"orWhere": "Or Where",
"andRow": "And",
"and": "AND",
"or": "OR",
"addAnd": "Add another AND condition to this group",
"addOr": "Add an OR group",
"removeGroup": "Remove group",
"removeCondition": "Remove condition",
"searchPlaceholder": "Enter search query",
"noValue": "(no value required)"
},
"reports": {
"reportLabel": "Report",
"report": {
"pagesPerDir": "Pages per Directory",
"statusCodes": "Status Code Histogram",
"indexabilityDistribution": "Indexability Distribution",
"contentKindDistribution": "Content-Type Distribution",
"depth": "Depth Histogram",
"responseTime": "Response Time Histogram",
"slowestUrls": "Slowest URLs (Top 25)",
"mostInlinks": "Most-Linked URLs (Top 25)",
"leastInlinks": "Least-Linked URLs (Bottom 25, indexable HTML)",
"mostOutlinks": "Most-Outlinking URLs (Top 25)",
"biggestPages": "Biggest Pages (Top 25)",
"deepestUrls": "Deepest URLs (Top 25)",
"externalDomainHealth": "External Domain Health",
"analyticsCoverage": "Analytics Tracker Coverage",
"linkPositions": "Internal Link Positions",
"imageWeight": "Image Weight per Page (Top 25)",
"inlinksHistogram": "Inlinks Histogram",
"wordCountHistogram": "Word Count Histogram",
"urlLengthHistogram": "URL Length Histogram",
"wordCountPerDir": "Word Count per Directory",
"sitemapOrphans": "Sitemap Orphans (Top 1000)",
"serverHeaders": "Server Stack (Server Header)",
"topWords": "Top Words (Title + Meta + H1, Top 100)"
},
"groupAtDepth": "Group at depth",
"depthTopLevel": "1 (top-level)",
"summary": "{{rows}} rows · {{urls}} URLs",
"noData": "No data — run a crawl first.",
"count": "Count",
"share": "Share",
"keyDirectory": "Directory",
"keyStatus": "Status",
"keyIndexability": "Indexability",
"keyContentType": "Content Type",
"keyDepth": "Depth",
"keyBucket": "Bucket",
"keyDomain": "Domain",
"keyTracker": "Tracker",
"keyPosition": "Position",
"keyServer": "Server",
"keyWord": "Word"
},
"sitemap": {
"title": "Sitemap Validator",
"urlLabel": "Sitemap URL",
"validate": "Validate",
"validating": "Validating…",
"walkInfo": "Walks nested sitemap-index entries up to depth 3 / 100K URLs.",
"valid": "Valid",
"truncated": "(truncated)",
"findingsCount": "{{n}} finding(s)",
"urlsParsed": "URL(s) parsed",
"sitemapsTried": "Sitemaps tried",
"sitemapsParsed": "Sitemaps parsed",
"urlEntries": "URL entries",
"findings": "Findings",
"fetchErrors": "Fetch errors",
"lastmodSamples": "Sample lastmod values",
"sitemapsWalked": "Sitemaps walked"
},
"robots": {
"title": "Robots.txt Tester",
"urlToTest": "URL to test",
"userAgent": "User-Agent",
"customMode": "Test against a custom robots.txt (draft mode)",
"loadFromFile": "Load from file…",
"customBodyLabel": "Custom robots.txt body (parsed — no fetch)",
"test": "Test",
"testCustom": "Test (custom)",
"testing": "Testing…",
"allowed": "Allowed",
"disallowed": "Disallowed",
"byRobotsFor": "by robots.txt for User-Agent",
"crawlDelay": "Crawl-Delay",
"sitemapsDeclared": "Sitemaps declared",
"sitemapsHeader": "Sitemaps",
"bodyHeader": "robots.txt body",
"charsCount": "{{n}} chars",
"lintPrompt": "Paste a draft robots.txt above to lint syntax.",
"noIssues": "No syntax issues detected.",
"errorCount": "{{n}} error(s)",
"warningCount": "{{n}} warning(s)"
},
"settingsGroups": {
"threshold": "Threshold",
"scope": "Scope",
"memory": "Memory",
"queue": "Queue",
"cpu": "CPU",
"bodySnapshots": "Body Snapshots",
"linkResponseCaps": "Link & Response Caps",
"urlStructureThresholds": "URL Structure Thresholds",
"linkFollowBehaviour": "Link Follow Behaviour",
"savedProxyProfiles": "Saved Proxy Profiles",
"ruleN": "Rule #{{n}}"
},
"settingsPanels": {
"presets": {
"intro": "One-click profiles for common crawl scenarios. Clicking a preset overwrites the affected fields only — your URL list, custom rules, filters, and extraction rules are preserved.",
"importExport": "Import / Export",
"importExportIntro": "Save the current settings to a JSON file (e.g. for sharing with a teammate or version control), or load a previously-exported settings file. Importing replaces the current form with the file's contents — press Save at the bottom to persist.",
"exportBtn": "Export…",
"importBtn": "Import…",
"exported": "Settings exported.",
"imported": "Settings imported.",
"exportFailed": "Export failed: {{msg}}",
"importFailed": "Import failed: {{msg}}",
"outro": "After clicking Apply or Import, review each panel to verify the values, then press Save at the bottom to persist."
},
"mode": { "intro": "Choose how the crawler discovers URLs. Spider follows links from a start URL; List fetches a fixed set." },
"crawler": {
"intro": "Traversal limits and crawl-scope toggles. Screaming Frog equivalents shown in parentheses.",
"speedHintPrefix": "Looking for crawl speed?",
"speedHintBody": "Thread count, requests per second, per-request delay, and retries live in the",
"speedHintLink": "Speed",
"speedHintSuffix": "section — that's where you cap how fast the crawler hits a server."
},
"speed": {
"intro": "Control crawl throughput. Increasing parallelism speeds the crawl, but every extra request adds load on the target server — pick numbers a host that you don't own can absorb without rate-limiting / 429s.",
"effectiveCeiling": "Effective ceiling"
},
"requests": { "intro": "HTTP headers sent with every request." },
"filters": { "intro": "URL allowlist/blocklist. Patterns are JavaScript regex tested against the full URL." },
"customSearch": {
"intro1": "Flag pages whose body contains any of these terms. Two modes per line:",
"intro2": "for regex,",
"anythingElse": "anything else",
"intro3": "for literal case-insensitive substring."
},
"customExtraction": {
"intro1": "Up to 10 custom extraction rules. Each runs against every crawled HTML page; results are stored on the URL row and visible in the URL Details panel under",
"extractionLabel": "Extraction",
"empty": "No rules — click \"Add Rule\" to start."
},
"urlRewriting": { "intro": "Normalisation applied before URLs are deduplicated and queued." },
"webhook": {
"intro1": "Webhook fired once when each crawl finishes. Single",
"intro2": "with a JSON summary (start URL, duration, total URLs, status mix, every non-zero issue count). Empty disables.",
"outro": "Compatible with Slack incoming webhooks (the JSON shape is rich enough for Slack to render plain text), Zapier \"Catch Hook\" triggers, Discord webhooks, and custom HTTP endpoints."
},
"auth": { "intro": "HTTP authentication applied on every request. Useful for staging environments behind Basic auth, or APIs that require a Bearer token. Digest is not supported (challenge-response state machine)." },
"network": { "intro": "Network-level controls: proxy override, file-extension exclusion, redirect hop cap." },
"duplicates": {
"intro": "Near-duplicate detection. After every crawl, body text is hashed with a 64-bit SimHash, and pages whose hashes lie within the configured Hamming distance of each other are clustered as near-duplicates. Surfaced under",
"path": "Issues → Content → Near-Duplicate"
},
"hardware": { "intro": "Resource caps for the crawler process. Useful for keeping the machine usable while crawling large sites (1M+ URLs)." },
"content": { "intro": "How crawled HTML is stored on disk. Disable body snapshots to keep the project file small when you don't need the View Source detail tab; tighten the cap for sites with adversarially-large pages." },
"crawlAnalysis": { "intro": "Per-pass post-crawl analysis toggles. Each pass runs after the HTTP fetch phase finishes and feeds different issue filters. Skip passes you don't need to shave wall-clock on large crawls; the related issue counters quietly read as 0 until the pass runs." },
"issues": {
"intro": "Per-issue check on/off toggles.",
"v2Prefix": "Coming in V2.",
"v2Body": "Today every issue check runs unconditionally and surfaces in the sidebar. The plan is to let you silence specific checks per-project (e.g. disable \"Description = Title\" on a CMS that's known to do it intentionally). Until that ships, hide rows you don't care about by collapsing the sidebar group, or filter them out via the Advanced filter on each tab."
},
"advanced": { "intro": "Lower-level caps and link-follow toggles. Defaults are tuned for typical SEO audits — only touch these if you know why." },
"cookies": { "intro": "Cookie policy applied to every fetch. The crawler is otherwise stateless across requests; this setting controls whether Set-Cookie response headers are recorded for the cookie-flag issue checks (Missing Secure / HttpOnly / SameSite)." },
"perHostUa": {
"intro": "Override the User-Agent on a per-host basis. Useful when crawling a mobile subdomain with the mobile-Googlebot UA in the same run as the desktop site, or when a CDN serves a different page based on the requester's UA. The first matching pattern wins; the global User-Agent (Requests tab) is the fallback.",
"syntax1": "Pattern syntax: exact host",
"syntax2": "or leading wildcard",
"syntax3": "— the wildcard form matches any subdomain but",
"notApex": "not",
"theApex": "the apex.",
"empty": "No per-host overrides yet — add one below."
}
},
"compare": {
"title": "Compare Crawls",
"aCurrent": "A (current)",
"urlsUnit": "URLs",
"pickDifferent": "Pick a different project file",
"comparing": "Comparing…",
"openOther": "Open Other Project",
"pickPrompt": "Pick a `.seoproject` file to diff against the current crawl.",
"added": "Added",
"removed": "Removed",
"statusChanged": "Status Changed",
"titleChanged": "Title Changed",
"metaChanged": "Meta Description Changed",
"h1Changed": "H1 Changed",
"canonicalChanged": "Canonical Changed",
"indexabilityChanged": "Indexability Changed",
"responseTimeDelta": "Response Time Δ",
"entriesShown": "{{shown}} of {{total}} entries shown",
"truncated": "(truncated — export CSV for the full set)",
"urlHeader": "URL",
"beforeHeader": "Before",
"afterHeader": "After",
"noDiffs": "No diffs in this category."
},
"scheduled": {
"title": "Scheduled Crawl",
"closeEsc": "Close (Esc)",
"warningPrefix": "The schedule fires only while FreeCrawl is open on this project. For triggers that survive an app restart, run",
"warningSuffix": "(the CLI) from Windows Task Scheduler / macOS launchd / cron.",
"enabled": "Enabled",
"cadence": "Cadence",
"hourly": "Hourly",
"daily": "Daily",
"weekly": "Weekly",
"customInterval": "Custom interval",
"intervalMinutes": "Interval (minutes, min {{min}})",
"hour": "Hour (0–23)",
"minute": "Minute (0–59)",
"dayOfWeek": "Day of week",
"daySun": "Sun",
"dayMon": "Mon",
"dayTue": "Tue",
"dayWed": "Wed",
"dayThu": "Thu",
"dayFri": "Fri",
"daySat": "Sat",
"nextFire": "Next fire",
"lastFired": "Last fired",
"lastStatus": "Last status",
"hintDisabled": "Disabled — set \"Enabled\" and Save to arm the schedule.",
"hintEnabled": "Save to compute the next fire time. The schedule only fires while FreeCrawl is open.",
"confirmRemove": "Remove the scheduled crawl for this project?",
"saving": "Saving…"
},
"serp": {
"noTitle": "(no title)",
"noMeta": "(no meta description)",
"chars": "chars",
"titlePixelWidth": "Title pixel width",
"titleLength": "Title length",
"descriptionPixelWidth": "Description pixel width",
"descriptionLength": "Description length"
},
"cookies": {
"emptyPrefix": "This page did not set any cookies (no",
"emptySuffix": "response headers).",
"emptyNote": "Note: only first-party cookies set by the page itself are listed here. Cookies set by third-party scripts (analytics, ads) are set in the browser at runtime and are not visible to a static crawler.",
"cookiesSet": "cookies set",
"missing": "{{n}} missing"
},
"duplicates": {
"notInCluster": "This URL is not part of a near-duplicate cluster. Run Crawl Analysis on a finished crawl, or lower the Hamming threshold in Settings → Duplicates, to widen what counts as a near-duplicate.",
"noOtherMembers": "No other members in this cluster. (Cluster size {{n}}.)",
"cluster": "Cluster",
"membersTotal": "{{n}} member(s) total",
"hammingHint": "(Hamming distance from this URL — lower means more similar)"
},
"resources": {
"loadingSource": "Loading source…",
"loadingResources": "Loading resources…",
"requiresSnapshot": "Resources view requires a stored HTML body snapshot.",
"multiRequiresSnapshot": "Resources view requires a stored HTML body snapshot for each page.",
"recrawlPrefix": "Re-crawl with the",
"recrawlSuffix": "setting enabled.",
"noMatch": "No resources match this filter.",
"missingSnapshots": "{{missing}} of {{total}} pages have no stored body"
},
"extraction": {
"empty1": "No custom extraction rules or search terms have produced data for this page.",
"empty2": "Configure rules in",
"empty3": "or search terms in",
"empty4": "then re-crawl.",
"customExtraction": "Custom Extraction",
"customSearchHits": "Custom Search hits"
},
"structured": {
"blocks": "block(s)",
"invalid": "{{n}} invalid",
"microdataItems": "microdata items",
"rdfaAttrs": "RDFa attrs",
"empty": "No structured data declared on this page (no JSON-LD, microdata or RDFa).",
"schemaTypes": "Schema types",
"loadingSource": "Loading source…",
"jsonLdBlocks": "JSON-LD blocks",
"blockN": "Block #{{n}}",
"parsedOk": "parsed OK",
"parseFailed": "parse failed",
"snapshotUnavailable1": "JSON-LD blocks were detected during crawl but the page body snapshot is unavailable. Re-crawl with",
"snapshotUnavailable2": "enabled to view the raw payload."
},
"imagesView": {
"loading": "Loading images…",
"emptyPrefix": "No",
"emptySuffix": "tags discovered on this page.",
"images": "images",
"missingAlt": "{{n}} missing alt",
"emptyAlt": "{{n}} empty alt (decorative)",
"external": "external",
"largeCount": "{{n}} > 100 KB",
"multiEmpty": "No <img> tags discovered across the {{n}} selected pages.",
"multiSummary": "images across {{n}} pages"
},
"links": {
"showing": "Showing",
"of": "of",
"noLinks": "No links.",
"columnHint": "Click to select column · drag across headers to select multiple · drag right edge to resize",
"dragResize": "Drag to resize · double-click to reset"
},
"cellMenu": {
"copyCell": "Copy Cell",
"copyCells": "Copy {{n}} Cells",
"copyUrl": "Copy URL",
"copyUrls": "Copy {{n}} URLs",
"copyValue": "Copy Value",
"openInBrowser": "Open in Browser"
},
"diag": {
"fromHeaders": "From the response headers",
"likelyCauses": "Likely causes",
"whatToDo": "What to do"
},
"outline": {
"emptyPrefix": "This page has no detected headings (no",
"emptySuffix": "elements). Pages without headings are harder for screen readers to navigate and may rank poorly for long-form queries.",
"headings": "headings",
"skipped": "Skipped: previous was h{{n}}",
"skippedCount": "{{n}} skipped level(s)",
"capped": "(capped at 200)",
"emptyHeading": "(empty heading)"
},
"viewSource": {
"searchPlaceholder": "Search source…",
"matchOf": "{{n}} of {{total}}",
"prevMatch": "Previous match (Shift+Enter)",
"nextMatch": "Next match (Enter)",
"noMatches": "No matches",
"wrap": "Wrap",
"download": "Download",
"loadingSource": "Loading source…",
"noBody": "No HTML body stored for this URL.",
"noBodyHint1": "View Source is only captured for HTML pages crawled with the",
"noBodyHint2": "setting enabled."
},
"logs": {
"title": "Logs",
"level": "Level",
"filterPlaceholder": "Filter messages / sources…",
"autoScroll": "Auto-scroll",
"copy": "Copy",
"copyTitle": "Copy visible entries to clipboard",
"clear": "Clear",
"empty": "No log entries.",
"showingPrefix": "Showing",
"entriesSuffix": "entries",
"tailHint": "Live tail keeps the last {{n}} entries — full history persisted to disk (Help → Open Logs Folder)."
},
"viz": {
"layout": "Layout:",
"color": "Color:",
"nodes": "Nodes:",
"labels": "Labels:",
"byStatus": "By Status",
"byDepth": "By Depth",
"byIndexability": "By Indexability",
"hoverOnly": "Hover Only",
"top20": "Top 20",
"all": "All",
"fit": "Fit",
"fitTitle": "Fit graph to view",
"reload": "Reload",
"labelsTooltip": "Hover = on demand · Top 20 = only the most-linked hubs · All = every node",
"layoutTuning": "Layout Tuning",
"exportGraph": "Export graph",
"export": "Export",
"exportPng": "PNG (high-DPI raster)",
"exportSvg": "SVG (vector — Illustrator/Figma)",
"exportHtml": "Standalone HTML (shareable)",
"nodesEdgesCount": "{{nodes}} nodes · {{edges}} edges",
"interactionHint": "Hover = neighbours · Click = select · Empty click = clear · Double-click node = open · Double-click canvas = fit",
"loadingGraph": "Loading graph…",
"emptyGraph": "No URLs crawled yet — start a crawl to populate the graph.",
"topAnchorTexts": "Top Anchor Texts",
"anchorsEmpty": "No internal-link anchors collected yet.",
"resetToDefaults": "Reset to defaults",
"nodeSize": "Node size",
"nodeSizeHint": "Scales every dot's radius. Lower = tighter graph, higher = easier to click but more overlap.",
"nodeDistance": "Node distance (repulsion)",
"repulsionHint": "How strongly nodes push each other apart. Higher = more breathing room. Force-Directed only.",
"edgeLength": "Edge length",
"edgeLengthHint": "Target rest-length for connections. Higher = longer edges. Force-Directed only.",
"clusterSpacing": "Cluster spacing",
"clusterSpacingHint": "Gap between disconnected sub-graphs. Higher = isolated clusters spread further apart.",
"edgeOpacity": "Edge opacity",
"edgeOpacityHint": "Lower = less visual noise on dense graphs.",
"changesNeedRerun": "Some changes need a layout re-run.",
"rerunLayout": "Re-run layout"
},
"imagesTab": {
"searchPlaceholder": "Search image URLs / alt…",
"missingAltOnly": "Missing Alt only",
"imagesUnit": "images",
"loaded": "{{n}} loaded",
"row": "Row",
"noImages": "No images"
},
"serpTab": {
"title": "SERP Preview",
"filterPlaceholder": "Filter by URL or title…",
"sort": "Sort:",
"sortInlinks": "Inlinks (most-linked first)",
"sortUrl": "URL (alphabetical)",
"sortTitleLen": "Longest title first",
"sortDescLen": "Longest description first",
"summary": "{{shown}} of {{total}} indexable pages",
"firstN": "(first {{n}})",
"emptyNoData": "No indexable HTML pages with a title yet — run a crawl to populate SERP previews.",
"emptyNoMatch": "No pages match the current filter."
},
"brokenTab": {
"searchPlaceholder": "Search broken target URLs…",
"scopeAll": "All",
"scopeInternal": "Internal",
"scopeExternal": "External",
"internalOnly": "Internal only",
"externalOnly": "External only",
"linksUnit": "broken links",
"noBrokenLinks": "No broken links",
"noBrokenLinksHint": "Run a crawl that includes external links to see broken links."
},
"urlsTab": {
"searchPlaceholder": "Search URLs / titles…",
"advanced": "Advanced",
"advancedFilterTitle": "Advanced filter",
"clearAdvancedFilter": "Clear advanced filter",
"columns": "Columns",
"showHideColumns": "Show/hide columns",
"noToggleableColumns": "This tab has no toggleable columns",
"export": "Export",
"exportThisTable": "Export this table",
"noDataToExport": "No data to export",
"noUrlsToShow": "No URLs to show",
"makeAllVisible": "Make every column visible",
"hideAllExceptRow": "Hide every column except Row #",
"showAll": "Show all",
"hideAll": "Hide all",
"columnHeaderHint": "(click to select · drag to multi-select · right-click to hide)"
},
"settings": {
"title": "Settings",
"resetToDefaults": "Reset to Defaults",
"save": "Save",
"cancel": "Cancel",
"language": {
"title": "Language",
"intro": "Select the UI display language. Applied immediately.",
"label": "UI language",
"en": "English",
"tr": "Türkçe",
"note": "Some deep panels and dialogs may still display English while broader coverage is being added."
},
"storage": {
"title": "Storage",
"intro": "Default folder for new .seoproject files. Save As dialogs open here by default; the Open dialog is not constrained.",
"defaultSaveFolder": "Default Save Folder",
"placeholder": "(System Documents folder)",
"resolvedPath": "Resolved path",
"resetTitle": "Reset to the OS Documents folder",
"note": "Active crawl data lives in the app's user-data directory until you Save As — the path above only controls where Save As starts. Changing this won't move any existing project files."
},
"privacy": {
"title": "Privacy",
"intro": "FreeCrawl is local-first — crawl results, project files, and URLs you visit never leave your machine.",
"telemetryHeading": "Anonymous Telemetry",
"telemetryLabel": "Send anonymous usage telemetry (opt-in)",
"telemetryHelp": "Off by default. When on, aggregated counts of feature use (e.g. \"export ran\", \"report opened\") may be sent — never URLs, project contents, or crawl results.",
"statusNote": "No telemetry endpoint is currently wired up. Toggling this preference is recorded but nothing is transmitted in the current build. The toggle is exposed now so consent state persists across upgrades when a future build adds an endpoint."
}
}
}
@@ -0,0 +1,655 @@
{
"sidebar": {
"overview": "Genel Bakış",
"urls": "URL'ler",
"percentOfTotal": "Toplamın %"
},
"common": {
"save": "Kaydet",
"cancel": "İptal",
"close": "Kapat",
"apply": "Uygula",
"ok": "Tamam",
"remove": "Kaldır",
"reset": "Sıfırla",
"browse": "Gözat…",
"add": "Ekle",
"delete": "Sil",
"loading": "Yükleniyor…",
"noData": "Veri yok",
"search": "Ara…",
"language": "Dil"
},
"topbar": {
"urlPlaceholder": "https://ornek.com",
"recentUrls": "Son URL'ler",
"start": "Başlat",
"stop": "Durdur",
"pause": "Duraklat",
"resume": "Devam Et",
"clear": "Temizle",
"addUrl": "URL Ekle",
"settings": "Ayarlar",
"pauseTooltip": "Crawl'ı duraklat (işlem hattındaki istekler tamamlanır)",
"resumeTooltip": "Crawl'a devam et",
"addUrlTooltip": "Çalışan kuyruğa bir URL enjekte et",
"nothingToClear": "Temizlenecek bir şey yok",
"scope": {
"subdomain": "Alt Alan Adı",
"subdomainHint": "Yalnızca aynı alt alan adı",
"subfolder": "Alt Klasör",
"subfolderHint": "Yalnızca başlangıç URL yolunun altı",
"allSubdomains": "Tüm Alt Alan Adları",
"allSubdomainsHint": "*.ornek.com",
"exactUrl": "Tam URL",
"exactUrlHint": "Tek URL, link takibi yok"
},
"errorEmptyUrl": "Lütfen bir başlangıç URL'i gir.",
"errorAddUrl": "URL kabul edilmedi (geçersiz format, zaten tam derinlikte tarandı ya da kuyruk dolu).",
"addUrlPrompt": "Kuyruğa eklenecek URL:"
},
"stats": {
"discovered": "Keşfedilen",
"crawled": "Taranan",
"pending": "Bekleyen",
"failed": "Başarısız",
"urlPerSec": "URL/sn",
"avgResp": "Ort. yanıt",
"elapsed": "Geçen süre",
"fps": "FPS",
"heap": "Heap",
"rss": "RSS",
"sysFree": "Boş Bellek",
"perUrl": "URL Başına",
"capacity": "Kapasite",
"lag": "Gecikme",
"running": "Çalışıyor",
"paused": "Duraklatıldı",
"idle": "Boşta",
"dismiss": "kapat"
},
"menu": {
"file": "Dosya",
"newProject": "Yeni Proje",
"openProject": "Proje Aç…",
"openRecent": "Son Açılanlar",
"clearRecent": "Son Açılanları Temizle",
"emptyRecent": "(boş)",
"clearCrawlData": "Crawl Verilerini Temizle",
"exportCsv": "Mevcut Görünümü CSV Olarak Dışa Aktar",
"exportJson": "Mevcut Görünümü JSON Olarak Dışa Aktar…",
"exportXml": "Mevcut Görünümü XML Olarak Dışa Aktar…",
"generateSitemap": "XML Sitemap Oluştur…",
"exportHtmlReport": "HTML Rapor Dışa Aktar…",
"bulkExport": "Toplu Dışa Aktarım…",
"compareWith": "Projeyle Karşılaştır…",
"scheduledCrawl": "Zamanlanmış Crawl…",
"saveProjectAs": "Projeyi Farklı Kaydet…",
"settings": "Ayarlar…",
"view": "Görünüm",
"overviewSidebar": "Genel Bakış Kenar Çubuğu",
"detailPanel": "Detay Paneli",
"showVisualization": "Görselleştirme Sekmesini Göster",
"fullscreen": "Tam Ekran",
"reports": "Raporlar",
"reportsItem": "Raporlar…",
"help": "Yardım",
"documentation": "Dokümantasyon",
"showLogs": "Logları Göster…",
"openLogsFolder": "Log Klasörünü Aç",
"robotsTester": "Robots.txt Test Aracı…",
"sitemapValidator": "Sitemap Doğrulayıcı…",
"resetDiagnostics": "Tanı Uyarılarını Sıfırla",
"deleteDomainData": "Alan Verilerini Sil…",
"clearAllData": "Tüm Veriyi Temizle…",
"checkForUpdates": "Güncellemeleri Kontrol Et…",
"about": "FreeCrawl SEO Hakkında"
},
"tabs": {
"internal": "Dahili",
"external": "Harici",
"responseCodes": "Yanıt Kodları",
"url": "URL",
"pageTitles": "Sayfa Başlıkları",
"metaDescription": "Meta Açıklama",
"h1": "H1",
"h2": "H2",
"content": "İçerik",
"images": "Görseller",
"canonicals": "Canonical'lar",
"directives": "Direktifler",
"redirects": "Yönlendirmeler",
"pagination": "Sayfalama",
"hreflang": "Hreflang",
"amp": "AMP",
"structuredData": "Yapısal Veri",
"metaRefresh": "Meta Refresh",
"customExtraction": "Özel Çıkarım",
"customSearch": "Özel Arama",
"security": "Güvenlik",
"duplicates": "Yinelenenler",
"links": "Linkler",
"brokenLinks": "Kırık Linkler",
"serp": "SERP",
"visualization": "Görselleştirme"
},
"export": {
"title": "Dışa Aktar",
"tables": "Tablolar",
"active": "Aktif",
"all": "Tümü",
"none": "Hiçbiri",
"format": "Format",
"selectedOnly": "Yalnızca seçili satırlar",
"multiCsvNote": "Birden çok tablo + CSV → bir klasör seçmen istenecek; tablo başına bir dosya yazılır.",
"errorNoColumn": "En az bir kolon seç.",
"errorNoTable": "En az bir tablo seç.",
"successMsg": "{{count}} satır {{path}} konumuna aktarıldı",
"failedPrefix": "Dışa aktarım başarısız"
},
"app": {
"dropMissingPath": "{{name}} için bırakılan yol okunamadı. Bunun yerine Dosya → Proje Aç kullan.",
"dropOpenedProject": "Proje açıldı: {{path}}",
"dropFailedProject": "Proje açılamadı: {{name}}",
"dropNoUrls": "{{name}} içinde URL bulunamadı.",
"dropLoadedUrls": "{{name}} dosyasından {{count}} URL List moduna yüklendi.",
"crashRecovery": "FreeCrawl, temiz bitmemiş bir önceki crawl tespit etti:\n\n Başlangıç URL: {{seed}}\n Bekleyen URL: {{pending}}\n\nCrawl kaldığı yerden devam etsin mi?\n\nDevam etmek için Tamam, recovery durumunu iptal edip baştan başlamak için İptal'e tıkla.",
"deleteDomainPrompt": "GDPR Alan Temizleme — silinecek host'u gir (büyük/küçük harf duyarsız, tam eşleşme).\n\nÖrnek: example.com · blog.example.com\n\nGeri alınamaz. Yedek istiyorsan önce Projeyi Farklı Kaydet.",
"deleteDomainConfirm": "\"{{host}}\" üzerindeki tüm URL'leri, linklerini, başlıklarını, görsellerini ve kaynak snapshot'larını sil?\n\nBu işlem geri alınamaz.",
"deleteDomainResult": "{{host}} alanından {{urls}} URL satırı silindi (ve {{links}} bağlı link).",
"clearAllConfirm": "Aktif projedeki TÜM veriler temizlensin mi?\n\nHer URL, link, görsel, başlık, kaynak snapshot, sitemap ve issue silinecek. Geri alınamaz — yedek istiyorsan önce Projeyi Farklı Kaydet."
},
"infoTip": {
"moreInfo": "Daha fazla bilgi",
"example": "Örnek"
},
"detail": {
"comingSoon": "Yakında",
"urlsSelected": "URL seçili",
"aggregatingFirst": "ilk {{n}} toplanıyor",
"perPagePrefix": "Bu sekme sayfa başınadır — şu URL için veri gösteriliyor:",
"perPageSuffix": "(seçimin birincil URL'i).",
"selectUrl": "Detayları görmek için tablodan bir URL seç."
},
"filter": {
"title": "Gelişmiş Tablo Arama",
"where": "Şartı",
"orWhere": "Veya Şartı",
"andRow": "Ve",
"and": "VE",
"or": "VEYA",
"addAnd": "Bu gruba bir VE koşulu daha ekle",
"addOr": "Bir VEYA grubu ekle",
"removeGroup": "Grubu kaldır",
"removeCondition": "Koşulu kaldır",
"searchPlaceholder": "Arama sorgusu gir",
"noValue": "(değer gerekmiyor)"
},
"reports": {
"report": {
"pagesPerDir": "Dizin Başına Sayfa",
"statusCodes": "Durum Kodu Histogramı",
"indexabilityDistribution": "İndekslenebilirlik Dağılımı",
"contentKindDistribution": "İçerik Türü Dağılımı",
"depth": "Derinlik Histogramı",
"responseTime": "Yanıt Süresi Histogramı",
"slowestUrls": "En Yavaş URL'ler (Top 25)",
"mostInlinks": "En Çok Linklenen URL'ler (Top 25)",
"leastInlinks": "En Az Linklenen URL'ler (Bottom 25, indekslenebilir HTML)",
"mostOutlinks": "En Çok Outlink Veren URL'ler (Top 25)",
"biggestPages": "En Büyük Sayfalar (Top 25)",
"deepestUrls": "En Derin URL'ler (Top 25)",
"externalDomainHealth": "Harici Alan Sağlığı",
"analyticsCoverage": "Analitik İzleyici Kapsamı",
"linkPositions": "Dahili Link Konumları",
"imageWeight": "Sayfa Başına Görsel Yükü (Top 25)",
"inlinksHistogram": "Inlinks Histogramı",
"wordCountHistogram": "Kelime Sayısı Histogramı",
"urlLengthHistogram": "URL Uzunluğu Histogramı",
"wordCountPerDir": "Dizin Başına Kelime Sayısı",
"sitemapOrphans": "Sitemap Yetimleri (Top 1000)",
"serverHeaders": "Sunucu Stack (Server Header)",
"topWords": "En Çok Kelime (Başlık + Meta + H1, Top 100)"
},
"groupAtDepth": "Şu derinlikte grupla",
"depthTopLevel": "1 (üst seviye)",
"summary": "{{rows}} satır · {{urls}} URL",
"noData": "Veri yok — önce bir crawl çalıştır.",
"count": "Sayı",
"share": "Pay",
"keyDirectory": "Dizin",
"keyStatus": "Durum",
"keyIndexability": "İndekslenebilirlik",
"keyContentType": "İçerik Türü",
"keyDepth": "Derinlik",
"keyBucket": "Aralık",
"keyDomain": "Alan",
"keyTracker": "İzleyici",
"keyPosition": "Konum",
"keyServer": "Sunucu",
"keyWord": "Kelime"
},
"sitemap": {
"title": "Sitemap Doğrulayıcı",
"urlLabel": "Sitemap URL",
"validate": "Doğrula",
"validating": "Doğrulanıyor…",
"walkInfo": "İç içe sitemap-index girdilerini 3 derinlik / 100K URL'e kadar gezer.",
"valid": "Geçerli",
"truncated": "(kesildi)",
"findingsCount": "{{n}} bulgu",
"urlsParsed": "URL parse edildi",
"sitemapsTried": "Denenen sitemap",
"sitemapsParsed": "Parse edilen sitemap",
"urlEntries": "URL girdisi",
"findings": "Bulgular",
"fetchErrors": "Fetch hataları",
"lastmodSamples": "Örnek lastmod değerleri",
"sitemapsWalked": "Gezilen sitemap'ler"
},
"robots": {
"title": "Robots.txt Test Aracı",
"urlToTest": "Test edilecek URL",
"userAgent": "User-Agent",
"customMode": "Özel bir robots.txt'ye karşı test et (taslak modu)",
"loadFromFile": "Dosyadan yükle…",
"customBodyLabel": "Özel robots.txt gövdesi (parse edilir — fetch yapılmaz)",
"test": "Test Et",
"testCustom": "Test Et (özel)",
"testing": "Test ediliyor…",
"allowed": "İzinli",
"disallowed": "İzinsiz",
"byRobotsFor": "User-Agent için robots.txt tarafından",
"crawlDelay": "Crawl-Delay",
"sitemapsDeclared": "Tanımlı Sitemap",
"sitemapsHeader": "Sitemap'ler",
"bodyHeader": "robots.txt gövdesi",
"charsCount": "{{n}} karakter",
"lintPrompt": "Söz dizimini denetlemek için yukarıya bir robots.txt taslağı yapıştır.",
"noIssues": "Söz dizimi hatası bulunamadı.",
"errorCount": "{{n}} hata",
"warningCount": "{{n}} uyarı"
},
"settingsGroups": {
"threshold": "Eşik",
"scope": "Kapsam",
"memory": "Bellek",
"queue": "Kuyruk",
"cpu": "CPU",
"bodySnapshots": "Gövde Snapshot'ları",
"linkResponseCaps": "Link & Yanıt Limitleri",
"urlStructureThresholds": "URL Yapı Eşikleri",
"linkFollowBehaviour": "Link Takip Davranışı",
"savedProxyProfiles": "Kayıtlı Proxy Profilleri",
"ruleN": "Kural #{{n}}"
},
"settingsPanels": {
"presets": {
"intro": "Yaygın crawl senaryoları için tek tık profilleri. Bir preset'e tıklamak yalnızca etkilenen alanları üzerine yazar — URL listen, özel kuralların, filtrelerin ve çıkarım kuralların korunur.",
"importExport": "İçe / Dışa Aktar",
"importExportIntro": "Mevcut ayarları bir JSON dosyasına kaydet (örn. ekip arkadaşıyla paylaşmak veya versiyon kontrolü için) ya da daha önce dışa aktarılmış bir ayar dosyasını yükle. İçe aktarmak mevcut formu dosyanın içeriğiyle değiştirir — kalıcı kaydetmek için alttaki Kaydet'e bas.",
"exportBtn": "Dışa Aktar…",
"importBtn": "İçe Aktar…",
"exported": "Ayarlar dışa aktarıldı.",
"imported": "Ayarlar içe aktarıldı.",
"exportFailed": "Dışa aktarım başarısız: {{msg}}",
"importFailed": "İçe aktarım başarısız: {{msg}}",
"outro": "Uygula veya İçe Aktar'a tıkladıktan sonra her paneli değerleri doğrulamak için gözden geçir, sonra altta Kaydet'e bas."
},
"mode": { "intro": "Crawler'ın URL'leri nasıl keşfedeceğini seç. Spider başlangıç URL'inden linkleri takip eder; List sabit bir setten alır." },
"crawler": {
"intro": "Gezinme limitleri ve crawl-kapsam toggle'ları. Screaming Frog karşılıkları parantez içinde gösterildi.",
"speedHintPrefix": "Crawl hızı mı arıyorsun?",
"speedHintBody": "Thread sayısı, saniyedeki istek, istek-başına gecikme ve yeniden denemeler şurada yaşar:",
"speedHintLink": "Hız",
"speedHintSuffix": "bölümü — crawler'ın sunucuya ne hızda vurduğunu orada kıs."
},
"speed": {
"intro": "Crawl throughput'unu kontrol et. Paralelliği artırmak crawl'u hızlandırır, ama her ek istek hedef sunucuya yük bindirir — sahip olmadığın bir host'un rate-limit / 429 olmadan emebileceği sayılar seç.",
"effectiveCeiling": "Etkin tavan"
},
"requests": { "intro": "Her istekle gönderilen HTTP başlıkları." },
"filters": { "intro": "URL izin/yasak listesi. Kalıplar URL'nin tamamına karşı test edilen JavaScript regex'tir." },
"customSearch": {
"intro1": "Gövdesinde bu terimlerden herhangi birini içeren sayfaları işaretle. Satır başına iki mod:",
"intro2": "regex için,",
"anythingElse": "diğer her şey",
"intro3": "için harf duyarsız literal substring."
},
"customExtraction": {
"intro1": "10'a kadar özel çıkarım kuralı. Her biri her crawl edilmiş HTML sayfasına karşı çalışır; sonuçlar URL satırında saklanır ve URL Detayları panelinde",
"extractionLabel": "Extraction",
"empty": "Kural yok — başlamak için \"Add Rule\" tıkla."
},
"urlRewriting": { "intro": "URL'ler yinelemeden ve kuyruğa alınmadan önce uygulanan normalleştirme." },
"webhook": {
"intro1": "Her crawl bittiğinde bir kez fırlatılan webhook. Tek bir",
"intro2": "JSON özetle (başlangıç URL, süre, toplam URL, durum dağılımı, sıfır olmayan her issue sayacı). Boş bırakmak devre dışı bırakır.",
"outro": "Slack incoming webhook'ları (JSON şekli Slack'in düz metin render etmesi için yeterince zengin), Zapier \"Catch Hook\" tetikleri, Discord webhook'ları ve özel HTTP uç noktalarıyla uyumlu."
},
"auth": { "intro": "Her istekte uygulanan HTTP kimlik doğrulaması. Basic auth arkasındaki staging ortamları veya Bearer token gerektiren API'ler için faydalı. Digest desteklenmiyor (meydan okuma-yanıt durum makinesi)." },
"network": { "intro": "Ağ seviyesi kontroller: proxy override, dosya-uzantısı hariç tutma, yönlendirme hop limiti." },
"duplicates": {
"intro": "Yakın-yinelenen tespit. Her crawl sonrası gövde metni 64-bit SimHash ile hash'lenir; hash'leri yapılandırılmış Hamming mesafesi içindeki sayfalar yakın-yinelenen olarak kümelenir. Şurada görünür:",
"path": "Issues → Content → Near-Duplicate"
},
"hardware": { "intro": "Crawler süreci için kaynak limitleri. Büyük siteleri (1M+ URL) crawl ederken makineyi kullanılabilir tutmak için faydalı." },
"content": { "intro": "Crawl edilmiş HTML'in diskte nasıl saklandığı. View Source detay sekmesine ihtiyacın yoksa proje dosyasını küçük tutmak için body snapshot'ları kapat; düşman büyüklükteki sayfalı siteler için sınırı sıkılaştır." },
"crawlAnalysis": { "intro": "Pas-başına post-crawl analiz toggle'ları. Her pas HTTP fetch fazı bittikten sonra çalışır ve farklı issue filtrelerini besler. İhtiyacın olmayan pasları atla — ilgili issue sayaçları pas çalışana kadar 0 okur." },
"issues": {
"intro": "Issue başına kontrol on/off toggle'ları.",
"v2Prefix": "V2'de Gelecek.",
"v2Body": "Bugün her issue kontrolü koşulsuz çalışır ve kenar çubuğunda görünür. Plan, proje başına belirli kontrolleri susturmana izin vermek (örn. kasıtlı olarak yapan bir CMS'te \"Description = Title\"ı kapat). O gelene kadar, umursamadığın satırları kenar çubuğu grubunu daraltarak gizle veya her sekmedeki Advanced filtreyle dışla."
},
"advanced": { "intro": "Daha düşük seviyeli limitler ve link-takip toggle'ları. Varsayılanlar tipik SEO denetimleri için ayarlandı — sebebini bilmiyorsan dokunma." },
"cookies": { "intro": "Her fetch'e uygulanan çerez politikası. Crawler aksi halde istekler arası durumsuzdur; bu ayar cookie-flag issue kontrolleri (Missing Secure / HttpOnly / SameSite) için Set-Cookie yanıt başlıklarının kaydedilip kaydedilmeyeceğini kontrol eder." },
"perHostUa": {
"intro": "User-Agent'ı host bazında geçersiz kıl. Mobil bir alt alan adını mobile-Googlebot UA ile masaüstü sitesiyle aynı koşuda crawl ederken veya bir CDN, isteyene göre farklı sayfa servis ettiğinde faydalı. İlk eşleşen kalıp kazanır; global User-Agent (Requests sekmesi) yedektir.",
"syntax1": "Kalıp söz dizimi: tam host",
"syntax2": "veya başlangıç wildcard'ı",
"syntax3": "— wildcard formu herhangi bir alt alanı eşler ama",
"notApex": "değil",
"theApex": "apex'i.",
"empty": "Henüz host-bazlı override yok — aşağıdan ekle."
}
},
"compare": {
"title": "Crawl'ları Karşılaştır",
"aCurrent": "A (mevcut)",
"urlsUnit": "URL",
"pickDifferent": "Farklı bir proje dosyası seç",
"comparing": "Karşılaştırılıyor…",
"openOther": "Başka Proje Aç",
"pickPrompt": "Mevcut crawl ile karşılaştırmak için bir `.seoproject` dosyası seç.",
"added": "Eklenen",
"removed": "Kaldırılan",
"statusChanged": "Durum Değişti",
"titleChanged": "Başlık Değişti",
"metaChanged": "Meta Açıklama Değişti",
"h1Changed": "H1 Değişti",
"canonicalChanged": "Canonical Değişti",
"indexabilityChanged": "İndekslenebilirlik Değişti",
"responseTimeDelta": "Yanıt Süresi Δ",
"entriesShown": "{{total}} kaydın {{shown}}'i gösteriliyor",
"truncated": "(kesildi — tam set için CSV dışa aktar)",
"urlHeader": "URL",
"beforeHeader": "Önce",
"afterHeader": "Sonra",
"noDiffs": "Bu kategoride fark yok."
},
"scheduled": {
"title": "Zamanlanmış Crawl",
"closeEsc": "Kapat (Esc)",
"warningPrefix": "Zamanlama yalnızca bu projede FreeCrawl açıkken çalışır. Yeniden başlatmaya dayanan tetikler için",
"warningSuffix": "(CLI) Windows Task Scheduler / macOS launchd / cron üzerinden çalıştır.",
"enabled": "Etkin",
"cadence": "Sıklık",
"hourly": "Saatlik",
"daily": "Günlük",
"weekly": "Haftalık",
"customInterval": "Özel aralık",
"intervalMinutes": "Aralık (dakika, min {{min}})",
"hour": "Saat (0–23)",
"minute": "Dakika (0–59)",
"dayOfWeek": "Haftanın günü",
"daySun": "Paz",
"dayMon": "Pzt",
"dayTue": "Sal",
"dayWed": "Çar",
"dayThu": "Per",
"dayFri": "Cum",
"daySat": "Cmt",
"nextFire": "Sonraki",
"lastFired": "Son çalışma",
"lastStatus": "Son durum",
"hintDisabled": "Devre dışı — zamanlamayı kurmak için \"Etkin\"i işaretle ve Kaydet.",
"hintEnabled": "Bir sonraki çalışma zamanını hesaplamak için Kaydet. Zamanlama yalnızca FreeCrawl açıkken çalışır.",
"confirmRemove": "Bu projenin zamanlanmış crawl'ı kaldırılsın mı?",
"saving": "Kaydediliyor…"
},
"serp": {
"noTitle": "(başlık yok)",
"noMeta": "(meta açıklama yok)",
"chars": "karakter",
"titlePixelWidth": "Başlık pixel genişliği",
"titleLength": "Başlık uzunluğu",
"descriptionPixelWidth": "Açıklama pixel genişliği",
"descriptionLength": "Açıklama uzunluğu"
},
"cookies": {
"emptyPrefix": "Bu sayfa çerez yazmadı (",
"emptySuffix": "yanıt başlığı yok).",
"emptyNote": "Not: yalnızca sayfanın kendi yazdığı first-party çerezler burada listelenir. Üçüncü-parti script'lerin (analitik, reklam) runtime'da yazdığı çerezler tarayıcıda set edilir ve statik crawler'a görünmez.",
"cookiesSet": "çerez yazıldı",
"missing": "{{n}} eksik"
},
"duplicates": {
"notInCluster": "Bu URL bir yakın-yinelenen kümesinin parçası değil. Bitmiş bir crawl üzerinde Crawl Analysis çalıştır veya Settings → Duplicates'da Hamming eşiğini düşürerek yakın-yinelenen sınırını genişlet.",
"noOtherMembers": "Bu kümede başka üye yok. (Küme boyutu {{n}}.)",
"cluster": "Küme",
"membersTotal": "Toplam {{n}} üye",
"hammingHint": "(Bu URL'den Hamming mesafesi — düşük = daha benzer)"
},
"resources": {
"loadingSource": "Kaynak yükleniyor…",
"loadingResources": "Kaynaklar yükleniyor…",
"requiresSnapshot": "Resources görünümü saklı bir HTML gövde snapshot'ı gerektirir.",
"multiRequiresSnapshot": "Resources görünümü her sayfa için saklı bir HTML gövde snapshot'ı gerektirir.",
"recrawlPrefix": "Şu ayarla yeniden crawl çalıştır:",
"recrawlSuffix": "etkin halde.",
"noMatch": "Bu filtreye uyan kaynak yok.",
"missingSnapshots": "{{total}} sayfanın {{missing}}'inde saklı gövde yok"
},
"extraction": {
"empty1": "Bu sayfa için özel çıkarım kuralları veya arama terimleri henüz veri üretmedi.",
"empty2": "Kuralları",
"empty3": "üzerinden veya arama terimlerini",
"empty4": "üzerinden ayarla, sonra yeniden crawl çalıştır.",
"customExtraction": "Özel Çıkarım",
"customSearchHits": "Özel Arama Sonuçları"
},
"structured": {
"blocks": "blok",
"invalid": "{{n}} geçersiz",
"microdataItems": "microdata öğesi",
"rdfaAttrs": "RDFa özniteliği",
"empty": "Bu sayfada yapısal veri yok (JSON-LD, microdata veya RDFa yok).",
"schemaTypes": "Şema türleri",
"loadingSource": "Kaynak yükleniyor…",
"jsonLdBlocks": "JSON-LD blokları",
"blockN": "Blok #{{n}}",
"parsedOk": "parse edildi",
"parseFailed": "parse başarısız",
"snapshotUnavailable1": "Crawl sırasında JSON-LD blokları algılandı ama sayfa gövde snapshot'ı mevcut değil. Ham payload'u görmek için",
"snapshotUnavailable2": "etkin halde yeniden crawl çalıştır."
},
"imagesView": {
"loading": "Görseller yükleniyor…",
"emptyPrefix": "Bu sayfada",
"emptySuffix": "etiketi bulunamadı.",
"images": "görsel",
"missingAlt": "{{n}} alt eksik",
"emptyAlt": "{{n}} boş alt (dekoratif)",
"external": "harici",
"largeCount": "{{n}} > 100 KB",
"multiEmpty": "Seçili {{n}} sayfada <img> etiketi bulunamadı.",
"multiSummary": "{{n}} sayfada görseller"
},
"links": {
"showing": "Gösterilen",
"of": "/",
"noLinks": "Link yok.",
"columnHint": "Kolonu seçmek için tıkla · birden çok için başlıklarda sürükle · boyutlandırmak için sağ kenarı sürükle",
"dragResize": "Boyutlandırmak için sürükle · sıfırlamak için çift tıkla"
},
"cellMenu": {
"copyCell": "Hücreyi Kopyala",
"copyCells": "{{n}} Hücreyi Kopyala",
"copyUrl": "URL'yi Kopyala",
"copyUrls": "{{n}} URL Kopyala",
"copyValue": "Değeri Kopyala",
"openInBrowser": "Tarayıcıda Aç"
},
"diag": {
"fromHeaders": "Yanıt başlıklarından",
"likelyCauses": "Olası sebepler",
"whatToDo": "Ne yapılmalı"
},
"outline": {
"emptyPrefix": "Bu sayfada tespit edilen başlık yok (",
"emptySuffix": "elementi yok). Başlıksız sayfalar ekran okuyucular için zor, uzun-form sorgularda kötü sıralanabilir.",
"headings": "başlık",
"skipped": "Atlandı: önceki h{{n}}",
"skippedCount": "{{n}} seviye atlandı",
"capped": "(200 ile sınırlı)",
"emptyHeading": "(boş başlık)"
},
"viewSource": {
"searchPlaceholder": "Kaynakta ara…",
"matchOf": "{{total}} sonucun {{n}}.'si",
"prevMatch": "Önceki eşleşme (Shift+Enter)",
"nextMatch": "Sonraki eşleşme (Enter)",
"noMatches": "Eşleşme yok",
"wrap": "Sar",
"download": "İndir",
"loadingSource": "Kaynak yükleniyor…",
"noBody": "Bu URL için kaydedilmiş HTML gövde yok.",
"noBodyHint1": "View Source yalnızca şu ayarla crawl edilen HTML sayfaları için yakalanır:",
"noBodyHint2": "etkin halde."
},
"logs": {
"title": "Loglar",
"level": "Seviye",
"filterPlaceholder": "Mesaj / kaynak filtrele…",
"autoScroll": "Otomatik kaydır",
"copy": "Kopyala",
"copyTitle": "Görünen kayıtları panoya kopyala",
"clear": "Temizle",
"empty": "Log kaydı yok.",
"showingPrefix": "Gösterilen",
"entriesSuffix": "kayıt",
"tailHint": "Canlı tail son {{n}} kaydı tutar — tam geçmiş diske kaydedilir (Yardım → Log Klasörünü Aç)."
},
"viz": {
"layout": "Layout:",
"color": "Renk:",
"nodes": "Düğüm:",
"labels": "Etiket:",
"byStatus": "Duruma Göre",
"byDepth": "Derinliğe Göre",
"byIndexability": "İndekslenebilirliğe Göre",
"hoverOnly": "Yalnızca Hover",
"top20": "Top 20",
"all": "Tümü",
"fit": "Sığdır",
"fitTitle": "Grafiği görünüme sığdır",
"reload": "Yeniden Yükle",
"labelsTooltip": "Hover = istenildiğinde · Top 20 = en çok linklenen hub'lar · All = her düğüm",
"layoutTuning": "Layout Ayarları",
"exportGraph": "Grafiği dışa aktar",
"export": "Dışa Aktar",
"exportPng": "PNG (yüksek DPI raster)",
"exportSvg": "SVG (vektör — Illustrator/Figma)",
"exportHtml": "Standalone HTML (paylaşılabilir)",
"nodesEdgesCount": "{{nodes}} düğüm · {{edges}} kenar",
"interactionHint": "Hover = komşular · Tıkla = seç · Boş tıklama = temizle · Çift tık düğüm = aç · Çift tık tuval = sığdır",
"loadingGraph": "Grafik yükleniyor…",
"emptyGraph": "Henüz crawl edilmiş URL yok — grafiği doldurmak için bir crawl başlat.",
"topAnchorTexts": "En Çok Anchor Metinleri",
"anchorsEmpty": "Henüz toplanmış dahili-link anchor'ı yok.",
"resetToDefaults": "Varsayılana sıfırla",
"nodeSize": "Düğüm boyutu",
"nodeSizeHint": "Her noktanın yarıçapını ölçekler. Düşük = daha sıkı grafik, yüksek = tıklaması kolay ama daha fazla örtüşme.",
"nodeDistance": "Düğüm mesafesi (itme)",
"repulsionHint": "Düğümlerin birbirini ne kadar güçlü ittiği. Yüksek = daha fazla boşluk. Yalnızca Force-Directed.",
"edgeLength": "Kenar uzunluğu",
"edgeLengthHint": "Bağlantılar için hedef yaylanma boyu. Yüksek = daha uzun kenar. Yalnızca Force-Directed.",
"clusterSpacing": "Küme aralığı",
"clusterSpacingHint": "Bağlı olmayan alt-grafikler arasındaki boşluk. Yüksek = izole kümeler daha uzakta.",
"edgeOpacity": "Kenar opaklığı",
"edgeOpacityHint": "Düşük = yoğun grafiklerde daha az görsel gürültü.",
"changesNeedRerun": "Bazı değişiklikler layout yeniden çalıştırması gerektirir.",
"rerunLayout": "Layout'u yeniden çalıştır"
},
"imagesTab": {
"searchPlaceholder": "Görsel URL / alt ara…",
"missingAltOnly": "Yalnızca Alt eksik",
"imagesUnit": "görsel",
"loaded": "{{n}} yüklendi",
"row": "Satır",
"noImages": "Görsel yok"
},
"serpTab": {
"title": "SERP Önizleme",
"filterPlaceholder": "URL veya başlığa göre filtrele…",
"sort": "Sırala:",
"sortInlinks": "Inlinks (en çok link alan önce)",
"sortUrl": "URL (alfabetik)",
"sortTitleLen": "En uzun başlık önce",
"sortDescLen": "En uzun açıklama önce",
"summary": "{{total}} indekslenebilir sayfanın {{shown}}'i",
"firstN": "(ilk {{n}})",
"emptyNoData": "Henüz başlığı olan indekslenebilir HTML sayfası yok — SERP önizlemelerini doldurmak için bir crawl çalıştır.",
"emptyNoMatch": "Mevcut filtreyle eşleşen sayfa yok."
},
"brokenTab": {
"searchPlaceholder": "Kırık hedef URL ara…",
"scopeAll": "Tümü",
"scopeInternal": "Dahili",
"scopeExternal": "Harici",
"internalOnly": "Yalnızca Dahili",
"externalOnly": "Yalnızca Harici",
"linksUnit": "kırık link",
"noBrokenLinks": "Kırık link yok",
"noBrokenLinksHint": "Kırık linkleri görmek için harici linkleri de içeren bir crawl çalıştır."
},
"urlsTab": {
"searchPlaceholder": "URL / başlık ara…",
"advanced": "Gelişmiş",
"advancedFilterTitle": "Gelişmiş filtre",
"clearAdvancedFilter": "Gelişmiş filtreyi temizle",
"columns": "Kolonlar",
"showHideColumns": "Kolonları göster/gizle",
"noToggleableColumns": "Bu sekmede kapatılabilir kolon yok",
"export": "Dışa Aktar",
"exportThisTable": "Bu tabloyu dışa aktar",
"noDataToExport": "Dışa aktarılacak veri yok",
"noUrlsToShow": "Gösterilecek URL yok",
"makeAllVisible": "Tüm kolonları göster",
"hideAllExceptRow": "Satır # dışındaki her kolonu gizle",
"showAll": "Tümünü göster",
"hideAll": "Tümünü gizle",
"columnHeaderHint": "(seçmek için tıkla · çoklu seçim için sürükle · gizlemek için sağ-tıkla)"
},
"settings": {
"title": "Ayarlar",
"resetToDefaults": "Varsayılanlara Sıfırla",
"save": "Kaydet",
"cancel": "İptal",
"language": {
"title": "Dil",
"intro": "Arayüz dilini seç. Anında uygulanır.",
"label": "Arayüz dili",
"en": "English",
"tr": "Türkçe",
"note": "Bazı derin paneller ve diyaloglar, çeviri kapsamı genişletilirken hala İngilizce görünebilir."
},
"storage": {
"title": "Depolama",
"intro": "Yeni .seoproject dosyaları için varsayılan klasör. Farklı Kaydet diyalogları varsayılan olarak burayı açar; Aç diyaloğu kısıtlı değildir.",
"defaultSaveFolder": "Varsayılan Kayıt Klasörü",
"placeholder": "(Sistem Belgeler klasörü)",
"resolvedPath": "Çözümlenen yol",
"resetTitle": "OS Belgeler klasörüne sıfırla",
"note": "Aktif crawl verisi siz Farklı Kaydet diyene kadar uygulamanın kullanıcı veri dizininde durur — yukarıdaki yol yalnızca Farklı Kaydet'in nereden başlayacağını belirler. Bunu değiştirmek mevcut proje dosyalarını taşımaz."
},
"privacy": {
"title": "Gizlilik",
"intro": "FreeCrawl yerel önceliklidir — crawl sonuçları, proje dosyaları ve ziyaret ettiğiniz URL'ler makinenizden hiç çıkmaz.",
"telemetryHeading": "Anonim Telemetri",
"telemetryLabel": "Anonim kullanım telemetrisi gönder (opt-in)",
"telemetryHelp": "Varsayılan olarak kapalı. Açıkken, özellik kullanımının toplulaştırılmış sayıları (örn. \"dışa aktarım çalıştı\", \"rapor açıldı\") gönderilebilir — URL, proje içeriği veya crawl sonuçları asla gönderilmez.",
"statusNote": "Şu anda herhangi bir telemetri uç noktası kablolanmamıştır. Bu tercihi açıp kapatmak kaydedilir ancak mevcut sürümde hiçbir şey iletilmez. İlerideki bir sürüm uç nokta eklediğinde rıza durumunun korunması için bu toggle şimdi gösteriliyor."
}
}
}
+1
View File
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
import { App } from './App.js';
import { LogsView } from './LogsView.js';
import './styles.css';
import './i18n/index.js';
// The Logs popup window loads the same renderer bundle with `?logs=1` —
// we branch at the entry point so only the LogsView component mounts
@@ -1,9 +1,11 @@
import { useEffect, useRef, useState } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
import type { BrokenLinkRow } from '@freecrawl/shared-types';
import { useAppStore } from '../store.js';
import { InfoTip } from '../components/InfoTip.js';
import { translateLabel } from '../i18n/labels.js';
const ROW_HEIGHT = 24;
const HEADER_HEIGHT = 28;
@@ -18,6 +20,8 @@ const POLL_MS_IDLE = 30_000;
const PAGE_SIZE = 5000;
export function BrokenLinksTab() {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const activeCategory = useAppStore((s) => s.activeCategory);
const dataVersion = useAppStore((s) => s.dataVersion);
const progress = useAppStore((s) => s.progress);
@@ -128,19 +132,19 @@ export function BrokenLinksTab() {
<div className="flex shrink-0 items-center gap-2 border-b border-surface-800 bg-surface-900/30 px-3 py-1.5">
<input
className="input w-96"
placeholder="Search source / target…"
placeholder={t('brokenTab.searchPlaceholder', { defaultValue: 'Search broken target URLs…' })}
value={search}
onChange={(e) => setSearch(e.target.value)}
spellCheck={false}
/>
{internal !== 'all' && (
<span className="rounded border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[11px] text-amber-300">
{internal === 'internal' ? 'Internal only' : 'External only'}
{internal === 'internal' ? t('brokenTab.internalOnly', { defaultValue: 'Internal only' }) : t('brokenTab.externalOnly', { defaultValue: 'External only' })}
</span>
)}
<div className="ml-auto text-[11px] text-surface-500">
<span className="font-mono text-surface-200">{total.toLocaleString()}</span> broken links
<span className="ml-2 text-surface-600">({rows.length.toLocaleString()} loaded)</span>
<span className="font-mono text-surface-200">{total.toLocaleString()}</span> {t('brokenTab.linksUnit', { defaultValue: 'broken links' })}
<span className="ml-2 text-surface-600">({t('imagesTab.loaded', { defaultValue: '{{n}} loaded', n: rows.length.toLocaleString() })})</span>
</div>
</div>
@@ -158,7 +162,7 @@ export function BrokenLinksTab() {
flex: `0 0 ${ROW_NUM_WIDTH}px`,
}}
>
Row
{t('imagesTab.row', { defaultValue: 'Row' })}
</div>
{columns.map((c) => (
<div
@@ -167,7 +171,7 @@ export function BrokenLinksTab() {
style={{ width: c.width, minWidth: c.width, flex: `0 0 ${c.width}px` }}
>
<span className={clsx('truncate', c.align === 'right' && 'ml-auto')}>
{c.label}
{translateLabel(c.label, lang)}
</span>
{(c.info || c.example) && (
<span className="shrink-0">
@@ -315,7 +319,7 @@ export function BrokenLinksTab() {
style={{ top: HEADER_HEIGHT }}
>
<div className="max-w-md text-center">
<div className="mb-1 text-sm font-semibold text-surface-300">No broken links</div>
<div className="mb-1 text-sm font-semibold text-surface-300">{t('brokenTab.noBrokenLinks', { defaultValue: 'No broken links' })}</div>
<div className="text-xs text-surface-500">
Every link in the crawl resolves to a healthy response.
</div>
@@ -1,9 +1,11 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
import type { ImageRow } from '@freecrawl/shared-types';
import { useAppStore } from '../store.js';
import { InfoTip } from '../components/InfoTip.js';
import { translateLabel } from '../i18n/labels.js';
const ROW_HEIGHT = 24;
const HEADER_HEIGHT = 28;
@@ -17,6 +19,8 @@ const PAGE_SIZE = 5000;
type SortKey = 'src' | 'alt' | 'width' | 'height' | 'occurrences';
export function ImagesTab() {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const activeCategory = useAppStore((s) => s.activeCategory);
const dataVersion = useAppStore((s) => s.dataVersion);
const progress = useAppStore((s) => s.progress);
@@ -140,19 +144,19 @@ export function ImagesTab() {
<div className="flex shrink-0 items-center gap-2 border-b border-surface-800 bg-surface-900/30 px-3 py-1.5">
<input
className="input w-96"
placeholder="Search image URLs / alt…"
placeholder={t('imagesTab.searchPlaceholder', { defaultValue: 'Search image URLs / alt…' })}
value={search}
onChange={(e) => setSearch(e.target.value)}
spellCheck={false}
/>
{missingAltOnly && (
<span className="rounded border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[11px] text-amber-300">
Missing Alt only
{t('imagesTab.missingAltOnly', { defaultValue: 'Missing Alt only' })}
</span>
)}
<div className="ml-auto text-[11px] text-surface-500">
<span className="font-mono text-surface-200">{total.toLocaleString()}</span> images
<span className="ml-2 text-surface-600">({sorted.length.toLocaleString()} loaded)</span>
<span className="font-mono text-surface-200">{total.toLocaleString()}</span> {t('imagesTab.imagesUnit', { defaultValue: 'images' })}
<span className="ml-2 text-surface-600">({t('imagesTab.loaded', { defaultValue: '{{n}} loaded', n: sorted.length.toLocaleString() })})</span>
</div>
</div>
@@ -170,7 +174,7 @@ export function ImagesTab() {
flex: `0 0 ${ROW_NUM_WIDTH}px`,
}}
>
Row
{t('imagesTab.row', { defaultValue: 'Row' })}
</div>
{columns.map((c) => (
<div
@@ -180,7 +184,7 @@ export function ImagesTab() {
onClick={() => handleSort(c.key)}
>
<span className={clsx('truncate', c.align === 'right' && 'ml-auto')}>
{c.label}
{translateLabel(c.label, lang)}
</span>
{(c.info || c.example) && (
<span
@@ -316,7 +320,7 @@ export function ImagesTab() {
style={{ top: HEADER_HEIGHT }}
>
<div className="max-w-md text-center">
<div className="mb-1 text-sm font-semibold text-surface-300">No images</div>
<div className="mb-1 text-sm font-semibold text-surface-300">{t('imagesTab.noImages', { defaultValue: 'No images' })}</div>
<div className="text-xs text-surface-500">
{missingAltOnly
? 'No images without alt text.'
+17 -11
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useTranslation } from 'react-i18next';
import type { CrawlUrlRow } from '@freecrawl/shared-types';
import { useAppStore } from '../store.js';
@@ -22,6 +23,7 @@ const DESC_CHAR_HARD_CAP = 200;
type SortMode = 'inlinks' | 'url' | 'title-length-desc' | 'desc-length-desc';
export function SerpTab() {
const { t } = useTranslation();
const dataVersion = useAppStore((s) => s.dataVersion);
const progress = useAppStore((s) => s.progress);
const setSelectedUrlId = useAppStore((s) => s.setSelectedUrlId);
@@ -83,31 +85,35 @@ export function SerpTab() {
style={{ height: HEADER_HEIGHT }}
>
<div className="text-[12px] font-semibold tracking-wide text-surface-100">
SERP Preview
{t('serpTab.title', { defaultValue: 'SERP Preview' })}
</div>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Filter by URL or title…"
placeholder={t('serpTab.filterPlaceholder', { defaultValue: 'Filter by URL or title…' })}
className="h-6 w-72 rounded border border-surface-700 bg-surface-950 px-2 text-[11px] text-surface-100 placeholder-surface-500 focus:border-blue-500 focus:outline-none"
/>
<label className="flex items-center gap-1 text-[11px] text-surface-400">
Sort:
{t('serpTab.sort', { defaultValue: 'Sort:' })}
<select
className="h-6 rounded border border-surface-700 bg-surface-950 px-2 text-[11px] text-surface-100 focus:border-blue-500 focus:outline-none"
value={sortMode}
onChange={(e) => setSortMode(e.target.value as SortMode)}
>
<option value="inlinks">Inlinks (most-linked first)</option>
<option value="url">URL (alphabetical)</option>
<option value="title-length-desc">Longest title first</option>
<option value="desc-length-desc">Longest description first</option>
<option value="inlinks">{t('serpTab.sortInlinks', { defaultValue: 'Inlinks (most-linked first)' })}</option>
<option value="url">{t('serpTab.sortUrl', { defaultValue: 'URL (alphabetical)' })}</option>
<option value="title-length-desc">{t('serpTab.sortTitleLen', { defaultValue: 'Longest title first' })}</option>
<option value="desc-length-desc">{t('serpTab.sortDescLen', { defaultValue: 'Longest description first' })}</option>
</select>
</label>
<div className="ml-auto text-[11px] text-surface-500">
{rows.length.toLocaleString()} of {total.toLocaleString()} indexable pages
{total > rows.length && ' (first ' + PAGE_SIZE.toLocaleString() + ')'}
{t('serpTab.summary', {
defaultValue: '{{shown}} of {{total}} indexable pages',
shown: rows.length.toLocaleString(),
total: total.toLocaleString(),
})}
{total > rows.length && ' ' + t('serpTab.firstN', { defaultValue: '(first {{n}})', n: PAGE_SIZE.toLocaleString() })}
</div>
</div>
@@ -118,8 +124,8 @@ export function SerpTab() {
{rows.length === 0 && (
<div className="flex h-full items-center justify-center text-[12px] text-surface-500">
{total === 0
? 'No indexable HTML pages with a title yet — run a crawl to populate SERP previews.'
: 'No pages match the current filter.'}
? t('serpTab.emptyNoData', { defaultValue: 'No indexable HTML pages with a title yet — run a crawl to populate SERP previews.' })
: t('serpTab.emptyNoMatch', { defaultValue: 'No pages match the current filter.' })}
</div>
)}
{rows.length > 0 && (
+25 -19
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { ChevronsUpDown, Columns3, Download, Filter, X } from 'lucide-react';
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
import type { AdvancedFilter, CrawlUrlRow } from '@freecrawl/shared-types';
import { useAppStore, type TabKey } from '../store.js';
import { COLUMN_SPECS, columnId, type ColumnSpec } from './columns.js';
@@ -10,6 +11,7 @@ import { AdvancedFilterDialog } from '../components/AdvancedFilterDialog.js';
import { ErrorBoundary } from '../components/ErrorBoundary.js';
import { ExportDialog } from '../components/ExportDialog.js';
import { InfoTip } from '../components/InfoTip.js';
import { translateLabel } from '../i18n/labels.js';
const ROW_HEIGHT = 24;
const HEADER_HEIGHT = 28;
@@ -45,6 +47,8 @@ function saveHiddenColumns(tab: TabKey, hidden: Set<string>): void {
}
export function UrlsTab() {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const activeTab = useAppStore((s) => s.activeTab);
const activeCategory = useAppStore((s) => s.activeCategory);
const selectedUrlId = useAppStore((s) => s.selectedUrlId);
@@ -523,7 +527,7 @@ export function UrlsTab() {
<div className="flex shrink-0 items-center gap-2 border-b border-surface-800 bg-surface-900/30 px-3 py-1.5">
<input
className="input w-96"
placeholder="Search URLs / titles…"
placeholder={t('urlsTab.searchPlaceholder', { defaultValue: 'Search URLs / titles…' })}
value={search}
onChange={(e) => setSearch(e.target.value)}
spellCheck={false}
@@ -537,10 +541,10 @@ export function UrlsTab() {
? 'border-accent-500/60 bg-accent-500/15 text-accent-300 hover:bg-accent-500/25'
: 'border-surface-700 text-surface-300 hover:bg-surface-800',
)}
title="Advanced filter"
title={t('urlsTab.advancedFilterTitle', { defaultValue: 'Advanced filter' })}
>
<Filter className="h-3.5 w-3.5" />
<span>Advanced</span>
<span>{t('urlsTab.advanced', { defaultValue: 'Advanced' })}</span>
{activeClauseCount > 0 && (
<span className="rounded bg-accent-500/20 px-1 font-mono text-[10px]">
{activeClauseCount}
@@ -552,7 +556,7 @@ export function UrlsTab() {
type="button"
onClick={() => setFilter(null)}
className="inline-flex items-center gap-1 rounded border border-surface-700 px-1.5 py-1 text-[11px] text-surface-400 hover:bg-surface-800 hover:text-surface-200"
title="Clear advanced filter"
title={t('urlsTab.clearAdvancedFilter', { defaultValue: 'Clear advanced filter' })}
>
<X className="h-3 w-3" />
</button>
@@ -575,12 +579,12 @@ export function UrlsTab() {
)}
title={
allColumns.length === 0
? 'This tab has no toggleable columns'
: 'Show/hide columns'
? t('urlsTab.noToggleableColumns', { defaultValue: 'This tab has no toggleable columns' })
: t('urlsTab.showHideColumns', { defaultValue: 'Show/hide columns' })
}
>
<Columns3 className="h-3.5 w-3.5" />
<span>Columns</span>
<span>{t('urlsTab.columns', { defaultValue: 'Columns' })}</span>
{hiddenColumns.size > 0 && (
<span className="rounded bg-accent-500/20 px-1 font-mono text-[10px]">
{allColumns.length - hiddenColumns.size}/{allColumns.length}
@@ -606,10 +610,10 @@ export function UrlsTab() {
? 'cursor-not-allowed border-surface-800 text-surface-600'
: 'border-surface-700 text-surface-300 hover:bg-surface-800',
)}
title={lazy.total === 0 ? 'No data to export' : 'Export this table'}
title={lazy.total === 0 ? t('urlsTab.noDataToExport', { defaultValue: 'No data to export' }) : t('urlsTab.exportThisTable', { defaultValue: 'Export this table' })}
>
<Download className="h-3.5 w-3.5" />
<span>Export</span>
<span>{t('urlsTab.export', { defaultValue: 'Export' })}</span>
</button>
</div>
@@ -672,9 +676,9 @@ export function UrlsTab() {
next.add(columnId(c));
setHiddenColumns(next);
}}
title={c.header + ' (click to select · drag to multi-select · right-click to hide)'}
title={translateLabel(c.header, lang) + ' ' + t('urlsTab.columnHeaderHint', { defaultValue: '(click to select · drag to multi-select · right-click to hide)' })}
>
<span className="cursor-pointer truncate">{c.header}</span>
<span className="cursor-pointer truncate">{translateLabel(c.header, lang)}</span>
{(c.info || c.example) && (
<span
className="shrink-0"
@@ -855,7 +859,7 @@ export function UrlsTab() {
className="pointer-events-none absolute inset-0 flex items-center justify-center"
style={{ top: HEADER_HEIGHT }}
>
<div className="text-xs text-surface-500">Loading…</div>
<div className="text-xs text-surface-500">{t('common.loading', { defaultValue: 'Loading…' })}</div>
</div>
)}
{lazy.total === 0 && !lazy.isLoading && (
@@ -864,7 +868,7 @@ export function UrlsTab() {
style={{ top: HEADER_HEIGHT }}
>
<div className="max-w-md text-center">
<div className="mb-1 text-sm font-semibold text-surface-300">No URLs to show</div>
<div className="mb-1 text-sm font-semibold text-surface-300">{t('urlsTab.noUrlsToShow', { defaultValue: 'No URLs to show' })}</div>
<div className="text-xs text-surface-500">
Start a crawl or choose a different category.
</div>
@@ -937,6 +941,8 @@ function ColumnPickerPopover({
onChange: (next: Set<string>) => void;
onClose: () => void;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const popRef = useRef<HTMLDivElement | null>(null);
// Outside-click closes the popover. We attach to mousedown so the
// listener fires before any click handler inside the table can fire
@@ -983,24 +989,24 @@ function ColumnPickerPopover({
>
<div className="flex items-center justify-between border-b border-surface-800 px-3 py-2">
<div className="text-[12px] font-semibold text-surface-100">
Columns ({visibleCount}/{allColumns.length})
{t('urlsTab.columns', { defaultValue: 'Columns' })} ({visibleCount}/{allColumns.length})
</div>
<div className="flex items-center gap-1 text-[10px]">
<button
type="button"
onClick={() => onChange(new Set())}
className="rounded border border-surface-700 px-1.5 py-0.5 text-surface-300 hover:bg-surface-800"
title="Make every column visible"
title={t('urlsTab.makeAllVisible', { defaultValue: 'Make every column visible' })}
>
Show all
{t('urlsTab.showAll', { defaultValue: 'Show all' })}
</button>
<button
type="button"
onClick={() => onChange(new Set(allColumns.map((c) => columnId(c))))}
className="rounded border border-surface-700 px-1.5 py-0.5 text-surface-300 hover:bg-surface-800"
title="Hide every column except Row #"
title={t('urlsTab.hideAllExceptRow', { defaultValue: 'Hide every column except Row #' })}
>
Hide all
{t('urlsTab.hideAll', { defaultValue: 'Hide all' })}
</button>
</div>
</div>
@@ -1019,7 +1025,7 @@ function ColumnPickerPopover({
onChange={() => toggle(id)}
className="h-3 w-3 accent-blue-500"
/>
<span className="truncate">{c.header}</span>
<span className="truncate">{translateLabel(c.header, lang)}</span>
</label>
);
})}
@@ -1,12 +1,14 @@
import { useEffect, useRef, useState } from 'react';
import { RefreshCw, Sparkles, Settings2, RotateCcw, Download } from 'lucide-react';
import cytoscape, { type Core } from 'cytoscape';
import { useTranslation } from 'react-i18next';
import type {
AnchorTextRow,
GraphSnapshotResult,
Indexability,
} from '@freecrawl/shared-types';
import { useAppStore } from '../store.js';
import { translateLabel } from '../i18n/labels.js';
/**
* User-tunable layout knobs. Persisted in prefs under the
@@ -200,6 +202,8 @@ function nodeSize(inlinks: number, scale = 1): number {
type LabelMode = 'hover' | 'top' | 'always';
export function VisualizationTab() {
const { t, i18n } = useTranslation();
const lang = i18n.language;
// React to data refreshes so the graph re-snapshots when a new crawl
// populates URLs (or the active project is swapped).
const dataVersion = useAppStore((s) => s.dataVersion);
@@ -540,33 +544,33 @@ export function VisualizationTab() {
</div>
<div className="ml-2 flex flex-wrap items-center gap-2 text-[11px]">
<label className="flex items-center gap-1 text-surface-400">
Layout:
{t('viz.layout', { defaultValue: 'Layout:' })}
<select
className="rounded border border-surface-700 bg-surface-950 px-2 py-1 text-[11px] text-surface-100 focus:border-blue-500 focus:outline-none"
value={layout}
onChange={(e) => setLayout(e.target.value as LayoutKind)}
>
{LAYOUTS.map((l) => (
<option key={l.key} value={l.key} title={l.hint}>
{l.label}
<option key={l.key} value={l.key} title={translateLabel(l.hint, lang)}>
{translateLabel(l.label, lang)}
</option>
))}
</select>
</label>
<label className="flex items-center gap-1 text-surface-400">
Color:
{t('viz.color', { defaultValue: 'Color:' })}
<select
className="rounded border border-surface-700 bg-surface-950 px-2 py-1 text-[11px] text-surface-100 focus:border-blue-500 focus:outline-none"
value={colorMode}
onChange={(e) => setColorMode(e.target.value as ColorMode)}
>
<option value="status">By Status</option>
<option value="depth">By Depth</option>
<option value="indexability">By Indexability</option>
<option value="status">{t('viz.byStatus', { defaultValue: 'By Status' })}</option>
<option value="depth">{t('viz.byDepth', { defaultValue: 'By Depth' })}</option>
<option value="indexability">{t('viz.byIndexability', { defaultValue: 'By Indexability' })}</option>
</select>
</label>
<label className="flex items-center gap-1 text-surface-400">
Nodes:
{t('viz.nodes', { defaultValue: 'Nodes:' })}
<select
className="rounded border border-surface-700 bg-surface-950 px-2 py-1 text-[11px] text-surface-100 focus:border-blue-500 focus:outline-none"
value={String(nodeLimit)}
@@ -582,24 +586,24 @@ export function VisualizationTab() {
</select>
</label>
<label className="flex items-center gap-1 text-surface-400">
Labels:
{t('viz.labels', { defaultValue: 'Labels:' })}
<select
className="rounded border border-surface-700 bg-surface-950 px-2 py-1 text-[11px] text-surface-100 focus:border-blue-500 focus:outline-none"
value={labelMode}
onChange={(e) => setLabelMode(e.target.value as LabelMode)}
title="Hover = on demand · Top 20 = only the most-linked hubs · All = every node"
title={t('viz.labelsTooltip', { defaultValue: 'Hover = on demand · Top 20 = only the most-linked hubs · All = every node' })}
>
<option value="hover">Hover Only</option>
<option value="top">Top 20</option>
<option value="always">All</option>
<option value="hover">{t('viz.hoverOnly', { defaultValue: 'Hover Only' })}</option>
<option value="top">{t('viz.top20', { defaultValue: 'Top 20' })}</option>
<option value="always">{t('viz.all', { defaultValue: 'All' })}</option>
</select>
</label>
<button
className="rounded border border-surface-700 px-2 py-1 text-[11px] text-surface-200 hover:border-blue-500 hover:bg-surface-800"
onClick={() => cyRef.current?.fit(undefined, 30)}
title="Fit graph to view"
title={t('viz.fitTitle', { defaultValue: 'Fit graph to view' })}
>
Fit
{t('viz.fit', { defaultValue: 'Fit' })}
</button>
<button
className="flex items-center gap-1 rounded border border-surface-700 px-2 py-1 text-[11px] text-surface-200 hover:border-blue-500 hover:bg-surface-800"
@@ -611,7 +615,7 @@ export function VisualizationTab() {
) : (
<RefreshCw className="h-3 w-3" />
)}
Reload
{t('viz.reload', { defaultValue: 'Reload' })}
</button>
<div className="relative">
<button
@@ -622,8 +626,8 @@ export function VisualizationTab() {
: 'border-surface-700 text-surface-200 hover:border-blue-500 hover:bg-surface-800'
}`}
onClick={() => setTunerOpen((v) => !v)}
title="Layout tuning"
aria-label="Layout tuning"
title={t('viz.layoutTuning', { defaultValue: 'Layout tuning' })}
aria-label={t('viz.layoutTuning', { defaultValue: 'Layout tuning' })}
>
<Settings2 className="h-3 w-3" />
</button>
@@ -645,11 +649,11 @@ export function VisualizationTab() {
: 'border-surface-700 text-surface-200 hover:border-blue-500 hover:bg-surface-800'
}`}
onClick={() => setExportMenuOpen((v) => !v)}
title="Export graph"
aria-label="Export graph"
title={t('viz.exportGraph', { defaultValue: 'Export graph' })}
aria-label={t('viz.exportGraph', { defaultValue: 'Export graph' })}
>
<Download className="h-3 w-3" />
Export
{t('viz.export', { defaultValue: 'Export' })}
</button>
{exportMenuOpen && (
<div
@@ -663,7 +667,7 @@ export function VisualizationTab() {
setExportMenuOpen(false);
}}
>
PNG (high-DPI raster)
{t('viz.exportPng', { defaultValue: 'PNG (high-DPI raster)' })}
</button>
<button
className="block w-full px-3 py-1.5 text-left text-[11px] text-surface-200 hover:bg-surface-800"
@@ -672,7 +676,7 @@ export function VisualizationTab() {
setExportMenuOpen(false);
}}
>
SVG (vector — Illustrator/Figma)
{t('viz.exportSvg', { defaultValue: 'SVG (vector — Illustrator/Figma)' })}
</button>
<button
className="block w-full px-3 py-1.5 text-left text-[11px] text-surface-200 hover:bg-surface-800"
@@ -681,7 +685,7 @@ export function VisualizationTab() {
setExportMenuOpen(false);
}}
>
Standalone HTML (shareable)
{t('viz.exportHtml', { defaultValue: 'Standalone HTML (shareable)' })}
</button>
</div>
)}
@@ -708,13 +712,16 @@ export function VisualizationTab() {
)}
{graph && (
<div className="pointer-events-none absolute left-3 top-3 rounded bg-surface-900/80 px-2 py-1 text-[10px] text-surface-300">
{graph.nodes.length.toLocaleString()} nodes ·{' '}
{graph.edges.length.toLocaleString()} edges
{t('viz.nodesEdgesCount', {
defaultValue: '{{nodes}} nodes · {{edges}} edges',
nodes: graph.nodes.length.toLocaleString(),
edges: graph.edges.length.toLocaleString(),
})}
</div>
)}
{graph && (
<div className="pointer-events-none absolute right-3 top-3 rounded bg-surface-900/80 px-2 py-1 text-[10px] text-surface-400">
Hover = neighbours · Click = select · Empty click = clear · Double-click node = open · Double-click canvas = fit
{t('viz.interactionHint', { defaultValue: 'Hover = neighbours · Click = select · Empty click = clear · Double-click node = open · Double-click canvas = fit' })}
</div>
)}
{hover && (
@@ -724,12 +731,12 @@ export function VisualizationTab() {
)}
{loading && !graph && (
<div className="absolute inset-0 flex items-center justify-center text-[12px] text-surface-500">
Loading graph…
{t('viz.loadingGraph', { defaultValue: 'Loading graph…' })}
</div>
)}
{!loading && graph && graph.nodes.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center text-[12px] text-surface-500">
No URLs crawled yet — start a crawl to populate the graph.
{t('viz.emptyGraph', { defaultValue: 'No URLs crawled yet — start a crawl to populate the graph.' })}
</div>
)}
</div>
@@ -737,13 +744,13 @@ export function VisualizationTab() {
<aside className="flex w-72 flex-col border-l border-surface-800 bg-surface-950/40">
<div className="border-b border-surface-800 px-3 py-2 text-[11px] font-semibold uppercase tracking-wider text-surface-400">
<div className="flex items-center gap-1">
<Sparkles className="h-3 w-3" /> Top Anchor Texts
<Sparkles className="h-3 w-3" /> {t('viz.topAnchorTexts', { defaultValue: 'Top Anchor Texts' })}
</div>
</div>
<div className="flex-1 overflow-auto p-2 leading-snug">
{anchors.length === 0 && (
<div className="px-2 py-3 text-[11px] italic text-surface-500">
No internal-link anchors collected yet.
{t('viz.anchorsEmpty', { defaultValue: 'No internal-link anchors collected yet.' })}
</div>
)}
{anchors.length > 0 && (
@@ -788,6 +795,7 @@ function TuningPopover({
reload: () => void;
close: () => void;
}) {
const { t } = useTranslation();
const popRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const onDocDown = (e: MouseEvent) => {
@@ -811,78 +819,78 @@ function TuningPopover({
onClick={(e) => e.stopPropagation()}
>
<div className="mb-2 flex items-center justify-between">
<div className="text-[12px] font-semibold text-surface-100">Layout Tuning</div>
<div className="text-[12px] font-semibold text-surface-100">{t('viz.layoutTuning', { defaultValue: 'Layout Tuning' })}</div>
<button
className="flex items-center gap-1 rounded border border-surface-700 px-2 py-0.5 text-[10px] text-surface-300 hover:bg-surface-800"
onClick={reset}
title="Reset to defaults"
title={t('viz.resetToDefaults', { defaultValue: 'Reset to defaults' })}
>
<RotateCcw className="h-3 w-3" />
Reset
{t('common.reset', { defaultValue: 'Reset' })}
</button>
</div>
<Slider
label="Node size"
label={t('viz.nodeSize', { defaultValue: 'Node size' })}
value={tuning.nodeSizeScale}
min={0.4}
max={3}
step={0.1}
format={(v) => `${v.toFixed(1)}×`}
onChange={(v) => patch({ nodeSizeScale: v })}
hint="Scales every dot's radius. Lower = tighter graph, higher = easier to click but more overlap."
hint={t('viz.nodeSizeHint', { defaultValue: "Scales every dot's radius. Lower = tighter graph, higher = easier to click but more overlap." })}
/>
<Slider
label="Node distance (repulsion)"
label={t('viz.nodeDistance', { defaultValue: 'Node distance (repulsion)' })}
value={tuning.repulsionScale}
min={0.2}
max={5}
step={0.1}
format={(v) => `${v.toFixed(1)}×`}
onChange={(v) => patch({ repulsionScale: v })}
hint="How strongly nodes push each other apart. Higher = more breathing room. Force-Directed only."
hint={t('viz.repulsionHint', { defaultValue: 'How strongly nodes push each other apart. Higher = more breathing room. Force-Directed only.' })}
/>
<Slider
label="Edge length"
label={t('viz.edgeLength', { defaultValue: 'Edge length' })}
value={tuning.edgeLengthScale}
min={0.3}
max={4}
step={0.1}
format={(v) => `${v.toFixed(1)}×`}
onChange={(v) => patch({ edgeLengthScale: v })}
hint="Target rest-length for connections. Higher = longer edges. Force-Directed only."
hint={t('viz.edgeLengthHint', { defaultValue: 'Target rest-length for connections. Higher = longer edges. Force-Directed only.' })}
/>
<Slider
label="Cluster spacing"
label={t('viz.clusterSpacing', { defaultValue: 'Cluster spacing' })}
value={tuning.componentSpacingScale}
min={0.3}
max={4}
step={0.1}
format={(v) => `${v.toFixed(1)}×`}
onChange={(v) => patch({ componentSpacingScale: v })}
hint="Gap between disconnected sub-graphs. Higher = isolated clusters spread further apart."
hint={t('viz.clusterSpacingHint', { defaultValue: 'Gap between disconnected sub-graphs. Higher = isolated clusters spread further apart.' })}
/>
<Slider
label="Edge opacity"
label={t('viz.edgeOpacity', { defaultValue: 'Edge opacity' })}
value={tuning.edgeOpacity}
min={0.05}
max={1}
step={0.05}
format={(v) => `${Math.round(v * 100)}%`}
onChange={(v) => patch({ edgeOpacity: v })}
hint="Lower = less visual noise on dense graphs."
hint={t('viz.edgeOpacityHint', { defaultValue: 'Lower = less visual noise on dense graphs.' })}
/>
<div className="mt-3 flex items-center justify-between gap-2 border-t border-surface-800 pt-2">
<div className="text-[10px] text-surface-500">
Some changes need a layout re-run.
{t('viz.changesNeedRerun', { defaultValue: 'Some changes need a layout re-run.' })}
</div>
<button
className="flex items-center gap-1 rounded bg-blue-600 px-2 py-1 text-[10px] font-medium text-white hover:bg-blue-500"
onClick={() => reload()}
>
<RefreshCw className="h-3 w-3" />
Re-run layout
{t('viz.rerunLayout', { defaultValue: 'Re-run layout' })}
</button>
</div>
</div>
+1 -1
View File
@@ -14,6 +14,6 @@
"@renderer/*": ["src/*"]
}
},
"include": ["src/renderer/**/*"],
"include": ["src/renderer/**/*", "src/renderer/**/*.json"],
"references": [{ "path": "../../packages/shared-types" }]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/mcp-server",
"version": "0.4.1",
"version": "0.5.0",
"private": true,
"type": "module",
"bin": {
+103 -9
View File
@@ -1,12 +1,12 @@
{
"name": "freecrawl-seo",
"version": "0.3.7",
"version": "0.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "freecrawl-seo",
"version": "0.3.7",
"version": "0.5.0",
"license": "MIT",
"workspaces": [
"apps/*",
@@ -25,7 +25,7 @@
},
"apps/cli": {
"name": "@freecrawl/cli",
"version": "0.3.7",
"version": "0.5.0",
"dependencies": {
"@freecrawl/core": "*",
"@freecrawl/db": "*",
@@ -42,7 +42,7 @@
},
"apps/desktop": {
"name": "@freecrawl/desktop",
"version": "0.3.7",
"version": "0.5.0",
"dependencies": {
"@freecrawl/core": "*",
"@freecrawl/db": "*",
@@ -52,9 +52,11 @@
"@types/cytoscape": "^3.21.9",
"clsx": "^2.1.1",
"cytoscape": "^3.33.2",
"i18next": "^26.2.0",
"lucide-react": "^0.544.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-i18next": "^17.0.8",
"react-resizable-panels": "^2.1.9",
"zustand": "^5.0.2"
},
@@ -84,7 +86,7 @@
},
"apps/mcp-server": {
"name": "@freecrawl/mcp-server",
"version": "0.3.7",
"version": "0.5.0",
"dependencies": {
"@freecrawl/db": "*",
"@freecrawl/shared-types": "*"
@@ -382,6 +384,15 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -5358,6 +5369,15 @@
"dev": true,
"license": "ISC"
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
@@ -5438,6 +5458,35 @@
"node": ">= 14"
}
},
"node_modules/i18next": {
"version": "26.2.0",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.2.0.tgz",
"integrity": "sha512-zwBHldHdTmwN7r6UNc7lC6GWNN+YYg3DrRSeHR5PRRBf5QnJZcYHrQc0uaU26qZeYxR7iFZD+Y315dPnKP47wA==",
"funding": [
{
"type": "individual",
"url": "https://www.locize.com/i18next"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://www.locize.com"
}
],
"license": "MIT",
"peer": true,
"peerDependencies": {
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/iconv-corefoundation": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz",
@@ -6913,6 +6962,33 @@
"react": "^19.2.5"
}
},
"node_modules/react-i18next": {
"version": "17.0.8",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 26.2.0",
"react": ">= 16.8.0",
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
@@ -7856,7 +7932,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
@@ -7958,6 +8034,15 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/utf8-byte-length": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz",
@@ -8548,6 +8633,15 @@
"@esbuild/win32-x64": "0.27.7"
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
@@ -8732,7 +8826,7 @@
},
"packages/core": {
"name": "@freecrawl/core",
"version": "0.3.7",
"version": "0.5.0",
"dependencies": {
"@freecrawl/db": "*",
"@freecrawl/shared-types": "*",
@@ -8758,7 +8852,7 @@
},
"packages/db": {
"name": "@freecrawl/db",
"version": "0.3.7",
"version": "0.5.0",
"dependencies": {
"@freecrawl/shared-types": "*"
},
@@ -8770,7 +8864,7 @@
},
"packages/shared-types": {
"name": "@freecrawl/shared-types",
"version": "0.3.7",
"version": "0.5.0",
"devDependencies": {
"rimraf": "^6.0.1",
"typescript": "^5.7.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "freecrawl-seo",
"version": "0.4.1",
"version": "0.5.0",
"private": true,
"description": "Open-source desktop SEO crawler — a free alternative to Screaming Frog",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/core",
"version": "0.4.1",
"version": "0.5.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/db",
"version": "0.4.1",
"version": "0.5.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/shared-types",
"version": "0.4.1",
"version": "0.5.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
+11
View File
@@ -125,6 +125,13 @@ export const IPC = {
prefsImportSettings: 'prefs:import-settings',
scheduleGet: 'schedule:get',
scheduleSet: 'schedule:set',
/** Open a native directory picker. Used by Settings → Storage to let the
* user choose the default folder for new `.seoproject` files. */
pickDirectory: 'app:pick-directory',
/** Returns the resolved default project save directory — the user's
* `projectSaveDir` pref when set, otherwise the OS Documents folder.
* Used by Settings → Storage to display the active path. */
defaultProjectDir: 'app:default-project-dir',
} as const;
export type IpcChannel = (typeof IPC)[keyof typeof IPC];
@@ -949,6 +956,10 @@ export interface FreeCrawlApi {
/** Save / update the schedule for the currently-loaded project. Passing
* `null` removes the schedule entirely. */
scheduleSet(spec: ScheduleSpec | null): Promise<ScheduleEntry | null>;
/** Open a native directory picker. Returns the chosen path or null on cancel. */
pickDirectory(input?: { title?: string; defaultPath?: string }): Promise<string | null>;
/** Resolved default save directory for new projects (Documents fallback when unset). */
defaultProjectDir(): Promise<string>;
onLogEntry(cb: (entry: LogEntry) => void): () => void;
onLogsBatch(cb: (entries: LogEntry[]) => void): () => void;
onLogsBusy(cb: (busy: boolean) => void): () => void;