chore(dashboard): remove superseded /admins page
/admin/users is a strict superset of the pre-3.5 /admin/admins page (adds role chip, email column, search, role-aware affordance hiding, and the password-reveal flow), so the old page would only split traffic and confuse muscle memory. Also drops the AdminUserRecord type alias kept in place to ease the transition. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -274,9 +274,6 @@ export interface AdminDto {
|
|||||||
last_login_at: string | null;
|
last_login_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @deprecated use AdminDto. Kept until the /admins route is retired. */
|
|
||||||
export type AdminUserRecord = AdminDto;
|
|
||||||
|
|
||||||
export interface CreateAdminInput {
|
export interface CreateAdminInput {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
|||||||
@@ -1,687 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { base } from '$app/paths';
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { api, ApiError, type AdminUserRecord } from '$lib/api';
|
|
||||||
import { currentUser } from '$lib/auth';
|
|
||||||
|
|
||||||
let admins = $state<AdminUserRecord[]>([]);
|
|
||||||
let loadError = $state<string | null>(null);
|
|
||||||
let banner = $state<{ kind: 'error' | 'info'; message: string } | null>(null);
|
|
||||||
|
|
||||||
const me = $derived($currentUser);
|
|
||||||
|
|
||||||
let createOpen = $state(false);
|
|
||||||
let createForm = $state({ username: '', password: '', confirm: '' });
|
|
||||||
let createPending = $state(false);
|
|
||||||
let createError = $state<string | null>(null);
|
|
||||||
|
|
||||||
let passwordTarget = $state<AdminUserRecord | null>(null);
|
|
||||||
let passwordForm = $state({ password: '', confirm: '' });
|
|
||||||
let passwordPending = $state(false);
|
|
||||||
let passwordError = $state<string | null>(null);
|
|
||||||
|
|
||||||
let deleteTarget = $state<AdminUserRecord | null>(null);
|
|
||||||
let deletePending = $state(false);
|
|
||||||
|
|
||||||
let actionsOpenFor = $state<string | null>(null);
|
|
||||||
|
|
||||||
onMount(refresh);
|
|
||||||
|
|
||||||
async function refresh() {
|
|
||||||
loadError = null;
|
|
||||||
try {
|
|
||||||
admins = await api.admins.list();
|
|
||||||
} catch (e) {
|
|
||||||
loadError = e instanceof ApiError ? e.message : 'failed to load admins';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function flash(kind: 'error' | 'info', message: string) {
|
|
||||||
banner = { kind, message };
|
|
||||||
setTimeout(() => {
|
|
||||||
if (banner?.message === message) banner = null;
|
|
||||||
}, 6000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCreate() {
|
|
||||||
createForm = { username: '', password: '', confirm: '' };
|
|
||||||
createError = null;
|
|
||||||
createOpen = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitCreate(event: SubmitEvent) {
|
|
||||||
event.preventDefault();
|
|
||||||
createError = null;
|
|
||||||
if (createForm.password !== createForm.confirm) {
|
|
||||||
createError = 'Passwords do not match';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
createPending = true;
|
|
||||||
try {
|
|
||||||
await api.admins.create({
|
|
||||||
username: createForm.username.trim(),
|
|
||||||
password: createForm.password
|
|
||||||
});
|
|
||||||
createOpen = false;
|
|
||||||
await refresh();
|
|
||||||
flash('info', `Created admin "${createForm.username.trim()}".`);
|
|
||||||
} catch (e) {
|
|
||||||
createError = e instanceof ApiError ? e.message : 'failed to create admin';
|
|
||||||
} finally {
|
|
||||||
createPending = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openPassword(row: AdminUserRecord) {
|
|
||||||
passwordTarget = row;
|
|
||||||
passwordForm = { password: '', confirm: '' };
|
|
||||||
passwordError = null;
|
|
||||||
actionsOpenFor = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitPassword(event: SubmitEvent) {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!passwordTarget) return;
|
|
||||||
passwordError = null;
|
|
||||||
if (passwordForm.password !== passwordForm.confirm) {
|
|
||||||
passwordError = 'Passwords do not match';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
passwordPending = true;
|
|
||||||
try {
|
|
||||||
await api.admins.update(passwordTarget.id, { password: passwordForm.password });
|
|
||||||
const name = passwordTarget.username;
|
|
||||||
passwordTarget = null;
|
|
||||||
flash('info', `Password updated for "${name}".`);
|
|
||||||
} catch (e) {
|
|
||||||
passwordError = e instanceof ApiError ? e.message : 'failed to update password';
|
|
||||||
} finally {
|
|
||||||
passwordPending = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleActive(row: AdminUserRecord) {
|
|
||||||
actionsOpenFor = null;
|
|
||||||
try {
|
|
||||||
const updated = await api.admins.update(row.id, { is_active: !row.is_active });
|
|
||||||
admins = admins.map((a) => (a.id === updated.id ? updated : a));
|
|
||||||
flash('info', `${updated.username} ${updated.is_active ? 'reactivated' : 'deactivated'}.`);
|
|
||||||
} catch (e) {
|
|
||||||
flash('error', e instanceof ApiError ? e.message : 'failed to update admin');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openDelete(row: AdminUserRecord) {
|
|
||||||
deleteTarget = row;
|
|
||||||
actionsOpenFor = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmDelete() {
|
|
||||||
if (!deleteTarget) return;
|
|
||||||
deletePending = true;
|
|
||||||
const target = deleteTarget;
|
|
||||||
try {
|
|
||||||
await api.admins.remove(target.id);
|
|
||||||
deleteTarget = null;
|
|
||||||
if (me && me.id === target.id) {
|
|
||||||
// Just deleted ourselves — sign out and bounce.
|
|
||||||
await api.auth.logout();
|
|
||||||
await goto(`${base}/login`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await refresh();
|
|
||||||
flash('info', `Deleted "${target.username}".`);
|
|
||||||
} catch (e) {
|
|
||||||
flash('error', e instanceof ApiError ? e.message : 'failed to delete admin');
|
|
||||||
} finally {
|
|
||||||
deletePending = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleActions(id: string) {
|
|
||||||
actionsOpenFor = actionsOpenFor === id ? null : id;
|
|
||||||
}
|
|
||||||
|
|
||||||
function relative(iso: string | null): string {
|
|
||||||
if (!iso) return 'Never';
|
|
||||||
const then = new Date(iso).getTime();
|
|
||||||
const now = Date.now();
|
|
||||||
const sec = Math.round((now - then) / 1000);
|
|
||||||
if (sec < 60) return `${sec} second${sec === 1 ? '' : 's'} ago`;
|
|
||||||
const min = Math.round(sec / 60);
|
|
||||||
if (min < 60) return `${min} minute${min === 1 ? '' : 's'} ago`;
|
|
||||||
const hr = Math.round(min / 60);
|
|
||||||
if (hr < 24) return `${hr} hour${hr === 1 ? '' : 's'} ago`;
|
|
||||||
const day = Math.round(hr / 24);
|
|
||||||
if (day === 1) return 'Yesterday';
|
|
||||||
if (day < 7) return `${day} days ago`;
|
|
||||||
return new Date(iso).toLocaleDateString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function absolute(iso: string | null): string {
|
|
||||||
return iso ? new Date(iso).toISOString() : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function shortDate(iso: string): string {
|
|
||||||
return new Date(iso).toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<header class="head">
|
|
||||||
<h1>Admin Users</h1>
|
|
||||||
<button type="button" class="primary" onclick={openCreate}>+ New admin user</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{#if banner}
|
|
||||||
<div class="banner banner-{banner.kind}">{banner.message}</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if loadError}
|
|
||||||
<div class="error">
|
|
||||||
{loadError}
|
|
||||||
<button type="button" class="retry" onclick={refresh}>Retry</button>
|
|
||||||
</div>
|
|
||||||
{:else if admins.length === 0}
|
|
||||||
<p class="empty">No admin users yet. Add one to get started.</p>
|
|
||||||
{:else}
|
|
||||||
<div class="table">
|
|
||||||
<div class="row head-row">
|
|
||||||
<div>Username</div>
|
|
||||||
<div>Status</div>
|
|
||||||
<div>Created</div>
|
|
||||||
<div>Last login</div>
|
|
||||||
<div class="actions-col"></div>
|
|
||||||
</div>
|
|
||||||
{#each admins as row (row.id)}
|
|
||||||
<div class="row">
|
|
||||||
<div class="username-cell">
|
|
||||||
<span class="name">{row.username}</span>
|
|
||||||
{#if me && me.id === row.id}
|
|
||||||
<span class="you-tag">(you)</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{#if row.is_active}
|
|
||||||
<span class="status status-active">● Active</span>
|
|
||||||
{:else}
|
|
||||||
<span class="status status-inactive">○ Inactive</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div>{shortDate(row.created_at)}</div>
|
|
||||||
<div title={absolute(row.last_login_at)}>{relative(row.last_login_at)}</div>
|
|
||||||
<div class="actions-col">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="kebab"
|
|
||||||
aria-label="Actions for {row.username}"
|
|
||||||
onclick={() => toggleActions(row.id)}
|
|
||||||
>
|
|
||||||
⋮
|
|
||||||
</button>
|
|
||||||
{#if actionsOpenFor === row.id}
|
|
||||||
<div class="menu">
|
|
||||||
<button type="button" onclick={() => openPassword(row)}>Change password</button>
|
|
||||||
<button type="button" onclick={() => toggleActive(row)}>
|
|
||||||
{row.is_active ? 'Deactivate' : 'Reactivate'}
|
|
||||||
</button>
|
|
||||||
<button type="button" class="danger" onclick={() => openDelete(row)}>Delete</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- New admin modal -->
|
|
||||||
{#if createOpen}
|
|
||||||
<div
|
|
||||||
class="modal-backdrop"
|
|
||||||
role="presentation"
|
|
||||||
onclick={(e) => {
|
|
||||||
if (e.target === e.currentTarget) createOpen = false;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<form class="modal" onsubmit={submitCreate}>
|
|
||||||
<div class="modal-head">
|
|
||||||
<h2>New admin user</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="x"
|
|
||||||
aria-label="Close"
|
|
||||||
onclick={() => (createOpen = false)}>✕</button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<label>
|
|
||||||
<span>Username</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
autocomplete="off"
|
|
||||||
spellcheck="false"
|
|
||||||
bind:value={createForm.username}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<small>Lowercase letters, digits, . _ -</small>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Password</span>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
autocomplete="new-password"
|
|
||||||
bind:value={createForm.password}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<small>Minimum 8 characters</small>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Confirm password</span>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
autocomplete="new-password"
|
|
||||||
bind:value={createForm.confirm}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{#if createError}
|
|
||||||
<div class="error">{createError}</div>
|
|
||||||
{/if}
|
|
||||||
<div class="modal-actions">
|
|
||||||
<button type="button" class="ghost" onclick={() => (createOpen = false)}>Cancel</button>
|
|
||||||
<button type="submit" class="primary" disabled={createPending}>
|
|
||||||
{createPending ? 'Creating…' : 'Create user'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Change password modal -->
|
|
||||||
{#if passwordTarget}
|
|
||||||
<div
|
|
||||||
class="modal-backdrop"
|
|
||||||
role="presentation"
|
|
||||||
onclick={(e) => {
|
|
||||||
if (e.target === e.currentTarget) passwordTarget = null;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<form class="modal" onsubmit={submitPassword}>
|
|
||||||
<div class="modal-head">
|
|
||||||
<h2>Change password — {passwordTarget.username}</h2>
|
|
||||||
<button type="button" class="x" aria-label="Close" onclick={() => (passwordTarget = null)}
|
|
||||||
>✕</button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<label>
|
|
||||||
<span>New password</span>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
autocomplete="new-password"
|
|
||||||
bind:value={passwordForm.password}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Confirm password</span>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
autocomplete="new-password"
|
|
||||||
bind:value={passwordForm.confirm}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{#if passwordError}
|
|
||||||
<div class="error">{passwordError}</div>
|
|
||||||
{/if}
|
|
||||||
<div class="modal-actions">
|
|
||||||
<button type="button" class="ghost" onclick={() => (passwordTarget = null)}>Cancel</button>
|
|
||||||
<button type="submit" class="primary" disabled={passwordPending}>
|
|
||||||
{passwordPending ? 'Updating…' : 'Update'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Delete confirmation modal -->
|
|
||||||
{#if deleteTarget}
|
|
||||||
<div
|
|
||||||
class="modal-backdrop"
|
|
||||||
role="presentation"
|
|
||||||
onclick={(e) => {
|
|
||||||
if (e.target === e.currentTarget) deleteTarget = null;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div class="modal">
|
|
||||||
<div class="modal-head">
|
|
||||||
<h2>Delete {deleteTarget.username}?</h2>
|
|
||||||
<button type="button" class="x" aria-label="Close" onclick={() => (deleteTarget = null)}
|
|
||||||
>✕</button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{#if me && me.id === deleteTarget.id}
|
|
||||||
<p>
|
|
||||||
You are about to delete <strong>your own</strong> account. You will be signed out immediately
|
|
||||||
and will not be able to sign back in with these credentials.
|
|
||||||
</p>
|
|
||||||
{:else}
|
|
||||||
<p>
|
|
||||||
This permanently removes <strong>{deleteTarget.username}</strong> and all their sessions.
|
|
||||||
This cannot be undone.
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
<div class="modal-actions">
|
|
||||||
<button type="button" class="ghost" onclick={() => (deleteTarget = null)}>Cancel</button>
|
|
||||||
<button type="button" class="danger" disabled={deletePending} onclick={confirmDelete}>
|
|
||||||
{deletePending ? 'Deleting…' : 'Delete'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
margin: 0;
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner {
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
.banner-error {
|
|
||||||
background: #450a0a;
|
|
||||||
border: 1px solid #b91c1c;
|
|
||||||
color: #fecaca;
|
|
||||||
}
|
|
||||||
.banner-info {
|
|
||||||
background: #0c2a36;
|
|
||||||
border: 1px solid #155e75;
|
|
||||||
color: #a5f3fc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
color: #64748b;
|
|
||||||
text-align: center;
|
|
||||||
padding: 3rem 0;
|
|
||||||
border: 1px dashed #1e293b;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
border: 1px solid #1e293b;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
overflow: visible;
|
|
||||||
background: #0b1220;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1.5fr 0.9fr 1fr 1.2fr 3rem;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border-bottom: 1px solid #1e293b;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.head-row {
|
|
||||||
color: #94a3b8;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
background: #0f172a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.username-cell {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.name {
|
|
||||||
color: #e2e8f0;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.you-tag {
|
|
||||||
color: #64748b;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
.status-active {
|
|
||||||
color: #34d399;
|
|
||||||
}
|
|
||||||
.status-inactive {
|
|
||||||
color: #64748b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions-col {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kebab {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: #94a3b8;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
}
|
|
||||||
.kebab:hover {
|
|
||||||
background: #1e293b;
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu {
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
right: 0;
|
|
||||||
background: #0b1220;
|
|
||||||
border: 1px solid #1e293b;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-width: 12rem;
|
|
||||||
z-index: 10;
|
|
||||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu button {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: #cbd5e1;
|
|
||||||
text-align: left;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
.menu button:hover {
|
|
||||||
background: #1e293b;
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
.menu button.danger {
|
|
||||||
color: #fca5a5;
|
|
||||||
}
|
|
||||||
.menu button.danger:hover {
|
|
||||||
background: #450a0a;
|
|
||||||
color: #fecaca;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
background: #450a0a;
|
|
||||||
border: 1px solid #b91c1c;
|
|
||||||
color: #fecaca;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.retry {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid #b91c1c;
|
|
||||||
color: #fecaca;
|
|
||||||
padding: 0.25rem 0.6rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.primary {
|
|
||||||
background: #38bdf8;
|
|
||||||
color: #0b1220;
|
|
||||||
border: none;
|
|
||||||
padding: 0.55rem 0.9rem;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
button.primary:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.ghost {
|
|
||||||
background: transparent;
|
|
||||||
color: #94a3b8;
|
|
||||||
border: 1px solid #334155;
|
|
||||||
padding: 0.5rem 0.9rem;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
button.ghost:hover {
|
|
||||||
background: #1e293b;
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.danger {
|
|
||||||
background: #b91c1c;
|
|
||||||
color: #fef2f2;
|
|
||||||
border: none;
|
|
||||||
padding: 0.55rem 0.9rem;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
button.danger:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(15, 23, 42, 0.7);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
z-index: 50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal {
|
|
||||||
background: #0b1220;
|
|
||||||
border: 1px solid #1e293b;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
padding: 1.5rem;
|
|
||||||
min-width: 24rem;
|
|
||||||
max-width: 28rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1rem;
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.x {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: #64748b;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.x:hover {
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal label {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.4rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: #cbd5e1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal label small {
|
|
||||||
color: #64748b;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal input {
|
|
||||||
background: #0f172a;
|
|
||||||
color: #e2e8f0;
|
|
||||||
border: 1px solid #1e293b;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
padding: 0.55rem 0.75rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.modal input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #38bdf8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
color: #cbd5e1;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
Reference in New Issue
Block a user