feat: Phase 1 — extract the client (web SPA + desktop) to dtoro/oikos-web
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

Problem: the hexagonal refactor churns the backend tree for nine more
phases; the UI delivery stack (web/ SPA, cmd/desktop Wails wrapper,
compose/web image) must move to its own repo first so doc/layout
rewrites land once on a backend-only tree.

Change:
- New repo git.hubris.network/dtoro/oikos-web (v0.33.0): web/, desktop/
  (updateURL repointed to oikos-web releases), compose/, own CI (web +
  desktop jobs), own deploy script (CI-green gate, TOCTOU guard,
  version-tagged images, prune-to-3), own webhook receiver on :9798 +
  launchd unit, own compose project publishing the same 8091:80.
- Cutover executed on mac-mini in order: oikos stack's web service
  stopped+removed, oikos-web project brought up on 8091; outer Caddy
  untouched (targets the published port) — serving + Authentik flow +
  /wails 404 quirk verified post-cutover.
- Stripped from oikos: web/, cmd/desktop/, compose/web/, desktop CI
  workflow, ci.yml web job, Makefile ui/desktop/desktop-package/install
  targets, the compose web service, oikos-web from deploy.sh's fallback
  prune list; wails + go-keyring dropped from go.mod, vendor synced.
- README / CONTRIBUTING / AGENTS.md / .agents dev+operations docs now
  point at the new repo; mbse + mascot design docs carry a path note.

Risk: production SPA serving depends on the new pipeline now; rollback
is versioned-image re-up of the old web service from a pre-split
checkout (port 8091). Desktop builds installed before the split still
check dtoro/oikos releases — one manual reinstall, noted in the
oikos-web release notes.

Verification: go vet, make test (race), make generate-check, golangci
(no new findings; baseline down 400→365); post-cutover curls —
localhost:8091 200, /wails/runtime.js 404, outer Caddy 302 Authentik.
This commit is contained in:
2026-08-15 22:27:52 +02:00
parent e074f04bdf
commit d4d99a7473
18854 changed files with 2615729 additions and 173735 deletions

View File

@@ -0,0 +1,37 @@
import { type Writable, type Readable } from 'svelte/store';
import type { State, Sort, Filter } from './';
import type { Params } from './DataHandler';
import EventHandler from './handlers/EventHandler';
export default class Context<Row> {
totalRows: Writable<number | undefined>;
rowsPerPage: Writable<number>;
pageNumber: Writable<number>;
event: EventHandler;
search: Writable<string>;
filters: Writable<Filter<Row>[]>;
rows: Writable<Row[]>;
rowCount: Readable<{
total: number;
start: number;
end: number;
}>;
pages: Readable<number[]>;
pagesWithEllipsis: Readable<number[]>;
pageCount: Readable<number>;
sort: Writable<Sort<Row>>;
selected: Writable<(Row | Row[keyof Row])[]>;
isAllSelected: Readable<boolean>;
selectedCount: Readable<{
count: number;
total: number;
}>;
selectBy: keyof Row | undefined;
constructor(data: Row[], params: Params);
getState(): State;
private createPages;
private createPagesWithEllipsis;
private createPageCount;
private createRowCount;
private createIsAllSelected;
private createSelectedCount;
}

View File

@@ -0,0 +1,140 @@
import { writable, get, derived } from 'svelte/store';
import EventHandler from './handlers/EventHandler';
export default class Context {
totalRows;
rowsPerPage;
pageNumber;
event;
search;
filters;
rows;
rowCount;
pages;
pagesWithEllipsis;
pageCount;
sort;
selected;
isAllSelected;
selectedCount;
selectBy;
constructor(data, params) {
this.totalRows = writable(params.totalRows);
this.rowsPerPage = writable(params.rowsPerPage);
this.pageNumber = writable(1);
this.event = new EventHandler();
this.search = writable('');
this.filters = writable([]);
this.rows = writable(data);
this.rowCount = this.createRowCount();
this.pages = this.createPages();
this.pagesWithEllipsis = this.createPagesWithEllipsis();
this.pageCount = this.createPageCount();
this.sort = writable(undefined);
this.selected = writable([]);
this.isAllSelected = this.createIsAllSelected();
this.selectedCount = this.createSelectedCount();
this.selectBy = params.selectBy ?? undefined;
}
getState() {
const pageNumber = get(this.pageNumber);
const rowsPerPage = get(this.rowsPerPage);
const sort = get(this.sort);
const filters = get(this.filters);
return {
pageNumber,
rowsPerPage,
offset: rowsPerPage * (pageNumber - 1),
search: get(this.search),
sorted: sort ?? undefined, // deprecated
sort: sort ?? undefined,
filters: filters.length > 0 ? filters : undefined,
setTotalRows: (value) => this.totalRows.set(value)
};
}
createPages() {
return derived([this.rowsPerPage, this.totalRows], ([$rowsPerPage, $totalRows]) => {
if (!$rowsPerPage || !$totalRows) {
return undefined;
}
const pages = Array.from(Array(Math.ceil($totalRows / $rowsPerPage)));
return pages.map((_, i) => {
return i + 1;
});
});
}
createPagesWithEllipsis() {
return derived([this.pages, this.pageNumber], ([$pages, $pageNumber]) => {
if (!$pages) {
return undefined;
}
if ($pages.length <= 7) {
return $pages;
}
const ellipse = null;
const firstPage = 1;
const lastPage = $pages.length;
if ($pageNumber <= 4) {
return [
...$pages.slice(0, 5),
ellipse,
lastPage
];
}
else if ($pageNumber < $pages.length - 3) {
return [
firstPage,
ellipse,
...$pages.slice($pageNumber - 2, $pageNumber + 1),
ellipse,
lastPage
];
}
else {
return [
firstPage,
ellipse,
...$pages.slice($pages.length - 5, $pages.length)
];
}
});
}
createPageCount() {
return derived(this.pages, ($pages) => {
if (!$pages)
return undefined;
return $pages.length;
});
}
createRowCount() {
return derived([this.totalRows, this.pageNumber, this.rowsPerPage], ([$totalRows, $pageNumber, $rowsPerPage]) => {
if (!$rowsPerPage || !$totalRows) {
return undefined;
}
return {
total: $totalRows,
start: $pageNumber * $rowsPerPage - $rowsPerPage + 1,
end: Math.min($pageNumber * $rowsPerPage, $totalRows)
};
});
}
createIsAllSelected() {
return derived([this.selected, this.rows], ([$selected, $rows]) => {
if ($rows.length === 0) {
return false;
}
if (this.selectBy) {
const ids = $rows.map(row => row[this.selectBy]);
return ids.every(id => $selected.includes(id));
}
return $rows.every(row => $selected.includes(row));
});
}
createSelectedCount() {
return derived([this.selected, this.totalRows], ([$selected, $totalRows]) => {
return {
count: $selected.length,
total: $totalRows
};
});
}
}

