So many things

This commit is contained in:
2026-02-17 21:52:59 -06:00
parent 8e225aa254
commit a99c9f5102
27 changed files with 5398 additions and 123 deletions
+953
View File
@@ -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>
+2
View File
@@ -8,6 +8,8 @@ export const optima = {
credential: (await import("./optima-api/modules/credentials")).credential, credential: (await import("./optima-api/modules/credentials")).credential,
credentialType: (await import("./optima-api/modules/credentialTypes")) credentialType: (await import("./optima-api/modules/credentialTypes"))
.credentialType, .credentialType,
role: (await import("./optima-api/modules/roles")).role,
permission: (await import("./optima-api/modules/permissions")).permission,
user, user,
}; };
/** /**
+17 -1
View File
@@ -1,8 +1,16 @@
import api from "../axios"; import api from "../axios";
export const company = { export const company = {
async fetch(accessToken: string, id: string) { async fetch(
accessToken: string,
id: string,
options?: { includeAddress?: boolean },
) {
const params: Record<string, string> = {};
if (options?.includeAddress) params.includeAddress = "true";
const company = await api.get(`/v1/company/companies/${id}`, { const company = await api.get(`/v1/company/companies/${id}`, {
params,
headers: { headers: {
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
}, },
@@ -27,6 +35,14 @@ export const company = {
return companies.data; return companies.data;
}, },
async count(accessToken: string) {
const response = await api.get("/v1/company/count", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data.data.count;
},
async fetchConfigurations(accessToken: string, id: string) { async fetchConfigurations(accessToken: string, id: string) {
const configurations = await api.get( const configurations = await api.get(
`/v1/company/companies/${id}/configurations`, `/v1/company/companies/${id}/configurations`,
+47
View File
@@ -0,0 +1,47 @@
import api from "../axios";
export interface PermissionNode {
node: string;
description: string;
usedIn: string[];
dependencies?: string[];
}
export interface PermissionCategory {
name: string;
description: string;
permissions: PermissionNode[];
}
export interface PermissionsCategorized {
[category: string]: PermissionCategory;
}
export const permission = {
async fetchCategorized(accessToken: string) {
const response = await api.get("/v1/permissions", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
async fetchFlat(accessToken: string) {
const response = await api.get("/v1/permissions/nodes", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
async fetchByCategory(accessToken: string, category: string) {
const response = await api.get(`/v1/permissions/${category}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
};
+104
View File
@@ -0,0 +1,104 @@
import api from "../axios";
export interface Role {
id: string;
title: string;
moniker: string;
permissions: string[];
createdAt: string;
updatedAt: string;
}
export const role = {
async fetchMany(accessToken: string) {
const response = await api.get("/v1/role", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
async fetch(accessToken: string, identifier: string) {
const response = await api.get(`/v1/role/${identifier}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
async create(
accessToken: string,
data: Omit<Role, "id" | "createdAt" | "updatedAt">,
) {
const response = await api.post("/v1/role", data, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
async update(
accessToken: string,
identifier: string,
updates: Partial<Omit<Role, "id" | "createdAt" | "updatedAt">>,
) {
const response = await api.patch(`/v1/role/${identifier}`, updates, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
async delete(accessToken: string, identifier: string) {
const response = await api.delete(`/v1/role/${identifier}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
async addPermissions(
accessToken: string,
identifier: string,
permissions: string[],
) {
const response = await api.post(
`/v1/role/${identifier}/permissions`,
{ permissions },
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
return response.data;
},
async removePermissions(
accessToken: string,
identifier: string,
permissions: string[],
) {
const response = await api.delete(`/v1/role/${identifier}/permissions`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
data: { permissions },
});
return response.data;
},
async fetchUsers(accessToken: string, identifier: string) {
const response = await api.get(`/v1/role/${identifier}/users`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
},
};
+51
View File
@@ -0,0 +1,51 @@
import { optima } from "$lib";
export type PermissionMap = Record<string, boolean>;
/**
* Check multiple permissions for the current user and return a map of
* permission → boolean. Designed to be called from any +page.server.ts
* or +layout.server.ts load function.
*
* @example
* ```ts
* const perms = await checkPermissions(accessToken, [
* "company.fetch.address",
* "credential.create",
* ]);
* // perms => { "company.fetch.address": true, "credential.create": false }
* ```
*/
export async function checkPermissions(
accessToken: string,
permissions: string[],
): Promise<PermissionMap> {
if (!permissions.length) return {};
try {
const result = await optima.user.checkPermissions(accessToken, permissions);
const results: Array<{ permission: string; hasPermission: boolean }> =
result?.data?.results ?? [];
return results.reduce<PermissionMap>((map, entry) => {
map[entry.permission] = entry.hasPermission === true;
return map;
}, {});
} catch (err) {
console.error("Permission check failed:", err);
// Default every requested permission to false on failure
return permissions.reduce<PermissionMap>((map, p) => {
map[p] = false;
return map;
}, {});
}
}
/**
* Convenience helper — returns true when a specific permission is
* granted inside a PermissionMap.
*/
export function hasPermission(map: PermissionMap, permission: string): boolean {
return map[permission] === true;
}
+39
View File
@@ -0,0 +1,39 @@
import { writable } from "svelte/store";
import { browser } from "$app/environment";
type Theme = "light" | "dark";
function createThemeStore() {
const initial: Theme = browser
? ((localStorage.getItem("theme") as Theme) ?? "dark")
: "dark";
const { subscribe, set, update } = writable<Theme>(initial);
function applyTheme(theme: Theme) {
if (browser) {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("theme", theme);
}
}
// Apply on init
if (browser) applyTheme(initial);
return {
subscribe,
toggle() {
update((current) => {
const next = current === "dark" ? "light" : "dark";
applyTheme(next);
return next;
});
},
set(theme: Theme) {
applyTheme(theme);
set(theme);
},
};
}
export const theme = createThemeStore();
+55 -4
View File
@@ -4,6 +4,7 @@
import { enhance } from "$app/forms"; import { enhance } from "$app/forms";
import LoadingSpinner from "../../../components/LoadingSpinner.svelte"; import LoadingSpinner from "../../../components/LoadingSpinner.svelte";
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import { theme } from "$lib/theme";
const uriData = await optima.auth.fetchAuthRedirectUri(PUBLIC_API_URL); const uriData = await optima.auth.fetchAuthRedirectUri(PUBLIC_API_URL);
let loading = writable(false); let loading = writable(false);
@@ -34,6 +35,42 @@
</script> </script>
<div class="container"> <div class="container">
<button
class="theme-toggle login-theme-toggle"
onclick={() => theme.toggle()}
aria-label="Toggle {$theme === 'dark' ? 'light' : 'dark'} mode"
title="Switch to {$theme === 'dark' ? 'light' : 'dark'} mode"
>
<svg
class="theme-icon sun-icon"
class:visible={$theme === "dark"}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="5" />
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</svg>
<svg
class="theme-icon moon-icon"
class:visible={$theme === "light"}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
</button>
<form action="?/login" method="POST" onsubmit={handleSubmit} use:enhance> <form action="?/login" method="POST" onsubmit={handleSubmit} use:enhance>
<input type="hidden" name="callbackKey" value={uriData.callbackKey} /> <input type="hidden" name="callbackKey" value={uriData.callbackKey} />
<button <button
@@ -62,25 +99,39 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: #f3f2f1; background: var(--bg-base);
transition: background 0.2s ease;
} }
.ms-button { .ms-button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 12px 18px; padding: 12px 18px;
background: #2f2f2f; background: var(--bg-elevated);
color: white; color: var(--text-primary);
border: none; border: 1px solid var(--border-default);
border-radius: 6px; border-radius: 6px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
transition:
background 0.2s ease,
color 0.2s ease,
border-color 0.2s ease;
}
.ms-button:hover:not([disabled]) {
background: var(--card-hover-bg);
border-color: var(--border-strong);
} }
.ms-button[disabled] { .ms-button[disabled] {
opacity: 0.6; opacity: 0.6;
cursor: default; cursor: default;
} }
.login-theme-toggle {
position: absolute;
top: 16px;
right: 16px;
}
.ms-logo { .ms-logo {
width: 20px; width: 20px;
height: 20px; height: 20px;
+3 -3
View File
@@ -31,11 +31,11 @@
cx="60" cx="60"
cy="60" cy="60"
r="56" r="56"
fill="#fef2f2" fill="var(--error-circle-outer)"
stroke="#fecaca" stroke="var(--error-circle-stroke)"
stroke-width="2" stroke-width="2"
/> />
<circle cx="60" cy="60" r="40" fill="#fee2e2" /> <circle cx="60" cy="60" r="40" fill="var(--error-circle-inner)" />
<path <path
d="M60 35v30" d="M60 35v30"
stroke="#dc2626" stroke="#dc2626"
+36
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { optima } from "$lib"; import { optima } from "$lib";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { theme } from "$lib/theme";
const navItems = [ const navItems = [
{ {
@@ -36,6 +37,41 @@
<header class="header"> <header class="header">
<div class="header-content"> <div class="header-content">
<h1>Project Optima</h1> <h1>Project Optima</h1>
<button
class="theme-toggle"
on:click={() => theme.toggle()}
aria-label="Toggle {$theme === 'dark' ? 'light' : 'dark'} mode"
title="Switch to {$theme === 'dark' ? 'light' : 'dark'} mode"
>
<svg
class="theme-icon sun-icon"
class:visible={$theme === "dark"}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="5" />
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</svg>
<svg
class="theme-icon moon-icon"
class:visible={$theme === "light"}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
</button>
</div> </div>
</header> </header>
+40
View File
@@ -0,0 +1,40 @@
import { optima } from "$lib";
import { handleApiError } from "$lib/optima-api/errorHandler";
import { checkPermissions, type PermissionMap } from "$lib/permissions";
import { redirect } from "@sveltejs/kit";
import type { LayoutServerLoad } from "./$types";
export const load: LayoutServerLoad = async ({ locals }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
throw redirect(303, "/login");
}
try {
// Check the top-level admin gate + all per-tab permissions in one call
const permissions = await checkPermissions(accessToken, [
"ui.navigation.admin.view",
"admin.users.view",
"admin.roles.view",
"admin.credential-types.view",
]);
if (!permissions["ui.navigation.admin.view"]) {
throw redirect(303, "/");
}
// Fetch current user info for the dashboard greeting
const userInfo = await optima.user.fetchInfo(accessToken);
return {
user: userInfo?.data ?? null,
permissions,
};
} catch (err) {
// Re-throw redirects so SvelteKit handles them
if (err && typeof err === "object" && "status" in err) {
throw err;
}
handleApiError(err);
}
};
+105
View File
@@ -0,0 +1,105 @@
<script lang="ts">
import { page } from "$app/stores";
import "../../styles/admin.css";
import type { PermissionMap } from "$lib/permissions";
export let data: {
user: {
id: string;
name?: string;
email?: string;
[key: string]: unknown;
} | null;
permissions: PermissionMap;
};
$: permissions = data.permissions;
$: userName = data.user?.name || "Admin";
// Tab definitions — each gated by a permission
const allTabs = [
{ label: "Overview", href: "/admin", exact: true, permission: null },
{
label: "Users",
href: "/admin/users",
exact: false,
permission: "admin.users.view",
},
{
label: "Roles",
href: "/admin/roles",
exact: false,
permission: "admin.roles.view",
},
{
label: "Credential Types",
href: "/admin/credential-types",
exact: false,
permission: "admin.credential-types.view",
},
] as const;
// Only show tabs the user has permission for
$: visibleTabs = allTabs.filter(
(t) => t.permission === null || permissions[t.permission] === true,
);
function isActive(
tab: { href: string; exact?: boolean },
pathname: string,
): boolean {
if (tab.exact) return pathname === tab.href;
return pathname.startsWith(tab.href);
}
</script>
<svelte:head>
<title>Admin — Project Optima</title>
</svelte:head>
<div class="admin-page">
<div class="admin-pane">
<!-- Pane header -->
<div class="admin-header">
<div class="admin-header-left">
<svg
class="admin-header-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"
/>
</svg>
<h2 class="admin-title">Administration</h2>
<span class="admin-subtitle">Welcome back, {userName}</span>
</div>
</div>
<!-- Tab bar -->
<div class="tab-bar" role="tablist">
{#each visibleTabs as tab}
<a
href={tab.href}
class="tab-btn"
class:active={isActive(tab, $page.url.pathname)}
role="tab"
aria-selected={isActive(tab, $page.url.pathname)}
>
{tab.label}
</a>
{/each}
</div>
<!-- Tab content -->
<div class="admin-body">
<slot />
</div>
</div>
</div>
+20
View File
@@ -0,0 +1,20 @@
import { optima } from "$lib";
import { handleApiError } from "$lib/optima-api/errorHandler";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ locals }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return { companyCount: null };
}
try {
const companyCount = await optima.company.count(accessToken);
return {
companyCount: companyCount ?? null,
};
} catch (err) {
handleApiError(err);
}
};
+136
View File
@@ -0,0 +1,136 @@
<script lang="ts">
import { goto } from "$app/navigation";
export let data: {
companyCount: number | null;
};
$: companyCount = data.companyCount;
const quickActions = [
{
href: "/companies",
name: "Companies",
desc: "View and manage companies",
icon: '<path d="M3 21h18"></path><path d="M5 21V7l8-4v18"></path><path d="M19 21V11l-6-4"></path><path d="M9 9h1"></path><path d="M9 13h1"></path><path d="M9 17h1"></path>',
},
{
href: "/admin/users",
name: "Manage Users",
desc: "View, edit, and assign user roles",
icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path>',
},
{
href: "/admin/roles",
name: "Manage Roles",
desc: "Configure roles and permissions",
icon: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>',
},
{
href: "/admin/credential-types",
name: "Credential Types",
desc: "Configure credential type definitions",
icon: '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path>',
},
];
</script>
<!-- Stats overview -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke-width="2">
<path d="M3 21h18" />
<path d="M5 21V7l8-4v18" />
<path d="M19 21V11l-6-4" />
</svg>
</div>
<div class="stat-info">
<span class="stat-value">{companyCount ?? "—"}</span>
<span class="stat-label">Companies</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</div>
<div class="stat-info">
<span class="stat-value"></span>
<span class="stat-label">Users</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<div class="stat-info">
<span class="stat-value"></span>
<span class="stat-label">Credentials</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke-width="2">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
</div>
<div class="stat-info">
<span class="stat-value"></span>
<span class="stat-label">Activity Today</span>
</div>
</div>
</div>
<!-- Quick actions -->
<h3 class="section-heading">Quick Actions</h3>
<div class="actions-grid">
{#each quickActions as action}
<a
href={action.href}
class="action-card"
on:click|preventDefault={() => goto(action.href)}
>
<div class="action-icon">
<svg viewBox="0 0 24 24" fill="none" stroke-width="2">
{@html action.icon}
</svg>
</div>
<div class="action-text">
<span class="action-name">{action.name}</span>
<span class="action-desc">{action.desc}</span>
</div>
<svg
class="action-arrow"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</a>
{/each}
</div>
<!-- Recent activity placeholder -->
<h3 class="section-heading">Recent Activity</h3>
<div class="activity-section">
<div class="activity-empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span>Activity feed coming soon</span>
</div>
</div>
@@ -0,0 +1,33 @@
<script lang="ts">
import type { PermissionMap } from "$lib/permissions";
export let data: { permissions: PermissionMap };
$: hasAccess = data.permissions["admin.credential-types.view"] === true;
</script>
{#if !hasAccess}
<div class="admin-denied">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
</svg>
<h3>Access Denied</h3>
<p>
You don't have permission to manage credential types. Contact your
administrator to request access.
</p>
</div>
{:else}
<div class="admin-tab-empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
<h3>Credential Type Management</h3>
<p>
Credential type definitions and configuration will be wired up here.
Connect an API module to populate this view.
</p>
</div>
{/if}
+148
View File
@@ -0,0 +1,148 @@
import { optima } from "$lib";
import { handleApiError } from "$lib/optima-api/errorHandler";
import { checkPermissions } from "$lib/permissions";
import { fail } from "@sveltejs/kit";
import type { Actions, PageServerLoad } from "./$types";
import { AxiosError } from "axios";
export const load: PageServerLoad = async ({ locals }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return { roles: [], permissions: {}, permissionNodes: {} };
}
try {
const [rolesResult, permissions, permNodesResult] = await Promise.all([
optima.role.fetchMany(accessToken),
checkPermissions(accessToken, [
"admin.roles.view",
"admin.roles.create",
"admin.roles.edit",
"admin.roles.delete",
]),
optima.permission
.fetchCategorized(accessToken)
.catch(() => ({ data: {} })),
]);
const roles = rolesResult?.data ?? [];
// Fetch users for each role in parallel
const rolesWithUsers = await Promise.all(
roles.map(async (role: Record<string, unknown>) => {
try {
const usersResult = await optima.role.fetchUsers(
accessToken,
role.id as string,
);
return { ...role, users: usersResult?.data ?? [] };
} catch {
return { ...role, users: [] };
}
}),
);
return {
roles: rolesWithUsers,
permissions,
permissionNodes: permNodesResult?.data ?? {},
};
} catch (err) {
handleApiError(err);
}
};
export const actions: Actions = {
createRole: async ({ locals, request }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return fail(401, { message: "Not authenticated." });
}
const formData = await request.formData();
const title = (formData.get("title") as string)?.trim();
const moniker = (formData.get("moniker") as string)?.trim();
const permissions = formData.getAll("permissions") as string[];
if (!title || !moniker) {
return fail(400, { message: "Title and moniker are required." });
}
try {
await optima.role.create(accessToken, { title, moniker, permissions });
return {};
} catch (err: unknown) {
const data = (err as AxiosError)?.response?.data as
| Record<string, unknown>
| undefined;
const message =
(data?.message as string) ??
(err instanceof Error ? err.message : "Failed to create role.");
const status = (data?.status as number) ?? 500;
return fail(status, { message });
}
},
updateRole: async ({ locals, request }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return fail(401, { message: "Not authenticated." });
}
const formData = await request.formData();
const id = (formData.get("id") as string)?.trim();
const title = (formData.get("title") as string)?.trim();
const moniker = (formData.get("moniker") as string)?.trim();
const permissions = formData.getAll("permissions") as string[];
if (!id || !title || !moniker) {
return fail(400, { message: "Required fields are missing." });
}
try {
await optima.role.update(accessToken, id, {
title,
moniker,
permissions,
});
return {};
} catch (err: unknown) {
const data = (err as AxiosError)?.response?.data as
| Record<string, unknown>
| undefined;
const message =
(data?.message as string) ??
(err instanceof Error ? err.message : "Failed to update role.");
const status = (data?.status as number) ?? 500;
return fail(status, { message });
}
},
deleteRole: async ({ locals, request }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return fail(401, { message: "Not authenticated." });
}
const formData = await request.formData();
const id = (formData.get("id") as string)?.trim();
if (!id) {
return fail(400, { message: "Role ID is required." });
}
try {
await optima.role.delete(accessToken, id);
return {};
} catch (err: unknown) {
const data = (err as AxiosError)?.response?.data as
| Record<string, unknown>
| undefined;
const message =
(data?.message as string) ??
(err instanceof Error ? err.message : "Failed to delete role.");
const status = (data?.status as number) ?? 500;
return fail(status, { message });
}
},
};
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
<script lang="ts">
import type { PermissionMap } from "$lib/permissions";
export let data: { permissions: PermissionMap };
$: hasAccess = data.permissions["admin.users.view"] === true;
</script>
{#if !hasAccess}
<div class="admin-denied">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
</svg>
<h3>Access Denied</h3>
<p>
You don't have permission to manage users. Contact your administrator to
request access.
</p>
</div>
{:else}
<div class="admin-tab-empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
<h3>User Management</h3>
<p>
User listing and editing will be wired up here. Connect an API module to
populate this view.
</p>
</div>
{/if}
+12 -5
View File
@@ -25,14 +25,19 @@
let debounceTimer: ReturnType<typeof setTimeout>; let debounceTimer: ReturnType<typeof setTimeout>;
let isSearching = false; let isSearching = false;
let searchInputEl: HTMLInputElement; let searchInputEl: HTMLInputElement;
let searchStartedAt = 0;
// When navigation completes (results loaded), clear loading & refocus // When navigation completes (results loaded), clear loading & refocus
// Ensure spinner stays visible for at least 500ms
afterNavigate(() => { afterNavigate(() => {
isSearching = false; const elapsed = Date.now() - searchStartedAt;
if (searchInputEl && document.activeElement !== searchInputEl) { const remaining = Math.max(0, 500 - elapsed);
// Use tick to ensure DOM is settled setTimeout(() => {
requestAnimationFrame(() => searchInputEl?.focus()); isSearching = false;
} if (searchInputEl && document.activeElement !== searchInputEl) {
requestAnimationFrame(() => searchInputEl?.focus());
}
}, remaining);
}); });
$: currentPage = data.currentPage; $: currentPage = data.currentPage;
@@ -49,6 +54,7 @@
function handleSearch() { function handleSearch() {
isSearching = true; isSearching = true;
searchStartedAt = Date.now();
clearTimeout(debounceTimer); clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => { debounceTimer = setTimeout(() => {
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -61,6 +67,7 @@
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
if (e.key === "Enter") { if (e.key === "Enter") {
isSearching = true; isSearching = true;
searchStartedAt = Date.now();
clearTimeout(debounceTimer); clearTimeout(debounceTimer);
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set("page", "1"); params.set("page", "1");
+37
View File
@@ -0,0 +1,37 @@
import { optima } from "$lib";
import { handleApiError } from "$lib/optima-api/errorHandler";
import { checkPermissions, type PermissionMap } from "$lib/permissions";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ locals, params }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
return {
company: null,
configurations: [],
permissions: {} as PermissionMap,
};
}
try {
// Run permission checks in parallel with other data fetches.
// Add any new permissions the company page needs to this array.
const [permissions, configsResult] = await Promise.all([
checkPermissions(accessToken, ["company.fetch.address"]),
optima.company.fetchConfigurations(accessToken, params.id),
]);
// Fetch company with or without address based on permission
const companyResult = await optima.company.fetch(accessToken, params.id, {
includeAddress: permissions["company.fetch.address"] === true,
});
return {
company: companyResult?.data ?? null,
configurations: configsResult?.data ?? [],
permissions,
};
} catch (err) {
handleApiError(err);
}
};
+712
View File
@@ -0,0 +1,712 @@
<script lang="ts">
import { goto } from "$app/navigation";
import "../../../styles/companies/companydetail.css";
import type { PermissionMap } from "$lib/permissions";
export let data: {
company: {
id: string;
name: string;
status?: string;
type?: string;
createdAt?: string;
updatedAt?: string;
identifier?: string;
contactEmail?: string;
contactPhone?: string;
address?: string;
city?: string;
state?: string;
zip?: string;
country?: string;
cw_Data?: {
address?: {
line1?: string;
line2?: string | null;
city?: string;
state?: string;
zip?: string;
country?: string;
};
[key: string]: unknown;
};
[key: string]: unknown;
} | null;
configurations: Array<{
id: string | number;
name: string;
active?: boolean;
serialNumber?: string;
status?: {
id: number;
name: string;
};
type?: {
id: number;
name: string;
_info?: { type_href?: string };
};
notes?: string;
questions?: Array<{
id: number;
question: string;
answer?: string;
fieldType: string;
}> | null;
info?: {
lastUpdated?: string;
updatedBy?: string;
dateEntered?: string;
enteredBy?: string;
};
key?: string;
value?: string;
description?: string;
createdAt?: string;
updatedAt?: string;
[key: string]: unknown;
}>;
permissions: PermissionMap;
};
$: company = data.company;
$: configurations = data.configurations;
$: permissions = data.permissions;
const tabs = ["Credentials", "Configurations", "Users", "Activity"] as const;
type Tab = (typeof tabs)[number];
let activeTab: Tab = "Credentials";
// Configurations split-view state
let selectedConfig: (typeof configurations)[number] | null = null;
let configFadeKey = 0;
// Track which password fields are revealed (by question id)
let revealedPasswords: Record<number, boolean> = {};
function togglePassword(questionId: number) {
revealedPasswords[questionId] = !revealedPasswords[questionId];
revealedPasswords = revealedPasswords; // trigger reactivity
}
function selectConfig(config: (typeof configurations)[number]) {
if (selectedConfig?.id === config.id) {
// Clicking the active config collapses back to full list
selectedConfig = null;
} else {
selectedConfig = config;
configFadeKey++; // bump key to trigger fade animation
revealedPasswords = {}; // reset password visibility
}
}
function companyInitials(name: string): string {
return name
.split(/\s+/)
.slice(0, 2)
.map((w) => w[0])
.join("")
.toUpperCase();
}
function statusClass(status?: string): string {
if (!status) return "neutral";
const s = status.toLowerCase();
if (s === "active") return "active";
if (s === "inactive" || s === "disabled") return "inactive";
if (s === "pending") return "pending";
return "neutral";
}
function configStatusClass(statusName?: string): string {
if (!statusName) return "neutral";
const s = statusName.toLowerCase();
if (s === "active") return "active";
if (s === "inactive" || s === "automate inactive") return "inactive";
if (s === "reserved") return "reserved";
if (s === "provisioning" || s === "pending approval") return "pending";
return "neutral";
}
function formatDate(dateStr?: string): string {
if (!dateStr) return "";
try {
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
} catch {
return "";
}
}
function formatAddress(c: NonNullable<typeof company>): string[] {
// Prefer the nested cw_Data.address structure returned when includeAddress=true
const addr = c.cw_Data?.address;
if (addr) {
const lines: string[] = [];
if (addr.line1) lines.push(addr.line1);
if (addr.line2) lines.push(addr.line2);
const cityStateZip = [addr.city, addr.state, addr.zip]
.filter(Boolean)
.join(", ");
if (cityStateZip) lines.push(cityStateZip);
if (addr.country) lines.push(addr.country);
return lines;
}
// Fallback to flat fields
const lines: string[] = [];
if (c.address) lines.push(c.address);
const cityStateZip = [c.city, c.state, c.zip].filter(Boolean).join(", ");
if (cityStateZip) lines.push(cityStateZip);
if (c.country) lines.push(c.country);
return lines;
}
</script>
<svelte:head>
<title>{company?.name ?? "Company"} — Project Optima</title>
</svelte:head>
<div class="company-detail-page">
<!-- Left pane (1/4) — Company overview -->
<div class="company-detail-left">
<div class="detail-pane-body">
<button
class="back-btn"
on:click={() => goto("/companies")}
aria-label="Back to companies"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</button>
{#if company}
<!-- Avatar + name + status -->
<div class="profile-header">
<div class="profile-avatar">
<span class="profile-initials">{companyInitials(company.name)}</span
>
</div>
<h3 class="profile-name">{company.name}</h3>
{#if company.status}
<span class="profile-status {statusClass(company.status)}"
>{company.status}</span
>
{/if}
</div>
<!-- Info rows -->
<div class="profile-info">
{#if company.type}
<div class="info-row">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="info-icon"
>
<rect x="2" y="7" width="20" height="14" rx="2" ry="2" />
<path d="M16 3h-8l-2 4h12z" />
</svg>
<div class="info-content">
<span class="info-label">Type</span>
<span class="info-value">{company.type}</span>
</div>
</div>
{/if}
{#if company.identifier || company.id}
<div class="info-row">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="info-icon"
>
<path d="M4 9h16M4 15h16M10 3L8 21M16 3l-2 18" />
</svg>
<div class="info-content">
<span class="info-label">Identifier</span>
<span class="info-value mono"
>{company.identifier || company.id}</span
>
</div>
</div>
{/if}
{#if company.contactEmail}
<div class="info-row">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="info-icon"
>
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="M22 7l-10 7L2 7" />
</svg>
<div class="info-content">
<span class="info-label">Email</span>
<span class="info-value">{company.contactEmail}</span>
</div>
</div>
{/if}
{#if company.contactPhone}
<div class="info-row">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="info-icon"
>
<path
d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6A19.79 19.79 0 012.12 4.18 2 2 0 014.11 2h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 16.92z"
/>
</svg>
<div class="info-content">
<span class="info-label">Phone</span>
<span class="info-value">{company.contactPhone}</span>
</div>
</div>
{/if}
{#if permissions["company.fetch.address"] && formatAddress(company).length > 0}
<div class="info-row">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="info-icon"
>
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
<div class="info-content">
<span class="info-label">Address</span>
<span class="info-value address-multiline">
{#each formatAddress(company) as line}
{line}<br />
{/each}
</span>
</div>
</div>
{/if}
{#if formatDate(company.createdAt)}
<div class="info-row">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="info-icon"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
<div class="info-content">
<span class="info-label">Created</span>
<span class="info-value">{formatDate(company.createdAt)}</span>
</div>
</div>
{/if}
{#if formatDate(company.updatedAt)}
<div class="info-row">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="info-icon"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<div class="info-content">
<span class="info-label">Updated</span>
<span class="info-value">{formatDate(company.updatedAt)}</span>
</div>
</div>
{/if}
</div>
{:else}
<div class="profile-empty">
<p>Company not found.</p>
</div>
{/if}
</div>
</div>
<!-- Right pane (3/4) -->
<div class="company-detail-right">
<div class="tab-bar" role="tablist">
{#each tabs as tab}
<button
class="tab-btn"
class:active={activeTab === tab}
role="tab"
aria-selected={activeTab === tab}
on:click={() => (activeTab = tab)}
>
{tab}
{#if tab === "Configurations" && configurations.length > 0}
<span class="tab-count-badge">{configurations.length}</span>
{/if}
</button>
{/each}
</div>
<div class="detail-pane-body">
{#if activeTab === "Credentials"}
<p class="tab-placeholder">Credentials content</p>
{:else if activeTab === "Configurations"}
{#if configurations.length === 0}
<div class="tab-empty">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="tab-empty-icon"
>
<path
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
<p>No configurations found</p>
</div>
{:else}
<div class="config-split" class:expanded={selectedConfig !== null}>
<!-- Left side: config buttons -->
<div class="config-list" class:collapsed={selectedConfig !== null}>
{#each configurations as config (config.id)}
<button
class="config-item"
class:selected={selectedConfig?.id === config.id}
class:config-inactive={config.status?.name === "Inactive" ||
config.status?.name === "Automate Inactive"}
on:click={() => selectConfig(config)}
type="button"
>
<div class="config-item-header">
<div class="config-name-group">
<span
class="config-status-dot dot-{configStatusClass(
config.status?.name,
)}"
title={config.status?.name ?? "Unknown"}
></span>
<span class="config-name">{config.name}</span>
</div>
<div class="config-header-badges">
{#if config.status?.name && !selectedConfig}
<span
class="config-status-badge status-{configStatusClass(
config.status.name,
)}">{config.status.name}</span
>
{/if}
{#if config.type?.name && !selectedConfig}
<span class="config-type-badge">{config.type.name}</span
>
{/if}
</div>
</div>
{#if !selectedConfig}
{#if config.description}
<p class="config-description">{config.description}</p>
{/if}
{#if config.key}
<div class="config-kv">
<span class="config-key">{config.key}</span>
{#if config.value}
<span class="config-value">{config.value}</span>
{/if}
</div>
{/if}
{#if formatDate(config.updatedAt) || formatDate(config.createdAt) || formatDate(config.info?.lastUpdated) || formatDate(config.info?.dateEntered)}
<span class="config-date">
{#if formatDate(config.updatedAt)}
Updated {formatDate(config.updatedAt)}
{:else if formatDate(config.info?.lastUpdated)}
Updated {formatDate(config.info?.lastUpdated)}
{:else if formatDate(config.createdAt)}
Created {formatDate(config.createdAt)}
{:else if formatDate(config.info?.dateEntered)}
Created {formatDate(config.info?.dateEntered)}
{/if}
</span>
{/if}
{/if}
</button>
{/each}
</div>
<!-- Right side: config detail panel -->
{#if selectedConfig}
<div class="config-detail-panel">
{#key configFadeKey}
<div class="config-detail-content">
<div class="config-detail-header">
<div class="config-detail-header-left">
<h3 class="config-detail-title">
{selectedConfig.name}
</h3>
<div class="config-detail-meta-badges">
{#if selectedConfig.type?.name}
<span class="config-badge type"
>{selectedConfig.type.name}</span
>
{/if}
{#if selectedConfig.status?.name}
<span
class="config-badge status-{configStatusClass(
selectedConfig.status.name,
)}"
>
{selectedConfig.status.name}
</span>
{/if}
</div>
</div>
<button
class="config-detail-close"
on:click={() => (selectedConfig = null)}
aria-label="Close detail view"
type="button"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
{#if selectedConfig.serialNumber}
<div class="config-serial">
<span class="config-serial-label">Serial #</span>
<span class="config-serial-value"
>{selectedConfig.serialNumber}</span
>
</div>
{/if}
<!-- Notes -->
{#if selectedConfig.notes}
<div class="config-notes">
<h4 class="config-section-title">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path
d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-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>
Notes
</h4>
<p class="config-notes-text">{selectedConfig.notes}</p>
</div>
{/if}
<!-- Questions / Fields -->
{#if selectedConfig.questions && selectedConfig.questions.length > 0}
<div class="config-questions">
<h4 class="config-section-title">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"
/>
<rect x="9" y="3" width="6" height="4" rx="1" />
</svg>
Configuration Details
</h4>
<div class="questions-grid">
{#each selectedConfig.questions as q (q.id)}
<div
class="question-row"
class:has-answer={!!q.answer}
>
<span class="question-label">{q.question}</span>
<div class="question-value-wrap">
{#if q.fieldType === "Password"}
<span class="question-value password-value">
{#if revealedPasswords[q.id]}
{q.answer || "—"}
{:else}
{q.answer ? "••••••••" : "—"}
{/if}
</span>
{#if q.answer}
<button
class="password-toggle"
on:click={() => togglePassword(q.id)}
type="button"
aria-label={revealedPasswords[q.id]
? "Hide password"
: "Show password"}
>
{#if revealedPasswords[q.id]}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path
d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94"
/>
<path
d="M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19"
/>
<path
d="M14.12 14.12a3 3 0 11-4.24-4.24"
/>
<line x1="1" y1="1" x2="23" y2="23" />
</svg>
{:else}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path
d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"
/>
<circle cx="12" cy="12" r="3" />
</svg>
{/if}
</button>
{/if}
{:else if q.fieldType === "TextArea"}
<span class="question-value textarea-value"
>{q.answer || "—"}</span
>
{:else}
<span class="question-value"
>{q.answer || "—"}</span
>
{/if}
</div>
</div>
{/each}
</div>
</div>
{:else if !selectedConfig.notes}
<div class="config-no-questions">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
width="32"
height="32"
>
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
<p>No configuration fields available</p>
</div>
{/if}
<!-- Footer metadata -->
{#if selectedConfig.info}
<div class="config-info-footer">
{#if selectedConfig.info.enteredBy || selectedConfig.info.dateEntered}
<div class="config-info-item">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<path d="M12 5v14M5 12h14" />
</svg>
Created{#if selectedConfig.info.enteredBy}&nbsp;by <strong
>{selectedConfig.info.enteredBy}</strong
>{/if}{#if selectedConfig.info.dateEntered}&nbsp;on
{formatDate(selectedConfig.info.dateEntered)}{/if}
</div>
{/if}
{#if selectedConfig.info.updatedBy || selectedConfig.info.lastUpdated}
<div class="config-info-item">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
Updated{#if selectedConfig.info.updatedBy}&nbsp;by <strong
>{selectedConfig.info.updatedBy}</strong
>{/if}{#if selectedConfig.info.lastUpdated}&nbsp;on
{formatDate(selectedConfig.info.lastUpdated)}{/if}
</div>
{/if}
</div>
{/if}
</div>
{/key}
</div>
{/if}
</div>
{/if}
{:else if activeTab === "Users"}
<p class="tab-placeholder">Users content</p>
{:else if activeTab === "Activity"}
<p class="tab-placeholder">Activity content</p>
{/if}
</div>
</div>
</div>
+480
View File
@@ -0,0 +1,480 @@
/*
Admin Pane + Tab Bar Layout
*/
/* Page container */
.admin-page {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
width: 100%;
}
/* ── Pane container ── */
.admin-pane {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
background: var(--bg-surface);
border-radius: 12px;
box-shadow: var(--header-shadow);
overflow: hidden;
}
/* ── Pane header ── */
.admin-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 20px 24px 16px;
border-bottom: none;
flex-shrink: 0;
}
.admin-header-left {
display: flex;
align-items: center;
gap: 12px;
}
.admin-header-icon {
width: 22px;
height: 22px;
color: var(--text-secondary);
flex-shrink: 0;
}
.admin-title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
}
.admin-subtitle {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
margin-left: 4px;
}
/* ── Tab bar (mirrors companydetail.css) ── */
.admin-pane .tab-bar {
display: flex;
align-items: stretch;
gap: 0;
padding: 0 24px;
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
background: var(--bg-surface);
}
.admin-pane .tab-btn {
position: relative;
display: inline-flex;
align-items: center;
padding: 14px 16px 12px;
border: none;
background: none;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: color 0.15s;
white-space: nowrap;
text-decoration: none;
}
.admin-pane .tab-btn::after {
content: "";
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
border-radius: 2px 2px 0 0;
background: transparent;
transition: background 0.15s;
}
.admin-pane .tab-btn:hover {
color: var(--text-primary);
}
.admin-pane .tab-btn.active {
color: var(--text-primary);
font-weight: 600;
}
.admin-pane .tab-btn.active::after {
background: var(--input-focus-border);
}
/* ── Pane body (tab content area) ── */
.admin-body {
position: relative;
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 24px;
}
/* ── Stats grid ── */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.stat-card {
display: flex;
align-items: flex-start;
gap: 14px;
padding: 20px;
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 10px;
transition:
background 0.2s,
border-color 0.2s,
box-shadow 0.2s;
}
.stat-card:hover {
background: var(--card-hover-bg);
border-color: var(--card-hover-border);
box-shadow: var(--card-hover-shadow);
}
.stat-icon {
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 10px;
background: linear-gradient(
135deg,
var(--avatar-gradient-from),
var(--avatar-gradient-to)
);
flex-shrink: 0;
}
.stat-icon svg {
width: 20px;
height: 20px;
color: var(--text-inverse);
stroke: currentColor;
}
.stat-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.stat-value {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.2;
}
.stat-label {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
}
/* ── Section headings ── */
.section-heading {
margin: 0 0 16px;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
/* ── Quick-action cards ── */
.actions-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.action-card {
display: flex;
align-items: center;
gap: 16px;
padding: 18px 20px;
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 10px;
cursor: pointer;
text-decoration: none;
color: inherit;
transition:
background 0.2s,
border-color 0.2s,
box-shadow 0.2s;
position: relative;
}
.action-card:hover {
background: var(--card-hover-bg);
border-color: var(--card-hover-border);
box-shadow: var(--card-hover-shadow);
}
.action-card:active {
box-shadow: var(--card-active-shadow);
}
.action-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 10px;
background: var(--nav-active-bg);
flex-shrink: 0;
}
.action-icon svg {
width: 20px;
height: 20px;
color: var(--nav-active-color);
stroke: currentColor;
}
.action-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.action-name {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.action-desc {
font-size: 12px;
color: var(--text-secondary);
}
.action-arrow {
position: absolute;
right: 16px;
width: 16px;
height: 16px;
color: var(--card-arrow-color);
opacity: 0;
transform: translateX(-4px);
transition:
opacity 0.2s,
transform 0.2s;
}
.action-card:hover .action-arrow {
opacity: 1;
transform: translateX(0);
}
/* ── Activity placeholder ── */
.activity-section {
margin-bottom: 32px;
}
.activity-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
background: var(--bg-inset);
border: 1px dashed var(--border-default);
border-radius: 10px;
color: var(--text-muted);
font-size: 14px;
gap: 8px;
}
.activity-empty svg {
width: 32px;
height: 32px;
color: var(--text-faint);
}
/* ── Scrollbar ── */
.admin-body::-webkit-scrollbar {
width: 6px;
}
.admin-body::-webkit-scrollbar-track {
background: transparent;
}
.admin-body::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 3px;
}
.admin-body::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
}
/*
Admin Tab Empty State
*/
.admin-tab-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: var(--text-muted);
font-size: 14px;
gap: 12px;
}
.admin-tab-empty svg {
width: 40px;
height: 40px;
color: var(--text-faint);
}
.admin-tab-empty h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.admin-tab-empty p {
margin: 0;
color: var(--text-secondary);
font-size: 13px;
text-align: center;
max-width: 360px;
}
/*
Admin Permission Denied State
*/
.admin-denied {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
gap: 12px;
}
.admin-denied svg {
width: 40px;
height: 40px;
color: var(--status-inactive-color);
}
.admin-denied h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.admin-denied p {
margin: 0;
color: var(--text-secondary);
font-size: 13px;
text-align: center;
max-width: 360px;
}
/*
Admin Data Tables (Users, Roles, Cred Types)
*/
.admin-table-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 12px;
}
.admin-table-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.admin-table-header .result-count {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
margin-left: 8px;
}
.admin-table-wrap {
border: 1px solid var(--card-border);
border-radius: 10px;
overflow: hidden;
}
.admin-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.admin-table thead {
background: var(--bg-inset);
}
.admin-table th {
padding: 10px 16px;
text-align: left;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
border-bottom: 1px solid var(--card-border);
white-space: nowrap;
}
.admin-table td {
padding: 12px 16px;
color: var(--text-primary);
border-bottom: 1px solid var(--border-subtle);
vertical-align: middle;
}
.admin-table tbody tr {
transition: background 0.15s;
}
.admin-table tbody tr:hover {
background: var(--card-hover-bg);
}
.admin-table tbody tr:last-child td {
border-bottom: none;
}
+264
View File
@@ -2,6 +2,226 @@
@plugin '@tailwindcss/forms'; @plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography'; @plugin '@tailwindcss/typography';
/*
Theme Variables
*/
:root,
[data-theme="light"] {
/* Surfaces */
--bg-base: #f0f0f0;
--bg-surface: #ffffff;
--bg-surface-alt: #f8f9fb;
--bg-elevated: #ffffff;
--bg-inset: #f9fafb;
--bg-gradient-from: #3498db;
--bg-gradient-to: #2980b9;
/* Borders */
--border-default: #e0e0e0;
--border-subtle: #eef0f3;
--border-strong: #d1d5db;
/* Text */
--text-primary: #2c3e50;
--text-secondary: #666666;
--text-muted: #8492a6;
--text-faint: #9ca3af;
--text-inverse: #ffffff;
/* Nav */
--nav-hover-bg: rgba(0, 0, 0, 0.04);
--nav-active-bg: rgba(52, 152, 219, 0.08);
--nav-active-color: #3498db;
--nav-active-border: #3498db;
/* Header */
--header-bg: #ffffff;
--header-text: #2c3e50;
--header-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
--accent-bar-from: #2980b9;
--accent-bar-to: #3498db;
--accent-bar-height: 0;
/* Cards */
--card-bg: #f8f9fb;
--card-hover-bg: #ffffff;
--card-border: #eef0f3;
--card-hover-border: #d0d9e8;
--card-hover-shadow:
0 8px 24px rgba(0, 0, 0, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04);
--card-active-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
--card-focus-ring: #3498db;
--card-arrow-color: #cbd5e1;
/* Avatar */
--avatar-gradient-from: #3498db;
--avatar-gradient-to: #2980b9;
/* Inputs */
--input-bg: #f9fafb;
--input-focus-bg: #ffffff;
--input-border: #d1d5db;
--input-focus-border: #3498db;
--input-focus-ring: rgba(52, 152, 219, 0.12);
--input-text: #374151;
--input-placeholder: #9ca3af;
/* Pagination */
--page-btn-bg: #ffffff;
--page-btn-border: #d1d5db;
--page-btn-hover-bg: #f3f4f6;
--page-btn-hover-border: #9ca3af;
--page-btn-active-bg: #2c3e50;
--page-btn-active-border: #2c3e50;
--page-btn-active-color: #ffffff;
--page-btn-color: #374151;
/* Overlay */
--overlay-bg: rgba(255, 255, 255, 0.8);
--spinner-track: #eef0f3;
--spinner-accent: #3498db;
/* Scrollbar */
--scrollbar-thumb: rgba(0, 0, 0, 0.15);
--scrollbar-thumb-hover: rgba(0, 0, 0, 0.25);
/* Status labels */
--status-active-bg: #dcfce7;
--status-active-color: #15803d;
--status-inactive-bg: #fee2e2;
--status-inactive-color: #b91c1c;
--status-pending-bg: #fef3c7;
--status-pending-color: #a16207;
--status-reserved-bg: #dbeafe;
--status-reserved-color: #1d4ed8;
--status-provisioning-bg: #fef3c7;
--status-provisioning-color: #a16207;
--status-neutral-bg: #f1f5f9;
--status-neutral-color: #64748b;
--status-neutral-dot: #d1d5db;
--status-neutral-dot-ring: rgba(209, 213, 219, 0.3);
/* Toggle */
--toggle-bg: rgba(0, 0, 0, 0.08);
--toggle-hover-bg: rgba(0, 0, 0, 0.12);
--toggle-color: #666666;
/* Error illustration */
--error-circle-outer: #fef2f2;
--error-circle-stroke: #fecaca;
--error-circle-inner: #fee2e2;
color-scheme: light;
}
[data-theme="dark"] {
/* Surfaces */
--bg-base: #0e0e0e;
--bg-surface: #1a1a1a;
--bg-surface-alt: #141414;
--bg-elevated: #1c1c1c;
--bg-inset: #111111;
--bg-gradient-from: #12161e;
--bg-gradient-to: #181d28;
/* Borders */
--border-default: #2a2a2a;
--border-subtle: #262626;
--border-strong: #333333;
/* Text */
--text-primary: #e0e0e0;
--text-secondary: #737373;
--text-muted: #737373;
--text-faint: #525252;
--text-inverse: #0e0e0e;
/* Nav */
--nav-hover-bg: rgba(255, 255, 255, 0.04);
--nav-active-bg: rgba(255, 255, 255, 0.06);
--nav-active-color: #ffffff;
--nav-active-border: #ffffff;
/* Header */
--header-bg: #1a1a1a;
--header-text: #e0e0e0;
--header-shadow: none;
--accent-bar-from: #1a2a44;
--accent-bar-to: #0e1b33;
--accent-bar-height: 0;
/* Cards */
--card-bg: #141414;
--card-hover-bg: #1c1c1c;
--card-border: #262626;
--card-hover-border: #3d3d3d;
--card-hover-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
--card-active-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
--card-focus-ring: #a3a3a3;
--card-arrow-color: #a3a3a3;
/* Avatar */
--avatar-gradient-from: #333333;
--avatar-gradient-to: #262626;
/* Inputs */
--input-bg: #111111;
--input-focus-bg: #161616;
--input-border: #333333;
--input-focus-border: #525252;
--input-focus-ring: rgba(255, 255, 255, 0.04);
--input-text: #d4d4d4;
--input-placeholder: #525252;
/* Pagination */
--page-btn-bg: #1a1a1a;
--page-btn-border: #333333;
--page-btn-hover-bg: #262626;
--page-btn-hover-border: #404040;
--page-btn-active-bg: #ffffff;
--page-btn-active-border: #ffffff;
--page-btn-active-color: #0e0e0e;
--page-btn-color: #a3a3a3;
/* Overlay */
--overlay-bg: rgba(14, 14, 14, 0.85);
--spinner-track: #262626;
--spinner-accent: #a3a3a3;
/* Scrollbar */
--scrollbar-thumb: rgba(255, 255, 255, 0.15);
--scrollbar-thumb-hover: rgba(255, 255, 255, 0.3);
/* Status labels */
--status-active-bg: rgba(34, 197, 94, 0.12);
--status-active-color: #4ade80;
--status-inactive-bg: rgba(239, 68, 68, 0.12);
--status-inactive-color: #f87171;
--status-pending-bg: rgba(245, 158, 11, 0.12);
--status-pending-color: #fbbf24;
--status-reserved-bg: rgba(59, 130, 246, 0.12);
--status-reserved-color: #60a5fa;
--status-provisioning-bg: rgba(245, 158, 11, 0.12);
--status-provisioning-color: #fbbf24;
--status-neutral-bg: rgba(255, 255, 255, 0.06);
--status-neutral-color: #737373;
--status-neutral-dot: #525252;
--status-neutral-dot-ring: rgba(82, 82, 82, 0.3);
/* Toggle */
--toggle-bg: rgba(255, 255, 255, 0.08);
--toggle-hover-bg: rgba(255, 255, 255, 0.12);
--toggle-color: #a3a3a3;
/* Error illustration */
--error-circle-outer: rgba(239, 68, 68, 0.08);
--error-circle-stroke: rgba(239, 68, 68, 0.2);
--error-circle-inner: rgba(239, 68, 68, 0.12);
color-scheme: dark;
}
body { body {
margin: 0; margin: 0;
padding: 0; padding: 0;
@@ -9,3 +229,47 @@ body {
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
overflow: hidden; overflow: hidden;
} }
/*
Theme Toggle Button
*/
.theme-toggle {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: none;
border-radius: 8px;
background: var(--toggle-bg);
color: var(--toggle-color);
cursor: pointer;
transition:
background 0.2s,
color 0.2s;
margin-left: auto;
flex-shrink: 0;
}
.theme-toggle:hover {
background: var(--toggle-hover-bg);
color: var(--text-primary);
}
.theme-icon {
width: 18px;
height: 18px;
position: absolute;
opacity: 0;
transform: scale(0.6) rotate(-90deg);
transition:
opacity 0.25s ease,
transform 0.25s ease;
}
.theme-icon.visible {
opacity: 1;
transform: scale(1) rotate(0deg);
}
+925
View File
@@ -0,0 +1,925 @@
/*
Company Detail Two-Pane Layout
*/
/* Page container */
.company-detail-page {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
width: 100%;
gap: 16px;
}
/* ── Left pane (1/4 width) ── */
.company-detail-left {
display: flex;
flex-direction: column;
flex: 0 0 25%;
min-height: 0;
background: var(--bg-surface);
border-radius: 12px;
box-shadow: var(--header-shadow);
overflow: hidden;
}
/* ── Right pane (3/4 width) ── */
.company-detail-right {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
background: var(--bg-surface);
border-radius: 12px;
box-shadow: var(--header-shadow);
overflow: hidden;
}
/* ── Pane header (reusable for both panes) ── */
.detail-pane-header {
display: flex;
align-items: center;
gap: 12px;
padding: 20px 24px 16px;
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.detail-pane-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
/* ── Back button ── */
.back-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid var(--border-subtle);
border-radius: 8px;
background: var(--bg-surface);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
}
.back-btn:hover {
background: var(--nav-hover-bg);
color: var(--text-primary);
border-color: var(--input-focus-border);
}
/*
Right Pane Tab Bar
*/
.tab-bar {
display: flex;
align-items: stretch;
gap: 0;
padding: 0 24px;
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
background: var(--bg-surface);
}
.tab-btn {
position: relative;
padding: 14px 16px 12px;
border: none;
background: none;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: color 0.15s;
white-space: nowrap;
}
.tab-btn::after {
content: "";
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
border-radius: 2px 2px 0 0;
background: transparent;
transition: background 0.15s;
}
.tab-btn:hover {
color: var(--text-primary);
}
.tab-btn.active {
color: var(--text-primary);
font-weight: 600;
}
.tab-btn.active::after {
background: var(--input-focus-border);
}
.tab-count-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
margin-left: 6px;
border-radius: 10px;
background: var(--status-neutral-bg);
color: var(--status-neutral-color);
font-size: 11px;
font-weight: 600;
line-height: 1;
}
.tab-btn.active .tab-count-badge {
background: var(--input-focus-border);
color: #fff;
}
.tab-placeholder {
color: var(--text-secondary);
font-size: 14px;
}
/* ── Pane body (reusable for both panes) ── */
.detail-pane-body {
position: relative;
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 20px 24px;
}
/*
Left Pane Company Profile
*/
/* Profile header (avatar + name + status) */
.profile-header {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding-bottom: 20px;
border-bottom: 1px solid var(--border-subtle);
margin-bottom: 20px;
margin-top: 16px;
}
.profile-avatar {
width: 64px;
height: 64px;
border-radius: 16px;
background: linear-gradient(
135deg,
var(--avatar-gradient-from),
var(--avatar-gradient-to)
);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.profile-initials {
font-size: 22px;
font-weight: 700;
color: #fff;
letter-spacing: 0.5px;
line-height: 1;
}
.profile-name {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
text-align: center;
word-break: break-word;
}
.profile-status {
font-size: 11px;
font-weight: 600;
padding: 3px 12px;
border-radius: 20px;
text-transform: capitalize;
letter-spacing: 0.02em;
}
.profile-status.active {
background: var(--status-active-bg);
color: var(--status-active-color);
}
.profile-status.inactive {
background: var(--status-inactive-bg);
color: var(--status-inactive-color);
}
.profile-status.pending {
background: var(--status-pending-bg);
color: var(--status-pending-color);
}
.profile-status.neutral {
background: var(--status-neutral-bg);
color: var(--status-neutral-color);
}
/* Info rows */
.profile-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.info-row {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 10px 8px;
border-radius: 8px;
transition: background 0.15s;
}
.info-row:hover {
background: var(--nav-hover-bg);
}
.info-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
color: var(--text-faint);
margin-top: 2px;
}
.info-content {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.info-label {
font-size: 11px;
font-weight: 500;
color: var(--text-faint);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.info-value {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
word-break: break-word;
}
.info-value.mono {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
font-size: 12px;
letter-spacing: -0.02em;
}
.info-value.address-multiline {
line-height: 1.6;
}
/* Empty state */
.profile-empty {
display: flex;
justify-content: center;
align-items: center;
flex: 1;
padding: 48px 16px;
color: var(--text-secondary);
font-size: 14px;
}
/*
Tab Content Shared
*/
.tab-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 64px 16px;
color: var(--text-secondary);
font-size: 14px;
}
.tab-empty p {
margin: 0;
}
.tab-empty-icon {
width: 40px;
height: 40px;
color: var(--text-faint);
}
/*
Tab Content Configurations
*/
/* Split container */
.config-split {
display: flex;
gap: 0;
height: 100%;
min-height: 0;
}
.config-split.expanded {
gap: 16px;
}
.config-list {
display: flex;
flex-direction: column;
gap: 10px;
transition: flex 0.35s cubic-bezier(0.4, 0, 0.2, 1);
flex: 1 1 100%;
min-width: 0;
overflow-y: auto;
}
.config-list.collapsed {
flex: 0 0 220px;
max-width: 220px;
gap: 6px;
}
.config-item {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px 16px;
border: 1px solid var(--card-border);
border-radius: 10px;
background: var(--card-bg);
cursor: pointer;
text-align: left;
width: 100%;
font-family: inherit;
font-size: inherit;
transition:
border-color 0.15s,
background 0.15s,
padding 0.3s cubic-bezier(0.4, 0, 0.2, 1),
gap 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.config-list.collapsed .config-item {
padding: 10px 12px;
gap: 0;
}
.config-item:hover {
border-color: var(--card-hover-border);
background: var(--card-hover-bg);
}
.config-item.selected {
border-color: var(--input-focus-border);
background: var(--card-hover-bg);
border-radius: 14px;
}
.config-item-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.config-name-group {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.config-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.config-status-dot.dot-active {
background: var(--status-active-color);
box-shadow: 0 0 0 2px var(--status-active-bg);
}
.config-status-dot.dot-inactive {
background: var(--status-inactive-color);
box-shadow: 0 0 0 2px var(--status-inactive-bg);
}
.config-status-dot.dot-reserved {
background: var(--status-reserved-color);
box-shadow: 0 0 0 2px var(--status-reserved-bg);
}
.config-status-dot.dot-pending {
background: var(--status-pending-color);
box-shadow: 0 0 0 2px var(--status-pending-bg);
}
.config-status-dot.dot-neutral {
background: var(--status-neutral-dot);
box-shadow: 0 0 0 2px var(--status-neutral-dot-ring);
}
/* Config header badges wrapper */
.config-header-badges {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
/* Config status badge (in list items) */
.config-status-badge {
font-size: 10px;
font-weight: 600;
padding: 2px 8px;
border-radius: 20px;
white-space: nowrap;
letter-spacing: 0.02em;
}
.config-status-badge.status-active {
background: var(--status-active-bg);
color: var(--status-active-color);
}
.config-status-badge.status-inactive {
background: var(--status-inactive-bg);
color: var(--status-inactive-color);
}
.config-status-badge.status-reserved {
background: var(--status-reserved-bg);
color: var(--status-reserved-color);
}
.config-status-badge.status-pending {
background: var(--status-pending-bg);
color: var(--status-pending-color);
}
.config-status-badge.status-neutral {
background: var(--status-neutral-bg);
color: var(--status-neutral-color);
}
.config-item.config-inactive {
opacity: 0.55;
}
.config-item.config-inactive:hover {
opacity: 0.8;
}
.config-item.config-inactive.selected {
opacity: 0.85;
}
.config-name {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.config-list.collapsed .config-name {
font-size: 13px;
}
.config-type-badge {
font-size: 11px;
font-weight: 500;
padding: 2px 10px;
border-radius: 20px;
background: var(--status-neutral-bg);
color: var(--status-neutral-color);
white-space: nowrap;
flex-shrink: 0;
}
.config-description {
margin: 0;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.4;
}
.config-kv {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 6px;
background: var(--nav-hover-bg);
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
font-size: 12px;
overflow-x: auto;
}
.config-key {
color: var(--text-secondary);
font-weight: 500;
flex-shrink: 0;
}
.config-key::after {
content: ":";
margin-left: 2px;
}
.config-value {
color: var(--text-primary);
word-break: break-all;
}
.config-date {
font-size: 11px;
color: var(--text-faint);
}
/*
Config Detail Panel (right side)
*/
.config-detail-panel {
flex: 1 1 0%;
min-width: 0;
overflow: hidden;
border: 1px solid var(--border-subtle);
border-radius: 10px;
background: var(--card-bg);
display: flex;
flex-direction: column;
}
/* Fade-in animation via {#key} */
.config-detail-content {
display: flex;
flex-direction: column;
flex: 1;
padding: 20px 24px;
overflow-y: auto;
animation: configFadeIn 0.3s ease-out;
}
@keyframes configFadeIn {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.config-detail-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border-subtle);
}
.config-detail-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.config-detail-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid var(--border-subtle);
border-radius: 6px;
background: var(--bg-surface);
color: var(--text-secondary);
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s;
}
.config-detail-close:hover {
background: var(--nav-hover-bg);
color: var(--text-primary);
border-color: var(--input-focus-border);
}
.config-detail-json {
margin: 0;
padding: 16px;
border-radius: 8px;
background: var(--nav-hover-bg);
color: var(--text-primary);
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
overflow-y: auto;
flex: 1;
}
/*
Config Detail Structured Questions View
*/
/* Header with badges */
.config-detail-header-left {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
flex: 1;
}
.config-detail-meta-badges {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.config-badge {
font-size: 11px;
font-weight: 600;
padding: 2px 10px;
border-radius: 20px;
white-space: nowrap;
}
.config-badge.type {
background: var(--status-neutral-bg);
color: var(--status-neutral-color);
}
.config-badge.active-badge,
.config-badge.status-active {
background: var(--status-active-bg);
color: var(--status-active-color);
}
.config-badge.inactive-badge,
.config-badge.status-inactive {
background: var(--status-inactive-bg);
color: var(--status-inactive-color);
}
.config-badge.status-reserved {
background: var(--status-reserved-bg);
color: var(--status-reserved-color);
}
.config-badge.status-pending {
background: var(--status-pending-bg);
color: var(--status-pending-color);
}
.config-badge.status-neutral {
background: var(--status-neutral-bg);
color: var(--status-neutral-color);
}
/* Serial number */
.config-serial {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
margin-bottom: 16px;
border-radius: 8px;
background: var(--nav-hover-bg);
font-size: 13px;
}
.config-serial-label {
font-weight: 600;
color: var(--text-faint);
text-transform: uppercase;
font-size: 11px;
letter-spacing: 0.04em;
flex-shrink: 0;
}
.config-serial-value {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
color: var(--text-primary);
font-size: 12px;
word-break: break-all;
}
/* Notes */
.config-notes {
margin-bottom: 20px;
}
.config-notes-text {
margin: 0;
padding: 10px 14px;
border-radius: 8px;
background: var(--nav-hover-bg);
color: var(--text-primary);
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
/* Section title */
.config-section-title {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 14px;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.config-section-title svg {
color: var(--text-faint);
flex-shrink: 0;
}
/* Questions grid */
.config-questions {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.questions-grid {
display: flex;
flex-direction: column;
gap: 2px;
}
.question-row {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border-radius: 8px;
transition: background 0.15s;
}
.question-row:hover {
background: var(--nav-hover-bg);
}
.question-label {
font-size: 11px;
font-weight: 600;
color: var(--text-faint);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.question-value-wrap {
display: flex;
align-items: flex-start;
gap: 8px;
}
.question-value {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
word-break: break-word;
}
.question-row:not(.has-answer) .question-value {
color: var(--text-faint);
font-style: italic;
font-weight: 400;
}
.question-value.textarea-value {
white-space: pre-wrap;
line-height: 1.6;
font-size: 13px;
padding: 6px 10px;
border-radius: 6px;
background: var(--nav-hover-bg);
width: 100%;
}
.question-row:hover .question-value.textarea-value {
background: var(--card-bg);
}
.question-value.password-value {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
font-size: 13px;
letter-spacing: 0.05em;
}
/* Password toggle */
.password-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: 1px solid var(--border-subtle);
border-radius: 5px;
background: var(--bg-surface);
color: var(--text-secondary);
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s;
}
.password-toggle:hover {
background: var(--nav-hover-bg);
color: var(--text-primary);
border-color: var(--input-focus-border);
}
/* No questions placeholder */
.config-no-questions {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 40px 16px;
color: var(--text-faint);
font-size: 13px;
flex: 1;
}
.config-no-questions p {
margin: 0;
}
/* Footer metadata */
.config-info-footer {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: auto;
padding-top: 16px;
border-top: 1px solid var(--border-subtle);
}
.config-info-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--text-faint);
line-height: 1.4;
}
.config-info-item svg {
flex-shrink: 0;
color: var(--text-faint);
}
.config-info-item strong {
font-weight: 600;
color: var(--text-secondary);
}
+62 -61
View File
@@ -18,11 +18,9 @@
flex-direction: column; flex-direction: column;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
background: #ffffff; background: var(--bg-surface);
border-radius: 12px; border-radius: 12px;
box-shadow: box-shadow: var(--header-shadow);
0 4px 24px rgba(0, 0, 0, 0.08),
0 1px 4px rgba(0, 0, 0, 0.04);
overflow: hidden; overflow: hidden;
} }
@@ -34,7 +32,7 @@
flex-wrap: wrap; flex-wrap: wrap;
gap: 12px; gap: 12px;
padding: 20px 24px 16px; padding: 20px 24px 16px;
border-bottom: 1px solid #eef0f3; border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0; flex-shrink: 0;
} }
@@ -48,13 +46,13 @@
margin: 0; margin: 0;
font-size: 20px; font-size: 20px;
font-weight: 600; font-weight: 600;
color: #2c3e50; color: var(--text-primary);
} }
.result-count { .result-count {
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
color: #8492a6; color: var(--text-secondary);
} }
.search-bar { .search-bar {
@@ -65,25 +63,25 @@
.search-bar input { .search-bar input {
width: 100%; width: 100%;
padding: 9px 34px 9px 38px; padding: 9px 34px 9px 38px;
border: 1px solid #d1d5db; border: 1px solid var(--input-border);
border-radius: 8px; border-radius: 8px;
font-size: 14px; font-size: 14px;
outline: none; outline: none;
background: #f9fafb; background: var(--input-bg);
color: #374151; color: var(--input-text);
transition: transition:
border-color 0.2s, border-color 0.2s,
box-shadow 0.2s; box-shadow 0.2s;
} }
.search-bar input::placeholder { .search-bar input::placeholder {
color: #9ca3af; color: var(--input-placeholder);
} }
.search-bar input:focus { .search-bar input:focus {
border-color: #3498db; border-color: var(--input-focus-border);
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.12); box-shadow: 0 0 0 3px var(--input-focus-ring);
background: #fff; background: var(--input-focus-bg);
} }
.search-icon { .search-icon {
@@ -93,7 +91,7 @@
transform: translateY(-50%); transform: translateY(-50%);
width: 16px; width: 16px;
height: 16px; height: 16px;
color: #9ca3af; color: var(--text-faint);
pointer-events: none; pointer-events: none;
} }
@@ -104,7 +102,7 @@
transform: translateY(-50%); transform: translateY(-50%);
background: none; background: none;
border: none; border: none;
color: #9ca3af; color: var(--text-faint);
cursor: pointer; cursor: pointer;
padding: 4px; padding: 4px;
display: flex; display: flex;
@@ -117,8 +115,8 @@
} }
.search-clear:hover { .search-clear:hover {
color: #374151; color: var(--input-text);
background: #f3f4f6; background: var(--nav-hover-bg);
} }
/* ── Pane body ── */ /* ── Pane body ── */
@@ -134,7 +132,7 @@
.search-loading-overlay { .search-loading-overlay {
position: absolute; position: absolute;
inset: 0; inset: 0;
background: rgba(255, 255, 255, 0.8); background: var(--overlay-bg);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -146,8 +144,8 @@
width: 36px; width: 36px;
height: 36px; height: 36px;
border-radius: 50%; border-radius: 50%;
border: 4px solid #eef0f3; border: 4px solid var(--spinner-track);
border-top-color: #3498db; border-top-color: var(--spinner-accent);
animation: search-spin 0.7s linear infinite; animation: search-spin 0.7s linear infinite;
} }
@@ -184,9 +182,9 @@
flex-direction: column; flex-direction: column;
gap: 14px; gap: 14px;
padding: 18px; padding: 18px;
background: #f8f9fb; background: var(--card-bg);
border-radius: 12px; border-radius: 12px;
border: 1px solid #eef0f3; border: 1px solid var(--card-border);
box-shadow: none; box-shadow: none;
cursor: pointer; cursor: pointer;
transition: transition:
@@ -196,26 +194,25 @@
background 0.18s; background 0.18s;
text-align: left; text-align: left;
font: inherit; font: inherit;
color: inherit; color: var(--input-text);
overflow: hidden; overflow: hidden;
width: 100%; width: 100%;
} }
.company-card:hover { .company-card:hover {
transform: translateY(-3px); transform: translateY(-3px);
box-shadow: box-shadow: var(--card-hover-shadow);
0 8px 24px rgba(0, 0, 0, 0.08), border-color: var(--card-hover-border);
0 2px 8px rgba(0, 0, 0, 0.04); background: var(--card-hover-bg);
border-color: #d0d9e8;
} }
.company-card:active { .company-card:active {
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06); box-shadow: var(--card-active-shadow);
} }
.company-card:focus-visible { .company-card:focus-visible {
outline: 2px solid #3498db; outline: 2px solid var(--card-focus-ring);
outline-offset: 2px; outline-offset: 2px;
} }
@@ -230,7 +227,11 @@
width: 44px; width: 44px;
height: 44px; height: 44px;
border-radius: 12px; border-radius: 12px;
background: linear-gradient(135deg, #3498db, #2980b9); background: linear-gradient(
135deg,
var(--avatar-gradient-from),
var(--avatar-gradient-to)
);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -270,8 +271,8 @@
} }
.status-dot.neutral { .status-dot.neutral {
background: #d1d5db; background: var(--status-neutral-dot);
box-shadow: 0 0 0 3px rgba(209, 213, 219, 0.3); box-shadow: 0 0 0 3px var(--status-neutral-dot-ring);
} }
/* Card body */ /* Card body */
@@ -286,7 +287,7 @@
margin: 0; margin: 0;
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
color: #1e293b; color: var(--text-primary);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -295,7 +296,7 @@
.card-email { .card-email {
font-size: 13px; font-size: 13px;
color: #94a3b8; color: var(--text-secondary);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -313,14 +314,14 @@
align-items: center; align-items: center;
gap: 5px; gap: 5px;
font-size: 12px; font-size: 12px;
color: #64748b; color: var(--text-secondary);
} }
.meta-icon { .meta-icon {
width: 14px; width: 14px;
height: 14px; height: 14px;
flex-shrink: 0; flex-shrink: 0;
color: #94a3b8; color: var(--text-faint);
} }
.meta-item .mono { .meta-item .mono {
@@ -336,7 +337,7 @@
justify-content: space-between; justify-content: space-between;
gap: 8px; gap: 8px;
padding-top: 10px; padding-top: 10px;
border-top: 1px solid #f1f5f9; border-top: 1px solid var(--border-subtle);
margin-top: auto; margin-top: auto;
} }
@@ -350,28 +351,28 @@
} }
.status-label.active { .status-label.active {
background: #dcfce7; background: var(--status-active-bg);
color: #15803d; color: var(--status-active-color);
} }
.status-label.inactive { .status-label.inactive {
background: #fee2e2; background: var(--status-inactive-bg);
color: #b91c1c; color: var(--status-inactive-color);
} }
.status-label.pending { .status-label.pending {
background: #fef3c7; background: var(--status-pending-bg);
color: #a16207; color: var(--status-pending-color);
} }
.status-label.neutral { .status-label.neutral {
background: #f1f5f9; background: var(--status-neutral-bg);
color: #64748b; color: var(--status-neutral-color);
} }
.card-date { .card-date {
font-size: 12px; font-size: 12px;
color: #94a3b8; color: var(--text-faint);
margin-left: auto; margin-left: auto;
} }
@@ -382,7 +383,7 @@
right: 16px; right: 16px;
width: 18px; width: 18px;
height: 18px; height: 18px;
color: #cbd5e1; color: var(--card-arrow-color);
opacity: 0; opacity: 0;
transform: translateX(-4px); transform: translateX(-4px);
transition: transition:
@@ -405,14 +406,14 @@
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 16px;
padding: 12px 24px; padding: 12px 24px;
border-top: 1px solid #eef0f3; border-top: 1px solid var(--border-subtle);
background: #f8f9fb; background: var(--bg-surface-alt);
flex-shrink: 0; flex-shrink: 0;
} }
.page-info { .page-info {
font-size: 13px; font-size: 13px;
color: #8492a6; color: var(--text-secondary);
} }
.pagination { .pagination {
@@ -428,10 +429,10 @@
min-width: 32px; min-width: 32px;
height: 32px; height: 32px;
padding: 0 6px; padding: 0 6px;
border: 1px solid #d1d5db; border: 1px solid var(--page-btn-border);
border-radius: 6px; border-radius: 6px;
background: #fff; background: var(--page-btn-bg);
color: #374151; color: var(--page-btn-color);
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
@@ -439,19 +440,19 @@
} }
.page-btn:hover:not(:disabled):not(.active) { .page-btn:hover:not(:disabled):not(.active) {
background: #f3f4f6; background: var(--page-btn-hover-bg);
border-color: #9ca3af; border-color: var(--page-btn-hover-border);
} }
.page-btn:disabled { .page-btn:disabled {
opacity: 0.4; opacity: 0.3;
cursor: not-allowed; cursor: not-allowed;
} }
.page-btn.active { .page-btn.active {
background: #3498db; background: var(--page-btn-active-bg);
border-color: #3498db; border-color: var(--page-btn-active-border);
color: #fff; color: var(--page-btn-active-color);
font-weight: 600; font-weight: 600;
} }
@@ -461,7 +462,7 @@
justify-content: center; justify-content: center;
width: 32px; width: 32px;
height: 32px; height: 32px;
color: #9ca3af; color: var(--text-faint);
font-size: 13px; font-size: 13px;
user-select: none; user-select: none;
} }
+13 -13
View File
@@ -18,7 +18,7 @@
flex-direction: column; flex-direction: column;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
background: #ffffff; background: var(--bg-surface);
border-radius: 12px; border-radius: 12px;
box-shadow: box-shadow:
0 4px 24px rgba(0, 0, 0, 0.08), 0 4px 24px rgba(0, 0, 0, 0.08),
@@ -32,7 +32,7 @@
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 20px 24px 16px; padding: 20px 24px 16px;
border-bottom: 1px solid #eef0f3; border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0; flex-shrink: 0;
} }
@@ -40,7 +40,7 @@
margin: 0; margin: 0;
font-size: 20px; font-size: 20px;
font-weight: 600; font-weight: 600;
color: #2c3e50; color: var(--text-primary);
} }
.error-status-badge { .error-status-badge {
@@ -48,8 +48,8 @@
font-weight: 600; font-weight: 600;
padding: 3px 10px; padding: 3px 10px;
border-radius: 20px; border-radius: 20px;
background: #fee2e2; background: var(--status-inactive-bg);
color: #b91c1c; color: var(--status-inactive-color);
letter-spacing: 0.02em; letter-spacing: 0.02em;
} }
@@ -76,7 +76,7 @@
margin: 0; margin: 0;
font-size: 22px; font-size: 22px;
font-weight: 700; font-weight: 700;
color: #1e293b; color: var(--text-primary);
line-height: 1.3; line-height: 1.3;
} }
@@ -84,7 +84,7 @@
.error-message { .error-message {
margin: 0; margin: 0;
font-size: 15px; font-size: 15px;
color: #64748b; color: var(--text-secondary);
line-height: 1.6; line-height: 1.6;
max-width: 420px; max-width: 420px;
} }
@@ -92,7 +92,7 @@
.error-hint { .error-hint {
margin: 0; margin: 0;
font-size: 13px; font-size: 13px;
color: #94a3b8; color: var(--text-muted);
line-height: 1.5; line-height: 1.5;
max-width: 420px; max-width: 420px;
} }
@@ -139,14 +139,14 @@
} }
.btn-secondary { .btn-secondary {
background: #f1f5f9; background: var(--nav-hover-bg);
color: #475569; color: var(--text-secondary);
border: 1px solid #e2e8f0; border: 1px solid var(--border-default);
} }
.btn-secondary:hover { .btn-secondary:hover {
background: #e2e8f0; background: var(--nav-active-bg);
border-color: #cbd5e1; border-color: var(--border-strong);
transform: translateY(-1px); transform: translateY(-1px);
} }
+42 -36
View File
@@ -9,12 +9,12 @@
/* Header */ /* Header */
.header { .header {
height: 60px; height: 60px;
background-color: #ffffff; background: var(--header-bg);
color: #2c3e50; color: var(--header-text);
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0 20px; padding: 0 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: var(--header-shadow);
flex-shrink: 0; flex-shrink: 0;
z-index: 100; z-index: 100;
} }
@@ -30,6 +30,7 @@
font-size: 24px; font-size: 24px;
font-weight: 600; font-weight: 600;
letter-spacing: 1px; letter-spacing: 1px;
color: var(--header-text);
} }
/* Layout Wrapper */ /* Layout Wrapper */
@@ -42,9 +43,9 @@
/* Sidebar */ /* Sidebar */
.sidebar { .sidebar {
width: 72px; width: 72px;
background-color: #ffffff; background-color: var(--bg-surface-alt);
border-right: 1px solid #e0e0e0; border-right: 1px solid var(--border-default);
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.1); box-shadow: none;
flex-shrink: 0; flex-shrink: 0;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
@@ -71,7 +72,7 @@
justify-content: center; justify-content: center;
width: 100%; width: 100%;
padding: 14px 0; padding: 14px 0;
color: #666666; color: var(--text-secondary);
text-decoration: none; text-decoration: none;
transition: all 0.2s ease; transition: all 0.2s ease;
position: relative; position: relative;
@@ -82,28 +83,28 @@
} }
.nav-item:hover { .nav-item:hover {
background-color: #f0f4ff; background-color: var(--nav-hover-bg);
color: #2c3e50; color: var(--text-primary);
} }
.nav-item:active { .nav-item:active {
background-color: #e0eaff; background-color: var(--nav-active-bg);
color: #3498db; color: var(--nav-active-color);
} }
/* Active page indicator */ /* Active page indicator */
.nav-item.active { .nav-item.active {
color: #3498db; color: var(--nav-active-color);
background-color: #eef4ff; background-color: var(--nav-active-bg);
border-left: 3px solid #3498db; border-left: 3px solid var(--nav-active-border);
} }
.nav-item.active .nav-icon { .nav-item.active .nav-icon {
stroke: #3498db; stroke: var(--nav-active-color);
} }
.nav-item.active .nav-label { .nav-item.active .nav-label {
color: #3498db; color: var(--nav-active-color);
font-weight: 600; font-weight: 600;
} }
@@ -134,13 +135,18 @@
overflow: hidden; overflow: hidden;
position: relative; position: relative;
background: background:
linear-gradient(135deg, #3498db, #2980b9) top / 100% 220px no-repeat, linear-gradient(180deg, var(--bg-gradient-from), var(--bg-gradient-to))
#f0f0f0; top / 100% 220px no-repeat,
var(--bg-base);
} }
.accent-bar { .accent-bar {
height: 0; height: var(--accent-bar-height);
background: linear-gradient(135deg, #3498db, #2980b9); background: linear-gradient(
90deg,
var(--accent-bar-from),
var(--accent-bar-to)
);
flex-shrink: 0; flex-shrink: 0;
} }
@@ -170,13 +176,13 @@
.sidebar::-webkit-scrollbar-thumb, .sidebar::-webkit-scrollbar-thumb,
.main-content::-webkit-scrollbar-thumb { .main-content::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2); background: var(--scrollbar-thumb);
border-radius: 3px; border-radius: 3px;
} }
.sidebar::-webkit-scrollbar-thumb:hover, .sidebar::-webkit-scrollbar-thumb:hover,
.main-content::-webkit-scrollbar-thumb:hover { .main-content::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.4); background: var(--scrollbar-thumb-hover);
} }
/* Footer */ /* Footer */
@@ -185,10 +191,10 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 4px 20px; padding: 4px 20px;
background-color: #ffffff; background-color: var(--bg-surface);
border-top: 1px solid #e0e0e0; border-top: 1px solid var(--border-default);
font-size: 12px; font-size: 12px;
color: #999; color: var(--text-faint);
flex-shrink: 0; flex-shrink: 0;
} }
@@ -196,7 +202,7 @@
.nav-divider { .nav-divider {
width: calc(100% - 1.5rem); width: calc(100% - 1.5rem);
border: none; border: none;
border-top: 1px solid #d4dae3; border-top: 1px solid var(--border-default);
margin: 0.5rem auto; margin: 0.5rem auto;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
@@ -215,7 +221,6 @@
.layout-wrapper { .layout-wrapper {
flex-direction: column; flex-direction: column;
/* Account for fixed bottom nav */
padding-bottom: 56px; padding-bottom: 56px;
} }
@@ -227,8 +232,8 @@
width: 100%; width: 100%;
height: 56px; height: 56px;
border-right: none; border-right: none;
border-top: 1px solid #e0e0e0; border-top: 1px solid var(--border-default);
box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.08); box-shadow: var(--header-shadow);
padding: 0; padding: 0;
z-index: 100; z-index: 100;
} }
@@ -248,7 +253,7 @@
.nav-item.active { .nav-item.active {
border-left: none; border-left: none;
border-bottom: 3px solid #3498db; border-bottom: 3px solid var(--nav-active-border);
} }
.nav-icon { .nav-icon {
@@ -267,12 +272,12 @@
.content-area { .content-area {
background: background:
linear-gradient(135deg, #3498db, #2980b9) top / 100% 190px no-repeat, linear-gradient(180deg, var(--bg-gradient-from), var(--bg-gradient-to))
#f0f0f0; top / 100% 190px no-repeat,
var(--bg-base);
} }
.footer { .footer {
/* Hide footer on mobile since bottom nav takes that space */
display: none; display: none;
} }
@@ -280,7 +285,7 @@
width: 1px; width: 1px;
height: calc(100% - 1rem); height: calc(100% - 1rem);
border-top: none; border-top: none;
border-left: 1px solid #d4dae3; border-left: 1px solid var(--border-default);
margin: auto 0; margin: auto 0;
align-self: center; align-self: center;
} }
@@ -304,8 +309,9 @@
.content-area { .content-area {
background: background:
linear-gradient(135deg, #3498db, #2980b9) top / 100% 160px no-repeat, linear-gradient(180deg, var(--bg-gradient-from), var(--bg-gradient-to))
#f0f0f0; top / 100% 160px no-repeat,
var(--bg-base);
} }
.nav-item { .nav-item {