Skip to content

Commit 3a928d5

Browse files
release: v0.20.0 — UX polish: hover animations, status badges, gradient profile, admin search
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b641027 commit 3a928d5

13 files changed

Lines changed: 194 additions & 48 deletions

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
8181
| Regression tests | 0.19.0 | 28 new tests (191 → 219) covering ICS, validation, CSRF |
8282
| ICS attendee names | 0.19.0 | Calendar events show "{title} — {guest} & {host}" with guest notes in description |
8383
| Host confirmation email | 0.19.0 | Host receives booking confirmed email (without ICS) after approving pending bookings |
84+
| UX polish | 0.20.0 | Clickable dashboard cards, hover animations, status badges, gradient profile header, admin search/filter |
8485
| ICS time fix | 0.19.0 | Correct UTC times in ICS when confirming/cancelling bookings from the database |
8586

8687
## [Unreleased]
8788

89+
## [0.20.0] - 2026-03-13
90+
91+
### Added
92+
93+
- **Clickable dashboard cards** — stat tiles (Event Types, Upcoming Bookings, Pending Approval, Calendar Sources) are now links to their respective dashboard pages
94+
- **Public page link opens in new tab** — the `/u/{username}` link on the dashboard overview now opens in a new tab
95+
- **Admin search/filter** — users list has a live filter by name or email; groups list has a live filter by name
96+
- **Status badges** — "disabled" and "requires confirmation" on event types are now colored pill badges (red/amber) instead of plain text; pending bookings show an amber "pending" badge
97+
- **Card hover lift** — interactive cards (stat tiles, profile event types, group event types) lift with a shadow on hover
98+
- **Page fade-in animation** — subtle 0.3s fade-in + slide-up on every page load
99+
- **Slot button hover scale** — time slot buttons scale up slightly (1.03×) on hover for a tactile feel
100+
- **Colored left border** — event type cards on public profile and group pages have a 3px accent-colored left border
101+
- **Profile gradient banner** — public profile page has a blue-to-purple gradient header behind the avatar
102+
- **Animated checkmark** — confirmation page checkmark bounces in with a scale animation
103+
- **Better empty states** — empty listings (bookings, event types, slots) show a larger icon + descriptive text instead of a plain line
104+
- **Rust crab branding** — "Powered by calrs" footer now includes the 🦀 emoji on all pages
105+
88106
## [0.19.1] - 2026-03-13
89107

90108
### Changed

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.19.1"
3+
version = "0.20.0"
44
edition = "2021"
55
description = "A fast, self-hostable scheduling platform. Like Cal.com, but written in Rust."
66
license = "AGPL-3.0"

templates/admin.html

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,13 @@ <h2>Company logo</h2>
3030
<!-- Users -->
3131
<div class="card">
3232
<h2>Users ({{ user_count }})</h2>
33+
<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;">
35+
</div>
3336
{% if users %}
34-
<div style="display: flex; flex-direction: column; gap: 0.75rem;">
37+
<div id="user-list" style="display: flex; flex-direction: column; gap: 0.75rem;">
3538
{% for u in users %}
36-
<div style="border: 1px solid var(--border); border-radius: var(--radius); padding: 0.875rem 1rem;">
39+
<div class="user-item" data-name="{{ u.name | lower }}" data-email="{{ u.email | lower }}" style="border: 1px solid var(--border); border-radius: var(--radius); padding: 0.875rem 1rem;">
3740
<div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 0.75rem; flex-wrap: wrap;">
3841
<div style="min-width: 0; flex: 1;">
3942
<div style="font-weight: 600; font-size: 0.925rem;">{{ u.name }}</div>
@@ -72,6 +75,7 @@ <h2>Users ({{ user_count }})</h2>
7275
</div>
7376
{% endfor %}
7477
</div>
78+
<div id="user-no-results" style="display: none; padding: 1rem; color: var(--text-muted); font-size: 0.875rem;">No users match your filter.</div>
7579
{% else %}
7680
<p class="empty" style="padding: 1rem;">No users yet.</p>
7781
{% endif %}
@@ -81,6 +85,9 @@ <h2>Users ({{ user_count }})</h2>
8185
<div class="card">
8286
<h2>Groups ({{ group_count }})</h2>
8387
{% if groups %}
88+
<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+
</div>
8491
<div style="overflow-x: auto;">
8592
<table style="width: 100%; border-collapse: collapse; font-size: 0.875rem;">
8693
<thead>
@@ -89,16 +96,17 @@ <h2>Groups ({{ group_count }})</h2>
8996
<th style="padding: 0.5rem;">Members</th>
9097
</tr>
9198
</thead>
92-
<tbody>
99+
<tbody id="group-list">
93100
{% for g in groups %}
94-
<tr style="border-bottom: 1px solid var(--border);">
101+
<tr class="group-item" data-name="{{ g.name | lower }}" style="border-bottom: 1px solid var(--border);">
95102
<td style="padding: 0.5rem 0.5rem 0.5rem 0;">{{ g.name }}</td>
96103
<td style="padding: 0.5rem;">{{ g.member_count }}</td>
97104
</tr>
98105
{% endfor %}
99106
</tbody>
100107
</table>
101108
</div>
109+
<div id="group-no-results" style="display: none; padding: 1rem; color: var(--text-muted); font-size: 0.875rem;">No groups match your filter.</div>
102110
{% else %}
103111
<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>
104112
{% endif %}
@@ -174,4 +182,30 @@ <h2>SMTP settings</h2>
174182
<p style="color: var(--text-muted); font-size: 0.8rem; margin-top: 0.75rem;">SMTP configuration must be managed via the CLI (<code>calrs config smtp</code>) for security reasons.</p>
175183
</div>
176184