View File

@@ -0,0 +1,77 @@
import type { Writable, Readable } from 'svelte/store';
import type { Internationalization, Row, State, Sort } from './';
export type Params = {
rowsPerPage?: number;
totalRows?: number;
selectBy?: keyof Row;
i18n?: Internationalization;
};
export default class DataHandler<T extends Row = any> {
private context;
private triggerHandler;
private sortHandler;
private selectHandler;
private pageHandler;
private searchHandler;
private filterHandler;
i18n: Internationalization;
constructor(data?: T[], params?: Params);
onChange(callback: (state: State) => Promise<T[]>): void;
invalidate(): void;
setRows(data: T[]): void;
setTotalRows(value: number): void;
getRows(): Writable<T[]>;
select(value: T[keyof T] | T): void;
getSelected(): Writable<(T | T[keyof T])[]>;
selectAll(): void;
isAllSelected(): Readable<boolean>;
getSelectedCount(): Readable<{
count: number;
total: number;
}>;
clearSelection(): void;
getRowsPerPage(): Writable<number | null>;
sort(orderBy: keyof T): void;
applySort(params?: {
orderBy: keyof T;
direction?: 'asc' | 'desc';
}): void;
sortAsc(orderBy: keyof T): void;
sortDesc(orderBy: keyof T): void;
getSort(): Writable<Sort<T>>;
search(value: string): void;
clearSearch(): void;
filter(value: string | number, filterBy: keyof T): void;
clearFilters(): void;
getPages(params?: {
ellipsis: boolean;
}): Readable<number[]>;
getPageCount(): Readable<number>;
getPageNumber(): Writable<number>;
setPage(value: number | 'previous' | 'next'): void;
getRowCount(): Readable<{
total: number;
start: number;
end: number;
}>;
on(event: 'change', callback: () => void): void;
translate(i18n: Internationalization): Internationalization;
/**
*
* @depracted use on('change', callback) instead
*/
getTriggerChange(): Writable<number>;
/**
*
* @deprecated use applySort() instead
*/
applySorting(params?: {
orderBy: keyof T;
direction?: 'asc' | 'desc';
}): void;
/**
*
* @deprecated use getSort() instead
*/
getSorted(): Writable<Sort<T>>;
}

View File

