@at-flux/astroflare 1.0.6 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/dist/core.cjs +45 -8
- package/dist/core.d.cts +12 -1
- package/dist/core.d.ts +12 -1
- package/dist/core.js +41 -9
- package/dist/index.cjs +5 -0
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/package.json +1 -1
- package/scripts/link-local.mjs +8 -2
- package/src/collection-query.ts +79 -9
- package/src/components/CollectionFooterControls.astro +84 -0
- package/src/components/CollectionQuery.astro +55 -9
- package/src/components/FilterPills.astro +74 -11
- package/src/components/Pager.astro +40 -8
package/README.md
CHANGED
|
@@ -50,6 +50,7 @@ import { forms } from "@at-flux/astroflare/core";
|
|
|
50
50
|
- `FilterPills.astro` — Tag-colored filter chips with an `all` option and active-state styling
|
|
51
51
|
- `Pager.astro` — Pagination UI primitive for both browser-only and link-driven query pagination
|
|
52
52
|
- `CollectionQuery.astro` — Unified collection filtering/pagination component (client mode by default; URL-driven server mode with `useServer`)
|
|
53
|
+
- `CollectionFooterControls.astro` — Server-only row: optional `summary` slot, `Pager`, and page-size `<form>` (same query contract as `CollectionQuery` server mode)
|
|
53
54
|
|
|
54
55
|
#### Component props reference
|
|
55
56
|
|
|
@@ -68,6 +69,7 @@ import { forms } from "@at-flux/astroflare/core";
|
|
|
68
69
|
- `Pager.astro`: `pageCount`, `activePage`, `items`, `class`
|
|
69
70
|
- `CollectionQuery.astro`: `useServer`, `pathname`, `query`, `totalPages`, `currentPage`, `filters`, `maxPageButtons`, `filtersClass`, `pagerClass`, `perPage`, `class`
|
|
70
71
|
- when `useServer` is `true`, `pathname`, `query`, `totalPages`, and `currentPage` are required
|
|
72
|
+
- `CollectionFooterControls.astro`: `pathname`, `query`, `totalPages`, `currentPage`, `sizeOptions`, `maxPageButtons`, `class` — slot `summary` for “Showing X–Y of Z” text
|
|
71
73
|
|
|
72
74
|
### Server Islands Pattern
|
|
73
75
|
|
|
@@ -122,7 +124,8 @@ Use named slots to replace the default filter/pager rendering:
|
|
|
122
124
|
|
|
123
125
|
- `getTagPalette(tag, options?)` — Deterministic, readable tag color assignment with optional explicit overrides
|
|
124
126
|
- `formatDisplayDate(date, config?)` — Consistent card/detail date formatting with locale override support
|
|
125
|
-
- `parseCollectionQuery
|
|
127
|
+
- `parseCollectionQuery` + `paginateCollection` + `buildCollectionHref` + `buildPageSequence` + `matchesCollectionFilters` + `formatCollectionRangeLabel` + `resolveIslandSearchString` — URL-driven filtering and pagination (`filters` as stringified JSON). `resolveIslandSearchString` is for server islands (pass the page’s search from the page; fall back to `Referer`). `formatCollectionRangeLabel` is for “Showing result N of T” / “Showing results a–b of T” footers.
|
|
128
|
+
- `astroflare-link-local` is **shipped in this package**; run it from any project that depends on `@at-flux/astroflare` (it walks up to the nearest `package.json` that lists the dep). `status` shows whether a local overlay is active. See **Local checkout** below.
|
|
126
129
|
|
|
127
130
|
## Usage
|
|
128
131
|
|
package/dist/core.cjs
CHANGED
|
@@ -77,21 +77,25 @@ const formatDisplayDate = (date, config = {}) => {
|
|
|
77
77
|
};
|
|
78
78
|
//#endregion
|
|
79
79
|
//#region src/collection-query.ts
|
|
80
|
+
const normalizeFilterToken = (value) => value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").replace(/_+/g, "_");
|
|
81
|
+
const parseFilterValueList = (value) => (value ?? "").split(",").map(normalizeFilterToken).filter(Boolean).filter((token, index, list) => list.indexOf(token) === index);
|
|
82
|
+
const formatFilterValueListLabel = (value) => parseFilterValueList(value).map((token) => token.replace(/_/g, " ").toUpperCase()).join(" + ");
|
|
80
83
|
const toInt = (value, fallback) => {
|
|
81
84
|
const parsed = Number.parseInt(value ?? "", 10);
|
|
82
85
|
return Number.isFinite(parsed) ? parsed : fallback;
|
|
83
86
|
};
|
|
84
87
|
const parseCollectionQuery = (searchParams, options = {}) => {
|
|
85
88
|
const { defaultPage = 1, defaultSize = 12, maxSize = 100 } = options;
|
|
86
|
-
const page = Math.max(1, toInt(searchParams.get("page"), defaultPage));
|
|
87
89
|
const size = Math.min(maxSize, Math.max(1, toInt(searchParams.get("size"), defaultSize)));
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
+
const pageParam = Math.max(1, toInt(searchParams.get("page"), defaultPage));
|
|
91
|
+
const rawOffset = searchParams.has("offset") ? Math.max(0, toInt(searchParams.get("offset"), 0)) : (pageParam - 1) * size;
|
|
92
|
+
const page = Math.max(1, Math.floor(rawOffset / size) + 1);
|
|
93
|
+
const offset = (page - 1) * size;
|
|
90
94
|
const rawFilters = searchParams.get("filters");
|
|
91
95
|
let filters = {};
|
|
92
96
|
if (rawFilters) try {
|
|
93
97
|
const parsed = JSON.parse(rawFilters);
|
|
94
|
-
filters = Object.fromEntries(Object.entries(parsed).filter(([, value]) => typeof value === "string").map(([key, value]) => [key, String(value)]));
|
|
98
|
+
filters = Object.fromEntries(Object.entries(parsed).filter(([, value]) => typeof value === "string").map(([key, value]) => [key, parseFilterValueList(String(value)).join(",")]));
|
|
95
99
|
} catch {
|
|
96
100
|
filters = {};
|
|
97
101
|
}
|
|
@@ -120,17 +124,22 @@ const buildCollectionHref = (pathname, query, overrides = {}) => {
|
|
|
120
124
|
...query,
|
|
121
125
|
...overrides
|
|
122
126
|
};
|
|
123
|
-
const
|
|
127
|
+
const offsetFromPage = (next.page - 1) * next.size;
|
|
124
128
|
const params = new URLSearchParams({
|
|
125
129
|
page: String(next.page),
|
|
126
130
|
size: String(next.size),
|
|
127
|
-
offset: String(
|
|
131
|
+
offset: String(offsetFromPage)
|
|
128
132
|
});
|
|
129
|
-
if (Object.keys(next.filters ?? {}).length > 0)
|
|
133
|
+
if (Object.keys(next.filters ?? {}).length > 0) {
|
|
134
|
+
const normalizedFilters = Object.fromEntries(Object.entries(next.filters).map(([key, value]) => [key, parseFilterValueList(value).join(",")]));
|
|
135
|
+
params.set("filters", JSON.stringify(normalizedFilters));
|
|
136
|
+
}
|
|
130
137
|
return `${pathname}?${params.toString()}`;
|
|
131
138
|
};
|
|
132
139
|
const matchesCollectionFilters = (values, filters) => Object.entries(filters).every(([key, value]) => {
|
|
133
|
-
|
|
140
|
+
const expected = parseFilterValueList(value);
|
|
141
|
+
const candidates = (values[key] ?? []).map(normalizeFilterToken);
|
|
142
|
+
return expected.every((token) => candidates.includes(token));
|
|
134
143
|
});
|
|
135
144
|
const buildPageSequence = (totalPages, currentPage, maxButtons = 7) => {
|
|
136
145
|
if (totalPages <= maxButtons) return Array.from({ length: totalPages }, (_, index) => index + 1);
|
|
@@ -145,11 +154,36 @@ const buildPageSequence = (totalPages, currentPage, maxButtons = 7) => {
|
|
|
145
154
|
sequence.push(totalPages);
|
|
146
155
|
return sequence;
|
|
147
156
|
};
|
|
157
|
+
/**
|
|
158
|
+
* For Astro server islands: the serialized `search` prop is usually set from the
|
|
159
|
+
* page request, but the island subrequest may omit it. When empty, use the
|
|
160
|
+
* `Referer` request header so `parseCollectionQuery` still matches the main document URL.
|
|
161
|
+
*/
|
|
162
|
+
/** "Showing …" for collection footers: avoids "3—3" when one item; uses en dash for ranges. */
|
|
163
|
+
const formatCollectionRangeLabel = (slice) => {
|
|
164
|
+
if (slice.totalItems === 0) return "Showing 0 of 0";
|
|
165
|
+
const a = slice.start + 1;
|
|
166
|
+
const b = slice.end;
|
|
167
|
+
if (a === b) return `Showing result ${a} of ${slice.totalItems}`;
|
|
168
|
+
return `Showing results ${a}–${b} of ${slice.totalItems}`;
|
|
169
|
+
};
|
|
170
|
+
const resolveIslandSearchString = (searchFromProps, request) => {
|
|
171
|
+
if (searchFromProps) return searchFromProps;
|
|
172
|
+
const referer = request.headers.get("referer") ?? "";
|
|
173
|
+
if (!referer) return "";
|
|
174
|
+
try {
|
|
175
|
+
return new URL(referer).search;
|
|
176
|
+
} catch {
|
|
177
|
+
return "";
|
|
178
|
+
}
|
|
179
|
+
};
|
|
148
180
|
//#endregion
|
|
149
181
|
exports.buildCollectionHref = buildCollectionHref;
|
|
150
182
|
exports.buildPageSequence = buildPageSequence;
|
|
151
183
|
exports.composeEmailAddress = require_forms.composeEmailAddress;
|
|
184
|
+
exports.formatCollectionRangeLabel = formatCollectionRangeLabel;
|
|
152
185
|
exports.formatDisplayDate = formatDisplayDate;
|
|
186
|
+
exports.formatFilterValueListLabel = formatFilterValueListLabel;
|
|
153
187
|
Object.defineProperty(exports, "forms", {
|
|
154
188
|
enumerable: true,
|
|
155
189
|
get: function() {
|
|
@@ -160,7 +194,10 @@ exports.generateFormResultHtml = require_forms.generateFormResultHtml;
|
|
|
160
194
|
exports.generateFormSectionHtml = require_forms.generateFormSectionHtml;
|
|
161
195
|
exports.getTagPalette = getTagPalette;
|
|
162
196
|
exports.matchesCollectionFilters = matchesCollectionFilters;
|
|
197
|
+
exports.normalizeFilterToken = normalizeFilterToken;
|
|
163
198
|
exports.paginateCollection = paginateCollection;
|
|
164
199
|
exports.parseCollectionQuery = parseCollectionQuery;
|
|
200
|
+
exports.parseFilterValueList = parseFilterValueList;
|
|
165
201
|
exports.renderEmailTemplate = require_forms.renderEmailTemplate;
|
|
202
|
+
exports.resolveIslandSearchString = resolveIslandSearchString;
|
|
166
203
|
exports.sendEmail = require_forms.sendEmail;
|
package/dist/core.d.cts
CHANGED
|
@@ -50,10 +50,21 @@ interface PaginationSlice<T> {
|
|
|
50
50
|
start: number;
|
|
51
51
|
end: number;
|
|
52
52
|
}
|
|
53
|
+
declare const normalizeFilterToken: (value: string) => string;
|
|
54
|
+
declare const parseFilterValueList: (value: string | undefined) => string[];
|
|
55
|
+
declare const formatFilterValueListLabel: (value: string | undefined) => string;
|
|
53
56
|
declare const parseCollectionQuery: (searchParams: URLSearchParams, options?: CollectionQueryOptions) => CollectionQueryState;
|
|
54
57
|
declare const paginateCollection: <T>(list: T[], query: CollectionQueryState) => PaginationSlice<T>;
|
|
55
58
|
declare const buildCollectionHref: (pathname: string, query: CollectionQueryState, overrides?: Partial<CollectionQueryState>) => string;
|
|
56
59
|
declare const matchesCollectionFilters: (values: Record<string, string[]>, filters: Record<string, string>) => boolean;
|
|
57
60
|
declare const buildPageSequence: (totalPages: number, currentPage: number, maxButtons?: number) => Array<number | "\u2026">;
|
|
61
|
+
/**
|
|
62
|
+
* For Astro server islands: the serialized `search` prop is usually set from the
|
|
63
|
+
* page request, but the island subrequest may omit it. When empty, use the
|
|
64
|
+
* `Referer` request header so `parseCollectionQuery` still matches the main document URL.
|
|
65
|
+
*/
|
|
66
|
+
/** "Showing …" for collection footers: avoids "3—3" when one item; uses en dash for ranges. */
|
|
67
|
+
declare const formatCollectionRangeLabel: (slice: Pick<PaginationSlice<unknown>, "start" | "end" | "totalItems">) => string;
|
|
68
|
+
declare const resolveIslandSearchString: (searchFromProps: string | undefined, request: Request) => string;
|
|
58
69
|
//#endregion
|
|
59
|
-
export { CollectionQueryOptions, CollectionQueryState, DateFormatOptions, EmailPayload, FormSection, PaginationSlice, ResendConfig, TagColorOptions, TagPalette, buildCollectionHref, buildPageSequence, composeEmailAddress, formatDisplayDate, index_d_exports as forms, generateFormResultHtml, generateFormSectionHtml, getTagPalette, matchesCollectionFilters, paginateCollection, parseCollectionQuery, renderEmailTemplate, sendEmail };
|
|
70
|
+
export { CollectionQueryOptions, CollectionQueryState, DateFormatOptions, EmailPayload, FormSection, PaginationSlice, ResendConfig, TagColorOptions, TagPalette, buildCollectionHref, buildPageSequence, composeEmailAddress, formatCollectionRangeLabel, formatDisplayDate, formatFilterValueListLabel, index_d_exports as forms, generateFormResultHtml, generateFormSectionHtml, getTagPalette, matchesCollectionFilters, normalizeFilterToken, paginateCollection, parseCollectionQuery, parseFilterValueList, renderEmailTemplate, resolveIslandSearchString, sendEmail };
|
package/dist/core.d.ts
CHANGED
|
@@ -50,10 +50,21 @@ interface PaginationSlice<T> {
|
|
|
50
50
|
start: number;
|
|
51
51
|
end: number;
|
|
52
52
|
}
|
|
53
|
+
declare const normalizeFilterToken: (value: string) => string;
|
|
54
|
+
declare const parseFilterValueList: (value: string | undefined) => string[];
|
|
55
|
+
declare const formatFilterValueListLabel: (value: string | undefined) => string;
|
|
53
56
|
declare const parseCollectionQuery: (searchParams: URLSearchParams, options?: CollectionQueryOptions) => CollectionQueryState;
|
|
54
57
|
declare const paginateCollection: <T>(list: T[], query: CollectionQueryState) => PaginationSlice<T>;
|
|
55
58
|
declare const buildCollectionHref: (pathname: string, query: CollectionQueryState, overrides?: Partial<CollectionQueryState>) => string;
|
|
56
59
|
declare const matchesCollectionFilters: (values: Record<string, string[]>, filters: Record<string, string>) => boolean;
|
|
57
60
|
declare const buildPageSequence: (totalPages: number, currentPage: number, maxButtons?: number) => Array<number | "\u2026">;
|
|
61
|
+
/**
|
|
62
|
+
* For Astro server islands: the serialized `search` prop is usually set from the
|
|
63
|
+
* page request, but the island subrequest may omit it. When empty, use the
|
|
64
|
+
* `Referer` request header so `parseCollectionQuery` still matches the main document URL.
|
|
65
|
+
*/
|
|
66
|
+
/** "Showing …" for collection footers: avoids "3—3" when one item; uses en dash for ranges. */
|
|
67
|
+
declare const formatCollectionRangeLabel: (slice: Pick<PaginationSlice<unknown>, "start" | "end" | "totalItems">) => string;
|
|
68
|
+
declare const resolveIslandSearchString: (searchFromProps: string | undefined, request: Request) => string;
|
|
58
69
|
//#endregion
|
|
59
|
-
export { CollectionQueryOptions, CollectionQueryState, DateFormatOptions, EmailPayload, FormSection, PaginationSlice, ResendConfig, TagColorOptions, TagPalette, buildCollectionHref, buildPageSequence, composeEmailAddress, formatDisplayDate, index_d_exports as forms, generateFormResultHtml, generateFormSectionHtml, getTagPalette, matchesCollectionFilters, paginateCollection, parseCollectionQuery, renderEmailTemplate, sendEmail };
|
|
70
|
+
export { CollectionQueryOptions, CollectionQueryState, DateFormatOptions, EmailPayload, FormSection, PaginationSlice, ResendConfig, TagColorOptions, TagPalette, buildCollectionHref, buildPageSequence, composeEmailAddress, formatCollectionRangeLabel, formatDisplayDate, formatFilterValueListLabel, index_d_exports as forms, generateFormResultHtml, generateFormSectionHtml, getTagPalette, matchesCollectionFilters, normalizeFilterToken, paginateCollection, parseCollectionQuery, parseFilterValueList, renderEmailTemplate, resolveIslandSearchString, sendEmail };
|
package/dist/core.js
CHANGED
|
@@ -76,21 +76,25 @@ const formatDisplayDate = (date, config = {}) => {
|
|
|
76
76
|
};
|
|
77
77
|
//#endregion
|
|
78
78
|
//#region src/collection-query.ts
|
|
79
|
+
const normalizeFilterToken = (value) => value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").replace(/_+/g, "_");
|
|
80
|
+
const parseFilterValueList = (value) => (value ?? "").split(",").map(normalizeFilterToken).filter(Boolean).filter((token, index, list) => list.indexOf(token) === index);
|
|
81
|
+
const formatFilterValueListLabel = (value) => parseFilterValueList(value).map((token) => token.replace(/_/g, " ").toUpperCase()).join(" + ");
|
|
79
82
|
const toInt = (value, fallback) => {
|
|
80
83
|
const parsed = Number.parseInt(value ?? "", 10);
|
|
81
84
|
return Number.isFinite(parsed) ? parsed : fallback;
|
|
82
85
|
};
|
|
83
86
|
const parseCollectionQuery = (searchParams, options = {}) => {
|
|
84
87
|
const { defaultPage = 1, defaultSize = 12, maxSize = 100 } = options;
|
|
85
|
-
const page = Math.max(1, toInt(searchParams.get("page"), defaultPage));
|
|
86
88
|
const size = Math.min(maxSize, Math.max(1, toInt(searchParams.get("size"), defaultSize)));
|
|
87
|
-
const
|
|
88
|
-
const
|
|
89
|
+
const pageParam = Math.max(1, toInt(searchParams.get("page"), defaultPage));
|
|
90
|
+
const rawOffset = searchParams.has("offset") ? Math.max(0, toInt(searchParams.get("offset"), 0)) : (pageParam - 1) * size;
|
|
91
|
+
const page = Math.max(1, Math.floor(rawOffset / size) + 1);
|
|
92
|
+
const offset = (page - 1) * size;
|
|
89
93
|
const rawFilters = searchParams.get("filters");
|
|
90
94
|
let filters = {};
|
|
91
95
|
if (rawFilters) try {
|
|
92
96
|
const parsed = JSON.parse(rawFilters);
|
|
93
|
-
filters = Object.fromEntries(Object.entries(parsed).filter(([, value]) => typeof value === "string").map(([key, value]) => [key, String(value)]));
|
|
97
|
+
filters = Object.fromEntries(Object.entries(parsed).filter(([, value]) => typeof value === "string").map(([key, value]) => [key, parseFilterValueList(String(value)).join(",")]));
|
|
94
98
|
} catch {
|
|
95
99
|
filters = {};
|
|
96
100
|
}
|
|
@@ -119,17 +123,22 @@ const buildCollectionHref = (pathname, query, overrides = {}) => {
|
|
|
119
123
|
...query,
|
|
120
124
|
...overrides
|
|
121
125
|
};
|
|
122
|
-
const
|
|
126
|
+
const offsetFromPage = (next.page - 1) * next.size;
|
|
123
127
|
const params = new URLSearchParams({
|
|
124
128
|
page: String(next.page),
|
|
125
129
|
size: String(next.size),
|
|
126
|
-
offset: String(
|
|
130
|
+
offset: String(offsetFromPage)
|
|
127
131
|
});
|
|
128
|
-
if (Object.keys(next.filters ?? {}).length > 0)
|
|
132
|
+
if (Object.keys(next.filters ?? {}).length > 0) {
|
|
133
|
+
const normalizedFilters = Object.fromEntries(Object.entries(next.filters).map(([key, value]) => [key, parseFilterValueList(value).join(",")]));
|
|
134
|
+
params.set("filters", JSON.stringify(normalizedFilters));
|
|
135
|
+
}
|
|
129
136
|
return `${pathname}?${params.toString()}`;
|
|
130
137
|
};
|
|
131
138
|
const matchesCollectionFilters = (values, filters) => Object.entries(filters).every(([key, value]) => {
|
|
132
|
-
|
|
139
|
+
const expected = parseFilterValueList(value);
|
|
140
|
+
const candidates = (values[key] ?? []).map(normalizeFilterToken);
|
|
141
|
+
return expected.every((token) => candidates.includes(token));
|
|
133
142
|
});
|
|
134
143
|
const buildPageSequence = (totalPages, currentPage, maxButtons = 7) => {
|
|
135
144
|
if (totalPages <= maxButtons) return Array.from({ length: totalPages }, (_, index) => index + 1);
|
|
@@ -144,5 +153,28 @@ const buildPageSequence = (totalPages, currentPage, maxButtons = 7) => {
|
|
|
144
153
|
sequence.push(totalPages);
|
|
145
154
|
return sequence;
|
|
146
155
|
};
|
|
156
|
+
/**
|
|
157
|
+
* For Astro server islands: the serialized `search` prop is usually set from the
|
|
158
|
+
* page request, but the island subrequest may omit it. When empty, use the
|
|
159
|
+
* `Referer` request header so `parseCollectionQuery` still matches the main document URL.
|
|
160
|
+
*/
|
|
161
|
+
/** "Showing …" for collection footers: avoids "3—3" when one item; uses en dash for ranges. */
|
|
162
|
+
const formatCollectionRangeLabel = (slice) => {
|
|
163
|
+
if (slice.totalItems === 0) return "Showing 0 of 0";
|
|
164
|
+
const a = slice.start + 1;
|
|
165
|
+
const b = slice.end;
|
|
166
|
+
if (a === b) return `Showing result ${a} of ${slice.totalItems}`;
|
|
167
|
+
return `Showing results ${a}–${b} of ${slice.totalItems}`;
|
|
168
|
+
};
|
|
169
|
+
const resolveIslandSearchString = (searchFromProps, request) => {
|
|
170
|
+
if (searchFromProps) return searchFromProps;
|
|
171
|
+
const referer = request.headers.get("referer") ?? "";
|
|
172
|
+
if (!referer) return "";
|
|
173
|
+
try {
|
|
174
|
+
return new URL(referer).search;
|
|
175
|
+
} catch {
|
|
176
|
+
return "";
|
|
177
|
+
}
|
|
178
|
+
};
|
|
147
179
|
//#endregion
|
|
148
|
-
export { buildCollectionHref, buildPageSequence, composeEmailAddress, formatDisplayDate, forms_exports as forms, generateFormResultHtml, generateFormSectionHtml, getTagPalette, matchesCollectionFilters, paginateCollection, parseCollectionQuery, renderEmailTemplate, sendEmail };
|
|
180
|
+
export { buildCollectionHref, buildPageSequence, composeEmailAddress, formatCollectionRangeLabel, formatDisplayDate, formatFilterValueListLabel, forms_exports as forms, generateFormResultHtml, generateFormSectionHtml, getTagPalette, matchesCollectionFilters, normalizeFilterToken, paginateCollection, parseCollectionQuery, parseFilterValueList, renderEmailTemplate, resolveIslandSearchString, sendEmail };
|
package/dist/index.cjs
CHANGED
|
@@ -4,7 +4,9 @@ const require_core = require("./core.cjs");
|
|
|
4
4
|
exports.buildCollectionHref = require_core.buildCollectionHref;
|
|
5
5
|
exports.buildPageSequence = require_core.buildPageSequence;
|
|
6
6
|
exports.composeEmailAddress = require_forms.composeEmailAddress;
|
|
7
|
+
exports.formatCollectionRangeLabel = require_core.formatCollectionRangeLabel;
|
|
7
8
|
exports.formatDisplayDate = require_core.formatDisplayDate;
|
|
9
|
+
exports.formatFilterValueListLabel = require_core.formatFilterValueListLabel;
|
|
8
10
|
Object.defineProperty(exports, "forms", {
|
|
9
11
|
enumerable: true,
|
|
10
12
|
get: function() {
|
|
@@ -15,7 +17,10 @@ exports.generateFormResultHtml = require_forms.generateFormResultHtml;
|
|
|
15
17
|
exports.generateFormSectionHtml = require_forms.generateFormSectionHtml;
|
|
16
18
|
exports.getTagPalette = require_core.getTagPalette;
|
|
17
19
|
exports.matchesCollectionFilters = require_core.matchesCollectionFilters;
|
|
20
|
+
exports.normalizeFilterToken = require_core.normalizeFilterToken;
|
|
18
21
|
exports.paginateCollection = require_core.paginateCollection;
|
|
19
22
|
exports.parseCollectionQuery = require_core.parseCollectionQuery;
|
|
23
|
+
exports.parseFilterValueList = require_core.parseFilterValueList;
|
|
20
24
|
exports.renderEmailTemplate = require_forms.renderEmailTemplate;
|
|
25
|
+
exports.resolveIslandSearchString = require_core.resolveIslandSearchString;
|
|
21
26
|
exports.sendEmail = require_forms.sendEmail;
|
package/dist/index.d.cts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import { a as generateFormResultHtml, c as renderEmailTemplate, i as composeEmailAddress, l as sendEmail, n as FormSection, o as generateFormSectionHtml, r as ResendConfig, s as index_d_exports, t as EmailPayload } from "./index-CcH_yXr-.cjs";
|
|
2
|
-
import { CollectionQueryOptions, CollectionQueryState, DateFormatOptions, PaginationSlice, TagColorOptions, TagPalette, buildCollectionHref, buildPageSequence, formatDisplayDate, getTagPalette, matchesCollectionFilters, paginateCollection, parseCollectionQuery } from "./core.cjs";
|
|
3
|
-
export { CollectionQueryOptions, CollectionQueryState, DateFormatOptions, EmailPayload, FormSection, PaginationSlice, ResendConfig, TagColorOptions, TagPalette, buildCollectionHref, buildPageSequence, composeEmailAddress, formatDisplayDate, index_d_exports as forms, generateFormResultHtml, generateFormSectionHtml, getTagPalette, matchesCollectionFilters, paginateCollection, parseCollectionQuery, renderEmailTemplate, sendEmail };
|
|
2
|
+
import { CollectionQueryOptions, CollectionQueryState, DateFormatOptions, PaginationSlice, TagColorOptions, TagPalette, buildCollectionHref, buildPageSequence, formatCollectionRangeLabel, formatDisplayDate, formatFilterValueListLabel, getTagPalette, matchesCollectionFilters, normalizeFilterToken, paginateCollection, parseCollectionQuery, parseFilterValueList, resolveIslandSearchString } from "./core.cjs";
|
|
3
|
+
export { CollectionQueryOptions, CollectionQueryState, DateFormatOptions, EmailPayload, FormSection, PaginationSlice, ResendConfig, TagColorOptions, TagPalette, buildCollectionHref, buildPageSequence, composeEmailAddress, formatCollectionRangeLabel, formatDisplayDate, formatFilterValueListLabel, index_d_exports as forms, generateFormResultHtml, generateFormSectionHtml, getTagPalette, matchesCollectionFilters, normalizeFilterToken, paginateCollection, parseCollectionQuery, parseFilterValueList, renderEmailTemplate, resolveIslandSearchString, sendEmail };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import { a as generateFormResultHtml, c as renderEmailTemplate, i as composeEmailAddress, l as sendEmail, n as FormSection, o as generateFormSectionHtml, r as ResendConfig, s as index_d_exports, t as EmailPayload } from "./index-Bk-K9aDQ.js";
|
|
2
|
-
import { CollectionQueryOptions, CollectionQueryState, DateFormatOptions, PaginationSlice, TagColorOptions, TagPalette, buildCollectionHref, buildPageSequence, formatDisplayDate, getTagPalette, matchesCollectionFilters, paginateCollection, parseCollectionQuery } from "./core.js";
|
|
3
|
-
export { CollectionQueryOptions, CollectionQueryState, DateFormatOptions, EmailPayload, FormSection, PaginationSlice, ResendConfig, TagColorOptions, TagPalette, buildCollectionHref, buildPageSequence, composeEmailAddress, formatDisplayDate, index_d_exports as forms, generateFormResultHtml, generateFormSectionHtml, getTagPalette, matchesCollectionFilters, paginateCollection, parseCollectionQuery, renderEmailTemplate, sendEmail };
|
|
2
|
+
import { CollectionQueryOptions, CollectionQueryState, DateFormatOptions, PaginationSlice, TagColorOptions, TagPalette, buildCollectionHref, buildPageSequence, formatCollectionRangeLabel, formatDisplayDate, formatFilterValueListLabel, getTagPalette, matchesCollectionFilters, normalizeFilterToken, paginateCollection, parseCollectionQuery, parseFilterValueList, resolveIslandSearchString } from "./core.js";
|
|
3
|
+
export { CollectionQueryOptions, CollectionQueryState, DateFormatOptions, EmailPayload, FormSection, PaginationSlice, ResendConfig, TagColorOptions, TagPalette, buildCollectionHref, buildPageSequence, composeEmailAddress, formatCollectionRangeLabel, formatDisplayDate, formatFilterValueListLabel, index_d_exports as forms, generateFormResultHtml, generateFormSectionHtml, getTagPalette, matchesCollectionFilters, normalizeFilterToken, paginateCollection, parseCollectionQuery, parseFilterValueList, renderEmailTemplate, resolveIslandSearchString, sendEmail };
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import { composeEmailAddress, generateFormResultHtml, generateFormSectionHtml, renderEmailTemplate, sendEmail, t as forms_exports } from "./forms/index.js";
|
|
2
|
-
import { buildCollectionHref, buildPageSequence, formatDisplayDate, getTagPalette, matchesCollectionFilters, paginateCollection, parseCollectionQuery } from "./core.js";
|
|
3
|
-
export { buildCollectionHref, buildPageSequence, composeEmailAddress, formatDisplayDate, forms_exports as forms, generateFormResultHtml, generateFormSectionHtml, getTagPalette, matchesCollectionFilters, paginateCollection, parseCollectionQuery, renderEmailTemplate, sendEmail };
|
|
2
|
+
import { buildCollectionHref, buildPageSequence, formatCollectionRangeLabel, formatDisplayDate, formatFilterValueListLabel, getTagPalette, matchesCollectionFilters, normalizeFilterToken, paginateCollection, parseCollectionQuery, parseFilterValueList, resolveIslandSearchString } from "./core.js";
|
|
3
|
+
export { buildCollectionHref, buildPageSequence, composeEmailAddress, formatCollectionRangeLabel, formatDisplayDate, formatFilterValueListLabel, forms_exports as forms, generateFormResultHtml, generateFormSectionHtml, getTagPalette, matchesCollectionFilters, normalizeFilterToken, paginateCollection, parseCollectionQuery, parseFilterValueList, renderEmailTemplate, resolveIslandSearchString, sendEmail };
|
package/package.json
CHANGED
package/scripts/link-local.mjs
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* Does not change package.json or the lockfile — run `pnpm install` first so the
|
|
5
5
|
* registry version is recorded, then this replaces only the on-disk resolution.
|
|
6
6
|
*/
|
|
7
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { lstatSync } from 'node:fs';
|
|
8
9
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
9
10
|
import { spawnSync } from 'node:child_process';
|
|
10
11
|
import { platform } from 'node:os';
|
|
@@ -126,7 +127,9 @@ function cmdStatus(projectRoot) {
|
|
|
126
127
|
console.log(` marker: ${marker}`);
|
|
127
128
|
console.log(` local: ${data.localPath}`);
|
|
128
129
|
if (existsSync(target)) {
|
|
129
|
-
|
|
130
|
+
const st = lstatSync(target);
|
|
131
|
+
const link = st.isSymbolicLink() ? ` → ${readlinkSync(target)}` : " (not a symlink — registry or copy)";
|
|
132
|
+
console.log(` node_modules: ${target}${link}`);
|
|
130
133
|
}
|
|
131
134
|
} catch {
|
|
132
135
|
console.log('astroflare-link-local: marker present but invalid');
|
|
@@ -154,6 +157,9 @@ if (argv[0] === 'help' || argv[0] === '-h' || argv[0] === '--help') {
|
|
|
154
157
|
astroflare-link-local unlink --no-install
|
|
155
158
|
astroflare-link-local status
|
|
156
159
|
|
|
160
|
+
Shipped with @at-flux/astroflare: run from the app that depends on the package (not only from the monorepo
|
|
161
|
+
that builds astroflare). Walks up directories until it finds a package.json that depends on @at-flux/astroflare.
|
|
162
|
+
|
|
157
163
|
Symlinks node_modules/@at-flux/astroflare → local checkout. Does not edit package.json or lockfile.`);
|
|
158
164
|
process.exit(0);
|
|
159
165
|
}
|
package/src/collection-query.ts
CHANGED
|
@@ -19,6 +19,26 @@ export interface PaginationSlice<T> {
|
|
|
19
19
|
end: number;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
export const normalizeFilterToken = (value: string): string =>
|
|
23
|
+
value
|
|
24
|
+
.trim()
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
27
|
+
.replace(/^_+|_+$/g, "")
|
|
28
|
+
.replace(/_+/g, "_");
|
|
29
|
+
|
|
30
|
+
export const parseFilterValueList = (value: string | undefined): string[] =>
|
|
31
|
+
(value ?? "")
|
|
32
|
+
.split(",")
|
|
33
|
+
.map(normalizeFilterToken)
|
|
34
|
+
.filter(Boolean)
|
|
35
|
+
.filter((token, index, list) => list.indexOf(token) === index);
|
|
36
|
+
|
|
37
|
+
export const formatFilterValueListLabel = (value: string | undefined): string =>
|
|
38
|
+
parseFilterValueList(value)
|
|
39
|
+
.map((token) => token.replace(/_/g, " ").toUpperCase())
|
|
40
|
+
.join(" + ");
|
|
41
|
+
|
|
22
42
|
const toInt = (value: string | null, fallback: number): number => {
|
|
23
43
|
const parsed = Number.parseInt(value ?? "", 10);
|
|
24
44
|
return Number.isFinite(parsed) ? parsed : fallback;
|
|
@@ -30,13 +50,19 @@ export const parseCollectionQuery = (
|
|
|
30
50
|
): CollectionQueryState => {
|
|
31
51
|
const { defaultPage = 1, defaultSize = 12, maxSize = 100 } = options;
|
|
32
52
|
|
|
33
|
-
const page = Math.max(1, toInt(searchParams.get("page"), defaultPage));
|
|
34
53
|
const size = Math.min(
|
|
35
54
|
maxSize,
|
|
36
55
|
Math.max(1, toInt(searchParams.get("size"), defaultSize)),
|
|
37
56
|
);
|
|
38
|
-
const
|
|
39
|
-
const
|
|
57
|
+
const pageParam = Math.max(1, toInt(searchParams.get("page"), defaultPage));
|
|
58
|
+
const hasOffset = searchParams.has("offset");
|
|
59
|
+
const rawOffset = hasOffset
|
|
60
|
+
? Math.max(0, toInt(searchParams.get("offset"), 0))
|
|
61
|
+
: (pageParam - 1) * size;
|
|
62
|
+
// Offset (which window of the list) is authoritative. Fixes ?page=2&size=2&offset=0
|
|
63
|
+
// where 0 is valid but is not a multiple of size for page 2, and 0 is not "missing".
|
|
64
|
+
const page = Math.max(1, Math.floor(rawOffset / size) + 1);
|
|
65
|
+
const offset = (page - 1) * size;
|
|
40
66
|
const rawFilters = searchParams.get("filters");
|
|
41
67
|
let filters: Record<string, string> = {};
|
|
42
68
|
if (rawFilters) {
|
|
@@ -45,7 +71,7 @@ export const parseCollectionQuery = (
|
|
|
45
71
|
filters = Object.fromEntries(
|
|
46
72
|
Object.entries(parsed)
|
|
47
73
|
.filter(([, value]) => typeof value === "string")
|
|
48
|
-
.map(([key, value]) => [key, String(value)]),
|
|
74
|
+
.map(([key, value]) => [key, parseFilterValueList(String(value)).join(",")]),
|
|
49
75
|
);
|
|
50
76
|
} catch {
|
|
51
77
|
filters = {};
|
|
@@ -86,14 +112,21 @@ export const buildCollectionHref = (
|
|
|
86
112
|
...query,
|
|
87
113
|
...overrides,
|
|
88
114
|
};
|
|
89
|
-
const
|
|
115
|
+
const offsetFromPage = (next.page - 1) * next.size;
|
|
90
116
|
const params = new URLSearchParams({
|
|
91
117
|
page: String(next.page),
|
|
92
118
|
size: String(next.size),
|
|
93
|
-
offset:
|
|
119
|
+
/* Always follow page+size. Stale offset:0 is valid but must not break page 2+ links (0 is not nullish for ??) */
|
|
120
|
+
offset: String(offsetFromPage),
|
|
94
121
|
});
|
|
95
122
|
if (Object.keys(next.filters ?? {}).length > 0) {
|
|
96
|
-
|
|
123
|
+
const normalizedFilters = Object.fromEntries(
|
|
124
|
+
Object.entries(next.filters).map(([key, value]) => [
|
|
125
|
+
key,
|
|
126
|
+
parseFilterValueList(value).join(","),
|
|
127
|
+
]),
|
|
128
|
+
);
|
|
129
|
+
params.set("filters", JSON.stringify(normalizedFilters));
|
|
97
130
|
}
|
|
98
131
|
return `${pathname}?${params.toString()}`;
|
|
99
132
|
};
|
|
@@ -103,8 +136,9 @@ export const matchesCollectionFilters = (
|
|
|
103
136
|
filters: Record<string, string>,
|
|
104
137
|
): boolean =>
|
|
105
138
|
Object.entries(filters).every(([key, value]) => {
|
|
106
|
-
const
|
|
107
|
-
|
|
139
|
+
const expected = parseFilterValueList(value);
|
|
140
|
+
const candidates = (values[key] ?? []).map(normalizeFilterToken);
|
|
141
|
+
return expected.every((token) => candidates.includes(token));
|
|
108
142
|
});
|
|
109
143
|
|
|
110
144
|
export const buildPageSequence = (
|
|
@@ -128,3 +162,39 @@ export const buildPageSequence = (
|
|
|
128
162
|
sequence.push(totalPages);
|
|
129
163
|
return sequence;
|
|
130
164
|
};
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* For Astro server islands: the serialized `search` prop is usually set from the
|
|
168
|
+
* page request, but the island subrequest may omit it. When empty, use the
|
|
169
|
+
* `Referer` request header so `parseCollectionQuery` still matches the main document URL.
|
|
170
|
+
*/
|
|
171
|
+
/** "Showing …" for collection footers: avoids "3—3" when one item; uses en dash for ranges. */
|
|
172
|
+
export const formatCollectionRangeLabel = (slice: Pick<PaginationSlice<unknown>, "start" | "end" | "totalItems">): string => {
|
|
173
|
+
if (slice.totalItems === 0) {
|
|
174
|
+
return "Showing 0 of 0";
|
|
175
|
+
}
|
|
176
|
+
const a = slice.start + 1;
|
|
177
|
+
const b = slice.end;
|
|
178
|
+
if (a === b) {
|
|
179
|
+
return `Showing result ${a} of ${slice.totalItems}`;
|
|
180
|
+
}
|
|
181
|
+
return `Showing results ${a}–${b} of ${slice.totalItems}`;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
export const resolveIslandSearchString = (
|
|
185
|
+
searchFromProps: string | undefined,
|
|
186
|
+
request: Request,
|
|
187
|
+
): string => {
|
|
188
|
+
if (searchFromProps) {
|
|
189
|
+
return searchFromProps;
|
|
190
|
+
}
|
|
191
|
+
const referer = request.headers.get("referer") ?? "";
|
|
192
|
+
if (!referer) {
|
|
193
|
+
return "";
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
return new URL(referer).search;
|
|
197
|
+
} catch {
|
|
198
|
+
return "";
|
|
199
|
+
}
|
|
200
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Server-rendered row: result summary, link-driven Pager, and page-size form.
|
|
4
|
+
* Pairs with CollectionQuery filter pills when filters use URL query state.
|
|
5
|
+
*/
|
|
6
|
+
import Pager from "./Pager.astro";
|
|
7
|
+
import { buildCollectionHref, buildPageSequence, type CollectionQueryState } from "../collection-query";
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
pathname: string;
|
|
11
|
+
query: CollectionQueryState;
|
|
12
|
+
totalPages: number;
|
|
13
|
+
currentPage: number;
|
|
14
|
+
sizeOptions?: number[];
|
|
15
|
+
maxPageButtons?: number;
|
|
16
|
+
class?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const {
|
|
20
|
+
pathname,
|
|
21
|
+
query,
|
|
22
|
+
totalPages,
|
|
23
|
+
currentPage,
|
|
24
|
+
sizeOptions = [4, 2, 8, 12, 16],
|
|
25
|
+
maxPageButtons = 7,
|
|
26
|
+
class: className = "",
|
|
27
|
+
} = Astro.props;
|
|
28
|
+
|
|
29
|
+
const activeFilters = query.filters ?? {};
|
|
30
|
+
const pagerItems = buildPageSequence(totalPages, currentPage, maxPageButtons).map((item) =>
|
|
31
|
+
item === "…"
|
|
32
|
+
? { ellipsis: true as const }
|
|
33
|
+
: {
|
|
34
|
+
page: item,
|
|
35
|
+
href: buildCollectionHref(pathname, query, { page: item, offset: (item - 1) * query.size }),
|
|
36
|
+
active: item === currentPage,
|
|
37
|
+
disabled: totalPages <= 1 || item === currentPage,
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
const sanitizedSizeOptions = Array.from(
|
|
41
|
+
new Set([query.size, ...sizeOptions].filter((value) => Number.isFinite(value) && value > 0)),
|
|
42
|
+
).sort((a, b) => a - b);
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
<div class:list={["af-collection-footer grid gap-3 md:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] md:items-center", className]}>
|
|
46
|
+
<div class="af-collection-summary text-sm text-light-text-subtle dark:text-dark-text-subtle">
|
|
47
|
+
<slot name="summary" />
|
|
48
|
+
</div>
|
|
49
|
+
<Pager items={pagerItems} class="md:col-start-2 md:justify-center" />
|
|
50
|
+
<form method="get" action={pathname} data-astro-reload class="md:col-start-3 md:justify-self-end">
|
|
51
|
+
<input type="hidden" name="page" value="1" />
|
|
52
|
+
<input type="hidden" name="offset" value="0" />
|
|
53
|
+
{Object.keys(activeFilters).length > 0 && (
|
|
54
|
+
<input type="hidden" name="filters" value={JSON.stringify(activeFilters)} />
|
|
55
|
+
)}
|
|
56
|
+
<label class="af-page-size-label inline-flex items-center gap-2 text-xs uppercase tracking-[0.08em] text-light-text-subtle dark:text-dark-text-subtle">
|
|
57
|
+
<span>Page size</span>
|
|
58
|
+
<select
|
|
59
|
+
name="size"
|
|
60
|
+
class="select-none rounded-full border border-light-border bg-light-surface px-3 py-1.5 text-xs text-light-text-body dark:border-dark-border dark:bg-dark-surface dark:text-dark-text-body"
|
|
61
|
+
onchange="this.form?.submit()"
|
|
62
|
+
>
|
|
63
|
+
{sanitizedSizeOptions.map((size) => (
|
|
64
|
+
<option value={String(size)} selected={size === query.size}>
|
|
65
|
+
{size}
|
|
66
|
+
</option>
|
|
67
|
+
))}
|
|
68
|
+
</select>
|
|
69
|
+
</label>
|
|
70
|
+
</form>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<style is:global>
|
|
74
|
+
.af-collection-summary,
|
|
75
|
+
.af-page-size-label {
|
|
76
|
+
user-select: none;
|
|
77
|
+
-webkit-user-select: none;
|
|
78
|
+
}
|
|
79
|
+
.af-page-size-label select,
|
|
80
|
+
.af-page-size-label option {
|
|
81
|
+
user-select: none;
|
|
82
|
+
-webkit-user-select: none;
|
|
83
|
+
}
|
|
84
|
+
</style>
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
import FilterPills from "./FilterPills.astro";
|
|
3
3
|
import Pager from "./Pager.astro";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
buildCollectionHref,
|
|
6
|
+
buildPageSequence,
|
|
7
|
+
normalizeFilterToken,
|
|
8
|
+
parseFilterValueList,
|
|
9
|
+
} from "../collection-query";
|
|
5
10
|
import type { CollectionQueryProps, CollectionQueryServerProps } from "./collection-query-props";
|
|
6
11
|
type Props = CollectionQueryProps;
|
|
7
12
|
|
|
@@ -27,14 +32,43 @@ const totalPages = serverProps?.totalPages ?? 1;
|
|
|
27
32
|
const currentPage = serverProps?.currentPage ?? 1;
|
|
28
33
|
|
|
29
34
|
const activeFilters = query.filters ?? {};
|
|
35
|
+
const normalize = (value: string): string => value.trim().toLowerCase();
|
|
36
|
+
const maxTagFilters = 5;
|
|
37
|
+
const activeTagTokens = parseFilterValueList(activeFilters.tag);
|
|
30
38
|
const allHref = buildCollectionHref(pathname, query, { filters: {}, page: 1, offset: 0 });
|
|
31
39
|
const serverFilterItems = filters.map((option) => {
|
|
32
|
-
const
|
|
40
|
+
const isTagFilter = option.key === "tag";
|
|
41
|
+
const normalizedOptionValue = normalizeFilterToken(option.value);
|
|
42
|
+
const selectedTagTokens = activeTagTokens;
|
|
43
|
+
const isSelectedTag = isTagFilter && selectedTagTokens.includes(normalizedOptionValue);
|
|
44
|
+
const canAddTag = isTagFilter ? selectedTagTokens.length < maxTagFilters : true;
|
|
45
|
+
const nextTagTokens = isTagFilter
|
|
46
|
+
? (isSelectedTag
|
|
47
|
+
? selectedTagTokens.filter((token) => token !== normalizedOptionValue)
|
|
48
|
+
: [...selectedTagTokens, normalizedOptionValue]
|
|
49
|
+
)
|
|
50
|
+
: [];
|
|
51
|
+
const nextFilters = { ...activeFilters };
|
|
52
|
+
if (isTagFilter) {
|
|
53
|
+
if (nextTagTokens.length === 0) {
|
|
54
|
+
delete nextFilters.tag;
|
|
55
|
+
} else {
|
|
56
|
+
nextFilters.tag = nextTagTokens.join(",");
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
nextFilters[option.key] = normalizeFilterToken(option.value);
|
|
60
|
+
}
|
|
61
|
+
const isDisabledTag = isTagFilter && !isSelectedTag && !canAddTag;
|
|
33
62
|
return {
|
|
34
63
|
value: `${option.key}:${option.value}`,
|
|
35
64
|
label: option.label ?? option.value,
|
|
36
|
-
href:
|
|
37
|
-
|
|
65
|
+
href: isDisabledTag
|
|
66
|
+
? undefined
|
|
67
|
+
: buildCollectionHref(pathname, query, { filters: nextFilters, page: 1, offset: 0 }),
|
|
68
|
+
active: isTagFilter
|
|
69
|
+
? isSelectedTag
|
|
70
|
+
: normalize(activeFilters[option.key] ?? "") === normalize(option.value),
|
|
71
|
+
disabled: isDisabledTag,
|
|
38
72
|
colorKey: option.value,
|
|
39
73
|
};
|
|
40
74
|
});
|
|
@@ -74,20 +108,20 @@ const sanitizedSizeOptions = Array.from(
|
|
|
74
108
|
</slot>
|
|
75
109
|
<slot />
|
|
76
110
|
<slot name="pager">
|
|
77
|
-
<div class:list={["af-query-pager-row
|
|
78
|
-
<Pager items={pagerItems} class="md:
|
|
111
|
+
<div class:list={["af-query-pager-row grid gap-3 md:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] md:items-center", pagerClass]}>
|
|
112
|
+
<Pager items={pagerItems} class="md:col-start-2 md:justify-center" />
|
|
79
113
|
{showPageSize && (
|
|
80
|
-
<form method="get" action={pathname} class:list={["af-query-size-form md:
|
|
114
|
+
<form method="get" action={pathname} data-astro-reload class:list={["af-query-size-form md:col-start-3 md:justify-self-end", pageSizeClass]}>
|
|
81
115
|
<input type="hidden" name="page" value="1" />
|
|
82
116
|
<input type="hidden" name="offset" value="0" />
|
|
83
117
|
{Object.keys(activeFilters).length > 0 && (
|
|
84
118
|
<input type="hidden" name="filters" value={JSON.stringify(activeFilters)} />
|
|
85
119
|
)}
|
|
86
|
-
<label class="inline-flex items-center gap-2 text-xs uppercase tracking-[0.08em] text-light-text-subtle dark:text-dark-text-subtle">
|
|
120
|
+
<label class="af-page-size-label inline-flex items-center gap-2 text-xs uppercase tracking-[0.08em] text-light-text-subtle dark:text-dark-text-subtle">
|
|
87
121
|
<span>Page size</span>
|
|
88
122
|
<select
|
|
89
123
|
name="size"
|
|
90
|
-
class="rounded-full border border-light-border bg-light-surface px-3 py-1.5 text-xs text-light-text-body dark:border-dark-border dark:bg-dark-surface dark:text-dark-text-body"
|
|
124
|
+
class="select-none rounded-full border border-light-border bg-light-surface px-3 py-1.5 text-xs text-light-text-body dark:border-dark-border dark:bg-dark-surface dark:text-dark-text-body"
|
|
91
125
|
onchange="this.form?.submit()"
|
|
92
126
|
>
|
|
93
127
|
{sanitizedSizeOptions.map((size) => (
|
|
@@ -137,3 +171,15 @@ const sanitizedSizeOptions = Array.from(
|
|
|
137
171
|
</script>
|
|
138
172
|
)
|
|
139
173
|
}
|
|
174
|
+
|
|
175
|
+
<style is:global>
|
|
176
|
+
.af-page-size-label {
|
|
177
|
+
user-select: none;
|
|
178
|
+
-webkit-user-select: none;
|
|
179
|
+
}
|
|
180
|
+
.af-page-size-label select,
|
|
181
|
+
.af-page-size-label option {
|
|
182
|
+
user-select: none;
|
|
183
|
+
-webkit-user-select: none;
|
|
184
|
+
}
|
|
185
|
+
</style>
|
|
@@ -10,6 +10,7 @@ interface Props {
|
|
|
10
10
|
label?: string;
|
|
11
11
|
href?: string;
|
|
12
12
|
active?: boolean;
|
|
13
|
+
disabled?: boolean;
|
|
13
14
|
colorKey?: string;
|
|
14
15
|
}
|
|
15
16
|
>;
|
|
@@ -42,6 +43,14 @@ const {
|
|
|
42
43
|
|
|
43
44
|
const formatLabel = (value: string): string =>
|
|
44
45
|
itemCase === "upper" ? value.toUpperCase() : value;
|
|
46
|
+
const normalize = (value: string): string => value.trim().toLowerCase();
|
|
47
|
+
const formPartsFromHref = (href: string): { action: string; fields: Array<{ name: string; value: string }> } => {
|
|
48
|
+
const url = new URL(href, "https://astroflare.local");
|
|
49
|
+
return {
|
|
50
|
+
action: url.pathname,
|
|
51
|
+
fields: Array.from(url.searchParams.entries()).map(([name, value]) => ({ name, value })),
|
|
52
|
+
};
|
|
53
|
+
};
|
|
45
54
|
|
|
46
55
|
const normalizedItems = items.map((item) =>
|
|
47
56
|
typeof item === "string"
|
|
@@ -51,6 +60,7 @@ const normalizedItems = items.map((item) =>
|
|
|
51
60
|
label: item.label ?? item.value,
|
|
52
61
|
href: item.href,
|
|
53
62
|
active: item.active,
|
|
63
|
+
disabled: item.disabled,
|
|
54
64
|
colorKey: item.colorKey,
|
|
55
65
|
},
|
|
56
66
|
);
|
|
@@ -59,12 +69,22 @@ const normalizedItems = items.map((item) =>
|
|
|
59
69
|
<div class:list={["af-filter-pills flex flex-wrap items-center gap-2", className]}>
|
|
60
70
|
{includeAll && (
|
|
61
71
|
allHref ? (
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
72
|
+
(() => {
|
|
73
|
+
const form = formPartsFromHref(allHref);
|
|
74
|
+
return (
|
|
75
|
+
<form method="get" action={form.action} data-astro-reload class="af-filter-pill-form">
|
|
76
|
+
{form.fields.map((field) => (
|
|
77
|
+
<input type="hidden" name={field.name} value={field.value} />
|
|
78
|
+
))}
|
|
79
|
+
<button
|
|
80
|
+
type="submit"
|
|
81
|
+
class:list={["af-filter-pill", "af-filter-pill-all", active === "all" && "is-active"]}
|
|
82
|
+
>
|
|
83
|
+
{formatLabel(allLabel)}
|
|
84
|
+
</button>
|
|
85
|
+
</form>
|
|
86
|
+
);
|
|
87
|
+
})()
|
|
68
88
|
) : (
|
|
69
89
|
<button
|
|
70
90
|
type="button"
|
|
@@ -77,12 +97,36 @@ const normalizedItems = items.map((item) =>
|
|
|
77
97
|
)}
|
|
78
98
|
{normalizedItems.map((item) => {
|
|
79
99
|
const palette = getTagPalette(item.colorKey ?? item.value, { overrides: colorOverrides });
|
|
80
|
-
const isActive = item.active ?? active === item.value;
|
|
100
|
+
const isActive = item.active ?? normalize(active) === normalize(item.value);
|
|
81
101
|
const label = formatLabel(item.label);
|
|
82
102
|
return (
|
|
83
|
-
item.href ? (
|
|
84
|
-
|
|
85
|
-
|
|
103
|
+
item.href && !item.disabled ? (
|
|
104
|
+
(() => {
|
|
105
|
+
const form = formPartsFromHref(item.href);
|
|
106
|
+
return (
|
|
107
|
+
<form method="get" action={form.action} data-astro-reload class="af-filter-pill-form">
|
|
108
|
+
{form.fields.map((field) => (
|
|
109
|
+
<input type="hidden" name={field.name} value={field.value} />
|
|
110
|
+
))}
|
|
111
|
+
<button
|
|
112
|
+
type="submit"
|
|
113
|
+
class:list={["af-filter-pill", isActive && "is-active"]}
|
|
114
|
+
style={{
|
|
115
|
+
"--af-pill-bg": palette.bg,
|
|
116
|
+
"--af-pill-border": palette.border,
|
|
117
|
+
"--af-pill-text": palette.text,
|
|
118
|
+
}}
|
|
119
|
+
>
|
|
120
|
+
{label}
|
|
121
|
+
</button>
|
|
122
|
+
</form>
|
|
123
|
+
);
|
|
124
|
+
})()
|
|
125
|
+
) : item.disabled ? (
|
|
126
|
+
<button
|
|
127
|
+
type="button"
|
|
128
|
+
disabled
|
|
129
|
+
aria-disabled="true"
|
|
86
130
|
class:list={["af-filter-pill", isActive && "is-active"]}
|
|
87
131
|
style={{
|
|
88
132
|
"--af-pill-bg": palette.bg,
|
|
@@ -91,7 +135,7 @@ const normalizedItems = items.map((item) =>
|
|
|
91
135
|
}}
|
|
92
136
|
>
|
|
93
137
|
{label}
|
|
94
|
-
</
|
|
138
|
+
</button>
|
|
95
139
|
) : (
|
|
96
140
|
<button
|
|
97
141
|
type="button"
|
|
@@ -126,15 +170,34 @@ const normalizedItems = items.map((item) =>
|
|
|
126
170
|
text-decoration: none;
|
|
127
171
|
display: inline-flex;
|
|
128
172
|
align-items: center;
|
|
173
|
+
user-select: none;
|
|
174
|
+
-webkit-user-select: none;
|
|
175
|
+
}
|
|
176
|
+
.af-filter-pill-form {
|
|
177
|
+
display: inline-flex;
|
|
178
|
+
margin: 0;
|
|
129
179
|
}
|
|
130
180
|
|
|
131
181
|
.af-filter-pill:hover {
|
|
132
182
|
filter: brightness(1.04);
|
|
133
183
|
}
|
|
184
|
+
.af-filter-pill:disabled,
|
|
185
|
+
.af-filter-pill[aria-disabled="true"] {
|
|
186
|
+
cursor: not-allowed;
|
|
187
|
+
opacity: 0.48;
|
|
188
|
+
filter: grayscale(0.3);
|
|
189
|
+
}
|
|
134
190
|
|
|
135
191
|
.af-filter-pill.is-active {
|
|
136
192
|
border-color: color-mix(in oklab, var(--af-pill-text, var(--color-secondary)) 80%, transparent);
|
|
137
193
|
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--af-pill-text, var(--color-secondary)) 36%, transparent);
|
|
194
|
+
outline: 2px solid color-mix(in oklab, var(--af-pill-text, var(--color-secondary)) 48%, transparent);
|
|
195
|
+
outline-offset: 1px;
|
|
196
|
+
font-weight: 600;
|
|
197
|
+
filter: saturate(1.14) brightness(1.06);
|
|
198
|
+
text-decoration: underline;
|
|
199
|
+
text-decoration-thickness: 2px;
|
|
200
|
+
text-underline-offset: 0.15em;
|
|
138
201
|
}
|
|
139
202
|
.af-filter-pill.af-filter-pill-all {
|
|
140
203
|
--af-pill-bg: transparent;
|
|
@@ -15,6 +15,13 @@ interface Props {
|
|
|
15
15
|
|
|
16
16
|
const { pageCount = 0, activePage = 1, class: className = "", items = [] } = Astro.props;
|
|
17
17
|
const fallbackPages = Array.from({ length: Math.max(0, pageCount) }, (_, index) => index + 1);
|
|
18
|
+
const formPartsFromHref = (href: string): { action: string; fields: Array<{ name: string; value: string }> } => {
|
|
19
|
+
const url = new URL(href, "https://astroflare.local");
|
|
20
|
+
return {
|
|
21
|
+
action: url.pathname,
|
|
22
|
+
fields: Array.from(url.searchParams.entries()).map(([name, value]) => ({ name, value })),
|
|
23
|
+
};
|
|
24
|
+
};
|
|
18
25
|
---
|
|
19
26
|
|
|
20
27
|
<div class:list={["af-pager flex items-center justify-center gap-2", className]} data-pagination>
|
|
@@ -23,13 +30,23 @@ const fallbackPages = Array.from({ length: Math.max(0, pageCount) }, (_, index)
|
|
|
23
30
|
"ellipsis" in item ? (
|
|
24
31
|
<span class="af-pager-ellipsis" aria-hidden="true">{item.label ?? "…"}</span>
|
|
25
32
|
) : item.href && !item.active && !item.disabled ? (
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
(() => {
|
|
34
|
+
const form = formPartsFromHref(item.href);
|
|
35
|
+
return (
|
|
36
|
+
<form method="get" action={form.action} data-astro-reload class="af-pager-form">
|
|
37
|
+
{form.fields.map((field) => (
|
|
38
|
+
<input type="hidden" name={field.name} value={field.value} />
|
|
39
|
+
))}
|
|
40
|
+
<button
|
|
41
|
+
type="submit"
|
|
42
|
+
data-page={String(item.page)}
|
|
43
|
+
class:list={["af-pager-pill", item.active && "is-active"]}
|
|
44
|
+
>
|
|
45
|
+
{item.label ?? item.page}
|
|
46
|
+
</button>
|
|
47
|
+
</form>
|
|
48
|
+
);
|
|
49
|
+
})()
|
|
33
50
|
) : (
|
|
34
51
|
<button
|
|
35
52
|
type="button"
|
|
@@ -69,18 +86,33 @@ const fallbackPages = Array.from({ length: Math.max(0, pageCount) }, (_, index)
|
|
|
69
86
|
display: inline-flex;
|
|
70
87
|
align-items: center;
|
|
71
88
|
justify-content: center;
|
|
89
|
+
user-select: none;
|
|
90
|
+
-webkit-user-select: none;
|
|
91
|
+
}
|
|
92
|
+
.af-pager .af-pager-form {
|
|
93
|
+
display: inline-flex;
|
|
94
|
+
margin: 0;
|
|
72
95
|
}
|
|
73
96
|
.af-pager .af-pager-pill:disabled,
|
|
74
97
|
.af-pager .af-pager-pill[aria-disabled="true"] {
|
|
75
98
|
cursor: not-allowed;
|
|
76
|
-
opacity: 0.
|
|
99
|
+
opacity: 0.5;
|
|
77
100
|
pointer-events: none;
|
|
101
|
+
border-color: color-mix(in oklab, var(--color-light-border) 65%, transparent);
|
|
102
|
+
color: color-mix(in oklab, var(--color-light-text-subtle) 50%, transparent);
|
|
103
|
+
background: color-mix(in oklab, var(--color-light-surface) 35%, transparent);
|
|
78
104
|
}
|
|
79
105
|
[data-theme="dark"] .af-pager .af-pager-pill {
|
|
80
106
|
border-color: var(--color-dark-border);
|
|
81
107
|
color: color-mix(in oklab, var(--color-dark-text-subtle) 78%, transparent);
|
|
82
108
|
background: color-mix(in oklab, var(--color-dark-surface) 70%, transparent);
|
|
83
109
|
}
|
|
110
|
+
[data-theme="dark"] .af-pager .af-pager-pill:disabled,
|
|
111
|
+
[data-theme="dark"] .af-pager .af-pager-pill[aria-disabled="true"] {
|
|
112
|
+
border-color: color-mix(in oklab, var(--color-dark-border) 65%, transparent);
|
|
113
|
+
color: color-mix(in oklab, var(--color-dark-text-subtle) 48%, transparent);
|
|
114
|
+
background: color-mix(in oklab, var(--color-dark-surface) 35%, transparent);
|
|
115
|
+
}
|
|
84
116
|
.af-pager .af-pager-pill.is-active {
|
|
85
117
|
border-color: var(--color-secondary);
|
|
86
118
|
color: var(--color-secondary);
|