feat: add admin UI for logged-in users (dashboard link + FAB)

Auth detection via /session/login probe with sessionStorage cache.
Dashboard link appears in desktop and mobile nav when authenticated.
Floating action button with quick-create menu for Note, Article,
Photo, Bookmark, and Page post types.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ricardo
2026-02-05 12:12:33 +01:00
parent 4e6a4747e6
commit 8229dfe3c7
3 changed files with 187 additions and 0 deletions
+89
View File
@@ -154,6 +154,18 @@
</div>
</div>
<a href="/interactions/">Interactions</a>
<a href="/dashboard"
x-data="{ show: false }"
x-show="show"
x-cloak
x-transition
@indiekit:auth.window="show = $event.detail.loggedIn"
class="admin-nav-link">
<svg class="w-4 h-4 inline -mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>
</svg>
Dashboard
</a>
</nav>
<a href="/search/" aria-label="Search" title="Search" class="p-2 rounded-lg text-surface-600 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -220,6 +232,13 @@
</div>
<a href="/interactions/">Interactions</a>
<a href="/search/">Search</a>
<a href="/dashboard"
x-data="{ show: false }"
x-show="show"
x-cloak
@indiekit:auth.window="show = $event.detail.loggedIn">
Dashboard
</a>
{# Mobile theme toggle #}
<button type="button" class="mobile-theme-toggle" aria-label="Toggle dark mode">
<span class="theme-label">Theme</span>
@@ -334,5 +353,75 @@
</script>
{# Client-side webmention fetcher - supplements build-time cache with real-time data #}
<script src="/js/webmentions.js?v={{ '/js/webmentions.js' | hash }}" defer></script>
{# Admin auth detection - shows dashboard link + FAB when logged in #}
<script src="/js/admin.js?v={{ '/js/admin.js' | hash }}" defer></script>
{# Floating Action Button - visible only when logged in #}
<div x-data="{ show: false, open: false }"
x-show="show"
x-cloak
@indiekit:auth.window="show = $event.detail.loggedIn"
@keydown.escape.window="open = false"
class="fab-container">
{# Backdrop #}
<div x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click="open = false"
class="fab-backdrop"></div>
{# Menu items #}
<div x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 translate-y-4"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 translate-y-4"
class="fab-menu">
<a href="/posts/create?type=page" @click="open = false" class="fab-menu-item">
<svg class="w-5 h-5 text-surface-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
</svg>
<span>Page</span>
</a>
<a href="/posts/create?type=bookmark" @click="open = false" class="fab-menu-item">
<svg class="w-5 h-5 text-surface-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>
</svg>
<span>Bookmark</span>
</a>
<a href="/posts/create?type=photo" @click="open = false" class="fab-menu-item">
<svg class="w-5 h-5 text-surface-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>
</svg>
<span>Photo</span>
</a>
<a href="/posts/create?type=article" @click="open = false" class="fab-menu-item">
<svg class="w-5 h-5 text-surface-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>
</svg>
<span>Article</span>
</a>
<a href="/posts/create?type=note" @click="open = false" class="fab-menu-item">
<svg class="w-5 h-5 text-surface-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
</svg>
<span>Note</span>
</a>
</div>
{# FAB button #}
<button @click="open = !open"
class="fab-button"
:aria-expanded="open"
aria-label="Create new post">
<svg class="w-7 h-7 transition-transform duration-200" :class="{ 'rotate-45': open }" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5" stroke-linecap="round">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</button>
</div>
</body>
</html>
+31
View File
@@ -370,6 +370,37 @@
}
}
/* Admin UI - FAB and dashboard link */
@layer components {
.fab-container {
@apply fixed bottom-6 right-6 z-50 flex flex-col items-end;
}
.fab-backdrop {
@apply fixed inset-0 bg-black/20 dark:bg-black/40 z-40;
}
.fab-button {
@apply relative z-50 w-14 h-14 rounded-full bg-primary-600 hover:bg-primary-700 dark:bg-primary-500 dark:hover:bg-primary-600 text-white shadow-lg hover:shadow-xl transition-all duration-200 flex items-center justify-center;
}
.fab-button:focus-visible {
@apply outline-2 outline-offset-2 outline-primary-500;
}
.fab-menu {
@apply relative z-50 mb-3 flex flex-col gap-2 items-end;
}
.fab-menu-item {
@apply flex items-center gap-3 px-4 py-3 rounded-xl bg-white dark:bg-surface-800 shadow-md hover:shadow-lg border border-surface-200 dark:border-surface-700 text-surface-700 dark:text-surface-200 hover:text-primary-600 dark:hover:text-primary-400 no-underline transition-all duration-150 text-sm font-medium;
}
.admin-nav-link {
@apply text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 no-underline transition-colors py-2 inline-flex items-center gap-1;
}
}
/* Performance: content-visibility for off-screen rendering optimization */
@layer utilities {
.content-auto {
+67
View File
@@ -0,0 +1,67 @@
/**
* Admin UI auth detection
* Checks if the user is logged in to Indiekit by probing /session/login.
* Dispatches a custom event for Alpine.js components to react to.
*/
(function () {
const cacheKey = 'indiekit-auth-status';
const cacheTTL = 5 * 60 * 1000; // 5 minutes
function getCachedStatus() {
try {
const cached = sessionStorage.getItem(cacheKey);
if (!cached) return null;
const parsed = JSON.parse(cached);
if (Date.now() - parsed.ts > cacheTTL) {
sessionStorage.removeItem(cacheKey);
return null;
}
return parsed.loggedIn;
} catch {
return null;
}
}
function setCachedStatus(loggedIn) {
try {
sessionStorage.setItem(cacheKey, JSON.stringify({ ts: Date.now(), loggedIn: loggedIn }));
} catch {
// sessionStorage full or unavailable
}
}
function dispatch(loggedIn) {
document.dispatchEvent(new CustomEvent('indiekit:auth', { detail: { loggedIn: loggedIn } }));
if (loggedIn) {
document.body.setAttribute('data-indiekit-auth', 'true');
} else {
document.body.removeAttribute('data-indiekit-auth');
}
}
function checkAuth() {
return fetch('/session/login', { credentials: 'same-origin', redirect: 'manual', cache: 'no-store' })
.then(function (response) {
// opaqueredirect means 302 → user is logged in
return response.type === 'opaqueredirect';
})
.catch(function () {
return false;
});
}
// Cache-then-verify: show from cache instantly, correct in background
var cached = getCachedStatus();
if (cached !== null) {
dispatch(cached);
}
checkAuth().then(function (loggedIn) {
setCachedStatus(loggedIn);
// Only re-dispatch if different from cache or no cache existed
if (cached === null || cached !== loggedIn) {
dispatch(loggedIn);
}
});
})();