feat: add community publishing and news syndication
This commit is contained in:
@@ -20,111 +20,467 @@
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body { margin: 0; background: var(--canvas); color: var(--text); }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--canvas);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
button, input { font: inherit; }
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button, input[type="radio"], input[type="checkbox"] { accent-color: var(--orange); }
|
||||
button,
|
||||
input[type='radio'],
|
||||
input[type='checkbox'] {
|
||||
accent-color: var(--orange);
|
||||
}
|
||||
|
||||
button { min-height: 44px; border: 1px solid var(--line-strong); border-radius: 0; background: var(--surface); color: var(--text); cursor: pointer; padding: .65rem .9rem; font-weight: 700; }
|
||||
button:hover:not(:disabled) { background: #1b2021; border-color: var(--text); }
|
||||
button:active:not(:disabled) { transform: translateY(1px); }
|
||||
button:disabled { color: var(--dim); cursor: not-allowed; border-color: var(--line); background: #101213; }
|
||||
.button-primary { background: var(--orange); color: #12100b; border-color: var(--orange); }
|
||||
.button-primary:hover:not(:disabled) { background: var(--orange-hover); border-color: var(--orange-hover); }
|
||||
.button-danger { color: var(--danger); }
|
||||
.quiet-danger { border-color: #6f4941; }
|
||||
button {
|
||||
min-height: 44px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 0;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
padding: 0.65rem 0.9rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
button:hover:not(:disabled) {
|
||||
background: #1b2021;
|
||||
border-color: var(--text);
|
||||
}
|
||||
button:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
button:disabled {
|
||||
color: var(--dim);
|
||||
cursor: not-allowed;
|
||||
border-color: var(--line);
|
||||
background: #101213;
|
||||
}
|
||||
.button-primary {
|
||||
background: var(--orange);
|
||||
color: #12100b;
|
||||
border-color: var(--orange);
|
||||
}
|
||||
.button-primary:hover:not(:disabled) {
|
||||
background: var(--orange-hover);
|
||||
border-color: var(--orange-hover);
|
||||
}
|
||||
.button-danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
.quiet-danger {
|
||||
border-color: #6f4941;
|
||||
}
|
||||
|
||||
:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; }
|
||||
:focus-visible {
|
||||
outline: 3px solid var(--focus);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.skip-link { position: fixed; z-index: 2; top: 0; left: .5rem; transform: translateY(-120%); background: var(--orange); color: #12100b; padding: .65rem; font-weight: 700; }
|
||||
.skip-link:focus { transform: translateY(0); }
|
||||
.skip-link {
|
||||
position: fixed;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
left: 0.5rem;
|
||||
transform: translateY(-120%);
|
||||
background: var(--orange);
|
||||
color: #12100b;
|
||||
padding: 0.65rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.skip-link:focus {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.shell { width: min(100% - 2rem, 100rem); margin: 0 auto; padding: 1.5rem 0 3rem; }
|
||||
.command-header, .control-strip, .panel-heading { display: flex; gap: var(--space); justify-content: space-between; align-items: center; }
|
||||
.command-header { border-bottom: 1px solid var(--line); padding: 0 0 1.25rem; }
|
||||
.kicker { margin: 0 0 .35rem; color: var(--muted); font-size: .74rem; font-weight: 800; letter-spacing: .08em; }
|
||||
h1, h2, p { margin-top: 0; }
|
||||
h1 { margin-bottom: 0; font-size: clamp(1.65rem, 3vw, 2.35rem); letter-spacing: -.025em; }
|
||||
h2 { margin-bottom: 0; font-size: 1.15rem; }
|
||||
.header-state { text-align: right; }
|
||||
.header-state strong, #capture-state, #review-state { display: block; font-size: .88rem; font-weight: 800; letter-spacing: .04em; }
|
||||
.header-state span, #queue-state, #capture-count, .field-help, .action-help, figcaption { color: var(--muted); font-size: .88rem; }
|
||||
.shell {
|
||||
width: min(100% - 2rem, 100rem);
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 0 3rem;
|
||||
}
|
||||
.command-header,
|
||||
.control-strip,
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
gap: var(--space);
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.command-header {
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 0 0 1.25rem;
|
||||
}
|
||||
.kicker {
|
||||
margin: 0 0 0.35rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
h1 {
|
||||
margin-bottom: 0;
|
||||
font-size: clamp(1.65rem, 3vw, 2.35rem);
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
h2 {
|
||||
margin-bottom: 0;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
.header-state {
|
||||
text-align: right;
|
||||
}
|
||||
.header-state strong,
|
||||
#capture-state,
|
||||
#review-state {
|
||||
display: block;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.header-state span,
|
||||
#queue-state,
|
||||
#capture-count,
|
||||
.field-help,
|
||||
.action-help,
|
||||
figcaption {
|
||||
color: var(--muted);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.notice { display: flex; gap: .75rem; border: 1px solid var(--line); border-left: 2px solid var(--info); margin: 1rem 0; padding: .8rem 1rem; background: var(--well); }
|
||||
.notice strong { color: var(--info); font-size: .78rem; letter-spacing: .06em; }
|
||||
.notice[data-kind="success"] { border-left-color: var(--success); }
|
||||
.notice[data-kind="success"] strong { color: var(--success); }
|
||||
.notice[data-kind="error"], .notice[data-kind="conflict"] { border-left-color: var(--danger); }
|
||||
.notice[data-kind="error"] strong, .notice[data-kind="conflict"] strong { color: var(--danger); }
|
||||
.notice[data-kind="retained"] { border-left-color: var(--info); }
|
||||
.notice[data-kind="retained"] strong { color: var(--info); }
|
||||
.notice {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
border: 1px solid var(--line);
|
||||
border-left: 2px solid var(--info);
|
||||
margin: 1rem 0;
|
||||
padding: 0.8rem 1rem;
|
||||
background: var(--well);
|
||||
}
|
||||
.notice strong {
|
||||
color: var(--info);
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.notice[data-kind='success'] {
|
||||
border-left-color: var(--success);
|
||||
}
|
||||
.notice[data-kind='success'] strong {
|
||||
color: var(--success);
|
||||
}
|
||||
.notice[data-kind='error'],
|
||||
.notice[data-kind='conflict'] {
|
||||
border-left-color: var(--danger);
|
||||
}
|
||||
.notice[data-kind='error'] strong,
|
||||
.notice[data-kind='conflict'] strong {
|
||||
color: var(--danger);
|
||||
}
|
||||
.notice[data-kind='retained'] {
|
||||
border-left-color: var(--info);
|
||||
}
|
||||
.notice[data-kind='retained'] strong {
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.control-strip { border: 1px solid var(--line); background: var(--surface-raised); padding: 1rem; margin-bottom: 1rem; }
|
||||
.control-strip p:last-child { margin: 0; }
|
||||
.control-actions, .form-actions, .dialog-actions { display: flex; gap: .5rem; flex-wrap: wrap; }
|
||||
.control-strip {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface-raised);
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.control-strip p:last-child {
|
||||
margin: 0;
|
||||
}
|
||||
.control-actions,
|
||||
.form-actions,
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.workspace-grid { display: grid; grid-template-columns: minmax(17rem, 24rem) minmax(0, 1fr); border: 1px solid var(--line); background: var(--surface); }
|
||||
.capture-panel { border-right: 1px solid var(--line); display: flex; flex-direction: column; height: min(42rem, calc(100vh - 10rem)); min-height: 24rem; min-width: 0; }
|
||||
.capture-panel, .review-panel { padding: 1rem; }
|
||||
.panel-heading { border-bottom: 1px solid var(--line); padding-bottom: .85rem; }
|
||||
.queue-filter { display: flex; gap: .55rem; align-items: center; min-height: 44px; color: var(--muted); font-size: .88rem; }
|
||||
.queue-filter input { inline-size: 1.1rem; block-size: 1.1rem; flex: 0 0 auto; }
|
||||
.capture-scroll { min-height: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; padding-right: .25rem; scrollbar-gutter: stable; }
|
||||
.empty-copy, .review-empty { color: var(--muted); padding: 1rem 0; line-height: 1.5; }
|
||||
.capture-list { margin: 0; padding: 0; list-style: none; }
|
||||
.capture-list button { width: 100%; min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .25rem .6rem; margin-top: .55rem; text-align: left; background: var(--well); }
|
||||
.capture-list button[aria-current="true"] { border-color: var(--orange); color: var(--text); }
|
||||
.capture-list .capture-id { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.capture-list .capture-meta { display: grid; gap: .15rem; color: var(--muted); font-size: .72rem; line-height: 1.2; text-align: right; white-space: nowrap; }
|
||||
.capture-list .capture-review { color: var(--text); font-size: .75rem; font-weight: 800; letter-spacing: .04em; }
|
||||
.capture-list .capture-time { overflow: hidden; text-overflow: ellipsis; }
|
||||
.workspace-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(17rem, 24rem) minmax(0, 1fr);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
}
|
||||
.capture-panel {
|
||||
border-right: 1px solid var(--line);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: min(42rem, calc(100vh - 10rem));
|
||||
min-height: 24rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.capture-panel,
|
||||
.review-panel {
|
||||
padding: 1rem;
|
||||
}
|
||||
.panel-heading {
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding-bottom: 0.85rem;
|
||||
}
|
||||
.queue-filter {
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
color: var(--muted);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.queue-filter input {
|
||||
inline-size: 1.1rem;
|
||||
block-size: 1.1rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.capture-scroll {
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding-right: 0.25rem;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
.empty-copy,
|
||||
.review-empty {
|
||||
color: var(--muted);
|
||||
padding: 1rem 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.capture-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.capture-list button {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.25rem 0.6rem;
|
||||
margin-top: 0.55rem;
|
||||
text-align: left;
|
||||
background: var(--well);
|
||||
}
|
||||
.capture-list button[aria-current='true'] {
|
||||
border-color: var(--orange);
|
||||
color: var(--text);
|
||||
}
|
||||
.capture-list .capture-id {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.capture-list .capture-meta {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.2;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.capture-list .capture-review {
|
||||
color: var(--text);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.capture-list .capture-time {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.review-content { padding-top: 1rem; }
|
||||
.evidence-frame { margin: 0 0 1rem; border: 1px solid var(--line); background: var(--well); padding: .65rem; }
|
||||
.evidence-frame img { display: block; width: 100%; max-height: 31rem; object-fit: contain; background: #000; }
|
||||
.evidence-frame figcaption { padding-top: .65rem; overflow-wrap: anywhere; }
|
||||
fieldset { margin: 0 0 1rem; padding: .75rem; border: 1px solid var(--line); min-width: 0; }
|
||||
fieldset:disabled { opacity: .65; }
|
||||
legend { padding: 0 .35rem; font-weight: 700; }
|
||||
.choice-grid { display: flex; flex-wrap: wrap; gap: .45rem; }
|
||||
.choice { position: relative; display: inline-flex; min-height: 44px; align-items: center; border: 1px solid var(--line-strong); background: var(--well); cursor: pointer; }
|
||||
.choice:hover { border-color: var(--text); }
|
||||
.choice input { position: absolute; opacity: 0; inset: 0; cursor: pointer; }
|
||||
.choice span { padding: .65rem .75rem; }
|
||||
.choice input:checked + span { background: #3c2a0c; color: var(--text); box-shadow: inset 0 -2px var(--orange); }
|
||||
.choice input:focus-visible + span { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||
.validation-message { min-height: 1.5rem; margin: -.35rem 0 .75rem; color: var(--danger); }
|
||||
.action-help { max-width: 70ch; margin: .8rem 0 0; line-height: 1.45; }
|
||||
.review-content {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
.evidence-frame {
|
||||
margin: 0 0 1rem;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--well);
|
||||
padding: 0.65rem;
|
||||
}
|
||||
.evidence-frame img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 31rem;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
}
|
||||
.evidence-frame figcaption {
|
||||
padding-top: 0.65rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
fieldset {
|
||||
margin: 0 0 1rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--line);
|
||||
min-width: 0;
|
||||
}
|
||||
fieldset:disabled {
|
||||
opacity: 0.65;
|
||||
}
|
||||
legend {
|
||||
padding: 0 0.35rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.choice-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.choice {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
border: 1px solid var(--line-strong);
|
||||
background: var(--well);
|
||||
cursor: pointer;
|
||||
}
|
||||
.choice:hover {
|
||||
border-color: var(--text);
|
||||
}
|
||||
.choice input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
inset: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.choice span {
|
||||
padding: 0.65rem 0.75rem;
|
||||
}
|
||||
.choice input:checked + span {
|
||||
background: #3c2a0c;
|
||||
color: var(--text);
|
||||
box-shadow: inset 0 -2px var(--orange);
|
||||
}
|
||||
.choice input:focus-visible + span {
|
||||
outline: 3px solid var(--focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.validation-message {
|
||||
min-height: 1.5rem;
|
||||
margin: -0.35rem 0 0.75rem;
|
||||
color: var(--danger);
|
||||
}
|
||||
.action-help {
|
||||
max-width: 70ch;
|
||||
margin: 0.8rem 0 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
dialog { width: min(calc(100% - 2rem), 34rem); border: 1px solid var(--orange); border-radius: 0; padding: 1.3rem; color: var(--text); background: var(--surface-raised); box-shadow: 0 18px 60px #000; }
|
||||
dialog::backdrop { background: rgb(0 0 0 / 72%); }
|
||||
dialog p { line-height: 1.5; }
|
||||
dialog {
|
||||
width: min(calc(100% - 2rem), 34rem);
|
||||
border: 1px solid var(--orange);
|
||||
border-radius: 0;
|
||||
padding: 1.3rem;
|
||||
color: var(--text);
|
||||
background: var(--surface-raised);
|
||||
box-shadow: 0 18px 60px #000;
|
||||
}
|
||||
dialog::backdrop {
|
||||
background: rgb(0 0 0 / 72%);
|
||||
}
|
||||
dialog p {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 52rem) {
|
||||
.shell { width: min(100% - 1rem, 100rem); padding-top: 1rem; }
|
||||
.command-header, .control-strip { align-items: flex-start; flex-direction: column; }
|
||||
.header-state { text-align: left; }
|
||||
.workspace-grid { grid-template-columns: 1fr; }
|
||||
.capture-panel { height: auto; min-height: 0; border-right: 0; border-bottom: 1px solid var(--line); }
|
||||
.capture-scroll { overflow: visible; padding-right: 0; scrollbar-gutter: auto; }
|
||||
.capture-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: .55rem; }
|
||||
.capture-list button { margin-top: 0; }
|
||||
.shell {
|
||||
width: min(100% - 1rem, 100rem);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
.command-header,
|
||||
.control-strip {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
.header-state {
|
||||
text-align: left;
|
||||
}
|
||||
.workspace-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.capture-panel {
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.capture-scroll {
|
||||
overflow: visible;
|
||||
padding-right: 0;
|
||||
scrollbar-gutter: auto;
|
||||
}
|
||||
.capture-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.capture-list button {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 33rem) {
|
||||
.control-actions, .form-actions, .dialog-actions { display: grid; grid-template-columns: 1fr; width: 100%; }
|
||||
.control-actions button, .form-actions button, .dialog-actions button { width: 100%; }
|
||||
.notice { align-items: flex-start; flex-direction: column; gap: .25rem; }
|
||||
.control-actions,
|
||||
.form-actions,
|
||||
.dialog-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
width: 100%;
|
||||
}
|
||||
.control-actions button,
|
||||
.form-actions button,
|
||||
.dialog-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
.notice {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
button, fieldset, .choice, .notice, .workspace-grid, .evidence-frame { border: 1px solid CanvasText; }
|
||||
.button-primary, .choice input:checked + span { forced-color-adjust: none; background: Highlight; color: HighlightText; }
|
||||
button,
|
||||
fieldset,
|
||||
.choice,
|
||||
.notice,
|
||||
.workspace-grid,
|
||||
.evidence-frame {
|
||||
border: 1px solid CanvasText;
|
||||
}
|
||||
.button-primary,
|
||||
.choice input:checked + span {
|
||||
forced-color-adjust: none;
|
||||
background: Highlight;
|
||||
color: HighlightText;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,132 @@
|
||||
const taxonomy = Object.freeze({
|
||||
weapon: { views: ["detail", "blueprint", "crafting", "stat_range", "talent_grid", "augmentation"], records: { detail: ["weapon", "weapon_talent"], blueprint: ["weapon", "weapon_talent"], crafting: ["weapon"], stat_range: ["weapon"], talent_grid: ["weapon_talent"], augmentation: ["weapon_augmentation_profile"] } },
|
||||
gear: { views: ["detail", "blueprint", "talent", "augmentation"], records: { detail: ["gear", "brand_set"], blueprint: ["gear", "brand_set"], talent: ["gear_talent"], augmentation: ["gear_augmentation_profile"] } },
|
||||
skill_chip: { views: ["detail", "set_bonus", "talent"], records: { detail: ["skill_chip_set", "skill_chip_roll_pool"], set_bonus: ["skill_chip_set_bonus"], talent: ["skill_chip_talent"] } },
|
||||
os_protocol: { views: ["detail", "secondary_profile", "talent"], records: { detail: ["os_protocol"], secondary_profile: ["os_protocol_secondary_profile"], talent: ["os_protocol_talent"] } },
|
||||
specialization: { views: ["overview", "details", "ability", "focus_details", "focus_tree", "rank_details"], records: { overview: ["specialization"], details: ["specialization"], ability: ["specialization_ability"], focus_details: ["specialization_focus"], focus_tree: ["specialization_focus", "specialization_tree_node"], rank_details: ["specialization_tree_rank"] } },
|
||||
weapon_mod: { views: ["detail"], records: { detail: ["weapon_mod"] } },
|
||||
gear_mod: { views: ["detail"], records: { detail: ["gear_mod"] } },
|
||||
unknown: { views: ["unknown"], records: { unknown: [] } }
|
||||
weapon: {
|
||||
views: [
|
||||
'detail',
|
||||
'blueprint',
|
||||
'crafting',
|
||||
'stat_range',
|
||||
'talent_grid',
|
||||
'augmentation',
|
||||
],
|
||||
records: {
|
||||
detail: ['weapon', 'weapon_talent'],
|
||||
blueprint: ['weapon', 'weapon_talent'],
|
||||
crafting: ['weapon'],
|
||||
stat_range: ['weapon'],
|
||||
talent_grid: ['weapon_talent'],
|
||||
augmentation: ['weapon_augmentation_profile'],
|
||||
},
|
||||
},
|
||||
gear: {
|
||||
views: ['detail', 'blueprint', 'talent', 'augmentation'],
|
||||
records: {
|
||||
detail: ['gear', 'brand_set'],
|
||||
blueprint: ['gear', 'brand_set'],
|
||||
talent: ['gear_talent'],
|
||||
augmentation: ['gear_augmentation_profile'],
|
||||
},
|
||||
},
|
||||
skill_chip: {
|
||||
views: ['detail', 'set_bonus', 'talent'],
|
||||
records: {
|
||||
detail: ['skill_chip_set', 'skill_chip_roll_pool'],
|
||||
set_bonus: ['skill_chip_set_bonus'],
|
||||
talent: ['skill_chip_talent'],
|
||||
},
|
||||
},
|
||||
os_protocol: {
|
||||
views: ['detail', 'secondary_profile', 'talent'],
|
||||
records: {
|
||||
detail: ['os_protocol'],
|
||||
secondary_profile: ['os_protocol_secondary_profile'],
|
||||
talent: ['os_protocol_talent'],
|
||||
},
|
||||
},
|
||||
specialization: {
|
||||
views: [
|
||||
'overview',
|
||||
'details',
|
||||
'ability',
|
||||
'focus_details',
|
||||
'focus_tree',
|
||||
'rank_details',
|
||||
],
|
||||
records: {
|
||||
overview: ['specialization'],
|
||||
details: ['specialization'],
|
||||
ability: ['specialization_ability'],
|
||||
focus_details: ['specialization_focus'],
|
||||
focus_tree: ['specialization_focus', 'specialization_tree_node'],
|
||||
rank_details: ['specialization_tree_rank'],
|
||||
},
|
||||
},
|
||||
weapon_mod: { views: ['detail'], records: { detail: ['weapon_mod'] } },
|
||||
gear_mod: { views: ['detail'], records: { detail: ['gear_mod'] } },
|
||||
unknown: { views: ['unknown'], records: { unknown: [] } },
|
||||
});
|
||||
const families = Object.keys(taxonomy);
|
||||
const gearSlots = ["Mask", "Gloves", "Backpack", "Body Armor", "Holster", "Knee Pads"];
|
||||
const abstentionReasons = ["low_confidence", "ambiguous_family", "ambiguous_view", "unsupported_family", "unsupported_view", "insufficient_visual_evidence", "occluded", "corrupt_image", "resource_limit", "cancelled"];
|
||||
const gearSlots = [
|
||||
'Mask',
|
||||
'Gloves',
|
||||
'Backpack',
|
||||
'Body Armor',
|
||||
'Holster',
|
||||
'Knee Pads',
|
||||
];
|
||||
const abstentionReasons = [
|
||||
'low_confidence',
|
||||
'ambiguous_family',
|
||||
'ambiguous_view',
|
||||
'unsupported_family',
|
||||
'unsupported_view',
|
||||
'insufficient_visual_evidence',
|
||||
'occluded',
|
||||
'corrupt_image',
|
||||
'resource_limit',
|
||||
'cancelled',
|
||||
];
|
||||
const csrfPattern = /^[A-Za-z0-9_-]{32,128}$/;
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const els = Object.freeze({
|
||||
sessionState: $("session-state"), queueState: $("queue-state"), captureState: $("capture-state"), notice: $("notice"), noticeTitle: $("notice-title"), noticeCopy: $("notice-copy"), start: $("start-button"), stop: $("stop-button"), refresh: $("refresh-button"), filter: $("hide-reviewed"), list: $("capture-list"), listEmpty: $("capture-empty"), count: $("capture-count"), reviewTitle: $("review-title"), reviewState: $("review-state"), reviewEmpty: $("review-empty"), reviewContent: $("review-content"), media: $("capture-media"), meta: $("capture-meta"), labelForm: $("label-form"), validation: $("label-validation"), save: $("save-label-button"), reject: $("reject-button"), remove: $("delete-button"), family: $("family-options"), view: $("view-options"), gear: $("gear-slot-options"), records: $("record-type-options"), abstention: $("abstention-options"), viewFieldset: $("view-fieldset"), gearFieldset: $("gear-slot-fieldset"), recordFieldset: $("record-types-fieldset"), abstentionFieldset: $("abstention-fieldset"), dialog: $("confirmation-dialog"), dialogKicker: $("confirmation-kicker"), dialogTitle: $("confirmation-title"), dialogCopy: $("confirmation-copy"), dialogSubmit: $("confirmation-submit")
|
||||
sessionState: $('session-state'),
|
||||
queueState: $('queue-state'),
|
||||
captureState: $('capture-state'),
|
||||
notice: $('notice'),
|
||||
noticeTitle: $('notice-title'),
|
||||
noticeCopy: $('notice-copy'),
|
||||
start: $('start-button'),
|
||||
stop: $('stop-button'),
|
||||
refresh: $('refresh-button'),
|
||||
filter: $('hide-reviewed'),
|
||||
list: $('capture-list'),
|
||||
listEmpty: $('capture-empty'),
|
||||
count: $('capture-count'),
|
||||
reviewTitle: $('review-title'),
|
||||
reviewState: $('review-state'),
|
||||
reviewEmpty: $('review-empty'),
|
||||
reviewContent: $('review-content'),
|
||||
media: $('capture-media'),
|
||||
meta: $('capture-meta'),
|
||||
labelForm: $('label-form'),
|
||||
validation: $('label-validation'),
|
||||
save: $('save-label-button'),
|
||||
reject: $('reject-button'),
|
||||
remove: $('delete-button'),
|
||||
family: $('family-options'),
|
||||
view: $('view-options'),
|
||||
gear: $('gear-slot-options'),
|
||||
records: $('record-type-options'),
|
||||
abstention: $('abstention-options'),
|
||||
viewFieldset: $('view-fieldset'),
|
||||
gearFieldset: $('gear-slot-fieldset'),
|
||||
recordFieldset: $('record-types-fieldset'),
|
||||
abstentionFieldset: $('abstention-fieldset'),
|
||||
dialog: $('confirmation-dialog'),
|
||||
dialogKicker: $('confirmation-kicker'),
|
||||
dialogTitle: $('confirmation-title'),
|
||||
dialogCopy: $('confirmation-copy'),
|
||||
dialogSubmit: $('confirmation-submit'),
|
||||
});
|
||||
|
||||
let csrfToken = null;
|
||||
@@ -26,31 +137,75 @@ let pending = false;
|
||||
let returnFocus = null;
|
||||
let hideReviewed = false;
|
||||
|
||||
function setText(element, value) { element.textContent = value; }
|
||||
function clearNode(node) { while (node.firstChild) node.removeChild(node.firstChild); }
|
||||
function label(value) { return value.replaceAll("_", " "); }
|
||||
function notice(kind, title, copy) { els.notice.dataset.kind = kind; setText(els.noticeTitle, title); setText(els.noticeCopy, copy); }
|
||||
function hasValidCsrf(value) { return typeof value === "string" && csrfPattern.test(value); }
|
||||
function selected(name) { return document.querySelector(`input[name="${name}"]:checked`)?.value || null; }
|
||||
function selectedAll(name) { return [...document.querySelectorAll(`input[name="${name}"]:checked`)].map((input) => input.value); }
|
||||
function currentFamily() { return selected("family"); }
|
||||
function currentView() { return selected("view"); }
|
||||
function isReviewedCapture(capture) { return capture?.classification !== null && capture?.classification !== undefined; }
|
||||
function visibleCaptures() { return hideReviewed ? captures.filter((capture) => !isReviewedCapture(capture)) : captures; }
|
||||
function abbreviateCaptureId(value) { return value.length <= 22 ? value : `${value.slice(0, 11)}…${value.slice(-8)}`; }
|
||||
function setText(element, value) {
|
||||
element.textContent = value;
|
||||
}
|
||||
function clearNode(node) {
|
||||
while (node.firstChild) node.removeChild(node.firstChild);
|
||||
}
|
||||
function label(value) {
|
||||
return value.replaceAll('_', ' ');
|
||||
}
|
||||
function notice(kind, title, copy) {
|
||||
els.notice.dataset.kind = kind;
|
||||
setText(els.noticeTitle, title);
|
||||
setText(els.noticeCopy, copy);
|
||||
}
|
||||
function hasValidCsrf(value) {
|
||||
return typeof value === 'string' && csrfPattern.test(value);
|
||||
}
|
||||
function selected(name) {
|
||||
return document.querySelector(`input[name="${name}"]:checked`)?.value || null;
|
||||
}
|
||||
function selectedAll(name) {
|
||||
return [...document.querySelectorAll(`input[name="${name}"]:checked`)].map(
|
||||
(input) => input.value,
|
||||
);
|
||||
}
|
||||
function currentFamily() {
|
||||
return selected('family');
|
||||
}
|
||||
function currentView() {
|
||||
return selected('view');
|
||||
}
|
||||
function isReviewedCapture(capture) {
|
||||
return (
|
||||
capture?.classification !== null && capture?.classification !== undefined
|
||||
);
|
||||
}
|
||||
function visibleCaptures() {
|
||||
return hideReviewed
|
||||
? captures.filter((capture) => !isReviewedCapture(capture))
|
||||
: captures;
|
||||
}
|
||||
function abbreviateCaptureId(value) {
|
||||
return value.length <= 22
|
||||
? value
|
||||
: `${value.slice(0, 11)}…${value.slice(-8)}`;
|
||||
}
|
||||
function formatCaptureTime(value) {
|
||||
if (typeof value !== "string" || value.length > 64) return "TIME UNAVAILABLE";
|
||||
if (typeof value !== 'string' || value.length > 64) return 'TIME UNAVAILABLE';
|
||||
const timestamp = Date.parse(value);
|
||||
if (!Number.isFinite(timestamp)) return "TIME UNAVAILABLE";
|
||||
if (!Number.isFinite(timestamp)) return 'TIME UNAVAILABLE';
|
||||
const date = new Date(timestamp);
|
||||
const parts = [date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate(), date.getUTCHours(), date.getUTCMinutes()];
|
||||
if (parts.some((part) => !Number.isInteger(part))) return "TIME UNAVAILABLE";
|
||||
const parts = [
|
||||
date.getUTCFullYear(),
|
||||
date.getUTCMonth() + 1,
|
||||
date.getUTCDate(),
|
||||
date.getUTCHours(),
|
||||
date.getUTCMinutes(),
|
||||
];
|
||||
if (parts.some((part) => !Number.isInteger(part))) return 'TIME UNAVAILABLE';
|
||||
const [year, month, day, hour, minute] = parts;
|
||||
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")} ${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")} UTC`;
|
||||
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')} ${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')} UTC`;
|
||||
}
|
||||
function activeCaptureProjection() {
|
||||
if (!activeCapture) return null;
|
||||
return captures.find((capture) => capture.capture_id === activeCapture.capture_id) || activeCapture;
|
||||
return (
|
||||
captures.find(
|
||||
(capture) => capture.capture_id === activeCapture.capture_id,
|
||||
) || activeCapture
|
||||
);
|
||||
}
|
||||
|
||||
function syncControls() {
|
||||
@@ -62,23 +217,30 @@ function syncControls() {
|
||||
els.save.disabled = pending || locked || !activeCapture || !isValidLabel();
|
||||
els.reject.disabled = pending || locked || !activeCapture;
|
||||
els.remove.disabled = pending || locked || !activeCapture;
|
||||
document.querySelectorAll("#capture-list button").forEach((button) => {
|
||||
document.querySelectorAll('#capture-list button').forEach((button) => {
|
||||
button.disabled = pending || locked;
|
||||
});
|
||||
document.querySelectorAll("#label-form input").forEach((input) => {
|
||||
const fieldset = input.closest("fieldset");
|
||||
input.disabled = pending || locked || !activeCapture || Boolean(fieldset?.disabled || fieldset?.hidden);
|
||||
document.querySelectorAll('#label-form input').forEach((input) => {
|
||||
const fieldset = input.closest('fieldset');
|
||||
input.disabled =
|
||||
pending ||
|
||||
locked ||
|
||||
!activeCapture ||
|
||||
Boolean(fieldset?.disabled || fieldset?.hidden);
|
||||
});
|
||||
els.labelForm.setAttribute("aria-busy", String(pending));
|
||||
els.labelForm.setAttribute('aria-busy', String(pending));
|
||||
}
|
||||
function setBusy(value) {
|
||||
pending = value;
|
||||
syncControls();
|
||||
}
|
||||
function setBusy(value) { pending = value; syncControls(); }
|
||||
function clearActiveDetail() {
|
||||
activeCapture = null;
|
||||
els.media.removeAttribute("src");
|
||||
els.media.removeAttribute('src');
|
||||
els.reviewContent.hidden = true;
|
||||
els.reviewEmpty.hidden = false;
|
||||
els.reviewTitle.textContent = "No capture selected";
|
||||
els.reviewState.textContent = csrfToken ? "READY" : "LOCKED";
|
||||
els.reviewTitle.textContent = 'No capture selected';
|
||||
els.reviewState.textContent = csrfToken ? 'READY' : 'LOCKED';
|
||||
renderLabelForm(null);
|
||||
}
|
||||
function lock(message) {
|
||||
@@ -86,56 +248,125 @@ function lock(message) {
|
||||
armed = null;
|
||||
captures = [];
|
||||
clearActiveDetail();
|
||||
els.sessionState.textContent = "LOCKED";
|
||||
els.captureState.textContent = "DISARMED";
|
||||
els.queueState.textContent = "Queue unavailable";
|
||||
els.sessionState.textContent = 'LOCKED';
|
||||
els.captureState.textContent = 'DISARMED';
|
||||
els.queueState.textContent = 'Queue unavailable';
|
||||
els.listEmpty.hidden = false;
|
||||
setText(els.listEmpty, message);
|
||||
clearNode(els.list);
|
||||
els.count.textContent = "0 shown / 0 loaded";
|
||||
els.count.textContent = '0 shown / 0 loaded';
|
||||
setBusy(false);
|
||||
}
|
||||
function validSession(payload) {
|
||||
return Boolean(payload) && hasValidCsrf(payload.csrf_token) && typeof payload.armed === "boolean" && Number.isInteger(payload.queue_depth) && Number.isInteger(payload.queue_capacity) && payload.queue_depth >= 0 && payload.queue_depth <= payload.queue_capacity && payload.queue_capacity <= 256;
|
||||
return (
|
||||
Boolean(payload) &&
|
||||
hasValidCsrf(payload.csrf_token) &&
|
||||
typeof payload.armed === 'boolean' &&
|
||||
Number.isInteger(payload.queue_depth) &&
|
||||
Number.isInteger(payload.queue_capacity) &&
|
||||
payload.queue_depth >= 0 &&
|
||||
payload.queue_depth <= payload.queue_capacity &&
|
||||
payload.queue_capacity <= 256
|
||||
);
|
||||
}
|
||||
function updateSession(payload) {
|
||||
armed = payload.armed;
|
||||
els.sessionState.textContent = "UNLOCKED";
|
||||
els.captureState.textContent = armed ? "ARMED" : "DISARMED";
|
||||
els.sessionState.textContent = 'UNLOCKED';
|
||||
els.captureState.textContent = armed ? 'ARMED' : 'DISARMED';
|
||||
els.queueState.textContent = `Queue ${payload.queue_depth} / ${payload.queue_capacity}`;
|
||||
if (!activeCapture) els.reviewState.textContent = "READY";
|
||||
if (!activeCapture) els.reviewState.textContent = 'READY';
|
||||
syncControls();
|
||||
}
|
||||
function safeJson(response) { return response.json().catch(() => null); }
|
||||
function safeJson(response) {
|
||||
return response.json().catch(() => null);
|
||||
}
|
||||
async function request(path, options = {}, mutation = false) {
|
||||
const headers = new Headers(options.headers || {});
|
||||
if (mutation) { headers.set("Content-Type", "application/json"); headers.set("X-CSRF-Token", csrfToken || ""); }
|
||||
if (mutation) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
headers.set('X-CSRF-Token', csrfToken || '');
|
||||
}
|
||||
let response;
|
||||
try { response = await fetch(path, { ...options, headers, credentials: "same-origin" }); } catch { notice("error", "NETWORK ERROR", "The local service did not respond. Your current review remains unchanged."); return null; }
|
||||
if (response.status === 401 || response.status === 403) { lock("Local session expired or restarted. Open the provided local address again."); notice("error", "SESSION LOCKED", "The local session is no longer available. No action was retried."); return null; }
|
||||
try {
|
||||
response = await fetch(path, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
} catch {
|
||||
notice(
|
||||
'error',
|
||||
'NETWORK ERROR',
|
||||
'The local service did not respond. Your current review remains unchanged.',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
lock(
|
||||
'Local session expired or restarted. Open the provided local address again.',
|
||||
);
|
||||
notice(
|
||||
'error',
|
||||
'SESSION LOCKED',
|
||||
'The local session is no longer available. No action was retried.',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const payload = await safeJson(response);
|
||||
if (payload === null) { notice("error", "RESPONSE ERROR", "The local service returned an unusable response. Your current review remains unchanged."); return null; }
|
||||
if (payload === null) {
|
||||
notice(
|
||||
'error',
|
||||
'RESPONSE ERROR',
|
||||
'The local service returned an unusable response. Your current review remains unchanged.',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
const retained = response.status === 409 && payload.error === "retained";
|
||||
notice(retained ? "retained" : response.status === 409 ? "conflict" : "error", retained ? "RETAINED" : response.status === 409 ? "CONFLICT" : "ACTION FAILED", retained ? "Retention policy kept this evidence. No deletion occurred." : "The local service could not complete that action.");
|
||||
const retained = response.status === 409 && payload.error === 'retained';
|
||||
notice(
|
||||
retained ? 'retained' : response.status === 409 ? 'conflict' : 'error',
|
||||
retained
|
||||
? 'RETAINED'
|
||||
: response.status === 409
|
||||
? 'CONFLICT'
|
||||
: 'ACTION FAILED',
|
||||
retained
|
||||
? 'Retention policy kept this evidence. No deletion occurred.'
|
||||
: 'The local service could not complete that action.',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
function acceptMutationCsrf(payload) {
|
||||
if (hasValidCsrf(payload?.csrf_token)) { csrfToken = payload.csrf_token; return true; }
|
||||
lock("The local service returned an invalid session update. Open the provided local address again.");
|
||||
notice("error", "SESSION LOCKED", "The confirmed action was not retried.");
|
||||
if (hasValidCsrf(payload?.csrf_token)) {
|
||||
csrfToken = payload.csrf_token;
|
||||
return true;
|
||||
}
|
||||
lock(
|
||||
'The local service returned an invalid session update. Open the provided local address again.',
|
||||
);
|
||||
notice('error', 'SESSION LOCKED', 'The confirmed action was not retried.');
|
||||
return false;
|
||||
}
|
||||
function addChoice(container, type, name, value, checked, onChange) {
|
||||
const wrapper = document.createElement("label"); wrapper.className = "choice";
|
||||
const input = document.createElement("input"); input.type = type; input.name = name; input.value = value; input.checked = checked; input.addEventListener("change", onChange);
|
||||
const text = document.createElement("span"); text.textContent = label(value);
|
||||
wrapper.append(input, text); container.append(wrapper);
|
||||
const wrapper = document.createElement('label');
|
||||
wrapper.className = 'choice';
|
||||
const input = document.createElement('input');
|
||||
input.type = type;
|
||||
input.name = name;
|
||||
input.value = value;
|
||||
input.checked = checked;
|
||||
input.addEventListener('change', onChange);
|
||||
const text = document.createElement('span');
|
||||
text.textContent = label(value);
|
||||
wrapper.append(input, text);
|
||||
container.append(wrapper);
|
||||
}
|
||||
function setChecked(name, value) {
|
||||
document.querySelectorAll(`input[name="${name}"]`).forEach((input) => { input.checked = input.value === value; });
|
||||
document.querySelectorAll(`input[name="${name}"]`).forEach((input) => {
|
||||
input.checked = input.value === value;
|
||||
});
|
||||
}
|
||||
function setCheckedMembers(name, values) {
|
||||
const selectedValues = new Set(values);
|
||||
@@ -145,83 +376,177 @@ function setCheckedMembers(name, values) {
|
||||
}
|
||||
function renderFamilies() {
|
||||
clearNode(els.family);
|
||||
families.forEach((family) => addChoice(els.family, "radio", "family", family, false, () => { renderViews(); renderDependentFields(); }));
|
||||
families.forEach((family) =>
|
||||
addChoice(els.family, 'radio', 'family', family, false, () => {
|
||||
renderViews();
|
||||
renderDependentFields();
|
||||
}),
|
||||
);
|
||||
}
|
||||
function renderViews() {
|
||||
const family = currentFamily();
|
||||
clearNode(els.view);
|
||||
els.viewFieldset.disabled = !family;
|
||||
if (!family) return;
|
||||
taxonomy[family].views.forEach((view) => addChoice(els.view, "radio", "view", view, false, renderDependentFields));
|
||||
taxonomy[family].views.forEach((view) =>
|
||||
addChoice(els.view, 'radio', 'view', view, false, renderDependentFields),
|
||||
);
|
||||
}
|
||||
function renderDependentFields() {
|
||||
const family = currentFamily(); const view = currentView(); const unknown = family === "unknown"; const isGear = family === "gear";
|
||||
els.gearFieldset.hidden = !isGear; els.gearFieldset.disabled = !isGear;
|
||||
els.abstentionFieldset.hidden = !unknown; els.abstentionFieldset.disabled = !unknown;
|
||||
const family = currentFamily();
|
||||
const view = currentView();
|
||||
const unknown = family === 'unknown';
|
||||
const isGear = family === 'gear';
|
||||
els.gearFieldset.hidden = !isGear;
|
||||
els.gearFieldset.disabled = !isGear;
|
||||
els.abstentionFieldset.hidden = !unknown;
|
||||
els.abstentionFieldset.disabled = !unknown;
|
||||
els.recordFieldset.disabled = !family || !view || unknown;
|
||||
clearNode(els.gear); clearNode(els.records); clearNode(els.abstention);
|
||||
if (isGear) gearSlots.forEach((slot) => addChoice(els.gear, "radio", "gear_slot", slot, false, validateLabel));
|
||||
if (unknown) abstentionReasons.forEach((reason) => addChoice(els.abstention, "radio", "abstention_reason", reason, false, validateLabel));
|
||||
if (!unknown && family && view) (taxonomy[family].records[view] || []).forEach((record) => addChoice(els.records, "checkbox", "record_type", record, false, validateLabel));
|
||||
clearNode(els.gear);
|
||||
clearNode(els.records);
|
||||
clearNode(els.abstention);
|
||||
if (isGear)
|
||||
gearSlots.forEach((slot) =>
|
||||
addChoice(els.gear, 'radio', 'gear_slot', slot, false, validateLabel),
|
||||
);
|
||||
if (unknown)
|
||||
abstentionReasons.forEach((reason) =>
|
||||
addChoice(
|
||||
els.abstention,
|
||||
'radio',
|
||||
'abstention_reason',
|
||||
reason,
|
||||
false,
|
||||
validateLabel,
|
||||
),
|
||||
);
|
||||
if (!unknown && family && view)
|
||||
(taxonomy[family].records[view] || []).forEach((record) =>
|
||||
addChoice(
|
||||
els.records,
|
||||
'checkbox',
|
||||
'record_type',
|
||||
record,
|
||||
false,
|
||||
validateLabel,
|
||||
),
|
||||
);
|
||||
validateLabel();
|
||||
}
|
||||
function labelPayload() {
|
||||
const family = currentFamily(); const view = currentView(); const unknown = family === "unknown";
|
||||
return { family, view, gear_slot: family === "gear" ? selected("gear_slot") : null, record_types: unknown ? [] : selectedAll("record_type"), confidence: 1.0, abstention_reason: unknown ? selected("abstention_reason") : null };
|
||||
const family = currentFamily();
|
||||
const view = currentView();
|
||||
const unknown = family === 'unknown';
|
||||
return {
|
||||
family,
|
||||
view,
|
||||
gear_slot: family === 'gear' ? selected('gear_slot') : null,
|
||||
record_types: unknown ? [] : selectedAll('record_type'),
|
||||
confidence: 1.0,
|
||||
abstention_reason: unknown ? selected('abstention_reason') : null,
|
||||
};
|
||||
}
|
||||
function isValidLabel() {
|
||||
const payload = labelPayload();
|
||||
if (!payload.family || !payload.view) return false;
|
||||
if (payload.family === "unknown") return payload.view === "unknown" && !payload.gear_slot && payload.record_types.length === 0 && Boolean(payload.abstention_reason);
|
||||
return payload.record_types.length > 0 && (payload.family !== "gear" || Boolean(payload.gear_slot));
|
||||
if (payload.family === 'unknown')
|
||||
return (
|
||||
payload.view === 'unknown' &&
|
||||
!payload.gear_slot &&
|
||||
payload.record_types.length === 0 &&
|
||||
Boolean(payload.abstention_reason)
|
||||
);
|
||||
return (
|
||||
payload.record_types.length > 0 &&
|
||||
(payload.family !== 'gear' || Boolean(payload.gear_slot))
|
||||
);
|
||||
}
|
||||
function validateLabel() {
|
||||
const payload = labelPayload(); let message = "";
|
||||
if (!payload.family) message = "Select the evidence family.";
|
||||
else if (!payload.view) message = "Select the screen view.";
|
||||
else if (payload.family === "unknown" && !payload.abstention_reason) message = "Select an explicit abstention reason for an unknown screen.";
|
||||
else if (payload.family === "gear" && !payload.gear_slot) message = "Select the Gear slot shown by this evidence.";
|
||||
else if (payload.family !== "unknown" && payload.record_types.length === 0) message = "Select at least one compatible canonical record type.";
|
||||
const payload = labelPayload();
|
||||
let message = '';
|
||||
if (!payload.family) message = 'Select the evidence family.';
|
||||
else if (!payload.view) message = 'Select the screen view.';
|
||||
else if (payload.family === 'unknown' && !payload.abstention_reason)
|
||||
message = 'Select an explicit abstention reason for an unknown screen.';
|
||||
else if (payload.family === 'gear' && !payload.gear_slot)
|
||||
message = 'Select the Gear slot shown by this evidence.';
|
||||
else if (payload.family !== 'unknown' && payload.record_types.length === 0)
|
||||
message = 'Select at least one compatible canonical record type.';
|
||||
setText(els.validation, message);
|
||||
if (!pending) syncControls();
|
||||
return !message;
|
||||
}
|
||||
function renderLabelForm(classification) {
|
||||
renderFamilies();
|
||||
if (!classification) { renderViews(); renderDependentFields(); return; }
|
||||
setChecked("family", classification.family);
|
||||
if (!classification) {
|
||||
renderViews();
|
||||
renderDependentFields();
|
||||
return;
|
||||
}
|
||||
setChecked('family', classification.family);
|
||||
renderViews();
|
||||
setChecked("view", classification.view);
|
||||
setChecked('view', classification.view);
|
||||
renderDependentFields();
|
||||
if (classification.gear_slot) setChecked("gear_slot", classification.gear_slot);
|
||||
setCheckedMembers("record_type", classification.record_types || []);
|
||||
if (classification.abstention_reason) setChecked("abstention_reason", classification.abstention_reason);
|
||||
if (classification.gear_slot)
|
||||
setChecked('gear_slot', classification.gear_slot);
|
||||
setCheckedMembers('record_type', classification.record_types || []);
|
||||
if (classification.abstention_reason)
|
||||
setChecked('abstention_reason', classification.abstention_reason);
|
||||
validateLabel();
|
||||
}
|
||||
function renderCaptures() {
|
||||
const visible = visibleCaptures();
|
||||
clearNode(els.list); els.count.textContent = `${visible.length} shown / ${captures.length} loaded`; els.listEmpty.hidden = visible.length > 0;
|
||||
clearNode(els.list);
|
||||
els.count.textContent = `${visible.length} shown / ${captures.length} loaded`;
|
||||
els.listEmpty.hidden = visible.length > 0;
|
||||
if (!visible.length) {
|
||||
setText(els.listEmpty, captures.length ? "ALL LOADED CAPTURES HIDDEN: clear Hide reviewed captures to inspect loaded captures." : "NO LOADED CAPTURES: refresh to check the latest local queue.");
|
||||
setText(
|
||||
els.listEmpty,
|
||||
captures.length
|
||||
? 'ALL LOADED CAPTURES HIDDEN: clear Hide reviewed captures to inspect loaded captures.'
|
||||
: 'NO LOADED CAPTURES: refresh to check the latest local queue.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
visible.forEach((capture) => {
|
||||
const item = document.createElement("li"); const button = document.createElement("button"); button.type = "button";
|
||||
if (activeCapture?.capture_id === capture.capture_id) button.setAttribute("aria-current", "true");
|
||||
const reviewState = isReviewedCapture(capture) ? "REVIEWED" : "UNREVIEWED";
|
||||
const item = document.createElement('li');
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
if (activeCapture?.capture_id === capture.capture_id)
|
||||
button.setAttribute('aria-current', 'true');
|
||||
const reviewState = isReviewedCapture(capture) ? 'REVIEWED' : 'UNREVIEWED';
|
||||
const displayedTime = formatCaptureTime(capture.received_at);
|
||||
button.setAttribute("aria-label", `Capture ${capture.capture_id}. ${reviewState}. ${displayedTime}.`);
|
||||
const id = document.createElement("span"); id.className = "capture-id"; id.textContent = abbreviateCaptureId(capture.capture_id);
|
||||
const meta = document.createElement("span"); meta.className = "capture-meta";
|
||||
const review = document.createElement("span"); review.className = "capture-review"; review.textContent = reviewState;
|
||||
const time = document.createElement("span"); time.className = "capture-time"; time.textContent = displayedTime;
|
||||
button.setAttribute(
|
||||
'aria-label',
|
||||
`Capture ${capture.capture_id}. ${reviewState}. ${displayedTime}.`,
|
||||
);
|
||||
const id = document.createElement('span');
|
||||
id.className = 'capture-id';
|
||||
id.textContent = abbreviateCaptureId(capture.capture_id);
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'capture-meta';
|
||||
const review = document.createElement('span');
|
||||
review.className = 'capture-review';
|
||||
review.textContent = reviewState;
|
||||
const time = document.createElement('span');
|
||||
time.className = 'capture-time';
|
||||
time.textContent = displayedTime;
|
||||
meta.append(review, time);
|
||||
button.append(id, meta); button.addEventListener("click", () => selectCapture(capture.capture_id)); item.append(button); els.list.append(item);
|
||||
button.append(id, meta);
|
||||
button.addEventListener('click', () => selectCapture(capture.capture_id));
|
||||
item.append(button);
|
||||
els.list.append(item);
|
||||
});
|
||||
syncControls();
|
||||
}
|
||||
function focusIfAvailable(control) { if (control && !control.disabled) control.focus(); }
|
||||
function focusVisibleCaptureOrFilter() { focusIfAvailable(els.list.querySelector("button:not(:disabled)") || els.filter); }
|
||||
function focusIfAvailable(control) {
|
||||
if (control && !control.disabled) control.focus();
|
||||
}
|
||||
function focusVisibleCaptureOrFilter() {
|
||||
focusIfAvailable(
|
||||
els.list.querySelector('button:not(:disabled)') || els.filter,
|
||||
);
|
||||
}
|
||||
function applyReviewedFilter() {
|
||||
hideReviewed = els.filter.checked;
|
||||
const activeProjection = activeCaptureProjection();
|
||||
@@ -232,103 +557,295 @@ function applyReviewedFilter() {
|
||||
}
|
||||
async function refreshCaptures({ announce = true, fallbackFocus = null } = {}) {
|
||||
if (!csrfToken || pending) return false;
|
||||
setBusy(true); if (announce) notice("info", "LOADING", "Refreshing the local capture queue.");
|
||||
const payload = await request("/api/captures");
|
||||
if (!payload || !Array.isArray(payload.captures)) { setBusy(false); if (announce) focusIfAvailable(fallbackFocus); return false; }
|
||||
setBusy(true);
|
||||
if (announce)
|
||||
notice('info', 'LOADING', 'Refreshing the local capture queue.');
|
||||
const payload = await request('/api/captures');
|
||||
if (!payload || !Array.isArray(payload.captures)) {
|
||||
setBusy(false);
|
||||
if (announce) focusIfAvailable(fallbackFocus);
|
||||
return false;
|
||||
}
|
||||
captures = payload.captures;
|
||||
const activeProjection = activeCaptureProjection();
|
||||
if (activeCapture && (!activeProjection || (hideReviewed && isReviewedCapture(activeProjection)))) clearActiveDetail();
|
||||
renderCaptures(); if (announce) notice("success", "QUEUE REFRESHED", "Local capture list updated."); setBusy(false); if (announce) focusIfAvailable(fallbackFocus); return true;
|
||||
if (
|
||||
activeCapture &&
|
||||
(!activeProjection || (hideReviewed && isReviewedCapture(activeProjection)))
|
||||
)
|
||||
clearActiveDetail();
|
||||
renderCaptures();
|
||||
if (announce)
|
||||
notice('success', 'QUEUE REFRESHED', 'Local capture list updated.');
|
||||
setBusy(false);
|
||||
if (announce) focusIfAvailable(fallbackFocus);
|
||||
return true;
|
||||
}
|
||||
async function selectCapture(captureId, { announce = true } = {}) {
|
||||
if (!csrfToken || pending) return;
|
||||
setBusy(true); if (announce) notice("info", "LOADING", "Loading selected private evidence.");
|
||||
const payload = await request(`/api/captures/${encodeURIComponent(captureId)}`);
|
||||
if (!payload?.capture) { setBusy(false); return; }
|
||||
activeCapture = payload.capture; els.reviewContent.hidden = false; els.reviewEmpty.hidden = true; els.reviewTitle.textContent = activeCapture.capture_id; els.reviewState.textContent = activeCapture.state;
|
||||
setBusy(true);
|
||||
if (announce) notice('info', 'LOADING', 'Loading selected private evidence.');
|
||||
const payload = await request(
|
||||
`/api/captures/${encodeURIComponent(captureId)}`,
|
||||
);
|
||||
if (!payload?.capture) {
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
activeCapture = payload.capture;
|
||||
els.reviewContent.hidden = false;
|
||||
els.reviewEmpty.hidden = true;
|
||||
els.reviewTitle.textContent = activeCapture.capture_id;
|
||||
els.reviewState.textContent = activeCapture.state;
|
||||
els.meta.textContent = `State: ${activeCapture.state} · Received: ${activeCapture.received_at}`;
|
||||
els.media.removeAttribute("src"); els.media.src = `/api/captures/${encodeURIComponent(activeCapture.capture_id)}/media`;
|
||||
renderCaptures(); renderLabelForm(activeCapture.classification); if (announce) notice("success", "CAPTURE READY", "Review the evidence, then save a manual label or choose a separate evidence action."); setBusy(false);
|
||||
els.media.removeAttribute('src');
|
||||
els.media.src = `/api/captures/${encodeURIComponent(activeCapture.capture_id)}/media`;
|
||||
renderCaptures();
|
||||
renderLabelForm(activeCapture.classification);
|
||||
if (announce)
|
||||
notice(
|
||||
'success',
|
||||
'CAPTURE READY',
|
||||
'Review the evidence, then save a manual label or choose a separate evidence action.',
|
||||
);
|
||||
setBusy(false);
|
||||
}
|
||||
function routeForAction(action) {
|
||||
if (action === "start") return ["/api/session/start", "START"];
|
||||
if (action === "stop") return ["/api/session/stop", "STOP"];
|
||||
if ((action === "reject" || action === "delete") && activeCapture) return [`/api/captures/${encodeURIComponent(activeCapture.capture_id)}/${action}`, action === "reject" ? "REJECT" : "DELETE"];
|
||||
if (action === 'start') return ['/api/session/start', 'START'];
|
||||
if (action === 'stop') return ['/api/session/stop', 'STOP'];
|
||||
if ((action === 'reject' || action === 'delete') && activeCapture)
|
||||
return [
|
||||
`/api/captures/${encodeURIComponent(activeCapture.capture_id)}/${action}`,
|
||||
action === 'reject' ? 'REJECT' : 'DELETE',
|
||||
];
|
||||
return null;
|
||||
}
|
||||
function showConfirmation(action, source) {
|
||||
if ((action === "reject" || action === "delete") && !activeCapture) { notice("error", "ACTION UNAVAILABLE", "Select a capture before confirming this evidence action."); return; }
|
||||
const messages = { start: ["START SESSION", "Start capture now? This is the only action that can arm capture.", "Start Session"], stop: ["STOP SESSION", "Stop capture now? New screenshots will not enter the queue after the session stops.", "Stop Session"], reject: ["REJECT EVIDENCE", "Reject this evidence? The evidence remains retained for review and will not be deleted.", "Reject evidence"], delete: ["DELETE CAPTURE", "Request deletion of this capture? Retention policy may refuse deletion. Rejection does not delete evidence.", "Delete capture"] };
|
||||
const message = messages[action]; if (!message) return;
|
||||
returnFocus = source; els.dialog.returnValue = "cancel"; els.dialog.dataset.action = action;
|
||||
els.dialogKicker.textContent = "EXPLICIT CONFIRMATION"; els.dialogTitle.textContent = message[0]; els.dialogCopy.textContent = message[1]; els.dialogSubmit.textContent = message[2]; els.dialogSubmit.className = action === "delete" || action === "reject" ? "button button-danger" : "button button-primary";
|
||||
els.dialog.showModal(); els.dialogSubmit.focus();
|
||||
if ((action === 'reject' || action === 'delete') && !activeCapture) {
|
||||
notice(
|
||||
'error',
|
||||
'ACTION UNAVAILABLE',
|
||||
'Select a capture before confirming this evidence action.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const messages = {
|
||||
start: [
|
||||
'START SESSION',
|
||||
'Start capture now? This is the only action that can arm capture.',
|
||||
'Start Session',
|
||||
],
|
||||
stop: [
|
||||
'STOP SESSION',
|
||||
'Stop capture now? New screenshots will not enter the queue after the session stops.',
|
||||
'Stop Session',
|
||||
],
|
||||
reject: [
|
||||
'REJECT EVIDENCE',
|
||||
'Reject this evidence? The evidence remains retained for review and will not be deleted.',
|
||||
'Reject evidence',
|
||||
],
|
||||
delete: [
|
||||
'DELETE CAPTURE',
|
||||
'Request deletion of this capture? Retention policy may refuse deletion. Rejection does not delete evidence.',
|
||||
'Delete capture',
|
||||
],
|
||||
};
|
||||
const message = messages[action];
|
||||
if (!message) return;
|
||||
returnFocus = source;
|
||||
els.dialog.returnValue = 'cancel';
|
||||
els.dialog.dataset.action = action;
|
||||
els.dialogKicker.textContent = 'EXPLICIT CONFIRMATION';
|
||||
els.dialogTitle.textContent = message[0];
|
||||
els.dialogCopy.textContent = message[1];
|
||||
els.dialogSubmit.textContent = message[2];
|
||||
els.dialogSubmit.className =
|
||||
action === 'delete' || action === 'reject'
|
||||
? 'button button-danger'
|
||||
: 'button button-primary';
|
||||
els.dialog.showModal();
|
||||
els.dialogSubmit.focus();
|
||||
}
|
||||
async function confirmed(action, fallbackFocus) {
|
||||
if (pending) return;
|
||||
const route = routeForAction(action);
|
||||
if (!route) { notice("error", "ACTION UNAVAILABLE", "Select a capture before confirming this evidence action."); fallbackFocus?.focus(); return; }
|
||||
setBusy(true); notice("info", "PENDING", "The local service is processing your confirmed action.");
|
||||
const [path, confirmation] = route; const payload = await request(path, { method: "POST", body: JSON.stringify({ confirmation }) }, true);
|
||||
if (!payload || !acceptMutationCsrf(payload)) { setBusy(false); fallbackFocus?.focus(); return; }
|
||||
if ((action === "start" || action === "stop") && !validSession(payload)) { lock("The local service returned an invalid session update. Open the provided local address again."); setBusy(false); return; }
|
||||
if (action === "delete") clearActiveDetail();
|
||||
if (action === "start" || action === "stop") updateSession(payload);
|
||||
const successFocus = action === "start" ? els.stop : action === "stop" ? els.start : action === "delete" ? els.refresh : fallbackFocus;
|
||||
setBusy(false); if (!(await refreshCaptures({ announce: false }))) { focusIfAvailable(successFocus); return; }
|
||||
notice("success", "SUCCESS", action === "reject" ? "Evidence rejected and retained." : action === "delete" ? "Deletion request completed." : `Capture session ${action === "start" ? "armed" : "disarmed"}.`);
|
||||
if (!route) {
|
||||
notice(
|
||||
'error',
|
||||
'ACTION UNAVAILABLE',
|
||||
'Select a capture before confirming this evidence action.',
|
||||
);
|
||||
fallbackFocus?.focus();
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
notice(
|
||||
'info',
|
||||
'PENDING',
|
||||
'The local service is processing your confirmed action.',
|
||||
);
|
||||
const [path, confirmation] = route;
|
||||
const payload = await request(
|
||||
path,
|
||||
{ method: 'POST', body: JSON.stringify({ confirmation }) },
|
||||
true,
|
||||
);
|
||||
if (!payload || !acceptMutationCsrf(payload)) {
|
||||
setBusy(false);
|
||||
fallbackFocus?.focus();
|
||||
return;
|
||||
}
|
||||
if ((action === 'start' || action === 'stop') && !validSession(payload)) {
|
||||
lock(
|
||||
'The local service returned an invalid session update. Open the provided local address again.',
|
||||
);
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
if (action === 'delete') clearActiveDetail();
|
||||
if (action === 'start' || action === 'stop') updateSession(payload);
|
||||
const successFocus =
|
||||
action === 'start'
|
||||
? els.stop
|
||||
: action === 'stop'
|
||||
? els.start
|
||||
: action === 'delete'
|
||||
? els.refresh
|
||||
: fallbackFocus;
|
||||
setBusy(false);
|
||||
if (!(await refreshCaptures({ announce: false }))) {
|
||||
focusIfAvailable(successFocus);
|
||||
return;
|
||||
}
|
||||
notice(
|
||||
'success',
|
||||
'SUCCESS',
|
||||
action === 'reject'
|
||||
? 'Evidence rejected and retained.'
|
||||
: action === 'delete'
|
||||
? 'Deletion request completed.'
|
||||
: `Capture session ${action === 'start' ? 'armed' : 'disarmed'}.`,
|
||||
);
|
||||
focusIfAvailable(successFocus);
|
||||
}
|
||||
async function initialize() {
|
||||
els.filter.checked = false; hideReviewed = false;
|
||||
els.filter.checked = false;
|
||||
hideReviewed = false;
|
||||
renderLabelForm(null);
|
||||
let fragment = window.location.hash; let token = null;
|
||||
if (fragment.length > 1 && fragment.length <= 256) { const params = new URLSearchParams(fragment.slice(1)); const candidate = params.get("bootstrap"); if (candidate && csrfPattern.test(candidate)) token = candidate; }
|
||||
if (fragment) history.replaceState(null, "", `${window.location.pathname}${window.location.search}`);
|
||||
fragment = "";
|
||||
let fragment = window.location.hash;
|
||||
let token = null;
|
||||
if (fragment.length > 1 && fragment.length <= 256) {
|
||||
const params = new URLSearchParams(fragment.slice(1));
|
||||
const candidate = params.get('bootstrap');
|
||||
if (candidate && csrfPattern.test(candidate)) token = candidate;
|
||||
}
|
||||
if (fragment)
|
||||
history.replaceState(
|
||||
null,
|
||||
'',
|
||||
`${window.location.pathname}${window.location.search}`,
|
||||
);
|
||||
fragment = '';
|
||||
if (token) {
|
||||
const bootstrap = await request("/api/bootstrap", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token }) }); token = null;
|
||||
if (!hasValidCsrf(bootstrap?.csrf_token)) { lock("The local service could not establish a safe session. Open the provided local address again."); return; }
|
||||
const bootstrap = await request('/api/bootstrap', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
token = null;
|
||||
if (!hasValidCsrf(bootstrap?.csrf_token)) {
|
||||
lock(
|
||||
'The local service could not establish a safe session. Open the provided local address again.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
csrfToken = bootstrap.csrf_token;
|
||||
}
|
||||
const session = await request("/api/session");
|
||||
if (!validSession(session)) { lock("LOCKED: start the local service with its provided bootstrap address. This browser has no process-local session."); return; }
|
||||
csrfToken = session.csrf_token; updateSession(session); if (!(await refreshCaptures({ announce: false }))) return;
|
||||
const startupCopy = session.armed ? "Capture is ARMED. Stop Session requires explicit confirmation." : "Capture is DISARMED. Start Session requires explicit confirmation.";
|
||||
notice("success", "LOCAL SESSION READY", startupCopy);
|
||||
const session = await request('/api/session');
|
||||
if (!validSession(session)) {
|
||||
lock(
|
||||
'LOCKED: start the local service with its provided bootstrap address. This browser has no process-local session.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
csrfToken = session.csrf_token;
|
||||
updateSession(session);
|
||||
if (!(await refreshCaptures({ announce: false }))) return;
|
||||
const startupCopy = session.armed
|
||||
? 'Capture is ARMED. Stop Session requires explicit confirmation.'
|
||||
: 'Capture is DISARMED. Start Session requires explicit confirmation.';
|
||||
notice('success', 'LOCAL SESSION READY', startupCopy);
|
||||
}
|
||||
|
||||
els.start.addEventListener("click", () => showConfirmation("start", els.start));
|
||||
els.stop.addEventListener("click", () => showConfirmation("stop", els.stop));
|
||||
els.refresh.addEventListener("click", () => refreshCaptures({ fallbackFocus: els.refresh }));
|
||||
els.labelForm.addEventListener("submit", async (event) => {
|
||||
els.start.addEventListener('click', () => showConfirmation('start', els.start));
|
||||
els.stop.addEventListener('click', () => showConfirmation('stop', els.stop));
|
||||
els.refresh.addEventListener('click', () =>
|
||||
refreshCaptures({ fallbackFocus: els.refresh }),
|
||||
);
|
||||
els.labelForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
if (!activeCapture || !validateLabel() || pending) return;
|
||||
const captureId = activeCapture.capture_id;
|
||||
setBusy(true); notice("info", "PENDING", "Saving the manual reviewed label.");
|
||||
const payload = await request(`/api/captures/${encodeURIComponent(captureId)}/label`, { method: "POST", body: JSON.stringify(labelPayload()) }, true);
|
||||
if (!payload || !acceptMutationCsrf(payload)) { setBusy(false); return; }
|
||||
setBusy(true);
|
||||
notice('info', 'PENDING', 'Saving the manual reviewed label.');
|
||||
const payload = await request(
|
||||
`/api/captures/${encodeURIComponent(captureId)}/label`,
|
||||
{ method: 'POST', body: JSON.stringify(labelPayload()) },
|
||||
true,
|
||||
);
|
||||
if (!payload || !acceptMutationCsrf(payload)) {
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
setBusy(false);
|
||||
if (!(await refreshCaptures({ announce: false }))) { focusIfAvailable(els.save); return; }
|
||||
const savedCapture = captures.find((capture) => capture.capture_id === captureId);
|
||||
const savedCaptureIsHidden = Boolean(savedCapture && hideReviewed && isReviewedCapture(savedCapture));
|
||||
if (savedCapture && !savedCaptureIsHidden) await selectCapture(captureId, { announce: false });
|
||||
else { clearActiveDetail(); renderCaptures(); }
|
||||
notice("success", "LABEL SAVED", "Manual reviewed label saved with confidence 1.0.");
|
||||
if (!(await refreshCaptures({ announce: false }))) {
|
||||
focusIfAvailable(els.save);
|
||||
return;
|
||||
}
|
||||
const savedCapture = captures.find(
|
||||
(capture) => capture.capture_id === captureId,
|
||||
);
|
||||
const savedCaptureIsHidden = Boolean(
|
||||
savedCapture && hideReviewed && isReviewedCapture(savedCapture),
|
||||
);
|
||||
if (savedCapture && !savedCaptureIsHidden)
|
||||
await selectCapture(captureId, { announce: false });
|
||||
else {
|
||||
clearActiveDetail();
|
||||
renderCaptures();
|
||||
}
|
||||
notice(
|
||||
'success',
|
||||
'LABEL SAVED',
|
||||
'Manual reviewed label saved with confidence 1.0.',
|
||||
);
|
||||
if (savedCaptureIsHidden) focusVisibleCaptureOrFilter();
|
||||
else focusIfAvailable(els.save);
|
||||
});
|
||||
els.filter.addEventListener("change", applyReviewedFilter);
|
||||
els.reject.addEventListener("click", () => showConfirmation("reject", els.reject));
|
||||
els.remove.addEventListener("click", () => showConfirmation("delete", els.remove));
|
||||
els.dialog.addEventListener("close", () => {
|
||||
const action = els.dialog.dataset.action; const shouldConfirm = els.dialog.returnValue === "confirm";
|
||||
els.dialog.dataset.action = "";
|
||||
els.filter.addEventListener('change', applyReviewedFilter);
|
||||
els.reject.addEventListener('click', () =>
|
||||
showConfirmation('reject', els.reject),
|
||||
);
|
||||
els.remove.addEventListener('click', () =>
|
||||
showConfirmation('delete', els.remove),
|
||||
);
|
||||
els.dialog.addEventListener('close', () => {
|
||||
const action = els.dialog.dataset.action;
|
||||
const shouldConfirm = els.dialog.returnValue === 'confirm';
|
||||
els.dialog.dataset.action = '';
|
||||
const focusTarget = returnFocus;
|
||||
if (focusTarget) focusTarget.focus();
|
||||
returnFocus = null;
|
||||
if (shouldConfirm && action) confirmed(action, focusTarget);
|
||||
});
|
||||
els.dialog.addEventListener("cancel", () => { els.dialog.returnValue = "cancel"; });
|
||||
els.media.addEventListener("error", () => notice("error", "MEDIA UNAVAILABLE", "The private evidence image could not be loaded. Refresh captures before retrying."));
|
||||
els.dialog.addEventListener('cancel', () => {
|
||||
els.dialog.returnValue = 'cancel';
|
||||
});
|
||||
els.media.addEventListener('error', () =>
|
||||
notice(
|
||||
'error',
|
||||
'MEDIA UNAVAILABLE',
|
||||
'The private evidence image could not be loaded. Refresh captures before retrying.',
|
||||
),
|
||||
);
|
||||
initialize();
|
||||
|
||||
@@ -33,29 +33,46 @@
|
||||
<p id="capture-state">DISARMED</p>
|
||||
</div>
|
||||
<div class="control-actions">
|
||||
<button class="button button-primary" id="start-button" type="button" disabled>Start Session</button>
|
||||
<button class="button" id="stop-button" type="button" disabled>Stop Session</button>
|
||||
<button class="button" id="refresh-button" type="button" disabled>Refresh captures</button>
|
||||
<button
|
||||
class="button button-primary"
|
||||
id="start-button"
|
||||
type="button"
|
||||
disabled
|
||||
>
|
||||
Start Session
|
||||
</button>
|
||||
<button class="button" id="stop-button" type="button" disabled>
|
||||
Stop Session
|
||||
</button>
|
||||
<button class="button" id="refresh-button" type="button" disabled>
|
||||
Refresh captures
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="workspace-grid" aria-label="Capture review workspace">
|
||||
<aside class="capture-panel" aria-labelledby="capture-list-title">
|
||||
<div class="panel-heading">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="kicker">PRIVATE QUEUE</p>
|
||||
<h2 id="capture-list-title">Captures</h2>
|
||||
</div>
|
||||
<span id="capture-count">0 shown / 0 loaded</span>
|
||||
</div>
|
||||
<label class="queue-filter" for="hide-reviewed">
|
||||
<input id="hide-reviewed" type="checkbox" />
|
||||
<span>Hide reviewed captures</span>
|
||||
</label>
|
||||
<div class="capture-scroll" id="capture-scroll">
|
||||
<p class="empty-copy" id="capture-empty">Authenticate to inspect the local capture queue.</p>
|
||||
<ol class="capture-list" id="capture-list" aria-label="Available captures"></ol>
|
||||
</div>
|
||||
<span id="capture-count">0 shown / 0 loaded</span>
|
||||
</div>
|
||||
<label class="queue-filter" for="hide-reviewed">
|
||||
<input id="hide-reviewed" type="checkbox" />
|
||||
<span>Hide reviewed captures</span>
|
||||
</label>
|
||||
<div class="capture-scroll" id="capture-scroll">
|
||||
<p class="empty-copy" id="capture-empty">
|
||||
Authenticate to inspect the local capture queue.
|
||||
</p>
|
||||
<ol
|
||||
class="capture-list"
|
||||
id="capture-list"
|
||||
aria-label="Available captures"
|
||||
></ol>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="review-panel" aria-labelledby="review-title">
|
||||
@@ -68,7 +85,10 @@
|
||||
</div>
|
||||
|
||||
<div class="review-empty" id="review-empty">
|
||||
<p>Select a capture after the local session is unlocked. Media remains private to this browser session.</p>
|
||||
<p>
|
||||
Select a capture after the local session is unlocked. Media
|
||||
remains private to this browser session.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="review-content" id="review-content" hidden>
|
||||
@@ -95,7 +115,9 @@
|
||||
|
||||
<fieldset id="record-types-fieldset" disabled>
|
||||
<legend>Canonical records evidenced</legend>
|
||||
<p class="field-help" id="record-types-help">Choose every record type shown by this screen.</p>
|
||||
<p class="field-help" id="record-types-help">
|
||||
Choose every record type shown by this screen.
|
||||
</p>
|
||||
<div class="choice-grid" id="record-type-options"></div>
|
||||
</fieldset>
|
||||
|
||||
@@ -104,27 +126,73 @@
|
||||
<div class="choice-grid" id="abstention-options"></div>
|
||||
</fieldset>
|
||||
|
||||
<p class="validation-message" id="label-validation" role="status"></p>
|
||||
<p
|
||||
class="validation-message"
|
||||
id="label-validation"
|
||||
role="status"
|
||||
></p>
|
||||
<div class="form-actions">
|
||||
<button class="button button-primary" id="save-label-button" type="submit" disabled>Save reviewed label</button>
|
||||
<button class="button button-danger" id="reject-button" type="button" disabled>Reject evidence</button>
|
||||
<button class="button button-danger quiet-danger" id="delete-button" type="button" disabled>Delete capture</button>
|
||||
<button
|
||||
class="button button-primary"
|
||||
id="save-label-button"
|
||||
type="submit"
|
||||
disabled
|
||||
>
|
||||
Save reviewed label
|
||||
</button>
|
||||
<button
|
||||
class="button button-danger"
|
||||
id="reject-button"
|
||||
type="button"
|
||||
disabled
|
||||
>
|
||||
Reject evidence
|
||||
</button>
|
||||
<button
|
||||
class="button button-danger quiet-danger"
|
||||
id="delete-button"
|
||||
type="button"
|
||||
disabled
|
||||
>
|
||||
Delete capture
|
||||
</button>
|
||||
</div>
|
||||
<p class="action-help">Rejecting retains evidence. Deletion requires separate confirmation and may be retained by policy.</p>
|
||||
<p class="action-help">
|
||||
Rejecting retains evidence. Deletion requires separate
|
||||
confirmation and may be retained by policy.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<dialog class="confirmation-dialog" id="confirmation-dialog" aria-labelledby="confirmation-title">
|
||||
<dialog
|
||||
class="confirmation-dialog"
|
||||
id="confirmation-dialog"
|
||||
aria-labelledby="confirmation-title"
|
||||
>
|
||||
<form method="dialog" id="confirmation-form">
|
||||
<p class="kicker" id="confirmation-kicker">CONFIRMATION REQUIRED</p>
|
||||
<h2 id="confirmation-title">Confirm action</h2>
|
||||
<p id="confirmation-copy"></p>
|
||||
<div class="dialog-actions">
|
||||
<button class="button" id="confirmation-cancel" value="cancel" type="submit">Cancel</button>
|
||||
<button class="button button-primary" id="confirmation-submit" value="confirm" type="submit">Confirm</button>
|
||||
<button
|
||||
class="button"
|
||||
id="confirmation-cancel"
|
||||
value="cancel"
|
||||
type="submit"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="button button-primary"
|
||||
id="confirmation-submit"
|
||||
value="confirm"
|
||||
type="submit"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
Reference in New Issue
Block a user