Quiiz system

This commit is contained in:
2026-07-26 16:46:41 +02:00
parent 7f17d69f5c
commit 4780016980
192 changed files with 15031 additions and 566 deletions
+15
View File
@@ -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);
}
+43
View File
@@ -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);
}
+7 -2
View File
@@ -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;
}
+26
View File
@@ -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;
}
+813
View File
@@ -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;
}
}
+6 -1
View File
@@ -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.
+5 -3
View File
@@ -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() {
+6 -22
View File
@@ -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;
+47
View File
@@ -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`;
}
}
+61 -43
View File
@@ -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} &#9733;)</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)} &#9733;)</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} &#9733;` : `+${quest.reward_coins}c`;
const reward = quest.reward_stars ? `+${Format.exact(quest.reward_stars)} &#9733;` : `+${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">&#128167; 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">&#9889; 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">&#9889; Fertilize (${Format.coins(plot.fertilize_cost)})</button></form>`;
}
} else {
const goldenMark = plot.is_golden ? " &#10024;" : "";
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);
}
}
+102 -16
View File
@@ -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);
+2
View File
@@ -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");
}