185+
<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+
}
210+
</script>
177211
{% endblock dashboard_content %}

templates/base.html

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@
190190
color: var(--accent);
191191
background: var(--accent-subtle);
192192
box-shadow: var(--shadow);
193+
transform: scale(1.03);
193194
}
194195

195196
/* Form */
@@ -261,7 +262,22 @@
261262
.detail { margin-bottom: 0.625rem; color: var(--text-secondary); font-size: 0.925rem; }
262263
.detail strong { color: var(--text); }
263264

264-
.empty { color: var(--text-muted); text-align: center; padding: 2.5rem 1rem; }
265+
.empty {
266+
color: var(--text-muted);
267+
text-align: center;
268+
padding: 2.5rem 1rem;
269+
}
270+
.empty-icon {
271+
font-size: 2.5rem;
272+
margin-bottom: 0.75rem;
273+
opacity: 0.5;
274+
}
275+
.empty-text {
276+
font-size: 0.925rem;
277+
max-width: 320px;
278+
margin: 0 auto;
279+
line-height: 1.5;
280+
}
265281

266282
.logo { text-align: center; margin-bottom: 1.5rem; }
267283
.logo img { max-height: 80px; max-width: 280px; object-fit: contain; }
@@ -318,6 +334,62 @@
318334
.week-nav { flex-wrap: wrap; gap: 0.5rem; }
319335
.week-nav .range { font-size: 0.8rem; }
320336
}
337+
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+
347+
/* Interactive card hover lift */
348+
a.card {
349+
transition: border-color 0.15s ease, box-shadow 0.2s ease, transform 0.2s ease;
350+
}
351+
a.card:hover {
352+
border-color: var(--accent-border);
353+
box-shadow: var(--shadow-lg);
354+
transform: translateY(-2px);
355+
}
356+
357+
/* Status badges */
358+
.badge {
359+
display: inline-block;
360+
font-size: 0.7rem;
361+
font-weight: 600;
362+
padding: 0.15rem 0.5rem;
363+
border-radius: 9999px;
364+
letter-spacing: 0.02em;
365+
vertical-align: middle;
366+
}
367+
.badge-disabled {
368+
background: var(--error-bg);
369+
color: var(--error-text);
370+
}
371+
.badge-pending {
372+
background: rgba(245, 158, 11, 0.12);
373+
color: #d97706;
374+
}
375+
.badge-confirmed {
376+
background: rgba(22, 163, 74, 0.1);
377+
color: var(--success);
378+
}
379+
.badge-requires {
380+
background: rgba(245, 158, 11, 0.12);
381+
color: #d97706;
382+
}
383+
384+
/* Checkmark animation */
385+
@keyframes checkPop {
386+
0% { transform: scale(0); opacity: 0; }
387+
60% { transform: scale(1.15); opacity: 1; }
388+
100% { transform: scale(1); opacity: 1; }
389+
}
390+
.check {
391+
animation: checkPop 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
392+
}
321393
</style>
322394
{% block head %}{% endblock %}
323395
</head>
@@ -333,7 +405,7 @@
333405
{% endif %}
334406
<div class="container">
335407
{% block content %}{% endblock %}
336-
<p class="powered">Powered by <a href="https://github.com/olivierlambert/calrs">calrs</a></p>
408+
<p class="powered">Powered by <a href="https://github.com/olivierlambert/calrs">calrs</a> 🦀</p>
337409
</div>
338410
<script>
339411
// CSRF protection: inject _csrf hidden field into all POST forms

