git list refactor
This commit is contained in:
613
frontend/src/components/RepositoryDashboardGrid.svelte
Normal file
613
frontend/src/components/RepositoryDashboardGrid.svelte
Normal file
@@ -0,0 +1,613 @@
|
||||
<!-- [DEF:DashboardGrid:Component] -->
|
||||
<!--
|
||||
@TIER: STANDARD
|
||||
@SEMANTICS: dashboard, grid, selection, pagination
|
||||
@PURPOSE: Displays a grid of dashboards with selection and pagination.
|
||||
@LAYER: Component
|
||||
@RELATION: USED_BY -> frontend/src/routes/migration/+page.svelte
|
||||
|
||||
@INVARIANT: Selected IDs must be a subset of available dashboards.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
// [SECTION: IMPORTS]
|
||||
import { createEventDispatcher, untrack } from "svelte";
|
||||
import type { DashboardMetadata } from "../types/dashboard";
|
||||
import { t } from "../lib/i18n";
|
||||
import { Button, Input } from "../lib/ui";
|
||||
import GitManager from "./git/GitManager.svelte";
|
||||
import { gitService } from "../services/gitService";
|
||||
import { addToast } from "../lib/toasts.js";
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: PROPS]
|
||||
let { dashboards = [], selectedIds = [], statusMode = "dashboard" } = $props();
|
||||
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: STATE]
|
||||
let filterText = $state("");
|
||||
let currentPage = $state(0);
|
||||
let pageSize = $state(20);
|
||||
let sortColumn: keyof DashboardMetadata = $state("title");
|
||||
let sortDirection: "asc" | "desc" = $state("asc");
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: UI STATE]
|
||||
let showGitManager = $state(false);
|
||||
let gitDashboardId: number | null = $state(null);
|
||||
let gitDashboardTitle = $state("");
|
||||
let repositoryStatusByDashboardId = $state<Record<number, string>>({});
|
||||
let repositoryStatusRequestId = $state(0);
|
||||
let bulkActionRunning = $state(false);
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: DERIVED]
|
||||
let filteredDashboards = $derived(
|
||||
dashboards.filter((d) =>
|
||||
d.title.toLowerCase().includes(filterText.toLowerCase()),
|
||||
),
|
||||
);
|
||||
|
||||
let sortedDashboards = $derived(
|
||||
[...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]
|
||||
|
||||
// [SECTION: EVENTS]
|
||||
const dispatch = createEventDispatcher<{ selectionChanged: number[] }>();
|
||||
// [/SECTION]
|
||||
|
||||
// [DEF:handleSort:Function]
|
||||
// @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";
|
||||
}
|
||||
}
|
||||
// [/DEF:handleSort:Function]
|
||||
|
||||
// [DEF:handleSelectionChange:Function]
|
||||
// @PURPOSE: Handles individual checkbox changes.
|
||||
// @PRE: dashboard ID and checked status provided.
|
||||
// @POST: selectedIds array updated and selectionChanged event dispatched.
|
||||
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;
|
||||
dispatch("selectionChanged", newSelected);
|
||||
}
|
||||
// [/DEF:handleSelectionChange:Function]
|
||||
|
||||
// [DEF:handleSelectAll:Function]
|
||||
// @PURPOSE: Handles select all checkbox.
|
||||
// @PRE: checked status provided.
|
||||
// @POST: selectedIds array updated for all paginated items and event dispatched.
|
||||
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;
|
||||
dispatch("selectionChanged", newSelected);
|
||||
}
|
||||
// [/DEF:handleSelectAll:Function]
|
||||
|
||||
// [DEF:goToPage:Function]
|
||||
// @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;
|
||||
}
|
||||
}
|
||||
// [/DEF:goToPage:Function]
|
||||
|
||||
// [DEF:getRepositoryStatusToken:Function]
|
||||
/**
|
||||
* @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 {
|
||||
return repositoryStatusByDashboardId[dashboardId] || "loading";
|
||||
}
|
||||
// [/DEF:getRepositoryStatusToken:Function]
|
||||
|
||||
// [DEF:isRepositoryReady:Function]
|
||||
// @PURPOSE: Determines whether git actions can run for a dashboard.
|
||||
function isRepositoryReady(dashboardId: number): boolean {
|
||||
const token = getRepositoryStatusToken(dashboardId);
|
||||
return token !== "loading" && token !== "no_repo" && token !== "error";
|
||||
}
|
||||
// [/DEF:isRepositoryReady:Function]
|
||||
|
||||
// [DEF:invalidateRepositoryStatuses:Function]
|
||||
// @PURPOSE: Marks dashboard statuses as loading so they are refetched.
|
||||
function invalidateRepositoryStatuses(dashboardIds: number[]): void {
|
||||
if (dashboardIds.length === 0) return;
|
||||
const nextStatuses = { ...repositoryStatusByDashboardId };
|
||||
dashboardIds.forEach((dashboardId) => {
|
||||
nextStatuses[dashboardId] = "loading";
|
||||
});
|
||||
repositoryStatusByDashboardId = nextStatuses;
|
||||
}
|
||||
// [/DEF:invalidateRepositoryStatuses:Function]
|
||||
|
||||
// [DEF:resolveRepositoryStatusToken:Function]
|
||||
/**
|
||||
* @purpose Converts git status payload into a stable UI status token.
|
||||
*/
|
||||
function resolveRepositoryStatusToken(status: any): string {
|
||||
const syncState = String(status?.sync_state || "").toUpperCase();
|
||||
if (syncState === "DIVERGED") return "diverged";
|
||||
if (syncState === "BEHIND_REMOTE") return "behind_remote";
|
||||
if (syncState === "AHEAD_REMOTE") return "ahead_remote";
|
||||
if (syncState === "CHANGES") return "changes";
|
||||
if (syncState === "SYNCED") return "synced";
|
||||
|
||||
const syncStatus = String(status?.sync_status || "").toUpperCase();
|
||||
if (syncStatus === "NO_REPO") return "no_repo";
|
||||
if (syncStatus === "ERROR") return "error";
|
||||
if (syncStatus === "DIFF") return "changes";
|
||||
if (syncStatus === "OK") return "synced";
|
||||
|
||||
const aheadCount = Number(status?.ahead_count || 0);
|
||||
const behindCount = Number(status?.behind_count || 0);
|
||||
if (aheadCount > 0 && behindCount > 0) return "diverged";
|
||||
if (behindCount > 0) return "behind_remote";
|
||||
if (aheadCount > 0) return "ahead_remote";
|
||||
|
||||
const hasChanges =
|
||||
Boolean(status?.is_dirty) ||
|
||||
(status?.untracked_files?.length || 0) > 0 ||
|
||||
(status?.modified_files?.length || 0) > 0 ||
|
||||
(status?.staged_files?.length || 0) > 0;
|
||||
|
||||
return hasChanges ? "changes" : "synced";
|
||||
}
|
||||
// [/DEF:resolveRepositoryStatusToken:Function]
|
||||
|
||||
// [DEF:loadRepositoryStatuses:Function]
|
||||
/**
|
||||
* @purpose Hydrates repository status map for dashboards in repository mode.
|
||||
*/
|
||||
async function loadRepositoryStatuses() {
|
||||
if (statusMode !== "repository") {
|
||||
repositoryStatusByDashboardId = {};
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = repositoryStatusRequestId + 1;
|
||||
repositoryStatusRequestId = requestId;
|
||||
|
||||
const visibleDashboards = paginatedDashboards;
|
||||
const visibleIds = visibleDashboards.map((dashboard) => dashboard.id);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const loadingStatuses = { ...repositoryStatusByDashboardId };
|
||||
missingIds.forEach((dashboardId) => {
|
||||
loadingStatuses[dashboardId] = "loading";
|
||||
});
|
||||
repositoryStatusByDashboardId = loadingStatuses;
|
||||
|
||||
let entries: Array<readonly [number, string]> = [];
|
||||
try {
|
||||
const batchResult = await gitService.getStatusesBatch(
|
||||
missingIds,
|
||||
);
|
||||
const statusesByDashboardId = batchResult?.statuses || {};
|
||||
entries = missingIds.map((dashboardId) => {
|
||||
const status =
|
||||
statusesByDashboardId[dashboardId] ??
|
||||
statusesByDashboardId[String(dashboardId)];
|
||||
if (!status) return [dashboardId, "error"] as const;
|
||||
return [dashboardId, resolveRepositoryStatusToken(status)] as const;
|
||||
});
|
||||
} catch (error) {
|
||||
entries = missingIds.map((dashboardId) => [dashboardId, "error"] as const);
|
||||
}
|
||||
|
||||
if (requestId !== repositoryStatusRequestId) return;
|
||||
repositoryStatusByDashboardId = {
|
||||
...repositoryStatusByDashboardId,
|
||||
...Object.fromEntries(entries),
|
||||
};
|
||||
}
|
||||
// [/DEF:loadRepositoryStatuses:Function]
|
||||
|
||||
// [DEF:runBulkGitAction:Function]
|
||||
// @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;
|
||||
const selectedDashboardIds = selectedIds.filter((dashboardId) =>
|
||||
isRepositoryReady(dashboardId),
|
||||
);
|
||||
|
||||
if (selectedDashboardIds.length === 0) {
|
||||
addToast($t.git?.no_repositories_selected, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
bulkActionRunning = true;
|
||||
const concurrency = 3;
|
||||
const idsQueue = [...selectedDashboardIds];
|
||||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
const worker = async () => {
|
||||
while (idsQueue.length > 0) {
|
||||
const dashboardId = idsQueue.shift();
|
||||
if (dashboardId === undefined) break;
|
||||
try {
|
||||
await action(dashboardId);
|
||||
successCount += 1;
|
||||
} catch (_error) {
|
||||
failedCount += 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(concurrency, selectedDashboardIds.length) }, () => worker()),
|
||||
);
|
||||
invalidateRepositoryStatuses(selectedDashboardIds);
|
||||
const actionLabel = $t.git?.[`bulk_action_${actionToken}`];
|
||||
addToast(
|
||||
$t.git?.bulk_result
|
||||
.replace("{action}", actionLabel)
|
||||
.replace("{success}", String(successCount))
|
||||
.replace("{failed}", String(failedCount)),
|
||||
failedCount > 0 ? "warning" : "success",
|
||||
);
|
||||
} finally {
|
||||
bulkActionRunning = false;
|
||||
}
|
||||
}
|
||||
// [/DEF:runBulkGitAction:Function]
|
||||
|
||||
// [DEF:handleBulkSync:Function]
|
||||
async function handleBulkSync(): Promise<void> {
|
||||
await runBulkGitAction("sync", (dashboardId) => gitService.sync(dashboardId));
|
||||
}
|
||||
// [/DEF:handleBulkSync:Function]
|
||||
|
||||
// [DEF:handleBulkCommit:Function]
|
||||
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(), []),
|
||||
);
|
||||
}
|
||||
// [/DEF:handleBulkCommit:Function]
|
||||
|
||||
// [DEF:handleBulkPull:Function]
|
||||
async function handleBulkPull(): Promise<void> {
|
||||
await runBulkGitAction("pull", (dashboardId) => gitService.pull(dashboardId));
|
||||
}
|
||||
// [/DEF:handleBulkPull:Function]
|
||||
|
||||
// [DEF:handleBulkPush:Function]
|
||||
async function handleBulkPush(): Promise<void> {
|
||||
await runBulkGitAction("push", (dashboardId) => gitService.push(dashboardId));
|
||||
}
|
||||
// [/DEF:handleBulkPush:Function]
|
||||
|
||||
// [DEF:handleManageSelected:Function]
|
||||
// @PURPOSE: Opens Git manager for exactly one selected dashboard.
|
||||
async function handleManageSelected(): 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,
|
||||
);
|
||||
|
||||
gitDashboardId = selectedDashboardId;
|
||||
gitDashboardTitle = selectedDashboard?.title || "";
|
||||
showGitManager = true;
|
||||
}
|
||||
// [/DEF:handleManageSelected:Function]
|
||||
|
||||
// [DEF:getSortStatusValue:Function]
|
||||
/**
|
||||
* @purpose Returns sort value for status column based on mode.
|
||||
*/
|
||||
function getSortStatusValue(dashboard: DashboardMetadata): string {
|
||||
if (statusMode === "repository") {
|
||||
return getRepositoryStatusToken(dashboard.id);
|
||||
}
|
||||
return String(dashboard.status || "").toLowerCase();
|
||||
}
|
||||
// [/DEF:getSortStatusValue:Function]
|
||||
|
||||
// [DEF:getStatusLabel:Function]
|
||||
/**
|
||||
* @purpose Returns localized label for status column.
|
||||
*/
|
||||
function getStatusLabel(dashboard: DashboardMetadata): string {
|
||||
if (statusMode !== "repository") {
|
||||
return String(dashboard.status || "");
|
||||
}
|
||||
const token = getRepositoryStatusToken(dashboard.id);
|
||||
return $t.git?.repo_status?.[token] || token;
|
||||
}
|
||||
// [/DEF:getStatusLabel:Function]
|
||||
|
||||
// [DEF:getStatusBadgeClass:Function]
|
||||
/**
|
||||
* @purpose Returns badge style for status column.
|
||||
*/
|
||||
function getStatusBadgeClass(dashboard: DashboardMetadata): string {
|
||||
if (statusMode !== "repository") {
|
||||
return dashboard.status === "published"
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-gray-100 text-gray-800";
|
||||
}
|
||||
|
||||
const token = getRepositoryStatusToken(dashboard.id);
|
||||
if (token === "loading") return "bg-slate-100 text-slate-500";
|
||||
if (token === "no_repo") return "bg-gray-100 text-gray-800";
|
||||
if (token === "synced") return "bg-green-100 text-green-800";
|
||||
if (token === "changes") return "bg-amber-100 text-amber-800";
|
||||
if (token === "behind_remote") return "bg-blue-100 text-blue-800";
|
||||
if (token === "ahead_remote") return "bg-indigo-100 text-indigo-800";
|
||||
if (token === "diverged") return "bg-purple-100 text-purple-800";
|
||||
return "bg-rose-100 text-rose-800";
|
||||
}
|
||||
// [/DEF:getStatusBadgeClass:Function]
|
||||
|
||||
$effect(() => {
|
||||
dashboards;
|
||||
statusMode;
|
||||
currentPage;
|
||||
pageSize;
|
||||
filterText;
|
||||
sortColumn;
|
||||
sortDirection;
|
||||
untrack(() => {
|
||||
void loadRepositoryStatuses();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- [SECTION: TEMPLATE] -->
|
||||
<div class="dashboard-grid">
|
||||
<!-- Filter Input -->
|
||||
<div class="mb-6">
|
||||
<Input bind:value={filterText} placeholder={$t.dashboard.search} />
|
||||
</div>
|
||||
|
||||
{#if selectedIds.length > 0}
|
||||
<div class="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-blue-100 bg-blue-50/60 px-3 py-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onclick={handleManageSelected}
|
||||
disabled={bulkActionRunning || selectedIds.length !== 1}
|
||||
class="border-blue-200 bg-white text-blue-700 hover:bg-blue-50 disabled:opacity-40"
|
||||
>
|
||||
{$t.git?.manage_selected}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onclick={handleBulkSync} disabled={bulkActionRunning} class="border-blue-200 bg-white text-blue-700 hover:bg-blue-50">
|
||||
{$t.git?.bulk_sync}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onclick={handleBulkCommit} disabled={bulkActionRunning} class="border-amber-200 bg-white text-amber-700 hover:bg-amber-50">
|
||||
{$t.git?.bulk_commit}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onclick={handleBulkPull} disabled={bulkActionRunning} class="border-cyan-200 bg-white text-cyan-700 hover:bg-cyan-50">
|
||||
{$t.git?.bulk_pull}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onclick={handleBulkPush} disabled={bulkActionRunning} class="border-indigo-200 bg-white text-indigo-700 hover:bg-indigo-50">
|
||||
{$t.git?.bulk_push}
|
||||
</Button>
|
||||
<span class="ml-1 text-xs font-medium text-slate-600">
|
||||
{$t.git?.selected_count.replace(
|
||||
"{count}",
|
||||
String(selectedIds.length),
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Grid/Table -->
|
||||
<div class="overflow-x-auto rounded-lg border border-gray-200">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<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-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700 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-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700 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-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700 transition-colors"
|
||||
onclick={() => handleSort("status")}
|
||||
>
|
||||
{$t.dashboard.status}
|
||||
{sortColumn === "status"
|
||||
? sortDirection === "asc"
|
||||
? "↑"
|
||||
: "↓"
|
||||
: ""}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{#each paginatedDashboards as dashboard (dashboard.id)}
|
||||
<tr class="hover:bg-gray-50 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-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900"
|
||||
>{dashboard.title}</td
|
||||
>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"
|
||||
>{new Date(dashboard.last_modified).toLocaleDateString()}</td
|
||||
>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<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-gray-500">
|
||||
{($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>
|
||||
|
||||
{#if showGitManager && gitDashboardId}
|
||||
<GitManager
|
||||
dashboardId={gitDashboardId}
|
||||
dashboardTitle={gitDashboardTitle}
|
||||
bind:show={showGitManager}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- [/SECTION] -->
|
||||
|
||||
<!-- [/DEF:DashboardGrid:Component] -->
|
||||
@@ -157,17 +157,56 @@
|
||||
}
|
||||
// [/DEF:handlePull:Function]
|
||||
|
||||
// [DEF:closeModal:Function]
|
||||
/**
|
||||
* @purpose Закрывает модальное окно управления Git.
|
||||
* @post show=false.
|
||||
*/
|
||||
function closeModal() {
|
||||
show = false;
|
||||
}
|
||||
// [/DEF:closeModal:Function]
|
||||
|
||||
// [DEF:handleBackdropClick:Function]
|
||||
/**
|
||||
* @purpose Закрывает модалку по клику на подложку.
|
||||
* @pre Событие пришло с оверлея.
|
||||
* @post show=false.
|
||||
*/
|
||||
function handleBackdropClick(event) {
|
||||
if (event.target === event.currentTarget) {
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
// [/DEF:handleBackdropClick:Function]
|
||||
|
||||
onMount(checkStatus);
|
||||
</script>
|
||||
|
||||
<!-- [SECTION: TEMPLATE] -->
|
||||
{#if show}
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white p-6 rounded-lg shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<div
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
||||
onclick={handleBackdropClick}
|
||||
>
|
||||
<div
|
||||
class="relative bg-white p-6 rounded-lg shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto"
|
||||
onclick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={closeModal}
|
||||
class="absolute right-4 top-4 z-10 rounded p-1 text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors"
|
||||
aria-label={$t.common?.close || "Close"}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<PageHeader title={`${$t.git?.management}: ${dashboardTitle}`}>
|
||||
<div slot="subtitle" class="text-sm text-gray-500">{$t.common?.id}: {dashboardId}</div>
|
||||
<div slot="actions">
|
||||
<button onclick={() => show = false} class="text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<button type="button" onclick={closeModal} class="text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
|
||||
Reference in New Issue
Block a user