Company listing, authentication, and page error handling are all working
This commit is contained in:
+140
-39
@@ -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/`.
|
## Architecture Layers
|
||||||
- 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).
|
|
||||||
|
|
||||||
How to run & build (developer workflows)
|
### Electron Architecture
|
||||||
|
|
||||||
- Uses pnpm. Always use `pnpm install` first.
|
- **`electron/main.ts`**: Main process—creates/manages windows, handles file system access. Loads preload script and serves the renderer.
|
||||||
- Dev (runs Electron + Vite via Electron Forge): `pnpm run start` (invokes `electron-forge start`).
|
- **`electron/preload.ts`**: Currently empty bridge between main and renderer processes. Extend here to expose secure IPC handlers if needed.
|
||||||
- Build & package: `pnpm run package` (builds renderer with `vite build` then runs `electron-forge package`).
|
- **`forge.config.ts`**: Electron Forge configuration with Vite plugin for building main, preload, and renderer targets.
|
||||||
- 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.
|
|
||||||
|
|
||||||
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`.
|
- **`src/routes/`**: SvelteKit file-based routing with standard pathname router.
|
||||||
- Preload bridge: [electron/preload.ts](electron/preload.ts) (currently empty) — add safe, whitelisted APIs here when exposing functionality to the renderer.
|
- `(auth)` group: Authentication pages (login)
|
||||||
- SvelteKit entry routes: `src/routes/` (examples: [src/routes/+page.svelte](src/routes/+page.svelte), [src/routes/companies/+page.svelte](src/routes/companies/+page.svelte)).
|
- `(secure)` group: Protected pages requiring auth
|
||||||
- API client: [src/lib/axios.ts](src/lib/axios.ts) — `api` is created from `PUBLIC_API_URL` ($env/static/public).
|
- **`src/lib/`**: Reusable modules
|
||||||
- Auth helper: [src/lib/authUri.ts](src/lib/authUri.ts) — example of calling backend `/v1/auth/uri`.
|
- `optima-api/`: API client abstraction with modular endpoints (auth, companies, credentials, etc.)
|
||||||
- Data access: [src/lib/companies.ts](src/lib/companies.ts) — `fetchMany(accessToken)` demonstrates header usage `Authorization: Bearer <token>`.
|
- `axios.ts`: Base axios instance with `PUBLIC_API_URL` env variable
|
||||||
- Global styles: `src/app.css` (Tailwind is present in the project).
|
- **`src/components/`**: Reusable Svelte components (modals, spinners, error boundaries)
|
||||||
- Patches: `patches/` contains `@sveltejs__kit.patch` and is referenced in `package.json` via `patchedDependencies` — don't remove or ignore without verifying its purpose.
|
|
||||||
|
|
||||||
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)).
|
The `$lib/index.ts` exports `optima` object aggregating all API modules. Example:
|
||||||
- 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).
|
|
||||||
|
|
||||||
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.
|
Each module (e.g., `auth.ts`) exports functions that call the API using a custom axios instance.
|
||||||
- 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.
|
|
||||||
|
|
||||||
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.
|
### Routing
|
||||||
- If adding new native electron APIs, update `electron/preload.ts` to expose a minimal API surface and document it.
|
|
||||||
|
|
||||||
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 Module Pattern
|
||||||
- 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)).
|
|
||||||
|
|
||||||
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
@@ -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 { 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 }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
const accessToken = event.cookies.get("access_token");
|
const accessToken = event.cookies.get("accessToken") || null;
|
||||||
const refreshToken = event.cookies.get("refresh_token");
|
const refreshToken = event.cookies.get("refreshToken") || null;
|
||||||
|
|
||||||
event.locals.session = {
|
|
||||||
accessToken: accessToken || "",
|
|
||||||
refreshToken: refreshToken || "",
|
|
||||||
};
|
|
||||||
|
|
||||||
if (event.url.pathname === "/logout") {
|
if (event.url.pathname === "/logout") {
|
||||||
event.cookies.delete("access_token", { path: "/" });
|
event.cookies.delete("accessToken", { path: "/" });
|
||||||
event.cookies.delete("refresh_token", { path: "/" });
|
event.cookies.delete("refreshToken", { path: "/" });
|
||||||
|
|
||||||
redirect(303, "/login");
|
return redirect(303, "/login");
|
||||||
|
|
||||||
return resolve(event);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.url.pathname.startsWith("/login") && user.isLoggedIn()) {
|
if (event.url.pathname.startsWith("/login") && optima.user.isLoggedIn()) {
|
||||||
return redirect(303, "/");
|
return redirect(303, "/");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,31 +21,79 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
return await resolve(event);
|
return await resolve(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!accessToken || !refreshToken) {
|
if (!accessToken && !refreshToken) {
|
||||||
user.logout(event);
|
optima.user.logout(event);
|
||||||
return resolve(event);
|
redirect(303, "/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if the access token is expired or near expiry and refresh if needed
|
||||||
|
let currentAccessToken = accessToken;
|
||||||
|
let currentRefreshToken = refreshToken;
|
||||||
|
|
||||||
|
if (currentAccessToken) {
|
||||||
try {
|
try {
|
||||||
if (accessToken && refreshToken) {
|
const [, payload] = currentAccessToken.split(".");
|
||||||
const newSession = await user.refreshSession(refreshToken);
|
const decoded = JSON.parse(
|
||||||
|
Buffer.from(payload, "base64url").toString("utf8"),
|
||||||
|
);
|
||||||
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
|
const thresholdSec = 60; // refresh if < 60s remaining
|
||||||
|
|
||||||
console.log(newSession);
|
if (!decoded?.exp || decoded.exp - nowSec < thresholdSec) {
|
||||||
|
// Token is expired or about to expire — try to refresh
|
||||||
event.cookies.set("access_token", newSession.accessToken, {
|
if (currentRefreshToken) {
|
||||||
httpOnly: true,
|
const refreshed =
|
||||||
path: "/",
|
await optima.user.refreshSession(currentRefreshToken);
|
||||||
});
|
currentAccessToken = refreshed.accessToken;
|
||||||
event.cookies.set("refresh_token", newSession.refreshToken, {
|
currentRefreshToken = refreshed.refreshToken ?? currentRefreshToken;
|
||||||
httpOnly: true,
|
} else {
|
||||||
path: "/",
|
// No refresh token available, force re-login
|
||||||
});
|
optima.user.logout(event);
|
||||||
|
return redirect(303, "/login");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
console.trace(err);
|
|
||||||
|
|
||||||
user.logout(event);
|
|
||||||
} finally {
|
|
||||||
return await resolve(event);
|
|
||||||
}
|
}
|
||||||
|
} 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
return company.data;
|
||||||
},
|
},
|
||||||
async fetchMany(accessToken: string, page: number = 1, search?: string) {
|
async fetchMany(
|
||||||
const params: Record<string, unknown> = { page };
|
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;
|
if (search && search.length > 0) params.search = search;
|
||||||
|
|
||||||
const companies = await api.get("/v1/company/companies", {
|
const companies = await api.get("/v1/company/companies", {
|
||||||
@@ -19,6 +24,7 @@ export const company = {
|
|||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return companies.data;
|
return companies.data;
|
||||||
},
|
},
|
||||||
async fetchConfigurations(accessToken: string, id: string) {
|
async fetchConfigurations(accessToken: string, id: string) {
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import { getRequestEvent } from "$app/server";
|
|||||||
import { PUBLIC_API_URL } from "$env/static/public";
|
import { PUBLIC_API_URL } from "$env/static/public";
|
||||||
import { redirect, RequestEvent } from "@sveltejs/kit";
|
import { redirect, RequestEvent } from "@sveltejs/kit";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import api from "../axios";
|
||||||
import { io } from "socket.io-client";
|
import { io } from "socket.io-client";
|
||||||
|
|
||||||
export const user = {
|
export const user = {
|
||||||
isLoggedIn(): boolean {
|
isLoggedIn(): boolean {
|
||||||
const event = getRequestEvent();
|
const event = getRequestEvent();
|
||||||
const authToken = event.cookies.get("authToken");
|
const authToken = event.cookies.get("accessToken");
|
||||||
return !!authToken;
|
return !!authToken;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -28,18 +29,39 @@ export const user = {
|
|||||||
return refreshedTokens;
|
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) {
|
logout(event: RequestEvent) {
|
||||||
if (!event) return;
|
if (!event) return;
|
||||||
|
|
||||||
// Clear authentication cookies
|
// Clear authentication cookies
|
||||||
event.cookies.delete("authToken", { path: "/" });
|
event.cookies.delete("accessToken", { path: "/" });
|
||||||
event.cookies.delete("refreshToken", { path: "/" });
|
event.cookies.delete("refreshToken", { path: "/" });
|
||||||
|
|
||||||
return redirect(303, "/login");
|
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.
|
* @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,
|
data.get("callbackKey") as string,
|
||||||
);
|
);
|
||||||
|
|
||||||
event.cookies.set("access_token", tokens.accessToken, {
|
event.cookies.set("accessToken", tokens.accessToken, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
path: "/",
|
path: "/",
|
||||||
});
|
});
|
||||||
event.cookies.set("refresh_token", tokens.refreshToken, {
|
event.cookies.set("refreshToken", tokens.refreshToken, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
path: "/",
|
path: "/",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,11 +2,10 @@
|
|||||||
import { optima } from "$lib";
|
import { optima } from "$lib";
|
||||||
import { PUBLIC_API_URL } from "$env/static/public";
|
import { PUBLIC_API_URL } from "$env/static/public";
|
||||||
import { enhance } from "$app/forms";
|
import { enhance } from "$app/forms";
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
import LoadingSpinner from "../../../components/LoadingSpinner.svelte";
|
import LoadingSpinner from "../../../components/LoadingSpinner.svelte";
|
||||||
import { writable } from "svelte/store";
|
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);
|
let loading = writable(false);
|
||||||
|
|
||||||
function handleSubmit(e: SubmitEvent) {
|
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>
|
<script>
|
||||||
|
import { page } from "$app/stores";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import "../styles/errorpage.css";
|
||||||
|
|
||||||
function signOut() {
|
$: status = $page.status || 500;
|
||||||
goto("/logout");
|
$: message = $page.error?.message || "Something went wrong";
|
||||||
}
|
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
history.back();
|
history.back();
|
||||||
@@ -11,159 +12,75 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Error — App</title>
|
<title>Error {status} — Project Optima</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<header class="header container">
|
<div class="error-page">
|
||||||
<h1>Error</h1>
|
<div class="error-pane">
|
||||||
<nav>
|
<!-- Pane header -->
|
||||||
<a href="/">Home</a>
|
<div class="error-pane-header">
|
||||||
<button on:click={signOut}>Sign out</button>
|
<h2 class="error-pane-title">Error</h2>
|
||||||
</nav>
|
<span class="error-status-badge">{status}</span>
|
||||||
</header>
|
</div>
|
||||||
|
|
||||||
<main class="container">
|
<!-- Pane body -->
|
||||||
<section class="error-section">
|
<div class="error-pane-body">
|
||||||
<div class="error-box">
|
<div class="error-illustration">
|
||||||
<h2>Oops! Something went wrong</h2>
|
<svg viewBox="0 0 120 120" width="120" height="120" aria-hidden="true">
|
||||||
<p class="error-message">
|
<circle
|
||||||
We encountered an error while processing your request. Please try again
|
cx="60"
|
||||||
or contact support if the problem persists.
|
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>
|
</p>
|
||||||
|
|
||||||
<div class="error-actions">
|
<div class="error-actions">
|
||||||
<button class="btn btn-primary" on:click={goBack}>Go Back</button>
|
<button class="btn btn-primary" on:click={goBack}>
|
||||||
<a href="/" class="btn btn-secondary">Go Home</a>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|||||||
@@ -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>
|
</svelte:head>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<h1>Welcome</h1>
|
<h1>Home Page</h1>
|
||||||
<p>Your new landing page. Ready to build.</p>
|
|
||||||
</main>
|
</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";
|
@import "tailwindcss";
|
||||||
@plugin '@tailwindcss/forms';
|
@plugin '@tailwindcss/forms';
|
||||||
@plugin '@tailwindcss/typography';
|
@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