docs: add Claude AI assistant rules and configuration
Add comprehensive rule documentation for AI-assisted development covering authentication surfaces, outbound-call safety invariants, and other project conventions to guide Claude's understanding of critical system behaviors.
This commit is contained in:
11
dashboard/package-lock.json
generated
11
dashboard/package-lock.json
generated
@@ -13,6 +13,7 @@
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@tailwindcss/vite": "^4.1.3",
|
||||
"@types/node": "^25.8.0",
|
||||
"daisyui": "^5.0.0",
|
||||
"svelte": "^5.25.3",
|
||||
"svelte-check": "^4.1.4",
|
||||
"tailwindcss": "^4.1.3",
|
||||
@@ -1262,6 +1263,16 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/daisyui": {
|
||||
"version": "5.7.0",
|
||||
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.7.0.tgz",
|
||||
"integrity": "sha512-2/kYbxaKtv349lPrTyxMKC9SHsyA7fBULMSabJljDE82D079cjqz+UyAzsogWgy4sTs5NDvD000acfcFqbO1XA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/saadeghi/daisyui?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@tailwindcss/vite": "^4.1.3",
|
||||
"@types/node": "^25.8.0",
|
||||
"daisyui": "^5.0.0",
|
||||
"svelte": "^5.25.3",
|
||||
"svelte-check": "^4.1.4",
|
||||
"tailwindcss": "^4.1.3",
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin 'daisyui' {
|
||||
themes: light --default, dark --prefersdark;
|
||||
}
|
||||
|
||||
5
dashboard/src/hooks.client.ts
Normal file
5
dashboard/src/hooks.client.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { auth } from '$lib/auth.svelte';
|
||||
|
||||
// Runs once, before the app mounts: capture the Casdoor callback token from
|
||||
// the URL fragment before any /auth/me call or render.
|
||||
auth.captureFragmentToken();
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
AccessToken,
|
||||
CallHistoryRow,
|
||||
CallSummary,
|
||||
DeviceStatus,
|
||||
@@ -10,19 +11,12 @@ import type {
|
||||
} from './types';
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Bearer token — one static API_TOKEN shared with REST/WS/MCP.
|
||||
// Kept in localStorage; a 401 prompts once and retries.
|
||||
// Authed fetch — the browser holds a Casdoor JWT (or, for scripted use,
|
||||
// a PAT) in localStorage via the auth store. On a 401 we attempt one
|
||||
// silent refresh and retry; a second failure logs out.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
const TOKEN_KEY = 'hold-slayer-token';
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem(TOKEN_KEY) ?? '';
|
||||
}
|
||||
|
||||
export function setToken(value: string): void {
|
||||
localStorage.setItem(TOKEN_KEY, value);
|
||||
}
|
||||
import { auth, getToken } from './auth.svelte';
|
||||
|
||||
function withAuth(init: RequestInit): RequestInit {
|
||||
const token = getToken();
|
||||
@@ -36,10 +30,11 @@ function withAuth(init: RequestInit): RequestInit {
|
||||
async function request(path: string, init: RequestInit = {}): Promise<Response> {
|
||||
let res = await fetch(path, withAuth(init));
|
||||
if (res.status === 401) {
|
||||
const supplied = window.prompt('Hold Slayer API token (API_TOKEN in .env):');
|
||||
if (supplied !== null && supplied.trim()) {
|
||||
setToken(supplied.trim());
|
||||
const refreshed = await auth.trySilentRefresh();
|
||||
if (refreshed) {
|
||||
res = await fetch(path, withAuth(init));
|
||||
} else {
|
||||
auth.setUnauthenticated();
|
||||
}
|
||||
}
|
||||
return res;
|
||||
@@ -89,8 +84,10 @@ export async function fetchTranscript(callId: string): Promise<TranscriptRow[]>
|
||||
}
|
||||
|
||||
export function recordingUrl(callId: string): string {
|
||||
// <audio> can't send headers, so the token rides as a query param
|
||||
// (accepted server-side alongside the Authorization header).
|
||||
// <audio> can't send headers, so the current token (Casdoor JWT, or a PAT)
|
||||
// rides as a query param — the same narrow fallback the WebSocket uses,
|
||||
// accepted server-side alongside the Authorization header. The proactive
|
||||
// refresh timer keeps the stored JWT valid, so it's fresh at click time.
|
||||
const token = getToken();
|
||||
const suffix = token ? `?token=${encodeURIComponent(token)}` : '';
|
||||
return `/api/v1/calls/${callId}/recording${suffix}`;
|
||||
@@ -137,11 +134,35 @@ export async function setDeviceDnd(deviceId: string, enabled: boolean): Promise<
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Personal Access Tokens (owner-only) — for MCP/CLI clients.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
export async function fetchTokens(): Promise<AccessToken[]> {
|
||||
return get<AccessToken[]>('/api/v1/tokens');
|
||||
}
|
||||
|
||||
export async function createToken(name: string): Promise<AccessToken> {
|
||||
const res = await request('/api/v1/tokens', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
return res.json() as Promise<AccessToken>;
|
||||
}
|
||||
|
||||
export async function revokeToken(tokenId: string): Promise<void> {
|
||||
const res = await request(`/api/v1/tokens/${tokenId}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
export function connectEventStream(
|
||||
onEvent: (e: GatewayEvent) => void,
|
||||
onClose: () => void,
|
||||
): () => void {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
// Browsers can't set headers on WS connects — the token rides as ?token=.
|
||||
const token = getToken();
|
||||
const suffix = token ? `?token=${encodeURIComponent(token)}` : '';
|
||||
const ws = new WebSocket(`${proto}//${location.host}/ws/events${suffix}`);
|
||||
|
||||
146
dashboard/src/lib/auth.svelte.ts
Normal file
146
dashboard/src/lib/auth.svelte.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { User } from './types';
|
||||
|
||||
const TOKEN_KEY = 'hold-slayer-token';
|
||||
|
||||
// 'denied' = authenticated with Casdoor but not the owner of this gateway.
|
||||
export type AuthStatus = 'loading' | 'authed' | 'unauthenticated' | 'denied';
|
||||
|
||||
export function getToken(): string {
|
||||
return localStorage.getItem(TOKEN_KEY) || '';
|
||||
}
|
||||
|
||||
function setToken(token: string) {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
class AuthStore {
|
||||
status = $state<AuthStatus>('loading');
|
||||
user = $state<User | null>(null);
|
||||
|
||||
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
get isOwner(): boolean {
|
||||
return this.user?.is_owner ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a `#token=...` fragment left by the Casdoor callback, persist it,
|
||||
* and scrub it from the URL. Runs before the first /auth/me call.
|
||||
*/
|
||||
captureFragmentToken() {
|
||||
const hash = window.location.hash;
|
||||
if (hash.startsWith('#token=')) {
|
||||
const token = hash.slice(7);
|
||||
if (token) setToken(token);
|
||||
history.replaceState(null, '', window.location.pathname + window.location.search);
|
||||
}
|
||||
}
|
||||
|
||||
/** Determine auth state on boot. */
|
||||
async init(): Promise<void> {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
// No token: maybe SSO is disabled (dev mode) — /auth/me succeeds tokenless.
|
||||
try {
|
||||
const res = await fetch('/auth/me');
|
||||
if (res.ok) {
|
||||
this.applyUser(await res.json());
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* fall through to unauthenticated */
|
||||
}
|
||||
this.status = 'unauthenticated';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/auth/me', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!res.ok) {
|
||||
// Token invalid — try silent refresh once, then retry.
|
||||
const refreshed = await this.trySilentRefresh();
|
||||
if (refreshed) return this.init();
|
||||
clearToken();
|
||||
this.status = 'unauthenticated';
|
||||
return;
|
||||
}
|
||||
this.applyUser(await res.json());
|
||||
this.scheduleTokenRefresh(token);
|
||||
} catch {
|
||||
this.status = 'unauthenticated';
|
||||
}
|
||||
}
|
||||
|
||||
/** Set user + status from an /auth/me payload. Non-owners are denied. */
|
||||
private applyUser(user: User) {
|
||||
this.user = user;
|
||||
this.status = user.is_owner ? 'authed' : 'denied';
|
||||
}
|
||||
|
||||
setUnauthenticated() {
|
||||
clearToken();
|
||||
this.user = null;
|
||||
this.status = 'unauthenticated';
|
||||
}
|
||||
|
||||
/** Silent token refresh via a hidden iframe + postMessage. */
|
||||
trySilentRefresh(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.style.display = 'none';
|
||||
iframe.src = '/auth/silent-refresh';
|
||||
let resolved = false;
|
||||
|
||||
const cleanup = () => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
window.removeEventListener('message', onMessage);
|
||||
iframe.remove();
|
||||
};
|
||||
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
if (!event.data || event.data.type !== 'hold-slayer-refresh') return;
|
||||
cleanup();
|
||||
if (event.data.token) {
|
||||
setToken(event.data.token);
|
||||
this.scheduleTokenRefresh(event.data.token);
|
||||
resolve(true);
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', onMessage);
|
||||
document.body.appendChild(iframe);
|
||||
|
||||
setTimeout(() => {
|
||||
cleanup();
|
||||
resolve(false);
|
||||
}, 10000);
|
||||
});
|
||||
}
|
||||
|
||||
/** Proactively refresh 5 minutes before JWT expiry. PATs are skipped. */
|
||||
scheduleTokenRefresh(token: string) {
|
||||
if (this.refreshTimer) clearTimeout(this.refreshTimer);
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
const exp = payload.exp * 1000;
|
||||
const refreshIn = Math.max(exp - Date.now() - 5 * 60 * 1000, 30 * 1000);
|
||||
this.refreshTimer = setTimeout(async () => {
|
||||
const ok = await this.trySilentRefresh();
|
||||
if (!ok) this.setUnauthenticated();
|
||||
}, refreshIn);
|
||||
} catch {
|
||||
// Not a JWT (e.g. a PAT) — no refresh needed.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const auth = new AuthStore();
|
||||
17
dashboard/src/lib/components/DeniedScreen.svelte
Normal file
17
dashboard/src/lib/components/DeniedScreen.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { auth } from '$lib/auth.svelte';
|
||||
</script>
|
||||
|
||||
<div class="bg-base-100 fixed inset-0 z-[9999] flex items-center justify-center p-4">
|
||||
<div class="card bg-base-200 w-96 shadow-xl">
|
||||
<div class="card-body items-center gap-4 text-center">
|
||||
<h1 class="text-2xl font-bold">Not authorized</h1>
|
||||
<p class="text-sm opacity-70">
|
||||
Hold Slayer is a single-operator gateway. You're signed in as
|
||||
<span class="font-medium">{auth.user?.display_name ?? auth.user?.name}</span>,
|
||||
but this gateway is reserved for its owner.
|
||||
</p>
|
||||
<a href="/auth/logout" class="btn btn-outline w-full">Sign out</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
17
dashboard/src/lib/components/LoginScreen.svelte
Normal file
17
dashboard/src/lib/components/LoginScreen.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
// A full-screen sign-in gate. The anchor is a real navigation to the
|
||||
// server-side /auth/login route (which 302s to Casdoor), not a fetch.
|
||||
</script>
|
||||
|
||||
<div class="bg-base-100 fixed inset-0 z-[9999] flex items-center justify-center p-4">
|
||||
<div class="card bg-base-200 w-80 shadow-xl">
|
||||
<div class="card-body items-center gap-4 text-center">
|
||||
<h1 class="flex items-center justify-center gap-2 text-3xl font-bold">
|
||||
<span class="text-orange-500">🔥</span>
|
||||
Hold Slayer
|
||||
</h1>
|
||||
<p class="text-sm opacity-60">Sign in to the gateway</p>
|
||||
<a href="/auth/login" class="btn btn-primary w-full">Sign in with SSO</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
162
dashboard/src/lib/components/TokensModal.svelte
Normal file
162
dashboard/src/lib/components/TokensModal.svelte
Normal file
@@ -0,0 +1,162 @@
|
||||
<script lang="ts">
|
||||
import type { AccessToken } from '$lib/types';
|
||||
import { createToken, fetchTokens, revokeToken } from '$lib/api';
|
||||
|
||||
let { open = $bindable(false) }: { open?: boolean } = $props();
|
||||
|
||||
let tokens = $state<AccessToken[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let newName = $state('');
|
||||
let creating = $state(false);
|
||||
// The plaintext of a just-created token — shown once, never re-fetchable.
|
||||
let created = $state<AccessToken | null>(null);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
tokens = await fetchTokens();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
created = null;
|
||||
void load();
|
||||
}
|
||||
});
|
||||
|
||||
async function create() {
|
||||
if (!newName.trim()) return;
|
||||
creating = true;
|
||||
error = null;
|
||||
try {
|
||||
created = await createToken(newName.trim());
|
||||
newName = '';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(id: string) {
|
||||
error = null;
|
||||
try {
|
||||
await revokeToken(id);
|
||||
await load();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
}
|
||||
|
||||
function mcpConfig(plaintext: string): string {
|
||||
const url = `${location.origin}/mcp`;
|
||||
return JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
'hold-slayer': {
|
||||
type: 'streamable-http',
|
||||
url,
|
||||
headers: { Authorization: `Bearer ${plaintext}` }
|
||||
}
|
||||
}
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
function copy(text: string) {
|
||||
void navigator.clipboard.writeText(text);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div class="modal modal-open">
|
||||
<div class="modal-box max-w-2xl">
|
||||
<h3 class="text-lg font-bold">API Tokens</h3>
|
||||
<p class="py-1 text-sm opacity-60">
|
||||
Personal access tokens for MCP/CLI clients (Claude Desktop, Cline). The
|
||||
plaintext is shown once — store it now.
|
||||
</p>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-error my-2 text-sm">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if created?.token}
|
||||
<div class="alert alert-success my-3 flex-col items-start gap-2">
|
||||
<span class="font-medium">Token created — copy it now, it won't be shown again.</span>
|
||||
<code class="bg-base-300 w-full break-all rounded p-2 text-xs">{created.token}</code>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-xs" onclick={() => copy(created!.token!)}>Copy token</button>
|
||||
<button class="btn btn-xs" onclick={() => copy(mcpConfig(created!.token!))}
|
||||
>Copy MCP config</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="my-3 flex gap-2">
|
||||
<input
|
||||
class="input input-bordered flex-1"
|
||||
placeholder="Token name (e.g. Claude Desktop)"
|
||||
bind:value={newName}
|
||||
onkeydown={(e) => e.key === 'Enter' && create()}
|
||||
/>
|
||||
<button class="btn btn-primary" disabled={creating || !newName.trim()} onclick={create}>
|
||||
{creating ? 'Creating…' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="py-4 text-center opacity-60">Loading…</div>
|
||||
{:else if tokens.length === 0}
|
||||
<div class="py-4 text-center opacity-60">No tokens yet.</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Prefix</th>
|
||||
<th>Last used</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each tokens as t (t.id)}
|
||||
<tr class:opacity-50={t.revoked_at}>
|
||||
<td>{t.name}</td>
|
||||
<td><code class="text-xs">{t.token_prefix}…</code></td>
|
||||
<td class="text-xs">{t.last_used_at?.slice(0, 10) ?? '—'}</td>
|
||||
<td class="text-right">
|
||||
{#if t.revoked_at}
|
||||
<span class="badge badge-ghost badge-sm">revoked</span>
|
||||
{:else}
|
||||
<button class="btn btn-ghost btn-xs text-error" onclick={() => revoke(t.id)}>
|
||||
Revoke
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="modal-action">
|
||||
<button class="btn" onclick={() => (open = false)}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="modal-backdrop" onclick={() => (open = false)} aria-label="Close"></button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,3 +1,23 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
display_name: string | null;
|
||||
email: string | null;
|
||||
is_owner: boolean;
|
||||
}
|
||||
|
||||
export interface AccessToken {
|
||||
id: string;
|
||||
name: string;
|
||||
token_prefix: string;
|
||||
created_at: string | null;
|
||||
last_used_at: string | null;
|
||||
expires_at: string | null;
|
||||
revoked_at: string | null;
|
||||
// Present only in the create response — the plaintext, shown once.
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export interface GatewayStatus {
|
||||
name: string;
|
||||
version: string;
|
||||
|
||||
@@ -2,9 +2,15 @@
|
||||
import '../app.css';
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { auth } from '$lib/auth.svelte';
|
||||
import LoginScreen from '$lib/components/LoginScreen.svelte';
|
||||
import DeniedScreen from '$lib/components/DeniedScreen.svelte';
|
||||
import TokensModal from '$lib/components/TokensModal.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let tokensOpen = $state(false);
|
||||
|
||||
type ThemeOverride = 'dark' | 'light' | null;
|
||||
let override = $state<ThemeOverride>(null);
|
||||
let systemDark = $state(true);
|
||||
@@ -12,7 +18,10 @@
|
||||
let isDark = $derived(override !== null ? override === 'dark' : systemDark);
|
||||
|
||||
$effect(() => {
|
||||
// Keep both theming systems in sync: Tailwind `dark:` variant (.dark class)
|
||||
// for the existing pages, and DaisyUI `data-theme` for the SSO components.
|
||||
document.documentElement.classList.toggle('dark', isDark);
|
||||
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
|
||||
});
|
||||
|
||||
function toggleTheme() {
|
||||
@@ -27,6 +36,8 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void auth.init();
|
||||
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
systemDark = mq.matches;
|
||||
|
||||
@@ -49,40 +60,63 @@
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-slate-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100">
|
||||
<header
|
||||
class="border-b border-gray-200 dark:border-gray-800 bg-white/80 dark:bg-gray-900/80 backdrop-blur sticky top-0 z-10"
|
||||
>
|
||||
<div class="mx-auto max-w-7xl px-4 py-3 flex items-center gap-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-orange-500 text-lg leading-none">🔥</span>
|
||||
<span class="font-semibold text-gray-900 dark:text-white tracking-tight">Hold Slayer</span>
|
||||
<span class="text-gray-500 text-sm hidden sm:inline">Gateway</span>
|
||||
</div>
|
||||
<nav class="flex gap-1 ml-2">
|
||||
{#each nav as item}
|
||||
<a
|
||||
href={item.href}
|
||||
class="px-3 py-1.5 rounded text-sm font-medium transition-colors {$page.url.pathname ===
|
||||
item.href
|
||||
? 'bg-orange-600 text-white'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'}"
|
||||
{#if auth.status === 'loading'}
|
||||
<div class="fixed inset-0 flex items-center justify-center bg-slate-50 dark:bg-gray-950">
|
||||
<span class="loading loading-spinner loading-lg text-orange-500"></span>
|
||||
</div>
|
||||
{:else if auth.status === 'unauthenticated'}
|
||||
<LoginScreen />
|
||||
{:else if auth.status === 'denied'}
|
||||
<DeniedScreen />
|
||||
{:else}
|
||||
<div class="min-h-screen bg-slate-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100">
|
||||
<header
|
||||
class="border-b border-gray-200 dark:border-gray-800 bg-white/80 dark:bg-gray-900/80 backdrop-blur sticky top-0 z-10"
|
||||
>
|
||||
<div class="mx-auto max-w-7xl px-4 py-3 flex items-center gap-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-orange-500 text-lg leading-none">🔥</span>
|
||||
<span class="font-semibold text-gray-900 dark:text-white tracking-tight">Hold Slayer</span>
|
||||
<span class="text-gray-500 text-sm hidden sm:inline">Gateway</span>
|
||||
</div>
|
||||
<nav class="flex gap-1 ml-2">
|
||||
{#each nav as item}
|
||||
<a
|
||||
href={item.href}
|
||||
class="px-3 py-1.5 rounded text-sm font-medium transition-colors {$page.url.pathname ===
|
||||
item.href
|
||||
? 'bg-orange-600 text-white'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'}"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
onclick={toggleTheme}
|
||||
class="text-xs px-2.5 py-1 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border border-gray-200 dark:border-gray-700 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
<button
|
||||
onclick={toggleTheme}
|
||||
class="ml-auto text-xs px-2.5 py-1 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border border-gray-200 dark:border-gray-700 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
{isDark ? 'Light' : 'Dark'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
{isDark ? 'Light' : 'Dark'}
|
||||
</button>
|
||||
<div class="dropdown dropdown-end">
|
||||
<button tabindex="0" class="btn btn-ghost btn-sm">
|
||||
{auth.user?.display_name ?? auth.user?.name ?? 'Owner'}
|
||||
</button>
|
||||
<ul class="dropdown-content menu bg-base-200 rounded-box z-20 w-48 p-2 shadow">
|
||||
<li><button onclick={() => (tokensOpen = true)}>API Tokens</button></li>
|
||||
<li><a href="/auth/logout">Sign out</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto max-w-7xl px-4 py-6">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
<main class="mx-auto max-w-7xl px-4 py-6">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<TokensModal bind:open={tokensOpen} />
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user