@@ -0,0 +1,157 @@
import Context from './Context';
import TriggerHandler from './handlers/TriggerHandler';
import SortHandler from './handlers/SortHandler';
import SelectHandler from './handlers/SelectHandler';
import PageHandler from './handlers/PageHandler';
import SearchHandler from './handlers/SearchHandler';
import FilterHandler from './handlers/FilterHandler';
export default class DataHandler {
context;
triggerHandler;
sortHandler;
selectHandler;
pageHandler;
searchHandler;
filterHandler;
i18n;
constructor(data = [], params = { rowsPerPage: 5 }) {
this.i18n = this.translate(params.i18n);
this.context = new Context(data, params);
this.triggerHandler = new TriggerHandler(this.context);
this.sortHandler = new SortHandler(this.context);
this.selectHandler = new SelectHandler(this.context);
this.pageHandler = new PageHandler(this.context);
this.searchHandler = new SearchHandler(this.context);
this.filterHandler = new FilterHandler(this.context);
}
onChange(callback) {
this.triggerHandler.set(callback);
}
invalidate() {
this.triggerHandler.invalidate();
}
setRows(data) {
this.context.rows.set(data);
}
setTotalRows(value) {
this.context.totalRows.set(value);
}
getRows() {
return this.context.rows;
}
select(value) {
this.selectHandler.set(value);
}
getSelected() {
return this.context.selected;
}
selectAll() {
this.selectHandler.all();
}
isAllSelected() {
return this.context.isAllSelected;
}
getSelectedCount() {
return this.context.selectedCount;
}
clearSelection() {
this.selectHandler.clear();
}
getRowsPerPage() {
return this.context.rowsPerPage;
}
sort(orderBy) {
this.setPage(1);
this.sortHandler.set(orderBy);
}
applySort(params = null) {
this.sortHandler.apply(params);
}
sortAsc(orderBy) {
this.setPage(1);
this.sortHandler.asc(orderBy);
}
sortDesc(orderBy) {
this.setPage(1);
this.sortHandler.desc(orderBy);
}
getSort() {
return this.context.sort;
}
search(value) {
this.setPage(1);
this.context.search.set(value);
}
clearSearch() {
this.searchHandler.remove();
}
filter(value, filterBy) {
this.setPage(1);
return this.filterHandler.set(value, filterBy);
}
clearFilters() {
this.filterHandler.remove();
}
getPages(params = { ellipsis: false }) {
if (params.ellipsis) {
return this.context.pagesWithEllipsis;
}
return this.context.pages;
}
getPageCount() {
return this.context.pageCount;
}
getPageNumber() {
return this.context.pageNumber;
}
setPage(value) {
switch (value) {
case 'previous': return this.pageHandler.previous();
case 'next': return this.pageHandler.next();
default: return this.pageHandler.goto(value);
}
}
getRowCount() {
return this.context.rowCount;
}
on(event, callback) {
this.context.event.add(event, callback);
}
translate(i18n) {
return {
...{
search: 'Search...',
show: 'Show',
entries: 'entries',
filter: 'Filter',
rowCount: 'Showing {start} to {end} of {total} entries',
noRows: 'No entries found',
previous: 'Previous',
next: 'Next',
selectedCount: '{count} of {total} row(s).'
},
...i18n
};
}
/**
*
* @depracted use on('change', callback) instead
*/
getTriggerChange() {
return this.context.event.triggerChange;
}
/**
*
* @deprecated use applySort() instead
*/
applySorting(params = null) {
this.applySort(params);
}
/**
*
* @deprecated use getSort() instead
*/
getSorted() {
return this.getSort();
}
}

View File

