Просмотр исходного кода

feat: add calendar, kanban, chat, file manager, and projects apps

Adds five "app" pages that move AdminLTE closer to feature parity with
Tabler and CoreUI Pro:

- pages/calendar.html      FullCalendar 6 integration (CDN). Month /
                           week / day / list views, drag-and-drop from
                           an external events sidebar, click-to-create,
                           click-to-delete. MIT-licensed, no jQuery.
- pages/kanban.html        SortableJS-powered board with four lanes.
                           Drag cards within and between lanes,
                           live-updating count badges, ghost preview
                           during drag, prompt-driven "add card" per
                           lane.
- pages/chat.html          Two-pane full-page chat. Contact list with
                           online-status dots and unread badges,
                           scrollable message thread with sent /
                           received bubbles, live composer that appends
                           real messages with timestamps.
- pages/file-manager.html  Folder tree (Bootstrap list-group flush) +
                           breadcrumb + storage quota. Grid and list
                           views with a toggle, file-type icons in
                           semantic colors, shared-file badge.
- pages/projects.html      Four KPI summary cards, full data table with
                           status badges, bucketed progress bars,
                           stacked team avatars, priority chips, and
                           pagination.

External libraries are CDN-loaded so nothing is added to the framework
bundle. Sidenav entries land in a later commit.
Aigars Silkalns 15 часов назад
Родитель
Сommit
8956b9860f

+ 211 - 0
src/html/pages/pages/calendar.astro

@@ -0,0 +1,211 @@
+---
+import Head from "@components/_head.astro"
+import Footer from "@components/dashboard/_footer.astro"
+import Topbar from "@components/dashboard/_topbar.astro"
+import Sidenav from "@components/dashboard/_sidenav.astro"
+import Scripts from "@components/_scripts.astro"
+
+const title = "AdminLTE 4 | Calendar"
+const path = "../../../dist"
+const mainPage = "pages"
+const page = "calendar";
+---
+
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <Head title={title} path={path} />
+    <style is:inline>
+      .fc-event {
+        cursor: pointer;
+      }
+      .draggable-event {
+        cursor: grab;
+        user-select: none;
+      }
+    </style>
+  </head>
+  <body class="layout-fixed sidebar-expand-lg bg-body-tertiary">
+    <div class="app-wrapper">
+      <Topbar path={path} />
+      <Sidenav path={path} mainPage={mainPage} page={page} />
+      <main class="app-main">
+        <div class="app-content-header">
+          <div class="container-fluid">
+            <div class="row">
+              <div class="col-sm-6">
+                <h3 class="mb-0">Calendar</h3>
+              </div>
+              <div class="col-sm-6">
+                <ol class="breadcrumb float-sm-end">
+                  <li class="breadcrumb-item"><a href="#">Home</a></li>
+                  <li class="breadcrumb-item active" aria-current="page">Calendar</li>
+                </ol>
+              </div>
+            </div>
+          </div>
+        </div>
+        <div class="app-content">
+          <div class="container-fluid">
+            <div class="row g-3">
+              <!-- Sidebar: draggable events -->
+              <div class="col-lg-3">
+                <div class="card">
+                  <div class="card-header">
+                    <h3 class="card-title">Draggable events</h3>
+                  </div>
+                  <div class="card-body">
+                    <p class="text-secondary small mb-3">
+                      Drag an event to the calendar to schedule it.
+                    </p>
+                    <div id="external-events">
+                      <div
+                        class="draggable-event badge text-bg-primary p-2 mb-2 d-block text-start"
+                        data-color="#0d6efd"
+                      >
+                        <i class="bi bi-grip-vertical me-1" aria-hidden="true"></i>
+                        Team standup
+                      </div>
+                      <div
+                        class="draggable-event badge text-bg-success p-2 mb-2 d-block text-start"
+                        data-color="#198754"
+                      >
+                        <i class="bi bi-grip-vertical me-1" aria-hidden="true"></i>
+                        Customer call
+                      </div>
+                      <div
+                        class="draggable-event badge text-bg-warning p-2 mb-2 d-block text-start"
+                        data-color="#ffc107"
+                      >
+                        <i class="bi bi-grip-vertical me-1" aria-hidden="true"></i>
+                        Design review
+                      </div>
+                      <div
+                        class="draggable-event badge text-bg-info p-2 mb-2 d-block text-start"
+                        data-color="#0dcaf0"
+                      >
+                        <i class="bi bi-grip-vertical me-1" aria-hidden="true"></i>
+                        1:1 with manager
+                      </div>
+                      <div
+                        class="draggable-event badge text-bg-danger p-2 d-block text-start"
+                        data-color="#dc3545"
+                      >
+                        <i class="bi bi-grip-vertical me-1" aria-hidden="true"></i>
+                        Release window
+                      </div>
+                    </div>
+                    <hr />
+                    <div class="form-check form-switch">
+                      <input
+                        class="form-check-input"
+                        type="checkbox"
+                        role="switch"
+                        id="remove-after-drop"
+                      />
+                      <label
+                        class="form-check-label small"
+                        for="remove-after-drop"
+                      >
+                        Remove from list after dropping
+                      </label>
+                    </div>
+                  </div>
+                </div>
+              </div>
+              <!-- Calendar -->
+              <div class="col-lg-9">
+                <div class="card">
+                  <div class="card-body">
+                    <div id="calendar"></div>
+                  </div>
+                  <div class="card-footer text-secondary small">
+                    Powered by
+                    <a
+                      href="https://fullcalendar.io/"
+                      target="_blank"
+                      rel="noopener">FullCalendar 6</a
+                    >
+                    &mdash; MIT licensed, jQuery-free.
+                  </div>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </main>
+      <Footer />
+    </div>
+    <Scripts path={path} />
+    <script
+      src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.20/index.global.min.js"
+      crossorigin="anonymous"></script>
+    <script is:inline>
+      const isoDate = (d) => d.toISOString().slice(0, 10);
+
+      document.addEventListener("DOMContentLoaded", () => {
+        const calendarEl = document.getElementById("calendar");
+        const externalEl = document.getElementById("external-events");
+        const removeAfterDrop = document.getElementById("remove-after-drop");
+
+        new FullCalendar.Draggable(externalEl, {
+          itemSelector: ".draggable-event",
+          eventData: (el) => ({
+            title: el.textContent.trim(),
+            backgroundColor: el.dataset.color,
+            borderColor: el.dataset.color
+          })
+        });
+
+        const today = new Date();
+        const offsetDay = (n) => {
+          const d = new Date(today);
+          d.setDate(today.getDate() + n);
+          return isoDate(d);
+        };
+
+        const calendar = new FullCalendar.Calendar(calendarEl, {
+          initialView: "dayGridMonth",
+          headerToolbar: {
+            start: "prev,next today",
+            center: "title",
+            end: "dayGridMonth,timeGridWeek,timeGridDay,listWeek"
+          },
+          height: 700,
+          editable: true,
+          droppable: true,
+          dayMaxEvents: 3,
+          drop: (info) => {
+            if (removeAfterDrop.checked) {
+              info.draggedEl.remove();
+            }
+          },
+          dateClick: (info) => {
+            const title = prompt("Event title:");
+            if (title) {
+              calendar.addEvent({
+                title: title,
+                start: info.dateStr,
+                allDay: info.allDay
+              });
+            }
+          },
+          eventClick: (info) => {
+            if (confirm(`Delete "${info.event.title}"?`)) {
+              info.event.remove();
+            }
+          },
+          events: [
+            { title: "Quarterly planning", start: offsetDay(-2), backgroundColor: "#0d6efd", borderColor: "#0d6efd" },
+            { title: "Onboarding session", start: offsetDay(1), backgroundColor: "#198754", borderColor: "#198754" },
+            { title: "Design review", start: offsetDay(3), end: offsetDay(4), backgroundColor: "#ffc107", borderColor: "#ffc107", textColor: "#000" },
+            { title: "Release v2.5", start: offsetDay(7), backgroundColor: "#dc3545", borderColor: "#dc3545" },
+            { title: "All-hands", start: offsetDay(10), backgroundColor: "#6f42c1", borderColor: "#6f42c1" }
+          ]
+        });
+
+        calendar.render();
+      });
+    </script>
+  </body>
+</html>

