feat(ui): establish shared desktop shell and assets
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,35 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const root = document.documentElement;
|
||||
const theme = document.querySelector('[data-theme-select]');
|
||||
const stored = (() => {
|
||||
try { return localStorage.getItem('labtimesheet-theme') || 'system'; }
|
||||
catch (_) { return 'system'; }
|
||||
})();
|
||||
if (theme) {
|
||||
theme.value = stored;
|
||||
theme.addEventListener('change', () => {
|
||||
try {
|
||||
theme.value === 'system'
|
||||
? localStorage.removeItem('labtimesheet-theme')
|
||||
: localStorage.setItem('labtimesheet-theme', theme.value);
|
||||
} catch (_) {
|
||||
// Theme still applies for this page when persistence is unavailable.
|
||||
}
|
||||
const dark = theme.value === 'dark'
|
||||
|| (theme.value === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
root.dataset.theme = dark ? 'dark' : 'light';
|
||||
root.style.colorScheme = dark ? 'dark' : 'light';
|
||||
});
|
||||
}
|
||||
|
||||
let collapsed = false;
|
||||
try { collapsed = localStorage.getItem('labtimesheet-sidebar') === 'collapsed'; }
|
||||
catch (_) { /* Use the expanded default. */ }
|
||||
root.dataset.sidebarCollapsed = String(collapsed);
|
||||
document.querySelector('[data-sidebar-toggle]')?.addEventListener('click', () => {
|
||||
collapsed = !collapsed;
|
||||
root.dataset.sidebarCollapsed = String(collapsed);
|
||||
try { localStorage.setItem('labtimesheet-sidebar', collapsed ? 'collapsed' : 'expanded'); }
|
||||
catch (_) { /* Collapse still works for this page. */ }
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="display:none"><symbol id="bell" viewBox="0 0 24 24"><path d="M10.268 21a2 2 0 0 0 3.464 0" />
|
||||
<path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></symbol><symbol id="calendar-days" viewBox="0 0 24 24"><path d="M8 2v4" />
|
||||
<path d="M16 2v4" />
|
||||
<rect width="18" height="18" x="3" y="4" rx="2" />
|
||||
<path d="M3 10h18" />
|
||||
<path d="M8 14h.01" />
|
||||
<path d="M12 14h.01" />
|
||||
<path d="M16 14h.01" />
|
||||
<path d="M8 18h.01" />
|
||||
<path d="M12 18h.01" />
|
||||
<path d="M16 18h.01" /></symbol><symbol id="check-circle-2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" />
|
||||
<path d="m9 12 2 2 4-4" /></symbol><symbol id="chevron-left" viewBox="0 0 24 24"><path d="m15 18-6-6 6-6" /></symbol><symbol id="chevron-right" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6" /></symbol><symbol id="circle-user-round" viewBox="0 0 24 24"><path d="M17.925 20.056a6 6 0 0 0-11.851.001" />
|
||||
<circle cx="12" cy="11" r="4" />
|
||||
<circle cx="12" cy="12" r="10" /></symbol><symbol id="clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 6v6l4 2" /></symbol><symbol id="folder-kanban" viewBox="0 0 24 24"><path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z" />
|
||||
<path d="M8 10v4" />
|
||||
<path d="M12 10v2" />
|
||||
<path d="M16 10v6" /></symbol><symbol id="folder-open" viewBox="0 0 24 24"><path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" /></symbol><symbol id="inbox" viewBox="0 0 24 24"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
|
||||
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" /></symbol><symbol id="layout-dashboard" viewBox="0 0 24 24"><rect width="7" height="9" x="3" y="3" rx="1" />
|
||||
<rect width="7" height="5" x="14" y="3" rx="1" />
|
||||
<rect width="7" height="9" x="14" y="12" rx="1" />
|
||||
<rect width="7" height="5" x="3" y="16" rx="1" /></symbol><symbol id="list-check" viewBox="0 0 24 24"><path d="M16 5H3" />
|
||||
<path d="M16 12H3" />
|
||||
<path d="M11 19H3" />
|
||||
<path d="m15 18 2 2 4-4" /></symbol><symbol id="log-out" viewBox="0 0 24 24"><path d="m16 17 5-5-5-5" />
|
||||
<path d="M21 12H9" />
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /></symbol><symbol id="monitor" viewBox="0 0 24 24"><rect width="20" height="14" x="2" y="3" rx="2" />
|
||||
<line x1="8" x2="16" y1="21" y2="21" />
|
||||
<line x1="12" x2="12" y1="17" y2="21" /></symbol><symbol id="moon" viewBox="0 0 24 24"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401" /></symbol><symbol id="panel-left" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M9 3v18" /></symbol><symbol id="settings" viewBox="0 0 24 24"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" />
|
||||
<circle cx="12" cy="12" r="3" /></symbol><symbol id="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 20v2" />
|
||||
<path d="m4.93 4.93 1.41 1.41" />
|
||||
<path d="m17.66 17.66 1.41 1.41" />
|
||||
<path d="M2 12h2" />
|
||||
<path d="M20 12h2" />
|
||||
<path d="m6.34 17.66-1.41 1.41" />
|
||||
<path d="m19.07 4.93-1.41 1.41" /></symbol><symbol id="triangle-alert" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
|
||||
<path d="M12 9v4" />
|
||||
<path d="M12 17h.01" /></symbol><symbol id="users" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<path d="M16 3.128a4 4 0 0 1 0 7.744" />
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<circle cx="9" cy="7" r="4" /></symbol><symbol id="x" viewBox="0 0 24 24"><path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" /></symbol></svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,12 @@
|
||||
(() => {
|
||||
const systemIsDark = () => window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
let preference = 'system';
|
||||
try {
|
||||
preference = localStorage.getItem('labtimesheet-theme') || 'system';
|
||||
} catch (_) {
|
||||
// Browser privacy settings may disable storage; the system preference remains usable.
|
||||
}
|
||||
const dark = preference === 'dark' || (preference === 'system' && systemIsDark());
|
||||
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
|
||||
document.documentElement.style.colorScheme = dark ? 'dark' : 'light';
|
||||
})();
|
||||
@@ -0,0 +1,33 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||
<body>
|
||||
<a th:fragment="buttonLink(href, label, tone)" class="button" th:classappend="${tone == 'primary' ? ' button-primary' : tone == 'danger' ? ' button-danger' : ''}" th:href="${href}" th:text="${label}">Action</a>
|
||||
|
||||
<div th:fragment="input(name, label, value, error)" class="field">
|
||||
<label class="field-label" th:for="${name}" th:text="${label}">Label</label>
|
||||
<input class="control" th:id="${name}" th:name="${name}" th:value="${value}" th:attr="aria-invalid=${error != null},aria-describedby=${error != null ? name + '-error' : null}">
|
||||
<p class="field-error" th:if="${error != null}" th:id="${name + '-error'}" th:text="${error}">Error</p>
|
||||
</div>
|
||||
|
||||
<div th:fragment="select(name, label, options, selected, error)" class="field">
|
||||
<label class="field-label" th:for="${name}" th:text="${label}">Label</label>
|
||||
<select class="control" th:id="${name}" th:name="${name}" th:attr="aria-invalid=${error != null},aria-describedby=${error != null ? name + '-error' : null}">
|
||||
<option th:each="option : ${options}" th:value="${option.key}" th:text="${option.value}" th:selected="${option.key == selected}">Option</option>
|
||||
</select>
|
||||
<p class="field-error" th:if="${error != null}" th:id="${name + '-error'}" th:text="${error}">Error</p>
|
||||
</div>
|
||||
|
||||
<label th:fragment="checkbox(name, label, checked)" class="checkbox"><input type="checkbox" th:id="${name}" th:name="${name}" th:checked="${checked}"><span th:text="${label}">Label</span></label>
|
||||
<section th:fragment="card(title, content)" class="panel"><header class="panel-header"><h2 class="panel-title" th:text="${title}">Title</h2></header><div th:replace="${content}"></div></section>
|
||||
<div th:fragment="metric(label, value, detail)" class="metric"><div class="metric-label" th:text="${label}">Label</div><div class="metric-value" th:text="${value}">Value</div><div class="metric-detail" th:text="${detail}">Detail</div></div>
|
||||
<span th:fragment="badge(label, tone)" class="badge" th:classappend="${' badge-' + tone}" th:attr="aria-label=${'Status: ' + label}" th:text="${label}">Status</span>
|
||||
<nav th:fragment="tabs(items, active)" class="tabs" aria-label="Views"><a th:each="item : ${items}" class="tab" th:href="${item.href}" th:text="${item.label}" th:attr="aria-current=${item.key == active} ? 'page' : null">View</a></nav>
|
||||
<div th:fragment="table(caption, head, body)" class="table-scroll"><table class="data-table"><caption class="sr-only" th:text="${caption}">Data</caption><thead th:replace="${head}"></thead><tbody th:replace="${body}"></tbody></table></div>
|
||||
<nav th:fragment="pagination(previous, next)" class="pagination" aria-label="Pagination"><a class="button" th:if="${previous != null}" th:href="${previous}">Previous</a><a class="button" th:if="${next != null}" th:href="${next}">Next</a></nav>
|
||||
<div th:fragment="alert(tone, message)" class="alert" th:classappend="${' alert-' + tone}" role="alert" th:text="${message}">Message</div>
|
||||
<dialog th:fragment="confirmation(id, title, description)" th:id="${id}" th:attr="aria-labelledby=${id + '-title'},aria-describedby=${id + '-description'}"><h2 th:id="${id + '-title'}" th:text="${title}">Confirm</h2><p th:id="${id + '-description'}" th:text="${description}">Consequence</p><form method="dialog"><button class="button" value="cancel">Cancel</button><button class="button button-danger" value="confirm">Confirm</button></form></dialog>
|
||||
<section th:fragment="empty(title, description)" class="empty-state"><h2 th:text="${title}">No records</h2><p th:text="${description}">Nothing to show.</p></section>
|
||||
<div th:fragment="skeleton(label)" role="status" th:attr="aria-label=${label}"><div class="skeleton"></div></div>
|
||||
<div th:fragment="notificationMenu(items)" class="panel notification-menu"><h2 class="panel-title">Notifications</h2><div th:if="${#lists.isEmpty(items)}">No unread notifications.</div><a th:each="item : ${items}" th:href="${item.actionUrl}" th:text="${item.title}">Notification</a></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,62 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
|
||||
th:fragment="shell(pageTitle, section, activeNav, primaryAction, content)">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title th:text="${pageTitle + ' · Lab Timesheet'}">Lab Timesheet</title>
|
||||
<script th:src="@{/assets/theme.js}"></script>
|
||||
<link rel="stylesheet" th:href="@{/assets/app.css}">
|
||||
<script defer th:src="@{/assets/app.js}"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar" aria-label="Primary navigation">
|
||||
<a class="brand" th:href="@{/dashboard}">
|
||||
<span class="brand-mark" aria-hidden="true">LT</span>
|
||||
<span class="sidebar-label"><strong>Lab Timesheet</strong><br><small th:text="${section + ' workspace'}">Workspace</small></span>
|
||||
</a>
|
||||
<nav>
|
||||
<p class="nav-label sidebar-label">Workspace</p>
|
||||
<ul class="nav-list">
|
||||
<li><a class="nav-link" th:href="@{/dashboard}" th:attr="aria-current=${activeNav == 'dashboard'} ? 'page' : null">
|
||||
<svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#layout-dashboard}"></use></svg><span class="sidebar-label">Overview</span></a></li>
|
||||
<li sec:authorize="hasRole('ADMIN')"><a class="nav-link" th:href="@{/admin/users}"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#users}"></use></svg><span class="sidebar-label">Accounts</span></a></li>
|
||||
<li sec:authorize="hasRole('ADMIN')"><a class="nav-link" th:href="@{/admin/calendar}"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#calendar-days}"></use></svg><span class="sidebar-label">Global calendar</span></a></li>
|
||||
<li sec:authorize="hasRole('MENTOR')"><a class="nav-link" th:href="@{/projects}"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#folder-kanban}"></use></svg><span class="sidebar-label">Owned Projects</span></a></li>
|
||||
<li sec:authorize="hasRole('MENTOR')"><a class="nav-link" th:href="@{/attendance}"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#clock}"></use></svg><span class="sidebar-label">Intern attendance</span></a></li>
|
||||
<li sec:authorize="hasRole('INTERN')"><a class="nav-link" th:href="@{/attendance/me}"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#clock}"></use></svg><span class="sidebar-label">My attendance</span></a></li>
|
||||
<li sec:authorize="hasRole('INTERN')"><a class="nav-link" th:href="@{/projects}"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#folder-kanban}"></use></svg><span class="sidebar-label">My Projects</span></a></li>
|
||||
<li sec:authorize="hasRole('INTERN')"><a class="nav-link" th:href="@{/tasks}"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#list-check}"></use></svg><span class="sidebar-label">Assigned Tasks</span></a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<div class="theme-field">
|
||||
<label class="field-label sidebar-label" for="theme-preference">Theme</label>
|
||||
<select id="theme-preference" data-theme-select aria-label="Theme">
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
<option value="system">System</option>
|
||||
</select>
|
||||
</div>
|
||||
<a class="account" th:href="@{/profile}"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#circle-user-round}"></use></svg><span class="sidebar-label" sec:authentication="name">Profile</span></a>
|
||||
<form class="logout-form" th:action="@{/logout}" method="post"><button class="nav-link" type="submit"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#log-out}"></use></svg><span class="sidebar-label">Logout</span></button></form>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="app-column">
|
||||
<header class="app-header">
|
||||
<button class="icon-button" type="button" data-sidebar-toggle aria-label="Toggle sidebar" title="Toggle sidebar"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#panel-left}"></use></svg></button>
|
||||
<div class="header-title"><span class="breadcrumb" th:text="${section}">Section</span> / <span th:text="${pageTitle}">Page</span></div>
|
||||
<div class="header-actions"><a class="icon-button" th:href="@{/notifications}" aria-label="Notifications" title="Notifications"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#bell}"></use></svg></a></div>
|
||||
</header>
|
||||
<div class="page">
|
||||
<div class="page-heading">
|
||||
<div class="page-heading-copy"><h1 class="page-title" th:text="${pageTitle}">Page</h1></div>
|
||||
<div class="primary-action" th:if="${primaryAction != null}" th:replace="${primaryAction}"></div>
|
||||
</div>
|
||||
<th:block th:replace="${content}"></th:block>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user