source: Klonkt/src/views/partials/bottom-tab.ejs@ 544cd86

main
Last change on this file since 544cd86 was 8aa85d0, checked in by roboburr <roboburr@…>, 3 months ago

Prutter = premium Hub feature: Hub-only + behind premium gate

Prutter (DMs) is now hard-scoped to Hub mode (route 404s outside hub) and
locked behind the premium gate via the new premiumUnlocked() (= premium off
→ freely available, demos keep working; premium on → Patreon required).
UI entry points (topnav/bottom-tab/Send DM) follow the same condition.
Added to the premium description in Admin → Settings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@…>

  • Property mode set to 100644
File size: 12.0 KB
Line 
1<%
2// Bottom-tab navigation — mobile-only (hidden ≥768px via CSS).
3//
4// Tabs:
5// logged-out: Home · Zoek · Inloggen (3 tabs)
6// logged-in: Home · Zoek · [Plus] · [Prutter] · Profiel
7// The Plus tab is a FAB-style center action (accent bg, slightly raised).
8// It only appears for users with create-post permission.
9// Prutter only appears when the site has DMs enabled.
10
11const _siteUrlBase = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
12const _path = (typeof currentPath !== 'undefined' && currentPath) ? currentPath : '/';
13const _canPost = !!(user && (typeof canMutate === 'undefined' || canMutate)
14 && permissions && permissions.canCreatePost && permissions.canCreatePost(user, site));
15const _hasPrutter = !!(user && site && site.enable_prutter && (typeof tenancy !== 'undefined' && tenancy === 'hub') && (typeof premiumUnlocked === 'undefined' || premiumUnlocked));
16const _ownProfile = user ? ('/users/' + user.username) : null;
17
18// Strip site prefix for cleaner matching
19function _normalize(p) {
20 if (_siteUrlBase && p.indexOf(_siteUrlBase) === 0) return p.slice(_siteUrlBase.length) || '/';
21 return p;
22}
23const _p = _normalize(_path);
24
25let _active = null;
26if (_p === '/' || _p === '') _active = 'home';
27else if (_p.indexOf('/search') === 0 || _p.indexOf('/tag/') === 0 || _p.indexOf('/type/') === 0) _active = 'search';
28else if (_p === '/posts/new' || /^\/posts\/[^/]+\/edit$/.test(_p)) _active = 'create';
29else if (_p.indexOf('/prutter') === 0) _active = 'prutter';
30else if (_p.indexOf('/account') === 0 || (_ownProfile && _p.indexOf(_ownProfile) === 0)) _active = 'profile';
31else if (_p.indexOf('/auth/login') === 0) _active = 'login';
32%>
33
34<nav class="bottom-tab" aria-label="Hoofdnavigatie">
35
36 <!-- Home -->
37 <a class="bottom-tab-item<%= _active === 'home' ? ' is-active' : '' %>"
38 href="<%= _siteUrlBase %>/"
39 hx-get="<%= _siteUrlBase %>/?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
40 hx-push-url="<%= _siteUrlBase %>/" hx-indicator="#pcms-loading"
41 aria-label="Home" <%= _active === 'home' ? 'aria-current="page"' : '' %>>
42 <svg class="bottom-tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
43 <path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
44 <polyline points="9 22 9 12 15 12 15 22"/>
45 </svg>
46 <span class="bottom-tab-label">Home</span>
47 </a>
48
49 <!-- Zoek -->
50 <button type="button" class="bottom-tab-item<%= _active === 'search' ? ' is-active' : '' %>"
51 id="bottom-tab-search-toggle" aria-label="Zoeken">
52 <svg class="bottom-tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
53 <circle cx="11" cy="11" r="7"/>
54 <line x1="21" y1="21" x2="16.65" y2="16.65"/>
55 </svg>
56 <span class="bottom-tab-label">Zoek</span>
57 </button>
58
59 <!-- Plus (FAB-style, only when user can post) -->
60 <% if (_canPost) { %>
61 <a class="bottom-tab-item bottom-tab-fab<%= _active === 'create' ? ' is-active' : '' %>"
62 href="<%= _siteUrlBase %>/posts/new"
63 hx-get="<%= _siteUrlBase %>/posts/new?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
64 hx-push-url="<%= _siteUrlBase %>/posts/new" hx-indicator="#pcms-loading"
65 aria-label="Nieuwe post">
66 <svg class="bottom-tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
67 <line x1="12" y1="5" x2="12" y2="19"/>
68 <line x1="5" y1="12" x2="19" y2="12"/>
69 </svg>
70 <span class="bottom-tab-label">Schrijven</span>
71 </a>
72 <% } %>
73
74 <!-- Prutter (DMs) -->
75 <% if (_hasPrutter) { %>
76 <a class="bottom-tab-item<%= _active === 'prutter' ? ' is-active' : '' %>"
77 href="<%= _siteUrlBase %>/prutter"
78 aria-label="Prutter berichten" <%= _active === 'prutter' ? 'aria-current="page"' : '' %>>
79 <svg class="bottom-tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
80 <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
81 </svg>
82 <span class="bottom-tab-label">Prutter</span>
83 <% if (typeof unreadDmCount !== 'undefined' && unreadDmCount > 0) { %>
84 <span class="bottom-tab-badge" aria-label="<%= unreadDmCount %> ongelezen"><%= unreadDmCount > 99 ? '99+' : unreadDmCount %></span>
85 <% } %>
86 </a>
87 <% } %>
88
89 <!-- Profiel / Inloggen -->
90 <% if (user) { %>
91 <button type="button"
92 class="bottom-tab-item<%= _active === 'profile' ? ' is-active' : '' %>"
93 data-profile-sheet-toggle
94 aria-label="Profiel menu" aria-haspopup="dialog">
95 <% if (user.avatar_url) { %>
96 <img class="bottom-tab-avatar bottom-tab-avatar-img" src="<%= user.avatar_url %>" alt="">
97 <% } else { %>
98 <span class="bottom-tab-avatar"><%= user.username.charAt(0).toUpperCase() %></span>
99 <% } %>
100 <span class="bottom-tab-label">Profiel</span>
101 </button>
102 <% } else { %>
103 <a class="bottom-tab-item<%= _active === 'login' ? ' is-active' : '' %>"
104 href="/auth/login"
105 aria-label="Inloggen">
106 <svg class="bottom-tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
107 <path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/>
108 <polyline points="10 17 15 12 10 7"/>
109 <line x1="15" y1="12" x2="3" y2="12"/>
110 </svg>
111 <span class="bottom-tab-label">Inloggen</span>
112 </a>
113 <% } %>
114
115</nav>
116
117<style>
118/* ============================================================
119 BOTTOM-TAB NAVIGATION (mobile-first)
120 - Visible by default (mobile)
121 - Hidden ≥768px (desktop uses .masthead instead)
122 - Sticks to bottom of viewport, respects safe-area-inset
123 ============================================================ */
124
125.bottom-tab {
126 position: fixed;
127 bottom: 0;
128 left: 0;
129 right: 0;
130 /* z-index 1050: above mini-player (1000), below open sheet (1100).
131 This makes the FAB (Schrijven knop) overlay the mini-player when
132 they overlap visually. */
133 z-index: 1050;
134 display: flex;
135 align-items: stretch;
136 justify-content: space-around;
137 background: color-mix(in srgb, var(--paper) 92%, transparent);
138 backdrop-filter: blur(16px) saturate(140%);
139 -webkit-backdrop-filter: blur(16px) saturate(140%);
140 border-top: 1px solid var(--rule);
141 padding-bottom: env(safe-area-inset-bottom, 0);
142 font-family: var(--font-ui);
143 /* Subtle shadow so the bar feels "lifted" above content */
144 box-shadow: 0 -2px 12px color-mix(in srgb, var(--ink) 5%, transparent);
145}
146
147.bottom-tab-item {
148 flex: 1 1 0;
149 min-height: 56px;
150 /* Tap target ≥44px guaranteed (icon-row 24 + label 12 + padding) */
151 display: flex;
152 flex-direction: column;
153 align-items: center;
154 justify-content: center;
155 gap: 2px;
156 padding: 6px 4px;
157 border: 0;
158 background: transparent;
159 color: var(--ink-muted);
160 text-decoration: none;
161 cursor: pointer;
162 font-family: inherit;
163 position: relative;
164 transition: color 120ms ease, transform 120ms ease;
165 -webkit-tap-highlight-color: transparent;
166}
167
168.bottom-tab-icon {
169 width: 24px;
170 height: 24px;
171 flex-shrink: 0;
172}
173
174.bottom-tab-label {
175 font-size: 10px;
176 font-weight: 500;
177 letter-spacing: 0.01em;
178 line-height: 1;
179 white-space: nowrap;
180}
181
182/* Pressed/active feedback — replaces hover for touch */
183.bottom-tab-item:active {
184 transform: scale(0.92);
185 color: var(--ink);
186}
187
188/* Selected tab */
189.bottom-tab-item.is-active {
190 color: var(--accent);
191}
192.bottom-tab-item.is-active::before {
193 content: '';
194 position: absolute;
195 top: 0;
196 left: 50%;
197 transform: translateX(-50%);
198 width: 28px;
199 height: 2px;
200 background: var(--accent);
201 border-radius: 0 0 2px 2px;
202}
203
204/* Hover only on devices that actually have it */
205@media (hover: hover) {
206 .bottom-tab-item:hover { color: var(--ink); }
207 .bottom-tab-item.is-active:hover { color: var(--accent); }
208}
209
210/* ── FAB-style Plus tab ───────────────────────────────
211 The FAB icon is taken OUT of the flex flow with position:absolute so it
212 cannot inflate the bar height or shift the label baseline. The label
213 stays in flex flow with a margin-top equal to (icon + gap) to mimic
214 a regular icon's contribution — that way center-alignment matches the
215 other tabs exactly. */
216.bottom-tab-fab {
217 position: relative;
218}
219.bottom-tab-fab .bottom-tab-icon {
220 position: absolute;
221 left: 50%;
222 top: -12px; /* overhang above the bar */
223 transform: translateX(-50%);
224 width: 44px;
225 height: 44px;
226 padding: 10px;
227 background: var(--accent);
228 color: #fff;
229 border-radius: 50%;
230 box-shadow: 0 4px 12px color-mix(in srgb, var(--accent) 35%, transparent);
231 transition: transform 120ms ease, box-shadow 120ms ease;
232 margin: 0; /* clear any inherited margin */
233}
234.bottom-tab-fab:active .bottom-tab-icon {
235 transform: translateX(-50%) scale(0.92);
236 box-shadow: 0 2px 6px color-mix(in srgb, var(--accent) 35%, transparent);
237}
238.bottom-tab-fab .bottom-tab-label {
239 /* Mimic the space a regular 24px icon + 2px gap would occupy, so the
240 label sits at the same vertical position as labels in sibling tabs. */
241 margin-top: 26px;
242 color: var(--ink-muted);
243}
244.bottom-tab-fab.is-active .bottom-tab-label { color: var(--accent); }
245.bottom-tab-fab.is-active::before { display: none; } /* FAB doesn't need top-bar indicator */
246
247/* ── Avatar in profile tab ──────────────────────────── */
248.bottom-tab-avatar {
249 display: inline-flex;
250 align-items: center;
251 justify-content: center;
252 width: 24px;
253 height: 24px;
254 border-radius: 50%;
255 background: var(--paper-2);
256 color: var(--ink-soft);
257 font-size: 11px;
258 font-weight: 600;
259 line-height: 1;
260 overflow: hidden;
261 object-fit: cover;
262}
263.bottom-tab-avatar-img {
264 /* When rendered as <img>, the same dimensions + object-fit give the
265 same circular crop as the letter span. */
266 display: block;
267}
268/* Active-state used to tint the avatar with --accent. With user-uploaded
269 avatars that override looks broken (orange wash over the photo). The
270 active state is already signalled by the label color, which is enough. */
271
272/* ── Unread badge ───────────────────────────────────── */
273.bottom-tab-badge {
274 position: absolute;
275 top: 4px;
276 left: calc(50% + 8px);
277 min-width: 18px;
278 height: 18px;
279 padding: 0 5px;
280 border-radius: 9px;
281 background: var(--accent);
282 color: #fff;
283 font-size: 10px;
284 font-weight: 700;
285 line-height: 18px;
286 text-align: center;
287 border: 2px solid var(--paper);
288 pointer-events: none;
289}
290
291/* ============================================================
292 HIDE on desktop — top-nav takes over
293 ============================================================ */
294@media (min-width: 768px) {
295 .bottom-tab { display: none; }
296}
297
298/* ============================================================
299 When bottom-tab is present, body needs bottom padding so
300 content doesn't sit underneath it. Applied via body class.
301 ============================================================ */
302@media (max-width: 767px) {
303 body.has-bottom-tab .pcms-main {
304 /* 56 (tab height) + safe-area + breathing room */
305 padding-bottom: calc(72px + env(safe-area-inset-bottom, 0));
306 }
307 /* The .has-audio-player override (extra padding for stacked bars) lives
308 in audio.css — keeps the audio-related rules together. */
309}
310</style>
311
312<script>
313// Wire the bottom-tab search button into the existing #search-overlay
314(function() {
315 const btn = document.getElementById('bottom-tab-search-toggle');
316 const overlay = document.getElementById('search-overlay');
317 if (btn && overlay) {
318 btn.addEventListener('click', function() {
319 overlay.hidden = false;
320 const inp = overlay.querySelector('input');
321 if (inp) inp.focus();
322 });
323 }
324})();
325</script>
Note: See TracBrowser for help on using the repository browser.