@@ -0,0 +1,98 @@
<script lang="ts">import { Search, RowsPerPage, RowCount, SelectedCount, Pagination } from './';
export let handler;
export let search = true;
export let rowsPerPage = true;
export let rowCount = true;
export let selectedCount = false;
export let pagination = true;
let element;
let clientWidth = 1000;
const height = (search || rowsPerPage ? 48 : 8) + (rowCount || selectedCount || pagination ? 48 : 8);
handler.on('change', () => {
if (element)
element.scrollTop = 0;
});
</script>
<section bind:clientWidth class={$$props.class ?? ''}>
<header class:container={search || rowsPerPage}>
{#if search}
<Search {handler} />
{:else}
<div></div>
{/if}
{#if rowsPerPage}
<RowsPerPage {handler} small={clientWidth < 600} />
{/if}
</header>
<article bind:this={element} style="height:calc(100% - {height}px)">
<slot />
</article>
<footer class:container={rowCount || pagination}>
{#if selectedCount}
<SelectedCount {handler}/>
{:else if rowCount}
<RowCount {handler} small={clientWidth < 600} />
{/if}
{#if pagination}
<Pagination {handler} small={clientWidth < 600} />
{/if}
</footer>
</section>
<style>
section {
height: 100%;
}
section :global(table) {
border-collapse: separate;
border-spacing: 0;
width: 100%;
}
section :global(thead) {
position: sticky;
inset-block-start: 0;
z-index: 1;
}
header,
footer {
min-height: 8px;
padding: 0 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
header.container,
footer.container {
height: 48px;
}
footer {
border-top: 1px solid #e0e0e0;
}
article {
position: relative;
/* height:calc(100% - 96px); */
overflow: auto;
scrollbar-width: thin;
}
article::-webkit-scrollbar {
width: 6px;
height: 6px;
}
article::-webkit-scrollbar-track {
background: #f5f5f5;
}
article::-webkit-scrollbar-thumb {
background: #c2c2c2;
}
article::-webkit-scrollbar-thumb:hover {
background: #9e9e9e;
}
</style>

View File

@@ -0,0 +1,36 @@
import { type DataHandler, type Row } from './';
declare class __sveltets_Render<T extends Row> {
props(): {
[x: string]: any;
handler: DataHandler<T>;
search?: boolean;
rowsPerPage?: boolean;
rowCount?: boolean;
selectedCount?: boolean;
pagination?: boolean;
};
events(): {} & {
[evt: string]: CustomEvent<any>;
};
slots(): {
default: {};
};
bindings(): string;
exports(): {};
}
interface $$IsomorphicComponent {
new <T extends Row>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']> & {
children?: any;
}>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
} & ReturnType<__sveltets_Render<T>['exports']>;
<T extends Row>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {
$$events?: ReturnType<__sveltets_Render<T>['events']>;
$$slots?: ReturnType<__sveltets_Render<T>['slots']>;
children?: any;
}): ReturnType<__sveltets_Render<T>['exports']>;
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
}
declare const Datatable: $$IsomorphicComponent;
type Datatable<T extends Row> = InstanceType<typeof Datatable<T>>;
export default Datatable;

View File

@@ -0,0 +1,146 @@
<script lang="ts">export let handler;
export let small = false;
const pageNumber = handler.getPageNumber();
const pageCount = handler.getPageCount();
const pages = handler.getPages({ ellipsis: true });
const setPage = (value) => {
handler.setPage(value);
handler.invalidate();
};
</script>
<section class={$$props.class ?? ''}>
{#if $pages === undefined}
<button type="button"
class="small"
class:disabled={$pageNumber === 1}
on:click={() => setPage('previous')}
>
&#10094;
</button>
<button type="button" class="page">page <b>{$pageNumber}</b></button>
<button type="button"
class="small"
on:click={() => setPage('next')}
>
&#10095;
</button>
{:else}
{#if small}
<button type="button"
class="small"
class:disabled={$pageNumber === 1}
on:click={() => setPage(1)}
>
&#10092;&#10092;
</button>
<button type="button"
class:disabled={$pageNumber === 1}
on:click={() => setPage('previous')}
>
&#10094;
</button>
<button type="button"
class:disabled={$pageNumber === $pageCount}
on:click={() => setPage('next')}
>
&#10095;
</button>
<button type="button"
class="small"
class:disabled={$pageNumber === $pageCount}
on:click={() => setPage($pageCount)}
>
&#10093;&#10093;
</button>
{:else}
<button type="button"
class:disabled={$pageNumber === 1}
on:click={() => setPage('previous')}
>
{@html handler.i18n.previous}
</button>
{#each $pages as page}
<button type="button"
class:active={$pageNumber === page}
class:ellipse={page === null}
on:click={() => setPage(page)}
>
{page ?? '...'}
</button>
{/each}
<button type="button"
class:disabled={$pageNumber === $pageCount}
on:click={() => setPage('next')}
>
{@html handler.i18n.next}
</button>
{/if}
{/if}
</section>
<style>
section {
display: flex;
}
button {
background: inherit;
height: 32px;
width: 32px;
color: #616161;
cursor: pointer;
font-size: 13px;
margin: 0;
padding: 0;
transition: all, 0.2s;
line-height: 32px;
border: 1px solid #e0e0e0;
border-right: none;
border-radius: 0;
outline: none;
}
button:first-child {
border-radius: 4px 0 0 4px;
}
button:last-child {
border-right: 1px solid #e0e0e0;
border-radius: 0 4px 4px 0;
}
button:first-child:not(.small),
button:last-child:not(.small) {
min-width: 72px;
}
button:not(.active):hover {
background: #eee;
}
button.disabled:hover {
background: inherit;
cursor: default;
}
button.page {
width: 72px;
background: #fafafa;
}
button.page:hover {
background: #fafafa;
cursor: default;
}
button.ellipse:hover {
background: inherit;
cursor: default;
}
button.active {
background: #eee;
font-weight: bold;
cursor: default;
}
button.disabled {
color: #bdbdbd;
}
button.disabled:hover {
background: inherit;
cursor: default;
}
</style>

View File

@@ -0,0 +1,26 @@
import type { DataHandler, Row } from './';
declare class __sveltets_Render<T extends Row> {
props(): {
[x: string]: any;
handler: DataHandler<T>;
small?: boolean;
};
events(): {} & {
[evt: string]: CustomEvent<any>;
};
slots(): {};
bindings(): string;
exports(): {};
}
interface $$IsomorphicComponent {
new <T extends Row>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
} & ReturnType<__sveltets_Render<T>['exports']>;
<T extends Row>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {
$$events?: ReturnType<__sveltets_Render<T>['events']>;
}): ReturnType<__sveltets_Render<T>['exports']>;
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
}
declare const Pagination: $$IsomorphicComponent;
type Pagination<T extends Row> = InstanceType<typeof Pagination<T>>;
export default Pagination;

View File

@@ -0,0 +1,12 @@
<div align="center">
<img align="center" src="../../../static/logo-remote.svg" alt="logo" width="172"/>
<p align="center">
<h1 align="center" style="font-size:32px;margin:0;border:none;">svelte simple datatables</h1>
<p style="color:#eee">For server-side pagination</p>
<img src="https://img.shields.io/npm/v/@vincjo/datatables?color=%23006990" alt="npm version"/>
<img src="https://img.shields.io/github/license/vincjo/datatables?color=006990" alt="last commit"/>
</p>
</div>
<p><strong><a href="https://vincjo.fr/datatables/remote/home" rel="nofollow">Home</a></strong></p>
<p><strong><a href="https://vincjo.fr/datatables/remote/examples" rel="nofollow">Examples</a></strong></p>

View File

@@ -0,0 +1,36 @@
<script lang="ts">export let handler;
export let small = false;
const rowCount = handler.getRowCount();
</script>
{#if $rowCount === undefined}
<div></div>
{:else}
<aside class={$$props.class ?? ''}>
{#if small}
{#if $rowCount.total > 0}
<b>{$rowCount.start}</b>-
<b>{$rowCount.end}</b>/
<b>{$rowCount.total}</b>
{:else}
{handler.i18n.noRows}
{/if}
{:else if $rowCount.total > 0}
{@html handler.i18n.rowCount
.replace('{start}', `<b>${$rowCount.start}</b>`)
.replace('{end}', `<b>${$rowCount.end}</b>`)
.replace('{total}', `<b>${$rowCount.total}</b>`)}
{:else}
{handler.i18n.noRows}
{/if}
</aside>
{/if}
<style>
aside {
color: #616161;
line-height: 32px;
font-size: 14px;
}
</style>

View File

@@ -0,0 +1,26 @@
import type { DataHandler, Row } from './';
declare class __sveltets_Render<T extends Row> {
props(): {
[x: string]: any;
handler: DataHandler<T>;
small?: boolean;
};
events(): {} & {
[evt: string]: CustomEvent<any>;
};
slots(): {};
bindings(): string;
exports(): {};
}
interface $$IsomorphicComponent {
new <T extends Row>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
} & ReturnType<__sveltets_Render<T>['exports']>;
<T extends Row>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {
$$events?: ReturnType<__sveltets_Render<T>['events']>;
}): ReturnType<__sveltets_Render<T>['exports']>;
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
}
declare const RowCount: $$IsomorphicComponent;
type RowCount<T extends Row> = InstanceType<typeof RowCount<T>>;
export default RowCount;

View File

@@ -0,0 +1,38 @@
<script lang="ts">export let handler;
export let small = false;
export let options = [5, 10, 20];
const rowsPerPage = handler.getRowsPerPage();
const setRowsPerPage = () => {
handler.setPage(1);
handler.invalidate();
};
</script>
<aside class={$$props.class ?? ''}>
{#if !small}
<span>{handler.i18n.show}</span>
{/if}
<select bind:value={$rowsPerPage} on:change={setRowsPerPage}>
{#each options as option}
<option value={option}>
{option}
</option>
{/each}
</select>
{#if !small}
<span>{handler.i18n.entries}</span>
{/if}
</aside>
<style>
aside {
display: flex;
justify-content: flex-start;
align-items: center;
height: 32px;
color: #757575;
}
select {
margin: 0 4px;
}
</style>

View File

@@ -0,0 +1,27 @@
import type { DataHandler, Row } from './';
declare class __sveltets_Render<T extends Row> {
props(): {
[x: string]: any;
handler: DataHandler<T>;
small?: boolean;
options?: number[];
};
events(): {} & {
[evt: string]: CustomEvent<any>;
};
slots(): {};
bindings(): string;
exports(): {};
}
interface $$IsomorphicComponent {
new <T extends Row>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
} & ReturnType<__sveltets_Render<T>['exports']>;
<T extends Row>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {
$$events?: ReturnType<__sveltets_Render<T>['events']>;
}): ReturnType<__sveltets_Render<T>['exports']>;
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
}
declare const RowsPerPage: $$IsomorphicComponent;
type RowsPerPage<T extends Row> = InstanceType<typeof RowsPerPage<T>>;
export default RowsPerPage;

View File

@@ -0,0 +1,43 @@
<script lang="ts">export let handler;
let value = '';
let timeout;
const search = () => {
handler.search(value);
clearTimeout(timeout);
timeout = setTimeout(() => {
handler.invalidate();
}, 400);
};
</script>
<input
class={$$props.class ?? ''}
bind:value
placeholder={handler.i18n.search}
spellcheck="false"
on:input={search}
/>
<style>
input {
border: 1px solid #e0e0e0;
border-radius: 4px;
outline: none;
padding: 0 8px;
line-height: 24px;
margin: 0;
height: 24px;
background: transparent;
width: 40%;
max-width: 176px;
min-width: 96px;
transition: all, 0.1s;
}
input:focus {
border: 1px solid #bdbdbd;
}
input::placeholder {
color: #9e9e9e;
line-height: 24px;
}
</style>

View File

@@ -0,0 +1,25 @@
import type { DataHandler, Row } from './';
declare class __sveltets_Render<T extends Row> {
props(): {
[x: string]: any;
handler: DataHandler<T>;
};
events(): {} & {
[evt: string]: CustomEvent<any>;
};
slots(): {};
bindings(): string;
exports(): {};
}
interface $$IsomorphicComponent {
new <T extends Row>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
} & ReturnType<__sveltets_Render<T>['exports']>;
<T extends Row>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {
$$events?: ReturnType<__sveltets_Render<T>['events']>;
}): ReturnType<__sveltets_Render<T>['exports']>;
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
}
declare const Search: $$IsomorphicComponent;
type Search<T extends Row> = InstanceType<typeof Search<T>>;
export default Search;

View File

@@ -0,0 +1,32 @@
<script lang="ts">export let handler;
const selectedCount = handler.getSelectedCount();
</script>
<aside class={$$props.class ?? ''}>
<b>{$selectedCount.count}</b>
{#if $selectedCount.total}
of <b>{$selectedCount.total}</b>
{/if}
row(s) selected.
{#if $selectedCount.count > 0}
<button type="button" on:click={() => handler.clearSelection()}> Clear</button>
{/if}
</aside>
<style>
aside {
color: #616161;
line-height: 32px;
font-size: 14px;
}
button {
background: #fafafa;
padding: 2px 4px;
border-radius: 2px;
cursor: pointer;
border: 1px solid #e0e0e0;
}
</style>

View File

@@ -0,0 +1,25 @@
import type { DataHandler, Row } from './';
declare class __sveltets_Render<T extends Row> {
props(): {
[x: string]: any;
handler: DataHandler<T>;
};
events(): {} & {
[evt: string]: CustomEvent<any>;
};
slots(): {};
bindings(): string;
exports(): {};
}
interface $$IsomorphicComponent {
new <T extends Row>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
} & ReturnType<__sveltets_Render<T>['exports']>;
<T extends Row>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {
$$events?: ReturnType<__sveltets_Render<T>['events']>;
}): ReturnType<__sveltets_Render<T>['exports']>;
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
}
declare const SelectedCount: $$IsomorphicComponent;
type SelectedCount<T extends Row> = InstanceType<typeof SelectedCount<T>>;
export default SelectedCount;

View File

@@ -0,0 +1,81 @@
<script lang="ts">export let handler;
export let orderBy;
export let align = 'left';
export let rowSpan = 1;
const sort = handler.getSort();
const update = () => {
handler.sort(orderBy);
handler.invalidate();
};
</script>
<th
on:click={update}
class:sortable={orderBy}
class:active={$sort?.orderBy === orderBy}
class={$$props.class ?? ''}
rowspan={rowSpan}
>
<div
class="flex"
style:justify-content={align === 'left' ? 'flex-start' : align === 'right' ? 'flex-end' : 'center'}
>
<strong>
<slot />
</strong>
<span class:asc={$sort?.direction === 'asc'} class:desc={$sort?.direction === 'desc'}></span>
</div>
</th>
<style>
th {
background: inherit;
padding: 8px 20px;
white-space: nowrap;
font-size: 13px;
user-select: none;
border-bottom: 1px solid #e0e0e0;
}
th.sortable {
cursor: pointer;
}
th strong {
white-space: pre-wrap;
font-size: 13.5px;
line-height: 16px;
}
th.sortable div.flex {
padding: 0;
display: flex;
align-items: center;
height: 100%;
}
th.sortable span {
padding-left: 8px;
}
th.sortable span:before,
th.sortable span:after {
border: 4px solid transparent;
content: '';
display: block;
height: 0;
width: 0;
}
th.sortable span:before {
border-bottom-color: #e0e0e0;
margin-top: 2px;
}
th.sortable span:after {
border-top-color: #e0e0e0;
margin-top: 2px;
}
th.active.sortable span.asc:before {
border-bottom-color: #9e9e9e;
}
th.active.sortable span.desc:after {
border-top-color: #9e9e9e;
}
th:not(.sortable) span {
visibility: hidden;
}
</style>

View File

@@ -0,0 +1,34 @@
import type { DataHandler, Row } from './';
declare class __sveltets_Render<T extends Row> {
props(): {
[x: string]: any;
handler: DataHandler<T>;
orderBy: keyof T;
align?: "left" | "right" | "center";
rowSpan?: number;
};
events(): {} & {
[evt: string]: CustomEvent<any>;
};
slots(): {
default: {};
};
bindings(): string;
exports(): {};
}
interface $$IsomorphicComponent {
new <T extends Row>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']> & {
children?: any;
}>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
} & ReturnType<__sveltets_Render<T>['exports']>;
<T extends Row>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {
$$events?: ReturnType<__sveltets_Render<T>['events']>;
$$slots?: ReturnType<__sveltets_Render<T>['slots']>;
children?: any;
}): ReturnType<__sveltets_Render<T>['exports']>;
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
}
declare const Th: $$IsomorphicComponent;
type Th<T extends Row> = InstanceType<typeof Th<T>>;
export default Th;

