mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-09-12 21:58:57 +02:00
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:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"core.min.js": "core.6332b3c288.min.js",
|
||||
"vendor.min.js": "vendor.c40cb91530.min.js",
|
||||
"vendor.min.js": "vendor.81d3dd59f8.min.js",
|
||||
"mermaid.min.js": "mermaid.f848a72d16.min.js",
|
||||
"all.min.css": "all.99ce8f3e14.min.css"
|
||||
"all.min.css": "all.6d950dfd4d.min.css"
|
||||
}
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+418
-72
@@ -3,10 +3,11 @@
|
||||
Serif display (Instrument Serif), Inter body, JetBrains Mono. */
|
||||
|
||||
.dark-mode {
|
||||
/* Warm charcoal — softened from pure black */
|
||||
--canvas-bg-color: #1f1f1f;
|
||||
/* Warm ink canvas — same temperature as the light paper palette so
|
||||
cards (#25231F) and borders (#33302B) sit on a matching base. */
|
||||
--canvas-bg-color: #1B1916;
|
||||
--sidebar-bg-color: #1A1918;
|
||||
--main-bg-color: #1f1f1f;
|
||||
--main-bg-color: #1B1916;
|
||||
--header-bg-color: #1A1918;
|
||||
|
||||
--hover-bg-color: rgba(255, 255, 255, 0.05);
|
||||
@@ -29,9 +30,20 @@
|
||||
|
||||
--accent: #A7B2FF;
|
||||
--accent-soft: rgba(167, 178, 255, 0.12);
|
||||
--ink-muted: #8A857C;
|
||||
/* --ink-muted is used for small text: 6.5:1 on the canvas.
|
||||
--ink-faint is for non-text (icons, rules, dots), never for copy. */
|
||||
--ink-muted: #A39D92;
|
||||
--ink-faint: #8A857C;
|
||||
--ink-soft: #B5AFA2;
|
||||
|
||||
/* Semantic status colours (text/dot/border on canvas ≥ 4.5:1) */
|
||||
--status-ready: #5FB27A;
|
||||
--status-progress: #E0A94A;
|
||||
--status-error: #F08A82;
|
||||
--status-inactive: #8A857C;
|
||||
--status-error-soft: rgba(240, 138, 130, 0.08);
|
||||
--status-error-soft-hover: rgba(240, 138, 130, 0.14);
|
||||
|
||||
--admin-nav-bg: #1A1918;
|
||||
--admin-nav-active: rgba(167, 178, 255, 0.12);
|
||||
--admin-stat-bg: #25231F;
|
||||
@@ -70,12 +82,27 @@ body {
|
||||
|
||||
--accent: #3B4AD6;
|
||||
--accent-soft: #EEF0FF;
|
||||
--ink-muted: #8A857C;
|
||||
/* --ink-muted is used for small text: 5.1:1 on the paper canvas.
|
||||
--ink-faint is for non-text (icons, rules, dots), never for copy. */
|
||||
--ink-muted: #6F6A61;
|
||||
--ink-faint: #8A857C;
|
||||
--ink-soft: #4A4641;
|
||||
--paper-bg-alt: #F3F1EC;
|
||||
--paper-card: #FFFFFF;
|
||||
--highlight: #FFF3C4;
|
||||
|
||||
/* Semantic status colours (text/dot/border on canvas ≥ 4.5:1) */
|
||||
--status-ready: #2F7A44;
|
||||
--status-progress: #946512;
|
||||
--status-error: #B42318;
|
||||
--status-inactive: #8A857C;
|
||||
--status-error-soft: rgba(180, 35, 24, 0.04);
|
||||
--status-error-soft-hover: rgba(180, 35, 24, 0.08);
|
||||
|
||||
/* Two-step radius system: controls and surfaces. Chips/badges are pills. */
|
||||
--radius-control: 6px;
|
||||
--radius-surface: 10px;
|
||||
|
||||
--font-serif: 'Instrument Serif', 'Iowan Old Style', Georgia, serif;
|
||||
--font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
@@ -106,19 +133,31 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
/* Keyboard focus: a visible 2px accent ring only when focus came from the
|
||||
keyboard (:focus-visible); pointer clicks get no ring. The ring is ≥ 3:1
|
||||
against both canvases (indigo 6.4:1 light, periwinkle 8.7:1 dark). */
|
||||
.btn:focus,
|
||||
.btn:active {
|
||||
outline: none !important;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
textarea,
|
||||
select,
|
||||
input,
|
||||
button {
|
||||
:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.btn:focus-visible,
|
||||
.form-control:focus-visible,
|
||||
.dropdown-item:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
input:hover,
|
||||
input:active {
|
||||
box-shadow: none !important;
|
||||
@@ -1898,9 +1937,13 @@ code {
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin-left: 2px;
|
||||
/* 24px tap target; negative margin keeps the chip visually compact */
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
margin: -6px -6px -6px 0;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.filter-chip-close:hover {
|
||||
@@ -2025,8 +2068,8 @@ code {
|
||||
.type-badge {
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: 0.06em;
|
||||
padding: 2px 7px;
|
||||
border-radius: 3px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--paper-bg-alt, transparent);
|
||||
}
|
||||
@@ -3372,16 +3415,26 @@ code {
|
||||
}
|
||||
|
||||
/* Paper progress bar */
|
||||
/* The track is a pseudo-element so the label below it lives in normal flow
|
||||
instead of overflowing an 8px-high box into the next section. */
|
||||
.paper-progress {
|
||||
position: relative;
|
||||
height: auto;
|
||||
background: none;
|
||||
margin: 18px 0 8px;
|
||||
}
|
||||
.paper-progress::before {
|
||||
content: "";
|
||||
display: block;
|
||||
height: 8px;
|
||||
background: var(--paper-bg-alt);
|
||||
border-radius: 999px;
|
||||
overflow: visible;
|
||||
margin: 18px 0 8px;
|
||||
}
|
||||
.paper-progress .paper-progress-bar {
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 8px;
|
||||
background: var(--color);
|
||||
border-radius: 999px;
|
||||
transition: width 0.4s ease;
|
||||
@@ -3898,7 +3951,7 @@ code {
|
||||
|
||||
.paper-crumbs {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
font-size: 11px;
|
||||
color: var(--ink-muted);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
@@ -3948,7 +4001,7 @@ code {
|
||||
|
||||
.paper-section-eyebrow {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-muted);
|
||||
@@ -4161,6 +4214,12 @@ textarea::selection {
|
||||
height: 38px;
|
||||
background: var(--paper-card);
|
||||
}
|
||||
.paper-page .dashboard-filter-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.paper-page .dashboard-filter-row .dropdown-toggle,
|
||||
.paper-page .dashboard-filter-row .btn-group .btn {
|
||||
background: var(--paper-card);
|
||||
@@ -4170,12 +4229,30 @@ textarea::selection {
|
||||
padding: 0 14px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-control, 6px);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
line-height: 1;
|
||||
}
|
||||
/* "Sort · Name ↓" style toggles: the label is muted, the value is ink so the
|
||||
current state reads at a glance. */
|
||||
.paper-page .dashboard-filter-row .dropdown-toggle .filter-btn-label {
|
||||
color: var(--ink-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
.paper-page .dashboard-filter-row .dropdown-toggle .filter-btn-label::after {
|
||||
content: "·";
|
||||
margin-left: 6px;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.paper-page .dashboard-filter-row .dropdown-toggle .filter-btn-value {
|
||||
font-weight: 500;
|
||||
}
|
||||
.paper-page .dashboard-filter-row .dropdown-toggle .fas {
|
||||
font-size: 11px;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.paper-page .dashboard-filter-row .dropdown-toggle:hover,
|
||||
.paper-page .dashboard-filter-row .btn-group .btn:hover {
|
||||
background: var(--hover-bg-color);
|
||||
@@ -4222,20 +4299,42 @@ textarea::selection {
|
||||
}
|
||||
.paper-page .quota-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--ink-soft);
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color);
|
||||
}
|
||||
.paper-page .quota-progress {
|
||||
.paper-page .quota-value .quota-unlimited-tag {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 12px;
|
||||
color: var(--ink-muted);
|
||||
margin-left: 4px;
|
||||
}
|
||||
/* Quota bars: ink by default, colour only when a limit is nearly reached.
|
||||
Unlimited quotas render an empty track (no fill), never a full green bar. */
|
||||
.paper-page .quota-track {
|
||||
height: 6px;
|
||||
background: var(--paper-bg-alt);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 999px;
|
||||
margin-bottom: 0;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.paper-page .quota-progress .progress-bar {
|
||||
.paper-page .quota-track .quota-fill {
|
||||
height: 100%;
|
||||
min-width: 2px;
|
||||
border-radius: 999px;
|
||||
background: var(--color);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.paper-page .quota-track.quota-warn .quota-fill { background: var(--status-progress); }
|
||||
.paper-page .quota-track.quota-danger .quota-fill { background: var(--status-error); }
|
||||
.paper-page .quota-track.quota-unlimited {
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
var(--paper-bg-alt) 0 4px,
|
||||
var(--border-soft, var(--border-color)) 4px 6px
|
||||
);
|
||||
}
|
||||
|
||||
/* Dashboard repo/PR list refinements */
|
||||
@@ -4324,10 +4423,81 @@ textarea::selection {
|
||||
.paper-settings-section .form-group { margin-bottom: 18px; }
|
||||
.paper-settings-section .form-check { padding: 10px 0 10px 28px; border-bottom: 1px solid var(--border-soft, var(--border-color)); }
|
||||
.paper-settings-section .form-check:last-child { border-bottom: none; }
|
||||
.paper-settings-section .form-check-label { font-family: var(--font-serif); font-size: 16px; color: var(--color); }
|
||||
/* Checkbox labels are UI text, so they use the sans face like the rest of
|
||||
the forms; the serif stays reserved for display headings. */
|
||||
.paper-settings-section .form-check-label { font-family: var(--font-sans); font-size: 14px; font-weight: 500; color: var(--color); cursor: pointer; }
|
||||
.paper-settings-section .form-check-input { cursor: pointer; width: 16px; height: 16px; margin-top: 3px; margin-left: -28px; accent-color: var(--color); }
|
||||
.paper-settings-section .form-text { color: var(--ink-soft); font-size: 13px; margin-top: 4px; max-width: 48em; }
|
||||
.paper-settings-section .paper-section-copy { margin: -4px 0 14px; }
|
||||
.paper-settings-footer { padding-top: 24px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.paper-settings-footer .btn-ink { align-self: flex-start; }
|
||||
.paper-settings-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 14px; }
|
||||
.paper-settings-hint { font-size: 13px; color: var(--ink-muted); }
|
||||
|
||||
/* Inline alerts in the paper palette (Bootstrap's .alert colours clash) */
|
||||
.paper-inline-alert {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-control, 6px);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--paper-bg-alt);
|
||||
font-size: 13px;
|
||||
color: var(--color);
|
||||
}
|
||||
.paper-inline-alert i { margin-top: 2px; color: var(--ink-muted); }
|
||||
.paper-inline-alert-error { border-color: var(--status-error); background: var(--status-error-soft); }
|
||||
.paper-inline-alert-error i { color: var(--status-error); }
|
||||
|
||||
/* Account card and danger zone on the settings page */
|
||||
.paper-account-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-surface, 10px);
|
||||
background: var(--paper-card);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.paper-account-card img { flex-shrink: 0; }
|
||||
.paper-account-identity { flex: 1 1 auto; min-width: 0; }
|
||||
.paper-account-name { font-family: var(--font-mono); font-size: 14px; font-weight: 500; color: var(--color); }
|
||||
.paper-account-meta { font-size: 13px; color: var(--ink-muted); margin-top: 2px; }
|
||||
.paper-danger-zone {
|
||||
padding: 16px;
|
||||
border: 1px solid var(--status-error);
|
||||
border-radius: var(--radius-surface, 10px);
|
||||
background: var(--status-error-soft);
|
||||
}
|
||||
.paper-danger-head { font-weight: 600; font-size: 14px; color: var(--status-error); margin-bottom: 6px; }
|
||||
.paper-danger-copy { font-size: 13px; color: var(--ink-soft); max-width: 52em; margin-bottom: 12px; }
|
||||
.paper-danger-zone .paper-inline-alert { margin-bottom: 12px; }
|
||||
.btn.btn-outline-danger {
|
||||
background: transparent;
|
||||
color: var(--status-error);
|
||||
border: 1px solid var(--status-error);
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-control, 6px);
|
||||
padding: 8px 14px;
|
||||
}
|
||||
.btn.btn-outline-danger:hover { background: var(--status-error); color: #fff; border-color: var(--status-error); }
|
||||
|
||||
/* Navbar user menu: two-line items (label + hint) */
|
||||
.navbar .user-menu { min-width: 260px; }
|
||||
.navbar .user-menu .dropdown-item { display: flex; align-items: flex-start; gap: 10px; padding: 8px 10px !important; }
|
||||
.navbar .user-menu .dropdown-item i { width: 16px; margin-top: 3px; font-size: 13px; color: var(--ink-muted); text-align: center; }
|
||||
.navbar .user-menu .dropdown-item span { display: flex; flex-direction: column; line-height: 1.3; }
|
||||
.navbar .user-menu .dropdown-item small { font-size: 12px; color: var(--ink-muted); }
|
||||
.navbar .user-menu .dropdown-header {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-muted);
|
||||
padding: 8px 10px 6px;
|
||||
}
|
||||
.paper-field-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
@@ -4381,12 +4551,22 @@ textarea::selection {
|
||||
grid-template-columns: 28px minmax(280px, 2.4fr) 90px 120px 100px 52px !important;
|
||||
gap: 12px !important;
|
||||
}
|
||||
/* Dashboard: conference folded into the name cell, numbers right-aligned. */
|
||||
.paper-table.paper-table-dashboard .paper-table-head,
|
||||
.paper-table.paper-table-dashboard .paper-table-row {
|
||||
grid-template-columns: minmax(320px, 3fr) 190px 90px 170px 52px;
|
||||
gap: 20px;
|
||||
/* Two-line rows land at ~60px, close to Carbon's "large" density. */
|
||||
padding: 8px 4px;
|
||||
}
|
||||
.paper-table.paper-table-dashboard .cell-anon .anon-text { gap: 2px; }
|
||||
.paper-table.paper-table-dashboard .cell-anon .repo-name { padding: 0; }
|
||||
.paper-table .paper-table-head {
|
||||
background: transparent;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.14em;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-muted);
|
||||
padding-top: 8px;
|
||||
@@ -4397,13 +4577,25 @@ textarea::selection {
|
||||
user-select: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: 6px;
|
||||
/* Sortable headers are real <button>s for keyboard users. */
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 2px 0;
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
text-transform: inherit;
|
||||
color: inherit;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.paper-table .paper-table-head .sortable:hover { color: var(--color); }
|
||||
.paper-table .paper-table-head .sortable.active { color: var(--color); }
|
||||
.paper-table .paper-table-head .sortable i { font-size: 10px; }
|
||||
/* Quantitative columns are right-aligned so digits line up. */
|
||||
.paper-table .paper-table-head .num,
|
||||
.paper-table .paper-table-row .num { text-align: left; }
|
||||
.paper-table .paper-table-row .num { text-align: right; }
|
||||
.paper-table .paper-table-head .num .sortable { flex-direction: row-reverse; }
|
||||
.paper-table .paper-table-row {
|
||||
border-top: 1px solid var(--border-soft, var(--border-color));
|
||||
font-size: 14px;
|
||||
@@ -4412,6 +4604,8 @@ textarea::selection {
|
||||
.paper-table .paper-table-row:hover {
|
||||
background: var(--hover-bg-color);
|
||||
}
|
||||
/* Rows that open a view on click get a pointer so hover means something. */
|
||||
.paper-table .paper-table-row.row-clickable { cursor: pointer; }
|
||||
/* Dim inactive rows through their cells rather than the row itself. Applying
|
||||
opacity to the row creates a stacking context that traps the actions
|
||||
dropdown menu (z-index) beneath the rows rendered after it, making its
|
||||
@@ -4419,20 +4613,16 @@ textarea::selection {
|
||||
interactive. */
|
||||
.paper-table .paper-table-row.repo-inactive > *:not(.cell-actions) { opacity: 0.55; }
|
||||
.paper-table .paper-table-row.repo-error {
|
||||
background: rgba(180, 35, 24, 0.04);
|
||||
border-left: 2px solid #B42318;
|
||||
background: var(--status-error-soft);
|
||||
border-left: 2px solid var(--status-error);
|
||||
padding-left: 6px;
|
||||
}
|
||||
.paper-table .paper-table-row.repo-error:hover { background: rgba(180, 35, 24, 0.08); }
|
||||
.dark-mode .paper-table .paper-table-row.repo-error { background: rgba(240, 138, 130, 0.05); border-left-color: #F08A82; }
|
||||
.dark-mode .paper-table .paper-table-row.repo-error:hover { background: rgba(240, 138, 130, 0.1); }
|
||||
.paper-table .paper-table-row.repo-error:hover { background: var(--status-error-soft-hover); }
|
||||
.paper-table .cell-status .status-sub-error,
|
||||
.cell-status .status-sub.status-sub-error {
|
||||
color: #B42318;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--status-error);
|
||||
}
|
||||
.dark-mode .paper-table .cell-status .status-sub-error,
|
||||
.dark-mode .cell-status .status-sub.status-sub-error { color: #F08A82; }
|
||||
.paper-table .cell-status .status-sub-warn { color: var(--status-progress); }
|
||||
.paper-table .cell-anon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -4444,8 +4634,16 @@ textarea::selection {
|
||||
min-width: 52px;
|
||||
justify-content: center;
|
||||
}
|
||||
.paper-table .cell-anon .anon-text { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.paper-table .cell-anon .anon-text { min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
||||
.paper-table .cell-anon .anon-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.paper-table .cell-anon .anon-title .type-badge { margin-left: 0; }
|
||||
.paper-table .cell-anon .repo-name {
|
||||
/* Identifiers stay mono; everything human-readable below is sans. */
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
color: var(--color);
|
||||
@@ -4455,45 +4653,174 @@ textarea::selection {
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
padding: 2px 0;
|
||||
}
|
||||
.paper-table .cell-anon .repo-name-static { color: var(--ink-muted); font-style: italic; }
|
||||
.paper-table .cell-anon .anon-sub {
|
||||
font-size: 12px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
color: var(--ink-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
.paper-table .cell-anon .anon-sub .anon-source {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* The conference tag keeps a readable share of the line; the source
|
||||
truncates first since it is repeated in the name and in the tooltip. */
|
||||
.paper-table .cell-anon .anon-sub .conf-tag {
|
||||
flex: 0 1 auto;
|
||||
min-width: 72px;
|
||||
max-width: 50%;
|
||||
}
|
||||
.paper-table .cell-anon .anon-sub a {
|
||||
color: var(--ink-muted);
|
||||
border-bottom: 1px dotted var(--border-color);
|
||||
/* Taller hit area without changing the line box */
|
||||
padding: 4px 0;
|
||||
margin: -4px 0;
|
||||
}
|
||||
.paper-table .cell-anon .anon-sub a { color: var(--ink-muted); border-bottom: 1px dotted var(--border-color); }
|
||||
.paper-table .cell-anon .anon-sub a:hover { color: var(--color); }
|
||||
.paper-table .cell-anon .anon-sub .commit-hash { font-family: var(--font-mono); font-size: 12px; }
|
||||
/* Conference shown as a small tag inside the name cell. `.cell-conf` keeps
|
||||
the clipping rules so a long conference string never bleeds. */
|
||||
.paper-table .cell-conf {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
color: var(--color);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-muted);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.paper-table .cell-conf span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.paper-table .cell-status { display: flex; flex-wrap: wrap; align-items: center; gap: 2px 8px; font-size: 14px; color: var(--color); }
|
||||
.paper-table .cell-status { display: flex; flex-wrap: wrap; align-items: center; gap: 2px 8px; font-size: 14px; color: var(--color); min-width: 0; }
|
||||
.paper-table .cell-status .status-line { display: inline-flex; align-items: center; gap: 8px; }
|
||||
.paper-table .cell-status .status-sub { flex-basis: 100%; font-size: 11px; line-height: 1.2; color: var(--ink-muted); }
|
||||
.paper-table .cell-status .status-sub {
|
||||
flex-basis: 100%;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
color: var(--ink-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.paper-table .cell-views { font-family: var(--font-mono); font-variant-numeric: tabular-nums; color: var(--color); }
|
||||
.paper-table .cell-expires { font-size: 13px; color: var(--ink-soft); }
|
||||
.paper-table .empty-dash { color: var(--ink-muted); opacity: 0.5; }
|
||||
.paper-table .cell-expires { font-family: var(--font-sans); font-size: 13px; color: var(--ink-soft); }
|
||||
.paper-table .cell-expires .expires-never { color: var(--ink-muted); }
|
||||
.paper-table .cell-expires .expires-past { color: var(--ink-muted); }
|
||||
.paper-table .empty-dash { color: var(--ink-faint); }
|
||||
.paper-table .cell-actions { display: flex; justify-content: flex-end; }
|
||||
|
||||
/* Status dots use the semantic tokens so they stay legible in both themes.
|
||||
Old status-* class names are kept as aliases for other templates. */
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
display: inline-block;
|
||||
background: var(--ink-muted);
|
||||
flex-shrink: 0;
|
||||
background: var(--status-inactive);
|
||||
}
|
||||
.status-dot.status-ready { background: #2F7A44; }
|
||||
.status-dot.status-error { background: #B42318; }
|
||||
.status-dot.status-preparing { background: #C48A2E; }
|
||||
.status-dot.status-removed { background: #9A8F7B; }
|
||||
.status-dot.status-ready { background: var(--status-ready); }
|
||||
.status-dot.status-error { background: var(--status-error); }
|
||||
.status-dot.status-progress,
|
||||
.status-dot.status-preparing { background: var(--status-progress); }
|
||||
.status-dot.status-expired,
|
||||
.status-dot.status-removed { background: var(--status-inactive); }
|
||||
|
||||
/* ---- Dashboard extras ---------------------------------------------- */
|
||||
/* Room for the fixed Ko-fi pill so it never covers the last row's actions. */
|
||||
.dashboard-page { padding-bottom: 96px; }
|
||||
|
||||
.dashboard-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 10px;
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.dashboard-meta .dashboard-count { font-variant-numeric: tabular-nums; }
|
||||
.btn-link-inline {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 2px 4px;
|
||||
margin: -2px 0;
|
||||
font: inherit;
|
||||
color: var(--link-color);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.btn-link-inline:hover { color: var(--link-hover-color); text-decoration: underline; }
|
||||
|
||||
.btn.btn-outline-ink {
|
||||
background: transparent;
|
||||
color: var(--color);
|
||||
border: 1px solid var(--border-color);
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-control, 6px);
|
||||
padding: 8px 14px;
|
||||
}
|
||||
.btn.btn-outline-ink:hover { background: var(--hover-bg-color); color: var(--color); border-color: var(--color); }
|
||||
|
||||
.paper-page .dropdown-menu .dropdown-item-danger,
|
||||
.paper-page .dropdown-menu .dropdown-item-danger i { color: var(--status-error); }
|
||||
.paper-page .dropdown-menu .dropdown-item-danger:hover,
|
||||
.paper-page .dropdown-menu .dropdown-item-danger:focus { background: var(--status-error-soft-hover); color: var(--status-error); }
|
||||
.paper-page .dropdown-menu button.dropdown-item { width: 100%; text-align: left; background: none; border: none; }
|
||||
.paper-page .dropdown-menu .dropdown-divider { border-top-color: var(--border-soft, var(--border-color)); margin: 4px 0; }
|
||||
|
||||
/* Loading skeleton */
|
||||
.paper-table .paper-table-skeleton { pointer-events: none; }
|
||||
.skeleton {
|
||||
display: block;
|
||||
background: var(--paper-bg-alt);
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.skeleton::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transform: translateX(-100%);
|
||||
background: linear-gradient(90deg, transparent, var(--hover-bg-color), transparent);
|
||||
animation: skeleton-shimmer 1.4s infinite;
|
||||
}
|
||||
.skeleton-line { height: 12px; }
|
||||
.skeleton-line + .skeleton-line { margin-top: 8px; }
|
||||
.skeleton-line-sm { height: 10px; }
|
||||
.skeleton-badge { width: 52px; height: 22px; border-radius: 999px; flex-shrink: 0; }
|
||||
@keyframes skeleton-shimmer { to { transform: translateX(100%); } }
|
||||
@media (prefers-reduced-motion: reduce) { .skeleton::after { animation: none; } }
|
||||
|
||||
/* Empty state with a way out */
|
||||
.paper-table .paper-table-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 56px 20px;
|
||||
color: var(--ink-muted);
|
||||
text-align: center;
|
||||
border-top: 1px solid var(--border-soft, var(--border-color));
|
||||
}
|
||||
.paper-table .paper-table-empty i { font-size: 1.6rem; color: var(--ink-faint); }
|
||||
.paper-table .paper-table-empty-actions { display: flex; gap: 8px; margin-top: 8px; }
|
||||
|
||||
.bulk-bar {
|
||||
display: flex;
|
||||
@@ -5297,13 +5624,18 @@ textarea::selection {
|
||||
.paper-table .cell-conf,
|
||||
.paper-table .cell-expires {
|
||||
order: 3;
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--ink-muted);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
text-align: left;
|
||||
}
|
||||
/* Status, date, views and expiry sit on one meta line with a common
|
||||
baseline; the sub-text is inline instead of wrapping under the word. */
|
||||
.paper-table .cell-status { flex-wrap: nowrap; gap: 6px; min-width: 0; }
|
||||
.paper-table .cell-status .status-sub { flex-basis: auto; font-size: 13px; line-height: inherit; }
|
||||
.paper-table .cell-status .status-sub::before { content: "·"; margin-right: 6px; color: var(--ink-faint); }
|
||||
.paper-table .cell-conf {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
@@ -5311,6 +5643,9 @@ textarea::selection {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.paper-table .cell-views.num { text-align: left; }
|
||||
.paper-table .paper-table-skeleton .cell-status,
|
||||
.paper-table .paper-table-skeleton .cell-views,
|
||||
.paper-table .paper-table-skeleton .cell-expires { flex: 1 1 30%; }
|
||||
.paper-table .cell-views::before {
|
||||
content: '\f06e';
|
||||
font-family: 'Font Awesome 5 Free';
|
||||
@@ -5332,15 +5667,19 @@ textarea::selection {
|
||||
font-size: 10px;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
/* The dashboard renders its own icon inside the conference tag. */
|
||||
.paper-table-dashboard .cell-conf::before { content: none; }
|
||||
.paper-table .cell-conf:has(.empty-dash),
|
||||
.paper-table .cell-expires:has(.empty-dash) { display: none; }
|
||||
.paper-table .cell-views::before,
|
||||
.paper-table .cell-expires::before { color: var(--ink-faint); }
|
||||
}
|
||||
|
||||
/* Paper-themed dropdown menus (Sort / Status / Actions) */
|
||||
.paper-page .dropdown-menu {
|
||||
background: var(--paper-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-surface, 10px);
|
||||
box-shadow: 0 12px 28px rgba(26, 24, 21, 0.08);
|
||||
padding: 6px;
|
||||
min-width: 220px;
|
||||
@@ -5448,25 +5787,33 @@ textarea::selection {
|
||||
.paper-page .paper-page-lede { font-size: 13px; line-height: 1.4; margin-bottom: 0; }
|
||||
.paper-page .btn-ink { padding: 6px 12px; font-size: 13px; }
|
||||
|
||||
/* Quotas stack in one column on phones so labels and values keep the
|
||||
same 11px / 13px sizes as desktop instead of shrinking to 9px. */
|
||||
.paper-page .quota-row {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px 12px;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
padding: 0;
|
||||
margin: 12px 0 0;
|
||||
margin: 16px 0 0;
|
||||
}
|
||||
.paper-page .dashboard-filter-row {
|
||||
padding: 8px 0;
|
||||
margin-top: 10px;
|
||||
padding: 10px 0;
|
||||
margin-top: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
.paper-page .quota-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
.paper-page .quota-label { font-size: 10px; letter-spacing: 0.1em; }
|
||||
.paper-page .quota-value { font-size: 11px; }
|
||||
.paper-page .quota-value { white-space: nowrap; }
|
||||
|
||||
.paper-page .dashboard-filter-row { gap: 8px; }
|
||||
/* Filter controls: type group fills the row, Sort and Status share one. */
|
||||
.paper-page .dashboard-filter-controls { width: 100%; }
|
||||
.paper-page .dashboard-filter-controls .btn-group { flex: 1 1 100%; display: flex; }
|
||||
.paper-page .dashboard-filter-controls .btn-group .btn { flex: 1; justify-content: center; }
|
||||
.paper-page .dashboard-filter-controls .dropdown { flex: 1 1 0; min-width: 0; }
|
||||
.paper-page .dashboard-filter-controls .dropdown .dropdown-toggle { width: 100%; justify-content: space-between; }
|
||||
.paper-page .dashboard-filter-controls .dropdown .filter-btn-value { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
@@ -5476,9 +5823,6 @@ textarea::selection {
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.paper-page .quota-row { gap: 8px; }
|
||||
.paper-page .quota-label { font-size: 9px; letter-spacing: 0.06em; }
|
||||
.paper-page .quota-value { font-size: 10px; white-space: nowrap; }
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
@@ -5510,10 +5854,12 @@ textarea::selection {
|
||||
font-size: 10px;
|
||||
line-height: 10px;
|
||||
}
|
||||
.paper-page .progress .progress-bar.bg-success { background: #2F7A44; }
|
||||
.paper-page .progress .progress-bar.bg-warning { background: #C48A2E; color: #fff; }
|
||||
.paper-page .progress .progress-bar.bg-danger { background: #B42318; }
|
||||
.paper-page .progress .progress-bar.bg-dark { background: var(--ink-muted); }
|
||||
/* Bootstrap's .bg-* utilities are !important, so the paper palette must be
|
||||
too, otherwise the bright Bootstrap green/yellow/red leak through. */
|
||||
.paper-page .progress .progress-bar.bg-success { background: var(--status-ready) !important; }
|
||||
.paper-page .progress .progress-bar.bg-warning { background: var(--status-progress) !important; color: #fff; }
|
||||
.paper-page .progress .progress-bar.bg-danger { background: var(--status-error) !important; }
|
||||
.paper-page .progress .progress-bar.bg-dark { background: var(--ink-faint) !important; }
|
||||
|
||||
.paper-page > h5,
|
||||
.paper-page form > h5,
|
||||
|
||||
@@ -15,13 +15,13 @@
|
||||
type="text"
|
||||
class="form-control form-control-lg"
|
||||
ng-model="sourceUrl"
|
||||
placeholder="https://github.com/owner/repo, https://github.com/owner/repo/pull/42, or https://gist.github.com/owner/abcdef…"
|
||||
placeholder="https://github.com/owner/repository"
|
||||
ng-model-options="{ debounce: {default: 1000, blur: 0, click: 0}, updateOn: 'default blur click' }"
|
||||
ng-change="urlSelected()"
|
||||
/>
|
||||
</div>
|
||||
<small class="form-text" style="color: var(--ink-muted);">
|
||||
Paste a repository URL to anonymize a repo, a pull-request URL for a PR, or a gist URL for a gist.
|
||||
Repository, pull request (…/pull/42) and gist (gist.github.com/…) URLs are all accepted.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -90,7 +90,7 @@
|
||||
</select>
|
||||
<div class="input-group-append">
|
||||
<button class="btn" type="button" ng-click="getBranches(true)" title="Refresh" data-toggle="tooltip" data-placement="bottom">
|
||||
<i class="fa fa-undo"></i>
|
||||
<i class="fas fa-sync" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,8 +110,8 @@
|
||||
|
||||
<div class="form-check" ng-show="detectedType">
|
||||
<input class="form-check-input" type="checkbox" id="update" name="update" ng-model="options.update" />
|
||||
<label class="form-check-label" for="update">Auto update</label>
|
||||
<small class="form-text text-muted">Automatically update with the latest changes (hourly max).</small>
|
||||
<label class="form-check-label" for="update">Auto-update from GitHub</label>
|
||||
<small class="form-text text-muted">Follow the branch and pull the latest commit automatically, at most once an hour.</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
+11
-11
@@ -1,25 +1,26 @@
|
||||
<div class="container paper-page">
|
||||
<div class="paper-crumbs">Reference / <span class="here">Claim</span></div>
|
||||
<div class="paper-crumbs"><a href="/dashboard">My work</a> / <span class="here">Claim</span></div>
|
||||
<h1 class="paper-page-title">Claim an <em>anonymization</em></h1>
|
||||
<p class="paper-page-lede">
|
||||
Take ownership of an existing anonymized repository so it appears on your
|
||||
dashboard.
|
||||
</p>
|
||||
<div class="paper-meta-rule"></div>
|
||||
<form class="form needs-validation" name="claimForm" novalidate>
|
||||
<div class="paper-settings-main claim-form">
|
||||
<p class="paper-section-copy">Use this when an anonymization was created by a co-author or from another account. You must have access to the GitHub repository it was made from.</p>
|
||||
<form class="form needs-validation" name="claimForm" novalidate ng-submit="claim()">
|
||||
<div class="form-group">
|
||||
<label class="paper-field-label" for="repoUrl">Repository URL</label>
|
||||
<label class="paper-field-label" for="repoUrl">GitHub repository URL</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
name="repoUrl"
|
||||
id="repoUrl"
|
||||
required
|
||||
ng-class="{'is-invalid': claimForm.repoUrl.$invalid}"
|
||||
ng-class="{'is-invalid': claimForm.repoUrl.$invalid && (claimForm.repoUrl.$touched || claimForm.$submitted)}"
|
||||
ng-model="repoUrl"
|
||||
/>
|
||||
<div class="invalid-feedback" ng-show="claimForm.repoUrl.$error.not_found">
|
||||
The repository is not found.
|
||||
No anonymization matches this repository and ID. Check both values and that you can access the repository on GitHub.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -31,11 +32,10 @@
|
||||
id="repoId"
|
||||
required
|
||||
ng-model="repoId"
|
||||
ng-class="{'is-invalid': claimForm.repoId.$invalid}"
|
||||
ng-class="{'is-invalid': claimForm.repoId.$invalid && (claimForm.repoId.$touched || claimForm.$submitted)}"
|
||||
/>
|
||||
<small id="idHelp" class="form-text text-muted"
|
||||
>The id is in the repository
|
||||
https://anonymous.4open.science/r/{id}</small
|
||||
>The ID is the last part of the anonymized URL: <code>anonymous.4open.science/r/<ID></code>.</small
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -43,9 +43,9 @@
|
||||
id="submit"
|
||||
type="submit"
|
||||
class="btn btn-ink"
|
||||
ng-click="claim()"
|
||||
>
|
||||
Claim
|
||||
Claim anonymization
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,45 +1,46 @@
|
||||
<div class="container page paper-page">
|
||||
<div>
|
||||
<div class="paper-crumbs">
|
||||
<a href="/conferences">Conferences</a> / <span class="here">{{conference.conferenceID}}</span>
|
||||
<a href="/dashboard">My work</a> / <a href="/conferences">Conferences</a> / <span class="here">{{conference.conferenceID}}</span>
|
||||
</div>
|
||||
<div class="d-flex align-items-end flex-wrap" style="gap: 12px; justify-content: space-between;">
|
||||
<div>
|
||||
<h1 class="paper-page-title">
|
||||
<a ng-href="{{conference.url}}" style="color: var(--color);">{{conference.name}}</a>
|
||||
</h1>
|
||||
<h1 class="paper-page-title" ng-bind="conference.name"></h1>
|
||||
<p class="paper-page-lede" ng-if="conference.url">
|
||||
<a ng-href="{{conference.url}}" ng-bind="conference.url"></a>
|
||||
<a ng-href="{{conference.url}}" target="_blank" rel="noopener" ng-bind="conference.url"></a>
|
||||
</p>
|
||||
</div>
|
||||
<span class="status-dot-wrap">
|
||||
<span class="status-dot" ng-class="{'status-removed': conference.status == 'removed' || conference.status == 'expired', 'status-ready': conference.status == 'ready'}"></span>
|
||||
<span ng-bind="conference.status | title"></span>
|
||||
</span>
|
||||
<div class="d-flex align-items-center flex-wrap" style="gap: 10px;">
|
||||
<span class="status-pill" ng-class="{'status-pill-ready': conference.status == 'ready', 'status-pill-removed': conference.status == 'removed' || conference.status == 'expired'}">
|
||||
<span class="status-dot" ng-class="'status-' + conference.status" aria-hidden="true"></span>
|
||||
<span ng-bind="conference.status | statusLabel"></span>
|
||||
</span>
|
||||
<a class="btn btn-outline-ink" ng-href="/conference/{{conference.conferenceID}}/edit"><i class="far fa-edit mr-1" aria-hidden="true"></i> Edit conference</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="paper-meta-rule">
|
||||
<span>ID <b ng-bind="conference.conferenceID"></b></span>
|
||||
<span>From <b>{{conference.startDate | date}}</b></span>
|
||||
<span>Ends <b>{{conference.endDate | date}}</b></span>
|
||||
<span>Repos <b>{{::conference.repositories.length | number}}</b></span>
|
||||
<span>Price <b>{{conference.price || 0 | number}} €</b></span>
|
||||
<span>ID <b class="commit-hash" ng-bind="conference.conferenceID"></b></span>
|
||||
<span>Review window <b>{{conference.startDate | date:'mediumDate'}} – {{conference.endDate | date:'mediumDate'}}</b></span>
|
||||
<span>Repositories <b>{{conference.repositories.length | number}}</b></span>
|
||||
<span>Plan <b ng-bind="conference.plan.name || conference.plan.planID || 'Free'"></b></span>
|
||||
<span ng-if="conference.price">Cost so far <b>{{conference.price | number:2}} €</b></span>
|
||||
</div>
|
||||
|
||||
<div class="paper-section-eyebrow">Repositories</div>
|
||||
|
||||
<form class="w-100 dashboard-filter-row" aria-label="Repositories" accept-charset="UTF-8">
|
||||
<form class="w-100 dashboard-filter-row" aria-label="Filter repositories" accept-charset="UTF-8" ng-submit="$event.preventDefault()">
|
||||
<div class="search-wrap">
|
||||
<input
|
||||
type="search"
|
||||
id="search"
|
||||
class="form-control"
|
||||
aria-label="Find repositories"
|
||||
placeholder="Find repositories…"
|
||||
aria-label="Search repositories"
|
||||
placeholder="Search by ID or source repository…"
|
||||
autocomplete="off"
|
||||
ng-model="search"
|
||||
/>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap" style="gap: 8px">
|
||||
<div class="dashboard-filter-controls">
|
||||
<div class="dropdown">
|
||||
<button
|
||||
class="btn dropdown-toggle"
|
||||
@@ -49,13 +50,14 @@
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Sort
|
||||
<span class="filter-btn-label">Sort</span>
|
||||
<span class="filter-btn-value">{{ ({'-anonymizeDate': 'Anonymize date', repoId: 'ID', '-status': 'Status', '-pageView': 'Views'})[orderBy] || 'Custom' }}</span>
|
||||
</button>
|
||||
<div class="dropdown-menu" aria-labelledby="dropdownSort">
|
||||
<h6 class="dropdown-header">Select order</h6>
|
||||
<h6 class="dropdown-header">Order by</h6>
|
||||
<div class="form-check dropdown-item">
|
||||
<input class="form-check-input" type="radio" name="sort" id="anonymizeDate" value="-anonymizeDate" ng-model="orderBy" />
|
||||
<label class="form-check-label" for="anonymizeDate">Anonymize Date</label>
|
||||
<label class="form-check-label" for="anonymizeDate">Anonymize date</label>
|
||||
</div>
|
||||
<div class="form-check dropdown-item">
|
||||
<input class="form-check-input" type="radio" name="sort" id="sortID" value="repoId" ng-model="orderBy" />
|
||||
@@ -65,10 +67,14 @@
|
||||
<input class="form-check-input" type="radio" name="sort" id="sortStatus" value="-status" ng-model="orderBy" />
|
||||
<label class="form-check-label" for="sortStatus">Status</label>
|
||||
</div>
|
||||
<div class="form-check dropdown-item">
|
||||
<input class="form-check-input" type="radio" name="sort" id="sortViews" value="-pageView" ng-model="orderBy" />
|
||||
<label class="form-check-label" for="sortViews">Views</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dropdown">
|
||||
<div class="dropdown" ng-init="statusLabels = {ready: 'Ready', expired: 'Expired', removed: 'Removed'}">
|
||||
<button
|
||||
class="btn dropdown-toggle"
|
||||
type="button"
|
||||
@@ -77,58 +83,72 @@
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Status
|
||||
<span class="filter-btn-label">Status</span>
|
||||
<span class="filter-btn-value" ng-if="filters.status.ready && filters.status.expired && filters.status.removed">All</span>
|
||||
<span class="filter-btn-value" ng-if="!(filters.status.ready && filters.status.expired && filters.status.removed)">
|
||||
{{ (filters.status.ready ? 0 : 1) + (filters.status.expired ? 0 : 1) + (filters.status.removed ? 0 : 1) }} hidden
|
||||
</span>
|
||||
</button>
|
||||
<div class="dropdown-menu" aria-labelledby="dropdownStatus">
|
||||
<h6 class="dropdown-header">Select status</h6>
|
||||
<div class="form-check dropdown-item">
|
||||
<input class="form-check-input" type="checkbox" name="sort" id="statusReady" value="ready" ng-model="filters.status.ready" />
|
||||
<label class="form-check-label" for="statusReady">Ready</label>
|
||||
</div>
|
||||
<div class="form-check dropdown-item">
|
||||
<input class="form-check-input" type="checkbox" name="sort" id="statusExpired" value="expired" ng-model="filters.status.expired" />
|
||||
<label class="form-check-label" for="statusExpired">Expired</label>
|
||||
</div>
|
||||
<div class="form-check dropdown-item">
|
||||
<input class="form-check-input" type="checkbox" name="sort" id="statusRemoved" value="removed" ng-model="filters.status.removed" />
|
||||
<label class="form-check-label" for="statusRemoved">Removed</label>
|
||||
<h6 class="dropdown-header">Show statuses</h6>
|
||||
<div class="form-check dropdown-item" ng-repeat="(key, label) in statusLabels">
|
||||
<input class="form-check-input" type="checkbox" id="status-{{key}}" ng-model="filters.status[key]" />
|
||||
<label class="form-check-label" for="status-{{key}}">{{label}}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="dashboard-meta">
|
||||
<span class="dashboard-count">
|
||||
<span ng-if="filteredRepositories.length === conference.repositories.length">{{conference.repositories.length | number}} repositor<span ng-if="conference.repositories.length === 1">y</span><span ng-if="conference.repositories.length !== 1">ies</span></span>
|
||||
<span ng-if="filteredRepositories.length !== conference.repositories.length">{{filteredRepositories.length | number}} of {{conference.repositories.length | number}} shown</span>
|
||||
</span>
|
||||
<span class="filter-chip" ng-repeat="(f, v) in filters.status" ng-if="v === false">
|
||||
Hiding {{statusLabels[f]}}
|
||||
<button type="button" class="filter-chip-close" aria-label="Show {{statusLabels[f]}} again" ng-click="filters.status[f] = true;">×</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="paper-table paper-table-repos w-100" role="table" aria-label="Repositories">
|
||||
<div class="paper-table-head" role="row">
|
||||
<div role="columnheader">Repository</div>
|
||||
<div role="columnheader">Status</div>
|
||||
<div role="columnheader" class="num">Views</div>
|
||||
<div role="columnheader">Expires</div>
|
||||
<div role="columnheader" aria-label="Actions"></div>
|
||||
<div role="columnheader"><span class="sr-only">Actions</span></div>
|
||||
</div>
|
||||
<div
|
||||
class="paper-table-row"
|
||||
role="row"
|
||||
ng-class="{'repo-inactive': repo.status == 'expired' || repo.status == 'removed'}"
|
||||
ng-repeat="repo in conference.repositories| filter:repoFiler| orderBy:orderBy as filteredRepositories"
|
||||
ng-class="{'repo-inactive': repo.status == 'expired' || repo.status == 'removed', 'repo-error': repo.status == 'error'}"
|
||||
ng-repeat="repo in conference.repositories | filter:repoFiler | orderBy:orderBy as filteredRepositories track by repo.repoId"
|
||||
>
|
||||
<div class="cell-anon" role="cell">
|
||||
<span class="type-badge type-repo">Repo</span>
|
||||
<div class="anon-text">
|
||||
<a class="repo-name" ng-href="/r/{{repo.repoId}}" ng-bind="repo.repoId"></a>
|
||||
<a class="repo-name" ng-href="/r/{{repo.repoId}}/" ng-bind="repo.repoId"></a>
|
||||
<div class="anon-sub">
|
||||
<a href="https://github.com/{{repo.source.fullName}}/" ng-bind="repo.source.fullName"></a><span ng-if="repo.options.update"> · <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"> · @<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"> · <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"> · <a class="commit-hash" href="https://github.com/{{repo.source.fullName}}/tree/{{repo.source.commit}}" target="_blank" rel="noopener">@{{repo.source.commit.substring(0, 8)}}</a></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cell-status" role="cell">
|
||||
<span class="status-dot" ng-class="{'status-removed': repo.status == 'removed' || repo.status == 'expired', 'status-ready': repo.status == 'ready', 'status-error': repo.status == 'error'}"></span>
|
||||
<span ng-bind="repo.status | title"></span>
|
||||
<div class="status-line">
|
||||
<span class="status-dot" ng-class="'status-' + (repo.status == 'error' ? 'error' : repo.status == 'ready' ? 'ready' : (repo.status == 'expired' || repo.status == 'removed') ? repo.status : 'progress')" aria-hidden="true"></span>
|
||||
<span class="status-word" ng-bind="repo.status | statusLabel"></span>
|
||||
</div>
|
||||
<div class="status-sub" ng-if="repo.anonymizeDate">anonymized {{repo.anonymizeDate | humanTime}}</div>
|
||||
</div>
|
||||
<div class="cell-views num" role="cell" ng-bind="::repo.pageView | number"></div>
|
||||
<div class="cell-expires" role="cell">
|
||||
<span ng-if="repo.options.expirationMode !== 'never' && repo.status == 'ready'" ng-bind="repo.options.expirationDate | humanTime"></span>
|
||||
<span class="empty-dash" ng-if="!(repo.options.expirationMode !== 'never' && repo.status == 'ready')">—</span>
|
||||
<span class="expires-never" ng-if="repo.status == 'ready' && (repo.options.expirationMode === 'never' || !repo.options.expirationDate)">Never</span>
|
||||
<span ng-if="repo.status == 'ready' && repo.options.expirationMode !== 'never' && repo.options.expirationDate" ng-bind="repo.options.expirationDate | humanTime"></span>
|
||||
<span class="expires-past" ng-if="repo.status == 'expired'">Expired<span ng-if="repo.options.expirationDate"> {{repo.options.expirationDate | humanTime}}</span></span>
|
||||
<span class="empty-dash" ng-if="repo.status != 'ready' && repo.status != 'expired'" aria-label="Not applicable">—</span>
|
||||
</div>
|
||||
<div class="cell-actions" role="cell">
|
||||
<div class="dropdown">
|
||||
@@ -138,24 +158,28 @@
|
||||
data-toggle="dropdown"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
aria-label="Actions"
|
||||
aria-label="Actions for {{repo.repoId}}"
|
||||
>
|
||||
<i class="fas fa-ellipsis-h" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div class="dropdown-menu dropdown-menu-right">
|
||||
<a class="dropdown-item" href="/anonymize/{{repo.repoId}}">
|
||||
<i class="far fa-edit" aria-hidden="true"></i> Edit
|
||||
</a>
|
||||
<a class="dropdown-item" href="/r/{{repo.repoId}}/">
|
||||
<i class="fa fa-eye" aria-hidden="true"></i> View
|
||||
</a>
|
||||
<a class="dropdown-item" href="/anonymize/{{repo.repoId}}">
|
||||
<i class="far fa-edit" aria-hidden="true"></i> Edit
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="paper-table-empty" ng-if="filteredRepositories.length == 0">
|
||||
<i class="fas fa-inbox"></i>
|
||||
<span>There is no repository to display.</span>
|
||||
<i class="fas fa-inbox" aria-hidden="true"></i>
|
||||
<span ng-if="!conference.repositories.length">No repository has been submitted to this conference yet.</span>
|
||||
<span ng-if="conference.repositories.length">Nothing matches the current filters.</span>
|
||||
<div class="paper-table-empty-actions" ng-if="conference.repositories.length">
|
||||
<button type="button" class="btn btn-outline-ink" ng-click="search = ''; filters.status.ready = true; filters.status.expired = true; filters.status.removed = true;">Clear filters</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
<div class="container page paper-page">
|
||||
<div class="row">
|
||||
<div class="w-100">
|
||||
<div class="paper-crumbs">Reference / <span class="here">Conferences</span></div>
|
||||
<div class="paper-crumbs"><a href="/dashboard">My work</a> / <span class="here">Conferences</span></div>
|
||||
<div class="d-flex align-items-end justify-content-between flex-wrap" style="gap: 12px;">
|
||||
<div>
|
||||
<h1 class="paper-page-title">Your <em>conferences</em></h1>
|
||||
<p class="paper-page-lede">Group anonymizations by venue; set shared expiry and defaults.</p>
|
||||
<p class="paper-page-lede">Group anonymizations by venue, give chairs a shared dashboard, and set one expiry for every submission.</p>
|
||||
</div>
|
||||
<a href="/conference/new" class="btn btn-ink">
|
||||
<i class="fa fa-plus-circle mr-1"></i> New conference
|
||||
<i class="fa fa-plus-circle mr-1" aria-hidden="true"></i> New conference
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<form class="w-100 dashboard-filter-row" aria-label="Conferences" accept-charset="UTF-8">
|
||||
<form class="w-100 dashboard-filter-row" aria-label="Filter conferences" accept-charset="UTF-8" ng-submit="$event.preventDefault()">
|
||||
<div class="search-wrap">
|
||||
<input
|
||||
type="search"
|
||||
id="search"
|
||||
class="form-control"
|
||||
aria-label="Find a conference…"
|
||||
placeholder="Find a conference…"
|
||||
aria-label="Search conferences"
|
||||
placeholder="Search by name or ID…"
|
||||
autocomplete="off"
|
||||
ng-model="search"
|
||||
/>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap" style="gap: 8px">
|
||||
<div class="dashboard-filter-controls">
|
||||
<div class="dropdown">
|
||||
<button
|
||||
class="btn dropdown-toggle"
|
||||
@@ -34,10 +34,11 @@
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Sort
|
||||
<span class="filter-btn-label">Sort</span>
|
||||
<span class="filter-btn-value">{{ ({name: 'Name', conferenceID: 'ID', '-status': 'Status'})[orderBy] || 'Custom' }}</span>
|
||||
</button>
|
||||
<div class="dropdown-menu" aria-labelledby="dropdownSort">
|
||||
<h6 class="dropdown-header">Select order</h6>
|
||||
<h6 class="dropdown-header">Order by</h6>
|
||||
<div class="form-check dropdown-item">
|
||||
<input class="form-check-input" type="radio" name="sort" id="sortName" value="name" ng-model="orderBy" />
|
||||
<label class="form-check-label" for="sortName">Name</label>
|
||||
@@ -53,7 +54,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dropdown">
|
||||
<div class="dropdown" ng-init="statusLabels = {ready: 'Ready', expired: 'Expired', removed: 'Removed'}">
|
||||
<button
|
||||
class="btn dropdown-toggle"
|
||||
type="button"
|
||||
@@ -62,26 +63,33 @@
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Status
|
||||
<span class="filter-btn-label">Status</span>
|
||||
<span class="filter-btn-value" ng-if="filters.status.ready && filters.status.expired && filters.status.removed">All</span>
|
||||
<span class="filter-btn-value" ng-if="!(filters.status.ready && filters.status.expired && filters.status.removed)">
|
||||
{{ (filters.status.ready ? 0 : 1) + (filters.status.expired ? 0 : 1) + (filters.status.removed ? 0 : 1) }} hidden
|
||||
</span>
|
||||
</button>
|
||||
<div class="dropdown-menu" aria-labelledby="dropdownStatus">
|
||||
<h6 class="dropdown-header">Select status</h6>
|
||||
<div class="form-check dropdown-item">
|
||||
<input class="form-check-input" type="checkbox" name="sort" id="statusReady" value="ready" ng-model="filters.status.ready" />
|
||||
<label class="form-check-label" for="statusReady">Ready</label>
|
||||
</div>
|
||||
<div class="form-check dropdown-item">
|
||||
<input class="form-check-input" type="checkbox" name="sort" id="statusExpired" value="expired" ng-model="filters.status.expired" />
|
||||
<label class="form-check-label" for="statusExpired">Expired</label>
|
||||
</div>
|
||||
<div class="form-check dropdown-item">
|
||||
<input class="form-check-input" type="checkbox" name="sort" id="statusRemoved" value="removed" ng-model="filters.status.removed" />
|
||||
<label class="form-check-label" for="statusRemoved">Removed</label>
|
||||
<h6 class="dropdown-header">Show statuses</h6>
|
||||
<div class="form-check dropdown-item" ng-repeat="(key, label) in statusLabels">
|
||||
<input class="form-check-input" type="checkbox" id="status-{{key}}" ng-model="filters.status[key]" />
|
||||
<label class="form-check-label" for="status-{{key}}">{{label}}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="dashboard-meta">
|
||||
<span class="dashboard-count">
|
||||
<span ng-if="filteredConferences.length === conferences.length">{{conferences.length | number}} conference<span ng-if="conferences.length !== 1">s</span></span>
|
||||
<span ng-if="filteredConferences.length !== conferences.length">{{filteredConferences.length | number}} of {{conferences.length | number}} shown</span>
|
||||
</span>
|
||||
<span class="filter-chip" ng-repeat="(f, v) in filters.status" ng-if="v === false">
|
||||
Hiding {{statusLabels[f]}}
|
||||
<button type="button" class="filter-chip-close" aria-label="Show {{statusLabels[f]}} again" ng-click="filters.status[f] = true;">×</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="paper-table paper-table-conferences w-100" role="table" aria-label="Conferences">
|
||||
@@ -89,63 +97,72 @@
|
||||
<div role="columnheader">Conference</div>
|
||||
<div role="columnheader">Status</div>
|
||||
<div role="columnheader" class="num">Repos</div>
|
||||
<div role="columnheader">Window</div>
|
||||
<div role="columnheader" aria-label="Actions"></div>
|
||||
<div role="columnheader">Review window</div>
|
||||
<div role="columnheader"><span class="sr-only">Actions</span></div>
|
||||
</div>
|
||||
<div
|
||||
class="paper-table-row"
|
||||
class="paper-table-row row-clickable"
|
||||
role="row"
|
||||
ng-class="{'repo-inactive': conference.status == 'expired' || conference.status == 'removed'}"
|
||||
ng-repeat="conference in conferences| filter:conferenceFilter| orderBy:orderBy as filteredConferences"
|
||||
ng-repeat="conference in conferences | filter:conferenceFilter | orderBy:orderBy as filteredConferences track by conference.conferenceID"
|
||||
>
|
||||
<div class="cell-anon" role="cell">
|
||||
<span class="type-badge type-repo">Conf</span>
|
||||
<div class="anon-text">
|
||||
<a class="repo-name" ng-href="/conference/{{conference.conferenceID}}" ng-bind="conference.name"></a>
|
||||
<div class="anon-sub">
|
||||
ID <a ng-href="/conference/{{conference.conferenceID}}" ng-bind="conference.conferenceID"></a>
|
||||
<span class="anon-source">ID <span class="commit-hash" ng-bind="conference.conferenceID"></span><span ng-if="conference.url"> · <a ng-href="{{conference.url}}" target="_blank" rel="noopener" ng-bind="conference.url | limitTo: 60"></a></span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cell-status" role="cell">
|
||||
<span class="status-dot" ng-class="{'status-removed': conference.status == 'removed' || conference.status == 'expired', 'status-ready': conference.status == 'ready'}"></span>
|
||||
<span ng-bind="conference.status | title"></span>
|
||||
<div class="status-line">
|
||||
<span class="status-dot" ng-class="'status-' + conference.status" aria-hidden="true"></span>
|
||||
<span class="status-word" ng-bind="conference.status | statusLabel"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cell-views num" role="cell" ng-bind="::conference.nbRepositories || 0 | number"></div>
|
||||
<div class="cell-expires" role="cell">
|
||||
<span ng-if="conference.startDate">{{conference.startDate | date}} – {{conference.endDate | date}}</span>
|
||||
<span class="empty-dash" ng-if="!conference.startDate">—</span>
|
||||
<span ng-if="conference.startDate">{{conference.startDate | date:'mediumDate'}} – {{conference.endDate | date:'mediumDate'}}</span>
|
||||
<span class="empty-dash" ng-if="!conference.startDate" aria-label="No review window">—</span>
|
||||
</div>
|
||||
<div class="cell-actions" role="cell">
|
||||
<div class="dropdown">
|
||||
<button
|
||||
class="btn btn-icon-dots"
|
||||
type="button"
|
||||
id="dropdownMenuButton"
|
||||
data-toggle="dropdown"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
aria-label="Actions"
|
||||
aria-label="Actions for {{conference.name}}"
|
||||
>
|
||||
<i class="fas fa-ellipsis-h" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownMenuButton">
|
||||
<div class="dropdown-menu dropdown-menu-right">
|
||||
<a class="dropdown-item" href="/conference/{{conference.conferenceID}}/">
|
||||
<i class="fa fa-eye" aria-hidden="true"></i> View
|
||||
</a>
|
||||
<a class="dropdown-item" href="/conference/{{conference.conferenceID}}/edit">
|
||||
<i class="far fa-edit" aria-hidden="true"></i> Edit
|
||||
</a>
|
||||
<a class="dropdown-item" href="#" ng-show="conference.status != 'removed'" ng-click="removeConference(conference)">
|
||||
<i class="fas fa-trash-alt"></i> Remove
|
||||
</a>
|
||||
<a class="dropdown-item" href="/conference/{{conference.conferenceID}}/">
|
||||
<i class="fa fa-eye" aria-hidden="true"></i> View conference
|
||||
</a>
|
||||
<div ng-if="conference.status != 'removed'">
|
||||
<div class="dropdown-divider"></div>
|
||||
<button type="button" class="dropdown-item dropdown-item-danger" ng-click="removeConference(conference)">
|
||||
<i class="fas fa-trash-alt" aria-hidden="true"></i> Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="paper-table-empty" ng-if="filteredConferences.length == 0">
|
||||
<i class="fas fa-inbox"></i>
|
||||
<span>There are no conference entries. You can create a <a href="/conference/new">new one</a>.</span>
|
||||
<i class="fas fa-inbox" aria-hidden="true"></i>
|
||||
<span ng-if="conferences.length == 0">You have not created a conference yet.</span>
|
||||
<span ng-if="conferences.length > 0">Nothing matches the current filters.</span>
|
||||
<div class="paper-table-empty-actions">
|
||||
<button type="button" class="btn btn-outline-ink" ng-if="conferences.length > 0" ng-click="search = ''; filters.status.ready = true; filters.status.expired = true; filters.status.removed = true;">Clear filters</button>
|
||||
<a href="/conference/new" class="btn btn-ink" ng-if="conferences.length == 0"><i class="fa fa-plus-circle mr-1" aria-hidden="true"></i> New conference</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+189
-176
@@ -5,256 +5,269 @@
|
||||
<div class="d-flex align-items-end justify-content-between flex-wrap" style="gap: 12px;">
|
||||
<div>
|
||||
<h1 class="paper-page-title">Your <em>anonymizations</em></h1>
|
||||
<p class="paper-page-lede">Every repository and pull request you’ve mirrored, with live status and stats.</p>
|
||||
<p class="paper-page-lede">Every repository, pull request, and gist you’ve mirrored, with live status and stats.</p>
|
||||
</div>
|
||||
<a href="/anonymize" class="btn btn-ink">
|
||||
<i class="fa fa-plus-circle mr-1" aria-hidden="true"></i> New anonymization
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Quota bars -->
|
||||
<div class="quota-row" ng-show="quota">
|
||||
<div class="quota-item">
|
||||
<!-- Quota -->
|
||||
<div class="quota-row" ng-if="quota">
|
||||
<div class="quota-item" ng-repeat="q in [
|
||||
{key: 'repository', label: 'Repositories', kind: 'count'},
|
||||
{key: 'storage', label: 'Storage', kind: 'bytes'},
|
||||
{key: 'file', label: 'Files', kind: 'count'}
|
||||
]">
|
||||
<div class="quota-header">
|
||||
<span class="quota-label">Repositories</span>
|
||||
<span class="quota-value" ng-show="quota">{{quota.repository.used | number}}/{{quota.repository.total}}</span>
|
||||
<span class="quota-label">{{q.label}}</span>
|
||||
<span class="quota-value" ng-if="q.kind === 'count'">
|
||||
{{quota[q.key].used | number}}<span ng-if="!quota[q.key].unlimited"> / {{quota[q.key].total | number}}</span>
|
||||
<span class="quota-unlimited-tag" ng-if="quota[q.key].unlimited">Unlimited</span>
|
||||
</span>
|
||||
<span class="quota-value" ng-if="q.kind === 'bytes'">
|
||||
{{quota[q.key].used | humanFileSize}}<span ng-if="!quota[q.key].unlimited"> / {{quota[q.key].total | humanFileSize}}</span>
|
||||
<span class="quota-unlimited-tag" ng-if="quota[q.key].unlimited">Unlimited</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="progress quota-progress">
|
||||
<div
|
||||
class="progress-bar"
|
||||
ng-class="{'bg-success': quota.repository.percent < 25 || quota.repository.total == 0, 'bg-danger': quota.repository.percent > 95 && quota.repository.total > 0, 'bg-warning': quota.repository.percent > 75 && quota.repository.total > 0 }"
|
||||
role="progressbar"
|
||||
style="width: {{quota.repository.percent}}%;"
|
||||
aria-valuenow="{{quota.repository.used}}"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="{{quota.repository.total}}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="quota-item">
|
||||
<div class="quota-header">
|
||||
<span class="quota-label">Storage</span>
|
||||
<span class="quota-value" ng-show="quota">{{quota.storage.used | humanFileSize}}/{{quota.storage.total | humanFileSize}}</span>
|
||||
</div>
|
||||
<div class="progress quota-progress">
|
||||
<div
|
||||
class="progress-bar"
|
||||
ng-class="{'bg-success': quota.storage.percent < 25 || quota.storage.total == 0, 'bg-danger': quota.storage.percent > 95 && quota.storage.total > 0, 'bg-warning': quota.storage.percent > 75 && quota.storage.total > 0 }"
|
||||
role="progressbar"
|
||||
style="width: {{quota.storage.percent}}%;"
|
||||
aria-valuenow="{{quota.storage.used}}"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="{{quota.storage.total}}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="quota-item">
|
||||
<div class="quota-header">
|
||||
<span class="quota-label">Files</span>
|
||||
<span class="quota-value" ng-show="quota">{{quota.file.used | number}}/{{quota.file.total || "∞"}}</span>
|
||||
</div>
|
||||
<div class="progress quota-progress">
|
||||
<div
|
||||
class="progress-bar"
|
||||
ng-class="{'bg-success': quota.file.percent < 25 || quota.file.total == 0, 'bg-danger': quota.file.percent > 95 && quota.file.total > 0, 'bg-warning': quota.file.percent > 75 && quota.file.total > 0 }"
|
||||
role="progressbar"
|
||||
style="width: {{quota.file.percent}}%;"
|
||||
aria-valuenow="{{quota.file.used}}"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="{{quota.file.total}}"
|
||||
></div>
|
||||
<div
|
||||
class="quota-track"
|
||||
ng-class="'quota-' + quota[q.key].level"
|
||||
role="progressbar"
|
||||
aria-label="{{q.label}} quota"
|
||||
aria-valuenow="{{quota[q.key].used}}"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="{{quota[q.key].unlimited ? quota[q.key].used : quota[q.key].total}}"
|
||||
aria-valuetext="{{quota[q.key].unlimited ? 'unlimited' : (quota[q.key].percent | number:0) + '% used'}}"
|
||||
>
|
||||
<div class="quota-fill" ng-if="!quota[q.key].unlimited" ng-style="{width: quota[q.key].percent + '%'}"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search + filters row -->
|
||||
<form class="w-100 dashboard-filter-row" aria-label="Dashboard" accept-charset="UTF-8">
|
||||
<!-- Search + filters -->
|
||||
<form class="w-100 dashboard-filter-row" aria-label="Filter anonymizations" accept-charset="UTF-8" ng-submit="$event.preventDefault()">
|
||||
<div class="search-wrap">
|
||||
<input
|
||||
type="search"
|
||||
id="search"
|
||||
class="form-control"
|
||||
aria-label="Search..."
|
||||
placeholder="Search anonymizations…"
|
||||
aria-label="Search anonymizations"
|
||||
placeholder="Search by name, source, or conference…"
|
||||
autocomplete="off"
|
||||
ng-model="search"
|
||||
/>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap" style="gap: 8px">
|
||||
<!-- Type filter -->
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button type="button" class="btn" ng-class="{'btn-primary': typeFilter === 'all'}" ng-click="typeFilter = 'all'">All</button>
|
||||
<button type="button" class="btn" ng-class="{'btn-primary': typeFilter === 'repo'}" ng-click="typeFilter = 'repo'">Repos</button>
|
||||
<button type="button" class="btn" ng-class="{'btn-primary': typeFilter === 'pr'}" ng-click="typeFilter = 'pr'">PRs</button>
|
||||
<button type="button" class="btn" ng-class="{'btn-primary': typeFilter === 'gist'}" ng-click="typeFilter = 'gist'">Gists</button>
|
||||
</div>
|
||||
<div class="dashboard-filter-controls">
|
||||
<div class="btn-group" role="group" aria-label="Type">
|
||||
<button type="button" class="btn" ng-class="{'btn-primary': typeFilter === 'all'}" aria-pressed="{{typeFilter === 'all'}}" ng-click="typeFilter = 'all'">All</button>
|
||||
<button type="button" class="btn" ng-class="{'btn-primary': typeFilter === 'repo'}" aria-pressed="{{typeFilter === 'repo'}}" ng-click="typeFilter = 'repo'">Repos</button>
|
||||
<button type="button" class="btn" ng-class="{'btn-primary': typeFilter === 'pr'}" aria-pressed="{{typeFilter === 'pr'}}" ng-click="typeFilter = 'pr'">PRs</button>
|
||||
<button type="button" class="btn" ng-class="{'btn-primary': typeFilter === 'gist'}" aria-pressed="{{typeFilter === 'gist'}}" ng-click="typeFilter = 'gist'">Gists</button>
|
||||
</div>
|
||||
|
||||
<div class="dropdown">
|
||||
<button
|
||||
class="btn btn-sm dropdown-toggle"
|
||||
type="button"
|
||||
id="dropdownSort"
|
||||
data-toggle="dropdown"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Sort
|
||||
</button>
|
||||
<div class="dropdown-menu" aria-labelledby="dropdownSort">
|
||||
<h6 class="dropdown-header">Select order</h6>
|
||||
<div class="form-check dropdown-item">
|
||||
<input class="form-check-input" type="radio" name="sort" id="sortFullName" value="_name" ng-model="orderBy" />
|
||||
<label class="form-check-label" for="sortFullName">Name</label>
|
||||
</div>
|
||||
<div class="form-check dropdown-item">
|
||||
<input class="form-check-input" type="radio" name="sort" id="sortAnonymizeDate" value="-anonymizeDate" ng-model="orderBy" />
|
||||
<label class="form-check-label" for="sortAnonymizeDate">Anonymize Date</label>
|
||||
</div>
|
||||
<div class="form-check dropdown-item">
|
||||
<input class="form-check-input" type="radio" name="sort" id="sortStatus" value="-status" ng-model="orderBy" />
|
||||
<label class="form-check-label" for="sortStatus">Status</label>
|
||||
</div>
|
||||
<div class="form-check dropdown-item">
|
||||
<input class="form-check-input" type="radio" name="sort" id="sortLastView" value="-lastView" ng-model="orderBy" />
|
||||
<label class="form-check-label" for="sortLastView">Last View</label>
|
||||
</div>
|
||||
<div class="form-check dropdown-item">
|
||||
<input class="form-check-input" type="radio" name="sort" id="sortPageView" value="-pageView" ng-model="orderBy" />
|
||||
<label class="form-check-label" for="sortPageView">Page View</label>
|
||||
</div>
|
||||
<div class="dropdown">
|
||||
<button
|
||||
class="btn dropdown-toggle"
|
||||
type="button"
|
||||
id="dropdownSort"
|
||||
data-toggle="dropdown"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<span class="filter-btn-label">Sort</span>
|
||||
<span class="filter-btn-value">{{sortLabel()}}</span>
|
||||
<i class="fas" ng-class="sortDesc() ? 'fa-arrow-down' : 'fa-arrow-up'" aria-hidden="true"></i>
|
||||
<span class="sr-only">{{sortDesc() ? 'descending' : 'ascending'}}</span>
|
||||
</button>
|
||||
<div class="dropdown-menu" aria-labelledby="dropdownSort">
|
||||
<h6 class="dropdown-header">Order by</h6>
|
||||
<div class="form-check dropdown-item" ng-repeat="(field, def) in sortFields">
|
||||
<input class="form-check-input" type="radio" name="sort" id="sort-{{$index}}" ng-checked="isSortedBy(field)" ng-click="setSort(field, def.defaultDesc)" />
|
||||
<label class="form-check-label" for="sort-{{$index}}">{{def.label}}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dropdown">
|
||||
<button
|
||||
class="btn btn-sm dropdown-toggle"
|
||||
type="button"
|
||||
id="dropdownStatus"
|
||||
data-toggle="dropdown"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Status
|
||||
<div class="dropdown-divider"></div>
|
||||
<button type="button" class="dropdown-item" ng-click="toggleSortDirection()">
|
||||
<i class="fas fa-exchange-alt fa-rotate-90" aria-hidden="true"></i>
|
||||
{{sortDesc() ? 'Switch to ascending' : 'Switch to descending'}}
|
||||
</button>
|
||||
<div class="dropdown-menu" aria-labelledby="dropdownStatus">
|
||||
<h6 class="dropdown-header">Select status</h6>
|
||||
<div class="form-check dropdown-item">
|
||||
<input class="form-check-input" type="checkbox" id="statusReady" value="ready" ng-model="filters.status.ready" />
|
||||
<label class="form-check-label" for="statusReady">Ready</label>
|
||||
</div>
|
||||
<div class="form-check dropdown-item">
|
||||
<input class="form-check-input" type="checkbox" id="statusExpired" value="expired" ng-model="filters.status.expired" />
|
||||
<label class="form-check-label" for="statusExpired">Expired</label>
|
||||
</div>
|
||||
<div class="form-check dropdown-item">
|
||||
<input class="form-check-input" type="checkbox" id="statusRemoved" value="removed" ng-model="filters.status.removed" />
|
||||
<label class="form-check-label" for="statusRemoved">Removed</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dropdown">
|
||||
<button
|
||||
class="btn dropdown-toggle"
|
||||
type="button"
|
||||
id="dropdownStatus"
|
||||
data-toggle="dropdown"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<span class="filter-btn-label">Status</span>
|
||||
<span class="filter-btn-value" ng-if="!hasHiddenStatus()">All</span>
|
||||
<span class="filter-btn-value" ng-if="hasHiddenStatus()">{{hiddenStatusCount()}} hidden</span>
|
||||
</button>
|
||||
<div class="dropdown-menu" aria-labelledby="dropdownStatus">
|
||||
<h6 class="dropdown-header">Show statuses</h6>
|
||||
<div class="form-check dropdown-item" ng-repeat="(key, label) in statusKeyLabels">
|
||||
<input class="form-check-input" type="checkbox" id="status-{{key}}" ng-model="filters.status[key]" />
|
||||
<label class="form-check-label" for="status-{{key}}">{{label}}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Active filter chips -->
|
||||
<div class="d-flex flex-wrap mt-2" style="gap: 6px" ng-show="filters.status.ready === false || filters.status.expired === false || filters.status.removed === false">
|
||||
<span class="filter-chip" ng-show="!v" ng-repeat="(f, v) in filters.status">
|
||||
{{f | title}}
|
||||
<button type="button" class="filter-chip-close" aria-label="Remove filter" ng-click="filters.status[f] = true;">
|
||||
×
|
||||
</button>
|
||||
<!-- Result count + active filter chips -->
|
||||
<div class="dashboard-meta" ng-if="!loading">
|
||||
<span class="dashboard-count">
|
||||
<span ng-if="filteredItems.length === items.length">{{items.length | number}} anonymization<span ng-if="items.length !== 1">s</span></span>
|
||||
<span ng-if="filteredItems.length !== items.length">{{filteredItems.length | number}} of {{items.length | number}} shown</span>
|
||||
</span>
|
||||
<span class="filter-chip" ng-repeat="(f, v) in filters.status" ng-if="v === false">
|
||||
Hiding {{statusKeyLabels[f]}}
|
||||
<button type="button" class="filter-chip-close" aria-label="Show {{statusKeyLabels[f]}} again" ng-click="filters.status[f] = true;">×</button>
|
||||
</span>
|
||||
<button type="button" class="btn-link-inline" ng-if="hasActiveFilters()" ng-click="clearFilters()">Clear filters</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="paper-table w-100" role="table" aria-label="Anonymizations">
|
||||
<div class="paper-table paper-table-dashboard w-100" role="table" aria-label="Anonymizations" aria-busy="{{loading}}">
|
||||
<div class="paper-table-head" role="row">
|
||||
<div role="columnheader">Anonymization</div>
|
||||
<div role="columnheader">Conference</div>
|
||||
<div role="columnheader">Status</div>
|
||||
<div role="columnheader" class="num">Views</div>
|
||||
<div role="columnheader">Expires</div>
|
||||
<div role="columnheader" aria-label="Actions"></div>
|
||||
<div role="columnheader" aria-sort="{{isSortedBy('_name') ? (sortDesc() ? 'descending' : 'ascending') : 'none'}}">
|
||||
<button type="button" class="sortable" ng-class="{active: isSortedBy('_name')}" ng-click="setSort('_name')">
|
||||
Anonymization <i class="fas" ng-if="isSortedBy('_name')" ng-class="sortDesc() ? 'fa-arrow-down' : 'fa-arrow-up'" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div role="columnheader" aria-sort="{{isSortedBy('status') ? (sortDesc() ? 'descending' : 'ascending') : 'none'}}">
|
||||
<button type="button" class="sortable" ng-class="{active: isSortedBy('status')}" ng-click="setSort('status')">
|
||||
Status <i class="fas" ng-if="isSortedBy('status')" ng-class="sortDesc() ? 'fa-arrow-down' : 'fa-arrow-up'" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div role="columnheader" class="num" aria-sort="{{isSortedBy('pageView') ? (sortDesc() ? 'descending' : 'ascending') : 'none'}}">
|
||||
<button type="button" class="sortable" ng-class="{active: isSortedBy('pageView')}" ng-click="setSort('pageView')">
|
||||
Views <i class="fas" ng-if="isSortedBy('pageView')" ng-class="sortDesc() ? 'fa-arrow-down' : 'fa-arrow-up'" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div role="columnheader" aria-sort="{{isSortedBy('options.expirationDate') ? (sortDesc() ? 'descending' : 'ascending') : 'none'}}">
|
||||
<button type="button" class="sortable" ng-class="{active: isSortedBy('options.expirationDate')}" ng-click="setSort('options.expirationDate')">
|
||||
Expires <i class="fas" ng-if="isSortedBy('options.expirationDate')" ng-class="sortDesc() ? 'fa-arrow-down' : 'fa-arrow-up'" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div role="columnheader"><span class="sr-only">Actions</span></div>
|
||||
</div>
|
||||
|
||||
<!-- Loading skeleton: keeps the layout stable while the three lists load -->
|
||||
<div class="paper-table-row paper-table-skeleton" role="row" aria-hidden="true" ng-if="loading" ng-repeat="i in [1, 2, 3, 4]">
|
||||
<div class="cell-anon" role="cell"><span class="skeleton skeleton-badge"></span><div class="anon-text"><span class="skeleton skeleton-line" style="width: 38%"></span><span class="skeleton skeleton-line skeleton-line-sm" style="width: 56%"></span></div></div>
|
||||
<div class="cell-status" role="cell"><span class="skeleton skeleton-line" style="width: 60%"></span></div>
|
||||
<div class="cell-views num" role="cell"><span class="skeleton skeleton-line" style="width: 40%"></span></div>
|
||||
<div class="cell-expires" role="cell"><span class="skeleton skeleton-line" style="width: 55%"></span></div>
|
||||
<div class="cell-actions" role="cell"></div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="paper-table-row"
|
||||
role="row"
|
||||
ng-class="{'repo-inactive': item.status == 'expired' || item.status == 'removed', 'repo-error': item.status == 'error'}"
|
||||
ng-repeat="item in items | filter:itemFilter | orderBy:orderBy as filteredItems"
|
||||
ng-if="!loading"
|
||||
ng-class="{'repo-inactive': item._statusKey == 'expired' || item._statusKey == 'removed', 'repo-error': item.status == 'error', 'row-clickable': !!item._viewUrl}"
|
||||
ng-repeat="item in items | filter:itemFilter | orderBy:orderBy as filteredItems track by (item._type + ':' + (item._id || $index))"
|
||||
ng-click="openItem(item, $event)"
|
||||
>
|
||||
<div class="cell-anon" role="cell">
|
||||
<span class="type-badge" ng-class="{'type-repo': item._type === 'repo', 'type-pr': item._type === 'pr', 'type-gist': item._type === 'gist'}">{{item._type === 'repo' ? 'Repo' : item._type === 'pr' ? 'PR' : 'Gist'}}</span>
|
||||
<span class="type-badge type-coauthor" ng-if="item.role === 'coauthor'" title="You are a co-author on this anonymization">Co-author</span>
|
||||
<div class="anon-text">
|
||||
<a ng-href="{{item._viewUrl}}" class="repo-name" ng-bind="item._name"></a>
|
||||
<div class="anon-sub">
|
||||
<a ng-if="item._type === 'repo'" href="https://github.com/{{item.source.fullName}}/" ng-bind="item.source.fullName"></a><span ng-if="item._type === 'repo' && item.options.update"> · <a href="https://github.com/{{item.source.fullName}}/tree/{{item.source.branch}}" ng-bind="item.source.branch"></a><span ng-if="item.source.commit"> · @<a href="https://github.com/{{item.source.fullName}}/tree/{{item.source.commit}}" ng-bind="item.source.commit.substring(0, 8)"></a></span></span><span ng-if="item._type === 'repo' && !item.options.update"> · @<a href="https://github.com/{{item.source.fullName}}/tree/{{item.source.commit}}" ng-bind="item.source.commit.substring(0, 8)"></a></span>
|
||||
<a ng-if="item._type === 'pr'" href="https://github.com/{{item.source.repositoryFullName}}/pull/{{item.source.pullRequestId}}" ng-bind="item._source"></a>
|
||||
<a ng-if="item._type === 'gist'" href="https://gist.github.com/{{item.source.gistId}}" ng-bind="item._source"></a>
|
||||
<div class="anon-title">
|
||||
<a ng-if="item._viewUrl" ng-href="{{item._viewUrl}}" class="repo-name" ng-bind="item._name"></a>
|
||||
<span ng-if="!item._viewUrl" class="repo-name repo-name-static" ng-bind="item._name"></span>
|
||||
<span class="type-badge type-coauthor" ng-if="item.role === 'coauthor'" title="You are a co-author on this anonymization">Co-author</span>
|
||||
</div>
|
||||
<div class="anon-sub" ng-if="!item._broken || item.conference">
|
||||
<span class="anon-source" ng-if="item._type === 'repo' && item.source.fullName && !item._broken">
|
||||
<a href="https://github.com/{{item.source.fullName}}/" target="_blank" rel="noopener" ng-bind="item.source.fullName"></a><span ng-if="item.options.update && item.source.branch"> · <a href="https://github.com/{{item.source.fullName}}/tree/{{item.source.branch}}" target="_blank" rel="noopener" ng-bind="item.source.branch"></a></span><span ng-if="item.source.commit"> · <a class="commit-hash" href="https://github.com/{{item.source.fullName}}/tree/{{item.source.commit}}" target="_blank" rel="noopener">@{{item.source.commit.substring(0, 8)}}</a></span>
|
||||
</span>
|
||||
<span class="anon-source" ng-if="item._type === 'pr' && item.source.repositoryFullName && !item._broken">
|
||||
<a href="https://github.com/{{item.source.repositoryFullName}}/pull/{{item.source.pullRequestId}}" target="_blank" rel="noopener" ng-bind="item._source"></a>
|
||||
</span>
|
||||
<span class="anon-source" ng-if="item._type === 'gist' && item.source.gistId && !item._broken">
|
||||
<a href="https://gist.github.com/{{item.source.gistId}}" target="_blank" rel="noopener" ng-bind="item._source"></a>
|
||||
</span>
|
||||
<span class="cell-conf conf-tag" ng-if="item.conference" title="{{item.conference}}"><i class="fas fa-university" aria-hidden="true"></i> <span ng-bind="item.conference"></span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cell-conf" role="cell">
|
||||
<span ng-if="item.conference" title="{{item.conference}}" ng-bind="item.conference"></span>
|
||||
<span class="empty-dash" ng-if="!item.conference">—</span>
|
||||
</div>
|
||||
<div class="cell-status" role="cell">
|
||||
<div class="status-line">
|
||||
<span
|
||||
class="status-dot"
|
||||
ng-class="{'status-removed': item.status == 'removed' || item.status == 'expired' || item.status == 'removing' || item.status == 'expiring', 'status-preparing': item.status == 'preparing' || item.status == 'download', 'status-ready': item.status == 'ready', 'status-error': item.status == 'error'}"
|
||||
></span>
|
||||
<span ng-bind="item.status | title"></span>
|
||||
<span class="status-dot" ng-class="'status-' + item._statusKey" aria-hidden="true"></span>
|
||||
<span class="status-word" ng-bind="item.status | statusLabel"></span>
|
||||
</div>
|
||||
<div class="status-sub status-sub-error" ng-if="item.status == 'error' && item.statusMessage" title="{{item.statusMessage}}" ng-bind="item.statusMessage | statusMsg"></div>
|
||||
<div class="status-sub" ng-if="item.status != 'error' && item.anonymizeDate" title="Last anonymized {{item.anonymizeDate | humanTime}}" ng-bind="item.anonymizeDate | humanTime"></div>
|
||||
<div class="status-sub status-sub-warn" ng-if="item._stale" title="Last activity {{item.anonymizeDate || item.lastView | humanTime}}">
|
||||
Last activity {{item.anonymizeDate || item.lastView | humanTime}} · may be stuck
|
||||
</div>
|
||||
<div class="status-sub" ng-if="item.status != 'error' && !item._stale && item.anonymizeDate">
|
||||
anonymized {{item.anonymizeDate | humanTime}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="cell-views num" role="cell" ng-bind="item.pageView | number"></div>
|
||||
<div class="cell-expires" role="cell">
|
||||
<span ng-if="item.options.expirationMode !== 'never' && item.status == 'ready'" ng-bind="item.options.expirationDate | humanTime"></span>
|
||||
<span class="empty-dash" ng-if="!(item.options.expirationMode !== 'never' && item.status == 'ready')">—</span>
|
||||
<span ng-if="item._expiry.kind === 'never'" class="expires-never">Never</span>
|
||||
<span ng-if="item._expiry.kind === 'date'" ng-bind="item._expiry.date | humanTime"></span>
|
||||
<span ng-if="item._expiry.kind === 'expired' && item._expiry.date" class="expires-past">Expired {{item._expiry.date | humanTime}}</span>
|
||||
<span ng-if="item._expiry.kind === 'expired' && !item._expiry.date" class="expires-past">Expired</span>
|
||||
<span class="empty-dash" ng-if="item._expiry.kind === 'none'" aria-label="Not applicable">—</span>
|
||||
</div>
|
||||
<div class="cell-actions" role="cell">
|
||||
<div class="dropdown">
|
||||
<div class="dropdown" ng-if="!item._broken">
|
||||
<button
|
||||
class="btn btn-icon-dots"
|
||||
type="button"
|
||||
data-toggle="dropdown"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
aria-label="Actions"
|
||||
aria-label="Actions for {{item._name}}"
|
||||
>
|
||||
<i class="fas fa-ellipsis-h" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div class="dropdown-menu dropdown-menu-right">
|
||||
<a class="dropdown-item" ng-href="{{item._editUrl}}">
|
||||
<i class="far fa-edit" aria-hidden="true"></i> Edit
|
||||
</a>
|
||||
<a class="dropdown-item" href="#" ng-show="item.status == 'ready' || item.status == 'error'" ng-click="refreshItem(item)">
|
||||
<i class="fas fa-sync"></i> Force update
|
||||
</a>
|
||||
<a class="dropdown-item" href="#" ng-show="item.status == 'removed'" ng-click="refreshItem(item)">
|
||||
<i class="fas fa-check-circle"></i> Enable
|
||||
</a>
|
||||
<a class="dropdown-item" href="#" ng-show="item.status == 'expired'" ng-click="extendItem(item)">
|
||||
<i class="fas fa-calendar-plus"></i> Extend 6 months
|
||||
</a>
|
||||
<a class="dropdown-item" href="#" ng-show="(item.status == 'ready' || item.status == 'expired' || item.status == 'error') && item.role !== 'coauthor'" ng-click="removeItem(item)">
|
||||
<i class="fas fa-trash-alt"></i> Remove
|
||||
</a>
|
||||
<a class="dropdown-item" ng-href="{{item._viewUrl}}">
|
||||
<i class="fa fa-eye" aria-hidden="true"></i> View
|
||||
</a>
|
||||
<a class="dropdown-item" href="/w/{{item.repoId}}/" target="_self" ng-if="item._type === 'repo' && item.options.page && item.status == 'ready'">
|
||||
<i class="fas fa-globe"></i> View Page
|
||||
<i class="fas fa-globe" aria-hidden="true"></i> View page
|
||||
</a>
|
||||
<a class="dropdown-item" ng-href="{{item._editUrl}}">
|
||||
<i class="far fa-edit" aria-hidden="true"></i> Edit
|
||||
</a>
|
||||
<button type="button" class="dropdown-item" ng-if="item.status == 'ready' || item.status == 'error'" ng-click="refreshItem(item)">
|
||||
<i class="fas fa-sync" aria-hidden="true"></i> Force update
|
||||
</button>
|
||||
<button type="button" class="dropdown-item" ng-if="item.status == 'removed'" ng-click="refreshItem(item)">
|
||||
<i class="fas fa-check-circle" aria-hidden="true"></i> Enable
|
||||
</button>
|
||||
<button type="button" class="dropdown-item" ng-if="item.status == 'expired'" ng-click="extendItem(item)">
|
||||
<i class="fas fa-calendar-plus" aria-hidden="true"></i> Extend 6 months
|
||||
</button>
|
||||
<div ng-if="(item.status == 'ready' || item.status == 'expired' || item.status == 'error') && item.role !== 'coauthor'">
|
||||
<div class="dropdown-divider"></div>
|
||||
<button type="button" class="dropdown-item dropdown-item-danger" ng-click="removeItem(item)">
|
||||
<i class="fas fa-trash-alt" aria-hidden="true"></i> Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="paper-table-empty" ng-if="filteredItems.length == 0">
|
||||
<i class="fas fa-inbox"></i>
|
||||
<span>Nothing to display.</span>
|
||||
|
||||
<div class="paper-table-empty" ng-if="!loading && filteredItems.length == 0">
|
||||
<i class="fas fa-inbox" aria-hidden="true"></i>
|
||||
<span ng-if="items.length == 0">You have no anonymizations yet.</span>
|
||||
<span ng-if="items.length > 0">Nothing matches the current filters.</span>
|
||||
<div class="paper-table-empty-actions">
|
||||
<button type="button" class="btn btn-outline-ink" ng-if="hasActiveFilters()" ng-click="clearFilters()">Clear filters</button>
|
||||
<a href="/anonymize" class="btn btn-ink" ng-if="items.length == 0"><i class="fa fa-plus-circle mr-1" aria-hidden="true"></i> New anonymization</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<h1 class="paper-faq-title">Answers to the <em>questions</em> that come up most.</h1>
|
||||
<p class="paper-faq-lede">
|
||||
If something's missing, write to us —
|
||||
<a href="https://github.com/tdurieux/anonymous_github/issues">open an issue on GitHub</a>.
|
||||
<a href="https://github.com/tdurieux/anonymous_github/issues/new?template=issue_report.yml" target="_blank" rel="noopener">report a bug on GitHub</a>.
|
||||
</p>
|
||||
</div>
|
||||
<div class="paper-faq-body">
|
||||
|
||||
+37
-12
@@ -74,30 +74,37 @@
|
||||
class="nav-link nav-icon"
|
||||
target="_blank"
|
||||
href="https://github.com/tdurieux/anonymous_github/"
|
||||
title="GitHub repository"
|
||||
title="Anonymous GitHub source code"
|
||||
aria-label="Anonymous GitHub source code on GitHub"
|
||||
rel="noopener"
|
||||
data-offset="30"
|
||||
><i class="fab fa-github" aria-hidden="true"></i
|
||||
></a>
|
||||
</li>
|
||||
<!-- "Report a bug" is about this service. Reviewers who land on an
|
||||
anonymized repository often think it is about the paper, so the
|
||||
label names the service and the tooltip says who to contact. -->
|
||||
<li class="nav-item d-none d-lg-block">
|
||||
<a
|
||||
class="nav-link"
|
||||
target="_blank"
|
||||
href="https://github.com/tdurieux/anonymous_github/issues/new"
|
||||
rel="noopener"
|
||||
href="https://github.com/tdurieux/anonymous_github/issues/new?template=issue_report.yml"
|
||||
title="Report a problem with the Anonymous GitHub service. Questions about an anonymized repository or paper go to its authors."
|
||||
data-offset="30"
|
||||
>Report an issue
|
||||
><i class="fas fa-bug d-lg-none mr-1" aria-hidden="true"></i>Report a bug
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item" ng-if="!isDarkMode">
|
||||
<a class="nav-link nav-icon" href="#" ng-click="darkMode(true);" title="Dark mode">
|
||||
<i class="fas fa-moon"></i>
|
||||
<a class="nav-link nav-icon" href="#" ng-click="darkMode(true);" title="Switch to dark mode" aria-label="Switch to dark mode" role="button">
|
||||
<i class="fas fa-moon" aria-hidden="true"></i>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item" ng-if="isDarkMode">
|
||||
<a class="nav-link nav-icon" href="#" ng-click="darkMode(false);" title="Light mode">
|
||||
<i class="fas fa-sun"></i>
|
||||
<a class="nav-link nav-icon" href="#" ng-click="darkMode(false);" title="Switch to light mode" aria-label="Switch to light mode" role="button">
|
||||
<i class="fas fa-sun" aria-hidden="true"></i>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -126,12 +133,30 @@
|
||||
/>
|
||||
<span class="user-chip-name">{{user.username}}</span>
|
||||
</a>
|
||||
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="navbarDropdownMenuLink">
|
||||
<a class="dropdown-item" href="/profile">Default settings</a>
|
||||
<div class="dropdown-menu dropdown-menu-right user-menu" aria-labelledby="navbarDropdownMenuLink">
|
||||
<h6 class="dropdown-header">Signed in as @{{user.username}}</h6>
|
||||
<a class="dropdown-item" href="/profile">
|
||||
<i class="fas fa-sliders-h" aria-hidden="true"></i>
|
||||
<span>Settings<small>Defaults, quotas, account</small></span>
|
||||
</a>
|
||||
<a class="dropdown-item" href="/claim">
|
||||
<i class="fas fa-link" aria-hidden="true"></i>
|
||||
<span>Claim an anonymization<small>Attach an existing mirror to your account</small></span>
|
||||
</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
<a class="dropdown-item" href="/api/user/logout" target="_self"
|
||||
>Logout</a
|
||||
>
|
||||
<a class="dropdown-item" href="https://github.com/tdurieux/anonymous_github/issues/new?template=issue_report.yml" target="_blank" rel="noopener">
|
||||
<i class="fas fa-bug" aria-hidden="true"></i>
|
||||
<span>Report a bug<small>About this service, not a paper</small></span>
|
||||
</a>
|
||||
<a class="dropdown-item" href="https://github.com/tdurieux/anonymous_github/" target="_blank" rel="noopener">
|
||||
<i class="fab fa-github" aria-hidden="true"></i>
|
||||
<span>Source code</span>
|
||||
</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
<a class="dropdown-item" href="/api/user/logout" target="_self">
|
||||
<i class="fas fa-sign-out-alt" aria-hidden="true"></i>
|
||||
<span>Sign out</span>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
<span class="accent">not the author.</span>
|
||||
</h1>
|
||||
<p class="hero-subtitle">
|
||||
Anonymous GitHub makes a read-only mirror of your repository or pull
|
||||
request with every trace of identity removed — so you can attach a
|
||||
Anonymous GitHub makes a read-only mirror of your repository, pull
|
||||
request, or gist with every trace of identity removed — so you can attach a
|
||||
link to your submission without breaking anonymity.
|
||||
</p>
|
||||
|
||||
@@ -264,7 +264,7 @@
|
||||
<div class="paper-footer-col">
|
||||
<div class="paper-footer-head">Project</div>
|
||||
<a href="https://github.com/tdurieux/anonymous_github/" target="_blank">Source code</a>
|
||||
<a href="https://github.com/tdurieux/anonymous_github/issues/new" target="_blank">Report an issue</a>
|
||||
<a href="https://github.com/tdurieux/anonymous_github/issues/new?template=issue_report.yml" target="_blank" rel="noopener">Report a bug</a>
|
||||
<a href="https://github.com/sponsors/tdurieux/" target="_blank">Sponsor</a>
|
||||
</div>
|
||||
<div class="paper-footer-col">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="container paper-page paper-settings">
|
||||
<div class="paper-crumbs"><a href="/conferences">Conferences</a> / <span class="here">{{editionMode ? 'Edit' : 'New'}}</span></div>
|
||||
<div class="paper-crumbs"><a href="/dashboard">My work</a> / <a href="/conferences">Conferences</a> / <span class="here">{{editionMode ? 'Edit' : 'New'}}</span></div>
|
||||
<h1 class="paper-page-title">{{editionMode ? 'Edit' : 'Create a'}} <em>conference</em></h1>
|
||||
<p class="paper-page-lede">
|
||||
Give chairs access to every anonymization submitted to a venue, and lift
|
||||
@@ -7,8 +7,8 @@
|
||||
</p>
|
||||
|
||||
<div class="paper-settings-body">
|
||||
<aside class="paper-settings-toc">
|
||||
<div class="paper-settings-toc-head">Contents</div>
|
||||
<aside class="paper-settings-toc" paper-scrollspy>
|
||||
<div class="paper-settings-toc-head">On this page</div>
|
||||
<nav>
|
||||
<a href="#conf-basics">Basics</a>
|
||||
<a href="#conf-window">Review window</a>
|
||||
@@ -31,7 +31,7 @@
|
||||
name="name"
|
||||
id="name"
|
||||
required
|
||||
ng-class="{'is-invalid': conference.name.$invalid}"
|
||||
ng-class="{'is-invalid': conference.name.$invalid && (conference.name.$touched || conference.$submitted)}"
|
||||
ng-model="options.name"
|
||||
placeholder="e.g. International Conference on Software Engineering"
|
||||
/>
|
||||
@@ -50,7 +50,7 @@
|
||||
class="form-control"
|
||||
name="url"
|
||||
id="url"
|
||||
ng-class="{'is-invalid': conference.url.$invalid}"
|
||||
ng-class="{'is-invalid': conference.url.$invalid && (conference.url.$touched || conference.$submitted)}"
|
||||
ng-model="options.url"
|
||||
placeholder="https://example.org"
|
||||
/>
|
||||
@@ -68,7 +68,7 @@
|
||||
id="conferenceID"
|
||||
required
|
||||
pattern="[a-zA-Z0-9\-_]{3,10}"
|
||||
ng-class="{'is-invalid': conference.conferenceID.$invalid}"
|
||||
ng-class="{'is-invalid': conference.conferenceID.$invalid && (conference.conferenceID.$touched || conference.$submitted)}"
|
||||
ng-model="options.conferenceID"
|
||||
placeholder="ICSE26"
|
||||
/>
|
||||
@@ -97,7 +97,7 @@
|
||||
name="startDate"
|
||||
id="startDate"
|
||||
required
|
||||
ng-class="{'is-invalid': conference.startDate.$invalid}"
|
||||
ng-class="{'is-invalid': conference.startDate.$invalid && (conference.startDate.$touched || conference.$submitted)}"
|
||||
ng-model="options.startDate"
|
||||
/>
|
||||
<small class="form-text text-muted">Beginning of the review process.</small>
|
||||
@@ -117,7 +117,7 @@
|
||||
name="endDate"
|
||||
id="endDate"
|
||||
required
|
||||
ng-class="{'is-invalid': conference.endDate.$invalid}"
|
||||
ng-class="{'is-invalid': conference.endDate.$invalid && (conference.endDate.$touched || conference.$submitted)}"
|
||||
ng-model="options.endDate"
|
||||
/>
|
||||
<small class="form-text text-muted">All repositories expire on this date.</small>
|
||||
@@ -160,19 +160,19 @@
|
||||
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="update" name="update" ng-model="options.options.update" />
|
||||
<label class="form-check-label" for="update">Auto update</label>
|
||||
<label class="form-check-label" for="update">Auto-update from GitHub</label>
|
||||
<small class="form-text text-muted">Pull the latest commit automatically (hourly maximum).</small>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="page" name="page" ng-model="options.options.page" />
|
||||
<label class="form-check-label" for="page">GitHub pages</label>
|
||||
<label class="form-check-label" for="page">GitHub Pages</label>
|
||||
<small class="form-text text-muted">Enable anonymized GitHub pages.</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="conf-plan" class="paper-settings-section">
|
||||
<div class="paper-section-eyebrow">Plan</div>
|
||||
<p class="paper-settings-copy">Repositories are billed per hour.</p>
|
||||
<p class="paper-section-copy">Billed per repository per month, prorated by the day for as long as a repository stays in the conference.</p>
|
||||
|
||||
<div class="paper-plan-grid">
|
||||
<label
|
||||
@@ -202,14 +202,14 @@
|
||||
<div class="form-group">
|
||||
<label class="paper-field-label" for="billing_name">Name</label>
|
||||
<input type="text" class="form-control" name="billing_name" id="billing_name" required
|
||||
ng-class="{'is-invalid': conference.billing_name.$invalid}"
|
||||
ng-class="{'is-invalid': conference.billing_name.$invalid && (conference.billing_name.$touched || conference.$submitted)}"
|
||||
ng-model="options.billing.name" placeholder="First & last name" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="paper-field-label" for="email">Email</label>
|
||||
<input type="email" class="form-control" name="email" id="email" required
|
||||
ng-class="{'is-invalid': conference.email.$invalid}"
|
||||
ng-class="{'is-invalid': conference.email.$invalid && (conference.email.$touched || conference.$submitted)}"
|
||||
ng-model="options.billing.email" placeholder="you@example.org" />
|
||||
</div>
|
||||
|
||||
@@ -218,12 +218,12 @@
|
||||
<input type="text" class="form-control" name="inputAddress" id="inputAddress" required
|
||||
placeholder="1234 Main St"
|
||||
ng-model="options.billing.address"
|
||||
ng-class="{'is-invalid': conference.inputAddress.$invalid}" />
|
||||
ng-class="{'is-invalid': conference.inputAddress.$invalid && (conference.inputAddress.$touched || conference.$submitted)}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="text" class="form-control" name="inputAddress2" id="inputAddress2"
|
||||
ng-model="options.billing.address2"
|
||||
ng-class="{'is-invalid': conference.inputAddress2.$invalid}"
|
||||
ng-class="{'is-invalid': conference.inputAddress2.$invalid && (conference.inputAddress2.$touched || conference.$submitted)}"
|
||||
placeholder="Apartment, studio, or floor" />
|
||||
</div>
|
||||
|
||||
@@ -232,26 +232,26 @@
|
||||
<label class="paper-field-label" for="city">City</label>
|
||||
<input type="text" class="form-control" name="city" id="city"
|
||||
ng-model="options.billing.city" required
|
||||
ng-class="{'is-invalid': conference.city.$invalid}" placeholder="City" />
|
||||
ng-class="{'is-invalid': conference.city.$invalid && (conference.city.$touched || conference.$submitted)}" placeholder="City" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="paper-field-label" for="country">Country</label>
|
||||
<input type="text" class="form-control" name="country" id="country"
|
||||
ng-model="options.billing.country" required
|
||||
ng-class="{'is-invalid': conference.country.$invalid}" placeholder="Country" />
|
||||
ng-class="{'is-invalid': conference.country.$invalid && (conference.country.$touched || conference.$submitted)}" placeholder="Country" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="paper-field-label" for="zip">Zip</label>
|
||||
<input type="text" class="form-control" name="zip" id="zip"
|
||||
ng-model="options.billing.zip" required
|
||||
ng-class="{'is-invalid': conference.zip.$invalid}" placeholder="Zip" />
|
||||
ng-class="{'is-invalid': conference.zip.$invalid && (conference.zip.$touched || conference.$submitted)}" placeholder="Zip" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="paper-field-label" for="vat">VAT number</label>
|
||||
<input type="text" class="form-control" name="vat" id="vat"
|
||||
ng-class="{'is-invalid': conference.vat.$invalid}"
|
||||
ng-class="{'is-invalid': conference.vat.$invalid && (conference.vat.$touched || conference.$submitted)}"
|
||||
ng-model="options.billing.vat" placeholder="VAT Number" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+106
-95
@@ -1,15 +1,15 @@
|
||||
<div class="container paper-page paper-settings">
|
||||
<div class="paper-crumbs">Reference / <span class="here">Settings</span></div>
|
||||
<div class="paper-crumbs"><a href="/dashboard">My work</a> / <span class="here">Settings</span></div>
|
||||
<h1 class="paper-page-title">Your <em>settings</em></h1>
|
||||
<p class="paper-page-lede">Review quotas and set defaults applied to every new anonymization.</p>
|
||||
<p class="paper-page-lede">Defaults applied to every new anonymization, your quotas, and your account.</p>
|
||||
|
||||
<div class="paper-settings-body">
|
||||
<aside class="paper-settings-toc">
|
||||
<div class="paper-settings-toc-head">Contents</div>
|
||||
<aside class="paper-settings-toc" paper-scrollspy>
|
||||
<div class="paper-settings-toc-head">On this page</div>
|
||||
<nav>
|
||||
<a href="#settings-quota">Quota</a>
|
||||
<a href="#settings-terms">Terms</a>
|
||||
<a href="#settings-rendering">Rendering</a>
|
||||
<a href="#settings-terms">Terms to redact</a>
|
||||
<a href="#settings-display">Display</a>
|
||||
<a href="#settings-features">Features</a>
|
||||
<a href="#settings-expiration">Expiration</a>
|
||||
<a href="#settings-account">Account</a>
|
||||
@@ -17,170 +17,181 @@
|
||||
</aside>
|
||||
|
||||
<div class="paper-settings-main">
|
||||
<!-- Quota: same markup as the dashboard, driven by quotaService -->
|
||||
<section id="settings-quota" class="paper-settings-section">
|
||||
<div class="paper-section-eyebrow">Quota</div>
|
||||
<div class="quota-row">
|
||||
<div class="quota-item">
|
||||
<p class="paper-section-copy">What you are using across all your anonymizations. Conferences can lift these limits during a review window.</p>
|
||||
<div class="quota-row" ng-if="quota">
|
||||
<div class="quota-item" ng-repeat="q in [
|
||||
{key: 'repository', label: 'Repositories', kind: 'count'},
|
||||
{key: 'storage', label: 'Storage', kind: 'bytes'},
|
||||
{key: 'file', label: 'Files', kind: 'count'}
|
||||
]">
|
||||
<div class="quota-header">
|
||||
<span class="quota-label">Repositories</span>
|
||||
<span class="quota-value" ng-show="quota">{{quota.repository.used | number}}/{{quota.repository.total}}</span>
|
||||
<span class="quota-label">{{q.label}}</span>
|
||||
<span class="quota-value" ng-if="q.kind === 'count'">
|
||||
{{quota[q.key].used | number}}<span ng-if="!quota[q.key].unlimited"> / {{quota[q.key].total | number}}</span>
|
||||
<span class="quota-unlimited-tag" ng-if="quota[q.key].unlimited">Unlimited</span>
|
||||
</span>
|
||||
<span class="quota-value" ng-if="q.kind === 'bytes'">
|
||||
{{quota[q.key].used | humanFileSize}}<span ng-if="!quota[q.key].unlimited"> / {{quota[q.key].total | humanFileSize}}</span>
|
||||
<span class="quota-unlimited-tag" ng-if="quota[q.key].unlimited">Unlimited</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="progress quota-progress">
|
||||
<div
|
||||
class="progress-bar"
|
||||
ng-class="{'progress-bar-striped progress-bar-animated w-100 bg-dark': !quota, 'bg-success': quota.repository.percent < 25 || quota.repository.total == 0, 'bg-danger': quota.repository.percent > 95 && quota.repository.total > 0, 'bg-warning': quota.repository.percent > 75 && quota.repository.total > 0 }"
|
||||
role="progressbar"
|
||||
style="width: {{quota.repository.percent}}%;"
|
||||
aria-valuenow="{{quota.repository.used}}"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="{{quota.repository.total}}"
|
||||
></div>
|
||||
<div
|
||||
class="quota-track"
|
||||
ng-class="'quota-' + quota[q.key].level"
|
||||
role="progressbar"
|
||||
aria-label="{{q.label}} quota"
|
||||
aria-valuenow="{{quota[q.key].used}}"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="{{quota[q.key].unlimited ? quota[q.key].used : quota[q.key].total}}"
|
||||
aria-valuetext="{{quota[q.key].unlimited ? 'unlimited' : (quota[q.key].percent | number:0) + '% used'}}"
|
||||
>
|
||||
<div class="quota-fill" ng-if="!quota[q.key].unlimited" ng-style="{width: quota[q.key].percent + '%'}"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="quota-item">
|
||||
<div class="quota-header">
|
||||
<span class="quota-label">Storage</span>
|
||||
<span class="quota-value" ng-show="quota">{{quota.storage.used | humanFileSize}}/{{quota.storage.total | humanFileSize}}</span>
|
||||
</div>
|
||||
<div class="progress quota-progress">
|
||||
<div
|
||||
class="progress-bar"
|
||||
ng-class="{'progress-bar-striped progress-bar-animated w-100 bg-dark': !quota, 'bg-success': quota.storage.percent < 25 || quota.storage.total == 0, 'bg-danger': quota.storage.percent > 95 && quota.storage.total > 0, 'bg-warning': quota.storage.percent > 75 && quota.storage.total > 0 }"
|
||||
role="progressbar"
|
||||
style="width: {{quota.storage.percent}}%;"
|
||||
aria-valuenow="{{quota.storage.used}}"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="{{quota.storage.total}}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="quota-item">
|
||||
<div class="quota-header">
|
||||
<span class="quota-label">Files</span>
|
||||
<span class="quota-value" ng-show="quota">{{quota.file.used | number}}/{{quota.file.total || "∞"}}</span>
|
||||
</div>
|
||||
<div class="progress quota-progress">
|
||||
<div
|
||||
class="progress-bar"
|
||||
ng-class="{'progress-bar-striped progress-bar-animated w-100 bg-dark': !quota, 'bg-success': quota.file.percent < 25 || quota.file.total == 0, 'bg-danger': quota.file.percent > 95 && quota.file.total > 0, 'bg-warning': quota.file.percent > 75 && quota.file.total > 0 }"
|
||||
role="progressbar"
|
||||
style="width: {{quota.file.percent}}%;"
|
||||
aria-valuenow="{{quota.file.used}}"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="{{quota.file.total}}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="quota-row" ng-if="!quota" aria-hidden="true">
|
||||
<div class="quota-item" ng-repeat="i in [1, 2, 3]">
|
||||
<div class="quota-header"><span class="skeleton skeleton-line" style="width: 40%"></span><span class="skeleton skeleton-line" style="width: 25%"></span></div>
|
||||
<div class="quota-track"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form class="form needs-validation" name="default" novalidate>
|
||||
<form class="form needs-validation" name="defaultsForm" novalidate ng-submit="saveDefault($event)">
|
||||
<section id="settings-terms" class="paper-settings-section">
|
||||
<div class="paper-section-eyebrow">Default anonymization options</div>
|
||||
<div class="paper-section-eyebrow">Terms to redact</div>
|
||||
<p class="paper-section-copy">Pre-filled in every new anonymization. You can still edit the list per anonymization.</p>
|
||||
<div class="form-group">
|
||||
<label class="paper-field-label" for="terms">Terms to anonymize</label>
|
||||
<label class="paper-field-label" for="terms">Default terms</label>
|
||||
<textarea
|
||||
class="form-control"
|
||||
id="terms"
|
||||
name="terms"
|
||||
rows="4"
|
||||
placeholder="Jane Smith MIT CSAIL jane.smith@example.org"
|
||||
ng-model="terms"
|
||||
ng-model-options="{ debounce: 250 }"
|
||||
></textarea>
|
||||
<small id="termsHelp" class="form-text text-muted">One term per line. Each term will be replaced by XXX.</small>
|
||||
<div class="invalid-feedback" ng-show="anonymize.terms.$error.format">
|
||||
<small id="termsHelp" class="form-text text-muted">
|
||||
One term per line (regex allowed). Each match is replaced by
|
||||
<code>{{site_options.ANONYMIZATION_MASK || 'XXX'}}-[N]</code>, or use <code>term=>replacement</code> to pick your own.
|
||||
</small>
|
||||
<div class="invalid-feedback" ng-show="defaultsForm.terms.$error.format">
|
||||
Terms are in an invalid format
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="settings-rendering" class="paper-settings-section">
|
||||
<div class="paper-section-eyebrow">Rendering</div>
|
||||
<div class="form-group">
|
||||
<section id="settings-display" class="paper-settings-section">
|
||||
<div class="paper-section-eyebrow">Display</div>
|
||||
<p class="paper-section-copy">What reviewers can see inside an anonymized repository.</p>
|
||||
<div class="form-group paper-check-list">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="link" name="link" ng-model="options.link" />
|
||||
<label class="form-check-label" for="link">Keep links</label>
|
||||
<small class="form-text text-muted">Keep or remove all the links.</small>
|
||||
<small class="form-text text-muted">Unchecked, every link in text files is removed.</small>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="image" name="image" ng-model="options.image" />
|
||||
<label class="form-check-label" for="image">Display images</label>
|
||||
<small class="form-text text-muted">Images are not anonymized.</small>
|
||||
<small class="form-text text-muted">Images are shown as is. They are not anonymized.</small>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="pdf" name="pdf" ng-model="options.pdf" />
|
||||
<label class="form-check-label" for="pdf">Display PDFs</label>
|
||||
<small class="form-text text-muted">PDFs are not anonymized.</small>
|
||||
<small class="form-text text-muted">PDFs are shown as is. They are not anonymized.</small>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="notebook" name="notebook" ng-model="options.notebook" />
|
||||
<label class="form-check-label" for="notebook">Display Notebooks</label>
|
||||
<label class="form-check-label" for="notebook">Display notebooks</label>
|
||||
<small class="form-text text-muted">Render Jupyter notebooks instead of raw JSON.</small>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="settings-features" class="paper-settings-section">
|
||||
<div class="paper-section-eyebrow">Features</div>
|
||||
<div class="form-group">
|
||||
<div class="form-group paper-check-list">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="page" name="page" ng-model="options.page" />
|
||||
<label class="form-check-label" for="page">Github page</label>
|
||||
<small class="form-text text-muted">Enable anonymized GitHub pages. Currently only supported for pages defined in the same branch.</small>
|
||||
<label class="form-check-label" for="page">GitHub Pages</label>
|
||||
<small class="form-text text-muted">Serve an anonymized copy of the repository's GitHub Pages site. Only pages built from the anonymized branch are supported.</small>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="loc" name="loc" ng-model="options.loc" />
|
||||
<label class="form-check-label" for="loc">Line of code</label>
|
||||
<small class="form-text text-muted">Display the number of lines of code in the repository.</small>
|
||||
<label class="form-check-label" for="loc">Lines of code</label>
|
||||
<small class="form-text text-muted">Show the line count of the repository in the explorer.</small>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="update" name="update" ng-model="options.update" />
|
||||
<label class="form-check-label" for="update">Auto update</label>
|
||||
<small class="form-text text-muted">Automatically update the anonymized repository with the latest commit (once per hour maximum).</small>
|
||||
<label class="form-check-label" for="update">Auto-update from GitHub</label>
|
||||
<small class="form-text text-muted">Follow the branch and pull the latest commit automatically, at most once an hour.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="paper-field-label" for="mode">Proxy mode</label>
|
||||
<label class="paper-field-label" for="mode">Fetch mode</label>
|
||||
<select class="form-control" id="mode" name="mode" ng-model="options.mode">
|
||||
<option value="GitHubStream" selected>Stream</option>
|
||||
<option value="GitHubDownload">Download</option>
|
||||
<option value="GitHubStream" selected>Stream from GitHub on demand</option>
|
||||
<option value="GitHubDownload">Download to Anonymous GitHub</option>
|
||||
</select>
|
||||
<small class="form-text text-muted">Stream mode requests content on the fly. This is the only option for repositories bigger than {{site_options.MAX_REPO_SIZE * 1024| humanFileSize}}. Download fetches to the server — faster and with more features.</small>
|
||||
<small class="form-text text-muted">
|
||||
Download is faster for reviewers and enables every feature. Streaming is the only option above
|
||||
{{site_options.MAX_REPO_SIZE * 1024 | humanFileSize}}.
|
||||
</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="settings-expiration" class="paper-settings-section">
|
||||
<div class="paper-section-eyebrow">Expiration</div>
|
||||
<div class="form-group">
|
||||
<label class="paper-field-label" for="expiration">Expiration strategy</label>
|
||||
<label class="paper-field-label" for="expiration">When an anonymization expires</label>
|
||||
<select class="form-control" id="expiration" name="expiration" ng-model="options.expirationMode">
|
||||
<option value="redirect">Redirect to GitHub</option>
|
||||
<option value="remove" selected>Remove anonymized repository</option>
|
||||
<option value="redirect">Redirect visitors to the GitHub repository</option>
|
||||
<option value="remove" selected>Remove the anonymized repository</option>
|
||||
</select>
|
||||
<small class="form-text text-muted">Define the expiration strategy for new repositories.</small>
|
||||
<small class="form-text text-muted">
|
||||
The expiration date itself is chosen per anonymization. Redirecting reveals the source repository, so pick it only once the review is over.
|
||||
</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="paper-settings-footer">
|
||||
<div class="alert alert-danger" role="alert" ng-if="error" ng-bind="error"></div>
|
||||
<div class="alert alert-success" role="alert" ng-if="message" ng-bind="message"></div>
|
||||
<button id="save" type="submit" class="btn btn-ink" ng-click="saveDefault($event)">
|
||||
Save defaults
|
||||
</button>
|
||||
<div class="paper-inline-alert paper-inline-alert-error" role="alert" ng-if="error"><i class="fas fa-exclamation-circle" aria-hidden="true"></i> <span ng-bind="error"></span></div>
|
||||
<div class="paper-settings-actions">
|
||||
<button id="save" type="submit" class="btn btn-ink" ng-disabled="saving">
|
||||
<i class="fas fa-check mr-1" aria-hidden="true" ng-if="message"></i>{{ saving ? 'Saving…' : (message ? 'Saved' : 'Save defaults') }}
|
||||
</button>
|
||||
<span class="paper-settings-hint">Applies to new anonymizations only. Existing ones keep their settings.</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section id="settings-account" class="paper-settings-section">
|
||||
<div class="paper-section-eyebrow">Account</div>
|
||||
<p class="form-text text-muted">
|
||||
Deleting your account removes all your anonymized repositories,
|
||||
gists, and pull requests, revokes the application's access to your
|
||||
GitHub account, and erases your personal data (username, email,
|
||||
access tokens) from our database. This cannot be undone.
|
||||
</p>
|
||||
<div class="alert alert-danger" role="alert" ng-if="deleteError" ng-bind="deleteError"></div>
|
||||
<button type="button" class="btn btn-danger" ng-click="deleteAccount()" ng-disabled="deletingAccount">
|
||||
{{ deletingAccount ? 'Deleting…' : 'Delete my account' }}
|
||||
</button>
|
||||
<div class="paper-account-card">
|
||||
<img ng-src="{{user.photo}}" ng-if="user.photo" width="40" height="40" class="rounded-circle" alt="" />
|
||||
<div class="paper-account-identity">
|
||||
<div class="paper-account-name">@{{user.username}}</div>
|
||||
<div class="paper-account-meta">Signed in with GitHub. Anonymous GitHub only reads your repositories.</div>
|
||||
</div>
|
||||
<a class="btn btn-outline-ink" href="/api/user/logout" target="_self">Sign out</a>
|
||||
</div>
|
||||
|
||||
<div class="paper-danger-zone">
|
||||
<div class="paper-danger-head">Delete account</div>
|
||||
<p class="paper-danger-copy">
|
||||
Removes all your anonymized repositories, gists, and pull requests,
|
||||
revokes the application's access to your GitHub account, and erases
|
||||
your personal data (username, email, access tokens). This cannot be undone.
|
||||
</p>
|
||||
<div class="paper-inline-alert paper-inline-alert-error" role="alert" ng-if="deleteError"><i class="fas fa-exclamation-circle" aria-hidden="true"></i> <span ng-bind="deleteError"></span></div>
|
||||
<button type="button" class="btn btn-outline-danger" ng-click="deleteAccount()" ng-disabled="deletingAccount">
|
||||
<i class="fas fa-trash-alt mr-1" aria-hidden="true"></i>{{ deletingAccount ? 'Deleting…' : 'Delete my account' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="pr-page" ng-init="tabState = { active: (details && details.diff) ? 'diff' : 'comments' }">
|
||||
<div class="pr-page">
|
||||
<div class="container paper-page pr-page-inner">
|
||||
<div class="paper-crumbs">
|
||||
<a href="/dashboard">Reviewer</a> /
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
<div class="paper-error-actions">
|
||||
<a class="btn btn-ink" ng-href="/anonymize/{{repoId}}"><i class="far fa-edit mr-1"></i> Edit anonymization</a>
|
||||
<a class="btn" href="/faq"><i class="far fa-question-circle mr-1"></i> Read the FAQ</a>
|
||||
<a class="btn" href="https://github.com/tdurieux/anonymous_github/issues/new" target="_blank" rel="noopener"><i class="fab fa-github mr-1"></i> Report an issue</a>
|
||||
<a class="btn" href="https://github.com/tdurieux/anonymous_github/issues/new?template=issue_report.yml" target="_blank" rel="noopener"><i class="fas fa-bug mr-1" aria-hidden="true"></i> Report a bug in Anonymous GitHub</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -95,9 +95,9 @@
|
||||
<p>Collaborate by implementing new features and fixing bugs. Help with new file formats or deployment is welcome.</p>
|
||||
<span class="paper-support-cta">Open on GitHub <i class="fas fa-arrow-right"></i></span>
|
||||
</a>
|
||||
<a class="paper-support-card" href="https://github.com/tdurieux/anonymous_github/issues/new" target="_blank" rel="noopener">
|
||||
<a class="paper-support-card" href="https://github.com/tdurieux/anonymous_github/issues/new?template=issue_report.yml" target="_blank" rel="noopener">
|
||||
<div class="paper-support-eyebrow"><i class="far fa-comment-dots"></i> Feedback</div>
|
||||
<p>Tell us about bugs and missing features. Your feedback shapes the project's priorities.</p>
|
||||
<p>Tell us about bugs and missing features in Anonymous GitHub itself. Questions about an anonymized repository go to its authors.</p>
|
||||
<span class="paper-support-cta">File an issue <i class="fas fa-arrow-right"></i></span>
|
||||
</a>
|
||||
<a class="paper-support-card" href="https://github.com/sponsors/tdurieux/" target="_blank" rel="noopener">
|
||||
|
||||
+422
-129
@@ -93,13 +93,13 @@ angular
|
||||
templateUrl: "/partials/pullRequest.htm",
|
||||
controller: "pullRequestController",
|
||||
title: "Anonymous pull request – Anonymous GitHub",
|
||||
reloadOnUrl: false,
|
||||
reloadOnSearch: false,
|
||||
})
|
||||
.when("/gist/:gistId/:path*?", {
|
||||
templateUrl: "/partials/gist.htm",
|
||||
controller: "gistController",
|
||||
title: "Anonymous gist – Anonymous GitHub",
|
||||
reloadOnUrl: false,
|
||||
reloadOnSearch: false,
|
||||
})
|
||||
.when("/r/:repoId/:path*?", {
|
||||
templateUrl: "/partials/explorer.htm",
|
||||
@@ -212,11 +212,19 @@ angular
|
||||
seconds = Math.round((Date.now() - new Date(seconds)) / 1000);
|
||||
var suffix = seconds < 0 ? "from now" : "ago";
|
||||
|
||||
// more than 2 days ago display Date
|
||||
// more than 2 days ago display Date. Spell the month out so the date
|
||||
// is unambiguous regardless of the reader's locale (9/6 vs 6/9).
|
||||
if (Math.abs(seconds) > 2 * 60 * 60 * 24) {
|
||||
const now = new Date();
|
||||
now.setSeconds(now.getSeconds() - seconds);
|
||||
return "on " + now.toLocaleDateString();
|
||||
return (
|
||||
"on " +
|
||||
now.toLocaleDateString(undefined, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
seconds = Math.abs(seconds);
|
||||
@@ -255,7 +263,44 @@ angular
|
||||
return capitalized.join(" ");
|
||||
};
|
||||
})
|
||||
// Human-readable labels for the raw status values stored on repositories,
|
||||
// pull requests and gists. Anything unknown falls back to Title Case.
|
||||
.filter("statusLabel", function () {
|
||||
var labels = {
|
||||
ready: "Ready",
|
||||
error: "Error",
|
||||
expired: "Expired",
|
||||
expiring: "Expiring",
|
||||
removed: "Removed",
|
||||
removing: "Removing",
|
||||
queue: "Queued",
|
||||
download: "Downloading",
|
||||
downloaded: "Downloaded",
|
||||
preparing: "Preparing",
|
||||
anonymizing: "Anonymizing",
|
||||
};
|
||||
return function (status) {
|
||||
if (!status) return "";
|
||||
if (labels[status]) return labels[status];
|
||||
var s = String(status).replace(/[_-]+/g, " ").toLowerCase();
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
};
|
||||
})
|
||||
.filter("statusMsg", function () {
|
||||
// Known machine codes → sentences. Unknown snake_case codes are
|
||||
// converted to a sentence instead of leaking `branch_not_found`.
|
||||
var codes = {
|
||||
branch_not_found: "Branch not found on GitHub",
|
||||
repo_not_found: "Repository not found on GitHub",
|
||||
repository_not_found: "Repository not found on GitHub",
|
||||
repo_not_accessible: "Repository is not accessible with your token",
|
||||
pr_not_found: "Pull request not found on GitHub",
|
||||
gist_not_found: "Gist not found on GitHub",
|
||||
commit_not_found: "Commit not found on GitHub",
|
||||
repo_too_big: "Repository exceeds the size limit",
|
||||
quota_exceeded: "Storage quota exceeded",
|
||||
incomplete_record: "Incomplete record: missing identifier",
|
||||
};
|
||||
return function (msg) {
|
||||
if (!msg) return msg;
|
||||
var m = msg.match(/^rate_limited:(\d+)$/);
|
||||
@@ -266,9 +311,94 @@ angular
|
||||
var sec = remaining % 60;
|
||||
return "Rate limited — retrying in " + (min > 0 ? min + "m " + sec + "s" : sec + "s");
|
||||
}
|
||||
if (codes[msg]) return codes[msg];
|
||||
if (/^[a-z0-9]+(_[a-z0-9]+)+$/.test(msg)) {
|
||||
var s = msg.replace(/_/g, " ");
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
return msg;
|
||||
};
|
||||
})
|
||||
// Shared quota loader. A total of 0 means unlimited: no percentage, no fill.
|
||||
// `level` drives the bar colour: ink until 80%, amber to 95%, red above.
|
||||
.factory("quotaService", [
|
||||
"$http",
|
||||
function ($http) {
|
||||
function decorate(q) {
|
||||
q = q || { used: 0, total: 0 };
|
||||
q.unlimited = !q.total;
|
||||
q.percent = q.unlimited ? 0 : Math.min(100, (q.used * 100) / q.total);
|
||||
q.level = q.unlimited
|
||||
? "unlimited"
|
||||
: q.percent >= 95
|
||||
? "danger"
|
||||
: q.percent >= 80
|
||||
? "warn"
|
||||
: "ok";
|
||||
return q;
|
||||
}
|
||||
return {
|
||||
decorate: decorate,
|
||||
load: function () {
|
||||
return $http.get("/api/user/quota").then((res) => {
|
||||
const quota = res.data || {};
|
||||
quota.storage = decorate(quota.storage);
|
||||
quota.file = decorate(quota.file);
|
||||
quota.repository = decorate(quota.repository);
|
||||
return quota;
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
])
|
||||
// Highlights the link of the section currently in view inside a
|
||||
// `.paper-settings-toc`-style navigation. Falls back to no-op without
|
||||
// IntersectionObserver.
|
||||
.directive("paperScrollspy", [
|
||||
"$window",
|
||||
function ($window) {
|
||||
return {
|
||||
restrict: "A",
|
||||
link: function (scope, element) {
|
||||
if (!$window.IntersectionObserver) return;
|
||||
const links = Array.from(element[0].querySelectorAll('a[href^="#"]'));
|
||||
const byId = {};
|
||||
links.forEach((a) => {
|
||||
byId[a.getAttribute("href").slice(1)] = a;
|
||||
});
|
||||
const visible = new Set();
|
||||
function update() {
|
||||
let current = null;
|
||||
for (const id of Object.keys(byId)) {
|
||||
if (visible.has(id)) {
|
||||
current = id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
links.forEach((a) => a.classList.toggle("active", a === byId[current]));
|
||||
}
|
||||
const observer = new $window.IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((e) => {
|
||||
if (e.isIntersecting) visible.add(e.target.id);
|
||||
else visible.delete(e.target.id);
|
||||
});
|
||||
update();
|
||||
},
|
||||
{ rootMargin: "-20% 0px -60% 0px", threshold: 0 }
|
||||
);
|
||||
// Sections may render after the toc; observe on the next tick.
|
||||
$window.setTimeout(() => {
|
||||
Object.keys(byId).forEach((id) => {
|
||||
const target = $window.document.getElementById(id);
|
||||
if (target) observer.observe(target);
|
||||
});
|
||||
}, 0);
|
||||
scope.$on("$destroy", () => observer.disconnect());
|
||||
},
|
||||
};
|
||||
},
|
||||
])
|
||||
.filter("diff", [
|
||||
"$sce",
|
||||
function ($sce) {
|
||||
@@ -526,7 +656,7 @@ angular
|
||||
function ($element, $scope, $routeParams, $compile) {
|
||||
$scope.repoId = document.location.pathname.split("/")[2];
|
||||
|
||||
$scope.opens = {};
|
||||
$scope.opens = Object.create(null);
|
||||
|
||||
if ($routeParams.path) {
|
||||
let accumulatedPath = "";
|
||||
@@ -538,7 +668,8 @@ angular
|
||||
|
||||
const toArray = function (arr) {
|
||||
const output = [];
|
||||
const keys = { "": { child: output } };
|
||||
const keys = Object.create(null);
|
||||
keys[""] = { child: output };
|
||||
function ensurePath(path) {
|
||||
if (keys[path]) return;
|
||||
const segments = path.split("/");
|
||||
@@ -622,23 +753,6 @@ angular
|
||||
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
// 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function buildSearchFilter() {
|
||||
const results = $scope.searchResults;
|
||||
if (!results || !results.length) return null;
|
||||
@@ -721,12 +835,14 @@ angular
|
||||
cssClasses.push("truncated");
|
||||
}
|
||||
|
||||
const ngPath = escapeNgString(path);
|
||||
const nodeIndex = $scope.treeNodes.length;
|
||||
$scope.treeNodes.push({ name, path, href: `/r/${encodeURIComponent($scope.repoId)}${encodePathForUrl(path)}` });
|
||||
const node = `treeNodes[${nodeIndex}]`;
|
||||
output += `<li class="${cssClasses.join(
|
||||
" "
|
||||
)}" ng-class="{active: isActive('${ngPath}'), open: ${filterSet ? "opens['" + ngPath + "'] !== false" : "opens['" + ngPath + "']"}}" title="${escapeHtml(sizeTitle)}">`;
|
||||
)}" ng-class="{active: isActive(${node}.path), open: opens[${node}.path]${filterSet ? ' !== false' : ''}}" title="${escapeHtml(sizeTitle)}">`;
|
||||
if (dir) {
|
||||
output += `<a ng-click="openFolder('${ngPath}', $event)"><span class="tree-toggle"></span><span class="tree-icon-folder"></span><span class="tree-name">${escapeHtml(name)}</span>`;
|
||||
output += `<a ng-click="openFolder(${node}.path, $event)"><span class="tree-toggle"></span><span class="tree-icon-folder"></span><span class="tree-name" ng-bind="${node}.name"></span>`;
|
||||
if (truncated) {
|
||||
output += `<span class="truncated-warning" title="{{ 'WARNINGS.folder_truncated' | translate }}"><i class="fas fa-exclamation-triangle"></i></span>`;
|
||||
}
|
||||
@@ -736,9 +852,7 @@ angular
|
||||
output += `</a>`;
|
||||
} else {
|
||||
const needsSpacer = parentPath !== "";
|
||||
output += `<a href='/r/${$scope.repoId}${encodePathForUrl(
|
||||
path
|
||||
)}'>${needsSpacer ? '<span class="tree-spacer"></span>' : ''}<span class="tree-icon-file"></span><span class="tree-name">${escapeHtml(name)}</span></a>`;
|
||||
output += `<a ng-href='{{${node}.href}}'>${needsSpacer ? '<span class="tree-spacer"></span>' : ''}<span class="tree-icon-file"></span><span class="tree-name" ng-bind="${node}.name"></span></a>`;
|
||||
}
|
||||
if (isOpen && collapsed.child) {
|
||||
const children = collapsed.child;
|
||||
@@ -757,7 +871,11 @@ angular
|
||||
return output + "</ul>";
|
||||
}
|
||||
|
||||
let renderScope = null;
|
||||
function display() {
|
||||
if (renderScope) renderScope.$destroy();
|
||||
renderScope = $scope.$new();
|
||||
$scope.treeNodes = [];
|
||||
$element.html("");
|
||||
const filterSet = $scope.searchQuery ? buildSearchFilter() : null;
|
||||
let output;
|
||||
@@ -766,7 +884,7 @@ angular
|
||||
} else {
|
||||
output = generate(toArray($scope.file).sort(sortFiles), "", filterSet);
|
||||
}
|
||||
$compile(output)($scope, (clone) => {
|
||||
$compile(output)(renderScope, (clone) => {
|
||||
$element.append(clone);
|
||||
restoreFocus();
|
||||
});
|
||||
@@ -1150,7 +1268,10 @@ angular
|
||||
.controller("profileController", [
|
||||
"$scope",
|
||||
"$http",
|
||||
function ($scope, $http) {
|
||||
"$translate",
|
||||
"$timeout",
|
||||
"quotaService",
|
||||
function ($scope, $http, $translate, $timeout, quotaService) {
|
||||
$scope.terms = "";
|
||||
$scope.options = {
|
||||
expirationMode: "remove",
|
||||
@@ -1161,10 +1282,17 @@ angular
|
||||
loc: true,
|
||||
link: true,
|
||||
};
|
||||
$scope.saving = false;
|
||||
$scope.message = null;
|
||||
$scope.error = null;
|
||||
|
||||
quotaService.load().then((quota) => {
|
||||
$scope.quota = quota;
|
||||
}, console.error);
|
||||
|
||||
function getDefault() {
|
||||
$http.get("/api/user/default").then((res) => {
|
||||
const data = res.data;
|
||||
const data = res.data || {};
|
||||
if (data.terms) {
|
||||
$scope.terms = data.terms.join("\n");
|
||||
}
|
||||
@@ -1173,20 +1301,36 @@ angular
|
||||
}
|
||||
getDefault();
|
||||
|
||||
$scope.saveDefault = () => {
|
||||
let savedTimer = null;
|
||||
$scope.saveDefault = ($event) => {
|
||||
if ($event && $event.preventDefault) $event.preventDefault();
|
||||
const params = {
|
||||
terms: $scope.terms.trim().split("\n"),
|
||||
terms: $scope.terms
|
||||
.split("\n")
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0),
|
||||
options: $scope.options,
|
||||
};
|
||||
$scope.saving = true;
|
||||
$scope.error = null;
|
||||
$http.post("/api/user/default", params).then(
|
||||
() => {
|
||||
getDefault();
|
||||
$scope.saving = false;
|
||||
$scope.message = "Saved";
|
||||
if (savedTimer) $timeout.cancel(savedTimer);
|
||||
savedTimer = $timeout(() => {
|
||||
$scope.message = null;
|
||||
}, 2500);
|
||||
},
|
||||
(error) => {
|
||||
$translate("ERRORS." + error.data.error).then((translation) => {
|
||||
$scope.saving = false;
|
||||
const code = error && error.data && error.data.error;
|
||||
$translate("ERRORS." + code).then((translation) => {
|
||||
$scope.error = translation;
|
||||
}, console.error);
|
||||
}, () => {
|
||||
$scope.error = "Unable to save your defaults. Please try again.";
|
||||
});
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -1338,7 +1482,10 @@ angular
|
||||
"$scope",
|
||||
"$http",
|
||||
"$location",
|
||||
function ($scope, $http, $location) {
|
||||
"$q",
|
||||
"$window",
|
||||
"quotaService",
|
||||
function ($scope, $http, $location, $q, $window, quotaService) {
|
||||
$scope.$on("$routeChangeStart", function () {
|
||||
$('[data-toggle="tooltip"]').tooltip("dispose");
|
||||
});
|
||||
@@ -1357,11 +1504,35 @@ angular
|
||||
|
||||
$scope.items = [];
|
||||
$scope.search = "";
|
||||
$scope.loading = true;
|
||||
|
||||
// Status buckets used by the Status filter. Raw statuses are mapped
|
||||
// onto these keys so in-progress and error items can be filtered too.
|
||||
$scope.statusKeyLabels = {
|
||||
ready: "Ready",
|
||||
progress: "In progress",
|
||||
error: "Error",
|
||||
expired: "Expired",
|
||||
removed: "Removed",
|
||||
};
|
||||
const inProgress = ["queue", "download", "downloaded", "preparing", "anonymizing"];
|
||||
function statusKey(status) {
|
||||
if (status === "ready" || status === "error") return status;
|
||||
if (status === "expired" || status === "expiring") return "expired";
|
||||
if (status === "removed" || status === "removing") return "removed";
|
||||
if (inProgress.indexOf(status) > -1) return "progress";
|
||||
return "progress";
|
||||
}
|
||||
// An in-progress item whose last activity is older than this is
|
||||
// probably stuck; the row says so instead of showing "Downloading".
|
||||
const STALE_AFTER_MS = 2 * 60 * 60 * 1000;
|
||||
|
||||
const dashboardPrefsKey = "dashboard.filterPrefs";
|
||||
const dashboardPrefDefaults = {
|
||||
typeFilter: "all",
|
||||
filters: { status: { ready: true, expired: true, removed: false } },
|
||||
filters: {
|
||||
status: { ready: true, progress: true, error: true, expired: true, removed: false },
|
||||
},
|
||||
orderBy: "-anonymizeDate",
|
||||
};
|
||||
const savedDashboardPrefs = loadFilterPrefs(dashboardPrefsKey) || {};
|
||||
@@ -1375,6 +1546,37 @@ angular
|
||||
};
|
||||
$scope.orderBy = savedDashboardPrefs.orderBy || dashboardPrefDefaults.orderBy;
|
||||
|
||||
// ---- Sorting -------------------------------------------------------
|
||||
// `orderBy` is kept as the Angular orderBy expression ("-field" for
|
||||
// descending) so saved preferences stay compatible.
|
||||
const sortFields = {
|
||||
_name: { label: "Name", defaultDesc: false },
|
||||
anonymizeDate: { label: "Anonymize date", defaultDesc: true },
|
||||
status: { label: "Status", defaultDesc: false },
|
||||
lastView: { label: "Last view", defaultDesc: true },
|
||||
pageView: { label: "Views", defaultDesc: true },
|
||||
"options.expirationDate": { label: "Expiration", defaultDesc: false },
|
||||
};
|
||||
$scope.sortFields = sortFields;
|
||||
$scope.sortField = () => $scope.orderBy.replace(/^-/, "");
|
||||
$scope.sortDesc = () => $scope.orderBy.charAt(0) === "-";
|
||||
$scope.sortLabel = () => {
|
||||
const f = sortFields[$scope.sortField()];
|
||||
return f ? f.label : "Custom";
|
||||
};
|
||||
$scope.isSortedBy = (field) => $scope.sortField() === field;
|
||||
$scope.setSort = (field, desc) => {
|
||||
if (typeof desc !== "boolean") {
|
||||
desc = $scope.isSortedBy(field)
|
||||
? !$scope.sortDesc()
|
||||
: !!(sortFields[field] && sortFields[field].defaultDesc);
|
||||
}
|
||||
$scope.orderBy = (desc ? "-" : "") + field;
|
||||
};
|
||||
$scope.toggleSortDirection = () => {
|
||||
$scope.setSort($scope.sortField(), !$scope.sortDesc());
|
||||
};
|
||||
|
||||
$scope.$watchGroup(
|
||||
["typeFilter", "orderBy"],
|
||||
() => {
|
||||
@@ -1397,94 +1599,148 @@ angular
|
||||
true
|
||||
);
|
||||
|
||||
function getQuota() {
|
||||
$http.get("/api/user/quota").then((res) => {
|
||||
$scope.quota = res.data;
|
||||
$scope.quota.storage.percent = $scope.quota.storage.total
|
||||
? ($scope.quota.storage.used * 100) / $scope.quota.storage.total
|
||||
: 100;
|
||||
$scope.quota.file.percent = $scope.quota.file.total
|
||||
? ($scope.quota.file.used * 100) / $scope.quota.file.total
|
||||
: 100;
|
||||
$scope.quota.repository.percent = $scope.quota.repository.total
|
||||
? ($scope.quota.repository.used * 100) /
|
||||
$scope.quota.repository.total
|
||||
: 100;
|
||||
}, console.error);
|
||||
}
|
||||
getQuota();
|
||||
// ---- Quota (shared with the settings page via quotaService) --------
|
||||
quotaService.load().then((quota) => {
|
||||
$scope.quota = quota;
|
||||
}, console.error);
|
||||
|
||||
let loadedRepos = null;
|
||||
let loadedPRs = null;
|
||||
let loadedGists = null;
|
||||
|
||||
function mergeItems() {
|
||||
$scope.items = (loadedRepos || [])
|
||||
.concat(loadedPRs || [])
|
||||
.concat(loadedGists || []);
|
||||
// ---- Items ---------------------------------------------------------
|
||||
// Fields shared by repositories, pull requests and gists. Records that
|
||||
// lost their identifier (legacy data) are flagged as broken instead of
|
||||
// rendering an empty link to /pr/undefined/.
|
||||
function decorateItem(item, id, name, source, editUrl, viewUrl) {
|
||||
if (!item.pageView) item.pageView = 0;
|
||||
if (!item.lastView) item.lastView = "";
|
||||
item.options = item.options || {};
|
||||
item.options.terms = (item.options.terms || []).filter((f) => f);
|
||||
item._id = id || "";
|
||||
item._source = source;
|
||||
item._broken = !id;
|
||||
item._name = id || source || "(unnamed)";
|
||||
item._editUrl = id ? editUrl : null;
|
||||
item._viewUrl = id ? viewUrl : null;
|
||||
if (item._broken) {
|
||||
item.status = "error";
|
||||
item.statusMessage = "incomplete_record";
|
||||
}
|
||||
item._statusKey = statusKey(item.status);
|
||||
const last = item.anonymizeDate || item.lastView;
|
||||
item._stale =
|
||||
item._statusKey === "progress" &&
|
||||
!!last &&
|
||||
Date.now() - new Date(last).getTime() > STALE_AFTER_MS;
|
||||
// What the Expires column should show.
|
||||
if (item.status === "expired" || item.status === "expiring") {
|
||||
item._expiry = { kind: "expired", date: item.options.expirationDate };
|
||||
} else if (item.status !== "ready") {
|
||||
item._expiry = { kind: "none" };
|
||||
} else if (item.options.expirationMode === "never" || !item.options.expirationDate) {
|
||||
item._expiry = { kind: "never" };
|
||||
} else {
|
||||
item._expiry = { kind: "date", date: item.options.expirationDate };
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
function safeGet(url) {
|
||||
return $http.get(url).then(
|
||||
(res) => res.data || [],
|
||||
(err) => {
|
||||
console.error(err);
|
||||
return [];
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// All three lists load in parallel and are merged once, so the table
|
||||
// does not re-sort three times while it fills in.
|
||||
function loadAll() {
|
||||
loadedRepos = null;
|
||||
loadedPRs = null;
|
||||
loadedGists = null;
|
||||
$http.get("/api/user/anonymized_repositories").then(
|
||||
(res) => {
|
||||
loadedRepos = res.data.map((repo) => {
|
||||
if (!repo.pageView) repo.pageView = 0;
|
||||
if (!repo.lastView) repo.lastView = "";
|
||||
repo.options.terms = repo.options.terms.filter((f) => f);
|
||||
$scope.loading = true;
|
||||
return $q
|
||||
.all([
|
||||
safeGet("/api/user/anonymized_repositories"),
|
||||
safeGet("/api/user/anonymized_pull_requests"),
|
||||
safeGet("/api/user/anonymized_gists"),
|
||||
])
|
||||
.then((results) => {
|
||||
const repos = results[0];
|
||||
const prs = results[1];
|
||||
const gists = results[2];
|
||||
const items = [];
|
||||
repos.forEach((repo) => {
|
||||
repo._type = "repo";
|
||||
repo._id = repo.repoId;
|
||||
repo._name = repo.repoId;
|
||||
repo._source = repo.source.fullName;
|
||||
repo._editUrl = "/anonymize/" + repo.repoId;
|
||||
repo._viewUrl = "/r/" + repo.repoId + "/";
|
||||
return repo;
|
||||
const src = repo.source || {};
|
||||
items.push(
|
||||
decorateItem(
|
||||
repo,
|
||||
repo.repoId,
|
||||
repo.repoId,
|
||||
src.fullName,
|
||||
"/anonymize/" + repo.repoId,
|
||||
"/r/" + repo.repoId + "/"
|
||||
)
|
||||
);
|
||||
});
|
||||
mergeItems();
|
||||
},
|
||||
(err) => { console.error(err); }
|
||||
);
|
||||
$http.get("/api/user/anonymized_pull_requests").then(
|
||||
(res2) => {
|
||||
loadedPRs = res2.data.map((pr) => {
|
||||
if (!pr.pageView) pr.pageView = 0;
|
||||
if (!pr.lastView) pr.lastView = "";
|
||||
pr.options.terms = pr.options.terms.filter((f) => f);
|
||||
prs.forEach((pr) => {
|
||||
pr._type = "pr";
|
||||
pr._id = pr.pullRequestId;
|
||||
pr._name = pr.pullRequestId;
|
||||
pr._source = pr.source.repositoryFullName + "#" + pr.source.pullRequestId;
|
||||
pr._editUrl = "/pull-request-anonymize/" + pr.pullRequestId;
|
||||
pr._viewUrl = "/pr/" + pr.pullRequestId + "/";
|
||||
return pr;
|
||||
const src = pr.source || {};
|
||||
items.push(
|
||||
decorateItem(
|
||||
pr,
|
||||
pr.pullRequestId,
|
||||
pr.pullRequestId,
|
||||
src.repositoryFullName + "#" + src.pullRequestId,
|
||||
"/pull-request-anonymize/" + pr.pullRequestId,
|
||||
"/pr/" + pr.pullRequestId + "/"
|
||||
)
|
||||
);
|
||||
});
|
||||
mergeItems();
|
||||
},
|
||||
(err) => { console.error(err); }
|
||||
);
|
||||
$http.get("/api/user/anonymized_gists").then(
|
||||
(res3) => {
|
||||
loadedGists = res3.data.map((g) => {
|
||||
if (!g.pageView) g.pageView = 0;
|
||||
if (!g.lastView) g.lastView = "";
|
||||
g.options.terms = (g.options.terms || []).filter((f) => f);
|
||||
gists.forEach((g) => {
|
||||
g._type = "gist";
|
||||
g._id = g.gistId;
|
||||
g._name = g.gistId;
|
||||
g._source = g.source.gistId;
|
||||
g._editUrl = "/gist-anonymize/" + g.gistId;
|
||||
g._viewUrl = "/gist/" + g.gistId + "/";
|
||||
return g;
|
||||
const src = g.source || {};
|
||||
items.push(
|
||||
decorateItem(
|
||||
g,
|
||||
g.gistId,
|
||||
g.gistId,
|
||||
src.gistId,
|
||||
"/gist-anonymize/" + g.gistId,
|
||||
"/gist/" + g.gistId + "/"
|
||||
)
|
||||
);
|
||||
});
|
||||
mergeItems();
|
||||
},
|
||||
(err) => { console.error(err); }
|
||||
);
|
||||
$scope.items = items;
|
||||
$scope.loading = false;
|
||||
});
|
||||
}
|
||||
loadAll();
|
||||
|
||||
// Whole row opens the anonymized view; clicks on links, buttons and the
|
||||
// actions menu keep their own behaviour.
|
||||
$scope.openItem = (item, $event) => {
|
||||
if (!item._viewUrl) return;
|
||||
const target = $event && $event.target;
|
||||
if (target && target.closest && target.closest("a, button, .dropdown, input")) return;
|
||||
$window.location.href = item._viewUrl;
|
||||
};
|
||||
|
||||
$scope.hiddenStatusCount = () =>
|
||||
Object.keys($scope.filters.status).filter((k) => $scope.filters.status[k] === false).length;
|
||||
$scope.hasHiddenStatus = () => $scope.hiddenStatusCount() > 0;
|
||||
|
||||
$scope.hasActiveFilters = () =>
|
||||
$scope.typeFilter !== "all" ||
|
||||
$scope.search.trim().length > 0 ||
|
||||
Object.keys($scope.filters.status).some((k) => $scope.filters.status[k] === false);
|
||||
|
||||
$scope.clearFilters = () => {
|
||||
$scope.typeFilter = "all";
|
||||
$scope.search = "";
|
||||
Object.keys($scope.filters.status).forEach((k) => {
|
||||
$scope.filters.status[k] = true;
|
||||
});
|
||||
};
|
||||
|
||||
function waitRepoToBeReady(repoId, callback) {
|
||||
$http.get("/api/repo/" + repoId).then((res) => {
|
||||
for (const item of $scope.items) {
|
||||
@@ -1608,10 +1864,12 @@ angular
|
||||
|
||||
$scope.itemFilter = (item) => {
|
||||
if ($scope.typeFilter !== "all" && item._type !== $scope.typeFilter) return false;
|
||||
if ($scope.filters.status[item.status] == false) return false;
|
||||
if ($scope.search.trim().length == 0) return true;
|
||||
if (item._source && item._source.indexOf($scope.search) > -1) return true;
|
||||
if (item._id.indexOf($scope.search) > -1) return true;
|
||||
if ($scope.filters.status[item._statusKey] === false) return false;
|
||||
const needle = $scope.search.trim().toLowerCase();
|
||||
if (needle.length == 0) return true;
|
||||
if (item._source && String(item._source).toLowerCase().indexOf(needle) > -1) return true;
|
||||
if (item._id && String(item._id).toLowerCase().indexOf(needle) > -1) return true;
|
||||
if (item.conference && String(item.conference).toLowerCase().indexOf(needle) > -1) return true;
|
||||
return false;
|
||||
};
|
||||
},
|
||||
@@ -1642,6 +1900,8 @@ angular
|
||||
$scope.rateLimitCountdown = "";
|
||||
|
||||
var countdownTimer = null;
|
||||
let pollTimer = null;
|
||||
let destroyed = false;
|
||||
function startRateLimitCountdown(resetAt) {
|
||||
$scope.rateLimitResetAt = resetAt;
|
||||
if (countdownTimer) clearInterval(countdownTimer);
|
||||
@@ -1665,7 +1925,9 @@ angular
|
||||
countdownTimer = setInterval(tick, 1000);
|
||||
}
|
||||
$scope.$on("$destroy", function () {
|
||||
destroyed = true;
|
||||
if (countdownTimer) clearInterval(countdownTimer);
|
||||
if (pollTimer) clearTimeout(pollTimer);
|
||||
});
|
||||
|
||||
function parseStatusMessage(msg) {
|
||||
@@ -1680,6 +1942,7 @@ angular
|
||||
}
|
||||
|
||||
$scope.getStatus = () => {
|
||||
if (destroyed) return;
|
||||
$http
|
||||
.get("/api/repo/" + $scope.repoId, {
|
||||
repoId: $scope.repoId,
|
||||
@@ -1687,6 +1950,7 @@ angular
|
||||
})
|
||||
.then(
|
||||
(res) => {
|
||||
if (destroyed) return;
|
||||
$scope.repo = res.data;
|
||||
if (res.data.rateLimitResetAt) {
|
||||
startRateLimitCountdown(res.data.rateLimitResetAt);
|
||||
@@ -1706,12 +1970,12 @@ angular
|
||||
} else if ($scope.repo.status == "anonymizing") {
|
||||
$scope.progress = 75;
|
||||
}
|
||||
var shouldPoll = $scope.repo.status != "ready";
|
||||
var shouldPoll = !["ready", "removed", "expired"].includes($scope.repo.status);
|
||||
if ($scope.repo.status == "error" && !$scope.rateLimitResetAt) {
|
||||
shouldPoll = false;
|
||||
}
|
||||
if (shouldPoll) {
|
||||
setTimeout($scope.getStatus, 2000);
|
||||
pollTimer = setTimeout($scope.getStatus, 2000);
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
@@ -2215,7 +2479,7 @@ angular
|
||||
if (typeof d.body === "string") out.add(d.body);
|
||||
if (typeof d.diff === "string") out.add(d.diff);
|
||||
const comments =
|
||||
($scope.details && $scope.details.comments) || [];
|
||||
d.comments || [];
|
||||
for (const c of comments) {
|
||||
if (typeof c.author === "string") out.add(c.author);
|
||||
if (typeof c.body === "string") out.add(c.body);
|
||||
@@ -2589,6 +2853,13 @@ angular
|
||||
"$sce",
|
||||
"$q",
|
||||
function ($scope, $http, $location, $routeParams, $sce, $q) {
|
||||
let contentGeneration = 0;
|
||||
let destroyed = false;
|
||||
$scope.$on("$destroy", () => {
|
||||
destroyed = true;
|
||||
contentGeneration++;
|
||||
if (searchCanceller) searchCanceller.resolve();
|
||||
});
|
||||
$scope.files = [];
|
||||
$scope.isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent);
|
||||
$scope.fileSearchQuery = "";
|
||||
@@ -2619,11 +2890,13 @@ angular
|
||||
return;
|
||||
}
|
||||
$scope.fileSearchLoading = true;
|
||||
searchCanceller = $q.defer();
|
||||
const requestCanceller = $q.defer();
|
||||
searchCanceller = requestCanceller;
|
||||
$http.get(
|
||||
`/api/repo/${$scope.repoId}/files/search?q=${encodeURIComponent(query)}`,
|
||||
{ timeout: searchCanceller.promise }
|
||||
{ timeout: requestCanceller.promise }
|
||||
).then(function (res) {
|
||||
if (destroyed || searchCanceller !== requestCanceller) return;
|
||||
searchCanceller = null;
|
||||
$scope.fileSearchLoading = false;
|
||||
// Merge search results into $scope.files so the tree can render them.
|
||||
@@ -2673,8 +2946,8 @@ angular
|
||||
}
|
||||
$scope.fileSearchResults = res.data;
|
||||
}, function () {
|
||||
// Only clear loading if this wasn't a cancellation
|
||||
if (!searchCanceller) {
|
||||
if (!destroyed && searchCanceller === requestCanceller) {
|
||||
searchCanceller = null;
|
||||
$scope.fileSearchLoading = false;
|
||||
$scope.fileSearchResults = [];
|
||||
}
|
||||
@@ -2724,6 +2997,7 @@ angular
|
||||
];
|
||||
|
||||
$scope.$on("$routeUpdate", function (event, current) {
|
||||
if ($scope.repoId != $routeParams.repoId) return init();
|
||||
if (($routeParams.path || "") == $scope.filePath) {
|
||||
return;
|
||||
}
|
||||
@@ -2732,9 +3006,6 @@ angular
|
||||
.split("/")
|
||||
.filter((f) => f && f.trim().length > 0);
|
||||
|
||||
if ($scope.repoId != $routeParams.repoId) {
|
||||
return init();
|
||||
}
|
||||
|
||||
updateContent();
|
||||
|
||||
@@ -2792,23 +3063,28 @@ angular
|
||||
}
|
||||
$scope.fileCounts = null;
|
||||
$scope.getFiles = function (path) {
|
||||
const repoId = $scope.repoId;
|
||||
return $http.get(
|
||||
`/api/repo/${$scope.repoId}/files/?path=${encodeURIComponent(path)}&v=${$scope.options.lastUpdateDate}`
|
||||
).then(function (res) {
|
||||
if (destroyed || repoId !== $scope.repoId) return [];
|
||||
const normalized = path || "";
|
||||
$scope.files = $scope.files.filter((f) => f.path !== normalized);
|
||||
$scope.files.push(...res.data);
|
||||
return res.data;
|
||||
}, function (err) {
|
||||
if (destroyed || repoId !== $scope.repoId) return [];
|
||||
$scope.type = "error";
|
||||
$scope.content = (err && err.data && err.data.error) || "unknown_error";
|
||||
$scope.files = [];
|
||||
});
|
||||
};
|
||||
function fetchFileCounts() {
|
||||
const repoId = $scope.repoId;
|
||||
$http.get(
|
||||
`/api/repo/${$scope.repoId}/files/counts`
|
||||
).then(function (res) {
|
||||
if (destroyed || repoId !== $scope.repoId) return;
|
||||
$scope.fileCounts = res.data;
|
||||
}, function () {
|
||||
$scope.fileCounts = {};
|
||||
@@ -2827,8 +3103,11 @@ angular
|
||||
$scope.$on("$destroy", function () { if (rlCountdownTimer) clearInterval(rlCountdownTimer); });
|
||||
|
||||
function getOptions(callback) {
|
||||
if (destroyed) return;
|
||||
const repoId = $scope.repoId;
|
||||
$http.get(`/api/repo/${$scope.repoId}/options`).then(
|
||||
(res) => {
|
||||
if (destroyed || repoId !== $scope.repoId) return;
|
||||
$scope.options = res.data;
|
||||
if ($scope.options.url) {
|
||||
window.location = $scope.options.url;
|
||||
@@ -2839,6 +3118,7 @@ angular
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
if (destroyed || repoId !== $scope.repoId) return;
|
||||
var data = err.data || {};
|
||||
if (data.error === "rate_limited" && data.resetAt) {
|
||||
$scope.type = "rate_limited";
|
||||
@@ -2925,6 +3205,7 @@ angular
|
||||
}
|
||||
|
||||
function getContent(path, fileInfo) {
|
||||
const generation = contentGeneration;
|
||||
if (!path) {
|
||||
$scope.type = "error";
|
||||
$scope.content = "no_file_selected";
|
||||
@@ -2950,6 +3231,7 @@ angular
|
||||
)
|
||||
.then(
|
||||
(res) => {
|
||||
if (destroyed || generation !== contentGeneration) return;
|
||||
$scope.type = originalType;
|
||||
$scope.content = res.data;
|
||||
if ($scope.content == "") {
|
||||
@@ -2973,7 +3255,7 @@ angular
|
||||
suppressSubScriptHandling: true,
|
||||
suppressAutoLink: false,
|
||||
});
|
||||
$scope.content = $sce.trustAsHtml(orgHTMLDocument.toString());
|
||||
$scope.content = $sce.trustAsHtml(DOMPurify.sanitize(orgHTMLDocument.toString()));
|
||||
$scope.type = "html";
|
||||
}
|
||||
if (
|
||||
@@ -2988,6 +3270,7 @@ angular
|
||||
}, 50);
|
||||
},
|
||||
(err) => {
|
||||
if (destroyed || generation !== contentGeneration) return;
|
||||
$scope.type = "error";
|
||||
$scope.content = "unknown_error";
|
||||
try {
|
||||
@@ -3011,6 +3294,7 @@ angular
|
||||
}
|
||||
|
||||
function updateContent() {
|
||||
contentGeneration++;
|
||||
$scope.content = "";
|
||||
$scope.file = getSelectedFile();
|
||||
let fileVersion = "0";
|
||||
@@ -3184,11 +3468,18 @@ angular
|
||||
}
|
||||
|
||||
function init() {
|
||||
contentGeneration++;
|
||||
$scope.files = [];
|
||||
$scope.content = null;
|
||||
$scope.fileCounts = null;
|
||||
$scope.fileSearchQuery = "";
|
||||
$scope.onFileSearchChange();
|
||||
$scope.repoId = $routeParams.repoId;
|
||||
$scope.type = "loading";
|
||||
$scope.filePath = $routeParams.path || "";
|
||||
$scope.paths = $scope.filePath.split("/");
|
||||
|
||||
const repoId = $scope.repoId;
|
||||
getOptions(function (options) {
|
||||
fetchFileCounts();
|
||||
var chain = $q.resolve();
|
||||
@@ -3203,6 +3494,7 @@ angular
|
||||
});
|
||||
}
|
||||
chain.then(function () {
|
||||
if (destroyed || repoId !== $scope.repoId) return;
|
||||
if ($scope.files.length == 1 && $scope.files[0].name == "") {
|
||||
$scope.files = [];
|
||||
$scope.type = "empty";
|
||||
@@ -3248,6 +3540,7 @@ angular
|
||||
$http.get(`/api/pr/${$scope.pullRequestId}/content`).then(
|
||||
(res) => {
|
||||
$scope.details = res.data;
|
||||
$scope.tabState = { active: res.data.diff ? "diff" : "comments" };
|
||||
if (callback) {
|
||||
callback(res.data);
|
||||
}
|
||||
@@ -3460,7 +3753,7 @@ angular
|
||||
const start = new Date();
|
||||
start.setDate(1);
|
||||
start.setMonth(start.getMonth() + 1);
|
||||
const end = new Date();
|
||||
const end = new Date(start);
|
||||
end.setMonth(start.getMonth() + 7, 0);
|
||||
$scope.options = {
|
||||
startDate: start,
|
||||
|
||||
Vendored
+2
-2
File diff suppressed because one or more lines are too long
@@ -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`);
|
||||
});
|
||||
@@ -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; \}/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user