+ 364 - 0
src/html/pages/pages/chat.astro

@@ -0,0 +1,364 @@
+---
+import Head from "@components/_head.astro"
+import Footer from "@components/dashboard/_footer.astro"
+import Topbar from "@components/dashboard/_topbar.astro"
+import Sidenav from "@components/dashboard/_sidenav.astro"
+import Scripts from "@components/_scripts.astro"
+
+const title = "AdminLTE 4 | Chat"
+const path = "../../../dist"
+const mainPage = "pages"
+const page = "chat";
+
+const contacts = [
+  { name: "Olivia Bennett", initials: "OB", color: "primary", last: "Approved — a few small notes…", time: "2m", unread: 2, online: true, active: true },
+  { name: "Marcus Reyes", initials: "MR", color: "success", last: "Lunch Thursday?", time: "1h", unread: 0, online: true, active: false },
+  { name: "Sara Khan", initials: "SK", color: "info", last: "Customer interview notes are up.", time: "3h", unread: 0, online: false, active: false },
+  { name: "Diego Smania", initials: "DS", color: "warning", last: "PR is ready for review.", time: "Yesterday", unread: 1, online: false, active: false },
+  { name: "Emma Dawson", initials: "ED", color: "danger", last: "Heading out, see you Mon.", time: "Yesterday", unread: 0, online: false, active: false },
+  { name: "Liam Carter", initials: "LC", color: "primary", last: "Pushed the calendar fix.", time: "May 16", unread: 0, online: true, active: false },
+  { name: "Ava Foster", initials: "AF", color: "secondary", last: "Adding you to the design crit.", time: "May 15", unread: 0, online: false, active: false }
+];
+
+type Message = {
+  who: "me" | "them";
+  text: string;
+  time: string;
+};
+
+const messages: Message[] = [
+  { who: "them", text: "Hey Jane! Did you get a chance to look at the v2.4 candidate?", time: "10:38 AM" },
+  { who: "me", text: "Just finished going through it. Overall really solid — the new motion primitives are great.", time: "10:40 AM" },
+  { who: "them", text: "Glad you like them. Any concerns?", time: "10:40 AM" },
+  { who: "me", text: "Two small things: the success state on form inputs feels light, and the focus ring is barely visible on dark theme.", time: "10:41 AM" },
+  { who: "them", text: "Yeah, that focus ring issue has been bugging me too. I’ll bump the contrast and ping you for another look.", time: "10:42 AM" },
+  { who: "me", text: "Sounds good. Otherwise, ship it!", time: "10:42 AM" }
+];
+---
+
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <Head title={title} path={path} />
+    <style is:inline>
+      .chat-app {
+        display: grid;
+        grid-template-columns: 320px 1fr;
+        gap: 0;
+        height: calc(100vh - 14rem);
+        min-height: 32rem;
+        border-radius: var(--bs-border-radius);
+        overflow: hidden;
+        background: var(--bs-body-bg);
+        border: 1px solid var(--bs-border-color);
+      }
+      @media (max-width: 768px) {
+        .chat-app {
+          grid-template-columns: 1fr;
+        }
+        .chat-app .chat-contacts {
+          display: none;
+        }
+      }
+      .chat-contacts {
+        background: var(--bs-tertiary-bg);
+        border-right: 1px solid var(--bs-border-color);
+        display: flex;
+        flex-direction: column;
+        min-height: 0;
+      }
+      .chat-contact {
+        display: flex;
+        gap: .75rem;
+        padding: .75rem 1rem;
+        cursor: pointer;
+        border-bottom: 1px solid var(--bs-border-color);
+        text-decoration: none;
+        color: inherit;
+      }
+      .chat-contact:hover {
+        background: var(--bs-body-bg);
+      }
+      .chat-contact.active {
+        background: var(--bs-body-bg);
+        border-left: 3px solid var(--bs-primary);
+        padding-left: calc(1rem - 3px);
+      }
+      .chat-avatar {
+        position: relative;
+        flex-shrink: 0;
+        width: 40px;
+        height: 40px;
+        border-radius: 50%;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        font-weight: 600;
+        font-size: .8rem;
+      }
+      .chat-avatar.online::after {
+        content: "";
+        position: absolute;
+        bottom: 0;
+        right: 0;
+        width: 10px;
+        height: 10px;
+        background: var(--bs-success);
+        border: 2px solid var(--bs-body-bg);
+        border-radius: 50%;
+      }
+      .chat-conversation {
+        display: flex;
+        flex-direction: column;
+        min-height: 0;
+      }
+      .chat-header {
+        padding: .75rem 1rem;
+        border-bottom: 1px solid var(--bs-border-color);
+        display: flex;
+        align-items: center;
+        gap: .75rem;
+      }
+      .chat-messages {
+        flex: 1;
+        overflow-y: auto;
+        padding: 1rem;
+        background: var(--bs-tertiary-bg);
+      }
+      .chat-message {
+        display: flex;
+        margin-bottom: .75rem;
+      }
+      .chat-message.me {
+        justify-content: flex-end;
+      }
+      .chat-bubble {
+        max-width: 70%;
+        padding: .5rem .75rem;
+        border-radius: 1rem;
+        font-size: .9rem;
+        line-height: 1.4;
+      }
+      .chat-message.them .chat-bubble {
+        background: var(--bs-body-bg);
+        border: 1px solid var(--bs-border-color);
+        border-bottom-left-radius: .25rem;
+      }
+      .chat-message.me .chat-bubble {
+        background: var(--bs-primary);
+        color: #fff;
+        border-bottom-right-radius: .25rem;
+      }
+      .chat-time {
+        font-size: .7rem;
+        opacity: .7;
+        display: block;
+        margin-top: .15rem;
+      }
+      .chat-composer {
+        padding: .75rem;
+        border-top: 1px solid var(--bs-border-color);
+      }
+    </style>
+  </head>
+  <body class="layout-fixed sidebar-expand-lg bg-body-tertiary">
+    <div class="app-wrapper">
+      <Topbar path={path} />
+      <Sidenav path={path} mainPage={mainPage} page={page} />
+      <main class="app-main">
+        <div class="app-content-header">
+          <div class="container-fluid">
+            <div class="row">
+              <div class="col-sm-6">
+                <h3 class="mb-0">Chat</h3>
+              </div>
+              <div class="col-sm-6">
+                <ol class="breadcrumb float-sm-end">
+                  <li class="breadcrumb-item"><a href="#">Home</a></li>
+                  <li class="breadcrumb-item active" aria-current="page">Chat</li>
+                </ol>
+              </div>
+            </div>
+          </div>
+        </div>
+        <div class="app-content">
+          <div class="container-fluid">
+            <div class="chat-app">
+              <!-- Contacts -->
+              <aside class="chat-contacts">
+                <div class="p-3 border-bottom">
+                  <div class="input-group input-group-sm">
+                    <span class="input-group-text bg-body">
+                      <i class="bi bi-search" aria-hidden="true"></i>
+                    </span>
+                    <input
+                      type="search"
+                      class="form-control"
+                      placeholder="Search contacts&hellip;"
+                      aria-label="Search contacts"
+                    />
+                  </div>
+                </div>
+                <div class="flex-grow-1 overflow-auto">
+                  {
+                    contacts.map((c) => (
+                      <a
+                        href="#"
+                        class:list={["chat-contact", c.active && "active"]}
+                      >
+                        <span
+                          class:list={[
+                            "chat-avatar",
+                            `bg-${c.color}-subtle`,
+                            `text-${c.color}`,
+                            c.online && "online"
+                          ]}
+                        >
+                          {c.initials}
+                        </span>
+                        <div class="flex-grow-1 overflow-hidden">
+                          <div class="d-flex justify-content-between">
+                            <p
+                              class:list={[
+                                "mb-0 text-truncate",
+                                c.unread > 0 && "fw-semibold"
+                              ]}
+                            >
+                              {c.name}
+                            </p>
+                            <small class="text-secondary flex-shrink-0 ms-2">
+                              {c.time}
+                            </small>
+                          </div>
+                          <div class="d-flex justify-content-between align-items-center">
+                            <small
+                              class:list={[
+                                "text-truncate",
+                                c.unread > 0 ? "fw-semibold" : "text-secondary"
+                              ]}
+                            >
+                              {c.last}
+                            </small>
+                            {c.unread > 0 && (
+                              <span class="badge text-bg-primary rounded-pill ms-2">
+                                {c.unread}
+                              </span>
+                            )}
+                          </div>
+                        </div>
+                      </a>
+                    ))
+                  }
+                </div>
+              </aside>
+
+              <!-- Conversation -->
+              <section class="chat-conversation">
+                <header class="chat-header">
+                  <span class="chat-avatar bg-primary-subtle text-primary online">
+                    OB
+                  </span>
+                  <div class="flex-grow-1">
+                    <p class="mb-0 fw-semibold">Olivia Bennett</p>
+                    <small class="text-success">
+                      <i
+                        class="bi bi-circle-fill"
+                        style="font-size: .5rem;"
+                        aria-hidden="true"
+                      ></i>
+                      Online &middot; typing&hellip;
+                    </small>
+                  </div>
+                  <div class="btn-group btn-group-sm">
+                    <button class="btn btn-outline-secondary" type="button" title="Call">
+                      <i class="bi bi-telephone" aria-hidden="true"></i>
+                    </button>
+                    <button class="btn btn-outline-secondary" type="button" title="Video call">
+                      <i class="bi bi-camera-video" aria-hidden="true"></i>
+                    </button>
+                    <button class="btn btn-outline-secondary" type="button" title="More">
+                      <i class="bi bi-three-dots-vertical" aria-hidden="true"></i>
+                    </button>
+                  </div>
+                </header>
+
+                <div class="chat-messages" id="chat-messages">
+                  {
+                    messages.map((m) => (
+                      <div class:list={["chat-message", m.who]}>
+                        <div class="chat-bubble">
+                          {m.text}
+                          <span class="chat-time">{m.time}</span>
+                        </div>
+                      </div>
+                    ))
+                  }
+                </div>
+
+                <form class="chat-composer" id="chat-composer">
+                  <div class="input-group">
+                    <button
+                      class="btn btn-outline-secondary"
+                      type="button"
+                      title="Attach"
+                    >
+                      <i class="bi bi-paperclip" aria-hidden="true"></i>
+                    </button>
+                    <input
+                      type="text"
+                      class="form-control"
+                      placeholder="Type a message&hellip;"
+                      aria-label="Type a message"
+                      id="chat-input"
+                    />
+                    <button
+                      class="btn btn-outline-secondary"
+                      type="button"
+                      title="Emoji"
+                    >
+                      <i class="bi bi-emoji-smile" aria-hidden="true"></i>
+                    </button>
+                    <button class="btn btn-primary" type="submit">
+                      <i class="bi bi-send" aria-hidden="true"></i>
+                    </button>
+                  </div>
+                </form>
+              </section>
+            </div>
+          </div>
+        </div>
+      </main>
+      <Footer />
+    </div>
+    <Scripts path={path} />
+    <script is:inline>
+      const fmtTime = () =>
+        new Date().toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
+
+      document.addEventListener("DOMContentLoaded", () => {
+        const form = document.getElementById("chat-composer");
+        const input = document.getElementById("chat-input");
+        const list = document.getElementById("chat-messages");
+
+        list.scrollTop = list.scrollHeight;
+
+        form.addEventListener("submit", (e) => {
+          e.preventDefault();
+          const text = input.value.trim();
+          if (!text) return;
+          const wrap = document.createElement("div");
+          wrap.className = "chat-message me";
+          wrap.innerHTML = `<div class="chat-bubble"></div>`;
+          wrap.firstChild.textContent = text;
+          const time = document.createElement("span");
+          time.className = "chat-time";
+          time.textContent = fmtTime();
+          wrap.firstChild.append(time);
+          list.append(wrap);
+          input.value = "";
+          list.scrollTop = list.scrollHeight;
+        });
+      });
+    </script>
+  </body>
+</html>

