mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-09-12 13:48:58 +02:00
feat: make the dashboard readable at a glance
The dashboard showed the anonymize date under "Expired", a dash in the
Expires column for 54 of 60 rows, raw status codes such as
branch_not_found, and a chip reading "REMOVED" that actually meant
"removed items are hidden". Muted text sat at 3.5:1 contrast, keyboard
focus was invisible, numbers were left-aligned, and a legacy record
without an identifier rendered as an empty link to /pr/undefined/.
This rewrites the dashboard template and controller and tightens the
theme tokens:
- Status sub-line is labelled ("anonymized on Sep 6, 2026"); Expires
shows Never, the date, or "Expired on <date>".
- Conference folded into the name cell as a tag; Views right-aligned;
every column header is a keyboard-sortable button with aria-sort.
- Sort/Status buttons show their state; chips read "Hiding Removed";
result count and Clear filters; empty state with a way out.
- Status filter covers Ready, In progress, Error, Expired, Removed.
Stuck downloads say so; codes map to sentences; statuses to labels.
- Lists load in parallel and merge once behind a skeleton; broken
records are flagged instead of linking nowhere.
- Actions menu: View, View page, Edit, Force update, divider, Remove,
all as buttons rather than href="#" anchors.
- Theme: warm dark canvas, muted text >= 4.5:1 in both modes, semantic
status tokens, 11/13/14/16px type scale, mono only for identifiers,
6px/10px/pill radii, :focus-visible ring, quota bars in ink until a
limit is near, unlimited shown as such.
- Mobile: no 9px text, filter row wraps in two lines, meta on one line.
Adds scripts/dev-mock.js to serve the dashboard with fixture data and
test/dashboard-ui.test.js covering the filters, template invariants and
token contrast.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
9f00641529
commit
8e9b5b9a45
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"core.min.js": "core.6332b3c288.min.js",
|
||||
"vendor.min.js": "vendor.bc507e5015.min.js",
|
||||
"vendor.min.js": "vendor.6cf1035e61.min.js",
|
||||
"mermaid.min.js": "mermaid.f848a72d16.min.js",
|
||||
"all.min.css": "all.99ce8f3e14.min.css"
|
||||
}
|
||||
"all.min.css": "all.8084ca12ce.min.css"
|
||||
}
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+332
-67
@@ -3,10 +3,11 @@
|
||||
Serif display (Instrument Serif), Inter body, JetBrains Mono. */
|
||||
|
||||
.dark-mode {
|
||||
/* Warm charcoal — softened from pure black */
|
||||
--canvas-bg-color: #1f1f1f;
|
||||
/* Warm ink canvas — same temperature as the light paper palette so
|
||||
cards (#25231F) and borders (#33302B) sit on a matching base. */
|
||||
--canvas-bg-color: #1B1916;
|
||||
--sidebar-bg-color: #1A1918;
|
||||
--main-bg-color: #1f1f1f;
|
||||
--main-bg-color: #1B1916;
|
||||
--header-bg-color: #1A1918;
|
||||
|
||||
--hover-bg-color: rgba(255, 255, 255, 0.05);
|
||||
@@ -29,9 +30,20 @@
|
||||
|
||||
--accent: #A7B2FF;
|
||||
--accent-soft: rgba(167, 178, 255, 0.12);
|
||||
--ink-muted: #8A857C;
|
||||
/* --ink-muted is used for small text: 6.5:1 on the canvas.
|
||||
--ink-faint is for non-text (icons, rules, dots), never for copy. */
|
||||
--ink-muted: #A39D92;
|
||||
--ink-faint: #8A857C;
|
||||
--ink-soft: #B5AFA2;
|
||||
|
||||
/* Semantic status colours (text/dot/border on canvas ≥ 4.5:1) */
|
||||
--status-ready: #5FB27A;
|
||||
--status-progress: #E0A94A;
|
||||
--status-error: #F08A82;
|
||||
--status-inactive: #8A857C;
|
||||
--status-error-soft: rgba(240, 138, 130, 0.08);
|
||||
--status-error-soft-hover: rgba(240, 138, 130, 0.14);
|
||||
|
||||
--admin-nav-bg: #1A1918;
|
||||
--admin-nav-active: rgba(167, 178, 255, 0.12);
|
||||
--admin-stat-bg: #25231F;
|
||||
@@ -70,12 +82,27 @@ body {
|
||||
|
||||
--accent: #3B4AD6;
|
||||
--accent-soft: #EEF0FF;
|
||||
--ink-muted: #8A857C;
|
||||
/* --ink-muted is used for small text: 5.1:1 on the paper canvas.
|
||||
--ink-faint is for non-text (icons, rules, dots), never for copy. */
|
||||
--ink-muted: #6F6A61;
|
||||
--ink-faint: #8A857C;
|
||||
--ink-soft: #4A4641;
|
||||
--paper-bg-alt: #F3F1EC;
|
||||
--paper-card: #FFFFFF;
|
||||
--highlight: #FFF3C4;
|
||||
|
||||
/* Semantic status colours (text/dot/border on canvas ≥ 4.5:1) */
|
||||
--status-ready: #2F7A44;
|
||||
--status-progress: #946512;
|
||||
--status-error: #B42318;
|
||||
--status-inactive: #8A857C;
|
||||
--status-error-soft: rgba(180, 35, 24, 0.04);
|
||||
--status-error-soft-hover: rgba(180, 35, 24, 0.08);
|
||||
|
||||
/* Two-step radius system: controls and surfaces. Chips/badges are pills. */
|
||||
--radius-control: 6px;
|
||||
--radius-surface: 10px;
|
||||
|
||||
--font-serif: 'Instrument Serif', 'Iowan Old Style', Georgia, serif;
|
||||
--font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
@@ -106,19 +133,31 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
/* Keyboard focus: a visible 2px accent ring only when focus came from the
|
||||
keyboard (:focus-visible); pointer clicks get no ring. The ring is ≥ 3:1
|
||||
against both canvases (indigo 6.4:1 light, periwinkle 8.7:1 dark). */
|
||||
.btn:focus,
|
||||
.btn:active {
|
||||
outline: none !important;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
textarea,
|
||||
select,
|
||||
input,
|
||||
button {
|
||||
:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.btn:focus-visible,
|
||||
.form-control:focus-visible,
|
||||
.dropdown-item:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
input:hover,
|
||||
input:active {
|
||||
box-shadow: none !important;
|
||||
@@ -1898,9 +1937,13 @@ code {
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin-left: 2px;
|
||||
/* 24px tap target; negative margin keeps the chip visually compact */
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
margin: -6px -6px -6px 0;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.filter-chip-close:hover {
|
||||
@@ -2025,8 +2068,8 @@ code {
|
||||
.type-badge {
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: 0.06em;
|
||||
padding: 2px 7px;
|
||||
border-radius: 3px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--paper-bg-alt, transparent);
|
||||
}
|
||||
@@ -3898,7 +3941,7 @@ code {
|
||||
|
||||
.paper-crumbs {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
font-size: 11px;
|
||||
color: var(--ink-muted);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
@@ -4161,6 +4204,12 @@ textarea::selection {
|
||||
height: 38px;
|
||||
background: var(--paper-card);
|
||||
}
|
||||
.paper-page .dashboard-filter-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.paper-page .dashboard-filter-row .dropdown-toggle,
|
||||
.paper-page .dashboard-filter-row .btn-group .btn {
|
||||
background: var(--paper-card);
|
||||
@@ -4170,12 +4219,30 @@ textarea::selection {
|
||||
padding: 0 14px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-control, 6px);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
line-height: 1;
|
||||
}
|
||||
/* "Sort · Name ↓" style toggles: the label is muted, the value is ink so the
|
||||
current state reads at a glance. */
|
||||
.paper-page .dashboard-filter-row .dropdown-toggle .filter-btn-label {
|
||||
color: var(--ink-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
.paper-page .dashboard-filter-row .dropdown-toggle .filter-btn-label::after {
|
||||
content: "·";
|
||||
margin-left: 6px;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.paper-page .dashboard-filter-row .dropdown-toggle .filter-btn-value {
|
||||
font-weight: 500;
|
||||
}
|
||||
.paper-page .dashboard-filter-row .dropdown-toggle .fas {
|
||||
font-size: 11px;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.paper-page .dashboard-filter-row .dropdown-toggle:hover,
|
||||
.paper-page .dashboard-filter-row .btn-group .btn:hover {
|
||||
background: var(--hover-bg-color);
|
||||
@@ -4222,20 +4289,42 @@ textarea::selection {
|
||||
}
|
||||
.paper-page .quota-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--ink-soft);
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color);
|
||||
}
|
||||
.paper-page .quota-progress {
|
||||
.paper-page .quota-value .quota-unlimited-tag {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 12px;
|
||||
color: var(--ink-muted);
|
||||
margin-left: 4px;
|
||||
}
|
||||
/* Quota bars: ink by default, colour only when a limit is nearly reached.
|
||||
Unlimited quotas render an empty track (no fill), never a full green bar. */
|
||||
.paper-page .quota-track {
|
||||
height: 6px;
|
||||
background: var(--paper-bg-alt);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 999px;
|
||||
margin-bottom: 0;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.paper-page .quota-progress .progress-bar {
|
||||
.paper-page .quota-track .quota-fill {
|
||||
height: 100%;
|
||||
min-width: 2px;
|
||||
border-radius: 999px;
|
||||
background: var(--color);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.paper-page .quota-track.quota-warn .quota-fill { background: var(--status-progress); }
|
||||
.paper-page .quota-track.quota-danger .quota-fill { background: var(--status-error); }
|
||||
.paper-page .quota-track.quota-unlimited {
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
var(--paper-bg-alt) 0 4px,
|
||||
var(--border-soft, var(--border-color)) 4px 6px
|
||||
);
|
||||
}
|
||||
|
||||
/* Dashboard repo/PR list refinements */
|
||||
@@ -4381,12 +4470,22 @@ textarea::selection {
|
||||
grid-template-columns: 28px minmax(280px, 2.4fr) 90px 120px 100px 52px !important;
|
||||
gap: 12px !important;
|
||||
}
|
||||
/* Dashboard: conference folded into the name cell, numbers right-aligned. */
|
||||
.paper-table.paper-table-dashboard .paper-table-head,
|
||||
.paper-table.paper-table-dashboard .paper-table-row {
|
||||
grid-template-columns: minmax(320px, 3fr) 190px 90px 170px 52px;
|
||||
gap: 20px;
|
||||
/* Two-line rows land at ~60px, close to Carbon's "large" density. */
|
||||
padding: 8px 4px;
|
||||
}
|
||||
.paper-table.paper-table-dashboard .cell-anon .anon-text { gap: 2px; }
|
||||
.paper-table.paper-table-dashboard .cell-anon .repo-name { padding: 0; }
|
||||
.paper-table .paper-table-head {
|
||||
background: transparent;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.14em;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-muted);
|
||||
padding-top: 8px;
|
||||
@@ -4397,13 +4496,25 @@ textarea::selection {
|
||||
user-select: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: 6px;
|
||||
/* Sortable headers are real <button>s for keyboard users. */
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 2px 0;
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
text-transform: inherit;
|
||||
color: inherit;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.paper-table .paper-table-head .sortable:hover { color: var(--color); }
|
||||
.paper-table .paper-table-head .sortable.active { color: var(--color); }
|
||||
.paper-table .paper-table-head .sortable i { font-size: 10px; }
|
||||
/* Quantitative columns are right-aligned so digits line up. */
|
||||
.paper-table .paper-table-head .num,
|
||||
.paper-table .paper-table-row .num { text-align: left; }
|
||||
.paper-table .paper-table-row .num { text-align: right; }
|
||||
.paper-table .paper-table-head .num .sortable { flex-direction: row-reverse; }
|
||||
.paper-table .paper-table-row {
|
||||
border-top: 1px solid var(--border-soft, var(--border-color));
|
||||
font-size: 14px;
|
||||
@@ -4412,6 +4523,8 @@ textarea::selection {
|
||||
.paper-table .paper-table-row:hover {
|
||||
background: var(--hover-bg-color);
|
||||
}
|
||||
/* Rows that open a view on click get a pointer so hover means something. */
|
||||
.paper-table .paper-table-row.row-clickable { cursor: pointer; }
|
||||
/* Dim inactive rows through their cells rather than the row itself. Applying
|
||||
opacity to the row creates a stacking context that traps the actions
|
||||
dropdown menu (z-index) beneath the rows rendered after it, making its
|
||||
@@ -4419,20 +4532,16 @@ textarea::selection {
|
||||
interactive. */
|
||||
.paper-table .paper-table-row.repo-inactive > *:not(.cell-actions) { opacity: 0.55; }
|
||||
.paper-table .paper-table-row.repo-error {
|
||||
background: rgba(180, 35, 24, 0.04);
|
||||
border-left: 2px solid #B42318;
|
||||
background: var(--status-error-soft);
|
||||
border-left: 2px solid var(--status-error);
|
||||
padding-left: 6px;
|
||||
}
|
||||
.paper-table .paper-table-row.repo-error:hover { background: rgba(180, 35, 24, 0.08); }
|
||||
.dark-mode .paper-table .paper-table-row.repo-error { background: rgba(240, 138, 130, 0.05); border-left-color: #F08A82; }
|
||||
.dark-mode .paper-table .paper-table-row.repo-error:hover { background: rgba(240, 138, 130, 0.1); }
|
||||
.paper-table .paper-table-row.repo-error:hover { background: var(--status-error-soft-hover); }
|
||||
.paper-table .cell-status .status-sub-error,
|
||||
.cell-status .status-sub.status-sub-error {
|
||||
color: #B42318;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--status-error);
|
||||
}
|
||||
.dark-mode .paper-table .cell-status .status-sub-error,
|
||||
.dark-mode .cell-status .status-sub.status-sub-error { color: #F08A82; }
|
||||
.paper-table .cell-status .status-sub-warn { color: var(--status-progress); }
|
||||
.paper-table .cell-anon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -4444,8 +4553,16 @@ textarea::selection {
|
||||
min-width: 52px;
|
||||
justify-content: center;
|
||||
}
|
||||
.paper-table .cell-anon .anon-text { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.paper-table .cell-anon .anon-text { min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
||||
.paper-table .cell-anon .anon-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.paper-table .cell-anon .anon-title .type-badge { margin-left: 0; }
|
||||
.paper-table .cell-anon .repo-name {
|
||||
/* Identifiers stay mono; everything human-readable below is sans. */
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
color: var(--color);
|
||||
@@ -4455,45 +4572,174 @@ textarea::selection {
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
padding: 2px 0;
|
||||
}
|
||||
.paper-table .cell-anon .repo-name-static { color: var(--ink-muted); font-style: italic; }
|
||||
.paper-table .cell-anon .anon-sub {
|
||||
font-size: 12px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
color: var(--ink-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
.paper-table .cell-anon .anon-sub .anon-source {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* The conference tag keeps a readable share of the line; the source
|
||||
truncates first since it is repeated in the name and in the tooltip. */
|
||||
.paper-table .cell-anon .anon-sub .conf-tag {
|
||||
flex: 0 1 auto;
|
||||
min-width: 72px;
|
||||
max-width: 50%;
|
||||
}
|
||||
.paper-table .cell-anon .anon-sub a {
|
||||
color: var(--ink-muted);
|
||||
border-bottom: 1px dotted var(--border-color);
|
||||
/* Taller hit area without changing the line box */
|
||||
padding: 4px 0;
|
||||
margin: -4px 0;
|
||||
}
|
||||
.paper-table .cell-anon .anon-sub a { color: var(--ink-muted); border-bottom: 1px dotted var(--border-color); }
|
||||
.paper-table .cell-anon .anon-sub a:hover { color: var(--color); }
|
||||
.paper-table .cell-anon .anon-sub .commit-hash { font-family: var(--font-mono); font-size: 12px; }
|
||||
/* Conference shown as a small tag inside the name cell. `.cell-conf` keeps
|
||||
the clipping rules so a long conference string never bleeds. */
|
||||
.paper-table .cell-conf {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
color: var(--color);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-muted);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.paper-table .cell-conf span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.paper-table .cell-status { display: flex; flex-wrap: wrap; align-items: center; gap: 2px 8px; font-size: 14px; color: var(--color); }
|
||||
.paper-table .cell-status { display: flex; flex-wrap: wrap; align-items: center; gap: 2px 8px; font-size: 14px; color: var(--color); min-width: 0; }
|
||||
.paper-table .cell-status .status-line { display: inline-flex; align-items: center; gap: 8px; }
|
||||
.paper-table .cell-status .status-sub { flex-basis: 100%; font-size: 11px; line-height: 1.2; color: var(--ink-muted); }
|
||||
.paper-table .cell-status .status-sub {
|
||||
flex-basis: 100%;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
color: var(--ink-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.paper-table .cell-views { font-family: var(--font-mono); font-variant-numeric: tabular-nums; color: var(--color); }
|
||||
.paper-table .cell-expires { font-size: 13px; color: var(--ink-soft); }
|
||||
.paper-table .empty-dash { color: var(--ink-muted); opacity: 0.5; }
|
||||
.paper-table .cell-expires { font-family: var(--font-sans); font-size: 13px; color: var(--ink-soft); }
|
||||
.paper-table .cell-expires .expires-never { color: var(--ink-muted); }
|
||||
.paper-table .cell-expires .expires-past { color: var(--ink-muted); }
|
||||
.paper-table .empty-dash { color: var(--ink-faint); }
|
||||
.paper-table .cell-actions { display: flex; justify-content: flex-end; }
|
||||
|
||||
/* Status dots use the semantic tokens so they stay legible in both themes.
|
||||
Old status-* class names are kept as aliases for other templates. */
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
display: inline-block;
|
||||
background: var(--ink-muted);
|
||||
flex-shrink: 0;
|
||||
background: var(--status-inactive);
|
||||
}
|
||||
.status-dot.status-ready { background: #2F7A44; }
|
||||
.status-dot.status-error { background: #B42318; }
|
||||
.status-dot.status-preparing { background: #C48A2E; }
|
||||
.status-dot.status-removed { background: #9A8F7B; }
|
||||
.status-dot.status-ready { background: var(--status-ready); }
|
||||
.status-dot.status-error { background: var(--status-error); }
|
||||
.status-dot.status-progress,
|
||||
.status-dot.status-preparing { background: var(--status-progress); }
|
||||
.status-dot.status-expired,
|
||||
.status-dot.status-removed { background: var(--status-inactive); }
|
||||
|
||||
/* ---- Dashboard extras ---------------------------------------------- */
|
||||
/* Room for the fixed Ko-fi pill so it never covers the last row's actions. */
|
||||
.dashboard-page { padding-bottom: 96px; }
|
||||
|
||||
.dashboard-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 10px;
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.dashboard-meta .dashboard-count { font-variant-numeric: tabular-nums; }
|
||||
.btn-link-inline {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 2px 4px;
|
||||
margin: -2px 0;
|
||||
font: inherit;
|
||||
color: var(--link-color);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.btn-link-inline:hover { color: var(--link-hover-color); text-decoration: underline; }
|
||||
|
||||
.btn.btn-outline-ink {
|
||||
background: transparent;
|
||||
color: var(--color);
|
||||
border: 1px solid var(--border-color);
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-control, 6px);
|
||||
padding: 8px 14px;
|
||||
}
|
||||
.btn.btn-outline-ink:hover { background: var(--hover-bg-color); color: var(--color); border-color: var(--color); }
|
||||
|
||||
.paper-page .dropdown-menu .dropdown-item-danger,
|
||||
.paper-page .dropdown-menu .dropdown-item-danger i { color: var(--status-error); }
|
||||
.paper-page .dropdown-menu .dropdown-item-danger:hover,
|
||||
.paper-page .dropdown-menu .dropdown-item-danger:focus { background: var(--status-error-soft-hover); color: var(--status-error); }
|
||||
.paper-page .dropdown-menu button.dropdown-item { width: 100%; text-align: left; background: none; border: none; }
|
||||
.paper-page .dropdown-menu .dropdown-divider { border-top-color: var(--border-soft, var(--border-color)); margin: 4px 0; }
|
||||
|
||||
/* Loading skeleton */
|
||||
.paper-table .paper-table-skeleton { pointer-events: none; }
|
||||
.skeleton {
|
||||
display: block;
|
||||
background: var(--paper-bg-alt);
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.skeleton::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transform: translateX(-100%);
|
||||
background: linear-gradient(90deg, transparent, var(--hover-bg-color), transparent);
|
||||
animation: skeleton-shimmer 1.4s infinite;
|
||||
}
|
||||
.skeleton-line { height: 12px; }
|
||||
.skeleton-line + .skeleton-line { margin-top: 8px; }
|
||||
.skeleton-line-sm { height: 10px; }
|
||||
.skeleton-badge { width: 52px; height: 22px; border-radius: 999px; flex-shrink: 0; }
|
||||
@keyframes skeleton-shimmer { to { transform: translateX(100%); } }
|
||||
@media (prefers-reduced-motion: reduce) { .skeleton::after { animation: none; } }
|
||||
|
||||
/* Empty state with a way out */
|
||||
.paper-table .paper-table-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 56px 20px;
|
||||
color: var(--ink-muted);
|
||||
text-align: center;
|
||||
border-top: 1px solid var(--border-soft, var(--border-color));
|
||||
}
|
||||
.paper-table .paper-table-empty i { font-size: 1.6rem; color: var(--ink-faint); }
|
||||
.paper-table .paper-table-empty-actions { display: flex; gap: 8px; margin-top: 8px; }
|
||||
|
||||
.bulk-bar {
|
||||
display: flex;
|
||||
@@ -5297,13 +5543,18 @@ textarea::selection {
|
||||
.paper-table .cell-conf,
|
||||
.paper-table .cell-expires {
|
||||
order: 3;
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--ink-muted);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
text-align: left;
|
||||
}
|
||||
/* Status, date, views and expiry sit on one meta line with a common
|
||||
baseline; the sub-text is inline instead of wrapping under the word. */
|
||||
.paper-table .cell-status { flex-wrap: nowrap; gap: 6px; min-width: 0; }
|
||||
.paper-table .cell-status .status-sub { flex-basis: auto; font-size: 13px; line-height: inherit; }
|
||||
.paper-table .cell-status .status-sub::before { content: "·"; margin-right: 6px; color: var(--ink-faint); }
|
||||
.paper-table .cell-conf {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
@@ -5311,6 +5562,9 @@ textarea::selection {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.paper-table .cell-views.num { text-align: left; }
|
||||
.paper-table .paper-table-skeleton .cell-status,
|
||||
.paper-table .paper-table-skeleton .cell-views,
|
||||
.paper-table .paper-table-skeleton .cell-expires { flex: 1 1 30%; }
|
||||
.paper-table .cell-views::before {
|
||||
content: '\f06e';
|
||||
font-family: 'Font Awesome 5 Free';
|
||||
@@ -5332,15 +5586,19 @@ textarea::selection {
|
||||
font-size: 10px;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
/* The dashboard renders its own icon inside the conference tag. */
|
||||
.paper-table-dashboard .cell-conf::before { content: none; }
|
||||
.paper-table .cell-conf:has(.empty-dash),
|
||||
.paper-table .cell-expires:has(.empty-dash) { display: none; }
|
||||
.paper-table .cell-views::before,
|
||||
.paper-table .cell-expires::before { color: var(--ink-faint); }
|
||||
}
|
||||
|
||||
/* Paper-themed dropdown menus (Sort / Status / Actions) */
|
||||
.paper-page .dropdown-menu {
|
||||
background: var(--paper-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-surface, 10px);
|
||||
box-shadow: 0 12px 28px rgba(26, 24, 21, 0.08);
|
||||
padding: 6px;
|
||||
min-width: 220px;
|
||||
@@ -5448,25 +5706,33 @@ textarea::selection {
|
||||
.paper-page .paper-page-lede { font-size: 13px; line-height: 1.4; margin-bottom: 0; }
|
||||
.paper-page .btn-ink { padding: 6px 12px; font-size: 13px; }
|
||||
|
||||
/* Quotas stack in one column on phones so labels and values keep the
|
||||
same 11px / 13px sizes as desktop instead of shrinking to 9px. */
|
||||
.paper-page .quota-row {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px 12px;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
padding: 0;
|
||||
margin: 12px 0 0;
|
||||
margin: 16px 0 0;
|
||||
}
|
||||
.paper-page .dashboard-filter-row {
|
||||
padding: 8px 0;
|
||||
margin-top: 10px;
|
||||
padding: 10px 0;
|
||||
margin-top: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
.paper-page .quota-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
.paper-page .quota-label { font-size: 10px; letter-spacing: 0.1em; }
|
||||
.paper-page .quota-value { font-size: 11px; }
|
||||
.paper-page .quota-value { white-space: nowrap; }
|
||||
|
||||
.paper-page .dashboard-filter-row { gap: 8px; }
|
||||
/* Filter controls: type group fills the row, Sort and Status share one. */
|
||||
.paper-page .dashboard-filter-controls { width: 100%; }
|
||||
.paper-page .dashboard-filter-controls .btn-group { flex: 1 1 100%; display: flex; }
|
||||
.paper-page .dashboard-filter-controls .btn-group .btn { flex: 1; justify-content: center; }
|
||||
.paper-page .dashboard-filter-controls .dropdown { flex: 1 1 0; min-width: 0; }
|
||||
.paper-page .dashboard-filter-controls .dropdown .dropdown-toggle { width: 100%; justify-content: space-between; }
|
||||
.paper-page .dashboard-filter-controls .dropdown .filter-btn-value { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
@@ -5476,9 +5742,6 @@ textarea::selection {
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.paper-page .quota-row { gap: 8px; }
|
||||
.paper-page .quota-label { font-size: 9px; letter-spacing: 0.06em; }
|
||||
.paper-page .quota-value { font-size: 10px; white-space: nowrap; }
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
@@ -5510,10 +5773,12 @@ textarea::selection {
|
||||
font-size: 10px;
|
||||
line-height: 10px;
|
||||
}
|
||||
.paper-page .progress .progress-bar.bg-success { background: #2F7A44; }
|
||||
.paper-page .progress .progress-bar.bg-warning { background: #C48A2E; color: #fff; }
|
||||
.paper-page .progress .progress-bar.bg-danger { background: #B42318; }
|
||||
.paper-page .progress .progress-bar.bg-dark { background: var(--ink-muted); }
|
||||
/* Bootstrap's .bg-* utilities are !important, so the paper palette must be
|
||||
too, otherwise the bright Bootstrap green/yellow/red leak through. */
|
||||
.paper-page .progress .progress-bar.bg-success { background: var(--status-ready) !important; }
|
||||
.paper-page .progress .progress-bar.bg-warning { background: var(--status-progress) !important; color: #fff; }
|
||||
.paper-page .progress .progress-bar.bg-danger { background: var(--status-error) !important; }
|
||||
.paper-page .progress .progress-bar.bg-dark { background: var(--ink-faint) !important; }
|
||||
|
||||
.paper-page > h5,
|
||||
.paper-page form > h5,
|
||||
|
||||
+189
-176
@@ -5,256 +5,269 @@
|
||||
<div class="d-flex align-items-end justify-content-between flex-wrap" style="gap: 12px;">
|
||||
<div>
|
||||
<h1 class="paper-page-title">Your <em>anonymizations</em></h1>
|
||||
<p class="paper-page-lede">Every repository and pull request you’ve mirrored, with live status and stats.</p>
|
||||
<p class="paper-page-lede">Every repository, pull request, and gist you’ve mirrored, with live status and stats.</p>
|
||||
</div>
|
||||
<a href="/anonymize" class="btn btn-ink">
|
||||
<i class="fa fa-plus-circle mr-1" aria-hidden="true"></i> New anonymization
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Quota bars -->
|
||||
<div class="quota-row" ng-show="quota">
|
||||
<div class="quota-item">
|
||||
<!-- Quota -->
|
||||
<div class="quota-row" ng-if="quota">
|
||||
<div class="quota-item" ng-repeat="q in [
|
||||
{key: 'repository', label: 'Repositories', kind: 'count'},
|
||||
{key: 'storage', label: 'Storage', kind: 'bytes'},
|
||||
{key: 'file', label: 'Files', kind: 'count'}
|
||||
]">
|
||||
<div class="quota-header">
|
||||
<span class="quota-label">Repositories</span>
|
||||
<span class="quota-value" ng-show="quota">{{quota.repository.used | number}}/{{quota.repository.total}}</span>
|
||||
<span class="quota-label">{{q.label}}</span>
|
||||
<span class="quota-value" ng-if="q.kind === 'count'">
|
||||
{{quota[q.key].used | number}}<span ng-if="!quota[q.key].unlimited"> / {{quota[q.key].total | number}}</span>
|
||||
<span class="quota-unlimited-tag" ng-if="quota[q.key].unlimited">Unlimited</span>
|
||||
</span>
|
||||
<span class="quota-value" ng-if="q.kind === 'bytes'">
|
||||
{{quota[q.key].used | humanFileSize}}<span ng-if="!quota[q.key].unlimited"> / {{quota[q.key].total | humanFileSize}}</span>
|
||||
<span class="quota-unlimited-tag" ng-if="quota[q.key].unlimited">Unlimited</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="progress quota-progress">
|
||||
<div
|
||||
class="progress-bar"
|
||||
ng-class="{'bg-success': quota.repository.percent < 25 || quota.repository.total == 0, 'bg-danger': quota.repository.percent > 95 && quota.repository.total > 0, 'bg-warning': quota.repository.percent > 75 && quota.repository.total > 0 }"
|
||||
role="progressbar"
|
||||
style="width: {{quota.repository.percent}}%;"
|
||||
aria-valuenow="{{quota.repository.used}}"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="{{quota.repository.total}}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="quota-item">
|
||||
<div class="quota-header">
|
||||
<span class="quota-label">Storage</span>
|
||||
<span class="quota-value" ng-show="quota">{{quota.storage.used | humanFileSize}}/{{quota.storage.total | humanFileSize}}</span>
|
||||
</div>
|
||||
<div class="progress quota-progress">
|
||||
<div
|
||||
class="progress-bar"
|
||||
ng-class="{'bg-success': quota.storage.percent < 25 || quota.storage.total == 0, 'bg-danger': quota.storage.percent > 95 && quota.storage.total > 0, 'bg-warning': quota.storage.percent > 75 && quota.storage.total > 0 }"
|
||||
role="progressbar"
|
||||
style="width: {{quota.storage.percent}}%;"
|
||||
aria-valuenow="{{quota.storage.used}}"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="{{quota.storage.total}}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="quota-item">
|
||||
<div class="quota-header">
|
||||
<span class="quota-label">Files</span>
|
||||
<span class="quota-value" ng-show="quota">{{quota.file.used | number}}/{{quota.file.total || "∞"}}</span>
|
||||
</div>
|
||||
<div class="progress quota-progress">
|
||||
<div
|
||||
class="progress-bar"
|
||||
ng-class="{'bg-success': quota.file.percent < 25 || quota.file.total == 0, 'bg-danger': quota.file.percent > 95 && quota.file.total > 0, 'bg-warning': quota.file.percent > 75 && quota.file.total > 0 }"
|
||||
role="progressbar"
|
||||
style="width: {{quota.file.percent}}%;"
|
||||
aria-valuenow="{{quota.file.used}}"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="{{quota.file.total}}"
|
||||
></div>
|
||||
<div
|
||||
class="quota-track"
|
||||
ng-class="'quota-' + quota[q.key].level"
|
||||
role="progressbar"
|
||||
aria-label="{{q.label}} quota"
|
||||
aria-valuenow="{{quota[q.key].used}}"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="{{quota[q.key].unlimited ? quota[q.key].used : quota[q.key].total}}"
|
||||
aria-valuetext="{{quota[q.key].unlimited ? 'unlimited' : (quota[q.key].percent | number:0) + '% used'}}"
|
||||
>
|
||||
<div class="quota-fill" ng-if="!quota[q.key].unlimited" ng-style="{width: quota[q.key].percent + '%'}"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search + filters row -->
|
||||
<form class="w-100 dashboard-filter-row" aria-label="Dashboard" accept-charset="UTF-8">
|
||||
<!-- Search + filters -->
|
||||
<form class="w-100 dashboard-filter-row" aria-label="Filter anonymizations" accept-charset="UTF-8" ng-submit="$event.preventDefault()">
|
||||
<div class="search-wrap">
|
||||
<input
|
||||
type="search"
|
||||
id="search"
|
||||
class="form-control"
|
||||
aria-label="Search..."
|
||||
placeholder="Search anonymizations…"
|
||||
aria-label="Search anonymizations"
|
||||
placeholder="Search by name, source, or conference…"
|
||||
autocomplete="off"
|
||||
ng-model="search"
|
||||
/>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap" style="gap: 8px">
|
||||
<!-- Type filter -->
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button type="button" class="btn" ng-class="{'btn-primary': typeFilter === 'all'}" ng-click="typeFilter = 'all'">All</button>
|
||||
<button type="button" class="btn" ng-class="{'btn-primary': typeFilter === 'repo'}" ng-click="typeFilter = 'repo'">Repos</button>
|
||||
<button type="button" class="btn" ng-class="{'btn-primary': typeFilter === 'pr'}" ng-click="typeFilter = 'pr'">PRs</button>
|
||||
<button type="button" class="btn" ng-class="{'btn-primary': typeFilter === 'gist'}" ng-click="typeFilter = 'gist'">Gists</button>
|
||||
</div>
|
||||
<div class="dashboard-filter-controls">
|
||||
<div class="btn-group" role="group" aria-label="Type">
|
||||
<button type="button" class="btn" ng-class="{'btn-primary': typeFilter === 'all'}" aria-pressed="{{typeFilter === 'all'}}" ng-click="typeFilter = 'all'">All</button>
|
||||
<button type="button" class="btn" ng-class="{'btn-primary': typeFilter === 'repo'}" aria-pressed="{{typeFilter === 'repo'}}" ng-click="typeFilter = 'repo'">Repos</button>
|
||||
<button type="button" class="btn" ng-class="{'btn-primary': typeFilter === 'pr'}" aria-pressed="{{typeFilter === 'pr'}}" ng-click="typeFilter = 'pr'">PRs</button>
|
||||
<button type="button" class="btn" ng-class="{'btn-primary': typeFilter === 'gist'}" aria-pressed="{{typeFilter === 'gist'}}" ng-click="typeFilter = 'gist'">Gists</button>
|
||||
</div>
|
||||
|
||||
<div class="dropdown">
|
||||
<button
|
||||
class="btn btn-sm dropdown-toggle"
|
||||
type="button"
|
||||
id="dropdownSort"
|
||||
data-toggle="dropdown"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Sort
|
||||
</button>
|
||||
<div class="dropdown-menu" aria-labelledby="dropdownSort">
|
||||
<h6 class="dropdown-header">Select order</h6>
|
||||
<div class="form-check dropdown-item">
|
||||
<input class="form-check-input" type="radio" name="sort" id="sortFullName" value="_name" ng-model="orderBy" />
|
||||
<label class="form-check-label" for="sortFullName">Name</label>
|
||||
</div>
|
||||
<div class="form-check dropdown-item">
|
||||
<input class="form-check-input" type="radio" name="sort" id="sortAnonymizeDate" value="-anonymizeDate" ng-model="orderBy" />
|
||||
<label class="form-check-label" for="sortAnonymizeDate">Anonymize Date</label>
|
||||
</div>
|
||||
<div class="form-check dropdown-item">
|
||||
<input class="form-check-input" type="radio" name="sort" id="sortStatus" value="-status" ng-model="orderBy" />
|
||||
<label class="form-check-label" for="sortStatus">Status</label>
|
||||
</div>
|
||||
<div class="form-check dropdown-item">
|
||||
<input class="form-check-input" type="radio" name="sort" id="sortLastView" value="-lastView" ng-model="orderBy" />
|
||||
<label class="form-check-label" for="sortLastView">Last View</label>
|
||||
</div>
|
||||
<div class="form-check dropdown-item">
|
||||
<input class="form-check-input" type="radio" name="sort" id="sortPageView" value="-pageView" ng-model="orderBy" />
|
||||
<label class="form-check-label" for="sortPageView">Page View</label>
|
||||
</div>
|
||||
<div class="dropdown">
|
||||
<button
|
||||
class="btn dropdown-toggle"
|
||||
type="button"
|
||||
id="dropdownSort"
|
||||
data-toggle="dropdown"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<span class="filter-btn-label">Sort</span>
|
||||
<span class="filter-btn-value">{{sortLabel()}}</span>
|
||||
<i class="fas" ng-class="sortDesc() ? 'fa-arrow-down' : 'fa-arrow-up'" aria-hidden="true"></i>
|
||||
<span class="sr-only">{{sortDesc() ? 'descending' : 'ascending'}}</span>
|
||||
</button>
|
||||
<div class="dropdown-menu" aria-labelledby="dropdownSort">
|
||||
<h6 class="dropdown-header">Order by</h6>
|
||||
<div class="form-check dropdown-item" ng-repeat="(field, def) in sortFields">
|
||||
<input class="form-check-input" type="radio" name="sort" id="sort-{{$index}}" ng-checked="isSortedBy(field)" ng-click="setSort(field, def.defaultDesc)" />
|
||||
<label class="form-check-label" for="sort-{{$index}}">{{def.label}}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dropdown">
|
||||
<button
|
||||
class="btn btn-sm dropdown-toggle"
|
||||
type="button"
|
||||
id="dropdownStatus"
|
||||
data-toggle="dropdown"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Status
|
||||
<div class="dropdown-divider"></div>
|
||||
<button type="button" class="dropdown-item" ng-click="toggleSortDirection()">
|
||||
<i class="fas fa-exchange-alt fa-rotate-90" aria-hidden="true"></i>
|
||||
{{sortDesc() ? 'Switch to ascending' : 'Switch to descending'}}
|
||||
</button>
|
||||
<div class="dropdown-menu" aria-labelledby="dropdownStatus">
|
||||
<h6 class="dropdown-header">Select status</h6>
|
||||
<div class="form-check dropdown-item">
|
||||
<input class="form-check-input" type="checkbox" id="statusReady" value="ready" ng-model="filters.status.ready" />
|
||||
<label class="form-check-label" for="statusReady">Ready</label>
|
||||
</div>
|
||||
<div class="form-check dropdown-item">
|
||||
<input class="form-check-input" type="checkbox" id="statusExpired" value="expired" ng-model="filters.status.expired" />
|
||||
<label class="form-check-label" for="statusExpired">Expired</label>
|
||||
</div>
|
||||
<div class="form-check dropdown-item">
|
||||
<input class="form-check-input" type="checkbox" id="statusRemoved" value="removed" ng-model="filters.status.removed" />
|
||||
<label class="form-check-label" for="statusRemoved">Removed</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dropdown">
|
||||
<button
|
||||
class="btn dropdown-toggle"
|
||||
type="button"
|
||||
id="dropdownStatus"
|
||||
data-toggle="dropdown"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<span class="filter-btn-label">Status</span>
|
||||
<span class="filter-btn-value" ng-if="!hasHiddenStatus()">All</span>
|
||||
<span class="filter-btn-value" ng-if="hasHiddenStatus()">{{hiddenStatusCount()}} hidden</span>
|
||||
</button>
|
||||
<div class="dropdown-menu" aria-labelledby="dropdownStatus">
|
||||
<h6 class="dropdown-header">Show statuses</h6>
|
||||
<div class="form-check dropdown-item" ng-repeat="(key, label) in statusKeyLabels">
|
||||
<input class="form-check-input" type="checkbox" id="status-{{key}}" ng-model="filters.status[key]" />
|
||||
<label class="form-check-label" for="status-{{key}}">{{label}}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Active filter chips -->
|
||||
<div class="d-flex flex-wrap mt-2" style="gap: 6px" ng-show="filters.status.ready === false || filters.status.expired === false || filters.status.removed === false">
|
||||
<span class="filter-chip" ng-show="!v" ng-repeat="(f, v) in filters.status">
|
||||
{{f | title}}
|
||||
<button type="button" class="filter-chip-close" aria-label="Remove filter" ng-click="filters.status[f] = true;">
|
||||
×
|
||||
</button>
|
||||
<!-- Result count + active filter chips -->
|
||||
<div class="dashboard-meta" ng-if="!loading">
|
||||
<span class="dashboard-count">
|
||||
<span ng-if="filteredItems.length === items.length">{{items.length | number}} anonymization<span ng-if="items.length !== 1">s</span></span>
|
||||
<span ng-if="filteredItems.length !== items.length">{{filteredItems.length | number}} of {{items.length | number}} shown</span>
|
||||
</span>
|
||||
<span class="filter-chip" ng-repeat="(f, v) in filters.status" ng-if="v === false">
|
||||
Hiding {{statusKeyLabels[f]}}
|
||||
<button type="button" class="filter-chip-close" aria-label="Show {{statusKeyLabels[f]}} again" ng-click="filters.status[f] = true;">×</button>
|
||||
</span>
|
||||
<button type="button" class="btn-link-inline" ng-if="hasActiveFilters()" ng-click="clearFilters()">Clear filters</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="paper-table w-100" role="table" aria-label="Anonymizations">
|
||||
<div class="paper-table paper-table-dashboard w-100" role="table" aria-label="Anonymizations" aria-busy="{{loading}}">
|
||||
<div class="paper-table-head" role="row">
|
||||
<div role="columnheader">Anonymization</div>
|
||||
<div role="columnheader">Conference</div>
|
||||
<div role="columnheader">Status</div>
|
||||
<div role="columnheader" class="num">Views</div>
|
||||
<div role="columnheader">Expires</div>
|
||||
<div role="columnheader" aria-label="Actions"></div>
|
||||
<div role="columnheader" aria-sort="{{isSortedBy('_name') ? (sortDesc() ? 'descending' : 'ascending') : 'none'}}">
|
||||
<button type="button" class="sortable" ng-class="{active: isSortedBy('_name')}" ng-click="setSort('_name')">
|
||||
Anonymization <i class="fas" ng-if="isSortedBy('_name')" ng-class="sortDesc() ? 'fa-arrow-down' : 'fa-arrow-up'" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div role="columnheader" aria-sort="{{isSortedBy('status') ? (sortDesc() ? 'descending' : 'ascending') : 'none'}}">
|
||||
<button type="button" class="sortable" ng-class="{active: isSortedBy('status')}" ng-click="setSort('status')">
|
||||
Status <i class="fas" ng-if="isSortedBy('status')" ng-class="sortDesc() ? 'fa-arrow-down' : 'fa-arrow-up'" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div role="columnheader" class="num" aria-sort="{{isSortedBy('pageView') ? (sortDesc() ? 'descending' : 'ascending') : 'none'}}">
|
||||
<button type="button" class="sortable" ng-class="{active: isSortedBy('pageView')}" ng-click="setSort('pageView')">
|
||||
Views <i class="fas" ng-if="isSortedBy('pageView')" ng-class="sortDesc() ? 'fa-arrow-down' : 'fa-arrow-up'" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div role="columnheader" aria-sort="{{isSortedBy('options.expirationDate') ? (sortDesc() ? 'descending' : 'ascending') : 'none'}}">
|
||||
<button type="button" class="sortable" ng-class="{active: isSortedBy('options.expirationDate')}" ng-click="setSort('options.expirationDate')">
|
||||
Expires <i class="fas" ng-if="isSortedBy('options.expirationDate')" ng-class="sortDesc() ? 'fa-arrow-down' : 'fa-arrow-up'" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div role="columnheader"><span class="sr-only">Actions</span></div>
|
||||
</div>
|
||||
|
||||
<!-- Loading skeleton: keeps the layout stable while the three lists load -->
|
||||
<div class="paper-table-row paper-table-skeleton" role="row" aria-hidden="true" ng-if="loading" ng-repeat="i in [1, 2, 3, 4]">
|
||||
<div class="cell-anon" role="cell"><span class="skeleton skeleton-badge"></span><div class="anon-text"><span class="skeleton skeleton-line" style="width: 38%"></span><span class="skeleton skeleton-line skeleton-line-sm" style="width: 56%"></span></div></div>
|
||||
<div class="cell-status" role="cell"><span class="skeleton skeleton-line" style="width: 60%"></span></div>
|
||||
<div class="cell-views num" role="cell"><span class="skeleton skeleton-line" style="width: 40%"></span></div>
|
||||
<div class="cell-expires" role="cell"><span class="skeleton skeleton-line" style="width: 55%"></span></div>
|
||||
<div class="cell-actions" role="cell"></div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="paper-table-row"
|
||||
role="row"
|
||||
ng-class="{'repo-inactive': item.status == 'expired' || item.status == 'removed', 'repo-error': item.status == 'error'}"
|
||||
ng-repeat="item in items | filter:itemFilter | orderBy:orderBy as filteredItems"
|
||||
ng-if="!loading"
|
||||
ng-class="{'repo-inactive': item._statusKey == 'expired' || item._statusKey == 'removed', 'repo-error': item.status == 'error', 'row-clickable': !!item._viewUrl}"
|
||||
ng-repeat="item in items | filter:itemFilter | orderBy:orderBy as filteredItems track by (item._type + ':' + (item._id || $index))"
|
||||
ng-click="openItem(item, $event)"
|
||||
>
|
||||
<div class="cell-anon" role="cell">
|
||||
<span class="type-badge" ng-class="{'type-repo': item._type === 'repo', 'type-pr': item._type === 'pr', 'type-gist': item._type === 'gist'}">{{item._type === 'repo' ? 'Repo' : item._type === 'pr' ? 'PR' : 'Gist'}}</span>
|
||||
<span class="type-badge type-coauthor" ng-if="item.role === 'coauthor'" title="You are a co-author on this anonymization">Co-author</span>
|
||||
<div class="anon-text">
|
||||
<a ng-href="{{item._viewUrl}}" class="repo-name" ng-bind="item._name"></a>
|
||||
<div class="anon-sub">
|
||||
<a ng-if="item._type === 'repo'" href="https://github.com/{{item.source.fullName}}/" ng-bind="item.source.fullName"></a><span ng-if="item._type === 'repo' && item.options.update"> · <a href="https://github.com/{{item.source.fullName}}/tree/{{item.source.branch}}" ng-bind="item.source.branch"></a><span ng-if="item.source.commit"> · @<a href="https://github.com/{{item.source.fullName}}/tree/{{item.source.commit}}" ng-bind="item.source.commit.substring(0, 8)"></a></span></span><span ng-if="item._type === 'repo' && !item.options.update"> · @<a href="https://github.com/{{item.source.fullName}}/tree/{{item.source.commit}}" ng-bind="item.source.commit.substring(0, 8)"></a></span>
|
||||
<a ng-if="item._type === 'pr'" href="https://github.com/{{item.source.repositoryFullName}}/pull/{{item.source.pullRequestId}}" ng-bind="item._source"></a>
|
||||
<a ng-if="item._type === 'gist'" href="https://gist.github.com/{{item.source.gistId}}" ng-bind="item._source"></a>
|
||||
<div class="anon-title">
|
||||
<a ng-if="item._viewUrl" ng-href="{{item._viewUrl}}" class="repo-name" ng-bind="item._name"></a>
|
||||
<span ng-if="!item._viewUrl" class="repo-name repo-name-static" ng-bind="item._name"></span>
|
||||
<span class="type-badge type-coauthor" ng-if="item.role === 'coauthor'" title="You are a co-author on this anonymization">Co-author</span>
|
||||
</div>
|
||||
<div class="anon-sub" ng-if="!item._broken || item.conference">
|
||||
<span class="anon-source" ng-if="item._type === 'repo' && item.source.fullName && !item._broken">
|
||||
<a href="https://github.com/{{item.source.fullName}}/" target="_blank" rel="noopener" ng-bind="item.source.fullName"></a><span ng-if="item.options.update && item.source.branch"> · <a href="https://github.com/{{item.source.fullName}}/tree/{{item.source.branch}}" target="_blank" rel="noopener" ng-bind="item.source.branch"></a></span><span ng-if="item.source.commit"> · <a class="commit-hash" href="https://github.com/{{item.source.fullName}}/tree/{{item.source.commit}}" target="_blank" rel="noopener">@{{item.source.commit.substring(0, 8)}}</a></span>
|
||||
</span>
|
||||
<span class="anon-source" ng-if="item._type === 'pr' && item.source.repositoryFullName && !item._broken">
|
||||
<a href="https://github.com/{{item.source.repositoryFullName}}/pull/{{item.source.pullRequestId}}" target="_blank" rel="noopener" ng-bind="item._source"></a>
|
||||
</span>
|
||||
<span class="anon-source" ng-if="item._type === 'gist' && item.source.gistId && !item._broken">
|
||||
<a href="https://gist.github.com/{{item.source.gistId}}" target="_blank" rel="noopener" ng-bind="item._source"></a>
|
||||
</span>
|
||||
<span class="cell-conf conf-tag" ng-if="item.conference" title="{{item.conference}}"><i class="fas fa-university" aria-hidden="true"></i> <span ng-bind="item.conference"></span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cell-conf" role="cell">
|
||||
<span ng-if="item.conference" title="{{item.conference}}" ng-bind="item.conference"></span>
|
||||
<span class="empty-dash" ng-if="!item.conference">—</span>
|
||||
</div>
|
||||
<div class="cell-status" role="cell">
|
||||
<div class="status-line">
|
||||
<span
|
||||
class="status-dot"
|
||||
ng-class="{'status-removed': item.status == 'removed' || item.status == 'expired' || item.status == 'removing' || item.status == 'expiring', 'status-preparing': item.status == 'preparing' || item.status == 'download', 'status-ready': item.status == 'ready', 'status-error': item.status == 'error'}"
|
||||
></span>
|
||||
<span ng-bind="item.status | title"></span>
|
||||
<span class="status-dot" ng-class="'status-' + item._statusKey" aria-hidden="true"></span>
|
||||
<span class="status-word" ng-bind="item.status | statusLabel"></span>
|
||||
</div>
|
||||
<div class="status-sub status-sub-error" ng-if="item.status == 'error' && item.statusMessage" title="{{item.statusMessage}}" ng-bind="item.statusMessage | statusMsg"></div>
|
||||
<div class="status-sub" ng-if="item.status != 'error' && item.anonymizeDate" title="Last anonymized {{item.anonymizeDate | humanTime}}" ng-bind="item.anonymizeDate | humanTime"></div>
|
||||
<div class="status-sub status-sub-warn" ng-if="item._stale" title="Last activity {{item.anonymizeDate || item.lastView | humanTime}}">
|
||||
Last activity {{item.anonymizeDate || item.lastView | humanTime}} · may be stuck
|
||||
</div>
|
||||
<div class="status-sub" ng-if="item.status != 'error' && !item._stale && item.anonymizeDate">
|
||||
anonymized {{item.anonymizeDate | humanTime}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="cell-views num" role="cell" ng-bind="item.pageView | number"></div>
|
||||
<div class="cell-expires" role="cell">
|
||||
<span ng-if="item.options.expirationMode !== 'never' && item.status == 'ready'" ng-bind="item.options.expirationDate | humanTime"></span>
|
||||
<span class="empty-dash" ng-if="!(item.options.expirationMode !== 'never' && item.status == 'ready')">—</span>
|
||||
<span ng-if="item._expiry.kind === 'never'" class="expires-never">Never</span>
|
||||
<span ng-if="item._expiry.kind === 'date'" ng-bind="item._expiry.date | humanTime"></span>
|
||||
<span ng-if="item._expiry.kind === 'expired' && item._expiry.date" class="expires-past">Expired {{item._expiry.date | humanTime}}</span>
|
||||
<span ng-if="item._expiry.kind === 'expired' && !item._expiry.date" class="expires-past">Expired</span>
|
||||
<span class="empty-dash" ng-if="item._expiry.kind === 'none'" aria-label="Not applicable">—</span>
|
||||
</div>
|
||||
<div class="cell-actions" role="cell">
|
||||
<div class="dropdown">
|
||||
<div class="dropdown" ng-if="!item._broken">
|
||||
<button
|
||||
class="btn btn-icon-dots"
|
||||
type="button"
|
||||
data-toggle="dropdown"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
aria-label="Actions"
|
||||
aria-label="Actions for {{item._name}}"
|
||||
>
|
||||
<i class="fas fa-ellipsis-h" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div class="dropdown-menu dropdown-menu-right">
|
||||
<a class="dropdown-item" ng-href="{{item._editUrl}}">
|
||||
<i class="far fa-edit" aria-hidden="true"></i> Edit
|
||||
</a>
|
||||
<a class="dropdown-item" href="#" ng-show="item.status == 'ready' || item.status == 'error'" ng-click="refreshItem(item)">
|
||||
<i class="fas fa-sync"></i> Force update
|
||||
</a>
|
||||
<a class="dropdown-item" href="#" ng-show="item.status == 'removed'" ng-click="refreshItem(item)">
|
||||
<i class="fas fa-check-circle"></i> Enable
|
||||
</a>
|
||||
<a class="dropdown-item" href="#" ng-show="item.status == 'expired'" ng-click="extendItem(item)">
|
||||
<i class="fas fa-calendar-plus"></i> Extend 6 months
|
||||
</a>
|
||||
<a class="dropdown-item" href="#" ng-show="(item.status == 'ready' || item.status == 'expired' || item.status == 'error') && item.role !== 'coauthor'" ng-click="removeItem(item)">
|
||||
<i class="fas fa-trash-alt"></i> Remove
|
||||
</a>
|
||||
<a class="dropdown-item" ng-href="{{item._viewUrl}}">
|
||||
<i class="fa fa-eye" aria-hidden="true"></i> View
|
||||
</a>
|
||||
<a class="dropdown-item" href="/w/{{item.repoId}}/" target="_self" ng-if="item._type === 'repo' && item.options.page && item.status == 'ready'">
|
||||
<i class="fas fa-globe"></i> View Page
|
||||
<i class="fas fa-globe" aria-hidden="true"></i> View page
|
||||
</a>
|
||||
<a class="dropdown-item" ng-href="{{item._editUrl}}">
|
||||
<i class="far fa-edit" aria-hidden="true"></i> Edit
|
||||
</a>
|
||||
<button type="button" class="dropdown-item" ng-if="item.status == 'ready' || item.status == 'error'" ng-click="refreshItem(item)">
|
||||
<i class="fas fa-sync" aria-hidden="true"></i> Force update
|
||||
</button>
|
||||
<button type="button" class="dropdown-item" ng-if="item.status == 'removed'" ng-click="refreshItem(item)">
|
||||
<i class="fas fa-check-circle" aria-hidden="true"></i> Enable
|
||||
</button>
|
||||
<button type="button" class="dropdown-item" ng-if="item.status == 'expired'" ng-click="extendItem(item)">
|
||||
<i class="fas fa-calendar-plus" aria-hidden="true"></i> Extend 6 months
|
||||
</button>
|
||||
<div ng-if="(item.status == 'ready' || item.status == 'expired' || item.status == 'error') && item.role !== 'coauthor'">
|
||||
<div class="dropdown-divider"></div>
|
||||
<button type="button" class="dropdown-item dropdown-item-danger" ng-click="removeItem(item)">
|
||||
<i class="fas fa-trash-alt" aria-hidden="true"></i> Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="paper-table-empty" ng-if="filteredItems.length == 0">
|
||||
<i class="fas fa-inbox"></i>
|
||||
<span>Nothing to display.</span>
|
||||
|
||||
<div class="paper-table-empty" ng-if="!loading && filteredItems.length == 0">
|
||||
<i class="fas fa-inbox" aria-hidden="true"></i>
|
||||
<span ng-if="items.length == 0">You have no anonymizations yet.</span>
|
||||
<span ng-if="items.length > 0">Nothing matches the current filters.</span>
|
||||
<div class="paper-table-empty-actions">
|
||||
<button type="button" class="btn btn-outline-ink" ng-if="hasActiveFilters()" ng-click="clearFilters()">Clear filters</button>
|
||||
<a href="/anonymize" class="btn btn-ink" ng-if="items.length == 0"><i class="fa fa-plus-circle mr-1" aria-hidden="true"></i> New anonymization</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+260
-77
@@ -212,11 +212,19 @@ angular
|
||||
seconds = Math.round((Date.now() - new Date(seconds)) / 1000);
|
||||
var suffix = seconds < 0 ? "from now" : "ago";
|
||||
|
||||
// more than 2 days ago display Date
|
||||
// more than 2 days ago display Date. Spell the month out so the date
|
||||
// is unambiguous regardless of the reader's locale (9/6 vs 6/9).
|
||||
if (Math.abs(seconds) > 2 * 60 * 60 * 24) {
|
||||
const now = new Date();
|
||||
now.setSeconds(now.getSeconds() - seconds);
|
||||
return "on " + now.toLocaleDateString();
|
||||
return (
|
||||
"on " +
|
||||
now.toLocaleDateString(undefined, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
seconds = Math.abs(seconds);
|
||||
@@ -255,7 +263,44 @@ angular
|
||||
return capitalized.join(" ");
|
||||
};
|
||||
})
|
||||
// Human-readable labels for the raw status values stored on repositories,
|
||||
// pull requests and gists. Anything unknown falls back to Title Case.
|
||||
.filter("statusLabel", function () {
|
||||
var labels = {
|
||||
ready: "Ready",
|
||||
error: "Error",
|
||||
expired: "Expired",
|
||||
expiring: "Expiring",
|
||||
removed: "Removed",
|
||||
removing: "Removing",
|
||||
queue: "Queued",
|
||||
download: "Downloading",
|
||||
downloaded: "Downloaded",
|
||||
preparing: "Preparing",
|
||||
anonymizing: "Anonymizing",
|
||||
};
|
||||
return function (status) {
|
||||
if (!status) return "";
|
||||
if (labels[status]) return labels[status];
|
||||
var s = String(status).replace(/[_-]+/g, " ").toLowerCase();
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
};
|
||||
})
|
||||
.filter("statusMsg", function () {
|
||||
// Known machine codes → sentences. Unknown snake_case codes are
|
||||
// converted to a sentence instead of leaking `branch_not_found`.
|
||||
var codes = {
|
||||
branch_not_found: "Branch not found on GitHub",
|
||||
repo_not_found: "Repository not found on GitHub",
|
||||
repository_not_found: "Repository not found on GitHub",
|
||||
repo_not_accessible: "Repository is not accessible with your token",
|
||||
pr_not_found: "Pull request not found on GitHub",
|
||||
gist_not_found: "Gist not found on GitHub",
|
||||
commit_not_found: "Commit not found on GitHub",
|
||||
repo_too_big: "Repository exceeds the size limit",
|
||||
quota_exceeded: "Storage quota exceeded",
|
||||
incomplete_record: "Incomplete record: missing identifier",
|
||||
};
|
||||
return function (msg) {
|
||||
if (!msg) return msg;
|
||||
var m = msg.match(/^rate_limited:(\d+)$/);
|
||||
@@ -266,6 +311,11 @@ angular
|
||||
var sec = remaining % 60;
|
||||
return "Rate limited — retrying in " + (min > 0 ? min + "m " + sec + "s" : sec + "s");
|
||||
}
|
||||
if (codes[msg]) return codes[msg];
|
||||
if (/^[a-z0-9]+(_[a-z0-9]+)+$/.test(msg)) {
|
||||
var s = msg.replace(/_/g, " ");
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
return msg;
|
||||
};
|
||||
})
|
||||
@@ -1327,7 +1377,9 @@ angular
|
||||
"$scope",
|
||||
"$http",
|
||||
"$location",
|
||||
function ($scope, $http, $location) {
|
||||
"$q",
|
||||
"$window",
|
||||
function ($scope, $http, $location, $q, $window) {
|
||||
$scope.$on("$routeChangeStart", function () {
|
||||
$('[data-toggle="tooltip"]').tooltip("dispose");
|
||||
});
|
||||
@@ -1346,11 +1398,35 @@ angular
|
||||
|
||||
$scope.items = [];
|
||||
$scope.search = "";
|
||||
$scope.loading = true;
|
||||
|
||||
// Status buckets used by the Status filter. Raw statuses are mapped
|
||||
// onto these keys so in-progress and error items can be filtered too.
|
||||
$scope.statusKeyLabels = {
|
||||
ready: "Ready",
|
||||
progress: "In progress",
|
||||
error: "Error",
|
||||
expired: "Expired",
|
||||
removed: "Removed",
|
||||
};
|
||||
const inProgress = ["queue", "download", "downloaded", "preparing", "anonymizing"];
|
||||
function statusKey(status) {
|
||||
if (status === "ready" || status === "error") return status;
|
||||
if (status === "expired" || status === "expiring") return "expired";
|
||||
if (status === "removed" || status === "removing") return "removed";
|
||||
if (inProgress.indexOf(status) > -1) return "progress";
|
||||
return "progress";
|
||||
}
|
||||
// An in-progress item whose last activity is older than this is
|
||||
// probably stuck; the row says so instead of showing "Downloading".
|
||||
const STALE_AFTER_MS = 2 * 60 * 60 * 1000;
|
||||
|
||||
const dashboardPrefsKey = "dashboard.filterPrefs";
|
||||
const dashboardPrefDefaults = {
|
||||
typeFilter: "all",
|
||||
filters: { status: { ready: true, expired: true, removed: false } },
|
||||
filters: {
|
||||
status: { ready: true, progress: true, error: true, expired: true, removed: false },
|
||||
},
|
||||
orderBy: "-anonymizeDate",
|
||||
};
|
||||
const savedDashboardPrefs = loadFilterPrefs(dashboardPrefsKey) || {};
|
||||
@@ -1364,6 +1440,37 @@ angular
|
||||
};
|
||||
$scope.orderBy = savedDashboardPrefs.orderBy || dashboardPrefDefaults.orderBy;
|
||||
|
||||
// ---- Sorting -------------------------------------------------------
|
||||
// `orderBy` is kept as the Angular orderBy expression ("-field" for
|
||||
// descending) so saved preferences stay compatible.
|
||||
const sortFields = {
|
||||
_name: { label: "Name", defaultDesc: false },
|
||||
anonymizeDate: { label: "Anonymize date", defaultDesc: true },
|
||||
status: { label: "Status", defaultDesc: false },
|
||||
lastView: { label: "Last view", defaultDesc: true },
|
||||
pageView: { label: "Views", defaultDesc: true },
|
||||
"options.expirationDate": { label: "Expiration", defaultDesc: false },
|
||||
};
|
||||
$scope.sortFields = sortFields;
|
||||
$scope.sortField = () => $scope.orderBy.replace(/^-/, "");
|
||||
$scope.sortDesc = () => $scope.orderBy.charAt(0) === "-";
|
||||
$scope.sortLabel = () => {
|
||||
const f = sortFields[$scope.sortField()];
|
||||
return f ? f.label : "Custom";
|
||||
};
|
||||
$scope.isSortedBy = (field) => $scope.sortField() === field;
|
||||
$scope.setSort = (field, desc) => {
|
||||
if (typeof desc !== "boolean") {
|
||||
desc = $scope.isSortedBy(field)
|
||||
? !$scope.sortDesc()
|
||||
: !!(sortFields[field] && sortFields[field].defaultDesc);
|
||||
}
|
||||
$scope.orderBy = (desc ? "-" : "") + field;
|
||||
};
|
||||
$scope.toggleSortDirection = () => {
|
||||
$scope.setSort($scope.sortField(), !$scope.sortDesc());
|
||||
};
|
||||
|
||||
$scope.$watchGroup(
|
||||
["typeFilter", "orderBy"],
|
||||
() => {
|
||||
@@ -1386,94 +1493,168 @@ angular
|
||||
true
|
||||
);
|
||||
|
||||
// ---- Quota ---------------------------------------------------------
|
||||
// A quota with total 0 is unlimited: no percentage, no fill. Colour is
|
||||
// only introduced once a quota is nearly used up.
|
||||
function decorateQuota(q) {
|
||||
q.unlimited = !q.total;
|
||||
q.percent = q.unlimited ? 0 : Math.min(100, (q.used * 100) / q.total);
|
||||
q.level = q.unlimited
|
||||
? "unlimited"
|
||||
: q.percent >= 95
|
||||
? "danger"
|
||||
: q.percent >= 80
|
||||
? "warn"
|
||||
: "ok";
|
||||
return q;
|
||||
}
|
||||
function getQuota() {
|
||||
$http.get("/api/user/quota").then((res) => {
|
||||
$scope.quota = res.data;
|
||||
$scope.quota.storage.percent = $scope.quota.storage.total
|
||||
? ($scope.quota.storage.used * 100) / $scope.quota.storage.total
|
||||
: 100;
|
||||
$scope.quota.file.percent = $scope.quota.file.total
|
||||
? ($scope.quota.file.used * 100) / $scope.quota.file.total
|
||||
: 100;
|
||||
$scope.quota.repository.percent = $scope.quota.repository.total
|
||||
? ($scope.quota.repository.used * 100) /
|
||||
$scope.quota.repository.total
|
||||
: 100;
|
||||
decorateQuota($scope.quota.storage);
|
||||
decorateQuota($scope.quota.file);
|
||||
decorateQuota($scope.quota.repository);
|
||||
}, console.error);
|
||||
}
|
||||
getQuota();
|
||||
|
||||
let loadedRepos = null;
|
||||
let loadedPRs = null;
|
||||
let loadedGists = null;
|
||||
|
||||
function mergeItems() {
|
||||
$scope.items = (loadedRepos || [])
|
||||
.concat(loadedPRs || [])
|
||||
.concat(loadedGists || []);
|
||||
// ---- Items ---------------------------------------------------------
|
||||
// Fields shared by repositories, pull requests and gists. Records that
|
||||
// lost their identifier (legacy data) are flagged as broken instead of
|
||||
// rendering an empty link to /pr/undefined/.
|
||||
function decorateItem(item, id, name, source, editUrl, viewUrl) {
|
||||
if (!item.pageView) item.pageView = 0;
|
||||
if (!item.lastView) item.lastView = "";
|
||||
item.options = item.options || {};
|
||||
item.options.terms = (item.options.terms || []).filter((f) => f);
|
||||
item._id = id || "";
|
||||
item._source = source;
|
||||
item._broken = !id;
|
||||
item._name = id || source || "(unnamed)";
|
||||
item._editUrl = id ? editUrl : null;
|
||||
item._viewUrl = id ? viewUrl : null;
|
||||
if (item._broken) {
|
||||
item.status = "error";
|
||||
item.statusMessage = "incomplete_record";
|
||||
}
|
||||
item._statusKey = statusKey(item.status);
|
||||
const last = item.anonymizeDate || item.lastView;
|
||||
item._stale =
|
||||
item._statusKey === "progress" &&
|
||||
!!last &&
|
||||
Date.now() - new Date(last).getTime() > STALE_AFTER_MS;
|
||||
// What the Expires column should show.
|
||||
if (item.status === "expired" || item.status === "expiring") {
|
||||
item._expiry = { kind: "expired", date: item.options.expirationDate };
|
||||
} else if (item.status !== "ready") {
|
||||
item._expiry = { kind: "none" };
|
||||
} else if (item.options.expirationMode === "never" || !item.options.expirationDate) {
|
||||
item._expiry = { kind: "never" };
|
||||
} else {
|
||||
item._expiry = { kind: "date", date: item.options.expirationDate };
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
function safeGet(url) {
|
||||
return $http.get(url).then(
|
||||
(res) => res.data || [],
|
||||
(err) => {
|
||||
console.error(err);
|
||||
return [];
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// All three lists load in parallel and are merged once, so the table
|
||||
// does not re-sort three times while it fills in.
|
||||
function loadAll() {
|
||||
loadedRepos = null;
|
||||
loadedPRs = null;
|
||||
loadedGists = null;
|
||||
$http.get("/api/user/anonymized_repositories").then(
|
||||
(res) => {
|
||||
loadedRepos = res.data.map((repo) => {
|
||||
if (!repo.pageView) repo.pageView = 0;
|
||||
if (!repo.lastView) repo.lastView = "";
|
||||
repo.options.terms = repo.options.terms.filter((f) => f);
|
||||
$scope.loading = true;
|
||||
return $q
|
||||
.all([
|
||||
safeGet("/api/user/anonymized_repositories"),
|
||||
safeGet("/api/user/anonymized_pull_requests"),
|
||||
safeGet("/api/user/anonymized_gists"),
|
||||
])
|
||||
.then((results) => {
|
||||
const repos = results[0];
|
||||
const prs = results[1];
|
||||
const gists = results[2];
|
||||
const items = [];
|
||||
repos.forEach((repo) => {
|
||||
repo._type = "repo";
|
||||
repo._id = repo.repoId;
|
||||
repo._name = repo.repoId;
|
||||
repo._source = repo.source.fullName;
|
||||
repo._editUrl = "/anonymize/" + repo.repoId;
|
||||
repo._viewUrl = "/r/" + repo.repoId + "/";
|
||||
return repo;
|
||||
const src = repo.source || {};
|
||||
items.push(
|
||||
decorateItem(
|
||||
repo,
|
||||
repo.repoId,
|
||||
repo.repoId,
|
||||
src.fullName,
|
||||
"/anonymize/" + repo.repoId,
|
||||
"/r/" + repo.repoId + "/"
|
||||
)
|
||||
);
|
||||
});
|
||||
mergeItems();
|
||||
},
|
||||
(err) => { console.error(err); }
|
||||
);
|
||||
$http.get("/api/user/anonymized_pull_requests").then(
|
||||
(res2) => {
|
||||
loadedPRs = res2.data.map((pr) => {
|
||||
if (!pr.pageView) pr.pageView = 0;
|
||||
if (!pr.lastView) pr.lastView = "";
|
||||
pr.options.terms = pr.options.terms.filter((f) => f);
|
||||
prs.forEach((pr) => {
|
||||
pr._type = "pr";
|
||||
pr._id = pr.pullRequestId;
|
||||
pr._name = pr.pullRequestId;
|
||||
pr._source = pr.source.repositoryFullName + "#" + pr.source.pullRequestId;
|
||||
pr._editUrl = "/pull-request-anonymize/" + pr.pullRequestId;
|
||||
pr._viewUrl = "/pr/" + pr.pullRequestId + "/";
|
||||
return pr;
|
||||
const src = pr.source || {};
|
||||
items.push(
|
||||
decorateItem(
|
||||
pr,
|
||||
pr.pullRequestId,
|
||||
pr.pullRequestId,
|
||||
src.repositoryFullName + "#" + src.pullRequestId,
|
||||
"/pull-request-anonymize/" + pr.pullRequestId,
|
||||
"/pr/" + pr.pullRequestId + "/"
|
||||
)
|
||||
);
|
||||
});
|
||||
mergeItems();
|
||||
},
|
||||
(err) => { console.error(err); }
|
||||
);
|
||||
$http.get("/api/user/anonymized_gists").then(
|
||||
(res3) => {
|
||||
loadedGists = res3.data.map((g) => {
|
||||
if (!g.pageView) g.pageView = 0;
|
||||
if (!g.lastView) g.lastView = "";
|
||||
g.options.terms = (g.options.terms || []).filter((f) => f);
|
||||
gists.forEach((g) => {
|
||||
g._type = "gist";
|
||||
g._id = g.gistId;
|
||||
g._name = g.gistId;
|
||||
g._source = g.source.gistId;
|
||||
g._editUrl = "/gist-anonymize/" + g.gistId;
|
||||
g._viewUrl = "/gist/" + g.gistId + "/";
|
||||
return g;
|
||||
const src = g.source || {};
|
||||
items.push(
|
||||
decorateItem(
|
||||
g,
|
||||
g.gistId,
|
||||
g.gistId,
|
||||
src.gistId,
|
||||
"/gist-anonymize/" + g.gistId,
|
||||
"/gist/" + g.gistId + "/"
|
||||
)
|
||||
);
|
||||
});
|
||||
mergeItems();
|
||||
},
|
||||
(err) => { console.error(err); }
|
||||
);
|
||||
$scope.items = items;
|
||||
$scope.loading = false;
|
||||
});
|
||||
}
|
||||
loadAll();
|
||||
|
||||
// Whole row opens the anonymized view; clicks on links, buttons and the
|
||||
// actions menu keep their own behaviour.
|
||||
$scope.openItem = (item, $event) => {
|
||||
if (!item._viewUrl) return;
|
||||
const target = $event && $event.target;
|
||||
if (target && target.closest && target.closest("a, button, .dropdown, input")) return;
|
||||
$window.location.href = item._viewUrl;
|
||||
};
|
||||
|
||||
$scope.hiddenStatusCount = () =>
|
||||
Object.keys($scope.filters.status).filter((k) => $scope.filters.status[k] === false).length;
|
||||
$scope.hasHiddenStatus = () => $scope.hiddenStatusCount() > 0;
|
||||
|
||||
$scope.hasActiveFilters = () =>
|
||||
$scope.typeFilter !== "all" ||
|
||||
$scope.search.trim().length > 0 ||
|
||||
Object.keys($scope.filters.status).some((k) => $scope.filters.status[k] === false);
|
||||
|
||||
$scope.clearFilters = () => {
|
||||
$scope.typeFilter = "all";
|
||||
$scope.search = "";
|
||||
Object.keys($scope.filters.status).forEach((k) => {
|
||||
$scope.filters.status[k] = true;
|
||||
});
|
||||
};
|
||||
|
||||
function waitRepoToBeReady(repoId, callback) {
|
||||
$http.get("/api/repo/" + repoId).then((res) => {
|
||||
for (const item of $scope.items) {
|
||||
@@ -1597,10 +1778,12 @@ angular
|
||||
|
||||
$scope.itemFilter = (item) => {
|
||||
if ($scope.typeFilter !== "all" && item._type !== $scope.typeFilter) return false;
|
||||
if ($scope.filters.status[item.status] == false) return false;
|
||||
if ($scope.search.trim().length == 0) return true;
|
||||
if (item._source && item._source.indexOf($scope.search) > -1) return true;
|
||||
if (item._id.indexOf($scope.search) > -1) return true;
|
||||
if ($scope.filters.status[item._statusKey] === false) return false;
|
||||
const needle = $scope.search.trim().toLowerCase();
|
||||
if (needle.length == 0) return true;
|
||||
if (item._source && String(item._source).toLowerCase().indexOf(needle) > -1) return true;
|
||||
if (item._id && String(item._id).toLowerCase().indexOf(needle) > -1) return true;
|
||||
if (item.conference && String(item.conference).toLowerCase().indexOf(needle) > -1) return true;
|
||||
return false;
|
||||
};
|
||||
},
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Mock server for local UI work on authenticated pages (dashboard, etc.).
|
||||
*
|
||||
* Serves the local `public/` folder exactly like scripts/dev-proxy.js, but
|
||||
* instead of proxying the API to the live site it answers the user-facing
|
||||
* endpoints with fixture data. No database, no GitHub login required.
|
||||
*
|
||||
* node scripts/dev-mock.js # default port 4002
|
||||
* PORT=5000 node scripts/dev-mock.js
|
||||
*
|
||||
* The fixtures deliberately include awkward records (expired, error,
|
||||
* stuck download, legacy record without an id, conference tag, unlimited
|
||||
* quota) so the dashboard's edge cases can be checked visually.
|
||||
*/
|
||||
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const express = require("express");
|
||||
|
||||
const PORT = parseInt(process.env.PORT || "4002", 10);
|
||||
const PUBLIC_DIR = path.resolve(__dirname, "..", "public");
|
||||
const manifestPath = path.join(PUBLIC_DIR, "asset-manifest.json");
|
||||
|
||||
function asset(name) {
|
||||
try {
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
|
||||
return manifest[name] || name;
|
||||
} catch {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
function indexHtml() {
|
||||
return fs
|
||||
.readFileSync(path.join(PUBLIC_DIR, "index.html"), "utf-8")
|
||||
.replace("__CORE_JS__", asset("core.min.js"))
|
||||
.replace("__VENDOR_JS__", asset("vendor.min.js"))
|
||||
.replace("__MERMAID_JS__", asset("mermaid.min.js"))
|
||||
.replace("__ALL_CSS__", asset("all.min.css"));
|
||||
}
|
||||
|
||||
// ---- Fixtures --------------------------------------------------------------
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
const ago = (days) => new Date(Date.now() - days * DAY).toISOString();
|
||||
const ahead = (days) => new Date(Date.now() + days * DAY).toISOString();
|
||||
|
||||
const opts = (extra) =>
|
||||
Object.assign(
|
||||
{ terms: [], expirationMode: "never", update: false, page: false },
|
||||
extra || {}
|
||||
);
|
||||
|
||||
const repositories = [
|
||||
{
|
||||
repoId: "anonymous_github-C72C",
|
||||
status: "ready",
|
||||
anonymizeDate: ago(3),
|
||||
lastView: ago(1),
|
||||
pageView: 15,
|
||||
conference: "FSE25",
|
||||
source: { fullName: "tdurieux/anonymous_github", branch: "main", commit: "c40da900ab12" },
|
||||
options: opts({ expirationMode: "redirect", expirationDate: ahead(420), update: true, page: true }),
|
||||
},
|
||||
{
|
||||
repoId: "840c8c57-3c32-451e-bf12-0e20be300389",
|
||||
status: "ready",
|
||||
anonymizeDate: ago(120),
|
||||
lastView: ago(0.2),
|
||||
pageView: 596460,
|
||||
source: { fullName: "tdurieux/anonymous_github", branch: "main", commit: "f817a29a0000" },
|
||||
options: opts({ update: true }),
|
||||
},
|
||||
{
|
||||
repoId: "JarSift",
|
||||
status: "expired",
|
||||
anonymizeDate: ago(560),
|
||||
lastView: ago(30),
|
||||
pageView: 72,
|
||||
conference: "FSE25",
|
||||
source: { fullName: "Cornul11/JarSift", commit: "035adc67ffff" },
|
||||
options: opts({ expirationMode: "remove", expirationDate: ago(9) }),
|
||||
},
|
||||
{
|
||||
repoId: "b4751b8e-6139-4a94-84f8-646106435809",
|
||||
status: "error",
|
||||
statusMessage: "branch_not_found",
|
||||
anonymizeDate: ago(1990),
|
||||
pageView: 0,
|
||||
source: { fullName: "tdurieux/anonymous_github", branch: "master", commit: "a5b0c7c3aaaa" },
|
||||
options: opts({ update: true }),
|
||||
},
|
||||
{
|
||||
repoId: "runtime-repair-experiments-very-long-identifier-2ff4",
|
||||
status: "ready",
|
||||
anonymizeDate: ago(40),
|
||||
pageView: 5,
|
||||
conference: "International Conference on Software Engineering 2026 Artifact Track",
|
||||
source: { fullName: "Spirals-Team/runtime-repair-experiments", branch: "master", commit: "2ff4cdef1234" },
|
||||
options: opts({ expirationMode: "remove", expirationDate: ahead(1) , update: true }),
|
||||
},
|
||||
{
|
||||
repoId: "coauthored-repo-91AB",
|
||||
status: "ready",
|
||||
role: "coauthor",
|
||||
anonymizeDate: ago(12),
|
||||
pageView: 41,
|
||||
source: { fullName: "someone-else/paper-artifact", commit: "b5ab2e21dddd" },
|
||||
options: opts(),
|
||||
},
|
||||
];
|
||||
|
||||
const pullRequests = [
|
||||
{
|
||||
pullRequestId: "5774",
|
||||
status: "ready",
|
||||
anonymizeDate: ago(2),
|
||||
lastView: ago(2),
|
||||
pageView: 9,
|
||||
source: { repositoryFullName: "vyperlang/vyper", pullRequestId: 3224 },
|
||||
options: opts(),
|
||||
},
|
||||
{
|
||||
pullRequestId: "436B",
|
||||
status: "download",
|
||||
anonymizeDate: ago(126),
|
||||
pageView: 12,
|
||||
source: { repositoryFullName: "lncapital/torq", pullRequestId: 307 },
|
||||
options: opts(),
|
||||
},
|
||||
{
|
||||
pullRequestId: "7CF7",
|
||||
status: "preparing",
|
||||
anonymizeDate: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
|
||||
pageView: 0,
|
||||
source: { repositoryFullName: "Tiledesk/tiledesk-server", pullRequestId: 89 },
|
||||
options: opts(),
|
||||
},
|
||||
{
|
||||
// Legacy record without an identifier or options.
|
||||
status: "ready",
|
||||
pageView: 0,
|
||||
source: { repositoryFullName: "atmoz/sftp", pullRequestId: 357 },
|
||||
},
|
||||
{
|
||||
pullRequestId: "demo-pr",
|
||||
status: "removed",
|
||||
anonymizeDate: ago(1030),
|
||||
pageView: 319,
|
||||
source: { repositoryFullName: "tdurieux/anonymous_github", pullRequestId: 156 },
|
||||
options: opts(),
|
||||
},
|
||||
];
|
||||
|
||||
const gists = [
|
||||
{
|
||||
gistId: "g-4f2a",
|
||||
status: "ready",
|
||||
anonymizeDate: ago(6),
|
||||
pageView: 3,
|
||||
source: { gistId: "a1b2c3d4e5f6a7b8c9d0" },
|
||||
options: opts({ expirationMode: "remove", expirationDate: ahead(90) }),
|
||||
},
|
||||
];
|
||||
|
||||
const quota = {
|
||||
storage: { used: 5.6 * 1024 * 1024, total: 2 * 1024 * 1024 * 1024 },
|
||||
file: { used: 1381, total: 0 },
|
||||
repository: { used: 17, total: 20 },
|
||||
};
|
||||
|
||||
// ---- Server ----------------------------------------------------------------
|
||||
|
||||
const app = express();
|
||||
|
||||
app.get(/^\/(script|css)\/(.+)\.([a-f0-9]{10})\.(min\.\w+|\w+)$/, (req, res, next) => {
|
||||
const filePath = path.join(PUBLIC_DIR, req.params[0], `${req.params[1]}.${req.params[3]}`);
|
||||
if (!fs.existsSync(filePath)) return next();
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.sendFile(filePath);
|
||||
});
|
||||
|
||||
app.get("/api/user", (req, res) => res.json({ username: "tdurieux", photo: "", isAdmin: false }));
|
||||
app.get("/api/options", (req, res) => res.json({}));
|
||||
app.get("/api/message", (req, res) => res.status(404).end());
|
||||
app.get("/api/user/quota", (req, res) => res.json(quota));
|
||||
app.get("/api/user/anonymized_repositories", (req, res) => res.json(repositories));
|
||||
app.get("/api/user/anonymized_pull_requests", (req, res) => res.json(pullRequests));
|
||||
app.get("/api/user/anonymized_gists", (req, res) => res.json(gists));
|
||||
app.get("/api/user/default", (req, res) => res.json({}));
|
||||
app.all("/api/{*rest}", (req, res) => res.status(404).json({ error: "not mocked: " + req.path }));
|
||||
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader("Cache-Control", "no-store, max-age=0");
|
||||
next();
|
||||
});
|
||||
app.use(express.static(PUBLIC_DIR, { etag: false, cacheControl: false }));
|
||||
app.get("/{*rest}", (req, res) => res.type("html").send(indexHtml()));
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`mock ui http://localhost:${PORT}/dashboard`);
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
const { expect } = require("chai");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const vm = require("vm");
|
||||
|
||||
/**
|
||||
* Regression tests for the dashboard redesign (Sept 2026):
|
||||
* - status codes and raw statuses are rendered as sentences,
|
||||
* - dates spell the month so they are unambiguous across locales,
|
||||
* - the template no longer uses href="#" anchors for actions,
|
||||
* - the theme tokens keep muted text above WCAG AA contrast.
|
||||
*/
|
||||
|
||||
const root = path.join(__dirname, "..");
|
||||
const appJs = fs.readFileSync(path.join(root, "public", "script", "app.js"), "utf8");
|
||||
const css = fs.readFileSync(path.join(root, "public", "css", "style.css"), "utf8");
|
||||
const html = fs.readFileSync(path.join(root, "public", "partials", "dashboard.htm"), "utf8");
|
||||
|
||||
// Load app.js with a stub `angular` that records filter factories, so the
|
||||
// filters can be exercised without a browser.
|
||||
function loadFilters() {
|
||||
const filters = {};
|
||||
const chain = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_, prop) {
|
||||
if (prop === "filter") {
|
||||
return (name, factory) => {
|
||||
filters[name] = Array.isArray(factory) ? factory[factory.length - 1] : factory;
|
||||
return chain;
|
||||
};
|
||||
}
|
||||
return () => chain;
|
||||
},
|
||||
}
|
||||
);
|
||||
// jQuery is used at the top level of app.js for a couple of global
|
||||
// listeners; a self-returning proxy absorbs those calls.
|
||||
const jq = new Proxy(function () {}, {
|
||||
get: () => jq,
|
||||
apply: () => jq,
|
||||
});
|
||||
const sandbox = {
|
||||
angular: { module: () => chain, element: () => ({}) },
|
||||
$: jq,
|
||||
jQuery: jq,
|
||||
window: {},
|
||||
document: { addEventListener() {}, querySelector: () => null },
|
||||
localStorage: { getItem: () => null, setItem() {} },
|
||||
navigator: { language: "en-US" },
|
||||
console,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval,
|
||||
clearInterval,
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(appJs, sandbox, { filename: "app.js" });
|
||||
return filters;
|
||||
}
|
||||
|
||||
function contrast(hexA, hexB) {
|
||||
const lum = (hex) => {
|
||||
const c = hex.replace("#", "").match(/../g).map((x) => parseInt(x, 16) / 255);
|
||||
const f = (v) => (v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
|
||||
return 0.2126 * f(c[0]) + 0.7152 * f(c[1]) + 0.0722 * f(c[2]);
|
||||
};
|
||||
const [l1, l2] = [lum(hexA), lum(hexB)];
|
||||
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
||||
}
|
||||
|
||||
function token(block, name) {
|
||||
const m = block.match(new RegExp(`${name}:\\s*(#[0-9a-fA-F]{6})`));
|
||||
expect(m, `token ${name} not found`).to.not.equal(null);
|
||||
return m[1];
|
||||
}
|
||||
|
||||
describe("dashboard UI", function () {
|
||||
let filters;
|
||||
before(function () {
|
||||
filters = loadFilters();
|
||||
});
|
||||
|
||||
describe("statusMsg filter", function () {
|
||||
it("turns known machine codes into sentences", function () {
|
||||
expect(filters.statusMsg()("branch_not_found")).to.equal("Branch not found on GitHub");
|
||||
});
|
||||
it("turns unknown snake_case codes into a sentence", function () {
|
||||
expect(filters.statusMsg()("token_revoked_by_user")).to.equal("Token revoked by user");
|
||||
});
|
||||
it("leaves free text alone", function () {
|
||||
expect(filters.statusMsg()("Something odd happened")).to.equal("Something odd happened");
|
||||
});
|
||||
});
|
||||
|
||||
describe("statusLabel filter", function () {
|
||||
it("labels in-progress statuses as verbs", function () {
|
||||
const f = filters.statusLabel();
|
||||
expect(f("download")).to.equal("Downloading");
|
||||
expect(f("queue")).to.equal("Queued");
|
||||
expect(f("ready")).to.equal("Ready");
|
||||
});
|
||||
it("title-cases unknown statuses", function () {
|
||||
expect(filters.statusLabel()("half_done")).to.equal("Half done");
|
||||
});
|
||||
});
|
||||
|
||||
describe("humanTime filter", function () {
|
||||
it("spells the month for dates older than two days", function () {
|
||||
const out = filters.humanTime()("2024-03-09T12:00:00Z");
|
||||
expect(out).to.match(/^on /);
|
||||
expect(out).to.match(/Mar/);
|
||||
expect(out).to.not.match(/\d+\/\d+\/\d+/);
|
||||
});
|
||||
it("keeps relative wording for recent dates", function () {
|
||||
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
|
||||
expect(filters.humanTime()(twoHoursAgo)).to.equal("2 hours ago");
|
||||
});
|
||||
});
|
||||
|
||||
describe("template", function () {
|
||||
it("uses buttons, not href=\"#\" anchors, for row actions", function () {
|
||||
expect(html).to.not.match(/href="#"/);
|
||||
expect(html).to.match(/<button type="button" class="dropdown-item dropdown-item-danger"/);
|
||||
});
|
||||
it("puts the destructive action after a divider", function () {
|
||||
const divider = html.indexOf('<div class="dropdown-divider"></div>\n <button type="button" class="dropdown-item dropdown-item-danger"');
|
||||
expect(divider).to.be.greaterThan(-1);
|
||||
});
|
||||
it("labels hidden statuses as hidden, not as active filters", function () {
|
||||
expect(html).to.match(/Hiding \{\{statusKeyLabels\[f\]\}\}/);
|
||||
});
|
||||
it("shows an unlimited quota without a full bar", function () {
|
||||
expect(html).to.match(/quota-fill" ng-if="!quota\[q\.key\]\.unlimited"/);
|
||||
expect(html).to.not.match(/bg-success|bg-warning|bg-danger/);
|
||||
});
|
||||
it("right-aligns the Views column and marks it sortable", function () {
|
||||
expect(html).to.match(/class="num"[^>]*>\s*<button type="button" class="sortable"[^>]*ng-click="setSort\('pageView'\)"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("theme tokens", function () {
|
||||
const light = css.slice(css.indexOf("\nbody {"), css.indexOf("--font-serif"));
|
||||
const dark = css.slice(css.indexOf(".dark-mode {"), css.indexOf("\nbody {"));
|
||||
|
||||
it("keeps muted text at or above 4.5:1 in light mode", function () {
|
||||
expect(contrast(token(light, "--ink-muted"), token(light, "--canvas-bg-color"))).to.be.at.least(4.5);
|
||||
});
|
||||
it("keeps muted text at or above 4.5:1 in dark mode", function () {
|
||||
expect(contrast(token(dark, "--ink-muted"), token(dark, "--canvas-bg-color"))).to.be.at.least(4.5);
|
||||
});
|
||||
it("keeps status colours at or above 4.5:1 on both canvases", function () {
|
||||
for (const [block, name] of [[light, "light"], [dark, "dark"]]) {
|
||||
const canvas = token(block, "--canvas-bg-color");
|
||||
for (const t of ["--status-ready", "--status-progress", "--status-error"]) {
|
||||
expect(contrast(token(block, t), canvas), `${t} in ${name}`).to.be.at.least(4.5);
|
||||
}
|
||||
}
|
||||
});
|
||||
it("shows a keyboard focus ring instead of removing outlines", function () {
|
||||
expect(css).to.match(/:focus-visible\s*\{[^}]*outline:\s*2px solid var\(--accent\)/);
|
||||
expect(css).to.not.match(/\.btn:focus,\s*\.btn:active\s*\{[^}]*outline:\s*none/);
|
||||
});
|
||||
it("makes the paper palette win over Bootstrap's !important bg utilities", function () {
|
||||
expect(css).to.match(/\.progress-bar\.bg-success \{ background: var\(--status-ready\) !important; \}/);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user