templates/confirmed.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
<div class="card" style="text-align: center;">
99
{% if pending %}
10-
<div class="check" style="background: var(--text-muted);">&#8987;</div>
10+
<div class="check" style="background: #d97706;">&#8987;</div>
1111
<h1>Pending confirmation</h1>
1212
<p class="subtitle">Your booking request has been sent to {{ host_name }}. You'll receive an email at {{ guest_email }} once it's confirmed.</p>
1313
{% else %}

templates/dashboard_base.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,7 @@
330330
<div class="container">
331331
{% block dashboard_content %}{% endblock %}
332332
</div>
333-
<p class="powered" style="max-width: 900px;">Powered by <a href="https://github.com/olivierlambert/calrs">calrs</a></p>
333+
<p class="powered" style="max-width: 900px;">Powered by <a href="https://github.com/olivierlambert/calrs">calrs</a> 🦀</p>
334334
</main>
335335
</div>
336336
{% endblock %}

templates/dashboard_bookings.html

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,10 @@ <h1 style="margin-bottom: 1rem;">Upcoming bookings</h1>
4545
</div>
4646
{% endfor %}
4747
{% else %}
48-
<p class="empty" style="padding: 1rem;">No upcoming bookings.</p>
48+
<div class="empty" style="padding: 2rem 1rem;">
49+
<div class="empty-icon">&#128203;</div>
50+
<div class="empty-text">No upcoming bookings yet. They'll show up here once someone books a time with you.</div>
51+
</div>
4952
{% endif %}
5053
</div>
5154
{% endblock %}

templates/dashboard_event_types.html

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ <h1 style="margin-bottom: 0;">Event types</h1>
1212
<div class="et-info">
1313
<strong>{{ et.title }}</strong>
1414
<span style="color: var(--text-muted); font-size: 0.85rem;">&middot; {{ et.duration_min }}min</span>
15-
{% if not et.enabled %}<span style="color: var(--error-text); font-size: 0.8rem;"> (disabled)</span>{% endif %}
16-
{% if et.requires_confirmation %}<span style="color: var(--text-muted); font-size: 0.8rem;"> (requires confirmation)</span>{% endif %}
15+
{% if not et.enabled %}<span class="badge badge-disabled">disabled</span>{% endif %}
16+
{% if et.requires_confirmation %}<span class="badge badge-requires">requires confirmation</span>{% endif %}
1717
</div>
1818
<div class="et-actions">
1919
<a href="/dashboard/event-types/{{ et.slug }}/edit" class="slot-btn" style="text-decoration: none; font-size: 0.8rem;">Edit</a>
@@ -31,7 +31,10 @@ <h1 style="margin-bottom: 0;">Event types</h1>
3131
</div>
3232
{% endfor %}
3333
{% else %}
34-
<p class="empty" style="padding: 1rem;">No event types yet. <a href="/dashboard/event-types/new" style="color: var(--accent); text-decoration: none;">Create one</a> to start accepting bookings.</p>
34+
<div class="empty" style="padding: 2rem 1rem;">
35+
<div class="empty-icon">&#128197;</div>
36+
<div class="empty-text">No event types yet. <a href="/dashboard/event-types/new" style="color: var(--accent); text-decoration: none;">Create one</a> to start accepting bookings.</div>
37+
</div>
3538
{% endif %}
3639
</div>
3740

