mirror of
https://github.com/svemagie/blog-eleventy-indiekit.git
synced 2026-05-14 22:48:50 +02:00
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:
@@ -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>
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user