View File

@@ -0,0 +1,51 @@
<script lang="ts">export let handler;
export let filterBy;
export let align = 'left';
let value = '';
let timeout;
const filter = () => {
handler.filter(value, filterBy);
clearTimeout(timeout);
timeout = setTimeout(() => {
handler.invalidate();
}, 400);
};
</script>
<th class={$$props.class ?? ''}>
<input
style:text-align={align}
type="text"
placeholder={handler.i18n.filter}
spellcheck="false"
bind:value
on:input={filter}
/>
</th>
<style>
th {
border-bottom: 1px solid #e0e0e0;
}
input {
width: 100%;
height: 24px;
border: none;
text-align: left;
padding: 0 20px;
background: inherit;
outline: none;
border-radius: 0;
font-size: 14px;
font-family: Arial, Helvetica, sans-serif;
}
input::placeholder {
color: #bdbdbd;
font-style: italic;
font-size: 13px;
}
input:focus {
outline: none;
border: none;
}
</style>

View File

@@ -0,0 +1,27 @@
import type { DataHandler, Row } from './';
declare class __sveltets_Render<T extends Row> {
props(): {
[x: string]: any;
handler: DataHandler<T>;
filterBy: keyof T;
align?: "left" | "right" | "center";
};
events(): {} & {
[evt: string]: CustomEvent<any>;
};
slots(): {};
bindings(): string;
exports(): {};
}
interface $$IsomorphicComponent {
new <T extends Row>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
} & ReturnType<__sveltets_Render<T>['exports']>;
<T extends Row>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {
$$events?: ReturnType<__sveltets_Render<T>['events']>;
}): ReturnType<__sveltets_Render<T>['exports']>;
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
}
declare const ThFilter: $$IsomorphicComponent;
type ThFilter<T extends Row> = InstanceType<typeof ThFilter<T>>;
export default ThFilter;

