diff --git a/web/index.jsp b/web/index.jsp
index a761227..f6c9d24 100644
--- a/web/index.jsp
+++ b/web/index.jsp
@@ -199,6 +199,28 @@
color: var(--muted);
text-align: center;
}
+ .tracker {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ margin-top: 14px;
+ }
+ .tracker-item {
+ display: flex;
+ gap: 10px;
+ align-items: flex-start;
+ padding: 12px 14px;
+ border-radius: 16px;
+ border: 1px solid var(--line);
+ background: rgba(255,255,255,0.5);
+ }
+ .tracker-item input {
+ margin-top: 3px;
+ }
+ .tracker-item strong {
+ display: block;
+ margin-bottom: 4px;
+ }
@media (max-width: 1100px) {
.layout { grid-template-columns: 1fr; }
.grid, .pipeline, .metrics { grid-template-columns: 1fr 1fr; }
@@ -229,6 +251,8 @@
Guide pages4
Demo forms3
JSON endpoints2
+ Core lab track4
+ Completed labs0
@@ -257,9 +281,67 @@
JSON endpoint
+
+
+
+ Request lifecycle
+ How to explain Struts2 with one mental model
+
+
+
1. Request enters
+
The browser sends a URL and optional form fields or query params.
+
+
+
2. Action mapping
+
struts.xml resolves the action name and target class.
+
+
+
3. Parameter binding
+
Struts populates action properties before execute() runs.
+
+
+
4. Result routing
+
The returned result name decides which JSP or JSON renderer will respond.
+
+
+
+
Catalog
Interactive demos and guides
@@ -385,6 +467,33 @@
const searchInput = document.getElementById('searchInput');
const cards = Array.from(document.querySelectorAll('.demo-card'));
const emptyState = document.getElementById('emptyState');
+ const trackerItems = Array.from(document.querySelectorAll('[data-track]'));
+ const trackerKey = 'struts_demo_tracker';
+
+ function getTrackerState() {
+ return JSON.parse(localStorage.getItem(trackerKey) || '{}');
+ }
+
+ function renderTracker() {
+ const state = getTrackerState();
+ let completed = 0;
+ trackerItems.forEach((item) => {
+ item.checked = Boolean(state[item.dataset.track]);
+ if (item.checked) {
+ completed += 1;
+ }
+ });
+ document.getElementById('completedLabs').textContent = completed;
+ }
+
+ trackerItems.forEach((item) => {
+ item.addEventListener('change', () => {
+ const state = getTrackerState();
+ state[item.dataset.track] = item.checked;
+ localStorage.setItem(trackerKey, JSON.stringify(state));
+ renderTracker();
+ });
+ });
searchInput.addEventListener('input', () => {
const keyword = searchInput.value.trim().toLowerCase();
@@ -398,6 +507,8 @@
});
emptyState.style.display = visible ? 'none' : 'block';
});
+
+ renderTracker();