@@ -46,7 +49,7 @@ <h2 style="margin-bottom: 0;">Group event types</h2>
4649
<div class="et-info">
4750
<strong>{{ et.title }}</strong>
4851
<span style="color: var(--text-muted); font-size: 0.85rem;">&middot; {{ et.group_name }} &middot; {{ et.duration_min }}min</span>
49-
{% if not et.enabled %}<span style="color: var(--error-text); font-size: 0.8rem;"> (disabled)</span>{% endif %}
52+
{% if not et.enabled %}<span class="badge badge-disabled">disabled</span>{% endif %}
5053
</div>
5154
<div class="et-actions">
5255
<a href="/g/{{ et.group_slug }}/{{ et.slug }}" class="slot-btn" style="text-decoration: none; font-size: 0.8rem;">View</a>
@@ -60,7 +63,10 @@ <h2 style="margin-bottom: 0;">Group event types</h2>
6063
<h2 style="margin-bottom: 0;">Group event types</h2>
6164
<a href="/dashboard/group-event-types/new" class="slot-btn" style="text-decoration: none; font-size: 0.85rem; font-weight: 600; color: var(--accent); border-color: var(--accent);">+ New</a>
6265
</div>
63-
<p class="empty" style="padding: 1rem;">No group event types yet.</p>
66+
<div class="empty" style="padding: 2rem 1rem;">
67+
<div class="empty-icon">&#128101;</div>
68+
<div class="empty-text">No group event types yet.</div>
69+
</div>
6470
</div>
6571
{% endif %}
6672
{% endblock %}

templates/dashboard_overview.html

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ <h1>Welcome, {{ user_name }}</h1>
88
</p>
99
{% if username %}
1010
<p style="font-size: 0.85rem; color: var(--text-muted); margin-top: 0.5rem;">
11-
Public page: <a href="/u/{{ username }}" style="color: var(--accent); text-decoration: none;">/u/{{ username }}</a>
11+
Public page: <a href="/u/{{ username }}" target="_blank" rel="noopener" style="color: var(--accent); text-decoration: none;">/u/{{ username }}</a>
1212
</p>
1313
{% endif %}
1414
</div>
@@ -23,22 +23,22 @@ <h2 style="margin-bottom: 0.5rem;">Connect your calendar</h2>
2323
{% endif %}
2424

2525
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; margin-bottom: 1.25rem;">
26-
<div class="card" style="text-align: center; padding: 1.25rem;">
26+
<a href="/dashboard/event-types" class="card" style="text-align: center; padding: 1.25rem; text-decoration: none; color: inherit;">
2727
<div style="font-size: 1.75rem; font-weight: 700; color: var(--accent);">{{ event_type_count }}</div>
2828
<div style="font-size: 0.85rem; color: var(--text-muted);">Event Types</div>
29-
</div>
30-
<div class="card" style="text-align: center; padding: 1.25rem;">
29+
</a>
30+
<a href="/dashboard/bookings" class="card" style="text-align: center; padding: 1.25rem; text-decoration: none; color: inherit;">
3131
<div style="font-size: 1.75rem; font-weight: 700; color: var(--accent);">{{ upcoming_count }}</div>
3232
<div style="font-size: 0.85rem; color: var(--text-muted);">Upcoming Bookings</div>
33-
</div>
34-
<div class="card" style="text-align: center; padding: 1.25rem;">
33+
</a>
34+
<a href="/dashboard/bookings" class="card" style="text-align: center; padding: 1.25rem; text-decoration: none; color: inherit;">
3535
<div style="font-size: 1.75rem; font-weight: 700; color: var(--accent);">{{ pending_count }}</div>
3636
<div style="font-size: 0.85rem; color: var(--text-muted);">Pending Approval</div>
37-
</div>
38-
<div class="card" style="text-align: center; padding: 1.25rem;">
37+
</a>
38+
<a href="/dashboard/sources" class="card" style="text-align: center; padding: 1.25rem; text-decoration: none; color: inherit;">
3939
<div style="font-size: 1.75rem; font-weight: 700; color: var(--accent);">{{ source_count }}</div>
4040
<div style="font-size: 0.85rem; color: var(--text-muted);">Calendar Sources</div>
41-
</div>
41+
</a>
4242
</div>
4343

4444
{% if pending_bookings %}
@@ -47,7 +47,7 @@ <h2>Pending approval</h2>
4747
{% for b in pending_bookings %}
4848
<div class="booking-row" {% if not loop.last %}style="border-bottom: 1px solid var(--border);"{% endif %}>
4949
<div class="booking-info">
50-
<div><strong>{{ b.event_title }}</strong> with {{ b.guest_name }}</div>
50+
<div><strong>{{ b.event_title }}</strong> with {{ b.guest_name }} <span class="badge badge-pending">pending</span></div>
5151
<div style="color: var(--text-muted); font-size: 0.85rem;">{{ b.start_at }}</div>
5252
</div>
5353
<div class="booking-actions">

0 commit comments

Comments
 (0)