View File

@@ -0,0 +1,6 @@
export default class EventHandler {
private events;
triggerChange: import("svelte/store").Writable<number>;
add(event: keyof EventHandler['events'], callback: () => void): void;
trigger(event: keyof EventHandler['events']): void;
}

View File

@@ -0,0 +1,21 @@
import { writable } from 'svelte/store';
export default class EventHandler {
events = {
change: [],
clearFilters: [],
clearSearch: []
};
triggerChange = writable(0); // legacy
add(event, callback) {
this.events[event].push(callback);
}
trigger(event) {
for (const callback of this.events[event]) {
callback();
}
/* legacy: support for triggerChange store */
if (event === 'change') {
this.triggerChange.update((store) => { return store + 1; });
}
}
}

View File

@@ -0,0 +1,9 @@
import type { Filter } from '..';
import type Context from '../Context';
import type { Writable } from 'svelte/store';
export default class FilterHandler<Row> {
filters: Writable<Filter<Row>[]>;
constructor(context: Context<Row>);
set(value: string | number, filterBy: keyof Row): void;
remove(): void;
}

View File

@@ -0,0 +1,21 @@
export default class FilterHandler {
filters;
constructor(context) {
this.filters = context.filters;
}
set(value, filterBy) {
const filter = { filterBy, value };
this.filters.update((store) => {
store = store.filter((item) => {
return (item.filterBy !== filterBy) && item.value;
});
if (value) {
store.push(filter);
}
return store;
});
}
remove() {
this.filters.set([]);
}
}

