Merge pull request #809 from tdurieux/feat/dashboard-redesign

feat: make the dashboard, menus and settings readable at a glance
This commit is contained in:
Thomas Durieux
2026-09-08 21:07:30 +02:00
committed by GitHub
20 changed files with 1949 additions and 625 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{
"core.min.js": "core.6332b3c288.min.js",
"vendor.min.js": "vendor.c40cb91530.min.js",
"vendor.min.js": "vendor.81d3dd59f8.min.js",
"mermaid.min.js": "mermaid.f848a72d16.min.js",
"all.min.css": "all.99ce8f3e14.min.css"
"all.min.css": "all.6d950dfd4d.min.css"
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+418 -72
View File
@@ -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);
}
@@ -3372,16 +3415,26 @@ code {
}
/* Paper progress bar */
/* The track is a pseudo-element so the label below it lives in normal flow
instead of overflowing an 8px-high box into the next section. */
.paper-progress {
position: relative;
height: auto;
background: none;
margin: 18px 0 8px;
}
.paper-progress::before {
content: "";
display: block;
height: 8px;
background: var(--paper-bg-alt);
border-radius: 999px;
overflow: visible;
margin: 18px 0 8px;
}
.paper-progress .paper-progress-bar {
height: 100%;
position: absolute;
top: 0;
left: 0;
height: 8px;
background: var(--color);
border-radius: 999px;
transition: width 0.4s ease;
@@ -3898,7 +3951,7 @@ code {
.paper-crumbs {
font-family: var(--font-mono);
font-size: 11.5px;
font-size: 11px;
color: var(--ink-muted);
letter-spacing: 0.02em;
}
@@ -3948,7 +4001,7 @@ code {
.paper-section-eyebrow {
font-family: var(--font-mono);
font-size: 10.5px;
font-size: 11px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--ink-muted);
@@ -4161,6 +4214,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 +4229,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 +4299,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 */
@@ -4324,10 +4423,81 @@ textarea::selection {
.paper-settings-section .form-group { margin-bottom: 18px; }
.paper-settings-section .form-check { padding: 10px 0 10px 28px; border-bottom: 1px solid var(--border-soft, var(--border-color)); }
.paper-settings-section .form-check:last-child { border-bottom: none; }
.paper-settings-section .form-check-label { font-family: var(--font-serif); font-size: 16px; color: var(--color); }
/* Checkbox labels are UI text, so they use the sans face like the rest of
the forms; the serif stays reserved for display headings. */
.paper-settings-section .form-check-label { font-family: var(--font-sans); font-size: 14px; font-weight: 500; color: var(--color); cursor: pointer; }
.paper-settings-section .form-check-input { cursor: pointer; width: 16px; height: 16px; margin-top: 3px; margin-left: -28px; accent-color: var(--color); }
.paper-settings-section .form-text { color: var(--ink-soft); font-size: 13px; margin-top: 4px; max-width: 48em; }
.paper-settings-section .paper-section-copy { margin: -4px 0 14px; }
.paper-settings-footer { padding-top: 24px; display: flex; flex-direction: column; gap: 14px; }
.paper-settings-footer .btn-ink { align-self: flex-start; }
.paper-settings-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 14px; }
.paper-settings-hint { font-size: 13px; color: var(--ink-muted); }
/* Inline alerts in the paper palette (Bootstrap's .alert colours clash) */
.paper-inline-alert {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
border-radius: var(--radius-control, 6px);
border: 1px solid var(--border-color);
background: var(--paper-bg-alt);
font-size: 13px;
color: var(--color);
}
.paper-inline-alert i { margin-top: 2px; color: var(--ink-muted); }
.paper-inline-alert-error { border-color: var(--status-error); background: var(--status-error-soft); }
.paper-inline-alert-error i { color: var(--status-error); }
/* Account card and danger zone on the settings page */
.paper-account-card {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
border: 1px solid var(--border-color);
border-radius: var(--radius-surface, 10px);
background: var(--paper-card);
margin-bottom: 20px;
}
.paper-account-card img { flex-shrink: 0; }
.paper-account-identity { flex: 1 1 auto; min-width: 0; }
.paper-account-name { font-family: var(--font-mono); font-size: 14px; font-weight: 500; color: var(--color); }
.paper-account-meta { font-size: 13px; color: var(--ink-muted); margin-top: 2px; }
.paper-danger-zone {
padding: 16px;
border: 1px solid var(--status-error);
border-radius: var(--radius-surface, 10px);
background: var(--status-error-soft);
}
.paper-danger-head { font-weight: 600; font-size: 14px; color: var(--status-error); margin-bottom: 6px; }
.paper-danger-copy { font-size: 13px; color: var(--ink-soft); max-width: 52em; margin-bottom: 12px; }
.paper-danger-zone .paper-inline-alert { margin-bottom: 12px; }
.btn.btn-outline-danger {
background: transparent;
color: var(--status-error);
border: 1px solid var(--status-error);
font-weight: 500;
border-radius: var(--radius-control, 6px);
padding: 8px 14px;
}
.btn.btn-outline-danger:hover { background: var(--status-error); color: #fff; border-color: var(--status-error); }
/* Navbar user menu: two-line items (label + hint) */
.navbar .user-menu { min-width: 260px; }
.navbar .user-menu .dropdown-item { display: flex; align-items: flex-start; gap: 10px; padding: 8px 10px !important; }
.navbar .user-menu .dropdown-item i { width: 16px; margin-top: 3px; font-size: 13px; color: var(--ink-muted); text-align: center; }
.navbar .user-menu .dropdown-item span { display: flex; flex-direction: column; line-height: 1.3; }
.navbar .user-menu .dropdown-item small { font-size: 12px; color: var(--ink-muted); }
.navbar .user-menu .dropdown-header {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ink-muted);
padding: 8px 10px 6px;
}
.paper-field-label {
font-family: var(--font-mono);
font-size: 11px;
@@ -4381,12 +4551,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 +4577,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 +4604,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 +4613,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 +4634,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 +4653,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 +5624,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 +5643,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 +5667,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 +5787,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 +5823,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 +5854,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,
+5 -5
View File
@@ -15,13 +15,13 @@
type="text"
class="form-control form-control-lg"
ng-model="sourceUrl"
placeholder="https://github.com/owner/repo, https://github.com/owner/repo/pull/42, or https://gist.github.com/owner/abcdef…"
placeholder="https://github.com/owner/repository"
ng-model-options="{ debounce: {default: 1000, blur: 0, click: 0}, updateOn: 'default blur click' }"
ng-change="urlSelected()"
/>
</div>
<small class="form-text" style="color: var(--ink-muted);">
Paste a repository URL to anonymize a repo, a pull-request URL for a PR, or a gist URL for a gist.
Repository, pull request (…/pull/42) and gist (gist.github.com/…) URLs are all accepted.
</small>
</div>
</div>
@@ -90,7 +90,7 @@
</select>
<div class="input-group-append">
<button class="btn" type="button" ng-click="getBranches(true)" title="Refresh" data-toggle="tooltip" data-placement="bottom">
<i class="fa fa-undo"></i>
<i class="fas fa-sync" aria-hidden="true"></i>
</button>
</div>
</div>
@@ -110,8 +110,8 @@
<div class="form-check" ng-show="detectedType">
<input class="form-check-input" type="checkbox" id="update" name="update" ng-model="options.update" />
<label class="form-check-label" for="update">Auto update</label>
<small class="form-text text-muted">Automatically update with the latest changes (hourly max).</small>
<label class="form-check-label" for="update">Auto-update from GitHub</label>
<small class="form-text text-muted">Follow the branch and pull the latest commit automatically, at most once an hour.</small>
</div>
</section>
+11 -11
View File
@@ -1,25 +1,26 @@
<div class="container paper-page">
<div class="paper-crumbs">Reference &nbsp;/&nbsp; <span class="here">Claim</span></div>
<div class="paper-crumbs"><a href="/dashboard">My work</a> &nbsp;/&nbsp; <span class="here">Claim</span></div>
<h1 class="paper-page-title">Claim an <em>anonymization</em></h1>
<p class="paper-page-lede">
Take ownership of an existing anonymized repository so it appears on your
dashboard.
</p>
<div class="paper-meta-rule"></div>
<form class="form needs-validation" name="claimForm" novalidate>
<div class="paper-settings-main claim-form">
<p class="paper-section-copy">Use this when an anonymization was created by a co-author or from another account. You must have access to the GitHub repository it was made from.</p>
<form class="form needs-validation" name="claimForm" novalidate ng-submit="claim()">
<div class="form-group">
<label class="paper-field-label" for="repoUrl">Repository URL</label>
<label class="paper-field-label" for="repoUrl">GitHub repository URL</label>
<input
type="text"
class="form-control"
name="repoUrl"
id="repoUrl"
required
ng-class="{'is-invalid': claimForm.repoUrl.$invalid}"
ng-class="{'is-invalid': claimForm.repoUrl.$invalid && (claimForm.repoUrl.$touched || claimForm.$submitted)}"
ng-model="repoUrl"
/>
<div class="invalid-feedback" ng-show="claimForm.repoUrl.$error.not_found">
The repository is not found.
No anonymization matches this repository and ID. Check both values and that you can access the repository on GitHub.
</div>
</div>
<div class="form-group">
@@ -31,11 +32,10 @@
id="repoId"
required
ng-model="repoId"
ng-class="{'is-invalid': claimForm.repoId.$invalid}"
ng-class="{'is-invalid': claimForm.repoId.$invalid && (claimForm.repoId.$touched || claimForm.$submitted)}"
/>
<small id="idHelp" class="form-text text-muted"
>The id is in the repository
https://anonymous.4open.science/r/{id}</small
>The ID is the last part of the anonymized URL: <code>anonymous.4open.science/r/&lt;ID&gt;</code>.</small
>
</div>
@@ -43,9 +43,9 @@
id="submit"
type="submit"
class="btn btn-ink"
ng-click="claim()"
>
Claim
Claim anonymization
</button>
</form>
</div>
</div>
+74 -50
View File
@@ -1,45 +1,46 @@
<div class="container page paper-page">
<div>
<div class="paper-crumbs">
<a href="/conferences">Conferences</a> &nbsp;/&nbsp; <span class="here">{{conference.conferenceID}}</span>
<a href="/dashboard">My work</a> &nbsp;/&nbsp; <a href="/conferences">Conferences</a> &nbsp;/&nbsp; <span class="here">{{conference.conferenceID}}</span>
</div>
<div class="d-flex align-items-end flex-wrap" style="gap: 12px; justify-content: space-between;">
<div>
<h1 class="paper-page-title">
<a ng-href="{{conference.url}}" style="color: var(--color);">{{conference.name}}</a>
</h1>
<h1 class="paper-page-title" ng-bind="conference.name"></h1>
<p class="paper-page-lede" ng-if="conference.url">
<a ng-href="{{conference.url}}" ng-bind="conference.url"></a>
<a ng-href="{{conference.url}}" target="_blank" rel="noopener" ng-bind="conference.url"></a>
</p>
</div>
<span class="status-dot-wrap">
<span class="status-dot" ng-class="{'status-removed': conference.status == 'removed' || conference.status == 'expired', 'status-ready': conference.status == 'ready'}"></span>
<span ng-bind="conference.status | title"></span>
</span>
<div class="d-flex align-items-center flex-wrap" style="gap: 10px;">
<span class="status-pill" ng-class="{'status-pill-ready': conference.status == 'ready', 'status-pill-removed': conference.status == 'removed' || conference.status == 'expired'}">
<span class="status-dot" ng-class="'status-' + conference.status" aria-hidden="true"></span>
<span ng-bind="conference.status | statusLabel"></span>
</span>
<a class="btn btn-outline-ink" ng-href="/conference/{{conference.conferenceID}}/edit"><i class="far fa-edit mr-1" aria-hidden="true"></i> Edit conference</a>
</div>
</div>
<div class="paper-meta-rule">
<span>ID <b ng-bind="conference.conferenceID"></b></span>
<span>From <b>{{conference.startDate | date}}</b></span>
<span>Ends <b>{{conference.endDate | date}}</b></span>
<span>Repos <b>{{::conference.repositories.length | number}}</b></span>
<span>Price <b>{{conference.price || 0 | number}} &euro;</b></span>
<span>ID <b class="commit-hash" ng-bind="conference.conferenceID"></b></span>
<span>Review window <b>{{conference.startDate | date:'mediumDate'}} &ndash; {{conference.endDate | date:'mediumDate'}}</b></span>
<span>Repositories <b>{{conference.repositories.length | number}}</b></span>
<span>Plan <b ng-bind="conference.plan.name || conference.plan.planID || 'Free'"></b></span>
<span ng-if="conference.price">Cost so far <b>{{conference.price | number:2}} &euro;</b></span>
</div>
<div class="paper-section-eyebrow">Repositories</div>
<form class="w-100 dashboard-filter-row" aria-label="Repositories" accept-charset="UTF-8">
<form class="w-100 dashboard-filter-row" aria-label="Filter repositories" accept-charset="UTF-8" ng-submit="$event.preventDefault()">
<div class="search-wrap">
<input
type="search"
id="search"
class="form-control"
aria-label="Find repositories"
placeholder="Find repositories…"
aria-label="Search repositories"
placeholder="Search by ID or source repository…"
autocomplete="off"
ng-model="search"
/>
</div>
<div class="d-flex flex-wrap" style="gap: 8px">
<div class="dashboard-filter-controls">
<div class="dropdown">
<button
class="btn dropdown-toggle"
@@ -49,13 +50,14 @@
aria-haspopup="true"
aria-expanded="false"
>
Sort
<span class="filter-btn-label">Sort</span>
<span class="filter-btn-value">{{ ({'-anonymizeDate': 'Anonymize date', repoId: 'ID', '-status': 'Status', '-pageView': 'Views'})[orderBy] || 'Custom' }}</span>
</button>
<div class="dropdown-menu" aria-labelledby="dropdownSort">
<h6 class="dropdown-header">Select order</h6>
<h6 class="dropdown-header">Order by</h6>
<div class="form-check dropdown-item">
<input class="form-check-input" type="radio" name="sort" id="anonymizeDate" value="-anonymizeDate" ng-model="orderBy" />
<label class="form-check-label" for="anonymizeDate">Anonymize Date</label>
<label class="form-check-label" for="anonymizeDate">Anonymize date</label>
</div>
<div class="form-check dropdown-item">
<input class="form-check-input" type="radio" name="sort" id="sortID" value="repoId" ng-model="orderBy" />
@@ -65,10 +67,14 @@
<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="sortViews" value="-pageView" ng-model="orderBy" />
<label class="form-check-label" for="sortViews">Views</label>
</div>
</div>
</div>
<div class="dropdown">
<div class="dropdown" ng-init="statusLabels = {ready: 'Ready', expired: 'Expired', removed: 'Removed'}">
<button
class="btn dropdown-toggle"
type="button"
@@ -77,58 +83,72 @@
aria-haspopup="true"
aria-expanded="false"
>
Status
<span class="filter-btn-label">Status</span>
<span class="filter-btn-value" ng-if="filters.status.ready && filters.status.expired && filters.status.removed">All</span>
<span class="filter-btn-value" ng-if="!(filters.status.ready && filters.status.expired && filters.status.removed)">
{{ (filters.status.ready ? 0 : 1) + (filters.status.expired ? 0 : 1) + (filters.status.removed ? 0 : 1) }} hidden
</span>
</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" name="sort" 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" name="sort" 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" name="sort" id="statusRemoved" value="removed" ng-model="filters.status.removed" />
<label class="form-check-label" for="statusRemoved">Removed</label>
<h6 class="dropdown-header">Show statuses</h6>
<div class="form-check dropdown-item" ng-repeat="(key, label) in statusLabels">
<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>
<div class="dashboard-meta">
<span class="dashboard-count">
<span ng-if="filteredRepositories.length === conference.repositories.length">{{conference.repositories.length | number}} repositor<span ng-if="conference.repositories.length === 1">y</span><span ng-if="conference.repositories.length !== 1">ies</span></span>
<span ng-if="filteredRepositories.length !== conference.repositories.length">{{filteredRepositories.length | number}} of {{conference.repositories.length | number}} shown</span>
</span>
<span class="filter-chip" ng-repeat="(f, v) in filters.status" ng-if="v === false">
Hiding {{statusLabels[f]}}
<button type="button" class="filter-chip-close" aria-label="Show {{statusLabels[f]}} again" ng-click="filters.status[f] = true;">&times;</button>
</span>
</div>
<div class="paper-table paper-table-repos w-100" role="table" aria-label="Repositories">
<div class="paper-table-head" role="row">
<div role="columnheader">Repository</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"><span class="sr-only">Actions</span></div>
</div>
<div
class="paper-table-row"
role="row"
ng-class="{'repo-inactive': repo.status == 'expired' || repo.status == 'removed'}"
ng-repeat="repo in conference.repositories| filter:repoFiler| orderBy:orderBy as filteredRepositories"
ng-class="{'repo-inactive': repo.status == 'expired' || repo.status == 'removed', 'repo-error': repo.status == 'error'}"
ng-repeat="repo in conference.repositories | filter:repoFiler | orderBy:orderBy as filteredRepositories track by repo.repoId"
>
<div class="cell-anon" role="cell">
<span class="type-badge type-repo">Repo</span>
<div class="anon-text">
<a class="repo-name" ng-href="/r/{{repo.repoId}}" ng-bind="repo.repoId"></a>
<a class="repo-name" ng-href="/r/{{repo.repoId}}/" ng-bind="repo.repoId"></a>
<div class="anon-sub">
<a href="https://github.com/{{repo.source.fullName}}/" ng-bind="repo.source.fullName"></a><span ng-if="repo.options.update">&nbsp;&middot;&nbsp;<a href="https://github.com/{{repo.source.fullName}}/tree/{{repo.source.branch}}" ng-bind="repo.source.branch"></a></span><span ng-if="!repo.options.update">&nbsp;&middot;&nbsp;@<a href="https://github.com/{{repo.source.fullName}}/tree/{{repo.source.commit}}" ng-bind="repo.source.commit.substring(0, 8)"></a></span>
<span class="anon-source">
<a href="https://github.com/{{repo.source.fullName}}/" target="_blank" rel="noopener" ng-bind="repo.source.fullName"></a><span ng-if="repo.options.update && repo.source.branch"> &middot; <a href="https://github.com/{{repo.source.fullName}}/tree/{{repo.source.branch}}" target="_blank" rel="noopener" ng-bind="repo.source.branch"></a></span><span ng-if="repo.source.commit"> &middot; <a class="commit-hash" href="https://github.com/{{repo.source.fullName}}/tree/{{repo.source.commit}}" target="_blank" rel="noopener">@{{repo.source.commit.substring(0, 8)}}</a></span>
</span>
</div>
</div>
</div>
<div class="cell-status" role="cell">
<span class="status-dot" ng-class="{'status-removed': repo.status == 'removed' || repo.status == 'expired', 'status-ready': repo.status == 'ready', 'status-error': repo.status == 'error'}"></span>
<span ng-bind="repo.status | title"></span>
<div class="status-line">
<span class="status-dot" ng-class="'status-' + (repo.status == 'error' ? 'error' : repo.status == 'ready' ? 'ready' : (repo.status == 'expired' || repo.status == 'removed') ? repo.status : 'progress')" aria-hidden="true"></span>
<span class="status-word" ng-bind="repo.status | statusLabel"></span>
</div>
<div class="status-sub" ng-if="repo.anonymizeDate">anonymized {{repo.anonymizeDate | humanTime}}</div>
</div>
<div class="cell-views num" role="cell" ng-bind="::repo.pageView | number"></div>
<div class="cell-expires" role="cell">
<span ng-if="repo.options.expirationMode !== 'never' && repo.status == 'ready'" ng-bind="repo.options.expirationDate | humanTime"></span>
<span class="empty-dash" ng-if="!(repo.options.expirationMode !== 'never' && repo.status == 'ready')">&mdash;</span>
<span class="expires-never" ng-if="repo.status == 'ready' && (repo.options.expirationMode === 'never' || !repo.options.expirationDate)">Never</span>
<span ng-if="repo.status == 'ready' && repo.options.expirationMode !== 'never' && repo.options.expirationDate" ng-bind="repo.options.expirationDate | humanTime"></span>
<span class="expires-past" ng-if="repo.status == 'expired'">Expired<span ng-if="repo.options.expirationDate"> {{repo.options.expirationDate | humanTime}}</span></span>
<span class="empty-dash" ng-if="repo.status != 'ready' && repo.status != 'expired'" aria-label="Not applicable">&mdash;</span>
</div>
<div class="cell-actions" role="cell">
<div class="dropdown">
@@ -138,24 +158,28 @@
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
aria-label="Actions"
aria-label="Actions for {{repo.repoId}}"
>
<i class="fas fa-ellipsis-h" aria-hidden="true"></i>
</button>
<div class="dropdown-menu dropdown-menu-right">
<a class="dropdown-item" href="/anonymize/{{repo.repoId}}">
<i class="far fa-edit" aria-hidden="true"></i> Edit
</a>
<a class="dropdown-item" href="/r/{{repo.repoId}}/">
<i class="fa fa-eye" aria-hidden="true"></i> View
</a>
<a class="dropdown-item" href="/anonymize/{{repo.repoId}}">
<i class="far fa-edit" aria-hidden="true"></i> Edit
</a>
</div>
</div>
</div>
</div>
<div class="paper-table-empty" ng-if="filteredRepositories.length == 0">
<i class="fas fa-inbox"></i>
<span>There is no repository to display.</span>
<i class="fas fa-inbox" aria-hidden="true"></i>
<span ng-if="!conference.repositories.length">No repository has been submitted to this conference yet.</span>
<span ng-if="conference.repositories.length">Nothing matches the current filters.</span>
<div class="paper-table-empty-actions" ng-if="conference.repositories.length">
<button type="button" class="btn btn-outline-ink" ng-click="search = ''; filters.status.ready = true; filters.status.expired = true; filters.status.removed = true;">Clear filters</button>
</div>
</div>
</div>
</div>
+60 -43
View File
@@ -1,30 +1,30 @@
<div class="container page paper-page">
<div class="row">
<div class="w-100">
<div class="paper-crumbs">Reference &nbsp;/&nbsp; <span class="here">Conferences</span></div>
<div class="paper-crumbs"><a href="/dashboard">My work</a> &nbsp;/&nbsp; <span class="here">Conferences</span></div>
<div class="d-flex align-items-end justify-content-between flex-wrap" style="gap: 12px;">
<div>
<h1 class="paper-page-title">Your <em>conferences</em></h1>
<p class="paper-page-lede">Group anonymizations by venue; set shared expiry and defaults.</p>
<p class="paper-page-lede">Group anonymizations by venue, give chairs a shared dashboard, and set one expiry for every submission.</p>
</div>
<a href="/conference/new" class="btn btn-ink">
<i class="fa fa-plus-circle mr-1"></i> New conference
<i class="fa fa-plus-circle mr-1" aria-hidden="true"></i> New conference
</a>
</div>
<form class="w-100 dashboard-filter-row" aria-label="Conferences" accept-charset="UTF-8">
<form class="w-100 dashboard-filter-row" aria-label="Filter conferences" accept-charset="UTF-8" ng-submit="$event.preventDefault()">
<div class="search-wrap">
<input
type="search"
id="search"
class="form-control"
aria-label="Find a conference"
placeholder="Find a conference…"
aria-label="Search conferences"
placeholder="Search by name or ID…"
autocomplete="off"
ng-model="search"
/>
</div>
<div class="d-flex flex-wrap" style="gap: 8px">
<div class="dashboard-filter-controls">
<div class="dropdown">
<button
class="btn dropdown-toggle"
@@ -34,10 +34,11 @@
aria-haspopup="true"
aria-expanded="false"
>
Sort
<span class="filter-btn-label">Sort</span>
<span class="filter-btn-value">{{ ({name: 'Name', conferenceID: 'ID', '-status': 'Status'})[orderBy] || 'Custom' }}</span>
</button>
<div class="dropdown-menu" aria-labelledby="dropdownSort">
<h6 class="dropdown-header">Select order</h6>
<h6 class="dropdown-header">Order by</h6>
<div class="form-check dropdown-item">
<input class="form-check-input" type="radio" name="sort" id="sortName" value="name" ng-model="orderBy" />
<label class="form-check-label" for="sortName">Name</label>
@@ -53,7 +54,7 @@
</div>
</div>
<div class="dropdown">
<div class="dropdown" ng-init="statusLabels = {ready: 'Ready', expired: 'Expired', removed: 'Removed'}">
<button
class="btn dropdown-toggle"
type="button"
@@ -62,26 +63,33 @@
aria-haspopup="true"
aria-expanded="false"
>
Status
<span class="filter-btn-label">Status</span>
<span class="filter-btn-value" ng-if="filters.status.ready && filters.status.expired && filters.status.removed">All</span>
<span class="filter-btn-value" ng-if="!(filters.status.ready && filters.status.expired && filters.status.removed)">
{{ (filters.status.ready ? 0 : 1) + (filters.status.expired ? 0 : 1) + (filters.status.removed ? 0 : 1) }} hidden
</span>
</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" name="sort" 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" name="sort" 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" name="sort" id="statusRemoved" value="removed" ng-model="filters.status.removed" />
<label class="form-check-label" for="statusRemoved">Removed</label>
<h6 class="dropdown-header">Show statuses</h6>
<div class="form-check dropdown-item" ng-repeat="(key, label) in statusLabels">
<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>
<div class="dashboard-meta">
<span class="dashboard-count">
<span ng-if="filteredConferences.length === conferences.length">{{conferences.length | number}} conference<span ng-if="conferences.length !== 1">s</span></span>
<span ng-if="filteredConferences.length !== conferences.length">{{filteredConferences.length | number}} of {{conferences.length | number}} shown</span>
</span>
<span class="filter-chip" ng-repeat="(f, v) in filters.status" ng-if="v === false">
Hiding {{statusLabels[f]}}
<button type="button" class="filter-chip-close" aria-label="Show {{statusLabels[f]}} again" ng-click="filters.status[f] = true;">&times;</button>
</span>
</div>
</div>
<div class="paper-table paper-table-conferences w-100" role="table" aria-label="Conferences">
@@ -89,63 +97,72 @@
<div role="columnheader">Conference</div>
<div role="columnheader">Status</div>
<div role="columnheader" class="num">Repos</div>
<div role="columnheader">Window</div>
<div role="columnheader" aria-label="Actions"></div>
<div role="columnheader">Review window</div>
<div role="columnheader"><span class="sr-only">Actions</span></div>
</div>
<div
class="paper-table-row"
class="paper-table-row row-clickable"
role="row"
ng-class="{'repo-inactive': conference.status == 'expired' || conference.status == 'removed'}"
ng-repeat="conference in conferences| filter:conferenceFilter| orderBy:orderBy as filteredConferences"
ng-repeat="conference in conferences | filter:conferenceFilter | orderBy:orderBy as filteredConferences track by conference.conferenceID"
>
<div class="cell-anon" role="cell">
<span class="type-badge type-repo">Conf</span>
<div class="anon-text">
<a class="repo-name" ng-href="/conference/{{conference.conferenceID}}" ng-bind="conference.name"></a>
<div class="anon-sub">
ID <a ng-href="/conference/{{conference.conferenceID}}" ng-bind="conference.conferenceID"></a>
<span class="anon-source">ID <span class="commit-hash" ng-bind="conference.conferenceID"></span><span ng-if="conference.url"> &middot; <a ng-href="{{conference.url}}" target="_blank" rel="noopener" ng-bind="conference.url | limitTo: 60"></a></span></span>
</div>
</div>
</div>
<div class="cell-status" role="cell">
<span class="status-dot" ng-class="{'status-removed': conference.status == 'removed' || conference.status == 'expired', 'status-ready': conference.status == 'ready'}"></span>
<span ng-bind="conference.status | title"></span>
<div class="status-line">
<span class="status-dot" ng-class="'status-' + conference.status" aria-hidden="true"></span>
<span class="status-word" ng-bind="conference.status | statusLabel"></span>
</div>
</div>
<div class="cell-views num" role="cell" ng-bind="::conference.nbRepositories || 0 | number"></div>
<div class="cell-expires" role="cell">
<span ng-if="conference.startDate">{{conference.startDate | date}} &ndash; {{conference.endDate | date}}</span>
<span class="empty-dash" ng-if="!conference.startDate">&mdash;</span>
<span ng-if="conference.startDate">{{conference.startDate | date:'mediumDate'}} &ndash; {{conference.endDate | date:'mediumDate'}}</span>
<span class="empty-dash" ng-if="!conference.startDate" aria-label="No review window">&mdash;</span>
</div>
<div class="cell-actions" role="cell">
<div class="dropdown">
<button
class="btn btn-icon-dots"
type="button"
id="dropdownMenuButton"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
aria-label="Actions"
aria-label="Actions for {{conference.name}}"
>
<i class="fas fa-ellipsis-h" aria-hidden="true"></i>
</button>
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownMenuButton">
<div class="dropdown-menu dropdown-menu-right">
<a class="dropdown-item" href="/conference/{{conference.conferenceID}}/">
<i class="fa fa-eye" aria-hidden="true"></i> View
</a>
<a class="dropdown-item" href="/conference/{{conference.conferenceID}}/edit">
<i class="far fa-edit" aria-hidden="true"></i> Edit
</a>
<a class="dropdown-item" href="#" ng-show="conference.status != 'removed'" ng-click="removeConference(conference)">
<i class="fas fa-trash-alt"></i> Remove
</a>
<a class="dropdown-item" href="/conference/{{conference.conferenceID}}/">
<i class="fa fa-eye" aria-hidden="true"></i> View conference
</a>
<div ng-if="conference.status != 'removed'">
<div class="dropdown-divider"></div>
<button type="button" class="dropdown-item dropdown-item-danger" ng-click="removeConference(conference)">
<i class="fas fa-trash-alt" aria-hidden="true"></i> Remove
</button>
</div>
</div>
</div>
</div>
</div>
<div class="paper-table-empty" ng-if="filteredConferences.length == 0">
<i class="fas fa-inbox"></i>
<span>There are no conference entries. You can create a <a href="/conference/new">new one</a>.</span>
<i class="fas fa-inbox" aria-hidden="true"></i>
<span ng-if="conferences.length == 0">You have not created a conference yet.</span>
<span ng-if="conferences.length > 0">Nothing matches the current filters.</span>
<div class="paper-table-empty-actions">
<button type="button" class="btn btn-outline-ink" ng-if="conferences.length > 0" ng-click="search = ''; filters.status.ready = true; filters.status.expired = true; filters.status.removed = true;">Clear filters</button>
<a href="/conference/new" class="btn btn-ink" ng-if="conferences.length == 0"><i class="fa fa-plus-circle mr-1" aria-hidden="true"></i> New conference</a>
</div>
</div>
</div>
</div>
+189 -176
View File
@@ -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&rsquo;ve mirrored, with live status and stats.</p>
<p class="paper-page-lede">Every repository, pull request, and gist you&rsquo;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 || "&#8734;"}}</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;">
&times;
</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;">&times;</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">&nbsp;&middot;&nbsp;<a href="https://github.com/{{item.source.fullName}}/tree/{{item.source.branch}}" ng-bind="item.source.branch"></a><span ng-if="item.source.commit">&nbsp;&middot;&nbsp;@<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">&nbsp;&middot;&nbsp;@<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"> &middot; <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"> &middot; <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">&mdash;</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}} &middot; 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')">&mdash;</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">&mdash;</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>
+1 -1
View File
@@ -4,7 +4,7 @@
<h1 class="paper-faq-title">Answers to the <em>questions</em> that come up most.</h1>
<p class="paper-faq-lede">
If something's missing, write to us &mdash;
<a href="https://github.com/tdurieux/anonymous_github/issues">open an issue on GitHub</a>.
<a href="https://github.com/tdurieux/anonymous_github/issues/new?template=issue_report.yml" target="_blank" rel="noopener">report a bug on GitHub</a>.
</p>
</div>
<div class="paper-faq-body">
+37 -12
View File
@@ -74,30 +74,37 @@
class="nav-link nav-icon"
target="_blank"
href="https://github.com/tdurieux/anonymous_github/"
title="GitHub repository"
title="Anonymous GitHub source code"
aria-label="Anonymous GitHub source code on GitHub"
rel="noopener"
data-offset="30"
><i class="fab fa-github" aria-hidden="true"></i
></a>
</li>
<!-- "Report a bug" is about this service. Reviewers who land on an
anonymized repository often think it is about the paper, so the
label names the service and the tooltip says who to contact. -->
<li class="nav-item d-none d-lg-block">
<a
class="nav-link"
target="_blank"
href="https://github.com/tdurieux/anonymous_github/issues/new"
rel="noopener"
href="https://github.com/tdurieux/anonymous_github/issues/new?template=issue_report.yml"
title="Report a problem with the Anonymous GitHub service. Questions about an anonymized repository or paper go to its authors."
data-offset="30"
>Report an issue
><i class="fas fa-bug d-lg-none mr-1" aria-hidden="true"></i>Report a bug
</a>
</li>
<li class="nav-item" ng-if="!isDarkMode">
<a class="nav-link nav-icon" href="#" ng-click="darkMode(true);" title="Dark mode">
<i class="fas fa-moon"></i>
<a class="nav-link nav-icon" href="#" ng-click="darkMode(true);" title="Switch to dark mode" aria-label="Switch to dark mode" role="button">
<i class="fas fa-moon" aria-hidden="true"></i>
</a>
</li>
<li class="nav-item" ng-if="isDarkMode">
<a class="nav-link nav-icon" href="#" ng-click="darkMode(false);" title="Light mode">
<i class="fas fa-sun"></i>
<a class="nav-link nav-icon" href="#" ng-click="darkMode(false);" title="Switch to light mode" aria-label="Switch to light mode" role="button">
<i class="fas fa-sun" aria-hidden="true"></i>
</a>
</li>
@@ -126,12 +133,30 @@
/>
<span class="user-chip-name">{{user.username}}</span>
</a>
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="navbarDropdownMenuLink">
<a class="dropdown-item" href="/profile">Default settings</a>
<div class="dropdown-menu dropdown-menu-right user-menu" aria-labelledby="navbarDropdownMenuLink">
<h6 class="dropdown-header">Signed in as @{{user.username}}</h6>
<a class="dropdown-item" href="/profile">
<i class="fas fa-sliders-h" aria-hidden="true"></i>
<span>Settings<small>Defaults, quotas, account</small></span>
</a>
<a class="dropdown-item" href="/claim">
<i class="fas fa-link" aria-hidden="true"></i>
<span>Claim an anonymization<small>Attach an existing mirror to your account</small></span>
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="/api/user/logout" target="_self"
>Logout</a
>
<a class="dropdown-item" href="https://github.com/tdurieux/anonymous_github/issues/new?template=issue_report.yml" target="_blank" rel="noopener">
<i class="fas fa-bug" aria-hidden="true"></i>
<span>Report a bug<small>About this service, not a paper</small></span>
</a>
<a class="dropdown-item" href="https://github.com/tdurieux/anonymous_github/" target="_blank" rel="noopener">
<i class="fab fa-github" aria-hidden="true"></i>
<span>Source code</span>
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="/api/user/logout" target="_self">
<i class="fas fa-sign-out-alt" aria-hidden="true"></i>
<span>Sign out</span>
</a>
</div>
</li>
</ul>
+3 -3
View File
@@ -7,8 +7,8 @@
<span class="accent">not the author.</span>
</h1>
<p class="hero-subtitle">
Anonymous GitHub makes a read-only mirror of your repository or pull
request with every trace of identity removed &mdash; so you can attach a
Anonymous GitHub makes a read-only mirror of your repository, pull
request, or gist with every trace of identity removed &mdash; so you can attach a
link to your submission without breaking anonymity.
</p>
@@ -264,7 +264,7 @@
<div class="paper-footer-col">
<div class="paper-footer-head">Project</div>
<a href="https://github.com/tdurieux/anonymous_github/" target="_blank">Source code</a>
<a href="https://github.com/tdurieux/anonymous_github/issues/new" target="_blank">Report an issue</a>
<a href="https://github.com/tdurieux/anonymous_github/issues/new?template=issue_report.yml" target="_blank" rel="noopener">Report a bug</a>
<a href="https://github.com/sponsors/tdurieux/" target="_blank">Sponsor</a>
</div>
<div class="paper-footer-col">
+19 -19
View File
@@ -1,5 +1,5 @@
<div class="container paper-page paper-settings">
<div class="paper-crumbs"><a href="/conferences">Conferences</a> &nbsp;/&nbsp; <span class="here">{{editionMode ? 'Edit' : 'New'}}</span></div>
<div class="paper-crumbs"><a href="/dashboard">My work</a> &nbsp;/&nbsp; <a href="/conferences">Conferences</a> &nbsp;/&nbsp; <span class="here">{{editionMode ? 'Edit' : 'New'}}</span></div>
<h1 class="paper-page-title">{{editionMode ? 'Edit' : 'Create a'}} <em>conference</em></h1>
<p class="paper-page-lede">
Give chairs access to every anonymization submitted to a venue, and lift
@@ -7,8 +7,8 @@
</p>
<div class="paper-settings-body">
<aside class="paper-settings-toc">
<div class="paper-settings-toc-head">Contents</div>
<aside class="paper-settings-toc" paper-scrollspy>
<div class="paper-settings-toc-head">On this page</div>
<nav>
<a href="#conf-basics">Basics</a>
<a href="#conf-window">Review window</a>
@@ -31,7 +31,7 @@
name="name"
id="name"
required
ng-class="{'is-invalid': conference.name.$invalid}"
ng-class="{'is-invalid': conference.name.$invalid && (conference.name.$touched || conference.$submitted)}"
ng-model="options.name"
placeholder="e.g. International Conference on Software Engineering"
/>
@@ -50,7 +50,7 @@
class="form-control"
name="url"
id="url"
ng-class="{'is-invalid': conference.url.$invalid}"
ng-class="{'is-invalid': conference.url.$invalid && (conference.url.$touched || conference.$submitted)}"
ng-model="options.url"
placeholder="https://example.org"
/>
@@ -68,7 +68,7 @@
id="conferenceID"
required
pattern="[a-zA-Z0-9\-_]{3,10}"
ng-class="{'is-invalid': conference.conferenceID.$invalid}"
ng-class="{'is-invalid': conference.conferenceID.$invalid && (conference.conferenceID.$touched || conference.$submitted)}"
ng-model="options.conferenceID"
placeholder="ICSE26"
/>
@@ -97,7 +97,7 @@
name="startDate"
id="startDate"
required
ng-class="{'is-invalid': conference.startDate.$invalid}"
ng-class="{'is-invalid': conference.startDate.$invalid && (conference.startDate.$touched || conference.$submitted)}"
ng-model="options.startDate"
/>
<small class="form-text text-muted">Beginning of the review process.</small>
@@ -117,7 +117,7 @@
name="endDate"
id="endDate"
required
ng-class="{'is-invalid': conference.endDate.$invalid}"
ng-class="{'is-invalid': conference.endDate.$invalid && (conference.endDate.$touched || conference.$submitted)}"
ng-model="options.endDate"
/>
<small class="form-text text-muted">All repositories expire on this date.</small>
@@ -160,19 +160,19 @@
<div class="form-check">
<input class="form-check-input" type="checkbox" id="update" name="update" ng-model="options.options.update" />
<label class="form-check-label" for="update">Auto update</label>
<label class="form-check-label" for="update">Auto-update from GitHub</label>
<small class="form-text text-muted">Pull the latest commit automatically (hourly maximum).</small>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="page" name="page" ng-model="options.options.page" />
<label class="form-check-label" for="page">GitHub pages</label>
<label class="form-check-label" for="page">GitHub Pages</label>
<small class="form-text text-muted">Enable anonymized GitHub pages.</small>
</div>
</section>
<section id="conf-plan" class="paper-settings-section">
<div class="paper-section-eyebrow">Plan</div>
<p class="paper-settings-copy">Repositories are billed per hour.</p>
<p class="paper-section-copy">Billed per repository per month, prorated by the day for as long as a repository stays in the conference.</p>
<div class="paper-plan-grid">
<label
@@ -202,14 +202,14 @@
<div class="form-group">
<label class="paper-field-label" for="billing_name">Name</label>
<input type="text" class="form-control" name="billing_name" id="billing_name" required
ng-class="{'is-invalid': conference.billing_name.$invalid}"
ng-class="{'is-invalid': conference.billing_name.$invalid && (conference.billing_name.$touched || conference.$submitted)}"
ng-model="options.billing.name" placeholder="First &amp; last name" />
</div>
<div class="form-group">
<label class="paper-field-label" for="email">Email</label>
<input type="email" class="form-control" name="email" id="email" required
ng-class="{'is-invalid': conference.email.$invalid}"
ng-class="{'is-invalid': conference.email.$invalid && (conference.email.$touched || conference.$submitted)}"
ng-model="options.billing.email" placeholder="you@example.org" />
</div>
@@ -218,12 +218,12 @@
<input type="text" class="form-control" name="inputAddress" id="inputAddress" required
placeholder="1234 Main St"
ng-model="options.billing.address"
ng-class="{'is-invalid': conference.inputAddress.$invalid}" />
ng-class="{'is-invalid': conference.inputAddress.$invalid && (conference.inputAddress.$touched || conference.$submitted)}" />
</div>
<div class="form-group">
<input type="text" class="form-control" name="inputAddress2" id="inputAddress2"
ng-model="options.billing.address2"
ng-class="{'is-invalid': conference.inputAddress2.$invalid}"
ng-class="{'is-invalid': conference.inputAddress2.$invalid && (conference.inputAddress2.$touched || conference.$submitted)}"
placeholder="Apartment, studio, or floor" />
</div>
@@ -232,26 +232,26 @@
<label class="paper-field-label" for="city">City</label>
<input type="text" class="form-control" name="city" id="city"
ng-model="options.billing.city" required
ng-class="{'is-invalid': conference.city.$invalid}" placeholder="City" />
ng-class="{'is-invalid': conference.city.$invalid && (conference.city.$touched || conference.$submitted)}" placeholder="City" />
</div>
<div class="form-group">
<label class="paper-field-label" for="country">Country</label>
<input type="text" class="form-control" name="country" id="country"
ng-model="options.billing.country" required
ng-class="{'is-invalid': conference.country.$invalid}" placeholder="Country" />
ng-class="{'is-invalid': conference.country.$invalid && (conference.country.$touched || conference.$submitted)}" placeholder="Country" />
</div>
<div class="form-group">
<label class="paper-field-label" for="zip">Zip</label>
<input type="text" class="form-control" name="zip" id="zip"
ng-model="options.billing.zip" required
ng-class="{'is-invalid': conference.zip.$invalid}" placeholder="Zip" />
ng-class="{'is-invalid': conference.zip.$invalid && (conference.zip.$touched || conference.$submitted)}" placeholder="Zip" />
</div>
</div>
<div class="form-group">
<label class="paper-field-label" for="vat">VAT number</label>
<input type="text" class="form-control" name="vat" id="vat"
ng-class="{'is-invalid': conference.vat.$invalid}"
ng-class="{'is-invalid': conference.vat.$invalid && (conference.vat.$touched || conference.$submitted)}"
ng-model="options.billing.vat" placeholder="VAT Number" />
</div>
</section>
+106 -95
View File
@@ -1,15 +1,15 @@
<div class="container paper-page paper-settings">
<div class="paper-crumbs">Reference &nbsp;/&nbsp; <span class="here">Settings</span></div>
<div class="paper-crumbs"><a href="/dashboard">My work</a> &nbsp;/&nbsp; <span class="here">Settings</span></div>
<h1 class="paper-page-title">Your <em>settings</em></h1>
<p class="paper-page-lede">Review quotas and set defaults applied to every new anonymization.</p>
<p class="paper-page-lede">Defaults applied to every new anonymization, your quotas, and your account.</p>
<div class="paper-settings-body">
<aside class="paper-settings-toc">
<div class="paper-settings-toc-head">Contents</div>
<aside class="paper-settings-toc" paper-scrollspy>
<div class="paper-settings-toc-head">On this page</div>
<nav>
<a href="#settings-quota">Quota</a>
<a href="#settings-terms">Terms</a>
<a href="#settings-rendering">Rendering</a>
<a href="#settings-terms">Terms to redact</a>
<a href="#settings-display">Display</a>
<a href="#settings-features">Features</a>
<a href="#settings-expiration">Expiration</a>
<a href="#settings-account">Account</a>
@@ -17,170 +17,181 @@
</aside>
<div class="paper-settings-main">
<!-- Quota: same markup as the dashboard, driven by quotaService -->
<section id="settings-quota" class="paper-settings-section">
<div class="paper-section-eyebrow">Quota</div>
<div class="quota-row">
<div class="quota-item">
<p class="paper-section-copy">What you are using across all your anonymizations. Conferences can lift these limits during a review window.</p>
<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="{'progress-bar-striped progress-bar-animated w-100 bg-dark': !quota, '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
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 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="{'progress-bar-striped progress-bar-animated w-100 bg-dark': !quota, '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 || "&#8734;"}}</span>
</div>
<div class="progress quota-progress">
<div
class="progress-bar"
ng-class="{'progress-bar-striped progress-bar-animated w-100 bg-dark': !quota, '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>
</div>
<div class="quota-row" ng-if="!quota" aria-hidden="true">
<div class="quota-item" ng-repeat="i in [1, 2, 3]">
<div class="quota-header"><span class="skeleton skeleton-line" style="width: 40%"></span><span class="skeleton skeleton-line" style="width: 25%"></span></div>
<div class="quota-track"></div>
</div>
</div>
</section>
<form class="form needs-validation" name="default" novalidate>
<form class="form needs-validation" name="defaultsForm" novalidate ng-submit="saveDefault($event)">
<section id="settings-terms" class="paper-settings-section">
<div class="paper-section-eyebrow">Default anonymization options</div>
<div class="paper-section-eyebrow">Terms to redact</div>
<p class="paper-section-copy">Pre-filled in every new anonymization. You can still edit the list per anonymization.</p>
<div class="form-group">
<label class="paper-field-label" for="terms">Terms to anonymize</label>
<label class="paper-field-label" for="terms">Default terms</label>
<textarea
class="form-control"
id="terms"
name="terms"
rows="4"
placeholder="Jane Smith&#10;MIT CSAIL&#10;jane.smith@example.org"
ng-model="terms"
ng-model-options="{ debounce: 250 }"
></textarea>
<small id="termsHelp" class="form-text text-muted">One term per line. Each term will be replaced by XXX.</small>
<div class="invalid-feedback" ng-show="anonymize.terms.$error.format">
<small id="termsHelp" class="form-text text-muted">
One term per line (regex allowed). Each match is replaced by
<code>{{site_options.ANONYMIZATION_MASK || 'XXX'}}-[N]</code>, or use <code>term=&gt;replacement</code> to pick your own.
</small>
<div class="invalid-feedback" ng-show="defaultsForm.terms.$error.format">
Terms are in an invalid format
</div>
</div>
</section>
<section id="settings-rendering" class="paper-settings-section">
<div class="paper-section-eyebrow">Rendering</div>
<div class="form-group">
<section id="settings-display" class="paper-settings-section">
<div class="paper-section-eyebrow">Display</div>
<p class="paper-section-copy">What reviewers can see inside an anonymized repository.</p>
<div class="form-group paper-check-list">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="link" name="link" ng-model="options.link" />
<label class="form-check-label" for="link">Keep links</label>
<small class="form-text text-muted">Keep or remove all the links.</small>
<small class="form-text text-muted">Unchecked, every link in text files is removed.</small>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="image" name="image" ng-model="options.image" />
<label class="form-check-label" for="image">Display images</label>
<small class="form-text text-muted">Images are not anonymized.</small>
<small class="form-text text-muted">Images are shown as is. They are not anonymized.</small>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="pdf" name="pdf" ng-model="options.pdf" />
<label class="form-check-label" for="pdf">Display PDFs</label>
<small class="form-text text-muted">PDFs are not anonymized.</small>
<small class="form-text text-muted">PDFs are shown as is. They are not anonymized.</small>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="notebook" name="notebook" ng-model="options.notebook" />
<label class="form-check-label" for="notebook">Display Notebooks</label>
<label class="form-check-label" for="notebook">Display notebooks</label>
<small class="form-text text-muted">Render Jupyter notebooks instead of raw JSON.</small>
</div>
</div>
</section>
<section id="settings-features" class="paper-settings-section">
<div class="paper-section-eyebrow">Features</div>
<div class="form-group">
<div class="form-group paper-check-list">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="page" name="page" ng-model="options.page" />
<label class="form-check-label" for="page">Github page</label>
<small class="form-text text-muted">Enable anonymized GitHub pages. Currently only supported for pages defined in the same branch.</small>
<label class="form-check-label" for="page">GitHub Pages</label>
<small class="form-text text-muted">Serve an anonymized copy of the repository's GitHub Pages site. Only pages built from the anonymized branch are supported.</small>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="loc" name="loc" ng-model="options.loc" />
<label class="form-check-label" for="loc">Line of code</label>
<small class="form-text text-muted">Display the number of lines of code in the repository.</small>
<label class="form-check-label" for="loc">Lines of code</label>
<small class="form-text text-muted">Show the line count of the repository in the explorer.</small>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="update" name="update" ng-model="options.update" />
<label class="form-check-label" for="update">Auto update</label>
<small class="form-text text-muted">Automatically update the anonymized repository with the latest commit (once per hour maximum).</small>
<label class="form-check-label" for="update">Auto-update from GitHub</label>
<small class="form-text text-muted">Follow the branch and pull the latest commit automatically, at most once an hour.</small>
</div>
</div>
<div class="form-group">
<label class="paper-field-label" for="mode">Proxy mode</label>
<label class="paper-field-label" for="mode">Fetch mode</label>
<select class="form-control" id="mode" name="mode" ng-model="options.mode">
<option value="GitHubStream" selected>Stream</option>
<option value="GitHubDownload">Download</option>
<option value="GitHubStream" selected>Stream from GitHub on demand</option>
<option value="GitHubDownload">Download to Anonymous GitHub</option>
</select>
<small class="form-text text-muted">Stream mode requests content on the fly. This is the only option for repositories bigger than {{site_options.MAX_REPO_SIZE * 1024| humanFileSize}}. Download fetches to the server — faster and with more features.</small>
<small class="form-text text-muted">
Download is faster for reviewers and enables every feature. Streaming is the only option above
{{site_options.MAX_REPO_SIZE * 1024 | humanFileSize}}.
</small>
</div>
</section>
<section id="settings-expiration" class="paper-settings-section">
<div class="paper-section-eyebrow">Expiration</div>
<div class="form-group">
<label class="paper-field-label" for="expiration">Expiration strategy</label>
<label class="paper-field-label" for="expiration">When an anonymization expires</label>
<select class="form-control" id="expiration" name="expiration" ng-model="options.expirationMode">
<option value="redirect">Redirect to GitHub</option>
<option value="remove" selected>Remove anonymized repository</option>
<option value="redirect">Redirect visitors to the GitHub repository</option>
<option value="remove" selected>Remove the anonymized repository</option>
</select>
<small class="form-text text-muted">Define the expiration strategy for new repositories.</small>
<small class="form-text text-muted">
The expiration date itself is chosen per anonymization. Redirecting reveals the source repository, so pick it only once the review is over.
</small>
</div>
</section>
<div class="paper-settings-footer">
<div class="alert alert-danger" role="alert" ng-if="error" ng-bind="error"></div>
<div class="alert alert-success" role="alert" ng-if="message" ng-bind="message"></div>
<button id="save" type="submit" class="btn btn-ink" ng-click="saveDefault($event)">
Save defaults
</button>
<div class="paper-inline-alert paper-inline-alert-error" role="alert" ng-if="error"><i class="fas fa-exclamation-circle" aria-hidden="true"></i> <span ng-bind="error"></span></div>
<div class="paper-settings-actions">
<button id="save" type="submit" class="btn btn-ink" ng-disabled="saving">
<i class="fas fa-check mr-1" aria-hidden="true" ng-if="message"></i>{{ saving ? 'Saving…' : (message ? 'Saved' : 'Save defaults') }}
</button>
<span class="paper-settings-hint">Applies to new anonymizations only. Existing ones keep their settings.</span>
</div>
</div>
</form>
<section id="settings-account" class="paper-settings-section">
<div class="paper-section-eyebrow">Account</div>
<p class="form-text text-muted">
Deleting your account removes all your anonymized repositories,
gists, and pull requests, revokes the application's access to your
GitHub account, and erases your personal data (username, email,
access tokens) from our database. This cannot be undone.
</p>
<div class="alert alert-danger" role="alert" ng-if="deleteError" ng-bind="deleteError"></div>
<button type="button" class="btn btn-danger" ng-click="deleteAccount()" ng-disabled="deletingAccount">
{{ deletingAccount ? 'Deleting…' : 'Delete my account' }}
</button>
<div class="paper-account-card">
<img ng-src="{{user.photo}}" ng-if="user.photo" width="40" height="40" class="rounded-circle" alt="" />
<div class="paper-account-identity">
<div class="paper-account-name">@{{user.username}}</div>
<div class="paper-account-meta">Signed in with GitHub. Anonymous GitHub only reads your repositories.</div>
</div>
<a class="btn btn-outline-ink" href="/api/user/logout" target="_self">Sign out</a>
</div>
<div class="paper-danger-zone">
<div class="paper-danger-head">Delete account</div>
<p class="paper-danger-copy">
Removes all your anonymized repositories, gists, and pull requests,
revokes the application's access to your GitHub account, and erases
your personal data (username, email, access tokens). This cannot be undone.
</p>
<div class="paper-inline-alert paper-inline-alert-error" role="alert" ng-if="deleteError"><i class="fas fa-exclamation-circle" aria-hidden="true"></i> <span ng-bind="deleteError"></span></div>
<button type="button" class="btn btn-outline-danger" ng-click="deleteAccount()" ng-disabled="deletingAccount">
<i class="fas fa-trash-alt mr-1" aria-hidden="true"></i>{{ deletingAccount ? 'Deleting…' : 'Delete my account' }}
</button>
</div>
</section>
</div>
</div>
+1 -1
View File
@@ -1,4 +1,4 @@
<div class="pr-page" ng-init="tabState = { active: (details && details.diff) ? 'diff' : 'comments' }">
<div class="pr-page">
<div class="container paper-page pr-page-inner">
<div class="paper-crumbs">
<a href="/dashboard">Reviewer</a> &nbsp;/&nbsp;
+3 -3
View File
@@ -57,7 +57,7 @@
<div class="paper-error-actions">
<a class="btn btn-ink" ng-href="/anonymize/{{repoId}}"><i class="far fa-edit mr-1"></i> Edit anonymization</a>
<a class="btn" href="/faq"><i class="far fa-question-circle mr-1"></i> Read the FAQ</a>
<a class="btn" href="https://github.com/tdurieux/anonymous_github/issues/new" target="_blank" rel="noopener"><i class="fab fa-github mr-1"></i> Report an issue</a>
<a class="btn" href="https://github.com/tdurieux/anonymous_github/issues/new?template=issue_report.yml" target="_blank" rel="noopener"><i class="fas fa-bug mr-1" aria-hidden="true"></i> Report a bug in Anonymous GitHub</a>
</div>
</div>
@@ -95,9 +95,9 @@
<p>Collaborate by implementing new features and fixing bugs. Help with new file formats or deployment is welcome.</p>
<span class="paper-support-cta">Open on GitHub <i class="fas fa-arrow-right"></i></span>
</a>
<a class="paper-support-card" href="https://github.com/tdurieux/anonymous_github/issues/new" target="_blank" rel="noopener">
<a class="paper-support-card" href="https://github.com/tdurieux/anonymous_github/issues/new?template=issue_report.yml" target="_blank" rel="noopener">
<div class="paper-support-eyebrow"><i class="far fa-comment-dots"></i> Feedback</div>
<p>Tell us about bugs and missing features. Your feedback shapes the project's priorities.</p>
<p>Tell us about bugs and missing features in Anonymous GitHub itself. Questions about an anonymized repository go to its authors.</p>
<span class="paper-support-cta">File an issue <i class="fas fa-arrow-right"></i></span>
</a>
<a class="paper-support-card" href="https://github.com/sponsors/tdurieux/" target="_blank" rel="noopener">
+422 -129
View File
@@ -93,13 +93,13 @@ angular
templateUrl: "/partials/pullRequest.htm",
controller: "pullRequestController",
title: "Anonymous pull request Anonymous GitHub",
reloadOnUrl: false,
reloadOnSearch: false,
})
.when("/gist/:gistId/:path*?", {
templateUrl: "/partials/gist.htm",
controller: "gistController",
title: "Anonymous gist Anonymous GitHub",
reloadOnUrl: false,
reloadOnSearch: false,
})
.when("/r/:repoId/:path*?", {
templateUrl: "/partials/explorer.htm",
@@ -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,9 +311,94 @@ 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;
};
})
// Shared quota loader. A total of 0 means unlimited: no percentage, no fill.
// `level` drives the bar colour: ink until 80%, amber to 95%, red above.
.factory("quotaService", [
"$http",
function ($http) {
function decorate(q) {
q = q || { used: 0, total: 0 };
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;
}
return {
decorate: decorate,
load: function () {
return $http.get("/api/user/quota").then((res) => {
const quota = res.data || {};
quota.storage = decorate(quota.storage);
quota.file = decorate(quota.file);
quota.repository = decorate(quota.repository);
return quota;
});
},
};
},
])
// Highlights the link of the section currently in view inside a
// `.paper-settings-toc`-style navigation. Falls back to no-op without
// IntersectionObserver.
.directive("paperScrollspy", [
"$window",
function ($window) {
return {
restrict: "A",
link: function (scope, element) {
if (!$window.IntersectionObserver) return;
const links = Array.from(element[0].querySelectorAll('a[href^="#"]'));
const byId = {};
links.forEach((a) => {
byId[a.getAttribute("href").slice(1)] = a;
});
const visible = new Set();
function update() {
let current = null;
for (const id of Object.keys(byId)) {
if (visible.has(id)) {
current = id;
break;
}
}
links.forEach((a) => a.classList.toggle("active", a === byId[current]));
}
const observer = new $window.IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (e.isIntersecting) visible.add(e.target.id);
else visible.delete(e.target.id);
});
update();
},
{ rootMargin: "-20% 0px -60% 0px", threshold: 0 }
);
// Sections may render after the toc; observe on the next tick.
$window.setTimeout(() => {
Object.keys(byId).forEach((id) => {
const target = $window.document.getElementById(id);
if (target) observer.observe(target);
});
}, 0);
scope.$on("$destroy", () => observer.disconnect());
},
};
},
])
.filter("diff", [
"$sce",
function ($sce) {
@@ -526,7 +656,7 @@ angular
function ($element, $scope, $routeParams, $compile) {
$scope.repoId = document.location.pathname.split("/")[2];
$scope.opens = {};
$scope.opens = Object.create(null);
if ($routeParams.path) {
let accumulatedPath = "";
@@ -538,7 +668,8 @@ angular
const toArray = function (arr) {
const output = [];
const keys = { "": { child: output } };
const keys = Object.create(null);
keys[""] = { child: output };
function ensurePath(path) {
if (keys[path]) return;
const segments = path.split("/");
@@ -622,23 +753,6 @@ angular
return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
// Escape a value for safe interpolation into a single-quoted
// AngularJS expression string (e.g. ng-click="openFolder('...')")
// that itself sits inside a double-quoted HTML attribute which is
// later $compile()d. Backslash/quote are escaped at the Angular
// string level; &<>" are HTML-encoded for the attribute. Without
// this a file name like `');$emit(...)//` would break out of the
// expression string and execute (DOM XSS, CWE-79).
function escapeNgString(str) {
return String(str)
.replace(/\\/g, "\\\\")
.replace(/'/g, "\\'")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function buildSearchFilter() {
const results = $scope.searchResults;
if (!results || !results.length) return null;
@@ -721,12 +835,14 @@ angular
cssClasses.push("truncated");
}
const ngPath = escapeNgString(path);
const nodeIndex = $scope.treeNodes.length;
$scope.treeNodes.push({ name, path, href: `/r/${encodeURIComponent($scope.repoId)}${encodePathForUrl(path)}` });
const node = `treeNodes[${nodeIndex}]`;
output += `<li class="${cssClasses.join(
" "
)}" ng-class="{active: isActive('${ngPath}'), open: ${filterSet ? "opens['" + ngPath + "'] !== false" : "opens['" + ngPath + "']"}}" title="${escapeHtml(sizeTitle)}">`;
)}" ng-class="{active: isActive(${node}.path), open: opens[${node}.path]${filterSet ? ' !== false' : ''}}" title="${escapeHtml(sizeTitle)}">`;
if (dir) {
output += `<a ng-click="openFolder('${ngPath}', $event)"><span class="tree-toggle"></span><span class="tree-icon-folder"></span><span class="tree-name">${escapeHtml(name)}</span>`;
output += `<a ng-click="openFolder(${node}.path, $event)"><span class="tree-toggle"></span><span class="tree-icon-folder"></span><span class="tree-name" ng-bind="${node}.name"></span>`;
if (truncated) {
output += `<span class="truncated-warning" title="{{ 'WARNINGS.folder_truncated' | translate }}"><i class="fas fa-exclamation-triangle"></i></span>`;
}
@@ -736,9 +852,7 @@ angular
output += `</a>`;
} else {
const needsSpacer = parentPath !== "";
output += `<a href='/r/${$scope.repoId}${encodePathForUrl(
path
)}'>${needsSpacer ? '<span class="tree-spacer"></span>' : ''}<span class="tree-icon-file"></span><span class="tree-name">${escapeHtml(name)}</span></a>`;
output += `<a ng-href='{{${node}.href}}'>${needsSpacer ? '<span class="tree-spacer"></span>' : ''}<span class="tree-icon-file"></span><span class="tree-name" ng-bind="${node}.name"></span></a>`;
}
if (isOpen && collapsed.child) {
const children = collapsed.child;
@@ -757,7 +871,11 @@ angular
return output + "</ul>";
}
let renderScope = null;
function display() {
if (renderScope) renderScope.$destroy();
renderScope = $scope.$new();
$scope.treeNodes = [];
$element.html("");
const filterSet = $scope.searchQuery ? buildSearchFilter() : null;
let output;
@@ -766,7 +884,7 @@ angular
} else {
output = generate(toArray($scope.file).sort(sortFiles), "", filterSet);
}
$compile(output)($scope, (clone) => {
$compile(output)(renderScope, (clone) => {
$element.append(clone);
restoreFocus();
});
@@ -1150,7 +1268,10 @@ angular
.controller("profileController", [
"$scope",
"$http",
function ($scope, $http) {
"$translate",
"$timeout",
"quotaService",
function ($scope, $http, $translate, $timeout, quotaService) {
$scope.terms = "";
$scope.options = {
expirationMode: "remove",
@@ -1161,10 +1282,17 @@ angular
loc: true,
link: true,
};
$scope.saving = false;
$scope.message = null;
$scope.error = null;
quotaService.load().then((quota) => {
$scope.quota = quota;
}, console.error);
function getDefault() {
$http.get("/api/user/default").then((res) => {
const data = res.data;
const data = res.data || {};
if (data.terms) {
$scope.terms = data.terms.join("\n");
}
@@ -1173,20 +1301,36 @@ angular
}
getDefault();
$scope.saveDefault = () => {
let savedTimer = null;
$scope.saveDefault = ($event) => {
if ($event && $event.preventDefault) $event.preventDefault();
const params = {
terms: $scope.terms.trim().split("\n"),
terms: $scope.terms
.split("\n")
.map((t) => t.trim())
.filter((t) => t.length > 0),
options: $scope.options,
};
$scope.saving = true;
$scope.error = null;
$http.post("/api/user/default", params).then(
() => {
getDefault();
$scope.saving = false;
$scope.message = "Saved";
if (savedTimer) $timeout.cancel(savedTimer);
savedTimer = $timeout(() => {
$scope.message = null;
}, 2500);
},
(error) => {
$translate("ERRORS." + error.data.error).then((translation) => {
$scope.saving = false;
const code = error && error.data && error.data.error;
$translate("ERRORS." + code).then((translation) => {
$scope.error = translation;
}, console.error);
}, () => {
$scope.error = "Unable to save your defaults. Please try again.";
});
}
);
};
@@ -1338,7 +1482,10 @@ angular
"$scope",
"$http",
"$location",
function ($scope, $http, $location) {
"$q",
"$window",
"quotaService",
function ($scope, $http, $location, $q, $window, quotaService) {
$scope.$on("$routeChangeStart", function () {
$('[data-toggle="tooltip"]').tooltip("dispose");
});
@@ -1357,11 +1504,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) || {};
@@ -1375,6 +1546,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"],
() => {
@@ -1397,94 +1599,148 @@ angular
true
);
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;
}, console.error);
}
getQuota();
// ---- Quota (shared with the settings page via quotaService) --------
quotaService.load().then((quota) => {
$scope.quota = quota;
}, console.error);
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) {
@@ -1608,10 +1864,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;
};
},
@@ -1642,6 +1900,8 @@ angular
$scope.rateLimitCountdown = "";
var countdownTimer = null;
let pollTimer = null;
let destroyed = false;
function startRateLimitCountdown(resetAt) {
$scope.rateLimitResetAt = resetAt;
if (countdownTimer) clearInterval(countdownTimer);
@@ -1665,7 +1925,9 @@ angular
countdownTimer = setInterval(tick, 1000);
}
$scope.$on("$destroy", function () {
destroyed = true;
if (countdownTimer) clearInterval(countdownTimer);
if (pollTimer) clearTimeout(pollTimer);
});
function parseStatusMessage(msg) {
@@ -1680,6 +1942,7 @@ angular
}
$scope.getStatus = () => {
if (destroyed) return;
$http
.get("/api/repo/" + $scope.repoId, {
repoId: $scope.repoId,
@@ -1687,6 +1950,7 @@ angular
})
.then(
(res) => {
if (destroyed) return;
$scope.repo = res.data;
if (res.data.rateLimitResetAt) {
startRateLimitCountdown(res.data.rateLimitResetAt);
@@ -1706,12 +1970,12 @@ angular
} else if ($scope.repo.status == "anonymizing") {
$scope.progress = 75;
}
var shouldPoll = $scope.repo.status != "ready";
var shouldPoll = !["ready", "removed", "expired"].includes($scope.repo.status);
if ($scope.repo.status == "error" && !$scope.rateLimitResetAt) {
shouldPoll = false;
}
if (shouldPoll) {
setTimeout($scope.getStatus, 2000);
pollTimer = setTimeout($scope.getStatus, 2000);
}
},
(err) => {
@@ -2215,7 +2479,7 @@ angular
if (typeof d.body === "string") out.add(d.body);
if (typeof d.diff === "string") out.add(d.diff);
const comments =
($scope.details && $scope.details.comments) || [];
d.comments || [];
for (const c of comments) {
if (typeof c.author === "string") out.add(c.author);
if (typeof c.body === "string") out.add(c.body);
@@ -2589,6 +2853,13 @@ angular
"$sce",
"$q",
function ($scope, $http, $location, $routeParams, $sce, $q) {
let contentGeneration = 0;
let destroyed = false;
$scope.$on("$destroy", () => {
destroyed = true;
contentGeneration++;
if (searchCanceller) searchCanceller.resolve();
});
$scope.files = [];
$scope.isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent);
$scope.fileSearchQuery = "";
@@ -2619,11 +2890,13 @@ angular
return;
}
$scope.fileSearchLoading = true;
searchCanceller = $q.defer();
const requestCanceller = $q.defer();
searchCanceller = requestCanceller;
$http.get(
`/api/repo/${$scope.repoId}/files/search?q=${encodeURIComponent(query)}`,
{ timeout: searchCanceller.promise }
{ timeout: requestCanceller.promise }
).then(function (res) {
if (destroyed || searchCanceller !== requestCanceller) return;
searchCanceller = null;
$scope.fileSearchLoading = false;
// Merge search results into $scope.files so the tree can render them.
@@ -2673,8 +2946,8 @@ angular
}
$scope.fileSearchResults = res.data;
}, function () {
// Only clear loading if this wasn't a cancellation
if (!searchCanceller) {
if (!destroyed && searchCanceller === requestCanceller) {
searchCanceller = null;
$scope.fileSearchLoading = false;
$scope.fileSearchResults = [];
}
@@ -2724,6 +2997,7 @@ angular
];
$scope.$on("$routeUpdate", function (event, current) {
if ($scope.repoId != $routeParams.repoId) return init();
if (($routeParams.path || "") == $scope.filePath) {
return;
}
@@ -2732,9 +3006,6 @@ angular
.split("/")
.filter((f) => f && f.trim().length > 0);
if ($scope.repoId != $routeParams.repoId) {
return init();
}
updateContent();
@@ -2792,23 +3063,28 @@ angular
}
$scope.fileCounts = null;
$scope.getFiles = function (path) {
const repoId = $scope.repoId;
return $http.get(
`/api/repo/${$scope.repoId}/files/?path=${encodeURIComponent(path)}&v=${$scope.options.lastUpdateDate}`
).then(function (res) {
if (destroyed || repoId !== $scope.repoId) return [];
const normalized = path || "";
$scope.files = $scope.files.filter((f) => f.path !== normalized);
$scope.files.push(...res.data);
return res.data;
}, function (err) {
if (destroyed || repoId !== $scope.repoId) return [];
$scope.type = "error";
$scope.content = (err && err.data && err.data.error) || "unknown_error";
$scope.files = [];
});
};
function fetchFileCounts() {
const repoId = $scope.repoId;
$http.get(
`/api/repo/${$scope.repoId}/files/counts`
).then(function (res) {
if (destroyed || repoId !== $scope.repoId) return;
$scope.fileCounts = res.data;
}, function () {
$scope.fileCounts = {};
@@ -2827,8 +3103,11 @@ angular
$scope.$on("$destroy", function () { if (rlCountdownTimer) clearInterval(rlCountdownTimer); });
function getOptions(callback) {
if (destroyed) return;
const repoId = $scope.repoId;
$http.get(`/api/repo/${$scope.repoId}/options`).then(
(res) => {
if (destroyed || repoId !== $scope.repoId) return;
$scope.options = res.data;
if ($scope.options.url) {
window.location = $scope.options.url;
@@ -2839,6 +3118,7 @@ angular
}
},
(err) => {
if (destroyed || repoId !== $scope.repoId) return;
var data = err.data || {};
if (data.error === "rate_limited" && data.resetAt) {
$scope.type = "rate_limited";
@@ -2925,6 +3205,7 @@ angular
}
function getContent(path, fileInfo) {
const generation = contentGeneration;
if (!path) {
$scope.type = "error";
$scope.content = "no_file_selected";
@@ -2950,6 +3231,7 @@ angular
)
.then(
(res) => {
if (destroyed || generation !== contentGeneration) return;
$scope.type = originalType;
$scope.content = res.data;
if ($scope.content == "") {
@@ -2973,7 +3255,7 @@ angular
suppressSubScriptHandling: true,
suppressAutoLink: false,
});
$scope.content = $sce.trustAsHtml(orgHTMLDocument.toString());
$scope.content = $sce.trustAsHtml(DOMPurify.sanitize(orgHTMLDocument.toString()));
$scope.type = "html";
}
if (
@@ -2988,6 +3270,7 @@ angular
}, 50);
},
(err) => {
if (destroyed || generation !== contentGeneration) return;
$scope.type = "error";
$scope.content = "unknown_error";
try {
@@ -3011,6 +3294,7 @@ angular
}
function updateContent() {
contentGeneration++;
$scope.content = "";
$scope.file = getSelectedFile();
let fileVersion = "0";
@@ -3184,11 +3468,18 @@ angular
}
function init() {
contentGeneration++;
$scope.files = [];
$scope.content = null;
$scope.fileCounts = null;
$scope.fileSearchQuery = "";
$scope.onFileSearchChange();
$scope.repoId = $routeParams.repoId;
$scope.type = "loading";
$scope.filePath = $routeParams.path || "";
$scope.paths = $scope.filePath.split("/");
const repoId = $scope.repoId;
getOptions(function (options) {
fetchFileCounts();
var chain = $q.resolve();
@@ -3203,6 +3494,7 @@ angular
});
}
chain.then(function () {
if (destroyed || repoId !== $scope.repoId) return;
if ($scope.files.length == 1 && $scope.files[0].name == "") {
$scope.files = [];
$scope.type = "empty";
@@ -3248,6 +3540,7 @@ angular
$http.get(`/api/pr/${$scope.pullRequestId}/content`).then(
(res) => {
$scope.details = res.data;
$scope.tabState = { active: res.data.diff ? "diff" : "comments" };
if (callback) {
callback(res.data);
}
@@ -3460,7 +3753,7 @@ angular
const start = new Date();
start.setDate(1);
start.setMonth(start.getMonth() + 1);
const end = new Date();
const end = new Date(start);
end.setMonth(start.getMonth() + 7, 0);
$scope.options = {
startDate: start,
+2 -2
View File
File diff suppressed because one or more lines are too long
+280
View File
@@ -0,0 +1,280 @@
/**
* 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);
});
const conferences = [
{
conferenceID: "FSE25",
name: "Foundations of Software Engineering 2025",
url: "https://conf.researchr.org/home/fse-2025",
startDate: ago(400),
endDate: ahead(30),
status: "ready",
options: opts({ page: true }),
plan: { planID: "premium_conference", name: "Premium", pricePerRepository: 0.5 },
price: 12.5,
nbRepositories: 2,
repositories: repositories.slice(0, 4).map((r) => Object.assign({}, r, { addDate: ago(60) })),
},
{
conferenceID: "ICSE26-AE",
name: "ICSE 2026 Artifact Evaluation",
url: "https://conf.researchr.org/home/icse-2026",
startDate: ahead(10),
endDate: ahead(120),
status: "ready",
options: opts(),
plan: { planID: "free_conference", name: "Free", pricePerRepository: 0 },
price: 0,
nbRepositories: 0,
repositories: [],
},
{
conferenceID: "FSE23",
name: "Foundations of Software Engineering 2023",
url: "",
startDate: ago(1200),
endDate: ago(900),
status: "expired",
options: opts(),
plan: { planID: "free_conference", name: "Free", pricePerRepository: 0 },
price: 0,
nbRepositories: 3,
repositories: [],
},
];
const plans = [
{ id: "free_conference", name: "Free", pricePerRepo: 0, storagePerRepo: -1, description: "<li><strong>Quota is deducted from user account</strong></li><li>No download</li><li>Conference dashboard</li>" },
{ id: "premium_conference", name: "Premium", pricePerRepo: 0.5, storagePerRepo: 500 * 8 * 1024, description: "<li>500 MB per repository</li><li>Repository download</li><li>Conference dashboard</li>" },
{ id: "unlimited_conference", name: "Unlimited", pricePerRepo: 3, storagePerRepo: 0, description: "<li><strong>Unlimited</strong> repository size</li><li>Repository download</li><li>Conference dashboard</li>" },
];
const stat = { nbRepositories: 41230, nbUsers: 9870, nbPageViews: 3120000, nbPullRequests: 1480 };
const history = Array.from({ length: 60 }, (_, i) => ({
date: ago(59 - i),
nbRepositories: 40000 + i * 20,
nbUsers: 9500 + i * 6,
nbPageViews: 3000000 + i * 2000,
nbPullRequests: 1400 + i,
}));
// ANON=1 serves the signed-out experience (landing page, FAQ) instead.
app.get("/api/user", (req, res) =>
process.env.ANON
? res.status(401).json({ error: "not_connected" })
: res.json({ username: "tdurieux", photo: "https://avatars.githubusercontent.com/u/5577568?v=4", isAdmin: false })
);
app.get("/api/options", (req, res) => res.json({ MAX_REPO_SIZE: 8 * 1024, ANONYMIZATION_MASK: "XXXX" }));
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({ terms: ["Jane Smith", "MIT CSAIL"], options: { link: true, image: true, pdf: true, notebook: true, loc: true, page: false, update: false, mode: "GitHubStream", expirationMode: "remove" } })
);
app.post("/api/user/default", (req, res) => res.json({}));
app.get("/api/conferences/plans", (req, res) => res.json(plans));
app.get("/api/conferences/", (req, res) => res.json(conferences));
app.get("/api/conferences/:id", (req, res) => {
const c = conferences.find((x) => x.conferenceID === req.params.id);
return c ? res.json(c) : res.status(404).json({ error: "conf_not_found" });
});
app.get("/api/repo/:id", (req, res) => {
const r = repositories.find((x) => x.repoId === req.params.id) || repositories[0];
res.json(r);
});
app.get("/api/stat", (req, res) => res.json(stat));
app.get("/api/stat/history", (req, res) => res.json(history));
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();
});
// index: false so "/" falls through to the placeholder-filled index.html below.
app.use(express.static(PUBLIC_DIR, { etag: false, cacheControl: false, index: false }));
app.get("/{*rest}", (req, res) => res.type("html").send(indexHtml()));
app.listen(PORT, () => {
console.log(`mock ui http://localhost:${PORT}/dashboard`);
});
+165
View File
@@ -0,0 +1,165 @@
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,
};
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; \}/);
});
});
});
+150
View File
@@ -0,0 +1,150 @@
const { expect } = require("chai");
const fs = require("fs");
const vm = require("vm");
const path = require("path");
const { setImmediate } = require("timers");
const source = fs.readFileSync(path.join(__dirname, "../public/script/app.js"), "utf8");
function harness(date) {
const defs = {}, routes = {}, timers = new Map();
let timerId = 0;
const chain = new Proxy({}, { get: (_, method) => (...args) => {
if (["controller", "directive"].includes(method)) defs[args[0]] = args[1];
if (method === "config") {
const route = { when(p, opt) { routes[p] = opt; return route; }, otherwise() {} };
args[0].at(-1)(route, { html5Mode() {} }, { useStaticFilesLoader() {}, preferredLanguage() {} });
}
return chain;
} });
const context = { angular: { module: () => chain }, console, Map, Set, Date: date || Date,
navigator: { platform: "Linux" }, document: { location: { pathname: "/r/repo" }, addEventListener() {}, querySelector() {} },
window: {}, Prism: { highlightAll() {} }, encodeURIComponent,
encodePathForUrl: p => p.split("/").map(encodeURIComponent).join("/"),
humanFileSize: x => String(x), parseGithubUrl: () => ({ owner: "owner", repo: "repo" }),
$: () => ({ on() {}, tooltip() {} }),
setTimeout: fn => { timers.set(++timerId, fn); return timerId; },
clearTimeout: id => timers.delete(id), setInterval: () => 0, clearInterval() {},
};
vm.runInNewContext(source, context);
const events = {}, watches = {};
const scope = { $new() { return { $destroy() {} }; }, $on: (key, fn) => { (events[key] ||= []).push(fn); }, $watch: (key, fn) => { watches[key] = fn; }, $apply() {}, $applyAsync() {} };
const requests = [];
const http = {};
for (const method of ["get", "post"]) http[method] = (url, body) => new Promise((resolve, reject) => requests.push({ method, url, body, resolve, reject }));
const q = { resolve: () => Promise.resolve(), reject: e => Promise.reject(e), defer: () => { let resolve; const promise = new Promise(r => { resolve = r; }); return { promise, resolve }; } };
return { defs, routes, scope, http, requests, q, events, watches, timers, context,
emit: key => (events[key] || []).forEach(fn => fn()),
flush: () => new Promise(resolve => setImmediate(resolve)),
};
}
function explorer() {
const h = harness(); h.params = { repoId: "repo", path: "README.md" };
h.defs.exploreController.at(-1)(h.scope, h.http, { url: () => "/r/repo/README.md" }, h.params, { trustAsHtml: x => x }, h.q);
h.navigate = path => { h.params.path = path; h.emit("$routeUpdate"); };
return h;
}
describe("frontend production regressions", function () {
it("keeps filenames and folder paths out of compiled Angular templates", function () {
const h = harness(); let template;
const element = { html() {}, append() {}, 0: { addEventListener() {}, setAttribute() {} } };
h.scope.file = [{ name: '{{constructor.constructor("window.probe=1")()}}.txt', path: "", size: 1 }];
h.scope.$parent = {};
h.defs.tree[0]().controller.at(-1)(element, h.scope, {}, html => { template = html; return () => {}; });
h.watches.file(h.scope.file);
expect(template).not.to.include("constructor.constructor");
expect(template).to.include('ng-bind="treeNodes[0].name"');
expect(h.scope.treeNodes[0].name).to.equal(h.scope.file[0].name);
});
it("renders directories whose names collide with Object.prototype", function () {
const h = harness(); let template;
const element = { html() {}, 0: { addEventListener() {}, setAttribute() {} } };
h.scope.file = [{ name: "constructor", path: "" }, { name: "index.js", path: "constructor", size: 1 }];
h.scope.$parent = {};
h.defs.tree[0]().controller.at(-1)(element, h.scope, {}, html => { template = html; return () => {}; });
expect(() => h.watches.file(h.scope.file)).not.to.throw();
expect(template).to.include("treeNodes");
expect(h.scope.treeNodes[0].path).to.equal("/constructor/index.js");
});
it("sanitizes Org output before trusting it", async function () {
const h = explorer(); let untrusted;
h.context.Org = { Parser: function () { this.parse = () => ({ convert: () => ({ toString: () => '<img onerror="probe()">' }) }); }, ConverterHTML: {} };
h.context.contentAbs2Relative = x => x;
h.context.DOMPurify = { sanitize: html => { untrusted = html; return "sanitized"; } };
h.navigate("file.org"); h.requests.at(-1).resolve({ data: "org source", headers: () => "text/plain" }); await h.flush();
expect(untrusted).to.include("onerror"); expect(h.scope.content).to.equal("sanitized");
});
it("ignores stale successes and failures after selecting another file", async function () {
const h = explorer();
h.navigate("first.js"); const first = h.requests.at(-1);
h.navigate("second.js"); const second = h.requests.at(-1);
second.resolve({ data: "SECOND", headers: () => "text/plain" }); await h.flush();
first.resolve({ data: "FIRST", headers: () => "text/plain" }); await h.flush();
expect(h.scope.content).to.equal("SECOND");
h.navigate("third.js"); const third = h.requests.at(-1);
h.navigate("fourth.pdf"); third.reject({ status: 500 }); await h.flush();
expect(h.scope.type).to.equal("pdf");
});
it("reloads the repository when only its ID changes", function () {
const h = explorer(); h.scope.files = [{ name: "old", path: "old" }];
h.params.repoId = "new-repo"; h.emit("$routeUpdate");
expect(h.scope.repoId).to.equal("new-repo"); expect(h.scope.files).to.have.length(0);
expect(h.requests.at(-1).url).to.equal("/api/repo/new-repo/options");
});
it("reloads PR and Gist controllers for new resource IDs", function () {
const h = harness();
for (const route of ["/pr/:pullRequestId/:path*?", "/gist/:gistId/:path*?"]) {
expect(h.routes[route].reloadOnUrl).not.to.equal(false);
expect(h.routes[route].reloadOnSearch).to.equal(false);
}
});
it("includes PR comment authors and bodies in the preview batch", async function () {
const h = harness(); const pending = new Map(); let id = 0;
const timeout = fn => { pending.set(++id, fn); return id; }; timeout.cancel = id => pending.delete(id);
h.defs.anonymizeController.at(-1)(h.scope, h.http, {}, {}, {}, () => {}, timeout);
h.scope.detectedType = "pr"; h.scope.terms = "Alice";
h.scope.details = { pullRequest: { title: "title", comments: [{ author: "Alice", body: "Alice comment" }] } };
h.watches.terms(); [...pending.values()][0]();
const request = h.requests.at(-1);
expect(Array.from(request.body.contents)).to.deep.equal(["title", "Alice", "Alice comment"]);
request.resolve({ data: { contents: ["title", "MASK", "MASK comment"] } }); await h.flush();
expect(h.scope.anonymizePrContent("Alice")).to.equal("MASK");
});
it("selects the diff tab after asynchronous PR loading", async function () {
const h = harness(); h.defs.pullRequestController.at(-1)(h.scope, h.http, {}, { pullRequestId: "pr" }, {});
h.requests[0].resolve({ data: {} }); await h.flush();
h.requests[1].resolve({ data: { diff: "patch" } }); await h.flush();
expect(h.scope.tabState.active).to.equal("diff");
const template = fs.readFileSync(path.join(__dirname, "../public/partials/pullRequest.htm"), "utf8");
expect(template).not.to.include('ng-init="tabState');
});
it("finishes failed searches without letting canceled requests reset the next search", async function () {
const h = explorer(); h.scope.fileSearchQuery = "old"; h.scope.onFileSearchChange(); const old = h.requests.at(-1);
h.scope.fileSearchQuery = "new"; h.scope.onFileSearchChange(); const current = h.requests.at(-1);
old.reject({ status: -1 }); await h.flush(); expect(h.scope.fileSearchLoading).to.equal(true);
current.reject({ status: 500 }); await h.flush(); expect(h.scope.fileSearchLoading).to.equal(false);
expect(h.scope.fileSearchResults).to.have.length(0);
});
it("cancels status polling on destroy, including late responses", async function () {
const h = harness(); h.defs.statusController.at(-1)(h.scope, h.http, { repoId: "repo" });
h.requests[0].resolve({ data: { status: "preparing" } }); await h.flush();
expect(h.timers.size).to.equal(1); const callback = [...h.timers.values()][0];
h.emit("$destroy"); expect(h.timers.size).to.equal(0); callback(); expect(h.requests).to.have.length(1);
const late = harness(); late.defs.statusController.at(-1)(late.scope, late.http, { repoId: "repo" });
late.emit("$destroy"); late.requests[0].resolve({ data: { status: "preparing" } }); await late.flush(); expect(late.timers.size).to.equal(0);
});
it("translates profile save failures", async function () {
const h = harness(); expect(h.defs.profileController).to.include("$translate");
const timeout = Object.assign(() => 0, { cancel() {} });
h.defs.profileController.at(-1)(h.scope, h.http, key => Promise.resolve(key), timeout, { load: () => Promise.resolve({}) });
h.scope.saveDefault(); h.requests.at(-1).reject({ data: { error: "not_connected" } }); await h.flush();
expect(h.scope.error).to.equal("ERRORS.not_connected");
});
it("keeps the conference end date after the start across December", function () {
class December extends Date { constructor(...args) { super(...(args.length ? args : ["2026-12-15T12:00:00Z"])); } }
const h = harness(December); h.scope.user = {};
h.defs.newConferenceController.at(-1)(h.scope, h.http, {}, {});
expect(h.scope.options.startDate.getFullYear()).to.equal(2027);
expect(h.scope.options.endDate.getTime()).to.be.greaterThan(h.scope.options.startDate.getTime());
});
});