Quiiz system
This commit is contained in:
@@ -286,3 +286,18 @@ dp-upload[hidden] {
|
||||
.dp-toast-error {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
emoji-picker {
|
||||
color-scheme: dark;
|
||||
--background: var(--bg-modal);
|
||||
--border-color: var(--border);
|
||||
--border-radius: var(--radius);
|
||||
--button-active-background: var(--bg-card-hover);
|
||||
--button-hover-background: var(--bg-card-hover);
|
||||
--category-font-color: var(--text-secondary);
|
||||
--indicator-color: var(--accent);
|
||||
--input-border-color: var(--border);
|
||||
--input-font-color: var(--text-primary);
|
||||
--input-placeholder-color: var(--text-muted);
|
||||
--outline-color: var(--accent);
|
||||
}
|
||||
|
||||
@@ -132,6 +132,49 @@
|
||||
background: var(--bg-card-hover);
|
||||
}
|
||||
|
||||
.reaction-palette-more {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.reaction-palette-more:hover {
|
||||
background: var(--bg-card-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.reaction-picker {
|
||||
position: absolute;
|
||||
bottom: calc(100% + var(--space-xs));
|
||||
left: 0;
|
||||
z-index: var(--z-popover);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.reaction-picker.below {
|
||||
top: calc(100% + var(--space-xs));
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
.reaction-picker[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.reaction-picker emoji-picker {
|
||||
display: block;
|
||||
width: 320px;
|
||||
height: 350px;
|
||||
max-width: calc(100vw - 2 * var(--space-md));
|
||||
}
|
||||
|
||||
.bookmark-btn.bookmarked {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
.feed-layout {
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-width) 1fr 280px;
|
||||
grid-template-columns: var(--sidebar-width) minmax(0, 1fr) 280px;
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
@@ -386,7 +386,7 @@
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.feed-layout {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.feed-right {
|
||||
display: none;
|
||||
@@ -633,3 +633,8 @@
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.post-card-comments .rendered-content pre code {
|
||||
max-height: 14rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -83,6 +83,10 @@
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.game-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.game-section-title {
|
||||
font-size: 1.1rem;
|
||||
margin: 0 0 var(--space-md);
|
||||
@@ -532,3 +536,25 @@
|
||||
color: var(--warning);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.game-error {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--danger);
|
||||
border-left: 3px solid var(--danger);
|
||||
border-radius: var(--radius);
|
||||
color: var(--danger);
|
||||
margin: 0 0 var(--space-md);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
}
|
||||
|
||||
.shop-actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.plot-raided {
|
||||
color: var(--warning);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,813 @@
|
||||
/* retoor <retoor@molodetz.nl> */
|
||||
|
||||
.quiz-page-title {
|
||||
margin: 0 0 var(--space-lg);
|
||||
font-size: 1.6rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.quiz-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-lg);
|
||||
margin-bottom: var(--space-lg);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.quiz-login-hint {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.quiz-filter-count {
|
||||
margin-left: auto;
|
||||
padding: 0 var(--space-sm);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.quiz-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
min-width: 0;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.quiz-card-byline {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--space-xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.quiz-state-badge {
|
||||
margin-left: auto;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quiz-state-todo {
|
||||
background: var(--overlay-light);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.quiz-state-progress {
|
||||
background: rgba(var(--accent-rgb), 0.16);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.quiz-state-done {
|
||||
background: rgba(76, 175, 80, 0.16);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.quiz-card-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
margin: var(--space-md) 0;
|
||||
}
|
||||
|
||||
.quiz-meta-chip {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
background: var(--overlay-light);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.quiz-card-cta {
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.quiz-scoreboard-card,
|
||||
.quiz-progress-card {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.quiz-scoreboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.quiz-scoreboard-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
min-width: 0;
|
||||
padding: var(--space-xs) 0;
|
||||
}
|
||||
|
||||
.quiz-scoreboard-self {
|
||||
background: rgba(var(--accent-rgb), 0.1);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.quiz-rank {
|
||||
min-width: 1.6rem;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.quiz-scoreboard-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quiz-scoreboard-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
margin-left: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.quiz-scoreboard-sub {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quiz-score-chip {
|
||||
color: var(--accent);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quiz-scoreboard-empty {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.quiz-scoreboard-note {
|
||||
margin: var(--space-md) 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.quiz-standing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-md);
|
||||
padding-top: var(--space-md);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.quiz-progress-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.quiz-progress-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
padding: var(--space-sm);
|
||||
background: var(--overlay-light);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.quiz-progress-value {
|
||||
color: var(--text-primary);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.quiz-progress-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.quiz-detail-page,
|
||||
.quiz-form-page,
|
||||
.quiz-builder-page,
|
||||
.quiz-play-page,
|
||||
.quiz-results-page {
|
||||
max-width: 820px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.quiz-detail {
|
||||
padding: var(--space-xl);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.quiz-detail-title {
|
||||
margin: 0 0 var(--space-md);
|
||||
font-size: 1.7rem;
|
||||
}
|
||||
|
||||
.quiz-detail-author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.quiz-detail-desc {
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.quiz-stats-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.quiz-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
padding: var(--space-md);
|
||||
background: var(--overlay-light);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.quiz-stat-value {
|
||||
color: var(--text-primary);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.quiz-stat-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.quiz-detail-actions,
|
||||
.quiz-form-actions,
|
||||
.quiz-play-actions,
|
||||
.quiz-results-actions,
|
||||
.quiz-question-actions,
|
||||
.quiz-answer-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.quiz-banner {
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
margin-bottom: var(--space-lg);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.quiz-banner-draft {
|
||||
background: rgba(255, 152, 0, 0.14);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.quiz-banner-locked {
|
||||
background: var(--overlay-light);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.quiz-banner-error {
|
||||
background: rgba(229, 57, 53, 0.14);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.quiz-banner-done {
|
||||
background: rgba(76, 175, 80, 0.14);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.quiz-leaderboard {
|
||||
margin-top: var(--space-xl);
|
||||
}
|
||||
|
||||
.quiz-leaderboard-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.quiz-leaderboard-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
min-width: 0;
|
||||
padding: var(--space-sm);
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.quiz-leaderboard-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quiz-pass-badge {
|
||||
margin-left: auto;
|
||||
color: var(--success);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.quiz-form,
|
||||
.quiz-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.quiz-settings {
|
||||
padding: var(--space-lg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.quiz-settings legend {
|
||||
padding: 0 var(--space-sm);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.quiz-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.quiz-field-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.quiz-field input,
|
||||
.quiz-field textarea,
|
||||
.quiz-field select,
|
||||
.quiz-matching select,
|
||||
.quiz-ordering select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-input);
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.quiz-field textarea {
|
||||
min-height: 88px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.quiz-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
min-height: 44px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.quiz-form-intro {
|
||||
margin-bottom: var(--space-lg);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.quiz-builder-settings,
|
||||
.quiz-builder-add,
|
||||
.quiz-builder-list,
|
||||
.quiz-publish {
|
||||
margin-bottom: var(--space-2xl);
|
||||
}
|
||||
|
||||
.quiz-kind-fields[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.quiz-question-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.quiz-builder-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.quiz-question {
|
||||
padding: var(--space-lg);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.quiz-question-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-md);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.quiz-question-index {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
background: rgba(var(--accent-rgb), 0.16);
|
||||
border-radius: 50%;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.quiz-question-points {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.quiz-question-prompt {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.quiz-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.quiz-options-boolean {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.quiz-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
min-height: 44px;
|
||||
min-width: 0;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.quiz-option:hover {
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
.quiz-option-large {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
min-height: 64px;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.quiz-option-label {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.quiz-blanks,
|
||||
.quiz-matching {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.quiz-matching-row,
|
||||
.quiz-ordering-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.quiz-matching-left {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.quiz-matching-row select,
|
||||
.quiz-ordering-row select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.quiz-ordering {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
margin: 0 0 var(--space-lg);
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.quiz-ordering-position {
|
||||
min-width: 1.6rem;
|
||||
color: var(--text-muted);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.quiz-answer-result {
|
||||
margin-top: var(--space-lg);
|
||||
}
|
||||
|
||||
.quiz-grade {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quiz-grade-correct {
|
||||
background: rgba(76, 175, 80, 0.14);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.quiz-grade-wrong {
|
||||
background: rgba(229, 57, 53, 0.14);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.quiz-grade-points {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.quiz-grade-feedback {
|
||||
margin: var(--space-sm) 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.quiz-grade-fallback {
|
||||
margin: var(--space-sm) 0 0;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: rgba(255, 152, 0, 0.12);
|
||||
border-radius: var(--radius);
|
||||
color: var(--warning);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.quiz-explanation {
|
||||
margin-top: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
background: var(--overlay-light);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.quiz-preview-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
margin: 0 0 var(--space-md);
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.quiz-preview-option {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
min-width: 0;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
background: var(--overlay-light);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.quiz-preview-correct {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.quiz-preview-chosen {
|
||||
outline: 1px solid var(--accent);
|
||||
}
|
||||
|
||||
.quiz-preview-match,
|
||||
.quiz-preview-answer {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.quiz-checklist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
margin: 0 0 var(--space-md);
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.quiz-checklist-item {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: rgba(255, 152, 0, 0.12);
|
||||
border-radius: var(--radius);
|
||||
color: var(--warning);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.quiz-publish-ready {
|
||||
margin-bottom: var(--space-md);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.quiz-play-head {
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.quiz-hud {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-md);
|
||||
}
|
||||
|
||||
.quiz-hud-item {
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.82rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.quiz-hud-timer {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.quiz-slides {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.quiz-question-answered .quiz-answer-form {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.quiz-score-card {
|
||||
padding: var(--space-xl);
|
||||
margin-bottom: var(--space-xl);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.quiz-score-headline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.quiz-score-percent {
|
||||
color: var(--accent);
|
||||
font-size: 2.6rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.quiz-score-points {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.quiz-verdict {
|
||||
display: inline-block;
|
||||
margin-top: var(--space-md);
|
||||
padding: var(--space-xs) var(--space-lg);
|
||||
border-radius: var(--radius);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quiz-verdict-pass {
|
||||
background: rgba(76, 175, 80, 0.16);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.quiz-verdict-fail {
|
||||
background: rgba(229, 57, 53, 0.16);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.quiz-score-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: var(--space-md);
|
||||
margin-top: var(--space-md);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.quiz-review {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.quiz-review-item {
|
||||
padding: var(--space-lg);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--border-light);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.quiz-review-item.quiz-review-correct {
|
||||
border-left-color: var(--success);
|
||||
}
|
||||
|
||||
.quiz-review-item.quiz-review-wrong {
|
||||
border-left-color: var(--danger);
|
||||
}
|
||||
|
||||
.quiz-review-answer {
|
||||
margin: 0 0 var(--space-md);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.quiz-review-label {
|
||||
margin-right: var(--space-sm);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.quiz-review-disabled {
|
||||
margin-bottom: var(--space-xl);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.quiz-detail-page,
|
||||
.quiz-form-page,
|
||||
.quiz-builder-page,
|
||||
.quiz-play-page,
|
||||
.quiz-results-page {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.quiz-options-boolean {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.quiz-matching-row,
|
||||
.quiz-ordering-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.quiz-progress-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.quiz-state-badge {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.quiz-card-head {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,11 @@ This file documents JS module organization, custom web components, and shared fr
|
||||
## Custom web components (`static/js/components/`)
|
||||
|
||||
Self-contained, presentational UI is built as custom elements with the `dp-` prefix:
|
||||
`dp-avatar`, `dp-code`, `dp-content`, `dp-title`, `dp-toast`, `dp-dialog`, `dp-context-menu`, `dp-upload`, `dp-lightbox`, `dp-chat`.
|
||||
`dp-avatar`, `dp-code`, `dp-content`, `dp-title`, `dp-toast`, `dp-dialog`, `dp-context-menu`, `dp-upload`, `dp-lightbox`, `dp-chat`, `dp-quiz-player`, `dp-quiz-builder`.
|
||||
|
||||
`dp-quiz-player` (`AppQuizPlayer.js`) and `dp-quiz-builder` (`AppQuizBuilder.js`) both **adopt** server-rendered markup rather than building it (the `dp-chat` `mode="page"` pattern): the player intercepts each question's real `<form method="post">` through `Http.sendForm`, renders the graded result in place, shows a `Checking…` state while a `free_text` grade is in flight, ticks the remaining time from `Format.duration`, and advances to the next slide; the builder toggles the kind-specific field groups from the question-kind picker and submits the add-question form through `Http.sendForm`. Neither re-renders a prompt (the server already rendered it through `render_content`), neither opens a WebSocket, and neither implements a fetch wrapper, a poller or a dialog. Removing the JS leaves a fully working no-JS quiz.
|
||||
|
||||
**Prompt seeding on the shared Devii opener (`data-devii-prompt`).** `DeviiTerminal.bindTriggers` binds every `[data-devii-open]` element; it now reads `trigger.dataset.deviiPrompt` and passes it to `DeviiTerminal.open(prompt)`, which calls `this.element.open()` and then `devii-terminal.prefill(text)` (sets `this.input.value`, moves the caret to the end, focuses). **It never auto-sends** - the member reads the request and presses Enter, keeping the assistant's first action explicitly user-initiated. This is a platform-wide capability available to any page, not a quiz-only shim: add `data-devii-prompt="..."` beside `data-devii-open` and the terminal opens pre-filled.
|
||||
|
||||
`dp-chat` (`AppChat.js`) is the Slack-like DM chat widget that fully replaced the old page-controller trio `MessagesLayout.js`/`MessagesSocket.js`/`MessageSearch.js` (deleted) and `static/css/messages.css` (superseded by `static/css/chat.css`) on `/messages`. Unlike every other component here, it does NOT build its DOM from scratch on `connectedCallback` when server-rendered light-DOM children already exist (`mode="page"`) - it **adopts** the existing `.messages-list`/`.messages-main`/`.messages-thread`/`.message-bubble` markup `templates/messages.html` still renders (so no-JS and crawler fallback both keep working) and only builds a from-scratch skeleton when none is present (`mode="embed"`, a standalone `<dp-chat mode="embed" self-uid="..." with-uid="..." send-url="..." ws-url="...">` usable outside `/messages`). It owns its own `ChatSocket` instance, the conversation search dropdown (absorbed from the old `MessageSearch.js`, still built on the shared `ListNav` utility), consecutive-message grouping (`chat/MessageGrouping.js`), a working optimistic send with `.pending`/`.failed` (tap-to-retry) bubble states, and an opt-in (`ai-indicator` attribute) "Adjusted by AI" caption shown when a reconciled echo's `ai_processed` frame flag is true and the content changed from what was locally typed - never a diff/revert UI, the backend keeps no pre-correction copy to diff against. Presence for `mode="page"` needs no component code at all: the adopted markup keeps its `data-presence-uid` attributes, so the page-global `PresenceManager` (below) already drives it; `dp-chat` opens its own scoped `PubSubClient` subscription only in `mode="embed"`, where no page-global instance exists. The message body itself keeps rendering through `<dp-content>` (see "CLIENT-only now" below) - the redesign only removed the historical `no-copy` attribute on message bubbles so `dp-content`'s own existing `.content-copy-btn` doubles as the hover/focus-reveal action toolbar's Copy button (§6.3 of the design doc it was built from), instead of a second copy mechanism being hand-rolled.
|
||||
|
||||
@@ -42,6 +46,7 @@ Detail on each utility:
|
||||
- **`Poller` (`static/js/Poller.js`).** `new Poller(fn, intervalMs, { immediate = true, pauseHidden = false })` runs `fn` on an interval with `start()`/`stop()`/`tick()`; `tick()` swallows errors so one failed poll never kills the loop, and `pauseHidden` skips the tick while `document.hidden`. Used by every live-update loop: `CounterManager` (30s, `pauseHidden`), `ContainerManager` (3s), `ContainerList` (4s), `AiUsageMonitor`, `ServiceMonitor`, and `ContainerInstance`'s detail (4s) + logs (3s). Store the `Poller`, not a raw interval id.
|
||||
- **`JobPoller` (`static/js/JobPoller.js`).** `JobPoller.run(statusUrl, { onDone, onFailed, onTimeout, intervalMs = 1500, maxAttempts = 200 })` returns a Promise; it polls `Http.getJson(statusUrl)`, swallows transient fetch errors, and fires the matching callback on `status === "done"|"failed"` or timeout. This is the one place the async-job status-poll lives - `ProjectForker` and `ZipDownloader` both call it with their own navigate/download/toast callbacks.
|
||||
- **`OptimisticAction` (`static/js/OptimisticAction.js`).** Base with one method, `submit(url, params, errorTarget, render)`: `Http.sendForm` -> `render(result)` on success -> `console.error` + (when `errorTarget` is given) `Toast.flash(errorTarget, "Error", 1500)` on failure. `VoteManager`/`ReactionBar`/`BookmarkManager`/`PollManager` `extend` it and call `this.submit(...)` for their POST, **keeping their own event wiring** (so `ReactionBar`'s palette toggle and `PollManager`'s multi-action handlers and `VoteManager`'s per-button `stopPropagation` are untouched). Pass `errorTarget` only where the old code toasted (`VoteManager`); the others pass `null` to keep their console-only behaviour.
|
||||
- **`EmojiPickerElement` (`static/js/EmojiPickerElement.js`).** The single wrapper around the vendored `emoji-picker-element`: `EmojiPickerElement.load()` lazily imports the vendor module once (shared promise, failures swallowed) and `EmojiPickerElement.create(onSelect)` returns a configured `<emoji-picker>` (data source `static/vendor/emoji-picker-element/data.json`) that calls `onSelect(unicode)` on `emoji-click`. Both consumers use it: `EmojiPicker` (insert at cursor in a textarea) and `ReactionBar` (react with any emoji). Never build an `<emoji-picker>`, re-import the vendor module, or repeat the data-source path elsewhere.
|
||||
- **`FloatingWindow` / `WindowManager` (`static/js/components/`).** The draggable window base and shared z-order manager - documented in the Container manager section; both the container terminals and the Devii terminal extend `FloatingWindow`.
|
||||
- **`ScrollMemory` (`static/js/ScrollMemory.js`, `app.scrollMemory`).** Site-wide, per-tab scroll restoration - the fix for the "back to feed jumps to top" class of bugs. It sets `history.scrollRestoration = "manual"` once (never rely on the browser's auto-restore, which fires before late-loading content settles and never applies to normal link navigations), so ALL scroll restoration flows through this one module. State lives in `sessionStorage` (per-tab by definition, exactly the required scope): a position map keyed by exact `pathname + search` (hash ignored; saved by a throttled passive scroll listener plus a final write on `pagehide`/hidden `visibilitychange`, pruned to 50 entries / 60 min), a visited-URL trail (capped at 20), and a one-shot click-intent flag (30s validity, consumed on every load).
|
||||
**When it restores** - only when the situation is genuinely "going back": (1) navigation type `back_forward` (browser back without bfcache; with bfcache - `pageshow` `persisted` - the frozen page already has its scroll, so it only re-syncs the trail and clears the intent flag), (2) `reload`, (3) a same-origin click on `a.back-link` / `a[data-scroll-back]` / a breadcrumb link / any link whose target equals the trail's previous URL, which stamps the intent flag the next load matches. A fresh visit (topnav, address bar, redirect after POST) never restores, and a URL with a `#fragment` always wins over restoration.
|
||||
|
||||
@@ -36,7 +36,7 @@ export class DeviiTerminal {
|
||||
document.querySelectorAll("[data-devii-open]").forEach((trigger) => {
|
||||
trigger.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
this.open();
|
||||
this.open(trigger.dataset.deviiPrompt || "");
|
||||
});
|
||||
});
|
||||
const stored = this.element && this.element.hasStoredState && this.element.hasStoredState();
|
||||
@@ -99,9 +99,11 @@ export class DeviiTerminal {
|
||||
return true;
|
||||
}
|
||||
|
||||
async open() {
|
||||
async open(prompt = "") {
|
||||
await this.ready;
|
||||
if (this.element) this.element.open();
|
||||
if (!this.element) return;
|
||||
this.element.open();
|
||||
if (prompt && typeof this.element.prefill === "function") this.element.prefill(prompt);
|
||||
}
|
||||
|
||||
async close() {
|
||||
|
||||
@@ -2,20 +2,7 @@
|
||||
|
||||
import { TextInput } from "./TextInput.js";
|
||||
import { DomUtils } from "./DomUtils.js";
|
||||
import { assetUrl } from "./assetVersion.js";
|
||||
|
||||
const EMOJI_DATA_SOURCE = assetUrl("/static/vendor/emoji-picker-element/data.json");
|
||||
|
||||
let pickerElementModule = null;
|
||||
|
||||
function loadPickerElement() {
|
||||
if (!pickerElementModule) {
|
||||
pickerElementModule = import(
|
||||
assetUrl("/static/vendor/emoji-picker-element/index.js")
|
||||
).catch(() => {});
|
||||
}
|
||||
return pickerElementModule;
|
||||
}
|
||||
import { EmojiPickerElement } from "./EmojiPickerElement.js";
|
||||
|
||||
export class EmojiPicker {
|
||||
constructor(textarea) {
|
||||
@@ -34,17 +21,14 @@ export class EmojiPicker {
|
||||
this.wrapper.style.right = "0";
|
||||
this.wrapper.style.zIndex = "2000";
|
||||
|
||||
const picker = document.createElement("emoji-picker");
|
||||
picker.setAttribute("data-source", EMOJI_DATA_SOURCE);
|
||||
const picker = EmojiPickerElement.create((unicode) => {
|
||||
this.insert(unicode);
|
||||
this.hide();
|
||||
});
|
||||
picker.style.width = "320px";
|
||||
picker.style.height = "350px";
|
||||
this.wrapper.appendChild(picker);
|
||||
|
||||
picker.addEventListener("emoji-click", (event) => {
|
||||
this.insert(event.detail.unicode);
|
||||
this.hide();
|
||||
});
|
||||
|
||||
this.picker = picker;
|
||||
|
||||
const parent = this.textarea.parentElement;
|
||||
@@ -59,7 +43,7 @@ export class EmojiPicker {
|
||||
}
|
||||
|
||||
toggle() {
|
||||
loadPickerElement();
|
||||
EmojiPickerElement.load();
|
||||
DomUtils.toggle(this.wrapper);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { assetUrl } from "./assetVersion.js";
|
||||
|
||||
const EMOJI_DATA_SOURCE = assetUrl("/static/vendor/emoji-picker-element/data.json");
|
||||
|
||||
let pickerElementModule = null;
|
||||
|
||||
export class EmojiPickerElement {
|
||||
static load() {
|
||||
if (!pickerElementModule) {
|
||||
pickerElementModule = import(
|
||||
assetUrl("/static/vendor/emoji-picker-element/index.js")
|
||||
).catch(() => {});
|
||||
}
|
||||
return pickerElementModule;
|
||||
}
|
||||
|
||||
static create(onSelect) {
|
||||
const picker = document.createElement("emoji-picker");
|
||||
picker.setAttribute("data-source", EMOJI_DATA_SOURCE);
|
||||
picker.addEventListener("emoji-click", (event) => onSelect(event.detail.unicode));
|
||||
return picker;
|
||||
}
|
||||
}
|
||||
window.EmojiPickerElement = EmojiPickerElement;
|
||||
@@ -0,0 +1,47 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
export class Format {
|
||||
static COMPACT_FROM = 1000000;
|
||||
static UNITS = [
|
||||
{ limit: 1000000000000, suffix: "T" },
|
||||
{ limit: 1000000000, suffix: "B" },
|
||||
{ limit: 1000000, suffix: "M" },
|
||||
];
|
||||
|
||||
static number(value) {
|
||||
const amount = Math.trunc(Number(value) || 0);
|
||||
const magnitude = Math.abs(amount);
|
||||
if (magnitude < Format.COMPACT_FROM) return amount.toLocaleString("en-GB");
|
||||
for (const unit of Format.UNITS) {
|
||||
if (magnitude >= unit.limit) {
|
||||
const scaled = amount / unit.limit;
|
||||
const digits = Math.abs(scaled) < 10 ? 2 : 1;
|
||||
return `${Number(scaled.toFixed(digits))}${unit.suffix}`;
|
||||
}
|
||||
}
|
||||
return amount.toLocaleString("en-GB");
|
||||
}
|
||||
|
||||
static coins(value) {
|
||||
return `${Format.number(value)}c`;
|
||||
}
|
||||
|
||||
static exact(value) {
|
||||
return Math.trunc(Number(value) || 0).toLocaleString("en-GB");
|
||||
}
|
||||
|
||||
static speed(value) {
|
||||
const amount = Number(value) || 0;
|
||||
return `${Number(amount.toFixed(2))}x`;
|
||||
}
|
||||
|
||||
static duration(seconds) {
|
||||
const total = Math.max(0, Math.floor(Number(seconds) || 0));
|
||||
const hours = Math.floor(total / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const secs = total % 60;
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
if (minutes > 0) return `${minutes}m ${secs}s`;
|
||||
return `${secs}s`;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Format } from "./Format.js";
|
||||
import { Http } from "./Http.js";
|
||||
import { Poller } from "./Poller.js";
|
||||
|
||||
@@ -62,7 +63,7 @@ export class GameFarm {
|
||||
const data = await Http.send(form.getAttribute("action"), params);
|
||||
this.render(data.farm);
|
||||
if (action === "steal" && window.app && window.app.toast) {
|
||||
window.app.toast.show(`You stole ${data.stole_coins}c!`, { type: "success" });
|
||||
window.app.toast.show(`You stole ${Format.coins(data.stole_coins)}!`, { type: "success" });
|
||||
}
|
||||
if (action === "harvest" || action === "water" || action === "steal" || action === "prestige") {
|
||||
this._loadLeaderboard();
|
||||
@@ -103,42 +104,42 @@ export class GameFarm {
|
||||
const node = this.root.querySelector(selector);
|
||||
if (node) node.textContent = value;
|
||||
};
|
||||
set("[data-hud-coins]", farm.coins);
|
||||
this._setCoins("[data-hud-coins]", farm.coins);
|
||||
set("[data-hud-level]", farm.level);
|
||||
set("[data-hud-ci]", farm.ci_tier);
|
||||
set("[data-hud-ci-label]", `${farm.ci_label} (${farm.ci_speed}x)`);
|
||||
set("[data-hud-harvests]", farm.total_harvests);
|
||||
set("[data-hud-ci-label]", `${farm.ci_label} (${Format.speed(farm.ci_speed)})`);
|
||||
set("[data-hud-harvests]", Format.exact(farm.total_harvests));
|
||||
set("[data-hud-prestige]", farm.prestige);
|
||||
set("[data-hud-prestige-mult]", `+${Math.round(farm.prestige_multiplier * 100 - 100)}% coins`);
|
||||
set("[data-hud-stars]", farm.stars);
|
||||
set("[data-hud-mastery]", farm.mastery_points);
|
||||
set("[data-era-coins]", farm.era_coins);
|
||||
set("[data-era-harvests]", farm.era_harvests);
|
||||
set("[data-hud-stars]", Format.exact(farm.stars));
|
||||
set("[data-hud-mastery]", Format.exact(farm.mastery_points));
|
||||
set("[data-era-coins]", Format.coins(farm.era_coins));
|
||||
set("[data-era-harvests]", Format.exact(farm.era_harvests));
|
||||
const fill = this.root.querySelector("[data-hud-xp-fill]");
|
||||
if (fill) {
|
||||
const pct = farm.level_is_max || !farm.level_span ? 100 : Math.floor((100 * farm.level_into) / farm.level_span);
|
||||
fill.style.setProperty("--hud-xp", `${pct}%`);
|
||||
}
|
||||
set("[data-hud-xp]", farm.level_is_max ? "max level" : `${farm.level_into} / ${farm.level_span} XP`);
|
||||
set("[data-hud-xp]", farm.level_is_max ? "max level" : `${Format.exact(farm.level_into)} / ${Format.exact(farm.level_span)} XP`);
|
||||
}
|
||||
|
||||
_shopHtml(farm) {
|
||||
const ciUpgrade = farm.ci_next_tier
|
||||
? `<form method="post" action="/game/upgrade" data-game-action="upgrade"><button type="submit" class="btn btn-sm btn-primary">Upgrade (${farm.ci_next_cost}c)</button></form>`
|
||||
? `<form method="post" action="/game/upgrade" data-game-action="upgrade"><button type="submit" class="btn btn-sm btn-primary">Upgrade (${Format.coins(farm.ci_next_cost)})</button></form>`
|
||||
: "";
|
||||
const ciText = farm.ci_next_tier
|
||||
? `Upgrade to ${farm.ci_next_label} for faster builds`
|
||||
: "CI is at the top tier.";
|
||||
const prestigeBtn = farm.refactor_affordable
|
||||
? `<form method="post" action="/game/prestige" data-game-action="prestige"><button type="submit" class="btn btn-sm btn-primary" data-confirm="Refactoring costs ${farm.refactor_cost}c and resets coins, level, CI, extra plots, and perks for a permanent +25% coin bonus. You carry over ${farm.refactor_carryover_preview}c and keep your Stars and Legacy upgrades. Continue?">Refactor (${farm.refactor_cost}c)</button></form>`
|
||||
? `<form method="post" action="/game/prestige" data-game-action="prestige"><button type="submit" class="btn btn-sm btn-primary" data-confirm="Refactoring costs ${Format.exact(farm.refactor_cost)} coins and resets coins, level, CI, extra plots, and perks for a permanent +25% coin bonus. You carry over ${Format.exact(farm.refactor_carryover_preview)} coins and keep your Stars and Legacy upgrades. Continue?">Refactor (${Format.coins(farm.refactor_cost)})</button></form>`
|
||||
: "";
|
||||
let prestigeText;
|
||||
if (!farm.prestige_available) {
|
||||
prestigeText = `Reach level ${farm.prestige_min_level} to refactor.`;
|
||||
} else if (farm.refactor_affordable) {
|
||||
prestigeText = `Pay ${farm.refactor_cost}c to reset your farm for a permanent +25% coin bonus. You keep ${farm.refactor_carryover_pct}% of what remains (${farm.refactor_carryover_preview}c) plus your Stars and Legacy upgrades.`;
|
||||
prestigeText = `Pay ${Format.coins(farm.refactor_cost)} to reset your farm for a permanent +25% coin bonus. You keep ${farm.refactor_carryover_pct}% of what remains (${Format.coins(farm.refactor_carryover_preview)}) plus your Stars and Legacy upgrades.`;
|
||||
} else {
|
||||
prestigeText = `Refactoring costs ${farm.refactor_cost}c right now - keep farming to afford it.`;
|
||||
prestigeText = `Refactoring costs ${Format.coins(farm.refactor_cost)} right now - keep farming to afford it.`;
|
||||
}
|
||||
return (
|
||||
`<div class="shop-row"><div class="shop-info"><strong>CI upgrade</strong><span>${ciText}</span></div>${ciUpgrade}</div>` +
|
||||
@@ -151,7 +152,7 @@ export class GameFarm {
|
||||
.map((perk) => {
|
||||
const action = perk.maxed
|
||||
? `<span class="perk-maxed">Maxed</span>`
|
||||
: `<form method="post" action="/game/perk" data-game-action="perk"><input type="hidden" name="perk" value="${perk.key}"><button type="submit" class="btn btn-sm">Upgrade (${perk.cost}c)</button></form>`;
|
||||
: `<form method="post" action="/game/perk" data-game-action="perk"><input type="hidden" name="perk" value="${perk.key}"><button type="submit" class="btn btn-sm">Upgrade (${Format.coins(perk.cost)})</button></form>`;
|
||||
return `<div class="perk-card"><span class="perk-icon" aria-hidden="true">${perk.icon}</span><strong class="perk-name">${perk.name}</strong><span class="perk-level">Lv ${perk.level}/${perk.max_level}</span><span class="perk-effect">${perk.effect || perk.description}</span>${action}</div>`;
|
||||
})
|
||||
.join("");
|
||||
@@ -162,7 +163,7 @@ export class GameFarm {
|
||||
.map((up) => {
|
||||
const action = up.maxed
|
||||
? `<span class="perk-maxed">Maxed</span>`
|
||||
: `<form method="post" action="/game/legacy" data-game-action="legacy"><input type="hidden" name="key" value="${up.key}"><button type="submit" class="btn btn-sm">Buy (${up.cost} ★)</button></form>`;
|
||||
: `<form method="post" action="/game/legacy" data-game-action="legacy"><input type="hidden" name="key" value="${up.key}"><button type="submit" class="btn btn-sm">Buy (${Format.exact(up.cost)} ★)</button></form>`;
|
||||
return `<div class="perk-card legacy-card"><span class="perk-icon" aria-hidden="true">${up.icon}</span><strong class="perk-name">${up.name}</strong><span class="perk-level">Lv ${up.level}/${up.max_level}</span><span class="perk-effect">${up.effect || up.description}</span>${action}</div>`;
|
||||
})
|
||||
.join("");
|
||||
@@ -173,7 +174,7 @@ export class GameFarm {
|
||||
.map((m) => {
|
||||
const action = m.maxed
|
||||
? `<span class="perk-maxed">Maxed</span>`
|
||||
: `<form method="post" action="/game/mastery" data-game-action="mastery"><input type="hidden" name="key" value="${m.key}"><button type="submit" class="btn btn-sm">Upgrade (${m.cost} Mastery)</button></form>`;
|
||||
: `<form method="post" action="/game/mastery" data-game-action="mastery"><input type="hidden" name="key" value="${m.key}"><button type="submit" class="btn btn-sm">Upgrade (${Format.exact(m.cost)} Mastery)</button></form>`;
|
||||
return `<div class="perk-card"><span class="perk-icon" aria-hidden="true">${m.icon}</span><strong class="perk-name">${m.name}</strong><span class="perk-level">Lv ${m.level}/${m.max_level}</span><span class="perk-effect">${m.effect || m.description}</span>${action}</div>`;
|
||||
})
|
||||
.join("");
|
||||
@@ -182,8 +183,8 @@ export class GameFarm {
|
||||
_analyticsHtml(farm) {
|
||||
if (!farm.mastery_analytics_unlocked) return "";
|
||||
return (
|
||||
`<div class="shop-row"><div class="shop-info"><strong>Lifetime coins earned</strong><span>${farm.lifetime_coins_earned}c since Farm Analytics was unlocked.</span></div></div>` +
|
||||
`<div class="shop-row"><div class="shop-info"><strong>Lifetime harvests</strong><span>${farm.lifetime_harvests} builds harvested since Farm Analytics was unlocked.</span></div></div>`
|
||||
`<div class="shop-row"><div class="shop-info"><strong>Lifetime coins earned</strong><span>${Format.coins(farm.lifetime_coins_earned)} since Farm Analytics was unlocked.</span></div></div>` +
|
||||
`<div class="shop-row"><div class="shop-info"><strong>Lifetime harvests</strong><span>${Format.exact(farm.lifetime_harvests)} builds harvested since Farm Analytics was unlocked.</span></div></div>`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -196,7 +197,7 @@ export class GameFarm {
|
||||
} else if (farm.prestige < infra.min_prestige) {
|
||||
action = `<span class="perk-maxed">Requires prestige ${infra.min_prestige}</span>`;
|
||||
} else {
|
||||
action = `<form method="post" action="/game/infrastructure/buy" data-game-action="infrastructure"><input type="hidden" name="key" value="${infra.key}"><button type="submit" class="btn btn-sm" data-confirm="Buy ${infra.name} for ${infra.cost}c?">Buy (${infra.cost}c)</button></form>`;
|
||||
action = `<form method="post" action="/game/infrastructure/buy" data-game-action="infrastructure"><input type="hidden" name="key" value="${infra.key}"><button type="submit" class="btn btn-sm" data-confirm="Buy ${infra.name} for ${Format.exact(infra.cost)} coins?">Buy (${Format.coins(infra.cost)})</button></form>`;
|
||||
}
|
||||
return `<div class="perk-card"><span class="perk-icon" aria-hidden="true">${infra.icon}</span><strong class="perk-name">${infra.name}</strong><span class="perk-effect">${infra.description}</span>${action}</div>`;
|
||||
})
|
||||
@@ -205,12 +206,15 @@ export class GameFarm {
|
||||
|
||||
_defenseHtml(farm) {
|
||||
const upkeep = farm.defense_upkeep_daily
|
||||
? `Costs ${farm.defense_upkeep_daily}c/day upkeep - unpaid protection decays a level.`
|
||||
? `Costs ${Format.coins(farm.defense_upkeep_daily)}/day upkeep. If you cannot pay you lose one level - never your whole balance.`
|
||||
: "No upkeep at level 0.";
|
||||
const action = farm.defense_next_cost
|
||||
? `<form method="post" action="/game/defense/upgrade" data-game-action="defense"><button type="submit" class="btn btn-sm btn-primary">Upgrade (${farm.defense_next_cost}c)</button></form>`
|
||||
? `<form method="post" action="/game/defense/upgrade" data-game-action="defense"><button type="submit" class="btn btn-sm btn-primary">Upgrade (${Format.coins(farm.defense_next_cost)})</button></form>`
|
||||
: `<span class="perk-maxed">Maxed</span>`;
|
||||
return `<div class="shop-row"><div class="shop-info"><strong>Defense: ${farm.defense_tier_name} (level ${farm.defense_level})</strong><span>${upkeep}</span></div>${action}</div>`;
|
||||
const downgrade = farm.defense_downgrade_available
|
||||
? `<form method="post" action="/game/defense/downgrade" data-game-action="defense-downgrade"><button type="submit" class="btn btn-sm" data-confirm="Drop to the next defense tier down? Lower upkeep, less protection.">Downgrade</button></form>`
|
||||
: "";
|
||||
return `<div class="shop-row"><div class="shop-info"><strong>Defense: ${farm.defense_tier_name} (level ${farm.defense_level})</strong><span>${upkeep}</span></div><div class="shop-actions">${action}${downgrade}</div></div>`;
|
||||
}
|
||||
|
||||
_cosmeticsHtml(farm) {
|
||||
@@ -225,7 +229,7 @@ export class GameFarm {
|
||||
} else if (c.owned) {
|
||||
action = `<span class="perk-maxed">Owned</span>`;
|
||||
} else {
|
||||
action = `<form method="post" action="/game/cosmetics/buy" data-game-action="cosmetic-buy"><input type="hidden" name="key" value="${c.key}"><button type="submit" class="btn btn-sm" data-confirm="Buy ${c.name} for ${c.cost_coins}c?">Buy (${c.cost_coins}c)</button></form>`;
|
||||
action = `<form method="post" action="/game/cosmetics/buy" data-game-action="cosmetic-buy"><input type="hidden" name="key" value="${c.key}"><button type="submit" class="btn btn-sm" data-confirm="Buy ${c.name} for ${Format.exact(c.cost_coins)} coins?">Buy (${Format.coins(c.cost_coins)})</button></form>`;
|
||||
}
|
||||
return `<div class="perk-card"><span class="perk-icon" aria-hidden="true">${c.icon}</span><strong class="perk-name">${c.name}</strong><span class="perk-effect">${c.description}</span>${action}</div>`;
|
||||
})
|
||||
@@ -234,16 +238,16 @@ export class GameFarm {
|
||||
|
||||
_grantHtml(farm) {
|
||||
const action = farm.grant_available
|
||||
? `<form method="post" action="/game/grant" data-game-action="grant"><button type="submit" class="btn btn-sm btn-primary">Claim +${farm.grant_amount}c</button></form>`
|
||||
? `<form method="post" action="/game/grant" data-game-action="grant"><button type="submit" class="btn btn-sm btn-primary">Claim +${Format.coins(farm.grant_amount)}</button></form>`
|
||||
: `<span class="daily-claimed">${farm.grant_reason}</span>`;
|
||||
return `<p class="daily-streak">Treasury: <strong>${farm.treasury_balance}</strong>c collected from refactor fees</p>${action}`;
|
||||
return `<p class="daily-streak">Treasury: <strong>${Format.coins(farm.treasury_balance)}</strong> collected from refactor fees</p>${action}`;
|
||||
}
|
||||
|
||||
_dailyHtml(farm) {
|
||||
const action = farm.daily_available
|
||||
? `<form method="post" action="/game/daily" data-game-action="daily"><button type="submit" class="btn btn-sm btn-primary">Claim +${farm.daily_reward}c</button></form>`
|
||||
? `<form method="post" action="/game/daily" data-game-action="daily"><button type="submit" class="btn btn-sm btn-primary">Claim +${Format.coins(farm.daily_reward)}</button></form>`
|
||||
: `<span class="daily-claimed">Claimed today. Come back tomorrow.</span>`;
|
||||
return `<p class="daily-streak">Streak: <strong>${farm.streak}</strong> day(s)</p>${action}`;
|
||||
return `<p class="daily-streak">Streak: <strong>${Format.exact(farm.streak)}</strong> day(s)${farm.daily_streak_reset ? " - your streak lapsed, claiming today starts a new one" : ""}</p>${action}`;
|
||||
}
|
||||
|
||||
_questsHtml(farm) {
|
||||
@@ -252,14 +256,14 @@ export class GameFarm {
|
||||
return quests
|
||||
.map((quest) => {
|
||||
const pct = quest.goal ? Math.floor((100 * quest.progress) / quest.goal) : 0;
|
||||
const reward = quest.reward_stars ? `+${quest.reward_stars} ★` : `+${quest.reward_coins}c`;
|
||||
const reward = quest.reward_stars ? `+${Format.exact(quest.reward_stars)} ★` : `+${Format.coins(quest.reward_coins)}`;
|
||||
let foot = "";
|
||||
if (quest.can_claim) {
|
||||
foot = `<form method="post" action="/game/quests/claim" data-game-action="claim-quest"><input type="hidden" name="quest" value="${quest.kind}"><input type="hidden" name="scope" value="${quest.scope}"><button type="submit" class="btn btn-sm btn-primary">Claim</button></form>`;
|
||||
} else if (quest.claimed) {
|
||||
foot = `<span class="quest-claimed">Done</span>`;
|
||||
}
|
||||
return `<li class="quest-item${quest.claimed ? " quest-done" : ""}"><div class="quest-head"><span>${quest.label}</span><span class="quest-reward">${reward}</span></div><div class="quest-bar"><span class="quest-fill" style="--quest-pct:${pct}%"></span></div><div class="quest-foot"><span class="quest-progress">${quest.progress}/${quest.goal}</span>${foot}</div></li>`;
|
||||
return `<li class="quest-item${quest.claimed ? " quest-done" : ""}"><div class="quest-head"><span>${quest.label}</span><span class="quest-reward">${reward}</span></div><div class="quest-bar"><span class="quest-fill" style="--quest-pct:${pct}%"></span></div><div class="quest-foot"><span class="quest-progress">${Format.exact(quest.progress)}/${Format.exact(quest.goal)}</span>${foot}</div></li>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
@@ -268,7 +272,7 @@ export class GameFarm {
|
||||
const cells = farm.plots.map((plot) => this._plotHtml(plot, farm)).join("");
|
||||
let buy = "";
|
||||
if (farm.is_owner && farm.plot_count < farm.max_plots) {
|
||||
buy = `<form class="game-plot game-plot-buy" method="post" action="/game/buy-plot" data-game-action="buy-plot"><button type="submit" class="btn btn-sm">+ New plot (${farm.next_plot_cost}c)</button></form>`;
|
||||
buy = `<form class="game-plot game-plot-buy" method="post" action="/game/buy-plot" data-game-action="buy-plot"><button type="submit" class="btn btn-sm">+ New plot (${Format.coins(farm.next_plot_cost)})</button></form>`;
|
||||
}
|
||||
return `<div class="game-grid" data-game-grid>${cells}${buy}</div>`;
|
||||
}
|
||||
@@ -289,7 +293,7 @@ export class GameFarm {
|
||||
: crop.market_state === "boosted"
|
||||
? " (boosted)"
|
||||
: "";
|
||||
return `<option value="${crop.key}"${disabled}>${crop.icon} ${crop.name} - ${crop.cost}c${lock}${market}</option>`;
|
||||
return `<option value="${crop.key}"${disabled}>${crop.icon} ${crop.name} - ${Format.coins(crop.cost)}${lock}${market}</option>`;
|
||||
})
|
||||
.join("");
|
||||
body = `<form class="plot-plant-form" method="post" action="/game/plant" data-game-action="plant"><input type="hidden" name="slot" value="${plot.slot}"><select name="crop" class="plot-crop-select" aria-label="Choose a project to build">${options}</select><button type="submit" class="btn btn-sm btn-primary">Plant</button></form>`;
|
||||
@@ -302,16 +306,19 @@ export class GameFarm {
|
||||
body += `<form method="post" action="/game/farm/${farm.owner_username}/water" data-game-action="water"><input type="hidden" name="slot" value="${plot.slot}"><button type="submit" class="btn btn-sm">💧 Water</button></form>`;
|
||||
}
|
||||
if (farm.is_owner && plot.fertilize_cost) {
|
||||
body += `<form method="post" action="/game/fertilize" data-game-action="fertilize"><input type="hidden" name="slot" value="${plot.slot}"><button type="submit" class="btn btn-sm">⚡ Fertilize (${plot.fertilize_cost}c)</button></form>`;
|
||||
body += `<form method="post" action="/game/fertilize" data-game-action="fertilize"><input type="hidden" name="slot" value="${plot.slot}"><button type="submit" class="btn btn-sm">⚡ Fertilize (${Format.coins(plot.fertilize_cost)})</button></form>`;
|
||||
}
|
||||
} else {
|
||||
const goldenMark = plot.is_golden ? " ✨" : "";
|
||||
body = `<span class="plot-icon plot-ready-icon" aria-hidden="true">${plot.crop_icon}</span><span class="plot-crop-name">${plot.crop_name}${goldenMark}</span>`;
|
||||
const raided = plot.raided_pct ? `<span class="plot-raided">Raided ${plot.raided_pct}%</span>` : "";
|
||||
body = `<span class="plot-icon plot-ready-icon" aria-hidden="true">${plot.crop_icon}</span><span class="plot-crop-name">${plot.crop_name}${goldenMark}</span>${raided}`;
|
||||
if (farm.is_owner) {
|
||||
const label = plot.is_golden ? "Harvest golden" : "Harvest";
|
||||
body += `<form method="post" action="/game/harvest" data-game-action="harvest"><input type="hidden" name="slot" value="${plot.slot}"><button type="submit" class="btn btn-sm btn-primary">${label}</button></form>`;
|
||||
} else if (plot.can_steal) {
|
||||
body += `<form method="post" action="/game/farm/${farm.owner_username}/steal" data-game-action="steal"><input type="hidden" name="slot" value="${plot.slot}"><button type="submit" class="btn btn-sm btn-danger" data-confirm="Steal this ready build?">Steal +${plot.steal_coins}c</button></form>`;
|
||||
body += `<form method="post" action="/game/farm/${farm.owner_username}/steal" data-game-action="steal"><input type="hidden" name="slot" value="${plot.slot}"><button type="submit" class="btn btn-sm btn-danger" data-confirm="Steal this ready build?">Steal +${Format.coins(plot.steal_coins)}</button></form>`;
|
||||
} else if (plot.steal_reason === "stripped") {
|
||||
body += `<span class="plot-ready-label plot-steal-locked">Already stripped</span>`;
|
||||
} else if (plot.steal_reason === "cooldown") {
|
||||
body += `<span class="plot-ready-label plot-steal-locked">Raid again in ${this._format(plot.steal_cooldown_seconds)}</span>`;
|
||||
} else {
|
||||
@@ -326,6 +333,7 @@ export class GameFarm {
|
||||
}
|
||||
|
||||
_tick() {
|
||||
this._tickBoosts();
|
||||
let flipped = false;
|
||||
this.root.querySelectorAll("[data-plot-timer]").forEach((node) => {
|
||||
const readyAt = node.dataset.readyAt;
|
||||
@@ -348,12 +356,22 @@ export class GameFarm {
|
||||
}
|
||||
|
||||
_format(seconds) {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
if (minutes > 0) return `${minutes}m ${secs}s`;
|
||||
return `${secs}s`;
|
||||
return Format.duration(seconds);
|
||||
}
|
||||
|
||||
_setCoins(selector, value) {
|
||||
const node = this.root.querySelector(selector);
|
||||
if (!node) return;
|
||||
node.textContent = Format.number(value);
|
||||
node.title = Format.exact(value);
|
||||
}
|
||||
|
||||
_tickBoosts() {
|
||||
this.root.querySelectorAll("[data-seconds]").forEach((node) => {
|
||||
const remaining = Math.max(0, Number(node.dataset.seconds || 0) - 1);
|
||||
node.dataset.seconds = String(remaining);
|
||||
node.textContent = Format.duration(remaining);
|
||||
});
|
||||
}
|
||||
|
||||
async _loadLeaderboard() {
|
||||
@@ -379,8 +397,8 @@ export class GameFarm {
|
||||
}
|
||||
|
||||
_leaderboardValue(board, entry) {
|
||||
if (board === "raids") return `${entry.raid_avg.toLocaleString()}c/raid`;
|
||||
if (board === "time_to_kernel") return this._format(entry.time_to_kernel_seconds);
|
||||
return entry.score.toLocaleString();
|
||||
if (board === "raids") return `${Format.coins(entry.raid_avg)}/raid`;
|
||||
if (board === "time_to_kernel") return Format.duration(entry.time_to_kernel_seconds);
|
||||
return Format.exact(entry.score);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { OptimisticAction } from "./OptimisticAction.js";
|
||||
import { EmojiPickerElement } from "./EmojiPickerElement.js";
|
||||
|
||||
export class ReactionBar extends OptimisticAction {
|
||||
constructor() {
|
||||
@@ -9,50 +10,95 @@ export class ReactionBar extends OptimisticAction {
|
||||
}
|
||||
|
||||
async onClick(event) {
|
||||
if (event.target.closest(".reaction-picker")) {
|
||||
return;
|
||||
}
|
||||
const addButton = event.target.closest(".reaction-add-btn");
|
||||
if (addButton) {
|
||||
event.preventDefault();
|
||||
this.togglePalette(addButton);
|
||||
return;
|
||||
}
|
||||
const moreButton = event.target.closest(".reaction-palette-more");
|
||||
if (moreButton) {
|
||||
event.preventDefault();
|
||||
this.togglePicker(moreButton);
|
||||
return;
|
||||
}
|
||||
const trigger = event.target.closest("[data-reaction-emoji]");
|
||||
if (trigger && !trigger.disabled) {
|
||||
event.preventDefault();
|
||||
await this.react(trigger);
|
||||
return;
|
||||
}
|
||||
this.closeAllPalettes();
|
||||
this.closeAll();
|
||||
}
|
||||
|
||||
togglePalette(addButton) {
|
||||
const palette = addButton.parentElement.querySelector(".reaction-palette");
|
||||
const isOpen = palette.hidden === false;
|
||||
this.closeAllPalettes();
|
||||
this.closeAll();
|
||||
palette.hidden = isOpen;
|
||||
addButton.setAttribute("aria-expanded", palette.hidden ? "false" : "true");
|
||||
if (!palette.hidden) {
|
||||
this.clampToViewport(palette);
|
||||
this.ensureInView(palette);
|
||||
}
|
||||
}
|
||||
|
||||
clampToViewport(palette) {
|
||||
togglePicker(moreButton) {
|
||||
const bar = moreButton.closest(".reaction-bar");
|
||||
const host = bar ? bar.querySelector(".reaction-picker") : null;
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
const isOpen = host.hidden === false;
|
||||
this.closeAll();
|
||||
if (isOpen) {
|
||||
return;
|
||||
}
|
||||
this.mountPicker(host, bar);
|
||||
host.hidden = false;
|
||||
moreButton.setAttribute("aria-expanded", "true");
|
||||
this.clampToViewport(host);
|
||||
this.flipIfClipped(host);
|
||||
this.ensureInView(host);
|
||||
}
|
||||
|
||||
mountPicker(host, bar) {
|
||||
if (host.querySelector("emoji-picker")) {
|
||||
return;
|
||||
}
|
||||
EmojiPickerElement.load();
|
||||
host.appendChild(EmojiPickerElement.create((unicode) => this.reactWith(bar, unicode)));
|
||||
}
|
||||
|
||||
clampToViewport(panel) {
|
||||
const margin = 8;
|
||||
palette.style.left = "";
|
||||
const rect = palette.getBoundingClientRect();
|
||||
panel.style.left = "";
|
||||
const rect = panel.getBoundingClientRect();
|
||||
const overflowRight = rect.right - (window.innerWidth - margin);
|
||||
if (overflowRight > 0) {
|
||||
palette.style.left = `${-overflowRight}px`;
|
||||
panel.style.left = `${-overflowRight}px`;
|
||||
}
|
||||
const adjusted = palette.getBoundingClientRect();
|
||||
const adjusted = panel.getBoundingClientRect();
|
||||
if (adjusted.left < margin) {
|
||||
const current = parseFloat(palette.style.left || "0");
|
||||
palette.style.left = `${current + (margin - adjusted.left)}px`;
|
||||
const current = parseFloat(panel.style.left || "0");
|
||||
panel.style.left = `${current + (margin - adjusted.left)}px`;
|
||||
}
|
||||
}
|
||||
|
||||
ensureInView(palette) {
|
||||
flipIfClipped(panel) {
|
||||
const margin = 8;
|
||||
const rect = palette.getBoundingClientRect();
|
||||
panel.classList.remove("below");
|
||||
if (panel.getBoundingClientRect().top < margin) {
|
||||
panel.classList.add("below");
|
||||
}
|
||||
}
|
||||
|
||||
ensureInView(panel) {
|
||||
const margin = 8;
|
||||
const rect = panel.getBoundingClientRect();
|
||||
const extra = rect.height * 0.3;
|
||||
if (rect.top < margin) {
|
||||
window.scrollBy({ top: rect.top - margin - extra, behavior: "smooth" });
|
||||
@@ -61,9 +107,12 @@ export class ReactionBar extends OptimisticAction {
|
||||
}
|
||||
}
|
||||
|
||||
closeAllPalettes() {
|
||||
document.querySelectorAll(".reaction-palette").forEach((palette) => {
|
||||
palette.hidden = true;
|
||||
closeAll() {
|
||||
document.querySelectorAll(".reaction-palette, .reaction-picker").forEach((panel) => {
|
||||
panel.hidden = true;
|
||||
});
|
||||
document.querySelectorAll(".reaction-add-btn, .reaction-palette-more").forEach((button) => {
|
||||
button.setAttribute("aria-expanded", "false");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -72,22 +121,59 @@ export class ReactionBar extends OptimisticAction {
|
||||
if (!bar) {
|
||||
return;
|
||||
}
|
||||
await this.reactWith(bar, trigger.dataset.reactionEmoji);
|
||||
}
|
||||
|
||||
async reactWith(bar, emoji) {
|
||||
const type = bar.dataset.reactionType;
|
||||
const uid = bar.dataset.reactionUid;
|
||||
const emoji = trigger.dataset.reactionEmoji;
|
||||
await this.submit(`/reactions/${type}/${uid}`, { emoji }, null, (result) => this.render(bar, result));
|
||||
this.closeAllPalettes();
|
||||
this.closeAll();
|
||||
}
|
||||
|
||||
render(bar, result) {
|
||||
const counts = result.counts || {};
|
||||
const mine = new Set(result.mine || []);
|
||||
Object.keys(counts).forEach((emoji) => this.ensureChip(bar, emoji));
|
||||
mine.forEach((emoji) => this.ensureChip(bar, emoji));
|
||||
bar.querySelectorAll(".reaction-list .reaction-chip").forEach((chip) => {
|
||||
const emoji = chip.dataset.reactionEmoji;
|
||||
const count = counts[emoji] || 0;
|
||||
chip.querySelector(".reaction-count").textContent = count;
|
||||
chip.hidden = count === 0 && !mine.has(emoji);
|
||||
chip.classList.toggle("reacted", mine.has(emoji));
|
||||
chip.setAttribute("aria-pressed", mine.has(emoji) ? "true" : "false");
|
||||
});
|
||||
}
|
||||
|
||||
ensureChip(bar, emoji) {
|
||||
const list = bar.querySelector(".reaction-list");
|
||||
if (!list || this.findChip(list, emoji)) {
|
||||
return;
|
||||
}
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "reaction-chip";
|
||||
chip.dataset.reactionEmoji = emoji;
|
||||
chip.setAttribute("aria-label", `React with ${emoji}`);
|
||||
chip.setAttribute("aria-pressed", "false");
|
||||
chip.hidden = true;
|
||||
|
||||
const glyph = document.createElement("span");
|
||||
glyph.className = "reaction-emoji";
|
||||
glyph.textContent = emoji;
|
||||
|
||||
const count = document.createElement("span");
|
||||
count.className = "reaction-count";
|
||||
count.textContent = "0";
|
||||
|
||||
chip.append(glyph, count);
|
||||
list.appendChild(chip);
|
||||
}
|
||||
|
||||
findChip(list, emoji) {
|
||||
return Array.from(list.querySelectorAll(".reaction-chip")).find(
|
||||
(chip) => chip.dataset.reactionEmoji === emoji
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Component } from "./Component.js";
|
||||
import { Http } from "../Http.js";
|
||||
|
||||
const OPTION_KINDS = ["single_choice", "multiple_choice", "fill_blank", "ordering", "matching"];
|
||||
|
||||
const GROUP_FOR_KIND = {
|
||||
single_choice: ["options"],
|
||||
multiple_choice: ["options"],
|
||||
true_false: ["true_false"],
|
||||
free_text: ["free_text"],
|
||||
fill_blank: ["options", "fill_blank"],
|
||||
numeric: ["numeric"],
|
||||
ordering: ["options"],
|
||||
matching: ["options"],
|
||||
};
|
||||
|
||||
export class AppQuizBuilder extends Component {
|
||||
connectedCallback() {
|
||||
this._slug = this.attr("data-quiz-slug");
|
||||
this._picker = this.querySelector("[data-quiz-kind-picker]");
|
||||
this._groups = Array.from(this.querySelectorAll("[data-quiz-kind-fields]"));
|
||||
this._form = this.querySelector("[data-quiz-question-form]");
|
||||
if (this._picker) {
|
||||
this._picker.addEventListener("change", () => this._applyKind());
|
||||
this._applyKind();
|
||||
}
|
||||
if (this._form) {
|
||||
this._form.addEventListener("submit", (event) => this._submit(event));
|
||||
}
|
||||
}
|
||||
|
||||
_applyKind() {
|
||||
const kind = this._picker.value;
|
||||
const visible = GROUP_FOR_KIND[kind] || [];
|
||||
this._groups.forEach((group) => {
|
||||
group.hidden = !visible.includes(group.dataset.quizKindFields);
|
||||
});
|
||||
this._markRequired(kind);
|
||||
}
|
||||
|
||||
_markRequired(kind) {
|
||||
const options = this.querySelector("[name=options]");
|
||||
if (options) options.required = OPTION_KINDS.includes(kind);
|
||||
}
|
||||
|
||||
async _submit(event) {
|
||||
event.preventDefault();
|
||||
const button = this._form.querySelector("button[type=submit]");
|
||||
if (button) button.disabled = true;
|
||||
const params = [];
|
||||
new FormData(this._form).forEach((value, key) => params.push([key, value]));
|
||||
try {
|
||||
await Http.sendForm(`/quizzes/${this._slug}/questions`, params);
|
||||
} catch (error) {
|
||||
if (button) button.disabled = false;
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("dp-quiz-builder", AppQuizBuilder);
|
||||
@@ -0,0 +1,142 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Component } from "./Component.js";
|
||||
import { Http } from "../Http.js";
|
||||
import { Format } from "../Format.js";
|
||||
|
||||
const TICK_MS = 1000;
|
||||
const CHECKING_LABEL = "Checking…";
|
||||
|
||||
export class AppQuizPlayer extends Component {
|
||||
connectedCallback() {
|
||||
this._attemptUrl = this.attr("data-attempt-url");
|
||||
this._remaining = this.intAttr("data-remaining", 0);
|
||||
this._hasLimit = this.attr("data-has-limit") === "1";
|
||||
this._status = this.attr("data-status", "in_progress");
|
||||
this._progress = this.querySelector("[data-quiz-progress]");
|
||||
this._score = this.querySelector("[data-quiz-score]");
|
||||
this._timer = this.querySelector("[data-quiz-timer]");
|
||||
this._bindForms();
|
||||
if (this._hasLimit && this._status === "in_progress") this._startTimer();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this._interval) clearInterval(this._interval);
|
||||
}
|
||||
|
||||
_bindForms() {
|
||||
this.querySelectorAll("[data-quiz-answer-form]").forEach((form) => {
|
||||
form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
this._submit(form);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_startTimer() {
|
||||
this._interval = setInterval(() => {
|
||||
this._remaining = Math.max(0, this._remaining - 1);
|
||||
if (this._timer) this._timer.textContent = Format.duration(this._remaining);
|
||||
if (this._remaining === 0) {
|
||||
clearInterval(this._interval);
|
||||
window.location.reload();
|
||||
}
|
||||
}, TICK_MS);
|
||||
}
|
||||
|
||||
async _submit(form) {
|
||||
const button = form.querySelector("button[type=submit]");
|
||||
const original = button ? button.textContent : "";
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.textContent = CHECKING_LABEL;
|
||||
}
|
||||
let payload;
|
||||
try {
|
||||
payload = await Http.sendForm(this._attemptUrl + "/answer", this._params(form));
|
||||
} catch (error) {
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
button.textContent = original;
|
||||
}
|
||||
return;
|
||||
}
|
||||
this._renderAnswer(form, payload.answer);
|
||||
this._renderHud(payload.attempt);
|
||||
this._advance(form);
|
||||
}
|
||||
|
||||
_params(form) {
|
||||
const params = [];
|
||||
new FormData(form).forEach((value, key) => params.push([key, value]));
|
||||
return params;
|
||||
}
|
||||
|
||||
_renderAnswer(form, answer) {
|
||||
const question = form.closest("[data-quiz-question]");
|
||||
if (!question || !answer) return;
|
||||
question.classList.add("quiz-question-answered");
|
||||
form.querySelectorAll("input, textarea, select, button").forEach((field) => {
|
||||
field.disabled = true;
|
||||
});
|
||||
const actions = form.querySelector(".quiz-answer-actions");
|
||||
if (actions) actions.remove();
|
||||
const host = question.querySelector("[data-quiz-result]");
|
||||
if (!host) return;
|
||||
host.hidden = false;
|
||||
host.innerHTML = "";
|
||||
host.appendChild(this._gradeNode(question, answer));
|
||||
if (answer.feedback) host.appendChild(this._textNode("quiz-grade-feedback", answer.feedback));
|
||||
if (answer.graded_by === "fallback") {
|
||||
host.appendChild(
|
||||
this._textNode(
|
||||
"quiz-grade-fallback",
|
||||
"Automatic review was unavailable, so this answer was scored on keyword overlap.",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_gradeNode(question, answer) {
|
||||
const points = question.querySelector(".quiz-question-points");
|
||||
const max = points ? points.textContent.trim().split(" ")[0] : "";
|
||||
const grade = document.createElement("div");
|
||||
grade.className = "quiz-grade " + (answer.is_correct ? "quiz-grade-correct" : "quiz-grade-wrong");
|
||||
const verdict = document.createElement("span");
|
||||
verdict.className = "quiz-grade-verdict";
|
||||
verdict.textContent = answer.is_correct ? "Correct" : "Not correct";
|
||||
const scored = document.createElement("span");
|
||||
scored.className = "quiz-grade-points";
|
||||
scored.textContent = `${Format.number(answer.awarded_points)} / ${max} points`;
|
||||
grade.appendChild(verdict);
|
||||
grade.appendChild(scored);
|
||||
return grade;
|
||||
}
|
||||
|
||||
_textNode(className, text) {
|
||||
const node = document.createElement("p");
|
||||
node.className = className;
|
||||
node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
_renderHud(attempt) {
|
||||
if (!attempt) return;
|
||||
if (this._progress) {
|
||||
this._progress.textContent = `${attempt.answered_count} / ${attempt.question_count} answered`;
|
||||
}
|
||||
if (this._score) {
|
||||
this._score.textContent = `${Format.number(attempt.score_points)} / ${attempt.max_points} points`;
|
||||
}
|
||||
this._remaining = attempt.remaining_seconds;
|
||||
}
|
||||
|
||||
_advance(form) {
|
||||
const question = form.closest("[data-quiz-question]");
|
||||
if (!question) return;
|
||||
const next = question.nextElementSibling;
|
||||
if (next && next.scrollIntoView) next.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("dp-quiz-player", AppQuizPlayer);
|
||||
@@ -13,4 +13,6 @@ import "./AppDocsChat.js";
|
||||
import "./AppIsslop.js";
|
||||
import "./AppIsslopRun.js";
|
||||
import "./AppChat.js";
|
||||
import "./AppQuizPlayer.js";
|
||||
import "./AppQuizBuilder.js";
|
||||
import "./ContainerTerminal.js";
|
||||
|
||||
@@ -319,6 +319,13 @@ export default class DeviiTerminalElement extends FloatingWindow {
|
||||
this.input.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
prefill(text) {
|
||||
if (!text) return;
|
||||
this.input.value = text;
|
||||
this.input.setSelectionRange(text.length, text.length);
|
||||
this.input.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
close() {
|
||||
this._setState("closed");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user