View File

@@ -0,0 +1,21 @@
import type Context from '../Context';
import { type Writable, type Readable } from 'svelte/store';
import type EventHandler from './EventHandler';
export default class PageHandler<Row> {
totalRows: Writable<number>;
pageNumber: Writable<number>;
rowCount: Readable<{
total: number;
start: number;
end: number;
}>;
rowsPerPage: Writable<number | null>;
event: EventHandler;
pages: Readable<number[]>;
selected: Writable<(Row | Row[keyof Row])[]>;
constructor(context: Context<Row>);
get(): Readable<number[]>;
goto(number: number): void;
previous(): void;
next(): void;
}

View File

@@ -0,0 +1,50 @@
import { get } from 'svelte/store';
export default class PageHandler {
totalRows;
pageNumber;
rowCount;
rowsPerPage;
event;
pages;
selected;
constructor(context) {
this.totalRows = context.totalRows;
this.pageNumber = context.pageNumber;
this.rowCount = context.rowCount;
this.rowsPerPage = context.rowsPerPage;
this.event = context.event;
this.pages = context.pages;
this.selected = context.selected;
}
get() {
return this.pages;
}
goto(number) {
const rowsPerPage = get(this.rowsPerPage);
const totalRows = get(this.totalRows);
this.pageNumber.update((store) => {
if (rowsPerPage && totalRows) {
if (number >= 1 && number <= Math.ceil(totalRows / rowsPerPage)) {
store = number;
this.event.trigger('change');
}
return store;
}
else {
if (number >= 1) {
store = number;
this.event.trigger('change');
}
return store;
}
});
}
previous() {
const number = get(this.pageNumber) - 1;
this.goto(number);
}
next() {
const number = get(this.pageNumber) + 1;
this.goto(number);
}
}

View File

@@ -0,0 +1,7 @@
import type Context from '../Context';
export default class SearchHandler<Row> {
private search;
constructor(context: Context<Row>);
set(value: string): void;
remove(): void;
}

View File

@@ -0,0 +1,12 @@
export default class SearchHandler {
search;
constructor(context) {
this.search = context.search;
}
set(value) {
this.search.set(value ?? null);
}
remove() {
this.search.set(null);
}
}

View File

@@ -0,0 +1,11 @@
import type Context from '../Context';
export default class SelectHandler<Row> {
private rows;
private selected;
private isAllSelected;
private selectBy;
constructor(context: Context<Row>);
set(value: Row | Row[keyof Row]): void;
all(): void;
clear(): void;
}

View File

@@ -0,0 +1,46 @@
import { get } from 'svelte/store';
export default class SelectHandler {
rows;
selected;
isAllSelected;
selectBy;
constructor(context) {
this.rows = context.rows;
this.selected = context.selected;
this.isAllSelected = context.isAllSelected;
this.selectBy = context.selectBy;
}
set(value) {
const selected = get(this.selected);
if (selected.includes(value)) {
this.selected.set(selected.filter((item) => item !== value));
}
else {
this.selected.set([value, ...selected]);
}
}
all() {
const rows = get(this.rows);
const isAllSelected = get(this.isAllSelected);
this.selected.update(store => {
if (this.selectBy) {
return store = store.filter(item => !rows.map((row) => row[this.selectBy]).includes(item));
}
return store = store.filter(item => !rows.includes(item));
});
if (!isAllSelected) {
this.selected.update(store => {
if (this.selectBy) {
store = [...rows.map((row) => row[this.selectBy]), ...store];
}
else {
store = [...rows, ...store];
}
return store;
});
}
}
clear() {
this.selected.set([]);
}
}