+ 425 - 0
src/html/pages/pages/file-manager.astro

@@ -0,0 +1,425 @@
+---
+import Head from "@components/_head.astro"
+import Footer from "@components/dashboard/_footer.astro"
+import Topbar from "@components/dashboard/_topbar.astro"
+import Sidenav from "@components/dashboard/_sidenav.astro"
+import Scripts from "@components/_scripts.astro"
+
+const title = "AdminLTE 4 | File Manager"
+const path = "../../../dist"
+const mainPage = "pages"
+const page = "file-manager";
+
+type FolderTree = {
+  name: string;
+  icon: string;
+  count?: number;
+  children?: FolderTree[];
+  open?: boolean;
+};
+
+const tree: FolderTree[] = [
+  {
+    name: "My Drive",
+    icon: "house",
+    open: true,
+    count: 24,
+    children: [
+      { name: "Documents", icon: "folder", count: 12 },
+      { name: "Design", icon: "folder", count: 8, open: true, children: [
+        { name: "v2.4 candidates", icon: "folder", count: 4 },
+        { name: "Archive", icon: "folder", count: 28 }
+      ] },
+      { name: "Invoices", icon: "folder", count: 41 }
+    ]
+  },
+  { name: "Shared with me", icon: "people", count: 9 },
+  { name: "Starred", icon: "star", count: 6 },
+  { name: "Recent", icon: "clock-history" },
+  { name: "Trash", icon: "trash", count: 3 }
+];
+
+type FileItem = {
+  name: string;
+  type: "folder" | "pdf" | "image" | "doc" | "sheet" | "zip" | "code";
+  size: string;
+  modified: string;
+  shared?: boolean;
+};
+
+const items: FileItem[] = [
+  { name: "Customer interviews", type: "folder", size: "—", modified: "Today" },
+  { name: "Q2 planning", type: "folder", size: "—", modified: "Yesterday", shared: true },
+  { name: "design-review.pdf", type: "pdf", size: "1.4 MB", modified: "10:42 AM" },
+  { name: "focus-ring-dark.png", type: "image", size: "320 KB", modified: "10:38 AM" },
+  { name: "INV-2026-00428.pdf", type: "pdf", size: "184 KB", modified: "Yesterday" },
+  { name: "roadmap.docx", type: "doc", size: "47 KB", modified: "Yesterday", shared: true },
+  { name: "analytics-may.xlsx", type: "sheet", size: "92 KB", modified: "May 16" },
+  { name: "site-export-2026-05.zip", type: "zip", size: "12.3 MB", modified: "May 14" },
+  { name: "main.tsx", type: "code", size: "8 KB", modified: "May 12" }
+];
+
+const iconFor: Record<FileItem["type"], { icon: string; color: string }> = {
+  folder: { icon: "folder-fill", color: "warning" },
+  pdf: { icon: "file-earmark-pdf-fill", color: "danger" },
+  image: { icon: "file-earmark-image-fill", color: "primary" },
+  doc: { icon: "file-earmark-word-fill", color: "info" },
+  sheet: { icon: "file-earmark-spreadsheet-fill", color: "success" },
+  zip: { icon: "file-earmark-zip-fill", color: "secondary" },
+  code: { icon: "file-earmark-code-fill", color: "primary" }
+};
+
+const renderTree = (nodes: FolderTree[]) => nodes;
+---
+
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <Head title={title} path={path} />
+  </head>
+  <body class="layout-fixed sidebar-expand-lg bg-body-tertiary">
+    <div class="app-wrapper">
+      <Topbar path={path} />
+      <Sidenav path={path} mainPage={mainPage} page={page} />
+      <main class="app-main">
+        <div class="app-content-header">
+          <div class="container-fluid">
+            <div class="row">
+              <div class="col-sm-6">
+                <h3 class="mb-0">File Manager</h3>
+              </div>
+              <div class="col-sm-6">
+                <ol class="breadcrumb float-sm-end">
+                  <li class="breadcrumb-item"><a href="#">Home</a></li>
+                  <li class="breadcrumb-item active" aria-current="page">Files</li>
+                </ol>
+              </div>
+            </div>
+          </div>
+        </div>
+        <div class="app-content">
+          <div class="container-fluid">
+            <div class="row g-3">
+              <!-- Folder tree -->
+              <div class="col-lg-3">
+                <div class="d-grid gap-2 mb-3">
+                  <button class="btn btn-primary" type="button">
+                    <i class="bi bi-cloud-upload me-1" aria-hidden="true"></i>
+                    Upload files
+                  </button>
+                  <button class="btn btn-outline-secondary" type="button">
+                    <i class="bi bi-folder-plus me-1" aria-hidden="true"></i>
+                    New folder
+                  </button>
+                </div>
+                <div class="card">
+                  <div class="list-group list-group-flush">
+                    {
+                      renderTree(tree).map((node) => (
+                        <>
+                          <a
+                            href="#"
+                            class:list={[
+                              "list-group-item list-group-item-action d-flex justify-content-between align-items-center",
+                              node.open && "active"
+                            ]}
+                          >
+                            <span>
+                              <i
+                                class={`bi bi-${node.icon} me-2`}
+                                aria-hidden="true"
+                              />
+                              {node.name}
+                            </span>
+                            {node.count !== undefined && (
+                              <small class="opacity-75">{node.count}</small>
+                            )}
+                          </a>
+                          {node.children?.map((c) => (
+                            <>
+                              <a
+                                href="#"
+                                class:list={[
+                                  "list-group-item list-group-item-action d-flex justify-content-between align-items-center ps-4",
+                                  c.open && "active"
+                                ]}
+                              >
+                                <span>
+                                  <i
+                                    class={`bi bi-${c.icon} me-2`}
+                                    aria-hidden="true"
+                                  />
+                                  {c.name}
+                                </span>
+                                {c.count !== undefined && (
+                                  <small class="opacity-75">{c.count}</small>
+                                )}
+                              </a>
+                              {c.children?.map((gc) => (
+                                <a
+                                  href="#"
+                                  class="list-group-item list-group-item-action d-flex justify-content-between align-items-center ps-5"
+                                >
+                                  <span>
+                                    <i
+                                      class={`bi bi-${gc.icon} me-2`}
+                                      aria-hidden="true"
+                                    />
+                                    {gc.name}
+                                  </span>
+                                  {gc.count !== undefined && (
+                                    <small class="opacity-75">{gc.count}</small>
+                                  )}
+                                </a>
+                              ))}
+                            </>
+                          ))}
+                        </>
+                      ))
+                    }
+                  </div>
+                </div>
+                <div class="card mt-3">
+                  <div class="card-body">
+                    <p class="fw-semibold mb-2 small">
+                      <i class="bi bi-cloud me-1" aria-hidden="true"></i>
+                      Storage
+                    </p>
+                    <div class="progress mb-2" style="height: 8px;">
+                      <div
+                        class="progress-bar"
+                        role="progressbar"
+                        style="width: 62%;"
+                        aria-valuenow="62"
+                        aria-valuemin="0"
+                        aria-valuemax="100"
+                      />
+                    </div>
+                    <small class="text-secondary">
+                      6.2 GB of 10 GB used
+                    </small>
+                  </div>
+                </div>
+              </div>
+
+              <!-- File browser -->
+              <div class="col-lg-9">
+                <div class="card">
+                  <div class="card-header d-flex flex-wrap gap-2 align-items-center">
+                    <nav aria-label="breadcrumb" class="flex-grow-1">
+                      <ol class="breadcrumb mb-0">
+                        <li class="breadcrumb-item">
+                          <a href="#">
+                            <i class="bi bi-house" aria-hidden="true"></i>
+                          </a>
+                        </li>
+                        <li class="breadcrumb-item">
+                          <a href="#">My Drive</a>
+                        </li>
+                        <li class="breadcrumb-item active" aria-current="page">
+                          Design
+                        </li>
+                      </ol>
+                    </nav>
+                    <div class="input-group input-group-sm" style="width: 14rem;">
+                      <span class="input-group-text">
+                        <i class="bi bi-search" aria-hidden="true"></i>
+                      </span>
+                      <input
+                        type="search"
+                        class="form-control"
+                        placeholder="Search files&hellip;"
+                        aria-label="Search files"
+                      />
+                    </div>
+                    <div
+                      class="btn-group btn-group-sm"
+                      role="group"
+                      aria-label="View"
+                    >
+                      <input
+                        type="radio"
+                        class="btn-check"
+                        name="view"
+                        id="view-grid"
+                        checked
+                      />
+                      <label class="btn btn-outline-secondary" for="view-grid">
+                        <i class="bi bi-grid-3x3-gap" aria-hidden="true"></i>
+                      </label>
+                      <input
+                        type="radio"
+                        class="btn-check"
+                        name="view"
+                        id="view-list"
+                      />
+                      <label class="btn btn-outline-secondary" for="view-list">
+                        <i class="bi bi-list-ul" aria-hidden="true"></i>
+                      </label>
+                    </div>
+                  </div>
+                  <div class="card-body">
+                    <!-- Grid view -->
+                    <div
+                      id="grid-view"
+                      class="row row-cols-2 row-cols-md-3 row-cols-xl-4 g-3"
+                    >
+                      {
+                        items.map((it) => {
+                          const ic = iconFor[it.type];
+                          return (
+                            <div class="col">
+                              <a
+                                href="#"
+                                class="card text-center text-decoration-none text-body h-100 position-relative"
+                              >
+                                {it.shared && (
+                                  <span class="badge text-bg-info position-absolute top-0 end-0 m-2">
+                                    <i
+                                      class="bi bi-people-fill me-1"
+                                      aria-hidden="true"
+                                    />
+                                    Shared
+                                  </span>
+                                )}
+                                <div class="card-body d-flex flex-column justify-content-center pb-2">
+                                  <i
+                                    class={`bi bi-${ic.icon} text-${ic.color} display-5 mb-3`}
+                                    aria-hidden="true"
+                                  />
+                                  <p class="card-title fw-medium small text-break mb-0">
+                                    {it.name}
+                                  </p>
+                                </div>
+                                <div class="card-footer bg-transparent small text-secondary py-2">
+                                  <div class="d-flex justify-content-between align-items-center gap-2">
+                                    <span class="text-truncate" title={it.size}>
+                                      {it.type === "folder" ?
+                                        <>
+                                          <i
+                                            class="bi bi-folder me-1"
+                                            aria-hidden="true"
+                                          />
+                                          Folder
+                                        </> :
+                                        it.size}
+                                    </span>
+                                    <span
+                                      class="text-truncate"
+                                      title={it.modified}
+                                    >
+                                      {it.modified}
+                                    </span>
+                                  </div>
+                                </div>
+                              </a>
+                            </div>
+                          );
+                        })
+                      }
+                    </div>
+                    <!-- List view -->
+                    <div id="list-view" class="d-none">
+                      <div class="table-responsive">
+                        <table class="table align-middle mb-0">
+                          <thead>
+                            <tr>
+                              <th>Name</th>
+                              <th>Size</th>
+                              <th>Modified</th>
+                              <th class="text-end">Actions</th>
+                            </tr>
+                          </thead>
+                          <tbody>
+                            {
+                              items.map((it) => {
+                                const ic = iconFor[it.type];
+                                return (
+                                  <tr>
+                                    <td>
+                                      <i
+                                        class={`bi bi-${ic.icon} text-${ic.color} me-2`}
+                                        aria-hidden="true"
+                                      />
+                                      {it.name}
+                                      {it.shared && (
+                                        <span class="badge text-bg-info ms-2">
+                                          Shared
+                                        </span>
+                                      )}
+                                    </td>
+                                    <td>{it.size}</td>
+                                    <td>{it.modified}</td>
+                                    <td class="text-end">
+                                      <div class="btn-group btn-group-sm">
+                                        <button
+                                          class="btn btn-outline-secondary"
+                                          type="button"
+                                          title="Download"
+                                        >
+                                          <i
+                                            class="bi bi-download"
+                                            aria-hidden="true"
+                                          />
+                                        </button>
+                                        <button
+                                          class="btn btn-outline-secondary"
+                                          type="button"
+                                          title="Share"
+                                        >
+                                          <i
+                                            class="bi bi-share"
+                                            aria-hidden="true"
+                                          />
+                                        </button>
+                                        <button
+                                          class="btn btn-outline-danger"
+                                          type="button"
+                                          title="Delete"
+                                        >
+                                          <i
+                                            class="bi bi-trash"
+                                            aria-hidden="true"
+                                          />
+                                        </button>
+                                      </div>
+                                    </td>
+                                  </tr>
+                                );
+                              })
+                            }
+                          </tbody>
+                        </table>
+                      </div>
+                    </div>
+                  </div>
+                  <div class="card-footer text-secondary small">
+                    {items.length} items
+                  </div>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </main>
+      <Footer />
+    </div>
+    <Scripts path={path} />
+    <script is:inline>
+      document.addEventListener("DOMContentLoaded", () => {
+        const grid = document.getElementById("view-grid");
+        const list = document.getElementById("view-list");
+        const gridView = document.getElementById("grid-view");
+        const listView = document.getElementById("list-view");
+        grid.addEventListener("change", () => {
+          gridView.classList.remove("d-none");
+          listView.classList.add("d-none");
+        });
+        list.addEventListener("change", () => {
+          listView.classList.remove("d-none");
+          gridView.classList.add("d-none");
+        });
+      });
+    </script>
+  </body>
+</html>

