Skip to content

Commit 72bfc66

Browse files
release: v0.20.1 — sidebar redesign, Inter font, admin pagination, design polish
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3a928d5 commit 72bfc66

8 files changed

Lines changed: 235 additions & 83 deletions

File tree

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
8686

8787
## [Unreleased]
8888

89+
## [0.20.1] - 2026-03-13
90+
91+
### Changed
92+
93+
- **Sidebar redesign** — calrs logo + two-tone brand name ("cal" blue, "rs" orange) at top linking to dashboard; user profile moved to bottom in a compact row with inline sign-out icon; clicking name/avatar goes to settings
94+
- **Inter font** — loaded from Google Fonts for consistent typography across platforms
95+
- **Admin pagination** — users and groups lists paginated (5 per page) with prev/next navigation
96+
- **Admin search fields** — pill-shaped rounded inputs with accent focus ring
97+
- **Stat card watermark icons** — faint centered emoji backgrounds (4% opacity) for visual personality
98+
- **Welcome card accent** — 2px blue top border on the dashboard welcome card
99+
- **Button gradient** — primary buttons use a subtle diagonal gradient instead of flat color
100+
- **Pressed states** — buttons scale down (0.97×) on click for tactile feedback
101+
- **Brand logo route**`/brand-logo` serves the calrs logo (compiled into the binary)
102+
103+
### Fixed
104+
105+
- **Page flash removed** — removed the fade-in animation that caused a white flash on navigation
106+
- **Footer overlap** — "Powered by calrs" no longer renders under the sidebar on dashboard pages; hidden on authenticated pages, shown only on public pages
107+
- **Footer link** — "Powered by calrs" now links to cal.rs website instead of GitHub repo
108+
89109
## [0.20.0] - 2026-03-13
90110

91111
### Added

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "calrs"
3-
version = "0.20.0"
3+
version = "0.20.1"
44
edition = "2021"
55
description = "A fast, self-hostable scheduling platform. Like Cal.com, but written in Rust."
66
license = "AGPL-3.0"

src/web/mod.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,7 @@ pub fn create_router(pool: SqlitePool, data_dir: PathBuf, secret_key: [u8; 32])
460460
)
461461
// Serve logo
462462
.route("/logo", get(serve_logo))
463+
.route("/brand-logo", get(serve_brand_logo))
463464
// Group public routes (before the catch-all)
464465
.route("/g/{group_slug}", get(group_profile))
465466
.route("/g/{group_slug}/{slug}", get(show_group_slots))
@@ -6689,6 +6690,17 @@ async fn serve_logo(State(state): State<Arc<AppState>>) -> impl IntoResponse {
66896690
}
66906691
}
66916692