View File

@@ -0,0 +1,14 @@
import type Context from '../Context';
export default class SortHandler<Row> {
private event;
private hasMultipleSort;
private sort;
constructor(context: Context<Row>);
set(orderBy?: keyof Row): void;
asc(orderBy: keyof Row): void;
desc(orderBy: keyof Row): void;
apply(params?: {
orderBy: keyof Row;
direction?: 'asc' | 'desc';
}): any;
}

View File

@@ -0,0 +1,51 @@
import { get } from 'svelte/store';
export default class SortHandler {
event;
hasMultipleSort;
sort;
constructor(context) {
this.event = context.event;
this.hasMultipleSort = false;
this.sort = context.sort;
}
set(orderBy = null) {
if (!orderBy)
return;
const sort = get(this.sort);
if (!sort || sort.orderBy !== orderBy) {
this.asc(orderBy);
}
else if (sort.direction === 'asc') {
this.desc(sort.orderBy);
}
else if (sort.direction === 'desc') {
this.asc(orderBy);
}
}
asc(orderBy) {
if (!orderBy)
return;
this.sort.set({ orderBy, direction: 'asc' });
this.event.trigger('change');
}
desc(orderBy) {
if (!orderBy)
return;
this.sort.set({ orderBy, direction: 'desc' });
this.event.trigger('change');
}
apply(params = null) {
if (params) {
switch (params.direction) {
case 'asc': return this.asc(params.orderBy);
case 'desc': return this.desc(params.orderBy);
default: return this.set(params.orderBy);
}
}
const sort = get(this.sort);
if (sort) {
return this.apply({ orderBy: sort.orderBy, direction: sort.direction });
}
return;
}
}

View File

@@ -0,0 +1,9 @@
import type { State } from '..';
import type Context from '../Context';
export default class TriggerHandler<Row> {
private context;
private reload;
constructor(context: Context<Row>);
set(callback: (state: State) => Promise<Row[]>): void;
invalidate(): Promise<void>;
}

View File

@@ -0,0 +1,19 @@
export default class TriggerHandler {
context;
reload;
constructor(context) {
this.context = context;
}
set(callback) {
this.reload = callback;
}
async invalidate() {
if (!this.reload)
return;
const state = this.context.getState();
const data = await this.reload(state);
if (data) {
this.context.rows.set(data);
}
}
}

View File

@@ -0,0 +1,59 @@
import DataHandler from './DataHandler';
import Datatable from './Datatable.svelte';
import Search from './Search.svelte';
import RowsPerPage from './RowsPerPage.svelte';
import Th from './Th.svelte';
import ThFilter from './ThFilter.svelte';
import RowCount from './RowCount.svelte';
import SelectedCount from './SelectedCount.svelte';
import Pagination from './Pagination.svelte';
export { DataHandler, Datatable, Search, RowsPerPage, Th, ThFilter, RowCount, SelectedCount, Pagination };
export type Internationalization = {
search?: string;
show?: string;
entries?: string;
filter?: string;
rowCount?: string;
noRows?: string;
previous?: string;
next?: string;
selectedCount?: string;
};
export type Row = {
[key: string]: unknown;
};
export type Filter<Row> = {
filterBy: keyof Row;
value?: string | number | boolean;
};
export type Sort<Row> = {
orderBy?: keyof Row;
direction?: 'asc' | 'desc';
};
export type State = {
pageNumber: number;
rowsPerPage: number;
offset: number;
search: string | undefined;
sort: Sort<Row> | undefined;
filters: Filter<Row>[] | undefined;
setTotalRows: (value: number) => void;
/**
* @deprecated use 'sort' instead
*/
sorted: Sort<Row> | undefined;
};
/**
* @deprecated use (Row[keyof Row] | Row) instead
* @since v1.13.0 2023-11-14
*
* import type { Row } from '@vincjo/datatables'
*/
export type Selectable<Row> = Row[keyof Row] | Row;
/**
* @deprecated use type Sort<Row> instead
* @since v1.13.0 2023-11-14
*
* import type { Sort } from '@vincjo/datatables'
*/
export type Order<Row> = Sort<Row>;

View File

@@ -0,0 +1,11 @@
// Reexport your entry components here
import DataHandler from './DataHandler';
import Datatable from './Datatable.svelte';
import Search from './Search.svelte';
import RowsPerPage from './RowsPerPage.svelte';
import Th from './Th.svelte';
import ThFilter from './ThFilter.svelte';
import RowCount from './RowCount.svelte';
import SelectedCount from './SelectedCount.svelte';
import Pagination from './Pagination.svelte';
export { DataHandler, Datatable, Search, RowsPerPage, Th, ThFilter, RowCount, SelectedCount, Pagination };