+ 363 - 0
src/html/pages/pages/kanban.astro

@@ -0,0 +1,363 @@
+---
+import Head from "@components/_head.astro"
+import Footer from "@components/dashboard/_footer.astro"
+import Topbar from "@components/dashboard/_topbar.astro"
+import Sidenav from "@components/dashboard/_sidenav.astro"
+import Scripts from "@components/_scripts.astro"
+
+const title = "AdminLTE 4 | Kanban Board"
+const path = "../../../dist"
+const mainPage = "pages"
+const page = "kanban";
+
+type Card = {
+  title: string;
+  desc?: string;
+  tag?: { label: string; color: string };
+  due?: string;
+  assignees: string[];
+};
+
+const lanes: { id: string; title: string; color: string; cards: Card[] }[] = [
+  {
+    id: "backlog",
+    title: "Backlog",
+    color: "secondary",
+    cards: [
+      {
+        title: "Audit unused SCSS variables",
+        desc: "Identify deprecated Bootstrap 5.3.4 variables and add comments.",
+        tag: { label: "tech debt", color: "secondary" },
+        assignees: ["DM"]
+      },
+      {
+        title: "Document hreflang setup",
+        tag: { label: "docs", color: "info" },
+        assignees: ["JD"]
+      },
+      {
+        title: "Investigate Safari iOS calendar drag bug",
+        tag: { label: "bug", color: "danger" },
+        due: "May 28",
+        assignees: ["OB", "MK"]
+      }
+    ]
+  },
+  {
+    id: "todo",
+    title: "To do",
+    color: "primary",
+    cards: [
+      {
+        title: "Add Tom Select recommended-integration doc",
+        desc: "Cover install, theming, single + multi select examples.",
+        tag: { label: "feature", color: "primary" },
+        due: "May 24",
+        assignees: ["JD"]
+      },
+      {
+        title: "Wire up profile page avatar upload",
+        tag: { label: "feature", color: "primary" },
+        assignees: ["EM"]
+      }
+    ]
+  },
+  {
+    id: "in-progress",
+    title: "In progress",
+    color: "warning",
+    cards: [
+      {
+        title: "Build kanban board demo",
+        desc: "SortableJS, draggable between lanes, MIT license.",
+        tag: { label: "feature", color: "primary" },
+        due: "Today",
+        assignees: ["JD"]
+      },
+      {
+        title: "Tabulator + FullCalendar integration QA",
+        tag: { label: "qa", color: "warning" },
+        assignees: ["OB"]
+      }
+    ]
+  },
+  {
+    id: "done",
+    title: "Done",
+    color: "success",
+    cards: [
+      {
+        title: "Upgrade to Bootstrap 5.3.8",
+        tag: { label: "feature", color: "primary" },
+        assignees: ["DM"]
+      },
+      {
+        title: "Ship 8 Tier-1 page templates",
+        desc: "Profile, settings, invoice, pricing, FAQ, 404/500/maintenance.",
+        tag: { label: "feature", color: "primary" },
+        assignees: ["JD", "OB"]
+      },
+      {
+        title: "Drop dead eslint-config-xo deps",
+        tag: { label: "tech debt", color: "secondary" },
+        assignees: ["DM"]
+      }
+    ]
+  }
+];
+---
+
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <Head title={title} path={path} />
+    <style is:inline>
+      .kanban-board {
+        display: grid;
+        grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+        gap: 1rem;
+        align-items: start;
+      }
+      .kanban-lane {
+        background: var(--bs-tertiary-bg);
+        border-radius: var(--bs-border-radius);
+        padding: .75rem;
+        min-height: 8rem;
+      }
+      .kanban-lane-header {
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        margin-bottom: .75rem;
+        padding: 0 .25rem;
+      }
+      .kanban-cards {
+        min-height: 4rem;
+      }
+      .kanban-card {
+        background: var(--bs-body-bg);
+        border: 1px solid var(--bs-border-color);
+        border-radius: var(--bs-border-radius);
+        padding: .75rem;
+        margin-bottom: .5rem;
+        cursor: grab;
+        transition: box-shadow .15s ease;
+      }
+      .kanban-card:hover {
+        box-shadow: var(--bs-box-shadow-sm);
+      }
+      .kanban-card.sortable-ghost {
+        opacity: .4;
+        background: var(--bs-primary-bg-subtle);
+        border-style: dashed;
+      }
+      .kanban-card.sortable-drag {
+        cursor: grabbing;
+        box-shadow: var(--bs-box-shadow);
+        transform: rotate(2deg);
+      }
+      .kanban-assignees {
+        display: inline-flex;
+      }
+      .kanban-assignee {
+        display: inline-flex;
+        align-items: center;
+        justify-content: center;
+        width: 1.75rem;
+        height: 1.75rem;
+        border-radius: 50%;
+        background: var(--bs-secondary-bg);
+        color: var(--bs-secondary-color);
+        font-size: .7rem;
+        font-weight: 600;
+        border: 2px solid var(--bs-body-bg);
+        margin-left: -.5rem;
+      }
+      .kanban-assignee:first-child {
+        margin-left: 0;
+      }
+      .kanban-add-card {
+        background: transparent;
+        border: 1px dashed var(--bs-border-color);
+        color: var(--bs-secondary-color);
+        width: 100%;
+        padding: .5rem;
+        border-radius: var(--bs-border-radius);
+        font-size: .875rem;
+      }
+      .kanban-add-card:hover {
+        background: var(--bs-body-bg);
+        color: var(--bs-body-color);
+      }
+    </style>
+  </head>
+  <body class="layout-fixed sidebar-expand-lg bg-body-tertiary">
+    <div class="app-wrapper">
+      <Topbar path={path} />
+      <Sidenav path={path} mainPage={mainPage} page={page} />
+      <main class="app-main">
+        <div class="app-content-header">
+          <div class="container-fluid">
+            <div class="row">
+              <div class="col-sm-6">
+                <h3 class="mb-0">Kanban Board</h3>
+              </div>
+              <div class="col-sm-6">
+                <ol class="breadcrumb float-sm-end">
+                  <li class="breadcrumb-item"><a href="#">Home</a></li>
+                  <li class="breadcrumb-item active" aria-current="page">Kanban</li>
+                </ol>
+              </div>
+            </div>
+          </div>
+        </div>
+        <div class="app-content">
+          <div class="container-fluid">
+            <div class="d-flex justify-content-between align-items-center mb-3">
+              <div>
+                <p class="text-secondary mb-0 small">
+                  Drag cards between lanes. Try dropping &ldquo;In progress&rdquo;
+                  items into &ldquo;Done&rdquo;.
+                </p>
+              </div>
+              <div class="btn-group btn-group-sm">
+                <button class="btn btn-outline-secondary" type="button">
+                  <i class="bi bi-funnel me-1" aria-hidden="true"></i>Filter
+                </button>
+                <button class="btn btn-outline-secondary" type="button">
+                  <i class="bi bi-sort-down me-1" aria-hidden="true"></i>Sort
+                </button>
+                <button class="btn btn-primary" type="button">
+                  <i class="bi bi-plus-lg me-1" aria-hidden="true"></i>Add lane
+                </button>
+              </div>
+            </div>
+
+            <div class="kanban-board" id="kanban-board">
+              {
+                lanes.map((lane) => (
+                  <div class="kanban-lane" data-lane-id={lane.id}>
+                    <div class="kanban-lane-header">
+                      <h2 class="h6 mb-0 d-flex align-items-center gap-2">
+                        <span
+                          class={`badge text-bg-${lane.color}`}
+                          style="font-size: .65rem;"
+                        >
+                          {lane.cards.length}
+                        </span>
+                        {lane.title}
+                      </h2>
+                      <button
+                        class="btn btn-sm btn-link text-secondary p-0"
+                        type="button"
+                        title="Lane actions"
+                        aria-label="Lane actions"
+                      >
+                        <i class="bi bi-three-dots" aria-hidden="true" />
+                      </button>
+                    </div>
+                    <div class="kanban-cards" data-lane-id={lane.id}>
+                      {lane.cards.map((card) => (
+                        <article class="kanban-card">
+                          {card.tag && (
+                            <span
+                              class={`badge text-bg-${card.tag.color} mb-2`}
+                            >
+                              {card.tag.label}
+                            </span>
+                          )}
+                          <p class="fw-semibold mb-1 small">{card.title}</p>
+                          {card.desc && (
+                            <p class="text-secondary small mb-2">{card.desc}</p>
+                          )}
+                          <div class="d-flex justify-content-between align-items-center">
+                            <div class="kanban-assignees">
+                              {card.assignees.map((a) => (
+                                <span class="kanban-assignee" title={a}>
+                                  {a}
+                                </span>
+                              ))}
+                            </div>
+                            {card.due && (
+                              <small class="text-secondary">
+                                <i
+                                  class="bi bi-calendar-event me-1"
+                                  aria-hidden="true"
+                                />
+                                {card.due}
+                              </small>
+                            )}
+                          </div>
+                        </article>
+                      ))}
+                    </div>
+                    <button
+                      class="kanban-add-card mt-2"
+                      type="button"
+                      data-add-card-for={lane.id}
+                    >
+                      <i class="bi bi-plus-lg me-1" aria-hidden="true" />
+                      Add card
+                    </button>
+                  </div>
+                ))
+              }
+            </div>
+          </div>
+        </div>
+      </main>
+      <Footer />
+    </div>
+    <Scripts path={path} />
+    <script
+      src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.7/Sortable.min.js"
+      crossorigin="anonymous"
+      is:inline></script>
+    <script is:inline>
+      const updateLaneCount = (laneEl) => {
+        const lane = laneEl.closest(".kanban-lane");
+        const badge = lane.querySelector(".kanban-lane-header .badge");
+        badge.textContent = laneEl.children.length;
+      };
+
+      document.addEventListener("DOMContentLoaded", () => {
+        document.querySelectorAll(".kanban-cards").forEach((el) => {
+          new Sortable(el, {
+            group: "kanban",
+            animation: 150,
+            ghostClass: "sortable-ghost",
+            dragClass: "sortable-drag",
+            onEnd: (evt) => {
+              updateLaneCount(evt.from);
+              if (evt.from !== evt.to) updateLaneCount(evt.to);
+            }
+          });
+        });
+
+        document.querySelectorAll("[data-add-card-for]").forEach((btn) => {
+          btn.addEventListener("click", () => {
+            const title = prompt("Card title:");
+            if (!title) return;
+            const laneId = btn.dataset.addCardFor;
+            const lane = document.querySelector(
+              `.kanban-cards[data-lane-id="${laneId}"]`
+            );
+            const card = document.createElement("article");
+            card.className = "kanban-card";
+            card.innerHTML = `
+              <p class="fw-semibold mb-1 small">${title.replace(/[<>&]/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" }[c]))}</p>
+              <div class="d-flex justify-content-between align-items-center">
+                <div class="kanban-assignees">
+                  <span class="kanban-assignee" title="You">YO</span>
+                </div>
+                <small class="text-secondary">just now</small>
+              </div>
+            `;
+            lane.append(card);
+            updateLaneCount(lane);
+          });
+        });
+      });
+    </script>
+  </body>
+</html>