6693+
async fn serve_brand_logo() -> impl IntoResponse {
6694+
static BRAND_LOGO: &[u8] = include_bytes!("../../assets/calrs.png");
6695+
axum::response::Response::builder()
6696+
.status(200)
6697+
.header("Content-Type", "image/png")
6698+
.header("Cache-Control", "public, max-age=86400")
6699+
.body(axum::body::Body::from(BRAND_LOGO))
6700+
.unwrap_or_else(|_| axum::response::Response::new(axum::body::Body::empty()))
6701+
.into_response()
6702+
}
6703+
66926704
async fn admin_upload_logo(
66936705
State(state): State<Arc<AppState>>,
66946706
_admin: crate::auth::AdminUser,

templates/admin.html

Lines changed: 101 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ <h2>Company logo</h2>
3131
<div class="card">
3232
<h2>Users ({{ user_count }})</h2>
3333
<div style="margin-bottom: 1rem;">
34-
<input type="text" id="user-search" placeholder="Filter by name or email…" oninput="filterUsers()" style="width: 100%; max-width: 360px;">
34+
<input type="text" id="user-search" placeholder="Filter by name or email…" oninput="filterUsers()" style="width: 100%; max-width: 360px; padding: 0.5rem 0.875rem; border: 1px solid var(--border); border-radius: 9999px; font-size: 0.875rem; font-family: inherit; background: var(--surface); color: var(--text); outline: none; transition: border-color 0.15s ease, box-shadow 0.15s ease;" onfocus="this.style.borderColor='var(--accent)';this.style.boxShadow='0 0 0 3px var(--accent-subtle)'" onblur="this.style.borderColor='var(--border)';this.style.boxShadow='none'">
3535
</div>
3636
{% if users %}
3737
<div id="user-list" style="display: flex; flex-direction: column; gap: 0.75rem;">
@@ -75,6 +75,7 @@ <h2>Users ({{ user_count }})</h2>
7575
</div>
7676
{% endfor %}
7777
</div>
78+
<div id="user-pagination" style="display: flex; align-items: center; justify-content: center; gap: 0.5rem; margin-top: 1rem;"></div>
7879
<div id="user-no-results" style="display: none; padding: 1rem; color: var(--text-muted); font-size: 0.875rem;">No users match your filter.</div>
7980
{% else %}
8081
<p class="empty" style="padding: 1rem;">No users yet.</p>
@@ -86,7 +87,7 @@ <h2>Users ({{ user_count }})</h2>
8687
<h2>Groups ({{ group_count }})</h2>
8788
{% if groups %}
8889
<div style="margin-bottom: 1rem;">
89-
<input type="text" id="group-search" placeholder="Filter by group name…" oninput="filterGroups()" style="width: 100%; max-width: 360px;">
90+
<input type="text" id="group-search" placeholder="Filter by group name…" oninput="filterGroups()" style="width: 100%; max-width: 360px; padding: 0.5rem 0.875rem; border: 1px solid var(--border); border-radius: 9999px; font-size: 0.875rem; font-family: inherit; background: var(--surface); color: var(--text); outline: none; transition: border-color 0.15s ease, box-shadow 0.15s ease;" onfocus="this.style.borderColor='var(--accent)';this.style.boxShadow='0 0 0 3px var(--accent-subtle)'" onblur="this.style.borderColor='var(--border)';this.style.boxShadow='none'">
9091
</div>
9192
<div style="overflow-x: auto;">
9293
<table style="width: 100%; border-collapse: collapse; font-size: 0.875rem;">
@@ -106,6 +107,7 @@ <h2>Groups ({{ group_count }})</h2>
106107
</tbody>
107108
</table>
108109
</div>
110+
<div id="group-pagination" style="display: flex; align-items: center; justify-content: center; gap: 0.5rem; margin-top: 1rem;"></div>
109111
<div id="group-no-results" style="display: none; padding: 1rem; color: var(--text-muted); font-size: 0.875rem;">No groups match your filter.</div>
110112
{% else %}
111113
<p class="empty" style="padding: 1rem; color: var(--text-muted); font-size: 0.875rem;">No groups synced yet. Groups are automatically synced from your OIDC provider.</p>
@@ -183,29 +185,102 @@ <h2>SMTP settings</h2>
183185
</div>
184186

185187
<script>
186-
function filterUsers() {
187-
const q = document.getElementById('user-search').value.toLowerCase();
188-
const items = document.querySelectorAll('.user-item');
189-
let visible = 0;
190-
items.forEach(el => {
191-
const match = !q || el.dataset.name.includes(q) || el.dataset.email.includes(q);
192-
el.style.display = match ? '' : 'none';
193-
if (match) visible++;
194-
});
195-
const noResults = document.getElementById('user-no-results');
196-
if (noResults) noResults.style.display = (items.length > 0 && visible === 0) ? '' : 'none';
197-
}
198-
function filterGroups() {
199-
const q = document.getElementById('group-search').value.toLowerCase();
200-
const items = document.querySelectorAll('.group-item');
201-
let visible = 0;
202-
items.forEach(el => {
203-
const match = !q || el.dataset.name.includes(q);
204-
el.style.display = match ? '' : 'none';
205-
if (match) visible++;
206-
});
207-
const noResults = document.getElementById('group-no-results');
208-
if (noResults) noResults.style.display = (items.length > 0 && visible === 0) ? '' : 'none';
209-
}
188+
(function() {
189+
var USER_PAGE_SIZE = 5;
190+
var userPage = 1;
191+
192+
function getFilteredUsers() {
193+
var q = document.getElementById('user-search').value.toLowerCase();
194+
var items = document.querySelectorAll('.user-item');
195+
var filtered = [];
196+
items.forEach(function(el) {
197+
if (!q || el.dataset.name.indexOf(q) !== -1 || el.dataset.email.indexOf(q) !== -1) {
198+
filtered.push(el);
199+
}
200+
});
201+
return filtered;
202+
}
203+
204+
function renderUserPage() {
205+
var items = document.querySelectorAll('.user-item');
206+
var filtered = getFilteredUsers();
207+
var totalPages = Math.max(1, Math.ceil(filtered.length / USER_PAGE_SIZE));
208+
if (userPage > totalPages) userPage = totalPages;
209+
var start = (userPage - 1) * USER_PAGE_SIZE;
210+
var end = start + USER_PAGE_SIZE;
211+
212+
items.forEach(function(el) { el.style.display = 'none'; });
213+
filtered.forEach(function(el, i) {
214+
el.style.display = (i >= start && i < end) ? '' : 'none';
215+
});
216+
217+
var noResults = document.getElementById('user-no-results');
218+
if (noResults) noResults.style.display = (items.length > 0 && filtered.length === 0) ? '' : 'none';
219+
220+
var pag = document.getElementById('user-pagination');
221+
if (!pag) return;
222+
if (totalPages <= 1) { pag.innerHTML = ''; return; }
223+
224+
var html = '<span style="font-size:0.8rem;color:var(--text-muted);">Page ' + userPage + ' of ' + totalPages + '</span> ';
225+
if (userPage > 1) html += '<button onclick="window._userPageNav(-1)" class="slot-btn" style="font-size:0.8rem;cursor:pointer;padding:0.3rem 0.6rem;">&larr;</button> ';
226+
if (userPage < totalPages) html += '<button onclick="window._userPageNav(1)" class="slot-btn" style="font-size:0.8rem;cursor:pointer;padding:0.3rem 0.6rem;">&rarr;</button>';
227+
pag.innerHTML = html;
228+
}
229+
230+
window._userPageNav = function(dir) { userPage += dir; renderUserPage(); };
231+
232+
window.filterUsers = function() { userPage = 1; renderUserPage(); };
233+
234+
// Initial render
235+
if (document.getElementById('user-list')) renderUserPage();
236+
})();
237+
238+
(function() {
239+
var GROUP_PAGE_SIZE = 5;
240+
var groupPage = 1;
241+
242+
function getFilteredGroups() {
243+
var q = document.getElementById('group-search').value.toLowerCase();
244+
var items = document.querySelectorAll('.group-item');
245+
var filtered = [];
246+
items.forEach(function(el) {
247+
if (!q || el.dataset.name.indexOf(q) !== -1) {
248+
filtered.push(el);
249+
}
250+
});
251+
return filtered;
252+
}
253+
254+
function renderGroupPage() {
255+
var items = document.querySelectorAll('.group-item');
256+
var filtered = getFilteredGroups();
257+
var totalPages = Math.max(1, Math.ceil(filtered.length / GROUP_PAGE_SIZE));
258+
if (groupPage > totalPages) groupPage = totalPages;
259+
var start = (groupPage - 1) * GROUP_PAGE_SIZE;
260+
var end = start + GROUP_PAGE_SIZE;
261+
262+
items.forEach(function(el) { el.style.display = 'none'; });
263+
filtered.forEach(function(el, i) {
264+
el.style.display = (i >= start && i < end) ? '' : 'none';
265+
});
266+
267+
var noResults = document.getElementById('group-no-results');
268+
if (noResults) noResults.style.display = (items.length > 0 && filtered.length === 0) ? '' : 'none';
269+
270+
var pag = document.getElementById('group-pagination');
271+
if (!pag) return;
272+
if (totalPages <= 1) { pag.innerHTML = ''; return; }
273+
274+
var html = '<span style="font-size:0.8rem;color:var(--text-muted);">Page ' + groupPage + ' of ' + totalPages + '</span> ';
275+
if (groupPage > 1) html += '<button onclick="window._groupPageNav(-1)" class="slot-btn" style="font-size:0.8rem;cursor:pointer;padding:0.3rem 0.6rem;">&larr;</button> ';
276+
if (groupPage < totalPages) html += '<button onclick="window._groupPageNav(1)" class="slot-btn" style="font-size:0.8rem;cursor:pointer;padding:0.3rem 0.6rem;">&rarr;</button>';
277+
pag.innerHTML = html;
278+
}
279+
280+
window._groupPageNav = function(dir) { groupPage += dir; renderGroupPage(); };
281+
window.filterGroups = function() { groupPage = 1; renderGroupPage(); };
282+
283+
if (document.getElementById('group-list')) renderGroupPage();
284+
})();
210285
</script>
211286
{% endblock dashboard_content %}

templates/base.html

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
<meta name="viewport" content="width=device-width, initial-scale=1">
66
<meta name="color-scheme" content="light dark">
77
<title>{% block title %}calrs{% endblock %}</title>
8+
<link rel="preconnect" href="https://fonts.googleapis.com">
9+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
811
<style>
912
:root {
1013
--bg: #f4f4f5;
@@ -55,7 +58,7 @@
5558
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
5659

5760
body {
58-
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, sans-serif;
61+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
5962
line-height: 1.6;
6063
color: var(--text);
6164
background: var(--bg);
@@ -192,6 +195,10 @@
192195
box-shadow: var(--shadow);
193196
transform: scale(1.03);
194197
}
198+
.slot-btn:active {
199+
transform: scale(0.97);
200+
box-shadow: none;
201+
}
195202

196203
/* Form */
197204
.form-group { margin-bottom: 1.25rem; }
@@ -230,19 +237,19 @@
230237
.btn {
231238
display: inline-block;
232239
padding: 0.75rem 1.5rem;
233-
background: var(--accent);
240+
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
234241
color: #fff;
235242
border: none;
236243
border-radius: var(--radius);
237244
font-size: 0.95rem;
238245
font-weight: 600;
239246
cursor: pointer;
240247
width: 100%;
241-
transition: background 0.15s ease, box-shadow 0.15s ease;
248+
transition: all 0.15s ease;
242249
letter-spacing: -0.01em;
243250
}
244-
.btn:hover { background: var(--accent-hover); box-shadow: var(--shadow-lg); }
245-
.btn:active { transform: translateY(1px); }
251+
.btn:hover { background: linear-gradient(135deg, var(--accent-hover), var(--accent)); box-shadow: var(--shadow-lg); }
252+
.btn:active { transform: scale(0.97); box-shadow: none; }
246253
.btn:disabled { background: var(--accent-muted); cursor: not-allowed; }
247254

248255
/* Confirmation */
@@ -335,15 +342,6 @@
335342
.week-nav .range { font-size: 0.8rem; }
336343
}
337344

338-
/* Page fade-in */
339-
@keyframes fadeIn {
340-
from { opacity: 0; transform: translateY(6px); }
341-
to { opacity: 1; transform: translateY(0); }
342-
}
343-
body {
344-
animation: fadeIn 0.3s ease-out;
345-
}
346-
347345
/* Interactive card hover lift */
348346
a.card {
349347
transition: border-color 0.15s ease, box-shadow 0.2s ease, transform 0.2s ease;
@@ -405,7 +403,7 @@
405403
{% endif %}
406404
<div class="container">
407405
{% block content %}{% endblock %}
408-
<p class="powered">Powered by <a href="https://github.com/olivierlambert/calrs">calrs</a> 🦀</p>
406+
{% block footer %}<p class="powered">Powered by <a href="https://cal.rs">calrs</a> 🦀</p>{% endblock %}
409407
</div>
410408
<script>
411409
// CSRF protection: inject _csrf hidden field into all POST forms

0 commit comments

Comments
 (0)