So many things
This commit is contained in:
@@ -0,0 +1,953 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from "$app/forms";
|
||||
import type {
|
||||
PermissionsCategorized,
|
||||
PermissionCategory,
|
||||
PermissionNode,
|
||||
} from "$lib/optima-api/modules/permissions";
|
||||
import type { Role } from "$lib/optima-api/modules/roles";
|
||||
|
||||
export let isOpen = false;
|
||||
export let permissionNodes: PermissionsCategorized = {};
|
||||
export let roleToEdit: Role | null = null;
|
||||
export let onClose: () => void = () => {};
|
||||
export let onSuccess: () => void = () => {};
|
||||
|
||||
$: isEditMode = roleToEdit !== null;
|
||||
|
||||
$: if (isOpen && roleToEdit) {
|
||||
title = roleToEdit.title;
|
||||
moniker = roleToEdit.moniker;
|
||||
monikerEdited = true;
|
||||
selectedPermissions = new Set(roleToEdit.permissions);
|
||||
|
||||
// Auto-expand categories that contain selected permissions
|
||||
const sel = new Set(roleToEdit.permissions);
|
||||
const expanded = new Set<string>();
|
||||
for (const [key, cat] of Object.entries(permissionNodes) as [
|
||||
string,
|
||||
PermissionCategory,
|
||||
][]) {
|
||||
if (cat.permissions.some((p) => sel.has(p.node))) {
|
||||
expanded.add(key);
|
||||
}
|
||||
}
|
||||
expandedCategories = expanded;
|
||||
}
|
||||
|
||||
let title = "";
|
||||
let moniker = "";
|
||||
let monikerEdited = false;
|
||||
let selectedPermissions = new Set<string>();
|
||||
let permissionSearch = "";
|
||||
let expandedCategories = new Set<string>();
|
||||
let isSubmitting = false;
|
||||
let submitError = "";
|
||||
|
||||
$: if (!monikerEdited) {
|
||||
moniker = title
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^a-z0-9-]/g, "");
|
||||
}
|
||||
|
||||
$: filteredEntries = (() => {
|
||||
const entries = Object.entries(permissionNodes) as [
|
||||
string,
|
||||
PermissionCategory,
|
||||
][];
|
||||
if (!permissionSearch.trim()) return entries;
|
||||
const q = permissionSearch.toLowerCase();
|
||||
return entries
|
||||
.map(([key, cat]): [string, PermissionCategory] => [
|
||||
key,
|
||||
{
|
||||
...cat,
|
||||
permissions: cat.permissions.filter(
|
||||
(p: PermissionNode) =>
|
||||
p.node.toLowerCase().includes(q) ||
|
||||
p.description.toLowerCase().includes(q),
|
||||
),
|
||||
},
|
||||
])
|
||||
.filter(([, cat]) => cat.permissions.length > 0);
|
||||
})();
|
||||
|
||||
$: isValid = title.trim().length > 0 && moniker.trim().length > 0;
|
||||
$: selectedCount = selectedPermissions.size;
|
||||
|
||||
function togglePermission(node: string) {
|
||||
const next = new Set(selectedPermissions);
|
||||
next.has(node) ? next.delete(node) : next.add(node);
|
||||
selectedPermissions = next;
|
||||
}
|
||||
|
||||
function toggleCategory(key: string) {
|
||||
const next = new Set(expandedCategories);
|
||||
next.has(key) ? next.delete(key) : next.add(key);
|
||||
expandedCategories = next;
|
||||
}
|
||||
|
||||
function toggleAllInCategory(perms: PermissionNode[]) {
|
||||
const next = new Set(selectedPermissions);
|
||||
const allSelected = perms.every((p) => next.has(p.node));
|
||||
perms.forEach((p) =>
|
||||
allSelected ? next.delete(p.node) : next.add(p.node),
|
||||
);
|
||||
selectedPermissions = next;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
title = "";
|
||||
moniker = "";
|
||||
monikerEdited = false;
|
||||
selectedPermissions = new Set();
|
||||
permissionSearch = "";
|
||||
expandedCategories = new Set();
|
||||
isSubmitting = false;
|
||||
submitError = "";
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
reset();
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if ((e.target as HTMLElement).classList.contains("modal-backdrop"))
|
||||
handleClose();
|
||||
}
|
||||
|
||||
function handleBackdropKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") handleClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="modal-backdrop"
|
||||
on:click={handleBackdropClick}
|
||||
on:keydown={handleBackdropKeydown}
|
||||
>
|
||||
<div
|
||||
class="modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={isEditMode ? "Edit Role" : "Create Role"}
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="modal-header">
|
||||
<div class="modal-title-group">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="18"
|
||||
height="18"
|
||||
>
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
</svg>
|
||||
<h2>{isEditMode ? "Edit Role" : "Create Role"}</h2>
|
||||
</div>
|
||||
<button
|
||||
class="close-btn"
|
||||
on:click={handleClose}
|
||||
type="button"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="18"
|
||||
height="18"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action={isEditMode ? "?/updateRole" : "?/createRole"}
|
||||
use:enhance={() => {
|
||||
isSubmitting = true;
|
||||
submitError = "";
|
||||
return async ({ result, update }) => {
|
||||
isSubmitting = false;
|
||||
if (result.type === "success") {
|
||||
reset();
|
||||
onSuccess();
|
||||
} else if (result.type === "failure") {
|
||||
submitError = String(
|
||||
(result.data as Record<string, unknown>)?.message ??
|
||||
(isEditMode
|
||||
? "Failed to update role."
|
||||
: "Failed to create role."),
|
||||
);
|
||||
} else if (result.type === "error") {
|
||||
submitError =
|
||||
result.error?.message ?? "An unexpected error occurred.";
|
||||
}
|
||||
await update({ reset: false });
|
||||
};
|
||||
}}
|
||||
>
|
||||
{#each [...selectedPermissions] as node (node)}
|
||||
<input type="hidden" name="permissions" value={node} />
|
||||
{/each}
|
||||
{#if isEditMode && roleToEdit}
|
||||
<input type="hidden" name="id" value={roleToEdit.id} />
|
||||
{/if}
|
||||
|
||||
<div class="modal-body">
|
||||
{#if submitError}
|
||||
<div class="error-banner">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="14"
|
||||
height="14"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
{submitError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="role-title">Title <span class="req">*</span></label>
|
||||
<input
|
||||
id="role-title"
|
||||
name="title"
|
||||
type="text"
|
||||
placeholder="e.g. Administrator"
|
||||
bind:value={title}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="role-moniker">
|
||||
Moniker <span class="req">*</span>
|
||||
{#if !isEditMode}<span class="label-hint">Auto-generated</span
|
||||
>{/if}
|
||||
</label>
|
||||
<input
|
||||
id="role-moniker"
|
||||
name="moniker"
|
||||
type="text"
|
||||
placeholder="e.g. administrator"
|
||||
bind:value={moniker}
|
||||
on:input={() => (monikerEdited = true)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="perm-section">
|
||||
<div class="perm-section-header">
|
||||
<span class="perm-label">Permissions</span>
|
||||
{#if selectedCount > 0}
|
||||
<span class="selected-badge">{selectedCount} selected</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="search-wrap">
|
||||
<svg
|
||||
class="search-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="13"
|
||||
height="13"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Search permissions…"
|
||||
bind:value={permissionSearch}
|
||||
/>
|
||||
{#if permissionSearch}
|
||||
<button
|
||||
type="button"
|
||||
class="search-clear"
|
||||
on:click={() => (permissionSearch = "")}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
width="11"
|
||||
height="11"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="perm-list">
|
||||
{#if Object.keys(permissionNodes).length === 0}
|
||||
<p class="perm-empty">No permission data available.</p>
|
||||
{:else if filteredEntries.length === 0}
|
||||
<p class="perm-empty">No permissions match your search.</p>
|
||||
{:else}
|
||||
{#each filteredEntries as [catKey, category] (catKey)}
|
||||
{@const catPerms = category.permissions}
|
||||
{@const allSel =
|
||||
catPerms.length > 0 &&
|
||||
catPerms.every((p) => selectedPermissions.has(p.node))}
|
||||
{@const someSel =
|
||||
!allSel &&
|
||||
catPerms.some((p) => selectedPermissions.has(p.node))}
|
||||
{@const isExpanded =
|
||||
permissionSearch.trim().length > 0 ||
|
||||
expandedCategories.has(catKey)}
|
||||
<div class="cat-group">
|
||||
<div class="cat-header">
|
||||
<button
|
||||
type="button"
|
||||
class="cat-toggle"
|
||||
on:click={() => toggleCategory(catKey)}
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<svg
|
||||
class="chevron"
|
||||
class:rotated={isExpanded}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
width="11"
|
||||
height="11"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
<span class="cat-name">{category.name}</span>
|
||||
<span class="cat-count">{catPerms.length}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cat-all-btn"
|
||||
on:click={() => toggleAllInCategory(catPerms)}
|
||||
title={allSel
|
||||
? "Deselect all in category"
|
||||
: "Select all in category"}
|
||||
>
|
||||
<div
|
||||
class="cb"
|
||||
class:cb-checked={allSel}
|
||||
class:cb-indeterminate={someSel}
|
||||
>
|
||||
{#if allSel}
|
||||
<svg
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="8"
|
||||
height="8"
|
||||
>
|
||||
<polyline points="1.5 5 4 8 8.5 2" />
|
||||
</svg>
|
||||
{:else if someSel}
|
||||
<svg
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
width="8"
|
||||
height="8"
|
||||
>
|
||||
<line x1="1.5" y1="5" x2="8.5" y2="5" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if isExpanded}
|
||||
<div class="cat-perms">
|
||||
{#each catPerms as perm (perm.node)}
|
||||
{@const sel = selectedPermissions.has(perm.node)}
|
||||
<label class="perm-row" class:perm-sel={sel}>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="sr-only"
|
||||
checked={sel}
|
||||
on:change={() => togglePermission(perm.node)}
|
||||
/>
|
||||
<div class="cb" class:cb-checked={sel}>
|
||||
{#if sel}
|
||||
<svg
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="8"
|
||||
height="8"
|
||||
>
|
||||
<polyline points="1.5 5 4 8 8.5 2" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="perm-text">
|
||||
<code class="perm-node">{perm.node}</code>
|
||||
<span class="perm-desc">{perm.description}</span>
|
||||
</div>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-cancel"
|
||||
on:click={handleClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-create"
|
||||
disabled={!isValid || isSubmitting}
|
||||
>
|
||||
{isSubmitting
|
||||
? isEditMode
|
||||
? "Saving…"
|
||||
: "Creating…"
|
||||
: isEditMode
|
||||
? "Save Changes"
|
||||
: "Create Role"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.25);
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: modalIn 0.15s ease;
|
||||
}
|
||||
|
||||
@keyframes modalIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 20px 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-title-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-title-group svg {
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-title-group h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: var(--card-hover-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── Form wraps body + footer so the submit btn works ── */
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ── Body ── */
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ── Error banner ── */
|
||||
.error-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(220, 38, 38, 0.08);
|
||||
border: 1px solid rgba(220, 38, 38, 0.2);
|
||||
border-radius: 7px;
|
||||
font-size: 13px;
|
||||
color: #dc2626;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Form fields ── */
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.req {
|
||||
color: var(--accent-color, #0066cc);
|
||||
}
|
||||
|
||||
.label-hint {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-inset);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 7px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.form-group input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
border-color: var(--accent-color, #0066cc);
|
||||
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.12);
|
||||
}
|
||||
|
||||
/* ── Permissions section ── */
|
||||
.perm-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.perm-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.perm-label {
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.selected-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--accent-color, #0066cc);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── Search ── */
|
||||
.search-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 9px;
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 7px 10px 7px 29px;
|
||||
background: var(--bg-inset);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 7px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: var(--accent-color, #0066cc);
|
||||
}
|
||||
|
||||
.search-clear {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 3px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.1s;
|
||||
}
|
||||
|
||||
.search-clear:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── Permission list ── */
|
||||
.perm-list {
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 8px;
|
||||
overflow-y: auto;
|
||||
max-height: 240px;
|
||||
background: var(--bg-inset);
|
||||
}
|
||||
|
||||
.perm-empty {
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Category group ── */
|
||||
.cat-group {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.cat-group:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.cat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.cat-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
padding: 7px 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.cat-toggle:hover {
|
||||
background: var(--card-hover-bg);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.chevron.rotated {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.cat-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.cat-count {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.cat-all-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 8px;
|
||||
background: none;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
background 0.1s,
|
||||
border-color 0.1s;
|
||||
}
|
||||
|
||||
.cat-all-btn:hover {
|
||||
background: var(--card-hover-bg);
|
||||
border-color: var(--border-default);
|
||||
}
|
||||
|
||||
/* ── Custom checkbox ── */
|
||||
.cb {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border: 1.5px solid var(--border-default);
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-surface);
|
||||
color: #fff;
|
||||
transition:
|
||||
background 0.1s,
|
||||
border-color 0.1s;
|
||||
}
|
||||
|
||||
.cb.cb-checked {
|
||||
background: var(--accent-color, #0066cc);
|
||||
border-color: var(--accent-color, #0066cc);
|
||||
}
|
||||
|
||||
.cb.cb-indeterminate {
|
||||
background: var(--bg-surface);
|
||||
border-color: var(--accent-color, #0066cc);
|
||||
color: var(--accent-color, #0066cc);
|
||||
}
|
||||
|
||||
/* ── Permission rows ── */
|
||||
.cat-perms {
|
||||
padding: 2px 0 4px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.perm-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 9px;
|
||||
padding: 6px 12px 6px 26px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.perm-row:hover {
|
||||
background: var(--card-hover-bg);
|
||||
}
|
||||
|
||||
.perm-row.perm-sel {
|
||||
background: rgba(0, 102, 204, 0.05);
|
||||
}
|
||||
|
||||
.perm-row .cb {
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.perm-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.perm-node {
|
||||
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.perm-desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
/* ── Footer ── */
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-cancel,
|
||||
.btn-create {
|
||||
padding: 7px 16px;
|
||||
border-radius: 7px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: var(--bg-inset);
|
||||
border: 1px solid var(--border-subtle);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-cancel:hover:not(:disabled) {
|
||||
background: var(--card-hover-bg);
|
||||
border-color: var(--border-default);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-create {
|
||||
background: var(--accent-color, #0066cc);
|
||||
border: 1px solid transparent;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-create:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.btn-cancel:disabled,
|
||||
.btn-create:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user