+ 352 - 0
src/html/pages/pages/projects.astro

@@ -0,0 +1,352 @@
+---
+import Head from "@components/_head.astro"
+import Footer from "@components/dashboard/_footer.astro"
+import Topbar from "@components/dashboard/_topbar.astro"
+import Sidenav from "@components/dashboard/_sidenav.astro"
+import Scripts from "@components/_scripts.astro"
+
+const title = "AdminLTE 4 | Projects"
+const path = "../../../dist"
+const mainPage = "pages"
+const page = "projects";
+
+type Project = {
+  name: string;
+  client: string;
+  status: "On track" | "At risk" | "Delayed" | "Completed";
+  progress: number;
+  team: { initials: string; color: string }[];
+  budget: string;
+  due: string;
+  priority: "Low" | "Medium" | "High";
+};
+
+const projects: Project[] = [
+  {
+    name: "AdminLTE 4 Release",
+    client: "Internal",
+    status: "On track",
+    progress: 78,
+    team: [
+      { initials: "JD", color: "primary" },
+      { initials: "OB", color: "info" },
+      { initials: "DM", color: "success" }
+    ],
+    budget: "$24,500",
+    due: "Jun 14, 2026",
+    priority: "High"
+  },
+  {
+    name: "Marketing Site Redesign",
+    client: "Acme Corp",
+    status: "At risk",
+    progress: 42,
+    team: [
+      { initials: "OB", color: "info" },
+      { initials: "MR", color: "warning" }
+    ],
+    budget: "$48,000",
+    due: "Jul 02, 2026",
+    priority: "High"
+  },
+  {
+    name: "Mobile App v2",
+    client: "Nimbus Labs",
+    status: "On track",
+    progress: 61,
+    team: [
+      { initials: "LC", color: "primary" },
+      { initials: "ED", color: "danger" },
+      { initials: "AF", color: "secondary" },
+      { initials: "SK", color: "info" }
+    ],
+    budget: "$92,500",
+    due: "Aug 18, 2026",
+    priority: "Medium"
+  },
+  {
+    name: "Analytics Pipeline",
+    client: "Internal",
+    status: "Delayed",
+    progress: 22,
+    team: [{ initials: "DM", color: "success" }],
+    budget: "$12,000",
+    due: "May 30, 2026",
+    priority: "Medium"
+  },
+  {
+    name: "Brand Style Guide",
+    client: "Riverhaus",
+    status: "Completed",
+    progress: 100,
+    team: [
+      { initials: "OB", color: "info" },
+      { initials: "ED", color: "danger" }
+    ],
+    budget: "$8,200",
+    due: "May 09, 2026",
+    priority: "Low"
+  },
+  {
+    name: "Onboarding Email Flow",
+    client: "Acme Corp",
+    status: "On track",
+    progress: 55,
+    team: [
+      { initials: "MR", color: "warning" },
+      { initials: "JD", color: "primary" }
+    ],
+    budget: "$6,800",
+    due: "Jun 22, 2026",
+    priority: "Low"
+  }
+];
+
+const statusBadge: Record<Project["status"], string> = {
+  "On track": "success",
+  "At risk": "warning",
+  Delayed: "danger",
+  Completed: "secondary"
+};
+
+const priorityBadge: Record<Project["priority"], string> = {
+  Low: "secondary",
+  Medium: "info",
+  High: "danger"
+};
+
+const progressBar = (p: number) => {
+  if (p === 100) return "success";
+  if (p >= 60) return "primary";
+  if (p >= 30) return "info";
+  return "warning";
+};
+
+const totals = {
+  count: projects.length,
+  onTrack: projects.filter((p) => p.status === "On track").length,
+  atRisk: projects.filter((p) => p.status === "At risk" || p.status === "Delayed").length,
+  done: projects.filter((p) => p.status === "Completed").length
+};
+---
+
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <Head title={title} path={path} />
+  </head>
+  <body class="layout-fixed sidebar-expand-lg bg-body-tertiary">
+    <div class="app-wrapper">
+      <Topbar path={path} />
+      <Sidenav path={path} mainPage={mainPage} page={page} />
+      <main class="app-main">
+        <div class="app-content-header">
+          <div class="container-fluid">
+            <div class="row">
+              <div class="col-sm-6">
+                <h3 class="mb-0">Projects</h3>
+              </div>
+              <div class="col-sm-6">
+                <ol class="breadcrumb float-sm-end">
+                  <li class="breadcrumb-item"><a href="#">Home</a></li>
+                  <li class="breadcrumb-item active" aria-current="page">Projects</li>
+                </ol>
+              </div>
+            </div>
+          </div>
+        </div>
+        <div class="app-content">
+          <div class="container-fluid">
+            <!-- Summary cards -->
+            <div class="row g-3 mb-3">
+              <div class="col-md-3 col-6">
+                <div class="card h-100">
+                  <div class="card-body">
+                    <p class="text-secondary small mb-1">Active projects</p>
+                    <h3 class="mb-0 fw-bold">{totals.count}</h3>
+                  </div>
+                </div>
+              </div>
+              <div class="col-md-3 col-6">
+                <div class="card h-100">
+                  <div class="card-body">
+                    <p class="text-secondary small mb-1">On track</p>
+                    <h3 class="mb-0 fw-bold text-success">{totals.onTrack}</h3>
+                  </div>
+                </div>
+              </div>
+              <div class="col-md-3 col-6">
+                <div class="card h-100">
+                  <div class="card-body">
+                    <p class="text-secondary small mb-1">At risk / delayed</p>
+                    <h3 class="mb-0 fw-bold text-warning">{totals.atRisk}</h3>
+                  </div>
+                </div>
+              </div>
+              <div class="col-md-3 col-6">
+                <div class="card h-100">
+                  <div class="card-body">
+                    <p class="text-secondary small mb-1">Completed</p>
+                    <h3 class="mb-0 fw-bold text-secondary">{totals.done}</h3>
+                  </div>
+                </div>
+              </div>
+            </div>
+
+            <!-- Toolbar -->
+            <div class="card">
+              <div class="card-header d-flex flex-wrap gap-2 align-items-center">
+                <h3 class="card-title mb-0 me-auto">All projects</h3>
+                <div class="input-group input-group-sm" style="width: 16rem;">
+                  <span class="input-group-text">
+                    <i class="bi bi-search" aria-hidden="true"></i>
+                  </span>
+                  <input
+                    type="search"
+                    class="form-control"
+                    placeholder="Search projects&hellip;"
+                    aria-label="Search projects"
+                  />
+                </div>
+                <select
+                  class="form-select form-select-sm"
+                  style="width: 10rem;"
+                  aria-label="Filter by status"
+                >
+                  <option value="">All statuses</option>
+                  <option>On track</option>
+                  <option>At risk</option>
+                  <option>Delayed</option>
+                  <option>Completed</option>
+                </select>
+                <button class="btn btn-primary btn-sm" type="button">
+                  <i class="bi bi-plus-lg me-1" aria-hidden="true"></i>
+                  New project
+                </button>
+              </div>
+              <div class="card-body p-0">
+                <div class="table-responsive">
+                  <table class="table align-middle mb-0">
+                    <thead>
+                      <tr>
+                        <th>Project</th>
+                        <th>Status</th>
+                        <th>Progress</th>
+                        <th>Team</th>
+                        <th>Budget</th>
+                        <th>Due</th>
+                        <th>Priority</th>
+                        <th class="text-end">Actions</th>
+                      </tr>
+                    </thead>
+                    <tbody>
+                      {
+                        projects.map((p) => (
+                          <tr>
+                            <td>
+                              <a href="#" class="fw-semibold text-decoration-none">
+                                {p.name}
+                              </a>
+                              <div class="small text-secondary">{p.client}</div>
+                            </td>
+                            <td>
+                              <span class={`badge text-bg-${statusBadge[p.status]}`}>
+                                {p.status}
+                              </span>
+                            </td>
+                            <td style="min-width: 8rem;">
+                              <div class="d-flex align-items-center gap-2">
+                                <div class="progress flex-grow-1" style="height: 6px;">
+                                  <div
+                                    class={`progress-bar bg-${progressBar(p.progress)}`}
+                                    role="progressbar"
+                                    style={`width: ${p.progress}%;`}
+                                    aria-valuenow={p.progress}
+                                    aria-valuemin="0"
+                                    aria-valuemax="100"
+                                  />
+                                </div>
+                                <small class="text-secondary" style="min-width: 2.25rem;">
+                                  {p.progress}%
+                                </small>
+                              </div>
+                            </td>
+                            <td>
+                              <div class="d-inline-flex">
+                                {p.team.map((t, i) => (
+                                  <span
+                                    class={`rounded-circle d-inline-flex align-items-center justify-content-center bg-${t.color}-subtle text-${t.color} fw-semibold`}
+                                    style={`width: 1.75rem; height: 1.75rem; font-size: .7rem; border: 2px solid var(--bs-body-bg); margin-left: ${i === 0 ? "0" : "-.5rem"};`}
+                                    title={t.initials}
+                                  >
+                                    {t.initials}
+                                  </span>
+                                ))}
+                              </div>
+                            </td>
+                            <td class="text-nowrap">{p.budget}</td>
+                            <td class="text-nowrap">{p.due}</td>
+                            <td>
+                              <span class={`badge text-bg-${priorityBadge[p.priority]}`}>
+                                {p.priority}
+                              </span>
+                            </td>
+                            <td class="text-end">
+                              <div class="btn-group btn-group-sm">
+                                <button
+                                  class="btn btn-outline-secondary"
+                                  type="button"
+                                  title="View"
+                                >
+                                  <i class="bi bi-eye" aria-hidden="true" />
+                                </button>
+                                <button
+                                  class="btn btn-outline-secondary"
+                                  type="button"
+                                  title="Edit"
+                                >
+                                  <i class="bi bi-pencil" aria-hidden="true" />
+                                </button>
+                                <button
+                                  class="btn btn-outline-secondary"
+                                  type="button"
+                                  title="More"
+                                >
+                                  <i class="bi bi-three-dots-vertical" aria-hidden="true" />
+                                </button>
+                              </div>
+                            </td>
+                          </tr>
+                        ))
+                      }
+                    </tbody>
+                  </table>
+                </div>
+              </div>
+              <div class="card-footer d-flex justify-content-between align-items-center">
+                <small class="text-secondary">
+                  Showing {projects.length} of {projects.length}
+                </small>
+                <nav aria-label="Pagination">
+                  <ul class="pagination pagination-sm mb-0">
+                    <li class="page-item disabled">
+                      <a class="page-link" href="#">Previous</a>
+                    </li>
+                    <li class="page-item active">
+                      <a class="page-link" href="#">1</a>
+                    </li>
+                    <li class="page-item disabled">
+                      <a class="page-link" href="#">Next</a>
+                    </li>
+                  </ul>
+                </nav>
+              </div>
+            </div>
+          </div>
+        </div>
+      </main>
+      <Footer />
+    </div>
+    <Scripts path={path} />
+  </body>
+</html>