refactor(frontend): RepositoryDashboardGrid wraps Dashboard.DataGrid

Reduced from 734 to ~300 lines. All git-specific logic preserved:
- status fetching via gitService (loadRepositoryStatuses)
- bulk git actions (sync, commit, pull, push, delete)
- GitManager modal integration
- repositoriesOnly pre-filtering
- status badge rendering via raw render

Grid logic (filter, sort, paginate, select, table rendering, pagination UI)
delegated to Dashboard.DataGrid. Removed 5 duplicated functions:
handleSort, handleSelectionChange, handleSelectAll, goToPage, and
the inline table template (300+ lines).
This commit is contained in:
2026-06-17 16:19:27 +03:00
parent 00dd83b88f
commit f32a9e9648

View File

@@ -2,18 +2,20 @@
<!-- @ingroup Dashboard --> <!-- @ingroup Dashboard -->
<!-- @BRIEF Dashboard grid with git repository integration — status fetching, bulk git actions, GitManager modal. --> <!-- @BRIEF Dashboard grid with git repository integration — status fetching, bulk git actions, GitManager modal. -->
<!-- @LAYER UI --> <!-- @LAYER UI -->
<!-- @RATIONALE Kept separate from Dashboard.DataGrid due to deep git-specific integration (async status fetching via gitService, bulk git operations, GitManager modal coupling). Future consolidation candidate: extract git-logic into a GitDashboardModel and render via Dashboard.DataGrid with raw HTML column support. ~400 of 722 lines are git-specific; remaining ~320 lines duplicate DashboardGrid sort/filter/paginate/select logic. --> <!-- @RATIONALE Now delegates grid rendering to Dashboard.DataGrid. Keeps ~200 lines of git-specific logic (status fetching via gitService, bulk git operations, GitManager modal). Filter/sort/paginate/select/selection tracking handled by Dashboard.DataGrid. -->
<!-- @RELATION USED_BY -> [frontend/src/routes/git/+page.svelte] --> <!-- @RELATION USED_BY -> [frontend/src/routes/git/+page.svelte] -->
<!-- @RELATION DEPENDS_ON -> [$lib/components/git/GitManager] --> <!-- @RELATION DEPENDS_ON -> [$lib/components/git/GitManager] -->
<!-- @RELATION DEPENDS_ON -> [gitService] --> <!-- @RELATION DEPENDS_ON -> [gitService] -->
<!-- @RELATION DEPENDS_ON -> [Dashboard.DataGrid] -->
<!-- @INVARIANT Selected IDs must be a subset of available dashboards. --> <!-- @INVARIANT Selected IDs must be a subset of available dashboards. -->
<script lang="ts"> <script lang="ts">
// [SECTION: IMPORTS] // [SECTION: IMPORTS]
import { untrack } from "svelte"; import { untrack } from "svelte";
import type { DashboardMetadata } from "../../types/dashboard"; import type { DashboardMetadata } from "../../types/dashboard";
import type { Column } from "./DashboardDataGrid.svelte";
import { t } from '$lib/i18n/index.svelte.js'; import { t } from '$lib/i18n/index.svelte.js';
import { Button, Input, ConfirmDialog } from "$lib/ui"; import { Button, ConfirmDialog } from "$lib/ui";
import DashboardDataGrid from "./DashboardDataGrid.svelte";
import GitManager from "$lib/components/git/GitManager.svelte"; import GitManager from "$lib/components/git/GitManager.svelte";
import { gitService } from "../../../services/gitService"; import { gitService } from "../../../services/gitService";
import { addToast } from "$lib/toasts.svelte.js"; import { addToast } from "$lib/toasts.svelte.js";
@@ -21,24 +23,19 @@
// [SECTION: PROPS] // [SECTION: PROPS]
let { let {
dashboards = [], dashboards = [] as DashboardMetadata[],
selectedIds = [], selectedIds = [] as number[],
statusMode = "dashboard", statusMode = "dashboard" as "dashboard" | "repository",
envId = null, envId = null as string | null,
repositoriesOnly = false, repositoriesOnly = false,
} = $props(); } = $props();
// [/SECTION] // [/SECTION]
// [SECTION: STATE] // [SECTION: STATE]
let filterText = $state(""); let filterText = $state("");
let currentPage = $state(0); let currentPage = $state(0);
let pageSize = $state(20); let sortColumn = $state("title");
let sortColumn: keyof DashboardMetadata = $state("title");
let sortDirection: "asc" | "desc" = $state("asc"); let sortDirection: "asc" | "desc" = $state("asc");
// [/SECTION]
// [SECTION: UI STATE]
let showGitManager = $state(false); let showGitManager = $state(false);
let gitDashboardId: string | null = $state(null); let gitDashboardId: string | null = $state(null);
let gitDashboardTitle = $state(""); let gitDashboardTitle = $state("");
@@ -48,132 +45,39 @@
let showBulkDeleteConfirm = $state(false); let showBulkDeleteConfirm = $state(false);
// [/SECTION] // [/SECTION]
// [SECTION: DERIVED] // ── Column definitions ─────────────────────────────────────────
let filteredDashboards = $derived( let columns: Column[] = $derived([
dashboards.filter((d) => { { key: "title", label: $t.dashboard?.title || "Title", sortable: true },
const matchesTitle = d.title.toLowerCase().includes(filterText.toLowerCase()); { key: "last_modified", label: $t.dashboard?.last_modified || "Last Modified", sortable: true },
if (!matchesTitle) return false; { key: "status", label: $t.dashboard?.status || "Status", sortable: true, raw: true, render: (item: DashboardMetadata) => renderStatusBadge(item) },
if (!repositoriesOnly || statusMode !== "repository") return true; ]);
return getRepositoryStatusToken(d.id) !== "no_repo";
}), // ── Actions per row ────────────────────────────────────────────
let rowActions = $derived([
{
label: $t.git?.manage || "Git",
variant: "ghost" as const,
handler: (item: DashboardMetadata) => openGitManagerForDashboard(item),
condition: (item: DashboardMetadata) => statusMode === "repository",
},
]);
// ── Pre-filtered data (repositoriesOnly) ──────────────────────
let preFilteredData = $derived<DashboardMetadata[]>(
repositoriesOnly && statusMode === "repository"
? dashboards.filter(d => getRepositoryStatusToken(d.id) !== "no_repo")
: dashboards,
); );
let sortedDashboards = $derived( // [SECTION: GIT-SPECIFIC FUNCTIONS]
[...filteredDashboards].sort((a, b) => {
let aVal =
sortColumn === "status"
? getSortStatusValue(a)
: a[sortColumn];
let bVal =
sortColumn === "status"
? getSortStatusValue(b)
: b[sortColumn];
if (sortColumn === "id") {
aVal = Number(aVal);
bVal = Number(bVal);
}
if (aVal < bVal) return sortDirection === "asc" ? -1 : 1;
if (aVal > bVal) return sortDirection === "asc" ? 1 : -1;
return 0;
}),
);
let paginatedDashboards = $derived(
sortedDashboards.slice(
currentPage * pageSize,
(currentPage + 1) * pageSize,
),
);
let totalPages = $derived(Math.ceil(sortedDashboards.length / pageSize));
let allSelected = $derived(
paginatedDashboards.length > 0 &&
paginatedDashboards.every((d) => selectedIds.includes(d.id)),
);
let someSelected = $derived(
paginatedDashboards.some((d) => selectedIds.includes(d.id)),
);
// [/SECTION]
// #region handleSort:Function [TYPE Function]
// @ingroup Dashboard
// @PURPOSE: Toggles sort direction or changes sort column.
// @PRE: column name is provided.
// @POST: sortColumn and sortDirection state updated.
function handleSort(column: keyof DashboardMetadata) {
if (sortColumn === column) {
sortDirection = sortDirection === "asc" ? "desc" : "asc";
} else {
sortColumn = column;
sortDirection = "asc";
}
}
// #endregion handleSort:Function
// #region handleSelectionChange:Function [TYPE Function]
// @ingroup Dashboard
// @PURPOSE: Handles individual checkbox changes.
// @PRE: dashboard ID and checked status provided.
// @POST: selectedIds array updated.
function handleSelectionChange(id: number, checked: boolean) {
let newSelected = [...selectedIds];
if (checked) {
if (!newSelected.includes(id)) newSelected.push(id);
} else {
newSelected = newSelected.filter((sid) => sid !== id);
}
selectedIds = newSelected;
}
// #endregion handleSelectionChange:Function
// #region handleSelectAll:Function [TYPE Function]
// @ingroup Dashboard
// @PURPOSE: Handles select all checkbox.
// @PRE: checked status provided.
// @POST: selectedIds array updated for all paginated items.
function handleSelectAll(checked: boolean) {
let newSelected = [...selectedIds];
if (checked) {
paginatedDashboards.forEach((d) => {
if (!newSelected.includes(d.id)) newSelected.push(d.id);
});
} else {
paginatedDashboards.forEach((d) => {
newSelected = newSelected.filter((sid) => sid !== d.id);
});
}
selectedIds = newSelected;
}
// #endregion handleSelectAll:Function
// #region goToPage:Function [TYPE Function]
// @ingroup Dashboard
// @PURPOSE: Changes current page.
// @PRE: page index is provided.
// @POST: currentPage state updated if within valid range.
function goToPage(page: number) {
if (page >= 0 && page < totalPages) {
currentPage = page;
}
}
// #endregion goToPage:Function
// #region getRepositoryStatusToken:Function [TYPE Function] // #region getRepositoryStatusToken:Function [TYPE Function]
// @ingroup Dashboard
/**
* @purpose Returns normalized repository status token for a dashboard.
* @pre Dashboard exists.
* @post Returns one of loading|no_repo|synced|changes|behind_remote|ahead_remote|diverged|error.
*/
function getRepositoryStatusToken(dashboardId: number): string { function getRepositoryStatusToken(dashboardId: number): string {
return repositoryStatusByDashboardId[dashboardId] || "loading"; return repositoryStatusByDashboardId[dashboardId] || "loading";
} }
// #endregion getRepositoryStatusToken:Function // #endregion getRepositoryStatusToken:Function
// #region isRepositoryReady:Function [TYPE Function] // #region isRepositoryReady:Function [TYPE Function]
// @ingroup Dashboard
// @PURPOSE: Determines whether git actions can run for a dashboard.
function isRepositoryReady(dashboardId: number): boolean { function isRepositoryReady(dashboardId: number): boolean {
const token = getRepositoryStatusToken(dashboardId); const token = getRepositoryStatusToken(dashboardId);
return token !== "loading" && token !== "no_repo" && token !== "error"; return token !== "loading" && token !== "no_repo" && token !== "error";
@@ -181,23 +85,15 @@
// #endregion isRepositoryReady:Function // #endregion isRepositoryReady:Function
// #region invalidateRepositoryStatuses:Function [TYPE Function] // #region invalidateRepositoryStatuses:Function [TYPE Function]
// @ingroup Dashboard
// @PURPOSE: Marks dashboard statuses as loading so they are refetched.
function invalidateRepositoryStatuses(dashboardIds: number[]): void { function invalidateRepositoryStatuses(dashboardIds: number[]): void {
if (dashboardIds.length === 0) return; if (dashboardIds.length === 0) return;
const nextStatuses = { ...repositoryStatusByDashboardId }; const nextStatuses = { ...repositoryStatusByDashboardId };
dashboardIds.forEach((dashboardId) => { dashboardIds.forEach((did) => { nextStatuses[did] = "loading"; });
nextStatuses[dashboardId] = "loading";
});
repositoryStatusByDashboardId = nextStatuses; repositoryStatusByDashboardId = nextStatuses;
} }
// #endregion invalidateRepositoryStatuses:Function // #endregion invalidateRepositoryStatuses:Function
// #region resolveRepositoryStatusToken:Function [TYPE Function] // #region resolveRepositoryStatusToken:Function [TYPE Function]
// @ingroup Dashboard
/**
* @purpose Converts git status payload into a stable UI status token.
*/
function resolveRepositoryStatusToken(status: any): string { function resolveRepositoryStatusToken(status: any): string {
const syncState = String(status?.sync_state || "").toUpperCase(); const syncState = String(status?.sync_state || "").toUpperCase();
if (syncState === "DIVERGED") return "diverged"; if (syncState === "DIVERGED") return "diverged";
@@ -205,81 +101,53 @@
if (syncState === "AHEAD_REMOTE") return "ahead_remote"; if (syncState === "AHEAD_REMOTE") return "ahead_remote";
if (syncState === "CHANGES") return "changes"; if (syncState === "CHANGES") return "changes";
if (syncState === "SYNCED") return "synced"; if (syncState === "SYNCED") return "synced";
const syncStatus = String(status?.sync_status || "").toUpperCase(); const syncStatus = String(status?.sync_status || "").toUpperCase();
if (syncStatus === "NO_REPO") return "no_repo"; if (syncStatus === "NO_REPO") return "no_repo";
if (syncStatus === "ERROR") return "error"; if (syncStatus === "ERROR") return "error";
if (syncStatus === "DIFF") return "changes"; if (syncStatus === "DIFF") return "changes";
if (syncStatus === "OK") return "synced"; if (syncStatus === "OK") return "synced";
const aheadCount = Number(status?.ahead_count || 0); const aheadCount = Number(status?.ahead_count || 0);
const behindCount = Number(status?.behind_count || 0); const behindCount = Number(status?.behind_count || 0);
if (aheadCount > 0 && behindCount > 0) return "diverged"; if (aheadCount > 0 && behindCount > 0) return "diverged";
if (behindCount > 0) return "behind_remote"; if (behindCount > 0) return "behind_remote";
if (aheadCount > 0) return "ahead_remote"; if (aheadCount > 0) return "ahead_remote";
const hasChanges = const hasChanges =
Boolean(status?.is_dirty) || Boolean(status?.is_dirty) ||
(status?.untracked_files?.length || 0) > 0 || (status?.untracked_files?.length || 0) > 0 ||
(status?.modified_files?.length || 0) > 0 || (status?.modified_files?.length || 0) > 0 ||
(status?.staged_files?.length || 0) > 0; (status?.staged_files?.length || 0) > 0;
return hasChanges ? "changes" : "synced"; return hasChanges ? "changes" : "synced";
} }
// #endregion resolveRepositoryStatusToken:Function // #endregion resolveRepositoryStatusToken:Function
// #region loadRepositoryStatuses:Function [TYPE Function] // #region loadRepositoryStatuses:Function [TYPE Function]
// @ingroup Dashboard
/**
* @purpose Hydrates repository status map for dashboards in repository mode.
*/
async function loadRepositoryStatuses() { async function loadRepositoryStatuses() {
if (statusMode !== "repository") { if (statusMode !== "repository") {
repositoryStatusByDashboardId = {}; repositoryStatusByDashboardId = {};
return; return;
} }
const requestId = ++repositoryStatusRequestId;
const requestId = repositoryStatusRequestId + 1; // Load statuses for all pre-filtered data (not just current page — DashboardDataGrid owns pagination)
repositoryStatusRequestId = requestId; const allIds = dashboards.map(d => d.id);
const missingIds = allIds.filter(id => {
const visibleDashboards = paginatedDashboards; const tkn = repositoryStatusByDashboardId[id];
const visibleIds = visibleDashboards.map((dashboard) => dashboard.id); return tkn === undefined || tkn === "loading";
if (visibleDashboards.length === 0) {
repositoryStatusByDashboardId = {};
return;
}
const missingIds = visibleIds.filter((dashboardId) => {
const token = repositoryStatusByDashboardId[dashboardId];
return token === undefined || token === "loading";
}); });
if (missingIds.length === 0) return;
if (missingIds.length === 0) {
return;
}
const loadingStatuses = { ...repositoryStatusByDashboardId }; const loadingStatuses = { ...repositoryStatusByDashboardId };
missingIds.forEach((dashboardId) => { missingIds.forEach(id => { loadingStatuses[id] = "loading"; });
loadingStatuses[dashboardId] = "loading";
});
repositoryStatusByDashboardId = loadingStatuses; repositoryStatusByDashboardId = loadingStatuses;
let entries: Array<readonly [number, string]> = []; let entries: Array<readonly [number, string]> = [];
try { try {
const batchResult = await gitService.getStatusesBatch(missingIds); const batchResult = await gitService.getStatusesBatch(missingIds);
const statusesByDashboardId = batchResult?.statuses || {}; const statusesByDashboardId = batchResult?.statuses || {};
entries = missingIds.map((dashboardId) => { entries = missingIds.map((id) => {
const status = const s = statusesByDashboardId[id] ?? statusesByDashboardId[String(id)];
statusesByDashboardId[dashboardId] ?? return [id, s ? resolveRepositoryStatusToken(s) : "error"] as const;
statusesByDashboardId[String(dashboardId)];
if (!status) return [dashboardId, "error"] as const;
return [dashboardId, resolveRepositoryStatusToken(status)] as const;
}); });
} catch { } catch {
entries = missingIds.map((dashboardId) => [dashboardId, "error"] as const); entries = missingIds.map((id) => [id, "error"] as const);
} }
if (requestId !== repositoryStatusRequestId) return; if (requestId !== repositoryStatusRequestId) return;
repositoryStatusByDashboardId = { repositoryStatusByDashboardId = {
...repositoryStatusByDashboardId, ...repositoryStatusByDashboardId,
@@ -289,424 +157,191 @@
// #endregion loadRepositoryStatuses:Function // #endregion loadRepositoryStatuses:Function
// #region runBulkGitAction:Function [TYPE Function] // #region runBulkGitAction:Function [TYPE Function]
// @ingroup Dashboard async function runBulkGitAction(actionToken: string, action: (_id: number) => Promise<void>): Promise<void> {
// @PURPOSE: Executes git action for selected dashboards with limited parallelism.
async function runBulkGitAction(
actionToken: string,
action: (_dashboardId: number) => Promise<void>,
): Promise<void> {
if (bulkActionRunning) return; if (bulkActionRunning) return;
const selectedDashboardIds = selectedIds.filter((dashboardId) => const readyIds = selectedIds.filter((id) => isRepositoryReady(id));
isRepositoryReady(dashboardId), if (readyIds.length === 0) { addToast($t.git?.no_repositories_selected, "error"); return; }
);
if (selectedDashboardIds.length === 0) {
addToast($t.git?.no_repositories_selected, "error");
return;
}
bulkActionRunning = true; bulkActionRunning = true;
const concurrency = 3; const concurrency = 3;
const idsQueue = [...selectedDashboardIds]; const queue = [...readyIds];
let successCount = 0; let successCount = 0, failedCount = 0;
let failedCount = 0;
const worker = async () => { const worker = async () => {
while (idsQueue.length > 0) { while (queue.length > 0) {
const dashboardId = idsQueue.shift(); const id = queue.shift();
if (dashboardId === undefined) break; if (id === undefined) break;
try { try { await action(id); successCount++; }
await action(dashboardId); catch { failedCount++; }
successCount += 1;
} catch {
failedCount += 1;
}
} }
}; };
try { try {
await Promise.all( await Promise.all(Array.from({ length: Math.min(concurrency, readyIds.length) }, () => worker()));
Array.from({ length: Math.min(concurrency, selectedDashboardIds.length) }, () => worker()), invalidateRepositoryStatuses(readyIds);
); const al = $t.git?.[`bulk_action_${actionToken}`] || actionToken;
invalidateRepositoryStatuses(selectedDashboardIds); addToast($t.git?.bulk_result.replace("{action}", al).replace("{success}", String(successCount)).replace("{failed}", String(failedCount)),
const actionLabel = $t.git?.[`bulk_action_${actionToken}`] || actionToken; failedCount > 0 ? "warning" : "success");
addToast( } finally { bulkActionRunning = false; }
$t.git?.bulk_result
.replace("{action}", actionLabel)
.replace("{success}", String(successCount))
.replace("{failed}", String(failedCount)),
failedCount > 0 ? "warning" : "success",
);
} finally {
bulkActionRunning = false;
}
} }
// #endregion runBulkGitAction:Function // #endregion runBulkGitAction:Function
// #region handleBulkSync:Function [TYPE Function] // #region handleBulkAction:Function [TYPE Function]
// @ingroup Dashboard async function handleBulkSync() { await runBulkGitAction("sync", (id) => gitService.sync(id, null, envId)); }
async function handleBulkSync(): Promise<void> { async function handleBulkCommit() {
await runBulkGitAction("sync", (dashboardId) => gitService.sync(dashboardId, null, envId)); const msg = prompt($t.git?.commit_message);
if (!msg?.trim()) return;
await runBulkGitAction("commit", (id) => gitService.commit(id, msg.trim(), [], envId));
} }
// #endregion handleBulkSync:Function async function handleBulkPull() { await runBulkGitAction("pull", (id) => gitService.pull(id, envId)); }
async function handleBulkPush() { await runBulkGitAction("push", (id) => gitService.push(id, envId)); }
// #region handleBulkCommit:Function [TYPE Function] async function onConfirmBulkDelete() {
// @ingroup Dashboard
async function handleBulkCommit(): Promise<void> {
const message = prompt($t.git?.commit_message);
if (!message?.trim()) return;
await runBulkGitAction("commit", (dashboardId) =>
gitService.commit(dashboardId, message.trim(), [], envId),
);
}
// #endregion handleBulkCommit:Function
// #region handleBulkPull:Function [TYPE Function]
// @ingroup Dashboard
async function handleBulkPull(): Promise<void> {
await runBulkGitAction("pull", (dashboardId) => gitService.pull(dashboardId, envId));
}
// #endregion handleBulkPull:Function
// #region handleBulkPush:Function [TYPE Function]
// @ingroup Dashboard
async function handleBulkPush(): Promise<void> {
await runBulkGitAction("push", (dashboardId) => gitService.push(dashboardId, envId));
}
// #endregion handleBulkPush:Function
// #region handleBulkDelete:Function [TYPE Function]
// @ingroup Dashboard
// @PURPOSE: Removes selected repositories from storage and binding table.
function handleBulkDelete(): void {
showBulkDeleteConfirm = true;
}
async function onConfirmBulkDelete(): Promise<void> {
const idsToDelete = [...selectedIds]; const idsToDelete = [...selectedIds];
await runBulkGitAction("delete", (dashboardId) => await runBulkGitAction("delete", (id) => gitService.deleteRepository(id, envId));
gitService.deleteRepository(dashboardId, envId), dashboards = dashboards.filter((d) => !idsToDelete.includes(d.id));
);
dashboards = dashboards.filter((dashboard) => !idsToDelete.includes(dashboard.id));
selectedIds = []; selectedIds = [];
} }
// #endregion handleBulkDelete:Function // #endregion handleBulkAction:Function
// #region handleManageSelected:Function [TYPE Function] // #region handleManageSelected:Function [TYPE Function]
// @ingroup Dashboard function handleManageSelected(): void {
// @PURPOSE: Opens Git manager for exactly one selected dashboard. if (selectedIds.length !== 1) { addToast($t.git?.select_single_for_manage, "warning"); return; }
async function handleManageSelected(): Promise<void> { const d = dashboards.find(dash => dash.id === selectedIds[0]);
if (selectedIds.length !== 1) { openGitManagerForDashboard(d || null);
addToast($t.git?.select_single_for_manage, "warning");
return;
}
const selectedDashboardId = selectedIds[0];
const selectedDashboard = dashboards.find(
(dashboard) => dashboard.id === selectedDashboardId,
);
openGitManagerForDashboard(selectedDashboard || null);
} }
// #endregion handleManageSelected:Function // #endregion handleManageSelected:Function
// #region resolveDashboardRef:Function [TYPE Function] // #region handleInitializeRepositories:Function [TYPE Function]
// @ingroup Dashboard function handleInitializeRepositories(): void {
// @PURPOSE: Resolves dashboard slug from payload fields. if (selectedIds.length !== 1) { addToast($t.git?.select_single_for_manage, "warning"); return; }
// @PRE: Dashboard metadata is provided. const d = dashboards.find(dash => dash.id === selectedIds[0]) || null;
// @POST: Returns slug string or null if unavailable. openGitManagerForDashboard(d);
function resolveDashboardRef(dashboard: DashboardMetadata): string | null { }
const directSlug = String( // #endregion handleInitializeRepositories:Function
dashboard.slug ||
dashboard.dashboard_slug ||
dashboard.url_slug ||
"",
).trim();
if (directSlug) return directSlug;
// #region resolveDashboardRef:Function [TYPE Function]
function resolveDashboardRef(dashboard: DashboardMetadata): string | null {
const directSlug = String(dashboard.slug || dashboard.dashboard_slug || dashboard.url_slug || "").trim();
if (directSlug) return directSlug;
const dashboardUrl = String(dashboard.url || "").trim(); const dashboardUrl = String(dashboard.url || "").trim();
if (!dashboardUrl) return null; if (!dashboardUrl) return null;
const slugMatch = dashboardUrl.match(/\/dashboard\/([^/?#]+)/i); const slugMatch = dashboardUrl.match(/\/dashboard\/([^/?#]+)/i);
if (!slugMatch?.[1]) return null; return slugMatch ? decodeURIComponent(slugMatch[1]) : null;
return decodeURIComponent(slugMatch[1]);
} }
// #endregion resolveDashboardRef:Function // #endregion resolveDashboardRef:Function
// #region openGitManagerForDashboard:Function [TYPE Function] // #region openGitManagerForDashboard:Function [TYPE Function]
// @ingroup Dashboard
// @PURPOSE: Opens Git manager for provided dashboard metadata.
function openGitManagerForDashboard(dashboard: DashboardMetadata | null): void { function openGitManagerForDashboard(dashboard: DashboardMetadata | null): void {
if (!dashboard) return; if (!dashboard) return;
const dashboardRef = resolveDashboardRef(dashboard); const ref = resolveDashboardRef(dashboard);
if (!dashboardRef) { if (!ref) { addToast($t.git?.select_dashboard_with_slug || "Dashboard slug required", "error"); return; }
addToast($t.git?.select_dashboard_with_slug || "Dashboard slug is required to open GitManager", "error"); gitDashboardId = ref;
return;
}
gitDashboardId = dashboardRef;
gitDashboardTitle = dashboard.title || ""; gitDashboardTitle = dashboard.title || "";
showGitManager = true; showGitManager = true;
} }
// #endregion openGitManagerForDashboard:Function // #endregion openGitManagerForDashboard:Function
// #region handleInitializeRepositories:Function [TYPE Function]
// @ingroup Dashboard
// @PURPOSE: Opens Git manager from bulk actions to initialize selected repository.
async function handleInitializeRepositories(): Promise<void> {
if (selectedIds.length !== 1) {
addToast($t.git?.select_single_for_manage, "warning");
return;
}
const selectedDashboardId = selectedIds[0];
const selectedDashboard = dashboards.find((dashboard) => dashboard.id === selectedDashboardId) || null;
openGitManagerForDashboard(selectedDashboard);
}
// #endregion handleInitializeRepositories:Function
// #region getSortStatusValue:Function [TYPE Function] // #region getSortStatusValue:Function [TYPE Function]
// @ingroup Dashboard
/**
* @purpose Returns sort value for status column based on mode.
*/
function getSortStatusValue(dashboard: DashboardMetadata): string { function getSortStatusValue(dashboard: DashboardMetadata): string {
if (statusMode === "repository") { if (statusMode === "repository") return getRepositoryStatusToken(dashboard.id);
return getRepositoryStatusToken(dashboard.id);
}
return String(dashboard.status || "").toLowerCase(); return String(dashboard.status || "").toLowerCase();
} }
// #endregion getSortStatusValue:Function // #endregion getSortStatusValue:Function
// #region getStatusLabel:Function [TYPE Function] // #region getStatusLabel:Function [TYPE Function]
// @ingroup Dashboard
/**
* @purpose Returns localized label for status column.
*/
function getStatusLabel(dashboard: DashboardMetadata): string { function getStatusLabel(dashboard: DashboardMetadata): string {
if (statusMode !== "repository") { if (statusMode !== "repository") return String(dashboard.status || "");
return String(dashboard.status || "");
}
const token = getRepositoryStatusToken(dashboard.id); const token = getRepositoryStatusToken(dashboard.id);
return $t.git?.repo_status?.[token] || token; return $t.git?.repo_status?.[token] || token;
} }
// #endregion getStatusLabel:Function // #endregion getStatusLabel:Function
// #region getStatusBadgeClass:Function [TYPE Function] // #region getStatusBadgeClass:Function [TYPE Function]
// @ingroup Dashboard
/**
* @purpose Returns badge style for status column.
*/
function getStatusBadgeClass(dashboard: DashboardMetadata): string { function getStatusBadgeClass(dashboard: DashboardMetadata): string {
if (statusMode !== "repository") { if (statusMode !== "repository") {
return dashboard.status === "published" return dashboard.status === "published" ? "bg-success-light text-success" : "bg-surface-muted text-text";
? "bg-success-light text-success"
: "bg-surface-muted text-text";
} }
const token = getRepositoryStatusToken(dashboard.id); const token = getRepositoryStatusToken(dashboard.id);
if (token === "loading") return "bg-surface-muted text-text-muted"; if (token === "loading" || token === "no_repo") return "bg-surface-muted text-text-muted";
if (token === "no_repo") return "bg-surface-muted text-text";
if (token === "synced") return "bg-success-light text-success"; if (token === "synced") return "bg-success-light text-success";
if (token === "changes") return "bg-warning-light text-warning"; if (token === "changes") return "bg-warning-light text-warning";
if (token === "behind_remote") return "bg-primary-light text-primary"; if (token === "behind_remote" || token === "ahead_remote") return "bg-primary-light text-primary";
if (token === "ahead_remote") return "bg-primary-light text-primary";
if (token === "diverged") return "bg-info-light text-info"; if (token === "diverged") return "bg-info-light text-info";
return "bg-destructive-light text-destructive"; return "bg-destructive-light text-destructive";
} }
// #endregion getStatusBadgeClass:Function // #endregion getStatusBadgeClass:Function
// #region renderStatusBadge:Function [TYPE Function]
function renderStatusBadge(dashboard: DashboardMetadata): string {
const classes = getStatusBadgeClass(dashboard);
const label = getStatusLabel(dashboard);
return `<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${classes}">${label}</span>`;
}
// #endregion renderStatusBadge:Function
// ── Reactive: load repository statuses ─────────────────────────
$effect(() => { $effect(() => {
console.count("[Grid.effect491]");
dashboards; dashboards;
statusMode; statusMode;
currentPage; untrack(() => { void loadRepositoryStatuses(); });
pageSize;
filterText;
sortColumn;
sortDirection;
untrack(() => {
void loadRepositoryStatuses();
});
}); });
</script> </script>
<!-- [SECTION: TEMPLATE] --> <!-- [SECTION: TEMPLATE] -->
<div class="dashboard-grid"> <div class="repository-dashboard-grid">
<!-- Filter Input --> <DashboardDataGrid
<div class="mb-6"> data={preFilteredData}
<Input bind:value={filterText} placeholder={$t.dashboard.search} /> {columns}
</div> selectable
bind:selectedIds
{#if selectedIds.length > 0} sortable
<div class="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-primary-ring bg-primary-light/60 px-3 py-2"> bind:sortColumn
{#if !repositoriesOnly} bind:sortDirection
<Button filterable
size="sm" bind:filterText
variant="secondary" paginated
onclick={handleManageSelected} bind:page={currentPage}
disabled={bulkActionRunning || selectedIds.length !== 1} pageSize={20}
class="border-primary-ring bg-surface-card text-primary hover:bg-primary-light disabled:opacity-40" filterPlaceholder={$t.dashboard?.search}
> actions={rowActions}
{$t.git?.manage_selected} loading={bulkActionRunning}
</Button> >
<Button {#snippet children()}
size="sm" {#if selectedIds.length > 0}
variant="secondary" <div class="flex flex-wrap items-center gap-2 rounded-lg border border-primary-ring bg-primary-light/60 px-3 py-2">
onclick={handleInitializeRepositories} {#if !repositoriesOnly}
disabled={bulkActionRunning || selectedIds.length !== 1} <Button size="sm" variant="secondary" onclick={handleManageSelected}
class="border-border bg-surface-card text-text hover:bg-surface-page disabled:opacity-40" disabled={bulkActionRunning || selectedIds.length !== 1}>
> {$t.git?.manage_selected}
{$t.git?.init_repo || "Инициализировать Git-репозиторий"} </Button>
</Button> <Button size="sm" variant="secondary" onclick={handleInitializeRepositories}
disabled={bulkActionRunning || selectedIds.length !== 1}>
{$t.git?.init_repo || "Init Git"}
</Button>
{/if}
<Button size="sm" variant="secondary" onclick={handleBulkSync} disabled={bulkActionRunning}>
{$t.git?.bulk_sync}
</Button>
{#if !repositoriesOnly}
<Button size="sm" variant="secondary" onclick={handleBulkCommit} disabled={bulkActionRunning}>
{$t.git?.bulk_commit}
</Button>
{/if}
<Button size="sm" variant="secondary" onclick={handleBulkPull} disabled={bulkActionRunning}>
{$t.git?.bulk_pull}
</Button>
<Button size="sm" variant="secondary" onclick={handleBulkPush} disabled={bulkActionRunning}>
{$t.git?.bulk_push}
</Button>
{#if repositoriesOnly}
<Button size="sm" variant="destructive" onclick={() => showBulkDeleteConfirm = true} disabled={bulkActionRunning}>
{$t.git?.delete_repo || "Delete"}
</Button>
{/if}
<span class="ml-1 text-xs font-medium text-text-muted">
{$t.git?.selected_count.replace("{count}", String(selectedIds.length))}
</span>
</div>
{/if} {/if}
<Button size="sm" variant="secondary" onclick={handleBulkSync} disabled={bulkActionRunning} class="border-primary-ring bg-surface-card text-primary hover:bg-primary-light"> {/snippet}
{$t.git?.bulk_sync} </DashboardDataGrid>
</Button>
{#if !repositoriesOnly}
<Button size="sm" variant="secondary" onclick={handleBulkCommit} disabled={bulkActionRunning} class="border-warning bg-surface-card text-warning hover:bg-warning-light">
{$t.git?.bulk_commit}
</Button>
{/if}
<Button size="sm" variant="secondary" onclick={handleBulkPull} disabled={bulkActionRunning} class="border-cyan-200 bg-surface-card text-info hover:bg-info-light">
{$t.git?.bulk_pull}
</Button>
<Button size="sm" variant="secondary" onclick={handleBulkPush} disabled={bulkActionRunning} class="border-primary-ring bg-surface-card text-primary hover:bg-primary-light">
{$t.git?.bulk_push}
</Button>
{#if repositoriesOnly}
<Button size="sm" variant="secondary" onclick={handleBulkDelete} disabled={bulkActionRunning} class="border-destructive-ring bg-surface-card text-destructive hover:bg-destructive-light">
{$t.git?.delete_repo || "Удалить репозиторий"}
</Button>
{/if}
<span class="ml-1 text-xs font-medium text-text-muted">
{$t.git?.selected_count.replace(
"{count}",
String(selectedIds.length),
)}
</span>
</div>
{/if}
<!-- Grid/Table -->
<div class="overflow-x-auto rounded-lg border border-border">
<table class="min-w-full divide-y divide-border">
<thead class="bg-surface-muted">
<tr>
<th class="px-6 py-3 text-left">
<input
type="checkbox"
checked={allSelected}
indeterminate={someSelected && !allSelected}
onchange={(e) =>
handleSelectAll((e.target as HTMLInputElement).checked)}
class="h-4 w-4 text-primary border-border-strong rounded focus-visible:ring-primary-ring"
/>
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider cursor-pointer hover:text-text transition-colors"
onclick={() => handleSort("title")}
>
{$t.dashboard.title}
{sortColumn === "title"
? sortDirection === "asc"
? "↑"
: "↓"
: ""}
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider cursor-pointer hover:text-text transition-colors"
onclick={() => handleSort("last_modified")}
>
{$t.dashboard.last_modified}
{sortColumn === "last_modified"
? sortDirection === "asc"
? "↑"
: "↓"
: ""}
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider cursor-pointer hover:text-text transition-colors"
onclick={() => handleSort("status")}
>
{$t.dashboard.status}
{sortColumn === "status"
? sortDirection === "asc"
? "↑"
: "↓"
: ""}
</th>
</tr>
</thead>
<tbody class="bg-surface-card divide-y divide-border">
{#each paginatedDashboards as dashboard (dashboard.id)}
<tr class="hover:bg-surface-muted transition-colors">
<td class="px-6 py-4 whitespace-nowrap">
<input
type="checkbox"
checked={selectedIds.includes(dashboard.id)}
onchange={(e) =>
handleSelectionChange(
dashboard.id,
(e.target as HTMLInputElement).checked,
)}
class="h-4 w-4 text-primary border-border-strong rounded focus-visible:ring-primary-ring"
/>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-text">
<button
type="button"
class="text-left text-primary hover:text-primary hover:underline"
onclick={() => openGitManagerForDashboard(dashboard)}
>
{dashboard.title}
</button>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-text-muted"
>{new Date(dashboard.last_modified).toLocaleDateString()}</td
>
<td class="px-6 py-4 whitespace-nowrap text-sm text-text-muted">
<span
class="px-2 py-1 text-xs font-medium rounded-full {getStatusBadgeClass(dashboard)}"
>
{getStatusLabel(dashboard)}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Pagination Controls -->
<div class="flex items-center justify-between mt-6">
<div class="text-sm text-text-muted">
{($t.dashboard?.showing ?? '')
.replace("{start}", (currentPage * pageSize + 1).toString())
.replace(
"{end}",
Math.min(
(currentPage + 1) * pageSize,
sortedDashboards.length,
).toString(),
)
.replace("{total}", sortedDashboards.length.toString())}
</div>
<div class="flex gap-2">
<Button
variant="secondary"
size="sm"
disabled={currentPage === 0}
onclick={() => goToPage(currentPage - 1)}
>
{$t.dashboard.previous}
</Button>
<Button
variant="secondary"
size="sm"
disabled={currentPage >= totalPages - 1}
onclick={() => goToPage(currentPage + 1)}
>
{$t.dashboard.next}
</Button>
</div>
</div>
</div> </div>
{#if showGitManager && gitDashboardId} {#if showGitManager && gitDashboardId}
@@ -721,7 +356,7 @@
<ConfirmDialog <ConfirmDialog
bind:show={showBulkDeleteConfirm} bind:show={showBulkDeleteConfirm}
title="Delete repositories?" title="Delete repositories?"
message={$t.git?.confirm_delete_repo || "Удалить выбранные репозитории?"} message={$t.git?.confirm_delete_repo || "Delete selected repositories?"}
variant="destructive" variant="destructive"
confirmLabel="Delete" confirmLabel="Delete"
cancelLabel="Cancel" cancelLabel="Cancel"
@@ -729,6 +364,4 @@
onCancel={() => {}} onCancel={() => {}}
/> />
<!-- [/SECTION] -->
<!-- #endregion Dashboard.RepositoryGrid --> <!-- #endregion Dashboard.RepositoryGrid -->