Company listing, authentication, and page error handling are all working
This commit is contained in:
+77
-37
@@ -1,27 +1,19 @@
|
||||
import { api, user } from "$lib";
|
||||
// src/hooks.server.ts
|
||||
import { optima } from "$lib";
|
||||
import { redirect, type Handle } from "@sveltejs/kit";
|
||||
import { access } from "fs";
|
||||
import { a } from "vitest/dist/chunks/suite.d.FvehnV49.js";
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
const accessToken = event.cookies.get("access_token");
|
||||
const refreshToken = event.cookies.get("refresh_token");
|
||||
|
||||
event.locals.session = {
|
||||
accessToken: accessToken || "",
|
||||
refreshToken: refreshToken || "",
|
||||
};
|
||||
const accessToken = event.cookies.get("accessToken") || null;
|
||||
const refreshToken = event.cookies.get("refreshToken") || null;
|
||||
|
||||
if (event.url.pathname === "/logout") {
|
||||
event.cookies.delete("access_token", { path: "/" });
|
||||
event.cookies.delete("refresh_token", { path: "/" });
|
||||
event.cookies.delete("accessToken", { path: "/" });
|
||||
event.cookies.delete("refreshToken", { path: "/" });
|
||||
|
||||
redirect(303, "/login");
|
||||
|
||||
return resolve(event);
|
||||
return redirect(303, "/login");
|
||||
}
|
||||
|
||||
if (event.url.pathname.startsWith("/login") && user.isLoggedIn()) {
|
||||
if (event.url.pathname.startsWith("/login") && optima.user.isLoggedIn()) {
|
||||
return redirect(303, "/");
|
||||
}
|
||||
|
||||
@@ -29,31 +21,79 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
return await resolve(event);
|
||||
}
|
||||
|
||||
if (!accessToken || !refreshToken) {
|
||||
user.logout(event);
|
||||
return resolve(event);
|
||||
if (!accessToken && !refreshToken) {
|
||||
optima.user.logout(event);
|
||||
redirect(303, "/login");
|
||||
}
|
||||
|
||||
try {
|
||||
if (accessToken && refreshToken) {
|
||||
const newSession = await user.refreshSession(refreshToken);
|
||||
// Check if the access token is expired or near expiry and refresh if needed
|
||||
let currentAccessToken = accessToken;
|
||||
let currentRefreshToken = refreshToken;
|
||||
|
||||
console.log(newSession);
|
||||
if (currentAccessToken) {
|
||||
try {
|
||||
const [, payload] = currentAccessToken.split(".");
|
||||
const decoded = JSON.parse(
|
||||
Buffer.from(payload, "base64url").toString("utf8"),
|
||||
);
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const thresholdSec = 60; // refresh if < 60s remaining
|
||||
|
||||
event.cookies.set("access_token", newSession.accessToken, {
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
});
|
||||
event.cookies.set("refresh_token", newSession.refreshToken, {
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
});
|
||||
if (!decoded?.exp || decoded.exp - nowSec < thresholdSec) {
|
||||
// Token is expired or about to expire — try to refresh
|
||||
if (currentRefreshToken) {
|
||||
const refreshed =
|
||||
await optima.user.refreshSession(currentRefreshToken);
|
||||
currentAccessToken = refreshed.accessToken;
|
||||
currentRefreshToken = refreshed.refreshToken ?? currentRefreshToken;
|
||||
} else {
|
||||
// No refresh token available, force re-login
|
||||
optima.user.logout(event);
|
||||
return redirect(303, "/login");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Token is malformed or refresh failed — try refresh as fallback
|
||||
if (currentRefreshToken) {
|
||||
try {
|
||||
const refreshed =
|
||||
await optima.user.refreshSession(currentRefreshToken);
|
||||
currentAccessToken = refreshed.accessToken;
|
||||
currentRefreshToken = refreshed.refreshToken ?? currentRefreshToken;
|
||||
} catch {
|
||||
// Refresh also failed, force re-login
|
||||
optima.user.logout(event);
|
||||
return redirect(303, "/login");
|
||||
}
|
||||
} else {
|
||||
optima.user.logout(event);
|
||||
return redirect(303, "/login");
|
||||
}
|
||||
}
|
||||
} else if (currentRefreshToken) {
|
||||
// No access token but have a refresh token — try to get a new one
|
||||
try {
|
||||
const refreshed = await optima.user.refreshSession(currentRefreshToken);
|
||||
currentAccessToken = refreshed.accessToken;
|
||||
currentRefreshToken = refreshed.refreshToken ?? currentRefreshToken;
|
||||
} catch {
|
||||
optima.user.logout(event);
|
||||
return redirect(303, "/login");
|
||||
}
|
||||
} catch (err) {
|
||||
console.trace(err);
|
||||
|
||||
user.logout(event);
|
||||
} finally {
|
||||
return await resolve(event);
|
||||
}
|
||||
|
||||
const setTokens = async (accessToken: string, refreshToken: string) => {
|
||||
event.cookies.set("accessToken", accessToken, { path: "/" });
|
||||
event.cookies.set("refreshToken", refreshToken, { path: "/" });
|
||||
|
||||
event.locals.session = { accessToken, refreshToken, set: setTokens };
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
// Persist any refreshed tokens into cookies
|
||||
await setTokens(currentAccessToken!, currentRefreshToken!);
|
||||
|
||||
const response = await resolve(event);
|
||||
return response;
|
||||
};
|
||||
|
||||
@@ -9,8 +9,13 @@ export const company = {
|
||||
});
|
||||
return company.data;
|
||||
},
|
||||
async fetchMany(accessToken: string, page: number = 1, search?: string) {
|
||||
const params: Record<string, unknown> = { page };
|
||||
async fetchMany(
|
||||
accessToken: string,
|
||||
page: number = 1,
|
||||
search?: string,
|
||||
rpp: number = 30,
|
||||
) {
|
||||
const params: Record<string, unknown> = { page, rpp };
|
||||
if (search && search.length > 0) params.search = search;
|
||||
|
||||
const companies = await api.get("/v1/company/companies", {
|
||||
@@ -19,6 +24,7 @@ export const company = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
return companies.data;
|
||||
},
|
||||
async fetchConfigurations(accessToken: string, id: string) {
|
||||
|
||||
@@ -2,12 +2,13 @@ import { getRequestEvent } from "$app/server";
|
||||
import { PUBLIC_API_URL } from "$env/static/public";
|
||||
import { redirect, RequestEvent } from "@sveltejs/kit";
|
||||
import axios from "axios";
|
||||
import api from "../axios";
|
||||
import { io } from "socket.io-client";
|
||||
|
||||
export const user = {
|
||||
isLoggedIn(): boolean {
|
||||
const event = getRequestEvent();
|
||||
const authToken = event.cookies.get("authToken");
|
||||
const authToken = event.cookies.get("accessToken");
|
||||
return !!authToken;
|
||||
},
|
||||
|
||||
@@ -28,18 +29,39 @@ export const user = {
|
||||
return refreshedTokens;
|
||||
},
|
||||
|
||||
fetchInfo() {},
|
||||
async fetchInfo(accessToken: string) {
|
||||
const response = await api.get("/v1/user/@me", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
logout(event: RequestEvent) {
|
||||
if (!event) return;
|
||||
|
||||
// Clear authentication cookies
|
||||
event.cookies.delete("authToken", { path: "/" });
|
||||
event.cookies.delete("accessToken", { path: "/" });
|
||||
event.cookies.delete("refreshToken", { path: "/" });
|
||||
|
||||
return redirect(303, "/login");
|
||||
},
|
||||
|
||||
async checkPermissions(accessToken: string, permissions: string[]) {
|
||||
const response = await api.post(
|
||||
"/v1/user/@me/check-permission",
|
||||
{ permissions },
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* @todo Get communication with server working and setup a key system so that the frontend can listen for a specific key from the backend so that nobody can poach off of login events.
|
||||
*
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const ssr = true;
|
||||
@@ -0,0 +1 @@
|
||||
<slot />
|
||||
@@ -9,11 +9,11 @@ export const actions: Actions = {
|
||||
data.get("callbackKey") as string,
|
||||
);
|
||||
|
||||
event.cookies.set("access_token", tokens.accessToken, {
|
||||
event.cookies.set("accessToken", tokens.accessToken, {
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
});
|
||||
event.cookies.set("refresh_token", tokens.refreshToken, {
|
||||
event.cookies.set("refreshToken", tokens.refreshToken, {
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
});
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
import { optima } from "$lib";
|
||||
import { PUBLIC_API_URL } from "$env/static/public";
|
||||
import { enhance } from "$app/forms";
|
||||
import { goto } from "$app/navigation";
|
||||
import LoadingSpinner from "../../../components/LoadingSpinner.svelte";
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
const uriData = await fetchAuthRedirectUri(PUBLIC_API_URL);
|
||||
const uriData = await optima.auth.fetchAuthRedirectUri(PUBLIC_API_URL);
|
||||
let loading = writable(false);
|
||||
|
||||
function handleSubmit(e: SubmitEvent) {
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
// src/routes/(secure)/+layout.server.ts
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
import type { LayoutServerLoad } from "./$types";
|
||||
|
||||
type Session = {
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
};
|
||||
|
||||
type JwtPayload = {
|
||||
exp?: number; // seconds since epoch
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
const parseJwt = (token: string): JwtPayload | null => {
|
||||
try {
|
||||
const [, payload] = token.split(".");
|
||||
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const load: LayoutServerLoad = async (event) => {
|
||||
const { locals, url } = event;
|
||||
const session = locals.session as Session | undefined;
|
||||
|
||||
const accessToken = session?.accessToken ?? null;
|
||||
const refreshToken = session?.refreshToken ?? null;
|
||||
|
||||
if (!accessToken && !refreshToken) {
|
||||
throw redirect(302, `/login?redirectTo=${url.pathname}`);
|
||||
}
|
||||
|
||||
// Decide if access token is expired/near expiry from its exp claim
|
||||
let needsRefresh = false;
|
||||
|
||||
if (accessToken) {
|
||||
const payload = parseJwt(accessToken);
|
||||
if (!payload?.exp) {
|
||||
needsRefresh = true; // malformed or no exp, play safe
|
||||
} else {
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const thresholdSec = 60; // refresh if < 60s remaining
|
||||
if (payload.exp - nowSec < thresholdSec) {
|
||||
needsRefresh = true;
|
||||
}
|
||||
}
|
||||
} else if (refreshToken) {
|
||||
// No access token but we have a refresh token
|
||||
needsRefresh = true;
|
||||
}
|
||||
|
||||
if (needsRefresh && refreshToken) {
|
||||
const refreshed = await refreshTokens(refreshToken);
|
||||
|
||||
if (!refreshed) {
|
||||
throw redirect(302, `/login?redirectTo=${url.pathname}`);
|
||||
}
|
||||
|
||||
await locals.session!.set(
|
||||
refreshed.accessToken,
|
||||
refreshed.refreshToken ?? refreshToken,
|
||||
);
|
||||
}
|
||||
|
||||
// Expose only what secure pages need; they can decode again or call backend
|
||||
return {
|
||||
accessToken: locals.session!.accessToken,
|
||||
};
|
||||
};
|
||||
|
||||
// Your backend-specific implementation
|
||||
async function refreshTokens(refreshToken: string): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
} | null> {
|
||||
// e.g. call /auth/refresh and return new tokens
|
||||
return null;
|
||||
}
|
||||
+69
-152
@@ -1,9 +1,10 @@
|
||||
<script>
|
||||
import { page } from "$app/stores";
|
||||
import { goto } from "$app/navigation";
|
||||
import "../styles/errorpage.css";
|
||||
|
||||
function signOut() {
|
||||
goto("/logout");
|
||||
}
|
||||
$: status = $page.status || 500;
|
||||
$: message = $page.error?.message || "Something went wrong";
|
||||
|
||||
function goBack() {
|
||||
history.back();
|
||||
@@ -11,159 +12,75 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Error — App</title>
|
||||
<title>Error {status} — Project Optima</title>
|
||||
</svelte:head>
|
||||
|
||||
<header class="header container">
|
||||
<h1>Error</h1>
|
||||
<nav>
|
||||
<a href="/">Home</a>
|
||||
<button on:click={signOut}>Sign out</button>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="error-page">
|
||||
<div class="error-pane">
|
||||
<!-- Pane header -->
|
||||
<div class="error-pane-header">
|
||||
<h2 class="error-pane-title">Error</h2>
|
||||
<span class="error-status-badge">{status}</span>
|
||||
</div>
|
||||
|
||||
<main class="container">
|
||||
<section class="error-section">
|
||||
<div class="error-box">
|
||||
<h2>Oops! Something went wrong</h2>
|
||||
<p class="error-message">
|
||||
We encountered an error while processing your request. Please try again
|
||||
or contact support if the problem persists.
|
||||
<!-- Pane body -->
|
||||
<div class="error-pane-body">
|
||||
<div class="error-illustration">
|
||||
<svg viewBox="0 0 120 120" width="120" height="120" aria-hidden="true">
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="56"
|
||||
fill="#fef2f2"
|
||||
stroke="#fecaca"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<circle cx="60" cy="60" r="40" fill="#fee2e2" />
|
||||
<path
|
||||
d="M60 35v30"
|
||||
stroke="#dc2626"
|
||||
stroke-width="5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<circle cx="60" cy="78" r="4" fill="#dc2626" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 class="error-heading">Oops! Something went wrong</h3>
|
||||
<p class="error-message">{message}</p>
|
||||
<p class="error-hint">
|
||||
Please try again or contact support if the problem persists.
|
||||
</p>
|
||||
|
||||
<div class="error-actions">
|
||||
<button class="btn btn-primary" on:click={goBack}>Go Back</button>
|
||||
<a href="/" class="btn btn-secondary">Go Home</a>
|
||||
<button class="btn btn-primary" on:click={goBack}>
|
||||
<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>
|
||||
Go Back
|
||||
</button>
|
||||
<a href="/" class="btn btn-secondary">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
<polyline points="9 22 9 12 15 12 15 22" />
|
||||
</svg>
|
||||
Go Home
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="container">
|
||||
<small>© {new Date().getFullYear()} Your App</small>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"Helvetica Neue",
|
||||
Arial;
|
||||
background: #f7f7f8;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
nav a,
|
||||
nav button {
|
||||
padding: 0.5rem 1rem;
|
||||
text-decoration: none;
|
||||
color: #0066cc;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
nav a:hover,
|
||||
nav button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.error-section {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.error-box {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
max-width: 500px;
|
||||
text-align: center;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.error-box h2 {
|
||||
margin: 0 0 1rem;
|
||||
color: #d32f2f;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #666;
|
||||
margin: 1rem 0 2rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #0066cc;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #0052a3;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #e5e7eb;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #d1d5db;
|
||||
}
|
||||
|
||||
footer {
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1 +1,24 @@
|
||||
// Layout server code removed for fresh start
|
||||
import { optima } from "$lib";
|
||||
import type { LayoutServerLoad } from "./$types";
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
const accessToken = locals.session?.accessToken ?? null;
|
||||
|
||||
// Only check permissions if the user is authenticated
|
||||
if (!accessToken) {
|
||||
return { canViewAdmin: false };
|
||||
}
|
||||
|
||||
let canViewAdmin = false;
|
||||
try {
|
||||
const permResult = await optima.user.checkPermissions(accessToken, [
|
||||
"ui.navigation.admin.view",
|
||||
]);
|
||||
canViewAdmin = permResult?.data?.results?.[0]?.hasPermission === true;
|
||||
} catch (err) {
|
||||
console.error("Admin permission check failed:", err);
|
||||
canViewAdmin = false;
|
||||
}
|
||||
|
||||
return { canViewAdmin };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<script lang="ts">
|
||||
import { optima } from "$lib";
|
||||
import { page } from "$app/stores";
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
href: "/",
|
||||
label: "Home",
|
||||
icon: '<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline>',
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
href: "/companies",
|
||||
label: "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>',
|
||||
},
|
||||
];
|
||||
|
||||
const adminNavItem = {
|
||||
href: "/admin",
|
||||
label: "Admin",
|
||||
icon: '<path d="M12 15v2m-6 4h12a2 2 0 0 0 2-2v-6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2zm10-10V7a4 4 0 0 0-8 0v4h8z"></path>',
|
||||
};
|
||||
|
||||
$: canViewAdmin = $page.data?.canViewAdmin === true;
|
||||
|
||||
function isActive(pathname: string, item: (typeof navItems)[0]) {
|
||||
return item.exact ? pathname === item.href : pathname.startsWith(item.href);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $page.route.id?.startsWith("/(auth)")}
|
||||
<slot />
|
||||
{:else}
|
||||
<div class="layout-container">
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<h1>Project Optima</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="layout-wrapper">
|
||||
<aside class="sidebar">
|
||||
<nav class="sidebar-nav">
|
||||
{#each navItems as item}
|
||||
<a
|
||||
href={item.href}
|
||||
class="nav-item {isActive($page.url.pathname, item)
|
||||
? 'active'
|
||||
: ''}"
|
||||
title={item.label}
|
||||
>
|
||||
<svg
|
||||
class="nav-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
{@html item.icon}
|
||||
</svg>
|
||||
<span class="nav-label">{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
{#if canViewAdmin}
|
||||
<hr class="nav-divider" />
|
||||
<a
|
||||
href={adminNavItem.href}
|
||||
class="nav-item {$page.url.pathname.startsWith('/admin')
|
||||
? 'active'
|
||||
: ''}"
|
||||
title={adminNavItem.label}
|
||||
>
|
||||
<svg
|
||||
class="nav-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
{@html adminNavItem.icon}
|
||||
</svg>
|
||||
<span class="nav-label">{adminNavItem.label}</span>
|
||||
</a>
|
||||
{/if}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div class="content-area">
|
||||
<div class="accent-bar"></div>
|
||||
<main class="main-content">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="footer">
|
||||
<small>© {new Date().getFullYear()} Total Tech Solutions, LLC</small>
|
||||
</footer>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1 +1,3 @@
|
||||
import "..styles/app.css";
|
||||
import "../styles/app.css";
|
||||
import "../styles/layout.css";
|
||||
import "../styles/errorpage.css";
|
||||
|
||||
@@ -7,6 +7,5 @@
|
||||
</svelte:head>
|
||||
|
||||
<main>
|
||||
<h1>Welcome</h1>
|
||||
<p>Your new landing page. Ready to build.</p>
|
||||
<h1>Home Page</h1>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { optima } from "$lib";
|
||||
import { handleApiError } from "$lib/optima-api/errorHandler";
|
||||
import type { PageServerLoad } from "./$types";
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||
const accessToken = locals.session?.accessToken;
|
||||
if (!accessToken) {
|
||||
return {
|
||||
companies: [],
|
||||
totalPages: 1,
|
||||
currentPage: 1,
|
||||
totalRecords: 0,
|
||||
search: "",
|
||||
};
|
||||
}
|
||||
|
||||
const page = Math.max(1, Number(url.searchParams.get("page")) || 1);
|
||||
const search = url.searchParams.get("search") || "";
|
||||
|
||||
try {
|
||||
const result = await optima.company.fetchMany(accessToken, page, search);
|
||||
|
||||
return {
|
||||
companies: result?.data ?? [],
|
||||
totalPages: result?.meta?.pagination?.totalPages ?? 1,
|
||||
currentPage: result?.meta?.pagination?.currentPage ?? page,
|
||||
totalRecords:
|
||||
result?.meta?.pagination?.totalRecords ?? result?.data?.length ?? 0,
|
||||
search,
|
||||
};
|
||||
} catch (err) {
|
||||
handleApiError(err);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,355 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { afterNavigate } from "$app/navigation";
|
||||
import NoResultsMonkey from "../../components/NoResultsMonkey.svelte";
|
||||
import "../../styles/companies/companylist.css";
|
||||
|
||||
export let data: {
|
||||
companies: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
status?: string;
|
||||
type?: string;
|
||||
createdAt?: string;
|
||||
identifier?: string;
|
||||
contactEmail?: string;
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
totalPages: number;
|
||||
currentPage: number;
|
||||
totalRecords: number;
|
||||
search: string;
|
||||
};
|
||||
|
||||
let searchInput = data.search;
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
let isSearching = false;
|
||||
let searchInputEl: HTMLInputElement;
|
||||
|
||||
// When navigation completes (results loaded), clear loading & refocus
|
||||
afterNavigate(() => {
|
||||
isSearching = false;
|
||||
if (searchInputEl && document.activeElement !== searchInputEl) {
|
||||
// Use tick to ensure DOM is settled
|
||||
requestAnimationFrame(() => searchInputEl?.focus());
|
||||
}
|
||||
});
|
||||
|
||||
$: currentPage = data.currentPage;
|
||||
$: totalPages = data.totalPages;
|
||||
$: totalRecords = data.totalRecords;
|
||||
$: companies = data.companies;
|
||||
|
||||
function navigateToPage(p: number) {
|
||||
const params = new URLSearchParams();
|
||||
params.set("page", String(p));
|
||||
if (searchInput) params.set("search", searchInput);
|
||||
goto(`/companies?${params.toString()}`);
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
isSearching = true;
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("page", "1");
|
||||
if (searchInput) params.set("search", searchInput);
|
||||
goto(`/companies?${params.toString()}`);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter") {
|
||||
isSearching = true;
|
||||
clearTimeout(debounceTimer);
|
||||
const params = new URLSearchParams();
|
||||
params.set("page", "1");
|
||||
if (searchInput) params.set("search", searchInput);
|
||||
goto(`/companies?${params.toString()}`);
|
||||
}
|
||||
}
|
||||
|
||||
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 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 companyInitials(name: string): string {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.slice(0, 2)
|
||||
.map((w) => w[0])
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
// Generate visible page numbers with ellipsis
|
||||
function getPageNumbers(current: number, total: number): (number | "...")[] {
|
||||
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
|
||||
|
||||
const pages: (number | "...")[] = [];
|
||||
pages.push(1);
|
||||
|
||||
if (current > 3) pages.push("...");
|
||||
|
||||
const start = Math.max(2, current - 1);
|
||||
const end = Math.min(total - 1, current + 1);
|
||||
|
||||
for (let i = start; i <= end; i++) pages.push(i);
|
||||
|
||||
if (current < total - 2) pages.push("...");
|
||||
|
||||
pages.push(total);
|
||||
return pages;
|
||||
}
|
||||
|
||||
$: pageNumbers = getPageNumbers(currentPage, totalPages);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Companies — Project Optima</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="companies-page">
|
||||
<div class="companies-pane">
|
||||
<!-- Pane header -->
|
||||
<div class="pane-header">
|
||||
<div class="pane-header-left">
|
||||
<h2 class="page-title">Companies</h2>
|
||||
{#if totalRecords > 0}
|
||||
<span class="result-count"
|
||||
>{totalRecords} record{totalRecords === 1 ? "" : "s"}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="search-bar">
|
||||
<svg
|
||||
class="search-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="M21 21l-4.35-4.35" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search companies…"
|
||||
bind:this={searchInputEl}
|
||||
bind:value={searchInput}
|
||||
on:input={handleSearch}
|
||||
on:keydown={handleKeydown}
|
||||
/>
|
||||
{#if searchInput}
|
||||
<button
|
||||
class="search-clear"
|
||||
on:click={() => {
|
||||
searchInput = "";
|
||||
handleSearch();
|
||||
}}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="14"
|
||||
height="14"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pane body -->
|
||||
<div class="pane-body">
|
||||
{#if isSearching}
|
||||
<div class="search-loading-overlay">
|
||||
<div class="search-spinner"></div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if companies.length === 0}
|
||||
<div class="empty-state">
|
||||
<NoResultsMonkey
|
||||
message={searchInput
|
||||
? "No companies match your search"
|
||||
: "No companies found"}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card-grid">
|
||||
{#each companies as company (company.id)}
|
||||
<button
|
||||
class="company-card"
|
||||
on:click={() => goto(`/companies/${company.id}`)}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === "Enter") goto(`/companies/${company.id}`);
|
||||
}}
|
||||
>
|
||||
<!-- Card header: avatar + status -->
|
||||
<div class="card-top">
|
||||
<div class="card-avatar">
|
||||
<span class="avatar-initials"
|
||||
>{companyInitials(company.name)}</span
|
||||
>
|
||||
</div>
|
||||
<span
|
||||
class="status-dot {statusClass(company.status)}"
|
||||
title={company.status || "Unknown"}
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<!-- Card body -->
|
||||
<div class="card-body">
|
||||
<h3 class="card-name">{company.name}</h3>
|
||||
{#if company.contactEmail}
|
||||
<span class="card-email">{company.contactEmail}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Card meta -->
|
||||
<div class="card-meta">
|
||||
{#if company.type}
|
||||
<div class="meta-item">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
class="meta-icon"
|
||||
>
|
||||
<rect x="2" y="7" width="20" height="14" rx="2" ry="2" />
|
||||
<path d="M16 3h-8l-2 4h12z" />
|
||||
</svg>
|
||||
<span>{company.type}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if company.identifier || company.id}
|
||||
<div class="meta-item">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
class="meta-icon"
|
||||
>
|
||||
<path d="M4 9h16M4 15h16M10 3L8 21M16 3l-2 18" />
|
||||
</svg>
|
||||
<span class="mono"
|
||||
>{company.identifier || company.id?.slice(0, 8)}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Card footer -->
|
||||
<div class="card-footer">
|
||||
{#if company.status}
|
||||
<span class="status-label {statusClass(company.status)}"
|
||||
>{company.status}</span
|
||||
>
|
||||
{/if}
|
||||
{#if formatDate(company.createdAt)}
|
||||
<span class="card-date">{formatDate(company.createdAt)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Hover arrow -->
|
||||
<svg
|
||||
class="card-arrow"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Pane footer / Pagination -->
|
||||
{#if totalPages > 1}
|
||||
<div class="pane-footer">
|
||||
<span class="page-info">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<nav class="pagination" aria-label="Pagination">
|
||||
<button
|
||||
class="page-btn"
|
||||
disabled={currentPage <= 1}
|
||||
on:click={() => navigateToPage(currentPage - 1)}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#each pageNumbers as p}
|
||||
{#if p === "..."}
|
||||
<span class="page-ellipsis">…</span>
|
||||
{:else}
|
||||
<button
|
||||
class="page-btn"
|
||||
class:active={p === currentPage}
|
||||
on:click={() => navigateToPage(p)}
|
||||
aria-current={p === currentPage ? "page" : undefined}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<button
|
||||
class="page-btn"
|
||||
disabled={currentPage >= totalPages}
|
||||
on:click={() => navigateToPage(currentPage + 1)}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,21 +0,0 @@
|
||||
// src/hooks.server.ts
|
||||
import type { Handle } from "@sveltejs/kit";
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
const accessToken = event.cookies.get("acceessToken") || null;
|
||||
const refreshToken = event.cookies.get("refreshToken") || null;
|
||||
|
||||
const setTokens = async (accessToken: string, refreshToken: string) => {
|
||||
event.cookies.set("accessToken", accessToken, {} as any);
|
||||
event.cookies.set("refreshToken", refreshToken, {} as any);
|
||||
|
||||
event.locals.session = { accessToken, refreshToken, set: setTokens };
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
event.locals.session = { accessToken, refreshToken, set: setTokens };
|
||||
|
||||
const response = await resolve(event);
|
||||
return response;
|
||||
};
|
||||
@@ -1,3 +1,11 @@
|
||||
@import "tailwindcss";
|
||||
@plugin '@tailwindcss/forms';
|
||||
@plugin '@tailwindcss/typography';
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,615 @@
|
||||
/* ═══════════════════════════════════════════════════
|
||||
Companies Page — Pane + Card Grid Layout
|
||||
═══════════════════════════════════════════════════ */
|
||||
|
||||
/* Page container */
|
||||
.companies-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ── Pane container ── */
|
||||
.companies-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
box-shadow:
|
||||
0 4px 24px rgba(0, 0, 0, 0.08),
|
||||
0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Pane header ── */
|
||||
.pane-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid #eef0f3;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pane-header-left {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.result-count {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #8492a6;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
position: relative;
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.search-bar input {
|
||||
width: 100%;
|
||||
padding: 9px 34px 9px 38px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
background: #f9fafb;
|
||||
color: #374151;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.search-bar input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.search-bar input:focus {
|
||||
border-color: #3498db;
|
||||
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.12);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #9ca3af;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-clear {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition:
|
||||
color 0.15s,
|
||||
background 0.15s;
|
||||
}
|
||||
|
||||
.search-clear:hover {
|
||||
color: #374151;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
/* ── Pane body ── */
|
||||
.pane-body {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
/* Search loading overlay */
|
||||
.search-loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.search-spinner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: 4px solid #eef0f3;
|
||||
border-top-color: #3498db;
|
||||
animation: search-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes search-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Empty state ── */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
padding: 48px 16px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
Card Grid
|
||||
═══════════════════════════════════════════════════ */
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* ── Individual Card ── */
|
||||
.company-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
background: #f8f9fb;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #eef0f3;
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.18s,
|
||||
box-shadow 0.18s,
|
||||
border-color 0.18s,
|
||||
background 0.18s;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.company-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.08),
|
||||
0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
border-color: #d0d9e8;
|
||||
}
|
||||
|
||||
.company-card:active {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.company-card:focus-visible {
|
||||
outline: 2px solid #3498db;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Card top: avatar + status dot */
|
||||
.card-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.card-avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #3498db, #2980b9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar-initials {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
letter-spacing: 0.5px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Status dot */
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.status-dot.active {
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
|
||||
.status-dot.inactive {
|
||||
background: #ef4444;
|
||||
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
|
||||
.status-dot.pending {
|
||||
background: #f59e0b;
|
||||
box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.15);
|
||||
}
|
||||
|
||||
.status-dot.neutral {
|
||||
background: #d1d5db;
|
||||
box-shadow: 0 0 0 3px rgba(209, 213, 219, 0.3);
|
||||
}
|
||||
|
||||
/* Card body */
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.card-email {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Card meta */
|
||||
.card-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.meta-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.meta-item .mono {
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* Card footer */
|
||||
.card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
text-transform: capitalize;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.status-label.active {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.status-label.inactive {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.status-label.pending {
|
||||
background: #fef3c7;
|
||||
color: #a16207;
|
||||
}
|
||||
|
||||
.status-label.neutral {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.card-date {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Hover arrow */
|
||||
.card-arrow {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 16px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #cbd5e1;
|
||||
opacity: 0;
|
||||
transform: translateX(-4px);
|
||||
transition:
|
||||
opacity 0.18s,
|
||||
transform 0.18s;
|
||||
}
|
||||
|
||||
.company-card:hover .card-arrow {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
Pagination Footer
|
||||
═══════════════════════════════════════════════════ */
|
||||
|
||||
.pane-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 24px;
|
||||
border-top: 1px solid #eef0f3;
|
||||
background: #f8f9fb;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 13px;
|
||||
color: #8492a6;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 0 6px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #374151;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.page-btn:hover:not(:disabled):not(.active) {
|
||||
background: #f3f4f6;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.page-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.page-btn.active {
|
||||
background: #3498db;
|
||||
border-color: #3498db;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-ellipsis {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: #9ca3af;
|
||||
font-size: 13px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
Responsive
|
||||
═══════════════════════════════════════════════════ */
|
||||
|
||||
/* Tablets — 2 columns */
|
||||
@media (max-width: 1024px) {
|
||||
.card-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile — single column */
|
||||
@media (max-width: 768px) {
|
||||
.companies-pane {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.pane-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
padding: 16px 16px 12px;
|
||||
}
|
||||
|
||||
.pane-header-left {
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-bar input {
|
||||
font-size: 16px; /* prevents iOS zoom */
|
||||
padding: 11px 34px 11px 40px;
|
||||
}
|
||||
|
||||
.pane-body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.company-card {
|
||||
padding: 14px;
|
||||
border-radius: 10px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.avatar-initials {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.card-arrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pane-footer {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Small phones */
|
||||
@media (max-width: 480px) {
|
||||
.companies-pane {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.pane-header {
|
||||
padding: 12px 12px 10px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.pane-body {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.company-card {
|
||||
padding: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.card-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.avatar-initials {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.card-email {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 10px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.card-date {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.pane-footer {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
/* ═══════════════════════════════════════════════════
|
||||
Error Page — Pane Layout
|
||||
═══════════════════════════════════════════════════ */
|
||||
|
||||
/* Page container */
|
||||
.error-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ── Pane container ── */
|
||||
.error-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
box-shadow:
|
||||
0 4px 24px rgba(0, 0, 0, 0.08),
|
||||
0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Pane header ── */
|
||||
.error-pane-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid #eef0f3;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.error-pane-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.error-status-badge {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* ── Pane body ── */
|
||||
.error-pane-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
gap: 16px;
|
||||
text-align: center;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Illustration */
|
||||
.error-illustration {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Heading */
|
||||
.error-heading {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Message */
|
||||
.error-message {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
color: #64748b;
|
||||
line-height: 1.6;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.error-hint {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
line-height: 1.5;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.error-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: all 0.18s;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3498db;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2980b9;
|
||||
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.25);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 6px rgba(52, 152, 219, 0.2);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #e2e8f0;
|
||||
border-color: #cbd5e1;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-secondary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
Responsive
|
||||
═══════════════════════════════════════════════════ */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.error-pane {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.error-pane-header {
|
||||
padding: 16px 16px 12px;
|
||||
}
|
||||
|
||||
.error-pane-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.error-pane-body {
|
||||
padding: 32px 16px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.error-heading {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 18px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.error-pane {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.error-pane-header {
|
||||
padding: 12px 12px 10px;
|
||||
}
|
||||
|
||||
.error-pane-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.error-pane-body {
|
||||
padding: 24px 12px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.error-illustration svg {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
}
|
||||
|
||||
.error-heading {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn {
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
/* Layout Container */
|
||||
.layout-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
height: 60px;
|
||||
background-color: #ffffff;
|
||||
color: #2c3e50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
flex-shrink: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-content h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* Layout Wrapper */
|
||||
.layout-wrapper {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
width: 72px;
|
||||
background-color: #ffffff;
|
||||
border-right: 1px solid #e0e0e0;
|
||||
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.1);
|
||||
flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
z-index: 99;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Navigation Items */
|
||||
.nav-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 14px 0;
|
||||
color: #666666;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
gap: 4px;
|
||||
box-sizing: border-box;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background-color: #f0f4ff;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.nav-item:active {
|
||||
background-color: #e0eaff;
|
||||
color: #3498db;
|
||||
}
|
||||
|
||||
/* Active page indicator */
|
||||
.nav-item.active {
|
||||
color: #3498db;
|
||||
background-color: #eef4ff;
|
||||
border-left: 3px solid #3498db;
|
||||
}
|
||||
|
||||
.nav-item.active .nav-icon {
|
||||
stroke: #3498db;
|
||||
}
|
||||
|
||||
.nav-item.active .nav-label {
|
||||
color: #3498db;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Navigation Icon */
|
||||
.nav-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
stroke: currentColor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Navigation Label */
|
||||
.nav-label {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 60px;
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.content-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background:
|
||||
linear-gradient(135deg, #3498db, #2980b9) top / 100% 220px no-repeat,
|
||||
#f0f0f0;
|
||||
}
|
||||
|
||||
.accent-bar {
|
||||
height: 0;
|
||||
background: linear-gradient(135deg, #3498db, #2980b9);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
background-color: transparent;
|
||||
padding: 20px;
|
||||
padding-top: 80px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Scrollbar Styling */
|
||||
.sidebar::-webkit-scrollbar,
|
||||
.main-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.sidebar::-webkit-scrollbar-track,
|
||||
.main-content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sidebar::-webkit-scrollbar-thumb,
|
||||
.main-content::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.sidebar::-webkit-scrollbar-thumb:hover,
|
||||
.main-content::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 20px;
|
||||
background-color: #ffffff;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Nav Divider */
|
||||
.nav-divider {
|
||||
width: calc(100% - 1.5rem);
|
||||
border: none;
|
||||
border-top: 1px solid #d4dae3;
|
||||
margin: 0.5rem auto;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
height: 48px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.header-content h1 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.layout-wrapper {
|
||||
flex-direction: column;
|
||||
/* Account for fixed bottom nav */
|
||||
padding-bottom: 56px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
border-right: none;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.08);
|
||||
padding: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding: 6px 0;
|
||||
min-width: 64px;
|
||||
border-left: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
border-left: none;
|
||||
border-bottom: 3px solid #3498db;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 12px;
|
||||
padding-top: 60px;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
background:
|
||||
linear-gradient(135deg, #3498db, #2980b9) top / 100% 190px no-repeat,
|
||||
#f0f0f0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
/* Hide footer on mobile since bottom nav takes that space */
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-divider {
|
||||
width: 1px;
|
||||
height: calc(100% - 1rem);
|
||||
border-top: none;
|
||||
border-left: 1px solid #d4dae3;
|
||||
margin: auto 0;
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Small phones */
|
||||
@media (max-width: 480px) {
|
||||
.header {
|
||||
height: 44px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.header-content h1 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 8px;
|
||||
padding-top: 40px;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
background:
|
||||
linear-gradient(135deg, #3498db, #2980b9) top / 100% 160px no-repeat,
|
||||
#f0f0f0;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
min-width: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Safe area insets for notched devices */
|
||||
@supports (padding-bottom: env(safe-area-inset-bottom)) {
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
height: calc(56px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.layout-wrapper {
|
||||
padding-bottom: calc(56px + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { optima } from "$lib";
|
||||
import { redirect, type Handle } from "@sveltejs/kit";
|
||||
import { access } from "fs";
|
||||
import { a } from "vitest/dist/chunks/suite.d.FvehnV49.js";
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
const accessToken = event.cookies.get("access_token");
|
||||
const refreshToken = event.cookies.get("refresh_token");
|
||||
|
||||
event.locals.ession = {
|
||||
accessToken: accessToken || "",
|
||||
refreshToken: refreshToken || "",
|
||||
};
|
||||
|
||||
if (event.url.pathname === "/logout") {
|
||||
event.cookies.delete("access_token", { path: "/" });
|
||||
event.cookies.delete("refresh_token", { path: "/" });
|
||||
|
||||
redirect(303, "/login");
|
||||
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
if (event.url.pathname.startsWith("/login") && optima.user.isLoggedIn()) {
|
||||
return redirect(303, "/");
|
||||
}
|
||||
|
||||
if (event.url.pathname.startsWith("/login")) {
|
||||
return await resolve(event);
|
||||
}
|
||||
|
||||
if (!accessToken || !refreshToken) {
|
||||
optima.user.logout(event);
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
try {
|
||||
if (accessToken && refreshToken) {
|
||||
const newSession = await optima.user.refreshSession(refreshToken);
|
||||
|
||||
console.log(newSession);
|
||||
|
||||
event.cookies.set("access_token", newSession.accessToken, {
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
});
|
||||
event.cookies.set("refresh_token", newSession.refreshToken, {
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.trace(err);
|
||||
|
||||
optima.user.logout(event);
|
||||
} finally {
|
||||
return await resolve(event);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user