Companys are now listing on the companies page.

This commit is contained in:
2026-02-13 17:01:42 -06:00
parent 1a45f708ec
commit 6b176196d3
9 changed files with 304 additions and 250 deletions
+56
View File
@@ -0,0 +1,56 @@
# Copilot Instructions — electron-svelte (SveltronKit)
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.
Big picture
- 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).
How to run & build (developer workflows)
- 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.
Key files & patterns to reference
- 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.
Project-specific conventions and examples
- 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).
Tests & tooling
- 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.
When making changes, be conservative
- 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.
Useful snippets / concrete examples
- 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)).
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.
-- End
+6 -1
View File
@@ -3,7 +3,12 @@
declare global {
namespace App {
// interface Error {}
// interface Locals {}
interface Locals {
session?: {
accessToken: string;
refreshToken: string;
};
}
// interface PageData {}
// interface PageState {}
// interface Platform {}
+6 -1
View File
@@ -1,4 +1,4 @@
import { user } from "$lib";
import { api, user } from "$lib";
import { redirect, type Handle } from "@sveltejs/kit";
import { access } from "fs";
import { a } from "vitest/dist/chunks/suite.d.FvehnV49.js";
@@ -7,6 +7,11 @@ 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 || "",
};
if (event.url.pathname === "/logout") {
event.cookies.delete("access_token", { path: "/" });
event.cookies.delete("refresh_token", { path: "/" });
+3 -166
View File
@@ -1,172 +1,9 @@
import { getRequestEvent } from "$app/server";
import { redirect } from "@sveltejs/kit";
import axios, { AxiosInstance, AxiosRequestConfig, AxiosError } from "axios";
import { PUBLIC_API_URL } from "$env/static/public";
import axios, { AxiosInstance } from "axios";
type GetAccessToken = () => string | null | Promise<string | null>;
type SetAccessToken = (token: string | null) => void;
type RefreshHandler = () => Promise<string>;
type LogoutHandler = () => void;
interface Handlers {
getAccessToken?: GetAccessToken;
setAccessToken?: SetAccessToken;
refreshHandler?: RefreshHandler;
onRefreshFailed?: LogoutHandler;
}
const event = getRequestEvent();
const api: AxiosInstance = axios.create({
baseURL: process.env.API_URL || "",
withCredentials: true,
baseURL: PUBLIC_API_URL || "",
});
let handlers: Handlers = {
getAccessToken() {
if (!event) return null;
const token = event.cookies.get("authToken");
return token || null;
},
setAccessToken(token: string | null) {
if (!event) return;
if (token) {
event.cookies.set("authToken", token, { httpOnly: true, path: "/" });
}
},
refreshHandler: async () => {
if (!event) throw new Error("No request event available");
const refreshToken = event.cookies.get("refreshToken");
if (!refreshToken) throw new Error("No refresh token available");
const response = await api.post(
"/auth/refresh",
{},
{
headers: {
"x-refresh-token": `${refreshToken}`,
},
},
);
const newAuthToken = response.data.data.authToken;
const newRefreshToken = response.data.data.refreshToken;
// Update cookies
event.cookies.set("authToken", newAuthToken, { httpOnly: true, path: "/" });
event.cookies.set("refreshToken", newRefreshToken, {
httpOnly: true,
path: "/",
});
return newAuthToken;
},
onRefreshFailed: () => {
if (!event) return;
event.cookies.delete("authToken", { path: "/" });
event.cookies.delete("refreshToken", { path: "/" });
return redirect(303, "/");
},
};
let isRefreshing = false;
let refreshPromise: Promise<string> | null = null;
const queue: {
resolve: (value?: unknown) => void;
reject: (err: unknown) => void;
config: AxiosRequestConfig;
}[] = [];
export function registerAuthHandlers(h: Handlers) {
handlers = { ...handlers, ...h };
}
async function getToken(): Promise<string | null> {
if (!handlers.getAccessToken) return null;
return handlers.getAccessToken();
}
function setToken(token: string | null) {
if (!handlers.setAccessToken) return;
handlers.setAccessToken(token);
}
function enqueueRequest(p: {
resolve: (value?: unknown) => void;
reject: (err: unknown) => void;
config: AxiosRequestConfig;
}) {
queue.push(p);
}
function processQueue(error: unknown, token: string | null = null) {
while (queue.length) {
const p = queue.shift();
if (!p) continue;
if (error) p.reject(error);
else {
if (token && p.config.headers) {
(p.config.headers as Record<string, string>)["Authorization"] =
`Bearer ${token}`;
}
p.resolve(api.request(p.config));
}
}
}
api.interceptors.request.use(
async (config) => {
const token = await getToken();
if (token && config.headers) {
(config.headers as Record<string, string>)["Authorization"] =
`Bearer ${token}`;
}
return config;
},
(err) => Promise.reject(err),
);
api.interceptors.response.use(
(res) => res,
async (
error: AxiosError & { config?: AxiosRequestConfig & { _retry?: boolean } },
) => {
const originalConfig = error.config;
if (!originalConfig) return Promise.reject(error);
const status = error.response?.status;
if (status === 401 && !originalConfig._retry) {
originalConfig._retry = true;
if (!handlers.refreshHandler) {
handlers.onRefreshFailed?.();
return Promise.reject(error);
}
if (!isRefreshing) {
isRefreshing = true;
refreshPromise = handlers.refreshHandler!()
.then((newToken) => {
setToken(newToken);
processQueue(null, newToken);
return newToken;
})
.catch((err) => {
processQueue(err);
handlers.onRefreshFailed?.();
throw err;
})
.finally(() => {
isRefreshing = false;
refreshPromise = null;
});
}
return new Promise((resolve, reject) => {
enqueueRequest({ resolve, reject, config: originalConfig });
});
}
return Promise.reject(error);
},
);
export { api };
export default api;
+16
View File
@@ -0,0 +1,16 @@
import api from "./axios";
export const company = {
async fetch() {},
async fetchMany(accessToken: string, page: number = 1) {
const companies = await api.get("/v1/company/companies", {
params: {
page,
},
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return companies.data;
},
};
+2 -4
View File
@@ -1,10 +1,8 @@
// place files you want to import through the `$lib` alias in this folder.
/**
* @TODO MAKE THIS WORK
*/
//export * from "./axios";
export * from "./axios";
export * from "./user";
export * from "./companies";
/**
* @TODO
+2
View File
@@ -28,6 +28,8 @@ export const user = {
return refreshedTokens;
},
fetchInfo() {},
logout(event: RequestEvent) {
if (!event) return;
+12
View File
@@ -0,0 +1,12 @@
import { LayoutServerLoad } from "./$types";
export const load: LayoutServerLoad = async ({ locals }) => {
// WARNING: returning tokens to the client exposes them to JavaScript.
// Prefer keeping tokens httpOnly and proxying requests via server endpoints.
return {
session: {
accessToken: locals.session?.accessToken ?? null,
refreshToken: locals.session?.refreshToken ?? null,
},
};
};
+146 -23
View File
@@ -1,32 +1,94 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { company } from "$lib";
import LoadingSpinner from "$lib/../components/LoadingSpinner.svelte";
// Dummy data - 100 companies for pagination demo
const dummyCompanies = Array.from({ length: 100 }, (_, i) => ({
id: i + 1,
name: `Company ${i + 1}`,
cw_CompanyId: `CW-${String(i + 1).padStart(5, "0")}`,
cw_Identifier: `ID-${String(i + 1).padStart(5, "0")}`,
}));
export let data;
interface Company {
id: string;
name: string;
cw_CompanyId: number;
cw_Identifier: string;
createdAt: string;
updatedAt: string;
}
interface ApiResponse {
status: number;
message: string;
data: Company[];
successful: boolean;
meta: {
timestamp: number;
pagination: {
previousPage: number | null;
currentPage: number;
nextPage: number | null;
totalPages: number;
totalRecords: number;
listedRecords: number;
};
};
}
let companies: Company[] = [];
let totalPages = 0;
let currentPage = 1;
let totalRecords = 0;
let isLoading = true;
let error: string | null = null;
let searchQuery = "";
const itemsPerPage = 30; // 3 columns x 10 rows
$: totalPages = Math.ceil(dummyCompanies.length / itemsPerPage);
$: startIndex = (currentPage - 1) * itemsPerPage;
$: endIndex = startIndex + itemsPerPage;
$: displayedCompanies = dummyCompanies.slice(startIndex, endIndex);
const itemsPerPage = 30;
async function loadCompanies(page: number = 1) {
isLoading = true;
error = null;
try {
const response = await company.fetchMany(
data.session.accessToken || "",
page,
);
if (response && response.data && Array.isArray(response.data)) {
companies = response.data;
totalRecords =
response.meta?.pagination?.totalRecords || response.data.length;
totalPages =
response.meta?.pagination?.totalPages ||
Math.ceil(companies.length / itemsPerPage);
currentPage = page;
} else {
error = "Failed to load companies";
}
} catch (err) {
console.error("Failed to fetch companies:", err);
error = "Error loading companies. Please try again.";
} finally {
isLoading = false;
}
}
$: displayedCompanies = companies.filter(
(c) =>
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.cw_Identifier.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.cw_CompanyId.toString().includes(searchQuery),
);
function goToPage(page: number) {
if (page >= 1 && page <= totalPages) {
currentPage = page;
loadCompanies(page);
}
}
function signOut() {
goto("/logout");
}
// Load companies on component mount
loadCompanies();
</script>
<svelte:head>
@@ -46,9 +108,16 @@
<main class="container">
<section class="hero">
<h2>Company Directory</h2>
<p>Browse all companies. Total: {dummyCompanies.length} companies</p>
{#if isLoading}
<LoadingSpinner loading={true} />
{:else if error}
<p class="error">{error}</p>
{:else}
<p>Browse all companies. Total: {totalRecords} companies</p>
{/if}
</section>
{#if !isLoading && !error}
<section class="search-section">
<input
type="text"
@@ -56,27 +125,46 @@
bind:value={searchQuery}
class="search-bar"
/>
{#if searchQuery}
<p class="search-results">
Found {displayedCompanies.length} company/companies matching "{searchQuery}"
</p>
{/if}
</section>
{#if displayedCompanies.length > 0}
<section class="companies-grid">
{#each displayedCompanies as company (company.id)}
{#each displayedCompanies as comp (comp.id)}
<article class="company-card">
<h3>{company.name}</h3>
<h3>{comp.name}</h3>
<dl>
<dt>ID</dt>
<dd>{company.id}</dd>
<dt>CW Company ID</dt>
<dd>{company.cw_CompanyId}</dd>
<dd>{comp.cw_CompanyId}</dd>
<dt>CW Identifier</dt>
<dd>{company.cw_Identifier}</dd>
<dd>{comp.cw_Identifier}</dd>
<dt>Created</dt>
<dd>{new Date(comp.createdAt).toLocaleDateString()}</dd>
</dl>
<a href="/company/{company.id}" class="view-link">View Details</a>
<a href="/company/{comp.id}" class="view-link">View Details</a>
</article>
{/each}
</section>
{:else}
<section class="companies-grid">
<p class="no-results">No companies found</p>
</section>
{/if}
{#if totalPages > 1}
{#if totalPages > 1 && !searchQuery}
<section class="pagination">
<button
on:click={() => goToPage(1)}
disabled={currentPage === 1}
class="pagination-btn"
>
⟨⟨ First
</button>
<button
on:click={() => goToPage(currentPage - 1)}
disabled={currentPage === 1}
@@ -86,13 +174,18 @@
</button>
<div class="page-numbers">
{#each Array.from({ length: totalPages }, (_, i) => i + 1) as page}
{#each Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
const startPage = Math.max(1, currentPage - 2);
return startPage + i;
}) as page}
{#if page <= totalPages}
<button
on:click={() => goToPage(page)}
class="page-number {page === currentPage ? 'active' : ''}"
>
{page}
</button>
{/if}
{/each}
</div>
@@ -104,11 +197,20 @@
Next →
</button>
<button
on:click={() => goToPage(totalPages)}
disabled={currentPage === totalPages}
class="pagination-btn"
>
Last ⟩⟩
</button>
<span class="page-info">
Page {currentPage} of {totalPages}
</span>
</section>
{/if}
{/if}
</main>
<footer class="container">
@@ -169,6 +271,27 @@
color: #6b7280;
}
.error {
color: #dc2626;
padding: 1rem;
background: #fee2e2;
border-radius: 6px;
margin: 0;
}
.search-results {
color: #6b7280;
margin-top: 0.5rem;
font-size: 0.9rem;
}
.no-results {
color: #9ca3af;
text-align: center;
padding: 2rem;
grid-column: 1 / -1;
}
.search-section {
margin: 1.5rem 0;
}