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", "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", "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. */ Serif display (Instrument Serif), Inter body, JetBrains Mono. */
.dark-mode { .dark-mode {
/* Warm charcoal — softened from pure black */ /* Warm ink canvas — same temperature as the light paper palette so
--canvas-bg-color: #1f1f1f; cards (#25231F) and borders (#33302B) sit on a matching base. */
--canvas-bg-color: #1B1916;
--sidebar-bg-color: #1A1918; --sidebar-bg-color: #1A1918;
--main-bg-color: #1f1f1f; --main-bg-color: #1B1916;
--header-bg-color: #1A1918; --header-bg-color: #1A1918;
--hover-bg-color: rgba(255, 255, 255, 0.05); --hover-bg-color: rgba(255, 255, 255, 0.05);
@@ -29,9 +30,20 @@
--accent: #A7B2FF; --accent: #A7B2FF;
--accent-soft: rgba(167, 178, 255, 0.12); --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; --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-bg: #1A1918;
--admin-nav-active: rgba(167, 178, 255, 0.12); --admin-nav-active: rgba(167, 178, 255, 0.12);
--admin-stat-bg: #25231F; --admin-stat-bg: #25231F;
@@ -70,12 +82,27 @@ body {
--accent: #3B4AD6; --accent: #3B4AD6;
--accent-soft: #EEF0FF; --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; --ink-soft: #4A4641;
--paper-bg-alt: #F3F1EC; --paper-bg-alt: #F3F1EC;
--paper-card: #FFFFFF; --paper-card: #FFFFFF;
--highlight: #FFF3C4; --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-serif: 'Instrument Serif', 'Iowan Old Style', Georgia, serif;
--font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif; --font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; --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:focus,
.btn:active { .btn:active {
outline: none !important;
box-shadow: none; box-shadow: none;
} }
textarea, :focus:not(:focus-visible) {
select,
input,
button {
outline: none; 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:hover,
input:active { input:active {
box-shadow: none !important; box-shadow: none !important;
@@ -1898,9 +1937,13 @@ code {
opacity: 0.7; opacity: 0.7;
cursor: pointer; cursor: pointer;
padding: 0; 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; font-size: 1rem;
line-height: 1; line-height: 1;
border-radius: 999px;
} }
.filter-chip-close:hover { .filter-chip-close:hover {
@@ -2025,8 +2068,8 @@ code {
.type-badge { .type-badge {
font-family: var(--font-mono); font-family: var(--font-mono);
letter-spacing: 0.06em; letter-spacing: 0.06em;
padding: 2px 7px; padding: 2px 8px;
border-radius: 3px; border-radius: 999px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
background: var(--paper-bg-alt, transparent); background: var(--paper-bg-alt, transparent);
} }
@@ -3372,16 +3415,26 @@ code {
} }
/* Paper progress bar */ /* 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 { .paper-progress {
position: relative; position: relative;
height: auto;
background: none;
margin: 18px 0 8px;
}
.paper-progress::before {
content: "";
display: block;
height: 8px; height: 8px;
background: var(--paper-bg-alt); background: var(--paper-bg-alt);
border-radius: 999px; border-radius: 999px;
overflow: visible;
margin: 18px 0 8px;
} }
.paper-progress .paper-progress-bar { .paper-progress .paper-progress-bar {
height: 100%; position: absolute;
top: 0;
left: 0;
height: 8px;
background: var(--color); background: var(--color);
border-radius: 999px; border-radius: 999px;
transition: width 0.4s ease; transition: width 0.4s ease;
@@ -3898,7 +3951,7 @@ code {
.paper-crumbs { .paper-crumbs {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 11.5px; font-size: 11px;
color: var(--ink-muted); color: var(--ink-muted);
letter-spacing: 0.02em; letter-spacing: 0.02em;
} }
@@ -3948,7 +4001,7 @@ code {
.paper-section-eyebrow { .paper-section-eyebrow {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 10.5px; font-size: 11px;
letter-spacing: 0.14em; letter-spacing: 0.14em;
text-transform: uppercase; text-transform: uppercase;
color: var(--ink-muted); color: var(--ink-muted);
@@ -4161,6 +4214,12 @@ textarea::selection {
height: 38px; height: 38px;
background: var(--paper-card); 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 .dropdown-toggle,
.paper-page .dashboard-filter-row .btn-group .btn { .paper-page .dashboard-filter-row .btn-group .btn {
background: var(--paper-card); background: var(--paper-card);
@@ -4170,12 +4229,30 @@ textarea::selection {
padding: 0 14px; padding: 0 14px;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
border-radius: 6px; border-radius: var(--radius-control, 6px);
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
line-height: 1; 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 .dropdown-toggle:hover,
.paper-page .dashboard-filter-row .btn-group .btn:hover { .paper-page .dashboard-filter-row .btn-group .btn:hover {
background: var(--hover-bg-color); background: var(--hover-bg-color);
@@ -4222,20 +4299,42 @@ textarea::selection {
} }
.paper-page .quota-value { .paper-page .quota-value {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 12px; font-size: 13px;
color: var(--ink-soft); 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; height: 6px;
background: var(--paper-bg-alt); background: var(--paper-bg-alt);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 999px; border-radius: 999px;
margin-bottom: 0; overflow: hidden;
width: 100%; width: 100%;
min-width: 0; 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); 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 */ /* Dashboard repo/PR list refinements */
@@ -4324,10 +4423,81 @@ textarea::selection {
.paper-settings-section .form-group { margin-bottom: 18px; } .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 { 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: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 .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 { padding-top: 24px; display: flex; flex-direction: column; gap: 14px; }
.paper-settings-footer .btn-ink { align-self: flex-start; } .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 { .paper-field-label {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
@@ -4381,12 +4551,22 @@ textarea::selection {
grid-template-columns: 28px minmax(280px, 2.4fr) 90px 120px 100px 52px !important; grid-template-columns: 28px minmax(280px, 2.4fr) 90px 120px 100px 52px !important;
gap: 12px !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 { .paper-table .paper-table-head {
background: transparent; background: transparent;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 10.5px; font-size: 11px;
letter-spacing: 0.14em; letter-spacing: 0.12em;
text-transform: uppercase; text-transform: uppercase;
color: var(--ink-muted); color: var(--ink-muted);
padding-top: 8px; padding-top: 8px;
@@ -4397,13 +4577,25 @@ textarea::selection {
user-select: none; user-select: none;
display: inline-flex; display: inline-flex;
align-items: center; 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:hover { color: var(--color); }
.paper-table .paper-table-head .sortable.active { color: var(--color); } .paper-table .paper-table-head .sortable.active { color: var(--color); }
.paper-table .paper-table-head .sortable i { font-size: 10px; } .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-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 { .paper-table .paper-table-row {
border-top: 1px solid var(--border-soft, var(--border-color)); border-top: 1px solid var(--border-soft, var(--border-color));
font-size: 14px; font-size: 14px;
@@ -4412,6 +4604,8 @@ textarea::selection {
.paper-table .paper-table-row:hover { .paper-table .paper-table-row:hover {
background: var(--hover-bg-color); 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 /* Dim inactive rows through their cells rather than the row itself. Applying
opacity to the row creates a stacking context that traps the actions opacity to the row creates a stacking context that traps the actions
dropdown menu (z-index) beneath the rows rendered after it, making its dropdown menu (z-index) beneath the rows rendered after it, making its
@@ -4419,20 +4613,16 @@ textarea::selection {
interactive. */ interactive. */
.paper-table .paper-table-row.repo-inactive > *:not(.cell-actions) { opacity: 0.55; } .paper-table .paper-table-row.repo-inactive > *:not(.cell-actions) { opacity: 0.55; }
.paper-table .paper-table-row.repo-error { .paper-table .paper-table-row.repo-error {
background: rgba(180, 35, 24, 0.04); background: var(--status-error-soft);
border-left: 2px solid #B42318; border-left: 2px solid var(--status-error);
padding-left: 6px; padding-left: 6px;
} }
.paper-table .paper-table-row.repo-error:hover { background: rgba(180, 35, 24, 0.08); } .paper-table .paper-table-row.repo-error:hover { background: var(--status-error-soft-hover); }
.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 .cell-status .status-sub-error, .paper-table .cell-status .status-sub-error,
.cell-status .status-sub.status-sub-error { .cell-status .status-sub.status-sub-error {
color: #B42318; color: var(--status-error);
font-family: var(--font-mono);
} }
.dark-mode .paper-table .cell-status .status-sub-error, .paper-table .cell-status .status-sub-warn { color: var(--status-progress); }
.dark-mode .cell-status .status-sub.status-sub-error { color: #F08A82; }
.paper-table .cell-anon { .paper-table .cell-anon {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -4444,8 +4634,16 @@ textarea::selection {
min-width: 52px; min-width: 52px;
justify-content: center; 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 { .paper-table .cell-anon .repo-name {
/* Identifiers stay mono; everything human-readable below is sans. */
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 14px; font-size: 14px;
color: var(--color); color: var(--color);
@@ -4455,45 +4653,174 @@ textarea::selection {
white-space: nowrap; white-space: nowrap;
display: block; display: block;
max-width: 100%; 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 { .paper-table .cell-anon .anon-sub {
font-size: 12px; font-family: var(--font-sans);
font-size: 13px;
color: var(--ink-muted); color: var(--ink-muted);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; 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 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 { .paper-table .cell-conf {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 13px; font-size: 11px;
color: var(--color); letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ink-muted);
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.paper-table .cell-conf span { 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-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-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 .cell-expires { font-family: var(--font-sans); font-size: 13px; color: var(--ink-soft); }
.paper-table .empty-dash { color: var(--ink-muted); opacity: 0.5; } .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; } .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 { .status-dot {
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 999px; border-radius: 999px;
display: inline-block; display: inline-block;
background: var(--ink-muted); flex-shrink: 0;
background: var(--status-inactive);
} }
.status-dot.status-ready { background: #2F7A44; } .status-dot.status-ready { background: var(--status-ready); }
.status-dot.status-error { background: #B42318; } .status-dot.status-error { background: var(--status-error); }
.status-dot.status-preparing { background: #C48A2E; } .status-dot.status-progress,
.status-dot.status-removed { background: #9A8F7B; } .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 { .bulk-bar {
display: flex; display: flex;
@@ -5297,13 +5624,18 @@ textarea::selection {
.paper-table .cell-conf, .paper-table .cell-conf,
.paper-table .cell-expires { .paper-table .cell-expires {
order: 3; order: 3;
font-size: 12px; font-size: 13px;
color: var(--ink-muted); color: var(--ink-muted);
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
text-align: left; 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 { .paper-table .cell-conf {
max-width: 100%; max-width: 100%;
overflow: hidden; overflow: hidden;
@@ -5311,6 +5643,9 @@ textarea::selection {
white-space: nowrap; white-space: nowrap;
} }
.paper-table .cell-views.num { text-align: left; } .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 { .paper-table .cell-views::before {
content: '\f06e'; content: '\f06e';
font-family: 'Font Awesome 5 Free'; font-family: 'Font Awesome 5 Free';
@@ -5332,15 +5667,19 @@ textarea::selection {
font-size: 10px; font-size: 10px;
color: var(--ink-muted); 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-conf:has(.empty-dash),
.paper-table .cell-expires:has(.empty-dash) { display: none; } .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-themed dropdown menus (Sort / Status / Actions) */
.paper-page .dropdown-menu { .paper-page .dropdown-menu {
background: var(--paper-card); background: var(--paper-card);
border: 1px solid var(--border-color); 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); box-shadow: 0 12px 28px rgba(26, 24, 21, 0.08);
padding: 6px; padding: 6px;
min-width: 220px; 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 .paper-page-lede { font-size: 13px; line-height: 1.4; margin-bottom: 0; }
.paper-page .btn-ink { padding: 6px 12px; font-size: 13px; } .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 { .paper-page .quota-row {
grid-template-columns: repeat(3, 1fr); grid-template-columns: 1fr;
gap: 10px 12px; gap: 12px;
padding: 0; padding: 0;
margin: 12px 0 0; margin: 16px 0 0;
} }
.paper-page .dashboard-filter-row { .paper-page .dashboard-filter-row {
padding: 8px 0; padding: 10px 0;
margin-top: 10px; margin-top: 12px;
gap: 8px;
} }
.paper-page .quota-header { .paper-page .quota-header {
flex-direction: column; flex-direction: row;
align-items: flex-start; align-items: baseline;
gap: 2px; gap: 8px;
} }
.paper-page .quota-label { font-size: 10px; letter-spacing: 0.1em; } .paper-page .quota-value { white-space: nowrap; }
.paper-page .quota-value { font-size: 11px; }
.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) { @media (max-width: 480px) {
@@ -5476,9 +5823,6 @@ textarea::selection {
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; 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 */ /* Empty state */
@@ -5510,10 +5854,12 @@ textarea::selection {
font-size: 10px; font-size: 10px;
line-height: 10px; line-height: 10px;
} }
.paper-page .progress .progress-bar.bg-success { background: #2F7A44; } /* Bootstrap's .bg-* utilities are !important, so the paper palette must be
.paper-page .progress .progress-bar.bg-warning { background: #C48A2E; color: #fff; } too, otherwise the bright Bootstrap green/yellow/red leak through. */
.paper-page .progress .progress-bar.bg-danger { background: #B42318; } .paper-page .progress .progress-bar.bg-success { background: var(--status-ready) !important; }
.paper-page .progress .progress-bar.bg-dark { background: var(--ink-muted); } .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 > h5,
.paper-page form > h5, .paper-page form > h5,
+5 -5
View File
@@ -15,13 +15,13 @@
type="text" type="text"
class="form-control form-control-lg" class="form-control form-control-lg"
ng-model="sourceUrl" 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-model-options="{ debounce: {default: 1000, blur: 0, click: 0}, updateOn: 'default blur click' }"
ng-change="urlSelected()" ng-change="urlSelected()"
/> />
</div> </div>
<small class="form-text" style="color: var(--ink-muted);"> <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> </small>
</div> </div>
</div> </div>
@@ -90,7 +90,7 @@
</select> </select>
<div class="input-group-append"> <div class="input-group-append">
<button class="btn" type="button" ng-click="getBranches(true)" title="Refresh" data-toggle="tooltip" data-placement="bottom"> <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> </button>
</div> </div>
</div> </div>
@@ -110,8 +110,8 @@
<div class="form-check" ng-show="detectedType"> <div class="form-check" ng-show="detectedType">
<input class="form-check-input" type="checkbox" id="update" name="update" ng-model="options.update" /> <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> <label class="form-check-label" for="update">Auto-update from GitHub</label>
<small class="form-text text-muted">Automatically update with the latest changes (hourly max).</small> <small class="form-text text-muted">Follow the branch and pull the latest commit automatically, at most once an hour.</small>
</div> </div>
</section> </section>
+11 -11
View File
@@ -1,25 +1,26 @@
<div class="container paper-page"> <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> <h1 class="paper-page-title">Claim an <em>anonymization</em></h1>
<p class="paper-page-lede"> <p class="paper-page-lede">
Take ownership of an existing anonymized repository so it appears on your Take ownership of an existing anonymized repository so it appears on your
dashboard. dashboard.
</p> </p>
<div class="paper-meta-rule"></div> <div class="paper-settings-main claim-form">
<form class="form needs-validation" name="claimForm" novalidate> <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"> <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 <input
type="text" type="text"
class="form-control" class="form-control"
name="repoUrl" name="repoUrl"
id="repoUrl" id="repoUrl"
required required
ng-class="{'is-invalid': claimForm.repoUrl.$invalid}" ng-class="{'is-invalid': claimForm.repoUrl.$invalid && (claimForm.repoUrl.$touched || claimForm.$submitted)}"
ng-model="repoUrl" ng-model="repoUrl"
/> />
<div class="invalid-feedback" ng-show="claimForm.repoUrl.$error.not_found"> <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> </div>
<div class="form-group"> <div class="form-group">
@@ -31,11 +32,10 @@
id="repoId" id="repoId"
required required
ng-model="repoId" 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" <small id="idHelp" class="form-text text-muted"
>The id is in the repository >The ID is the last part of the anonymized URL: <code>anonymous.4open.science/r/&lt;ID&gt;</code>.</small
https://anonymous.4open.science/r/{id}</small
> >
</div> </div>
@@ -43,9 +43,9 @@
id="submit" id="submit"
type="submit" type="submit"
class="btn btn-ink" class="btn btn-ink"
ng-click="claim()"
> >
Claim Claim anonymization
</button> </button>
</form> </form>
</div>
</div> </div>
+74 -50
View File
@@ -1,45 +1,46 @@
<div class="container page paper-page"> <div class="container page paper-page">
<div> <div>
<div class="paper-crumbs"> <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>
<div class="d-flex align-items-end flex-wrap" style="gap: 12px; justify-content: space-between;"> <div class="d-flex align-items-end flex-wrap" style="gap: 12px; justify-content: space-between;">
<div> <div>
<h1 class="paper-page-title"> <h1 class="paper-page-title" ng-bind="conference.name"></h1>
<a ng-href="{{conference.url}}" style="color: var(--color);">{{conference.name}}</a>
</h1>
<p class="paper-page-lede" ng-if="conference.url"> <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> </p>
</div> </div>
<span class="status-dot-wrap"> <div class="d-flex align-items-center flex-wrap" style="gap: 10px;">
<span class="status-dot" ng-class="{'status-removed': conference.status == 'removed' || conference.status == 'expired', 'status-ready': conference.status == 'ready'}"></span> <span class="status-pill" ng-class="{'status-pill-ready': conference.status == 'ready', 'status-pill-removed': conference.status == 'removed' || conference.status == 'expired'}">
<span ng-bind="conference.status | title"></span> <span class="status-dot" ng-class="'status-' + conference.status" aria-hidden="true"></span>
</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>
<div class="paper-meta-rule"> <div class="paper-meta-rule">
<span>ID <b ng-bind="conference.conferenceID"></b></span> <span>ID <b class="commit-hash" ng-bind="conference.conferenceID"></b></span>
<span>From <b>{{conference.startDate | date}}</b></span> <span>Review window <b>{{conference.startDate | date:'mediumDate'}} &ndash; {{conference.endDate | date:'mediumDate'}}</b></span>
<span>Ends <b>{{conference.endDate | date}}</b></span> <span>Repositories <b>{{conference.repositories.length | number}}</b></span>
<span>Repos <b>{{::conference.repositories.length | number}}</b></span> <span>Plan <b ng-bind="conference.plan.name || conference.plan.planID || 'Free'"></b></span>
<span>Price <b>{{conference.price || 0 | number}} &euro;</b></span> <span ng-if="conference.price">Cost so far <b>{{conference.price | number:2}} &euro;</b></span>
</div> </div>
<div class="paper-section-eyebrow">Repositories</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"> <div class="search-wrap">
<input <input
type="search" type="search"
id="search" id="search"
class="form-control" class="form-control"
aria-label="Find repositories" aria-label="Search repositories"
placeholder="Find repositories…" placeholder="Search by ID or source repository…"
autocomplete="off" autocomplete="off"
ng-model="search" ng-model="search"
/> />
</div> </div>
<div class="d-flex flex-wrap" style="gap: 8px"> <div class="dashboard-filter-controls">
<div class="dropdown"> <div class="dropdown">
<button <button
class="btn dropdown-toggle" class="btn dropdown-toggle"
@@ -49,13 +50,14 @@
aria-haspopup="true" aria-haspopup="true"
aria-expanded="false" 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> </button>
<div class="dropdown-menu" aria-labelledby="dropdownSort"> <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"> <div class="form-check dropdown-item">
<input class="form-check-input" type="radio" name="sort" id="anonymizeDate" value="-anonymizeDate" ng-model="orderBy" /> <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>
<div class="form-check dropdown-item"> <div class="form-check dropdown-item">
<input class="form-check-input" type="radio" name="sort" id="sortID" value="repoId" ng-model="orderBy" /> <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" /> <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> <label class="form-check-label" for="sortStatus">Status</label>
</div> </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> </div>
<div class="dropdown"> <div class="dropdown" ng-init="statusLabels = {ready: 'Ready', expired: 'Expired', removed: 'Removed'}">
<button <button
class="btn dropdown-toggle" class="btn dropdown-toggle"
type="button" type="button"
@@ -77,58 +83,72 @@
aria-haspopup="true" aria-haspopup="true"
aria-expanded="false" 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> </button>
<div class="dropdown-menu" aria-labelledby="dropdownStatus"> <div class="dropdown-menu" aria-labelledby="dropdownStatus">
<h6 class="dropdown-header">Select status</h6> <h6 class="dropdown-header">Show statuses</h6>
<div class="form-check dropdown-item"> <div class="form-check dropdown-item" ng-repeat="(key, label) in statusLabels">
<input class="form-check-input" type="checkbox" name="sort" id="statusReady" value="ready" ng-model="filters.status.ready" /> <input class="form-check-input" type="checkbox" id="status-{{key}}" ng-model="filters.status[key]" />
<label class="form-check-label" for="statusReady">Ready</label> <label class="form-check-label" for="status-{{key}}">{{label}}</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>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</form> </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 paper-table-repos w-100" role="table" aria-label="Repositories">
<div class="paper-table-head" role="row"> <div class="paper-table-head" role="row">
<div role="columnheader">Repository</div> <div role="columnheader">Repository</div>
<div role="columnheader">Status</div> <div role="columnheader">Status</div>
<div role="columnheader" class="num">Views</div> <div role="columnheader" class="num">Views</div>
<div role="columnheader">Expires</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>
<div <div
class="paper-table-row" class="paper-table-row"
role="row" role="row"
ng-class="{'repo-inactive': repo.status == 'expired' || repo.status == 'removed'}" 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" ng-repeat="repo in conference.repositories | filter:repoFiler | orderBy:orderBy as filteredRepositories track by repo.repoId"
> >
<div class="cell-anon" role="cell"> <div class="cell-anon" role="cell">
<span class="type-badge type-repo">Repo</span> <span class="type-badge type-repo">Repo</span>
<div class="anon-text"> <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"> <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>
</div> </div>
<div class="cell-status" role="cell"> <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> <div class="status-line">
<span ng-bind="repo.status | title"></span> <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>
<div class="cell-views num" role="cell" ng-bind="::repo.pageView | number"></div> <div class="cell-views num" role="cell" ng-bind="::repo.pageView | number"></div>
<div class="cell-expires" role="cell"> <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="expires-never" ng-if="repo.status == 'ready' && (repo.options.expirationMode === 'never' || !repo.options.expirationDate)">Never</span>
<span class="empty-dash" ng-if="!(repo.options.expirationMode !== 'never' && repo.status == 'ready')">&mdash;</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>
<div class="cell-actions" role="cell"> <div class="cell-actions" role="cell">
<div class="dropdown"> <div class="dropdown">
@@ -138,24 +158,28 @@
data-toggle="dropdown" data-toggle="dropdown"
aria-haspopup="true" aria-haspopup="true"
aria-expanded="false" aria-expanded="false"
aria-label="Actions" aria-label="Actions for {{repo.repoId}}"
> >
<i class="fas fa-ellipsis-h" aria-hidden="true"></i> <i class="fas fa-ellipsis-h" aria-hidden="true"></i>
</button> </button>
<div class="dropdown-menu dropdown-menu-right"> <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}}/"> <a class="dropdown-item" href="/r/{{repo.repoId}}/">
<i class="fa fa-eye" aria-hidden="true"></i> View <i class="fa fa-eye" aria-hidden="true"></i> View
</a> </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> </div>
</div> </div>
<div class="paper-table-empty" ng-if="filteredRepositories.length == 0"> <div class="paper-table-empty" ng-if="filteredRepositories.length == 0">
<i class="fas fa-inbox"></i> <i class="fas fa-inbox" aria-hidden="true"></i>
<span>There is no repository to display.</span> <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> </div>
</div> </div>
+60 -43
View File
@@ -1,30 +1,30 @@
<div class="container page paper-page"> <div class="container page paper-page">
<div class="row"> <div class="row">
<div class="w-100"> <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 class="d-flex align-items-end justify-content-between flex-wrap" style="gap: 12px;">
<div> <div>
<h1 class="paper-page-title">Your <em>conferences</em></h1> <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> </div>
<a href="/conference/new" class="btn btn-ink"> <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> </a>
</div> </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"> <div class="search-wrap">
<input <input
type="search" type="search"
id="search" id="search"
class="form-control" class="form-control"
aria-label="Find a conference" aria-label="Search conferences"
placeholder="Find a conference…" placeholder="Search by name or ID…"
autocomplete="off" autocomplete="off"
ng-model="search" ng-model="search"
/> />
</div> </div>
<div class="d-flex flex-wrap" style="gap: 8px"> <div class="dashboard-filter-controls">
<div class="dropdown"> <div class="dropdown">
<button <button
class="btn dropdown-toggle" class="btn dropdown-toggle"
@@ -34,10 +34,11 @@
aria-haspopup="true" aria-haspopup="true"
aria-expanded="false" 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> </button>
<div class="dropdown-menu" aria-labelledby="dropdownSort"> <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"> <div class="form-check dropdown-item">
<input class="form-check-input" type="radio" name="sort" id="sortName" value="name" ng-model="orderBy" /> <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> <label class="form-check-label" for="sortName">Name</label>
@@ -53,7 +54,7 @@
</div> </div>
</div> </div>
<div class="dropdown"> <div class="dropdown" ng-init="statusLabels = {ready: 'Ready', expired: 'Expired', removed: 'Removed'}">
<button <button
class="btn dropdown-toggle" class="btn dropdown-toggle"
type="button" type="button"
@@ -62,26 +63,33 @@
aria-haspopup="true" aria-haspopup="true"
aria-expanded="false" 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> </button>
<div class="dropdown-menu" aria-labelledby="dropdownStatus"> <div class="dropdown-menu" aria-labelledby="dropdownStatus">
<h6 class="dropdown-header">Select status</h6> <h6 class="dropdown-header">Show statuses</h6>
<div class="form-check dropdown-item"> <div class="form-check dropdown-item" ng-repeat="(key, label) in statusLabels">
<input class="form-check-input" type="checkbox" name="sort" id="statusReady" value="ready" ng-model="filters.status.ready" /> <input class="form-check-input" type="checkbox" id="status-{{key}}" ng-model="filters.status[key]" />
<label class="form-check-label" for="statusReady">Ready</label> <label class="form-check-label" for="status-{{key}}">{{label}}</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>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</form> </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>
<div class="paper-table paper-table-conferences w-100" role="table" aria-label="Conferences"> <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">Conference</div>
<div role="columnheader">Status</div> <div role="columnheader">Status</div>
<div role="columnheader" class="num">Repos</div> <div role="columnheader" class="num">Repos</div>
<div role="columnheader">Window</div> <div role="columnheader">Review window</div>
<div role="columnheader" aria-label="Actions"></div> <div role="columnheader"><span class="sr-only">Actions</span></div>
</div> </div>
<div <div
class="paper-table-row" class="paper-table-row row-clickable"
role="row" role="row"
ng-class="{'repo-inactive': conference.status == 'expired' || conference.status == 'removed'}" 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"> <div class="cell-anon" role="cell">
<span class="type-badge type-repo">Conf</span> <span class="type-badge type-repo">Conf</span>
<div class="anon-text"> <div class="anon-text">
<a class="repo-name" ng-href="/conference/{{conference.conferenceID}}" ng-bind="conference.name"></a> <a class="repo-name" ng-href="/conference/{{conference.conferenceID}}" ng-bind="conference.name"></a>
<div class="anon-sub"> <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>
</div> </div>
<div class="cell-status" role="cell"> <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> <div class="status-line">
<span ng-bind="conference.status | title"></span> <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>
<div class="cell-views num" role="cell" ng-bind="::conference.nbRepositories || 0 | number"></div> <div class="cell-views num" role="cell" ng-bind="::conference.nbRepositories || 0 | number"></div>
<div class="cell-expires" role="cell"> <div class="cell-expires" role="cell">
<span ng-if="conference.startDate">{{conference.startDate | date}} &ndash; {{conference.endDate | date}}</span> <span ng-if="conference.startDate">{{conference.startDate | date:'mediumDate'}} &ndash; {{conference.endDate | date:'mediumDate'}}</span>
<span class="empty-dash" ng-if="!conference.startDate">&mdash;</span> <span class="empty-dash" ng-if="!conference.startDate" aria-label="No review window">&mdash;</span>
</div> </div>
<div class="cell-actions" role="cell"> <div class="cell-actions" role="cell">
<div class="dropdown"> <div class="dropdown">
<button <button
class="btn btn-icon-dots" class="btn btn-icon-dots"
type="button" type="button"
id="dropdownMenuButton"
data-toggle="dropdown" data-toggle="dropdown"
aria-haspopup="true" aria-haspopup="true"
aria-expanded="false" aria-expanded="false"
aria-label="Actions" aria-label="Actions for {{conference.name}}"
> >
<i class="fas fa-ellipsis-h" aria-hidden="true"></i> <i class="fas fa-ellipsis-h" aria-hidden="true"></i>
</button> </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"> <a class="dropdown-item" href="/conference/{{conference.conferenceID}}/edit">
<i class="far fa-edit" aria-hidden="true"></i> Edit <i class="far fa-edit" aria-hidden="true"></i> Edit
</a> </a>
<a class="dropdown-item" href="#" ng-show="conference.status != 'removed'" ng-click="removeConference(conference)"> <div ng-if="conference.status != 'removed'">
<i class="fas fa-trash-alt"></i> Remove <div class="dropdown-divider"></div>
</a> <button type="button" class="dropdown-item dropdown-item-danger" ng-click="removeConference(conference)">
<a class="dropdown-item" href="/conference/{{conference.conferenceID}}/"> <i class="fas fa-trash-alt" aria-hidden="true"></i> Remove
<i class="fa fa-eye" aria-hidden="true"></i> View conference </button>
</a> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="paper-table-empty" ng-if="filteredConferences.length == 0"> <div class="paper-table-empty" ng-if="filteredConferences.length == 0">
<i class="fas fa-inbox"></i> <i class="fas fa-inbox" aria-hidden="true"></i>
<span>There are no conference entries. You can create a <a href="/conference/new">new one</a>.</span> <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> </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 class="d-flex align-items-end justify-content-between flex-wrap" style="gap: 12px;">
<div> <div>
<h1 class="paper-page-title">Your <em>anonymizations</em></h1> <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> </div>
<a href="/anonymize" class="btn btn-ink"> <a href="/anonymize" class="btn btn-ink">
<i class="fa fa-plus-circle mr-1" aria-hidden="true"></i> New anonymization <i class="fa fa-plus-circle mr-1" aria-hidden="true"></i> New anonymization
</a> </a>
</div> </div>
<!-- Quota bars --> <!-- Quota -->
<div class="quota-row" ng-show="quota"> <div class="quota-row" ng-if="quota">
<div class="quota-item"> <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"> <div class="quota-header">
<span class="quota-label">Repositories</span> <span class="quota-label">{{q.label}}</span>
<span class="quota-value" ng-show="quota">{{quota.repository.used | number}}/{{quota.repository.total}}</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>
<div class="progress quota-progress"> <div
<div class="quota-track"
class="progress-bar" ng-class="'quota-' + quota[q.key].level"
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"
role="progressbar" aria-label="{{q.label}} quota"
style="width: {{quota.repository.percent}}%;" aria-valuenow="{{quota[q.key].used}}"
aria-valuenow="{{quota.repository.used}}" aria-valuemin="0"
aria-valuemin="0" aria-valuemax="{{quota[q.key].unlimited ? quota[q.key].used : quota[q.key].total}}"
aria-valuemax="{{quota.repository.total}}" aria-valuetext="{{quota[q.key].unlimited ? 'unlimited' : (quota[q.key].percent | number:0) + '% used'}}"
></div> >
</div> <div class="quota-fill" ng-if="!quota[q.key].unlimited" ng-style="{width: quota[q.key].percent + '%'}"></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> </div>
</div> </div>
</div> </div>
<!-- Search + filters row --> <!-- Search + filters -->
<form class="w-100 dashboard-filter-row" aria-label="Dashboard" accept-charset="UTF-8"> <form class="w-100 dashboard-filter-row" aria-label="Filter anonymizations" accept-charset="UTF-8" ng-submit="$event.preventDefault()">
<div class="search-wrap"> <div class="search-wrap">
<input <input
type="search" type="search"
id="search" id="search"
class="form-control" class="form-control"
aria-label="Search..." aria-label="Search anonymizations"
placeholder="Search anonymizations…" placeholder="Search by name, source, or conference…"
autocomplete="off" autocomplete="off"
ng-model="search" ng-model="search"
/> />
</div> </div>
<div class="d-flex flex-wrap" style="gap: 8px"> <div class="dashboard-filter-controls">
<!-- Type filter --> <div class="btn-group" role="group" aria-label="Type">
<div class="btn-group btn-group-sm" role="group"> <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 === '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 === '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 === '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>
<button type="button" class="btn" ng-class="{'btn-primary': typeFilter === 'gist'}" ng-click="typeFilter = 'gist'">Gists</button> </div>
</div>
<div class="dropdown"> <div class="dropdown">
<button <button
class="btn btn-sm dropdown-toggle" class="btn dropdown-toggle"
type="button" type="button"
id="dropdownSort" id="dropdownSort"
data-toggle="dropdown" data-toggle="dropdown"
aria-haspopup="true" aria-haspopup="true"
aria-expanded="false" aria-expanded="false"
> >
Sort <span class="filter-btn-label">Sort</span>
</button> <span class="filter-btn-value">{{sortLabel()}}</span>
<div class="dropdown-menu" aria-labelledby="dropdownSort"> <i class="fas" ng-class="sortDesc() ? 'fa-arrow-down' : 'fa-arrow-up'" aria-hidden="true"></i>
<h6 class="dropdown-header">Select order</h6> <span class="sr-only">{{sortDesc() ? 'descending' : 'ascending'}}</span>
<div class="form-check dropdown-item"> </button>
<input class="form-check-input" type="radio" name="sort" id="sortFullName" value="_name" ng-model="orderBy" /> <div class="dropdown-menu" aria-labelledby="dropdownSort">
<label class="form-check-label" for="sortFullName">Name</label> <h6 class="dropdown-header">Order by</h6>
</div> <div class="form-check dropdown-item" ng-repeat="(field, def) in sortFields">
<div class="form-check dropdown-item"> <input class="form-check-input" type="radio" name="sort" id="sort-{{$index}}" ng-checked="isSortedBy(field)" ng-click="setSort(field, def.defaultDesc)" />
<input class="form-check-input" type="radio" name="sort" id="sortAnonymizeDate" value="-anonymizeDate" ng-model="orderBy" /> <label class="form-check-label" for="sort-{{$index}}">{{def.label}}</label>
<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> </div>
</div> <div class="dropdown-divider"></div>
<button type="button" class="dropdown-item" ng-click="toggleSortDirection()">
<div class="dropdown"> <i class="fas fa-exchange-alt fa-rotate-90" aria-hidden="true"></i>
<button {{sortDesc() ? 'Switch to ascending' : 'Switch to descending'}}
class="btn btn-sm dropdown-toggle"
type="button"
id="dropdownStatus"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
>
Status
</button> </button>
<div class="dropdown-menu" aria-labelledby="dropdownStatus"> </div>
<h6 class="dropdown-header">Select status</h6> </div>
<div class="form-check dropdown-item">
<input class="form-check-input" type="checkbox" id="statusReady" value="ready" ng-model="filters.status.ready" /> <div class="dropdown">
<label class="form-check-label" for="statusReady">Ready</label> <button
</div> class="btn dropdown-toggle"
<div class="form-check dropdown-item"> type="button"
<input class="form-check-input" type="checkbox" id="statusExpired" value="expired" ng-model="filters.status.expired" /> id="dropdownStatus"
<label class="form-check-label" for="statusExpired">Expired</label> data-toggle="dropdown"
</div> aria-haspopup="true"
<div class="form-check dropdown-item"> aria-expanded="false"
<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> <span class="filter-btn-label">Status</span>
</div> <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>
</div> </div>
</div>
</form> </form>
<!-- Active filter chips --> <!-- Result count + 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"> <div class="dashboard-meta" ng-if="!loading">
<span class="filter-chip" ng-show="!v" ng-repeat="(f, v) in filters.status"> <span class="dashboard-count">
{{f | title}} <span ng-if="filteredItems.length === items.length">{{items.length | number}} anonymization<span ng-if="items.length !== 1">s</span></span>
<button type="button" class="filter-chip-close" aria-label="Remove filter" ng-click="filters.status[f] = true;"> <span ng-if="filteredItems.length !== items.length">{{filteredItems.length | number}} of {{items.length | number}} shown</span>
&times;
</button>
</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>
</div> </div>
<!-- Table --> <!-- 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 class="paper-table-head" role="row">
<div role="columnheader">Anonymization</div> <div role="columnheader" aria-sort="{{isSortedBy('_name') ? (sortDesc() ? 'descending' : 'ascending') : 'none'}}">
<div role="columnheader">Conference</div> <button type="button" class="sortable" ng-class="{active: isSortedBy('_name')}" ng-click="setSort('_name')">
<div role="columnheader">Status</div> Anonymization <i class="fas" ng-if="isSortedBy('_name')" ng-class="sortDesc() ? 'fa-arrow-down' : 'fa-arrow-up'" aria-hidden="true"></i>
<div role="columnheader" class="num">Views</div> </button>
<div role="columnheader">Expires</div> </div>
<div role="columnheader" aria-label="Actions"></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> </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 <div
class="paper-table-row" class="paper-table-row"
role="row" role="row"
ng-class="{'repo-inactive': item.status == 'expired' || item.status == 'removed', 'repo-error': item.status == 'error'}" ng-if="!loading"
ng-repeat="item in items | filter:itemFilter | orderBy:orderBy as filteredItems" 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"> <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" 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"> <div class="anon-text">
<a ng-href="{{item._viewUrl}}" class="repo-name" ng-bind="item._name"></a> <div class="anon-title">
<div class="anon-sub"> <a ng-if="item._viewUrl" ng-href="{{item._viewUrl}}" class="repo-name" ng-bind="item._name"></a>
<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> <span ng-if="!item._viewUrl" class="repo-name repo-name-static" ng-bind="item._name"></span>
<a ng-if="item._type === 'pr'" href="https://github.com/{{item.source.repositoryFullName}}/pull/{{item.source.pullRequestId}}" ng-bind="item._source"></a> <span class="type-badge type-coauthor" ng-if="item.role === 'coauthor'" title="You are a co-author on this anonymization">Co-author</span>
<a ng-if="item._type === 'gist'" href="https://gist.github.com/{{item.source.gistId}}" ng-bind="item._source"></a> </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>
</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="cell-status" role="cell">
<div class="status-line"> <div class="status-line">
<span <span class="status-dot" ng-class="'status-' + item._statusKey" aria-hidden="true"></span>
class="status-dot" <span class="status-word" ng-bind="item.status | statusLabel"></span>
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>
</div> </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 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>
<div class="cell-views num" role="cell" ng-bind="item.pageView | number"></div> <div class="cell-views num" role="cell" ng-bind="item.pageView | number"></div>
<div class="cell-expires" role="cell"> <div class="cell-expires" role="cell">
<span ng-if="item.options.expirationMode !== 'never' && item.status == 'ready'" ng-bind="item.options.expirationDate | humanTime"></span> <span ng-if="item._expiry.kind === 'never'" class="expires-never">Never</span>
<span class="empty-dash" ng-if="!(item.options.expirationMode !== 'never' && item.status == 'ready')">&mdash;</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>
<div class="cell-actions" role="cell"> <div class="cell-actions" role="cell">
<div class="dropdown"> <div class="dropdown" ng-if="!item._broken">
<button <button
class="btn btn-icon-dots" class="btn btn-icon-dots"
type="button" type="button"
data-toggle="dropdown" data-toggle="dropdown"
aria-haspopup="true" aria-haspopup="true"
aria-expanded="false" aria-expanded="false"
aria-label="Actions" aria-label="Actions for {{item._name}}"
> >
<i class="fas fa-ellipsis-h" aria-hidden="true"></i> <i class="fas fa-ellipsis-h" aria-hidden="true"></i>
</button> </button>
<div class="dropdown-menu dropdown-menu-right"> <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}}"> <a class="dropdown-item" ng-href="{{item._viewUrl}}">
<i class="fa fa-eye" aria-hidden="true"></i> View <i class="fa fa-eye" aria-hidden="true"></i> View
</a> </a>
<a class="dropdown-item" href="/w/{{item.repoId}}/" target="_self" ng-if="item._type === 'repo' && item.options.page && item.status == 'ready'"> <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>
<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> </div>
</div> </div>
<div class="paper-table-empty" ng-if="filteredItems.length == 0">
<i class="fas fa-inbox"></i> <div class="paper-table-empty" ng-if="!loading && filteredItems.length == 0">
<span>Nothing to display.</span> <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> </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> <h1 class="paper-faq-title">Answers to the <em>questions</em> that come up most.</h1>
<p class="paper-faq-lede"> <p class="paper-faq-lede">
If something's missing, write to us &mdash; 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> </p>
</div> </div>
<div class="paper-faq-body"> <div class="paper-faq-body">
+37 -12
View File
@@ -74,30 +74,37 @@
class="nav-link nav-icon" class="nav-link nav-icon"
target="_blank" target="_blank"
href="https://github.com/tdurieux/anonymous_github/" 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" data-offset="30"
><i class="fab fa-github" aria-hidden="true"></i ><i class="fab fa-github" aria-hidden="true"></i
></a> ></a>
</li> </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"> <li class="nav-item d-none d-lg-block">
<a <a
class="nav-link" class="nav-link"
target="_blank" 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" data-offset="30"
>Report an issue ><i class="fas fa-bug d-lg-none mr-1" aria-hidden="true"></i>Report a bug
</a> </a>
</li> </li>
<li class="nav-item" ng-if="!isDarkMode"> <li class="nav-item" ng-if="!isDarkMode">
<a class="nav-link nav-icon" href="#" ng-click="darkMode(true);" title="Dark mode"> <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"></i> <i class="fas fa-moon" aria-hidden="true"></i>
</a> </a>
</li> </li>
<li class="nav-item" ng-if="isDarkMode"> <li class="nav-item" ng-if="isDarkMode">
<a class="nav-link nav-icon" href="#" ng-click="darkMode(false);" title="Light mode"> <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"></i> <i class="fas fa-sun" aria-hidden="true"></i>
</a> </a>
</li> </li>
@@ -126,12 +133,30 @@
/> />
<span class="user-chip-name">{{user.username}}</span> <span class="user-chip-name">{{user.username}}</span>
</a> </a>
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="navbarDropdownMenuLink"> <div class="dropdown-menu dropdown-menu-right user-menu" aria-labelledby="navbarDropdownMenuLink">
<a class="dropdown-item" href="/profile">Default settings</a> <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> <div class="dropdown-divider"></div>
<a class="dropdown-item" href="/api/user/logout" target="_self" <a class="dropdown-item" href="https://github.com/tdurieux/anonymous_github/issues/new?template=issue_report.yml" target="_blank" rel="noopener">
>Logout</a <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> </div>
</li> </li>
</ul> </ul>
+3 -3
View File
@@ -7,8 +7,8 @@
<span class="accent">not the author.</span> <span class="accent">not the author.</span>
</h1> </h1>
<p class="hero-subtitle"> <p class="hero-subtitle">
Anonymous GitHub makes a read-only mirror of your repository or pull Anonymous GitHub makes a read-only mirror of your repository, pull
request with every trace of identity removed &mdash; so you can attach a request, or gist with every trace of identity removed &mdash; so you can attach a
link to your submission without breaking anonymity. link to your submission without breaking anonymity.
</p> </p>
@@ -264,7 +264,7 @@
<div class="paper-footer-col"> <div class="paper-footer-col">
<div class="paper-footer-head">Project</div> <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/" 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> <a href="https://github.com/sponsors/tdurieux/" target="_blank">Sponsor</a>
</div> </div>
<div class="paper-footer-col"> <div class="paper-footer-col">
+19 -19
View File
@@ -1,5 +1,5 @@
<div class="container paper-page paper-settings"> <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> <h1 class="paper-page-title">{{editionMode ? 'Edit' : 'Create a'}} <em>conference</em></h1>
<p class="paper-page-lede"> <p class="paper-page-lede">
Give chairs access to every anonymization submitted to a venue, and lift Give chairs access to every anonymization submitted to a venue, and lift
@@ -7,8 +7,8 @@
</p> </p>
<div class="paper-settings-body"> <div class="paper-settings-body">
<aside class="paper-settings-toc"> <aside class="paper-settings-toc" paper-scrollspy>
<div class="paper-settings-toc-head">Contents</div> <div class="paper-settings-toc-head">On this page</div>
<nav> <nav>
<a href="#conf-basics">Basics</a> <a href="#conf-basics">Basics</a>
<a href="#conf-window">Review window</a> <a href="#conf-window">Review window</a>
@@ -31,7 +31,7 @@
name="name" name="name"
id="name" id="name"
required required
ng-class="{'is-invalid': conference.name.$invalid}" ng-class="{'is-invalid': conference.name.$invalid && (conference.name.$touched || conference.$submitted)}"
ng-model="options.name" ng-model="options.name"
placeholder="e.g. International Conference on Software Engineering" placeholder="e.g. International Conference on Software Engineering"
/> />
@@ -50,7 +50,7 @@
class="form-control" class="form-control"
name="url" name="url"
id="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" ng-model="options.url"
placeholder="https://example.org" placeholder="https://example.org"
/> />
@@ -68,7 +68,7 @@
id="conferenceID" id="conferenceID"
required required
pattern="[a-zA-Z0-9\-_]{3,10}" 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" ng-model="options.conferenceID"
placeholder="ICSE26" placeholder="ICSE26"
/> />
@@ -97,7 +97,7 @@
name="startDate" name="startDate"
id="startDate" id="startDate"
required required
ng-class="{'is-invalid': conference.startDate.$invalid}" ng-class="{'is-invalid': conference.startDate.$invalid && (conference.startDate.$touched || conference.$submitted)}"
ng-model="options.startDate" ng-model="options.startDate"
/> />
<small class="form-text text-muted">Beginning of the review process.</small> <small class="form-text text-muted">Beginning of the review process.</small>
@@ -117,7 +117,7 @@
name="endDate" name="endDate"
id="endDate" id="endDate"
required required
ng-class="{'is-invalid': conference.endDate.$invalid}" ng-class="{'is-invalid': conference.endDate.$invalid && (conference.endDate.$touched || conference.$submitted)}"
ng-model="options.endDate" ng-model="options.endDate"
/> />
<small class="form-text text-muted">All repositories expire on this date.</small> <small class="form-text text-muted">All repositories expire on this date.</small>
@@ -160,19 +160,19 @@
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="update" name="update" ng-model="options.options.update" /> <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> <small class="form-text text-muted">Pull the latest commit automatically (hourly maximum).</small>
</div> </div>
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="page" name="page" ng-model="options.options.page" /> <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> <small class="form-text text-muted">Enable anonymized GitHub pages.</small>
</div> </div>
</section> </section>
<section id="conf-plan" class="paper-settings-section"> <section id="conf-plan" class="paper-settings-section">
<div class="paper-section-eyebrow">Plan</div> <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"> <div class="paper-plan-grid">
<label <label
@@ -202,14 +202,14 @@
<div class="form-group"> <div class="form-group">
<label class="paper-field-label" for="billing_name">Name</label> <label class="paper-field-label" for="billing_name">Name</label>
<input type="text" class="form-control" name="billing_name" id="billing_name" required <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" /> ng-model="options.billing.name" placeholder="First &amp; last name" />
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="paper-field-label" for="email">Email</label> <label class="paper-field-label" for="email">Email</label>
<input type="email" class="form-control" name="email" id="email" required <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" /> ng-model="options.billing.email" placeholder="you@example.org" />
</div> </div>
@@ -218,12 +218,12 @@
<input type="text" class="form-control" name="inputAddress" id="inputAddress" required <input type="text" class="form-control" name="inputAddress" id="inputAddress" required
placeholder="1234 Main St" placeholder="1234 Main St"
ng-model="options.billing.address" 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>
<div class="form-group"> <div class="form-group">
<input type="text" class="form-control" name="inputAddress2" id="inputAddress2" <input type="text" class="form-control" name="inputAddress2" id="inputAddress2"
ng-model="options.billing.address2" 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" /> placeholder="Apartment, studio, or floor" />
</div> </div>
@@ -232,26 +232,26 @@
<label class="paper-field-label" for="city">City</label> <label class="paper-field-label" for="city">City</label>
<input type="text" class="form-control" name="city" id="city" <input type="text" class="form-control" name="city" id="city"
ng-model="options.billing.city" required 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>
<div class="form-group"> <div class="form-group">
<label class="paper-field-label" for="country">Country</label> <label class="paper-field-label" for="country">Country</label>
<input type="text" class="form-control" name="country" id="country" <input type="text" class="form-control" name="country" id="country"
ng-model="options.billing.country" required 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>
<div class="form-group"> <div class="form-group">
<label class="paper-field-label" for="zip">Zip</label> <label class="paper-field-label" for="zip">Zip</label>
<input type="text" class="form-control" name="zip" id="zip" <input type="text" class="form-control" name="zip" id="zip"
ng-model="options.billing.zip" required 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> </div>
<div class="form-group"> <div class="form-group">
<label class="paper-field-label" for="vat">VAT number</label> <label class="paper-field-label" for="vat">VAT number</label>
<input type="text" class="form-control" name="vat" id="vat" <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" /> ng-model="options.billing.vat" placeholder="VAT Number" />
</div> </div>
</section> </section>
+106 -95
View File
@@ -1,15 +1,15 @@
<div class="container paper-page paper-settings"> <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> <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"> <div class="paper-settings-body">
<aside class="paper-settings-toc"> <aside class="paper-settings-toc" paper-scrollspy>
<div class="paper-settings-toc-head">Contents</div> <div class="paper-settings-toc-head">On this page</div>
<nav> <nav>
<a href="#settings-quota">Quota</a> <a href="#settings-quota">Quota</a>
<a href="#settings-terms">Terms</a> <a href="#settings-terms">Terms to redact</a>
<a href="#settings-rendering">Rendering</a> <a href="#settings-display">Display</a>
<a href="#settings-features">Features</a> <a href="#settings-features">Features</a>
<a href="#settings-expiration">Expiration</a> <a href="#settings-expiration">Expiration</a>
<a href="#settings-account">Account</a> <a href="#settings-account">Account</a>
@@ -17,170 +17,181 @@
</aside> </aside>
<div class="paper-settings-main"> <div class="paper-settings-main">
<!-- Quota: same markup as the dashboard, driven by quotaService -->
<section id="settings-quota" class="paper-settings-section"> <section id="settings-quota" class="paper-settings-section">
<div class="paper-section-eyebrow">Quota</div> <div class="paper-section-eyebrow">Quota</div>
<div class="quota-row"> <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-item"> <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"> <div class="quota-header">
<span class="quota-label">Repositories</span> <span class="quota-label">{{q.label}}</span>
<span class="quota-value" ng-show="quota">{{quota.repository.used | number}}/{{quota.repository.total}}</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>
<div class="progress quota-progress"> <div
<div class="quota-track"
class="progress-bar" ng-class="'quota-' + quota[q.key].level"
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"
role="progressbar" aria-label="{{q.label}} quota"
style="width: {{quota.repository.percent}}%;" aria-valuenow="{{quota[q.key].used}}"
aria-valuenow="{{quota.repository.used}}" aria-valuemin="0"
aria-valuemin="0" aria-valuemax="{{quota[q.key].unlimited ? quota[q.key].used : quota[q.key].total}}"
aria-valuemax="{{quota.repository.total}}" aria-valuetext="{{quota[q.key].unlimited ? 'unlimited' : (quota[q.key].percent | number:0) + '% used'}}"
></div> >
<div class="quota-fill" ng-if="!quota[q.key].unlimited" ng-style="{width: quota[q.key].percent + '%'}"></div>
</div> </div>
</div> </div>
<div class="quota-item"> </div>
<div class="quota-header"> <div class="quota-row" ng-if="!quota" aria-hidden="true">
<span class="quota-label">Storage</span> <div class="quota-item" ng-repeat="i in [1, 2, 3]">
<span class="quota-value" ng-show="quota">{{quota.storage.used | humanFileSize}}/{{quota.storage.total | humanFileSize}}</span> <div class="quota-header"><span class="skeleton skeleton-line" style="width: 40%"></span><span class="skeleton skeleton-line" style="width: 25%"></span></div>
</div> <div class="quota-track"></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>
</div> </div>
</section> </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"> <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"> <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 <textarea
class="form-control" class="form-control"
id="terms" id="terms"
name="terms" name="terms"
rows="4" rows="4"
placeholder="Jane Smith&#10;MIT CSAIL&#10;jane.smith@example.org"
ng-model="terms" ng-model="terms"
ng-model-options="{ debounce: 250 }" ng-model-options="{ debounce: 250 }"
></textarea> ></textarea>
<small id="termsHelp" class="form-text text-muted">One term per line. Each term will be replaced by XXX.</small> <small id="termsHelp" class="form-text text-muted">
<div class="invalid-feedback" ng-show="anonymize.terms.$error.format"> 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 Terms are in an invalid format
</div> </div>
</div> </div>
</section> </section>
<section id="settings-rendering" class="paper-settings-section"> <section id="settings-display" class="paper-settings-section">
<div class="paper-section-eyebrow">Rendering</div> <div class="paper-section-eyebrow">Display</div>
<div class="form-group"> <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"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="link" name="link" ng-model="options.link" /> <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> <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>
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="image" name="image" ng-model="options.image" /> <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> <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>
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="pdf" name="pdf" ng-model="options.pdf" /> <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> <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>
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="notebook" name="notebook" ng-model="options.notebook" /> <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>
</div> </div>
</section> </section>
<section id="settings-features" class="paper-settings-section"> <section id="settings-features" class="paper-settings-section">
<div class="paper-section-eyebrow">Features</div> <div class="paper-section-eyebrow">Features</div>
<div class="form-group"> <div class="form-group paper-check-list">
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="page" name="page" ng-model="options.page" /> <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> <label class="form-check-label" for="page">GitHub Pages</label>
<small class="form-text text-muted">Enable anonymized GitHub pages. Currently only supported for pages defined in the same branch.</small> <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>
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="loc" name="loc" ng-model="options.loc" /> <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> <label class="form-check-label" for="loc">Lines of code</label>
<small class="form-text text-muted">Display the number of lines of code in the repository.</small> <small class="form-text text-muted">Show the line count of the repository in the explorer.</small>
</div> </div>
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="update" name="update" ng-model="options.update" /> <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> <label class="form-check-label" for="update">Auto-update from GitHub</label>
<small class="form-text text-muted">Automatically update the anonymized repository with the latest commit (once per hour maximum).</small> <small class="form-text text-muted">Follow the branch and pull the latest commit automatically, at most once an hour.</small>
</div> </div>
</div> </div>
<div class="form-group"> <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"> <select class="form-control" id="mode" name="mode" ng-model="options.mode">
<option value="GitHubStream" selected>Stream</option> <option value="GitHubStream" selected>Stream from GitHub on demand</option>
<option value="GitHubDownload">Download</option> <option value="GitHubDownload">Download to Anonymous GitHub</option>
</select> </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> </div>
</section> </section>
<section id="settings-expiration" class="paper-settings-section"> <section id="settings-expiration" class="paper-settings-section">
<div class="paper-section-eyebrow">Expiration</div> <div class="paper-section-eyebrow">Expiration</div>
<div class="form-group"> <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"> <select class="form-control" id="expiration" name="expiration" ng-model="options.expirationMode">
<option value="redirect">Redirect to GitHub</option> <option value="redirect">Redirect visitors to the GitHub repository</option>
<option value="remove" selected>Remove anonymized repository</option> <option value="remove" selected>Remove the anonymized repository</option>
</select> </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> </div>
</section> </section>
<div class="paper-settings-footer"> <div class="paper-settings-footer">
<div class="alert alert-danger" role="alert" ng-if="error" ng-bind="error"></div> <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="alert alert-success" role="alert" ng-if="message" ng-bind="message"></div> <div class="paper-settings-actions">
<button id="save" type="submit" class="btn btn-ink" ng-click="saveDefault($event)"> <button id="save" type="submit" class="btn btn-ink" ng-disabled="saving">
Save defaults <i class="fas fa-check mr-1" aria-hidden="true" ng-if="message"></i>{{ saving ? 'Saving…' : (message ? 'Saved' : 'Save defaults') }}
</button> </button>
<span class="paper-settings-hint">Applies to new anonymizations only. Existing ones keep their settings.</span>
</div>
</div> </div>
</form> </form>
<section id="settings-account" class="paper-settings-section"> <section id="settings-account" class="paper-settings-section">
<div class="paper-section-eyebrow">Account</div> <div class="paper-section-eyebrow">Account</div>
<p class="form-text text-muted"> <div class="paper-account-card">
Deleting your account removes all your anonymized repositories, <img ng-src="{{user.photo}}" ng-if="user.photo" width="40" height="40" class="rounded-circle" alt="" />
gists, and pull requests, revokes the application's access to your <div class="paper-account-identity">
GitHub account, and erases your personal data (username, email, <div class="paper-account-name">@{{user.username}}</div>
access tokens) from our database. This cannot be undone. <div class="paper-account-meta">Signed in with GitHub. Anonymous GitHub only reads your repositories.</div>
</p> </div>
<div class="alert alert-danger" role="alert" ng-if="deleteError" ng-bind="deleteError"></div> <a class="btn btn-outline-ink" href="/api/user/logout" target="_self">Sign out</a>
<button type="button" class="btn btn-danger" ng-click="deleteAccount()" ng-disabled="deletingAccount"> </div>
{{ deletingAccount ? 'Deleting…' : 'Delete my account' }}
</button> <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> </section>
</div> </div>
</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="container paper-page pr-page-inner">
<div class="paper-crumbs"> <div class="paper-crumbs">
<a href="/dashboard">Reviewer</a> &nbsp;/&nbsp; <a href="/dashboard">Reviewer</a> &nbsp;/&nbsp;
+3 -3
View File
@@ -57,7 +57,7 @@
<div class="paper-error-actions"> <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 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="/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>
</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> <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> <span class="paper-support-cta">Open on GitHub <i class="fas fa-arrow-right"></i></span>
</a> </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> <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> <span class="paper-support-cta">File an issue <i class="fas fa-arrow-right"></i></span>
</a> </a>
<a class="paper-support-card" href="https://github.com/sponsors/tdurieux/" target="_blank" rel="noopener"> <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", templateUrl: "/partials/pullRequest.htm",
controller: "pullRequestController", controller: "pullRequestController",
title: "Anonymous pull request Anonymous GitHub", title: "Anonymous pull request Anonymous GitHub",
reloadOnUrl: false, reloadOnSearch: false,
}) })
.when("/gist/:gistId/:path*?", { .when("/gist/:gistId/:path*?", {
templateUrl: "/partials/gist.htm", templateUrl: "/partials/gist.htm",
controller: "gistController", controller: "gistController",
title: "Anonymous gist Anonymous GitHub", title: "Anonymous gist Anonymous GitHub",
reloadOnUrl: false, reloadOnSearch: false,
}) })
.when("/r/:repoId/:path*?", { .when("/r/:repoId/:path*?", {
templateUrl: "/partials/explorer.htm", templateUrl: "/partials/explorer.htm",
@@ -212,11 +212,19 @@ angular
seconds = Math.round((Date.now() - new Date(seconds)) / 1000); seconds = Math.round((Date.now() - new Date(seconds)) / 1000);
var suffix = seconds < 0 ? "from now" : "ago"; 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) { if (Math.abs(seconds) > 2 * 60 * 60 * 24) {
const now = new Date(); const now = new Date();
now.setSeconds(now.getSeconds() - seconds); now.setSeconds(now.getSeconds() - seconds);
return "on " + now.toLocaleDateString(); return (
"on " +
now.toLocaleDateString(undefined, {
day: "numeric",
month: "short",
year: "numeric",
})
);
} }
seconds = Math.abs(seconds); seconds = Math.abs(seconds);
@@ -255,7 +263,44 @@ angular
return capitalized.join(" "); 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 () { .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) { return function (msg) {
if (!msg) return msg; if (!msg) return msg;
var m = msg.match(/^rate_limited:(\d+)$/); var m = msg.match(/^rate_limited:(\d+)$/);
@@ -266,9 +311,94 @@ angular
var sec = remaining % 60; var sec = remaining % 60;
return "Rate limited — retrying in " + (min > 0 ? min + "m " + sec + "s" : sec + "s"); 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; 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", [ .filter("diff", [
"$sce", "$sce",
function ($sce) { function ($sce) {
@@ -526,7 +656,7 @@ angular
function ($element, $scope, $routeParams, $compile) { function ($element, $scope, $routeParams, $compile) {
$scope.repoId = document.location.pathname.split("/")[2]; $scope.repoId = document.location.pathname.split("/")[2];
$scope.opens = {}; $scope.opens = Object.create(null);
if ($routeParams.path) { if ($routeParams.path) {
let accumulatedPath = ""; let accumulatedPath = "";
@@ -538,7 +668,8 @@ angular
const toArray = function (arr) { const toArray = function (arr) {
const output = []; const output = [];
const keys = { "": { child: output } }; const keys = Object.create(null);
keys[""] = { child: output };
function ensurePath(path) { function ensurePath(path) {
if (keys[path]) return; if (keys[path]) return;
const segments = path.split("/"); const segments = path.split("/");
@@ -622,23 +753,6 @@ angular
return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); 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() { function buildSearchFilter() {
const results = $scope.searchResults; const results = $scope.searchResults;
if (!results || !results.length) return null; if (!results || !results.length) return null;
@@ -721,12 +835,14 @@ angular
cssClasses.push("truncated"); 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( 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) { 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) { if (truncated) {
output += `<span class="truncated-warning" title="{{ 'WARNINGS.folder_truncated' | translate }}"><i class="fas fa-exclamation-triangle"></i></span>`; 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>`; output += `</a>`;
} else { } else {
const needsSpacer = parentPath !== ""; const needsSpacer = parentPath !== "";
output += `<a href='/r/${$scope.repoId}${encodePathForUrl( 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>`;
path
)}'>${needsSpacer ? '<span class="tree-spacer"></span>' : ''}<span class="tree-icon-file"></span><span class="tree-name">${escapeHtml(name)}</span></a>`;
} }
if (isOpen && collapsed.child) { if (isOpen && collapsed.child) {
const children = collapsed.child; const children = collapsed.child;
@@ -757,7 +871,11 @@ angular
return output + "</ul>"; return output + "</ul>";
} }
let renderScope = null;
function display() { function display() {
if (renderScope) renderScope.$destroy();
renderScope = $scope.$new();
$scope.treeNodes = [];
$element.html(""); $element.html("");
const filterSet = $scope.searchQuery ? buildSearchFilter() : null; const filterSet = $scope.searchQuery ? buildSearchFilter() : null;
let output; let output;
@@ -766,7 +884,7 @@ angular
} else { } else {
output = generate(toArray($scope.file).sort(sortFiles), "", filterSet); output = generate(toArray($scope.file).sort(sortFiles), "", filterSet);
} }
$compile(output)($scope, (clone) => { $compile(output)(renderScope, (clone) => {
$element.append(clone); $element.append(clone);
restoreFocus(); restoreFocus();
}); });
@@ -1150,7 +1268,10 @@ angular
.controller("profileController", [ .controller("profileController", [
"$scope", "$scope",
"$http", "$http",
function ($scope, $http) { "$translate",
"$timeout",
"quotaService",
function ($scope, $http, $translate, $timeout, quotaService) {
$scope.terms = ""; $scope.terms = "";
$scope.options = { $scope.options = {
expirationMode: "remove", expirationMode: "remove",
@@ -1161,10 +1282,17 @@ angular
loc: true, loc: true,
link: true, link: true,
}; };
$scope.saving = false;
$scope.message = null;
$scope.error = null;
quotaService.load().then((quota) => {
$scope.quota = quota;
}, console.error);
function getDefault() { function getDefault() {
$http.get("/api/user/default").then((res) => { $http.get("/api/user/default").then((res) => {
const data = res.data; const data = res.data || {};
if (data.terms) { if (data.terms) {
$scope.terms = data.terms.join("\n"); $scope.terms = data.terms.join("\n");
} }
@@ -1173,20 +1301,36 @@ angular
} }
getDefault(); getDefault();
$scope.saveDefault = () => { let savedTimer = null;
$scope.saveDefault = ($event) => {
if ($event && $event.preventDefault) $event.preventDefault();
const params = { 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, options: $scope.options,
}; };
$scope.saving = true;
$scope.error = null;
$http.post("/api/user/default", params).then( $http.post("/api/user/default", params).then(
() => { () => {
getDefault(); getDefault();
$scope.saving = false;
$scope.message = "Saved"; $scope.message = "Saved";
if (savedTimer) $timeout.cancel(savedTimer);
savedTimer = $timeout(() => {
$scope.message = null;
}, 2500);
}, },
(error) => { (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; $scope.error = translation;
}, console.error); }, () => {
$scope.error = "Unable to save your defaults. Please try again.";
});
} }
); );
}; };
@@ -1338,7 +1482,10 @@ angular
"$scope", "$scope",
"$http", "$http",
"$location", "$location",
function ($scope, $http, $location) { "$q",
"$window",
"quotaService",
function ($scope, $http, $location, $q, $window, quotaService) {
$scope.$on("$routeChangeStart", function () { $scope.$on("$routeChangeStart", function () {
$('[data-toggle="tooltip"]').tooltip("dispose"); $('[data-toggle="tooltip"]').tooltip("dispose");
}); });
@@ -1357,11 +1504,35 @@ angular
$scope.items = []; $scope.items = [];
$scope.search = ""; $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 dashboardPrefsKey = "dashboard.filterPrefs";
const dashboardPrefDefaults = { const dashboardPrefDefaults = {
typeFilter: "all", typeFilter: "all",
filters: { status: { ready: true, expired: true, removed: false } }, filters: {
status: { ready: true, progress: true, error: true, expired: true, removed: false },
},
orderBy: "-anonymizeDate", orderBy: "-anonymizeDate",
}; };
const savedDashboardPrefs = loadFilterPrefs(dashboardPrefsKey) || {}; const savedDashboardPrefs = loadFilterPrefs(dashboardPrefsKey) || {};
@@ -1375,6 +1546,37 @@ angular
}; };
$scope.orderBy = savedDashboardPrefs.orderBy || dashboardPrefDefaults.orderBy; $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( $scope.$watchGroup(
["typeFilter", "orderBy"], ["typeFilter", "orderBy"],
() => { () => {
@@ -1397,94 +1599,148 @@ angular
true true
); );
function getQuota() { // ---- Quota (shared with the settings page via quotaService) --------
$http.get("/api/user/quota").then((res) => { quotaService.load().then((quota) => {
$scope.quota = res.data; $scope.quota = quota;
$scope.quota.storage.percent = $scope.quota.storage.total }, console.error);
? ($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();
let loadedRepos = null; // ---- Items ---------------------------------------------------------
let loadedPRs = null; // Fields shared by repositories, pull requests and gists. Records that
let loadedGists = null; // lost their identifier (legacy data) are flagged as broken instead of
// rendering an empty link to /pr/undefined/.
function mergeItems() { function decorateItem(item, id, name, source, editUrl, viewUrl) {
$scope.items = (loadedRepos || []) if (!item.pageView) item.pageView = 0;
.concat(loadedPRs || []) if (!item.lastView) item.lastView = "";
.concat(loadedGists || []); 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() { function loadAll() {
loadedRepos = null; $scope.loading = true;
loadedPRs = null; return $q
loadedGists = null; .all([
$http.get("/api/user/anonymized_repositories").then( safeGet("/api/user/anonymized_repositories"),
(res) => { safeGet("/api/user/anonymized_pull_requests"),
loadedRepos = res.data.map((repo) => { safeGet("/api/user/anonymized_gists"),
if (!repo.pageView) repo.pageView = 0; ])
if (!repo.lastView) repo.lastView = ""; .then((results) => {
repo.options.terms = repo.options.terms.filter((f) => f); const repos = results[0];
const prs = results[1];
const gists = results[2];
const items = [];
repos.forEach((repo) => {
repo._type = "repo"; repo._type = "repo";
repo._id = repo.repoId; const src = repo.source || {};
repo._name = repo.repoId; items.push(
repo._source = repo.source.fullName; decorateItem(
repo._editUrl = "/anonymize/" + repo.repoId; repo,
repo._viewUrl = "/r/" + repo.repoId + "/"; repo.repoId,
return repo; repo.repoId,
src.fullName,
"/anonymize/" + repo.repoId,
"/r/" + repo.repoId + "/"
)
);
}); });
mergeItems(); prs.forEach((pr) => {
},
(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);
pr._type = "pr"; pr._type = "pr";
pr._id = pr.pullRequestId; const src = pr.source || {};
pr._name = pr.pullRequestId; items.push(
pr._source = pr.source.repositoryFullName + "#" + pr.source.pullRequestId; decorateItem(
pr._editUrl = "/pull-request-anonymize/" + pr.pullRequestId; pr,
pr._viewUrl = "/pr/" + pr.pullRequestId + "/"; pr.pullRequestId,
return pr; pr.pullRequestId,
src.repositoryFullName + "#" + src.pullRequestId,
"/pull-request-anonymize/" + pr.pullRequestId,
"/pr/" + pr.pullRequestId + "/"
)
);
}); });
mergeItems(); gists.forEach((g) => {
},
(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);
g._type = "gist"; g._type = "gist";
g._id = g.gistId; const src = g.source || {};
g._name = g.gistId; items.push(
g._source = g.source.gistId; decorateItem(
g._editUrl = "/gist-anonymize/" + g.gistId; g,
g._viewUrl = "/gist/" + g.gistId + "/"; g.gistId,
return g; g.gistId,
src.gistId,
"/gist-anonymize/" + g.gistId,
"/gist/" + g.gistId + "/"
)
);
}); });
mergeItems(); $scope.items = items;
}, $scope.loading = false;
(err) => { console.error(err); } });
);
} }
loadAll(); 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) { function waitRepoToBeReady(repoId, callback) {
$http.get("/api/repo/" + repoId).then((res) => { $http.get("/api/repo/" + repoId).then((res) => {
for (const item of $scope.items) { for (const item of $scope.items) {
@@ -1608,10 +1864,12 @@ angular
$scope.itemFilter = (item) => { $scope.itemFilter = (item) => {
if ($scope.typeFilter !== "all" && item._type !== $scope.typeFilter) return false; if ($scope.typeFilter !== "all" && item._type !== $scope.typeFilter) return false;
if ($scope.filters.status[item.status] == false) return false; if ($scope.filters.status[item._statusKey] === false) return false;
if ($scope.search.trim().length == 0) return true; const needle = $scope.search.trim().toLowerCase();
if (item._source && item._source.indexOf($scope.search) > -1) return true; if (needle.length == 0) return true;
if (item._id.indexOf($scope.search) > -1) 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; return false;
}; };
}, },
@@ -1642,6 +1900,8 @@ angular
$scope.rateLimitCountdown = ""; $scope.rateLimitCountdown = "";
var countdownTimer = null; var countdownTimer = null;
let pollTimer = null;
let destroyed = false;
function startRateLimitCountdown(resetAt) { function startRateLimitCountdown(resetAt) {
$scope.rateLimitResetAt = resetAt; $scope.rateLimitResetAt = resetAt;
if (countdownTimer) clearInterval(countdownTimer); if (countdownTimer) clearInterval(countdownTimer);
@@ -1665,7 +1925,9 @@ angular
countdownTimer = setInterval(tick, 1000); countdownTimer = setInterval(tick, 1000);
} }
$scope.$on("$destroy", function () { $scope.$on("$destroy", function () {
destroyed = true;
if (countdownTimer) clearInterval(countdownTimer); if (countdownTimer) clearInterval(countdownTimer);
if (pollTimer) clearTimeout(pollTimer);
}); });
function parseStatusMessage(msg) { function parseStatusMessage(msg) {
@@ -1680,6 +1942,7 @@ angular
} }
$scope.getStatus = () => { $scope.getStatus = () => {
if (destroyed) return;
$http $http
.get("/api/repo/" + $scope.repoId, { .get("/api/repo/" + $scope.repoId, {
repoId: $scope.repoId, repoId: $scope.repoId,
@@ -1687,6 +1950,7 @@ angular
}) })
.then( .then(
(res) => { (res) => {
if (destroyed) return;
$scope.repo = res.data; $scope.repo = res.data;
if (res.data.rateLimitResetAt) { if (res.data.rateLimitResetAt) {
startRateLimitCountdown(res.data.rateLimitResetAt); startRateLimitCountdown(res.data.rateLimitResetAt);
@@ -1706,12 +1970,12 @@ angular
} else if ($scope.repo.status == "anonymizing") { } else if ($scope.repo.status == "anonymizing") {
$scope.progress = 75; $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) { if ($scope.repo.status == "error" && !$scope.rateLimitResetAt) {
shouldPoll = false; shouldPoll = false;
} }
if (shouldPoll) { if (shouldPoll) {
setTimeout($scope.getStatus, 2000); pollTimer = setTimeout($scope.getStatus, 2000);
} }
}, },
(err) => { (err) => {
@@ -2215,7 +2479,7 @@ angular
if (typeof d.body === "string") out.add(d.body); if (typeof d.body === "string") out.add(d.body);
if (typeof d.diff === "string") out.add(d.diff); if (typeof d.diff === "string") out.add(d.diff);
const comments = const comments =
($scope.details && $scope.details.comments) || []; d.comments || [];
for (const c of comments) { for (const c of comments) {
if (typeof c.author === "string") out.add(c.author); if (typeof c.author === "string") out.add(c.author);
if (typeof c.body === "string") out.add(c.body); if (typeof c.body === "string") out.add(c.body);
@@ -2589,6 +2853,13 @@ angular
"$sce", "$sce",
"$q", "$q",
function ($scope, $http, $location, $routeParams, $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.files = [];
$scope.isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent); $scope.isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent);
$scope.fileSearchQuery = ""; $scope.fileSearchQuery = "";
@@ -2619,11 +2890,13 @@ angular
return; return;
} }
$scope.fileSearchLoading = true; $scope.fileSearchLoading = true;
searchCanceller = $q.defer(); const requestCanceller = $q.defer();
searchCanceller = requestCanceller;
$http.get( $http.get(
`/api/repo/${$scope.repoId}/files/search?q=${encodeURIComponent(query)}`, `/api/repo/${$scope.repoId}/files/search?q=${encodeURIComponent(query)}`,
{ timeout: searchCanceller.promise } { timeout: requestCanceller.promise }
).then(function (res) { ).then(function (res) {
if (destroyed || searchCanceller !== requestCanceller) return;
searchCanceller = null; searchCanceller = null;
$scope.fileSearchLoading = false; $scope.fileSearchLoading = false;
// Merge search results into $scope.files so the tree can render them. // Merge search results into $scope.files so the tree can render them.
@@ -2673,8 +2946,8 @@ angular
} }
$scope.fileSearchResults = res.data; $scope.fileSearchResults = res.data;
}, function () { }, function () {
// Only clear loading if this wasn't a cancellation if (!destroyed && searchCanceller === requestCanceller) {
if (!searchCanceller) { searchCanceller = null;
$scope.fileSearchLoading = false; $scope.fileSearchLoading = false;
$scope.fileSearchResults = []; $scope.fileSearchResults = [];
} }
@@ -2724,6 +2997,7 @@ angular
]; ];
$scope.$on("$routeUpdate", function (event, current) { $scope.$on("$routeUpdate", function (event, current) {
if ($scope.repoId != $routeParams.repoId) return init();
if (($routeParams.path || "") == $scope.filePath) { if (($routeParams.path || "") == $scope.filePath) {
return; return;
} }
@@ -2732,9 +3006,6 @@ angular
.split("/") .split("/")
.filter((f) => f && f.trim().length > 0); .filter((f) => f && f.trim().length > 0);
if ($scope.repoId != $routeParams.repoId) {
return init();
}
updateContent(); updateContent();
@@ -2792,23 +3063,28 @@ angular
} }
$scope.fileCounts = null; $scope.fileCounts = null;
$scope.getFiles = function (path) { $scope.getFiles = function (path) {
const repoId = $scope.repoId;
return $http.get( return $http.get(
`/api/repo/${$scope.repoId}/files/?path=${encodeURIComponent(path)}&v=${$scope.options.lastUpdateDate}` `/api/repo/${$scope.repoId}/files/?path=${encodeURIComponent(path)}&v=${$scope.options.lastUpdateDate}`
).then(function (res) { ).then(function (res) {
if (destroyed || repoId !== $scope.repoId) return [];
const normalized = path || ""; const normalized = path || "";
$scope.files = $scope.files.filter((f) => f.path !== normalized); $scope.files = $scope.files.filter((f) => f.path !== normalized);
$scope.files.push(...res.data); $scope.files.push(...res.data);
return res.data; return res.data;
}, function (err) { }, function (err) {
if (destroyed || repoId !== $scope.repoId) return [];
$scope.type = "error"; $scope.type = "error";
$scope.content = (err && err.data && err.data.error) || "unknown_error"; $scope.content = (err && err.data && err.data.error) || "unknown_error";
$scope.files = []; $scope.files = [];
}); });
}; };
function fetchFileCounts() { function fetchFileCounts() {
const repoId = $scope.repoId;
$http.get( $http.get(
`/api/repo/${$scope.repoId}/files/counts` `/api/repo/${$scope.repoId}/files/counts`
).then(function (res) { ).then(function (res) {
if (destroyed || repoId !== $scope.repoId) return;
$scope.fileCounts = res.data; $scope.fileCounts = res.data;
}, function () { }, function () {
$scope.fileCounts = {}; $scope.fileCounts = {};
@@ -2827,8 +3103,11 @@ angular
$scope.$on("$destroy", function () { if (rlCountdownTimer) clearInterval(rlCountdownTimer); }); $scope.$on("$destroy", function () { if (rlCountdownTimer) clearInterval(rlCountdownTimer); });
function getOptions(callback) { function getOptions(callback) {
if (destroyed) return;
const repoId = $scope.repoId;
$http.get(`/api/repo/${$scope.repoId}/options`).then( $http.get(`/api/repo/${$scope.repoId}/options`).then(
(res) => { (res) => {
if (destroyed || repoId !== $scope.repoId) return;
$scope.options = res.data; $scope.options = res.data;
if ($scope.options.url) { if ($scope.options.url) {
window.location = $scope.options.url; window.location = $scope.options.url;
@@ -2839,6 +3118,7 @@ angular
} }
}, },
(err) => { (err) => {
if (destroyed || repoId !== $scope.repoId) return;
var data = err.data || {}; var data = err.data || {};
if (data.error === "rate_limited" && data.resetAt) { if (data.error === "rate_limited" && data.resetAt) {
$scope.type = "rate_limited"; $scope.type = "rate_limited";
@@ -2925,6 +3205,7 @@ angular
} }
function getContent(path, fileInfo) { function getContent(path, fileInfo) {
const generation = contentGeneration;
if (!path) { if (!path) {
$scope.type = "error"; $scope.type = "error";
$scope.content = "no_file_selected"; $scope.content = "no_file_selected";
@@ -2950,6 +3231,7 @@ angular
) )
.then( .then(
(res) => { (res) => {
if (destroyed || generation !== contentGeneration) return;
$scope.type = originalType; $scope.type = originalType;
$scope.content = res.data; $scope.content = res.data;
if ($scope.content == "") { if ($scope.content == "") {
@@ -2973,7 +3255,7 @@ angular
suppressSubScriptHandling: true, suppressSubScriptHandling: true,
suppressAutoLink: false, suppressAutoLink: false,
}); });
$scope.content = $sce.trustAsHtml(orgHTMLDocument.toString()); $scope.content = $sce.trustAsHtml(DOMPurify.sanitize(orgHTMLDocument.toString()));
$scope.type = "html"; $scope.type = "html";
} }
if ( if (
@@ -2988,6 +3270,7 @@ angular
}, 50); }, 50);
}, },
(err) => { (err) => {
if (destroyed || generation !== contentGeneration) return;
$scope.type = "error"; $scope.type = "error";
$scope.content = "unknown_error"; $scope.content = "unknown_error";
try { try {
@@ -3011,6 +3294,7 @@ angular
} }
function updateContent() { function updateContent() {
contentGeneration++;
$scope.content = ""; $scope.content = "";
$scope.file = getSelectedFile(); $scope.file = getSelectedFile();
let fileVersion = "0"; let fileVersion = "0";
@@ -3184,11 +3468,18 @@ angular
} }
function init() { function init() {
contentGeneration++;
$scope.files = [];
$scope.content = null;
$scope.fileCounts = null;
$scope.fileSearchQuery = "";
$scope.onFileSearchChange();
$scope.repoId = $routeParams.repoId; $scope.repoId = $routeParams.repoId;
$scope.type = "loading"; $scope.type = "loading";
$scope.filePath = $routeParams.path || ""; $scope.filePath = $routeParams.path || "";
$scope.paths = $scope.filePath.split("/"); $scope.paths = $scope.filePath.split("/");
const repoId = $scope.repoId;
getOptions(function (options) { getOptions(function (options) {
fetchFileCounts(); fetchFileCounts();
var chain = $q.resolve(); var chain = $q.resolve();
@@ -3203,6 +3494,7 @@ angular
}); });
} }
chain.then(function () { chain.then(function () {
if (destroyed || repoId !== $scope.repoId) return;
if ($scope.files.length == 1 && $scope.files[0].name == "") { if ($scope.files.length == 1 && $scope.files[0].name == "") {
$scope.files = []; $scope.files = [];
$scope.type = "empty"; $scope.type = "empty";
@@ -3248,6 +3540,7 @@ angular
$http.get(`/api/pr/${$scope.pullRequestId}/content`).then( $http.get(`/api/pr/${$scope.pullRequestId}/content`).then(
(res) => { (res) => {
$scope.details = res.data; $scope.details = res.data;
$scope.tabState = { active: res.data.diff ? "diff" : "comments" };
if (callback) { if (callback) {
callback(res.data); callback(res.data);
} }
@@ -3460,7 +3753,7 @@ angular
const start = new Date(); const start = new Date();
start.setDate(1); start.setDate(1);
start.setMonth(start.getMonth() + 1); start.setMonth(start.getMonth() + 1);
const end = new Date(); const end = new Date(start);
end.setMonth(start.getMonth() + 7, 0); end.setMonth(start.getMonth() + 7, 0);
$scope.options = { $scope.options = {
startDate: start, 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());
});
});