forked from retoor/devplacepy
Update
This commit is contained in:
@@ -59,6 +59,11 @@
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.ds-degraded {
|
||||
border-left: 4px solid var(--warning, #d97706);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ds-input {
|
||||
width: 100%;
|
||||
background: var(--bg-input);
|
||||
@@ -438,13 +443,6 @@
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.ds-gaps {
|
||||
margin: 0;
|
||||
padding-left: var(--space-lg);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.ds-sources {
|
||||
margin: 0;
|
||||
padding-left: var(--space-lg);
|
||||
@@ -458,6 +456,38 @@
|
||||
margin-left: var(--space-sm);
|
||||
}
|
||||
|
||||
.ds-cite {
|
||||
color: var(--accent, #2563eb);
|
||||
font-weight: 600;
|
||||
font-size: 0.85em;
|
||||
text-decoration: none;
|
||||
vertical-align: super;
|
||||
line-height: 0;
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
.ds-cite:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.ds-finding-cites {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.ds-sources li {
|
||||
scroll-margin-top: var(--space-xl);
|
||||
}
|
||||
|
||||
.ds-sources li:target {
|
||||
background: var(--bg-highlight, rgba(37, 99, 235, 0.12));
|
||||
border-radius: var(--radius-input);
|
||||
outline: 2px solid var(--accent, #2563eb);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.ds-chat-pane {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
|
||||
@@ -41,6 +41,7 @@ import { LiveNotifications } from "./LiveNotifications.js";
|
||||
import { PresenceManager } from "./PresenceManager.js";
|
||||
import { OnlineUsers } from "./OnlineUsers.js";
|
||||
import { LocalTime } from "./LocalTime.js";
|
||||
import { ScrollMemory } from "./ScrollMemory.js";
|
||||
import { GameFarm } from "./GameFarm.js";
|
||||
import { Accessibility } from "./Accessibility.js";
|
||||
|
||||
@@ -92,6 +93,7 @@ class Application {
|
||||
this.presence = new PresenceManager(this.pubsub);
|
||||
this.onlineUsers = new OnlineUsers(this.pubsub);
|
||||
this.localTime = new LocalTime();
|
||||
this.scrollMemory = new ScrollMemory();
|
||||
this.gameFarm = new GameFarm();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export class ContainerInstance {
|
||||
this.status = root.dataset.status;
|
||||
this.base = `/projects/${this.slug}/containers`;
|
||||
this.name = root.dataset.name || this.uid;
|
||||
this.canManage = root.dataset.canManage === "1";
|
||||
this.detailPoll = null;
|
||||
this.logPoll = null;
|
||||
}
|
||||
@@ -32,8 +33,10 @@ export class ContainerInstance {
|
||||
}
|
||||
|
||||
bind() {
|
||||
this.q("#ci-exec-run").addEventListener("click", () => this.execOnce());
|
||||
this.q("#ci-term-toggle").addEventListener("click", () => this.openTerminal());
|
||||
const execRun = this.q("#ci-exec-run");
|
||||
if (execRun) execRun.addEventListener("click", () => this.execOnce());
|
||||
const termToggle = this.q("#ci-term-toggle");
|
||||
if (termToggle) termToggle.addEventListener("click", () => this.openTerminal());
|
||||
const form = document.getElementById("ci-schedule-form");
|
||||
if (form) {
|
||||
form.addEventListener("submit", (e) => { e.preventDefault(); this.addSchedule(form); });
|
||||
@@ -62,6 +65,11 @@ export class ContainerInstance {
|
||||
// ---------------- actions ----------------
|
||||
|
||||
renderActions(status) {
|
||||
if (!this.canManage) {
|
||||
const box = this.q("#ci-actions");
|
||||
box.innerHTML = `<span class="cm-muted">View only. This container is managed by its owner.</span>`;
|
||||
return;
|
||||
}
|
||||
const running = status === "running";
|
||||
const paused = status === "paused";
|
||||
const spec = [
|
||||
@@ -174,6 +182,7 @@ export class ContainerInstance {
|
||||
|
||||
updateTerminalAvailability() {
|
||||
const toggle = this.q("#ci-term-toggle");
|
||||
if (!toggle) return;
|
||||
const running = this.status === "running";
|
||||
toggle.disabled = !running;
|
||||
toggle.title = running ? "" : "Start the instance to open an interactive shell";
|
||||
@@ -239,11 +248,14 @@ export class ContainerInstance {
|
||||
list.innerHTML = `<li class="cm-muted ci-schedule-empty">No schedules.</li>`;
|
||||
return;
|
||||
}
|
||||
const removeBtn = (s) => this.canManage
|
||||
? `<button type="button" class="btn btn-secondary btn-sm" data-schedule-delete="${this.escape(s.uid)}">Delete</button>`
|
||||
: "";
|
||||
list.innerHTML = schedules.map((s) => `<li class="ci-schedule" data-sid="${this.escape(s.uid)}">
|
||||
<span class="ci-schedule-action">${this.escape(s.action)}</span>
|
||||
<span class="ci-schedule-next">next ${this.escape(s.next_run_at || "-")}</span>
|
||||
<span class="cm-muted">runs ${this.escape(s.run_count || 0)}</span>
|
||||
<button type="button" class="btn btn-secondary btn-sm" data-schedule-delete="${this.escape(s.uid)}">Delete</button>
|
||||
${removeBtn(s)}
|
||||
</li>`).join("");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export class ContainerList {
|
||||
this.endpoint = root.dataset.endpoint;
|
||||
this.body = document.getElementById("cm-admin-rows");
|
||||
this.pollMs = 4000;
|
||||
this.instances = [];
|
||||
}
|
||||
|
||||
init() {
|
||||
@@ -21,10 +22,22 @@ export class ContainerList {
|
||||
});
|
||||
const pubsub = window.app && window.app.pubsub;
|
||||
if (pubsub) {
|
||||
pubsub.subscribe("container.list", (data) => this.render(data.instances || []));
|
||||
pubsub.subscribe("container.list", (data) => {
|
||||
if (data.partial) this.merge(data.instances || []);
|
||||
else this.render(data.instances || []);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
merge(updates) {
|
||||
const byUid = new Map(this.instances.map((inst) => [inst.uid, inst]));
|
||||
for (const update of updates) {
|
||||
const current = byUid.get(update.uid);
|
||||
byUid.set(update.uid, current ? { ...update, can_manage: current.can_manage } : update);
|
||||
}
|
||||
this.render([...byUid.values()]);
|
||||
}
|
||||
|
||||
toast(message, type) {
|
||||
if (window.app && window.app.toast) window.app.toast.show(message, { type: type || "info" });
|
||||
}
|
||||
@@ -117,6 +130,7 @@ export class ContainerList {
|
||||
}
|
||||
|
||||
render(instances) {
|
||||
this.instances = instances;
|
||||
if (!this.body) return;
|
||||
if (!instances.length) {
|
||||
this.body.innerHTML = `<tr><td colspan="8" class="admin-empty">No container instances yet. Create one above or open a project's container manager.</td></tr>`;
|
||||
@@ -138,6 +152,14 @@ export class ContainerList {
|
||||
const boot = inst.start_on_boot
|
||||
? `<span class="cm-badge cm-running">on</span>`
|
||||
: `<span class="cm-muted">off</span>`;
|
||||
const actions = inst.can_manage === false
|
||||
? `<span class="cm-muted">view only</span>`
|
||||
: `<button class="admin-btn admin-btn-sm" data-cm-action="start">Start</button>
|
||||
<button class="admin-btn admin-btn-sm" data-cm-action="stop">Stop</button>
|
||||
<button class="admin-btn admin-btn-sm" data-cm-action="restart">Restart</button>
|
||||
<button class="admin-btn admin-btn-sm" data-cm-action="terminal">Terminal</button>
|
||||
<a class="admin-btn admin-btn-sm" href="/admin/containers/${uid}/edit">Edit</a>
|
||||
<button class="admin-btn admin-btn-sm admin-btn-danger" data-cm-action="delete">Delete</button>`;
|
||||
return `<tr class="cm-row" data-uid="${uid}" data-slug="${slug}" data-name="${name}">
|
||||
<td><a class="cm-row-name" href="/admin/containers/${uid}">${name}</a></td>
|
||||
<td>${project}</td>
|
||||
@@ -146,14 +168,7 @@ export class ContainerList {
|
||||
<td>${ingress}</td>
|
||||
<td>${this.escape(inst.restart_policy || "never")}</td>
|
||||
<td>${boot}</td>
|
||||
<td class="cm-actions">
|
||||
<button class="admin-btn admin-btn-sm" data-cm-action="start">Start</button>
|
||||
<button class="admin-btn admin-btn-sm" data-cm-action="stop">Stop</button>
|
||||
<button class="admin-btn admin-btn-sm" data-cm-action="restart">Restart</button>
|
||||
<button class="admin-btn admin-btn-sm" data-cm-action="terminal">Terminal</button>
|
||||
<a class="admin-btn admin-btn-sm" href="/admin/containers/${uid}/edit">Edit</a>
|
||||
<button class="admin-btn admin-btn-sm admin-btn-danger" data-cm-action="delete">Delete</button>
|
||||
</td>
|
||||
<td class="cm-actions">${actions}</td>
|
||||
</tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ const PHASE_ORDER = [
|
||||
];
|
||||
|
||||
const AGENT_LABELS = {
|
||||
summarizer: "Summarizer",
|
||||
critic: "Critic",
|
||||
summarizer: "Report writer",
|
||||
extractor: "Findings extractor",
|
||||
linker: "Linker",
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
export class ScrollMemory {
|
||||
constructor() {
|
||||
this.positionsKey = "dp-scroll:positions";
|
||||
this.trailKey = "dp-scroll:trail";
|
||||
this.intentKey = "dp-scroll:intent";
|
||||
this.maxEntries = 50;
|
||||
this.maxTrail = 20;
|
||||
this.maxAgeMs = 60 * 60 * 1000;
|
||||
this.intentAgeMs = 30 * 1000;
|
||||
this.restoreWindowMs = 4000;
|
||||
this.stableFramesNeeded = 10;
|
||||
this.saveDelayMs = 200;
|
||||
this.saveTimer = null;
|
||||
this.backSelector = "a.back-link, a[data-scroll-back], .breadcrumb a";
|
||||
if (!this.storageAvailable()) return;
|
||||
history.scrollRestoration = "manual";
|
||||
this.url = this.normalize(location.href);
|
||||
this.previousUrl = this.recordTrail();
|
||||
this.upgradeBackLinks();
|
||||
this.bindSave();
|
||||
this.bindIntent();
|
||||
window.addEventListener("pageshow", (e) => {
|
||||
if (!e.persisted) return;
|
||||
this.takeIntent();
|
||||
this.previousUrl = this.recordTrail();
|
||||
});
|
||||
this.restoreIfNeeded();
|
||||
}
|
||||
|
||||
storageAvailable() {
|
||||
try {
|
||||
const probe = "dp-scroll:probe";
|
||||
sessionStorage.setItem(probe, "1");
|
||||
sessionStorage.removeItem(probe);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
normalize(href) {
|
||||
const url = new URL(href, location.href);
|
||||
return url.pathname + url.search;
|
||||
}
|
||||
|
||||
readJson(key, fallback) {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(key);
|
||||
if (!raw) return fallback;
|
||||
const value = JSON.parse(raw);
|
||||
return value === null || typeof value !== "object" ? fallback : value;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
writeJson(key, value) {
|
||||
try {
|
||||
sessionStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
positions() {
|
||||
return this.readJson(this.positionsKey, {});
|
||||
}
|
||||
|
||||
prune(positions) {
|
||||
const now = Date.now();
|
||||
const entries = Object.entries(positions).filter(([, v]) => Array.isArray(v) && now - v[1] <= this.maxAgeMs);
|
||||
entries.sort((a, b) => b[1][1] - a[1][1]);
|
||||
return Object.fromEntries(entries.slice(0, this.maxEntries));
|
||||
}
|
||||
|
||||
savePosition() {
|
||||
const y = Math.round(window.scrollY);
|
||||
const positions = this.prune(this.positions());
|
||||
if (y > 0) {
|
||||
positions[this.url] = [y, Date.now()];
|
||||
} else {
|
||||
delete positions[this.url];
|
||||
}
|
||||
this.writeJson(this.positionsKey, positions);
|
||||
}
|
||||
|
||||
recordTrail() {
|
||||
const trail = this.readJson(this.trailKey, []);
|
||||
if (trail[trail.length - 1] !== this.url) trail.push(this.url);
|
||||
while (trail.length > this.maxTrail) trail.shift();
|
||||
this.writeJson(this.trailKey, trail);
|
||||
return trail.length > 1 ? trail[trail.length - 2] : null;
|
||||
}
|
||||
|
||||
upgradeBackLinks() {
|
||||
if (!this.previousUrl) return;
|
||||
const previous = new URL(this.previousUrl, location.origin);
|
||||
document.querySelectorAll("a.back-link, a[data-scroll-back]").forEach((link) => {
|
||||
const href = link.getAttribute("href");
|
||||
if (!href) return;
|
||||
const target = new URL(href, location.href);
|
||||
if (target.origin !== location.origin || target.search || target.hash) return;
|
||||
if (target.pathname !== previous.pathname) return;
|
||||
if (this.normalize(target.href) === this.previousUrl) return;
|
||||
link.setAttribute("href", this.previousUrl);
|
||||
});
|
||||
}
|
||||
|
||||
bindSave() {
|
||||
window.addEventListener("scroll", () => {
|
||||
if (this.saveTimer) return;
|
||||
this.saveTimer = setTimeout(() => {
|
||||
this.saveTimer = null;
|
||||
this.savePosition();
|
||||
}, this.saveDelayMs);
|
||||
}, { passive: true });
|
||||
window.addEventListener("pagehide", () => this.savePosition());
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) this.savePosition();
|
||||
});
|
||||
}
|
||||
|
||||
bindIntent() {
|
||||
document.addEventListener("click", (e) => {
|
||||
if (e.defaultPrevented || e.button !== 0) return;
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
const link = e.target instanceof Element ? e.target.closest("a[href]") : null;
|
||||
if (!link || (link.target && link.target !== "_self")) return;
|
||||
const target = new URL(link.getAttribute("href"), location.href);
|
||||
if (target.origin !== location.origin) return;
|
||||
const destination = this.normalize(target.href);
|
||||
if (destination === this.url) return;
|
||||
if (!this.positions()[destination]) return;
|
||||
if (destination !== this.previousUrl && !link.matches(this.backSelector)) return;
|
||||
this.writeJson(this.intentKey, { url: destination, t: Date.now() });
|
||||
});
|
||||
}
|
||||
|
||||
takeIntent() {
|
||||
const intent = this.readJson(this.intentKey, null);
|
||||
try {
|
||||
sessionStorage.removeItem(this.intentKey);
|
||||
} catch {}
|
||||
if (!intent || typeof intent.url !== "string") return null;
|
||||
if (Date.now() - (intent.t || 0) > this.intentAgeMs) return null;
|
||||
return intent.url;
|
||||
}
|
||||
|
||||
restoreIfNeeded() {
|
||||
const intent = this.takeIntent();
|
||||
if (location.hash) return;
|
||||
const nav = performance.getEntriesByType("navigation")[0];
|
||||
const type = nav ? nav.type : "navigate";
|
||||
if (type !== "back_forward" && type !== "reload" && intent !== this.url) return;
|
||||
const saved = this.positions()[this.url];
|
||||
if (!saved) return;
|
||||
const [y, t] = saved;
|
||||
if (y <= 0 || Date.now() - t > this.maxAgeMs) return;
|
||||
this.applyScroll(y);
|
||||
}
|
||||
|
||||
applyScroll(y) {
|
||||
const doc = document.scrollingElement || document.documentElement;
|
||||
const deadline = performance.now() + this.restoreWindowMs;
|
||||
const cancelEvents = ["wheel", "touchstart", "keydown", "pointerdown"];
|
||||
let cancelled = false;
|
||||
let stableFrames = 0;
|
||||
let lastHeight = 0;
|
||||
const cancel = () => { cancelled = true; };
|
||||
cancelEvents.forEach((name) => window.addEventListener(name, cancel, { once: true, passive: true }));
|
||||
const cleanup = () => cancelEvents.forEach((name) => window.removeEventListener(name, cancel));
|
||||
const step = () => {
|
||||
if (cancelled) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
const limit = Math.max(0, doc.scrollHeight - window.innerHeight);
|
||||
const target = Math.min(y, limit);
|
||||
if (Math.abs(window.scrollY - target) > 1) this.scrollInstant(target);
|
||||
stableFrames = doc.scrollHeight === lastHeight && target === y ? stableFrames + 1 : 0;
|
||||
lastHeight = doc.scrollHeight;
|
||||
if (stableFrames >= this.stableFramesNeeded || performance.now() > deadline) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(step);
|
||||
};
|
||||
step();
|
||||
}
|
||||
|
||||
scrollInstant(top) {
|
||||
try {
|
||||
window.scrollTo({ top, left: 0, behavior: "instant" });
|
||||
} catch {
|
||||
window.scrollTo(0, top);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user