Company listing, authentication, and page error handling are all working

This commit is contained in:
2026-02-17 17:29:17 -06:00
parent 6d046e90ed
commit 8e225aa254
23 changed files with 2086 additions and 342 deletions
+140 -39
View File
@@ -1,56 +1,157 @@
# Copilot Instructions — electron-svelte (SveltronKit)
# Copilot Instructions for ttscm-ui
This repo is an Electron + SvelteKit app (SveltronKit). The goal of this document is to give an AI coding agent immediate, actionable knowledge about architecture, conventions, and common tasks so suggestions and changes are correct and context-aware.
## Project Overview
Big picture
**ttscm-ui** is an Electron desktop application built with **SvelteKit**, TypeScript, and Vite. It connects to the Optima API for credential and company management. The app uses standard SvelteKit routing for single-page navigation and pnpm for package management with patches applied to SvelteKit.
- App = Electron main + SvelteKit renderer. See [electron/main.ts](electron/main.ts) and renderer sources under `src/`.
- Renderer is a normal SvelteKit app but configured to use the hash router — all routed links expected in the app should begin with `#/` when constructing absolute links for the packaged Electron app (see README).
- Electron main process loads the Vite dev server when `MAIN_WINDOW_VITE_DEV_SERVER_URL` is set; otherwise loads the built renderer. See [electron/main.ts](electron/main.ts).
## Architecture Layers
How to run & build (developer workflows)
### Electron Architecture
- Uses pnpm. Always use `pnpm install` first.
- Dev (runs Electron + Vite via Electron Forge): `pnpm run start` (invokes `electron-forge start`).
- Build & package: `pnpm run package` (builds renderer with `vite build` then runs `electron-forge package`).
- Create distributable: `pnpm run make`.
- Tests: unit tests via `vitest` and e2e via Playwright. `pnpm run test` runs both (`test:unit` and `test:e2e`). See `package.json` scripts.
- **`electron/main.ts`**: Main process—creates/manages windows, handles file system access. Loads preload script and serves the renderer.
- **`electron/preload.ts`**: Currently empty bridge between main and renderer processes. Extend here to expose secure IPC handlers if needed.
- **`forge.config.ts`**: Electron Forge configuration with Vite plugin for building main, preload, and renderer targets.
Key files & patterns to reference
### Frontend (SvelteKit)
- Electron bootstrap: [electron/main.ts](electron/main.ts) — env vars used: `MAIN_WINDOW_VITE_DEV_SERVER_URL`, `MAIN_WINDOW_VITE_NAME`.
- Preload bridge: [electron/preload.ts](electron/preload.ts) (currently empty) — add safe, whitelisted APIs here when exposing functionality to the renderer.
- SvelteKit entry routes: `src/routes/` (examples: [src/routes/+page.svelte](src/routes/+page.svelte), [src/routes/companies/+page.svelte](src/routes/companies/+page.svelte)).
- API client: [src/lib/axios.ts](src/lib/axios.ts) — `api` is created from `PUBLIC_API_URL` ($env/static/public).
- Auth helper: [src/lib/authUri.ts](src/lib/authUri.ts) — example of calling backend `/v1/auth/uri`.
- Data access: [src/lib/companies.ts](src/lib/companies.ts) — `fetchMany(accessToken)` demonstrates header usage `Authorization: Bearer <token>`.
- Global styles: `src/app.css` (Tailwind is present in the project).
- Patches: `patches/` contains `@sveltejs__kit.patch` and is referenced in `package.json` via `patchedDependencies` — don't remove or ignore without verifying its purpose.
- **`src/routes/`**: SvelteKit file-based routing with standard pathname router.
- `(auth)` group: Authentication pages (login)
- `(secure)` group: Protected pages requiring auth
- **`src/lib/`**: Reusable modules
- `optima-api/`: API client abstraction with modular endpoints (auth, companies, credentials, etc.)
- `axios.ts`: Base axios instance with `PUBLIC_API_URL` env variable
- **`src/components/`**: Reusable Svelte components (modals, spinners, error boundaries)
Project-specific conventions and examples
### API Communication
- Router: Because of the hash-router configuration, when constructing absolute links in a packaged app prefer `#/path` (README highlights this). For client navigation within components prefer `goto()` from `$app/navigation` (see [src/routes/+page.svelte](src/routes/+page.svelte)).
- Env & API: Use `PUBLIC_API_URL` (imported from `$env/static/public`) as the renderer-side base URL. For backend calls that require auth, pass `Authorization: Bearer <token>` headers (see `src/lib/companies.ts`).
- IPC surface: The preload file is where to expose limited, safe APIs to the renderer. The main process defines windows and startup behavior — avoid adding unsafe globals to the renderer.
- TypeScript & Svelte: The project uses TypeScript + Svelte 5 — keep code consistent with existing component patterns (script blocks, `$:` reactivity, `svelte:head`, named +page routes).
The `$lib/index.ts` exports `optima` object aggregating all API modules. Example:
Tests & tooling
```typescript
export const optima = {
auth: (await import("./optima-api/modules/auth")).auth,
company: (await import("./optima-api/modules/companies")).company,
// etc.
};
```
- Unit tests: `vitest` (run `pnpm run test:unit`). Look at `demo.spec.ts` and `src/routes/page.svelte.test.ts` for existing examples.
- E2E: Playwright tests live under `e2e/` and are run with `pnpm run test:e2e`.
- Sync: `prepare` script runs `svelte-kit sync`. Running `pnpm run check` runs `svelte-check` too; use it when editing routes/type-heavy code.
Each module (e.g., `auth.ts`) exports functions that call the API using a custom axios instance.
When making changes, be conservative
## Key Conventions
- Changing build or packaging behavior affects developers' ability to run the app locally. Prefer edits to renderer code (under `src/`) unless the user asked to adjust packaging.
- If adding new native electron APIs, update `electron/preload.ts` to expose a minimal API surface and document it.
### Routing
Useful snippets / concrete examples
- **Use standard SvelteKit routing with `/` prefix** (e.g., `href="/credentials"`)
- Routes in `src/routes/` map to `/path` at runtime
- Do NOT use hash-based routing (`#/` routes)
- Navigate programmatically: `import { goto } from "$app/navigation"; goto('/logout');` (see [src/routes/+page.svelte](src/routes/+page.svelte)).
- API client usage: `import { api } from 'src/lib/axios'; const res = await api.get('/v1/...');`
- Auth redirect fetch: `const { uri, callbackKey } = await fetchAuthRedirectUri(apiUrl);` (see [src/lib/authUri.ts](src/lib/authUri.ts)).
### API Module Pattern
If anything is ambiguous or you need additional examples (tests, CI, or a missing preload implementation), ask the maintainer which behavior they want (safe IPC surface, packaging targets, or CI test matrix) before making large changes.
Create API modules in `src/lib/optima-api/modules/` following this pattern:
-- End
```typescript
// Example: credentials.ts
export const credential = {
async fetchCredentials(api: AxiosInstance) {
// Implementation
},
};
```
Export as a named object, then import/aggregate in `src/lib/index.ts`.
### Environment Variables
- `PUBLIC_API_URL`: API base URL, used in `src/lib/optima-api/axios.ts`
- Prefixed with `PUBLIC_` to be accessible in client code
### File Organization
- Components go in `src/components/` (e.g., modals, spinners)
- Page-specific logic in `src/routes/[route]/+page.svelte` and `+page.server.ts`
- Styles in `src/styles/` with Tailwind CSS + TailwindCSS vite plugin
- Tests colocated: `*.spec.ts` or `*.test.ts` next to source files
## Development Workflow
### Installation & Setup
```bash
pnpm install
```
Uses pnpm with SvelteKit patches (see `patches/` directory).
### Running in Development
```bash
pnpm run start
```
Electron Forge + Vite handles dev server and hot module replacement (HMR). Dev tools open automatically. Main window loads `/login` first.
### Building & Packaging
- **Build for production**: `pnpm run package` → outputs to `out/` directory
- **Create distributable**: `pnpm run make` → creates installers (configure in `forge.config.ts`)
- **Check types & lint**: `pnpm run check` (runs svelte-kit sync + svelte-check)
### Testing
#### Unit Tests (Vitest)
```bash
pnpm run test:unit
```
- Client tests: `src/**/*.svelte.{test,spec}.{js,ts}` (jsdom environment)
- Server tests: `src/**/*.{test,spec}.{js,ts}` excluding svelte tests (node environment)
- Setup: `vitest-setup-client.ts` mocks `window.matchMedia` for Svelte 5 + jsdom compatibility
#### E2E Tests (Playwright)
```bash
pnpm run test:e2e
```
- Tests in `e2e/` directory
- Config: `playwright.config.ts` (builds and previews before testing)
#### Run All Tests
```bash
pnpm run test
```
Runs unit tests first, then e2e.
## Critical Integration Points
### IPC (Electron Main ↔ Renderer)
**Status**: Preload script is currently empty. If file system access is needed, define IPC handlers in `electron/main.ts` and expose them via `electron/preload.ts` to renderer process.
### API Authentication
- Auth flow starts in `src/lib/optima-api/modules/auth.ts` with `fetchAuthRedirectUri()`
- TODO: Enforce auth checks on every page change (see `src/lib/index.ts` comment)
### Build Artifacts
- **Dev server URL**: `MAIN_WINDOW_VITE_DEV_SERVER_URL` (injected by Electron Forge)
- **Output**: Rendered app built to `.vite/renderer/main_window/` (configured in `svelte.config.js`)
## Common Patterns & Gotchas
1. **Standard Routing**: Use standard SvelteKit routing with `/` prefix (e.g., `href="/credentials"`) — do NOT use hash-based routing
2. **SvelteKit Patches**: Project patches SvelteKit in `patches/` to work around issues. When updating SvelteKit, verify patches still apply.
3. **Async Components**: Svelte 5 has `experimental.async: true` enabled; be aware of async component patterns.
4. **Tailwind**: Uses `@tailwindcss/vite` plugin; configure utilities in tailwind config if needed.
5. **API Error Handling**: API modules throw errors with descriptive messages. Use `$lib/errorHandler.ts` for consistent error formatting.
## Useful Entry Points for Navigation
- **Frontend Layout**: [src/routes/+layout.svelte](src/routes/+layout.svelte) (main shell, sidebar, header)
- **API Abstraction**: [src/lib/optima-api/axios.ts](src/lib/optima-api/axios.ts) (base client)
- **API Modules**: [src/lib/optima-api/modules/](src/lib/optima-api/modules/) (auth, companies, credentials, etc.)
- **App Entry**: [src/app.html](src/app.html)
- **Electron Main**: [electron/main.ts](electron/main.ts)
+77 -37
View File
@@ -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;
};
+8 -2
View File
@@ -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) {
+25 -3
View File
@@ -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.
*
+1
View File
@@ -0,0 +1 @@
export const ssr = true;
+1
View File
@@ -0,0 +1 @@
<slot />
+2 -2
View File
@@ -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: "/",
});
+1 -2
View File
@@ -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) {
-80
View File
@@ -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
View File
@@ -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>
+24 -1
View File
@@ -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 };
};
+102
View File
@@ -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>&copy; {new Date().getFullYear()} Total Tech Solutions, LLC</small>
</footer>
</div>
{/if}
+3 -1
View File
@@ -1 +1,3 @@
import "..styles/app.css";
import "../styles/app.css";
import "../styles/layout.css";
import "../styles/errorpage.css";
+1 -2
View File
@@ -7,6 +7,5 @@
</svelte:head>
<main>
<h1>Welcome</h1>
<p>Your new landing page. Ready to build.</p>
<h1>Home Page</h1>
</main>
+34
View File
@@ -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);
}
};
+355
View File
@@ -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>
-21
View File
@@ -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;
};
+8
View File
@@ -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;
}
+615
View File
@@ -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;
}
}
+233
View File
@@ -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%;
}
}
+328
View File
@@ -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));
}
}
}
+59
View File
@@ -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);
}
};