@at-flux/astroflare 1.0.4 → 1.0.5
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/package.json +2 -1
- package/src/collection-query.ts +130 -0
- package/src/core.ts +8 -0
- package/src/date-format.ts +23 -0
- package/src/index.ts +1 -0
- package/src/tag-colors.ts +89 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@at-flux/astroflare",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Reusable headless components, styles, and utilities for Astro + Tailwind v4 + Cloudflare projects",
|
|
6
6
|
"author": "atflux <dev@atflux.uk>",
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
},
|
|
55
55
|
"files": [
|
|
56
56
|
"dist",
|
|
57
|
+
"src/*.ts",
|
|
57
58
|
"src/components",
|
|
58
59
|
"src/runtime",
|
|
59
60
|
"src/styles",
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
export interface CollectionQueryOptions {
|
|
2
|
+
defaultPage?: number;
|
|
3
|
+
defaultSize?: number;
|
|
4
|
+
maxSize?: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface CollectionQueryState {
|
|
8
|
+
page: number;
|
|
9
|
+
size: number;
|
|
10
|
+
offset: number;
|
|
11
|
+
filters: Record<string, string>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface PaginationSlice<T> {
|
|
15
|
+
items: T[];
|
|
16
|
+
totalItems: number;
|
|
17
|
+
totalPages: number;
|
|
18
|
+
start: number;
|
|
19
|
+
end: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const toInt = (value: string | null, fallback: number): number => {
|
|
23
|
+
const parsed = Number.parseInt(value ?? "", 10);
|
|
24
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const parseCollectionQuery = (
|
|
28
|
+
searchParams: URLSearchParams,
|
|
29
|
+
options: CollectionQueryOptions = {},
|
|
30
|
+
): CollectionQueryState => {
|
|
31
|
+
const { defaultPage = 1, defaultSize = 12, maxSize = 100 } = options;
|
|
32
|
+
|
|
33
|
+
const page = Math.max(1, toInt(searchParams.get("page"), defaultPage));
|
|
34
|
+
const size = Math.min(
|
|
35
|
+
maxSize,
|
|
36
|
+
Math.max(1, toInt(searchParams.get("size"), defaultSize)),
|
|
37
|
+
);
|
|
38
|
+
const fallbackOffset = (page - 1) * size;
|
|
39
|
+
const offset = Math.max(0, toInt(searchParams.get("offset"), fallbackOffset));
|
|
40
|
+
const rawFilters = searchParams.get("filters");
|
|
41
|
+
let filters: Record<string, string> = {};
|
|
42
|
+
if (rawFilters) {
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(rawFilters) as Record<string, unknown>;
|
|
45
|
+
filters = Object.fromEntries(
|
|
46
|
+
Object.entries(parsed)
|
|
47
|
+
.filter(([, value]) => typeof value === "string")
|
|
48
|
+
.map(([key, value]) => [key, String(value)]),
|
|
49
|
+
);
|
|
50
|
+
} catch {
|
|
51
|
+
filters = {};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { page, size, offset, filters };
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const paginateCollection = <T>(
|
|
59
|
+
list: T[],
|
|
60
|
+
query: CollectionQueryState,
|
|
61
|
+
): PaginationSlice<T> => {
|
|
62
|
+
const totalItems = list.length;
|
|
63
|
+
const totalPages = Math.max(1, Math.ceil(totalItems / query.size));
|
|
64
|
+
const normalizedPage = Math.min(
|
|
65
|
+
totalPages,
|
|
66
|
+
Math.max(1, Math.floor(query.offset / query.size) + 1),
|
|
67
|
+
);
|
|
68
|
+
const start = (normalizedPage - 1) * query.size;
|
|
69
|
+
const end = start + query.size;
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
items: list.slice(start, end),
|
|
73
|
+
totalItems,
|
|
74
|
+
totalPages,
|
|
75
|
+
start,
|
|
76
|
+
end: Math.min(end, totalItems),
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const buildCollectionHref = (
|
|
81
|
+
pathname: string,
|
|
82
|
+
query: CollectionQueryState,
|
|
83
|
+
overrides: Partial<CollectionQueryState> = {},
|
|
84
|
+
): string => {
|
|
85
|
+
const next: CollectionQueryState = {
|
|
86
|
+
...query,
|
|
87
|
+
...overrides,
|
|
88
|
+
};
|
|
89
|
+
const normalizedOffset = (next.page - 1) * next.size;
|
|
90
|
+
const params = new URLSearchParams({
|
|
91
|
+
page: String(next.page),
|
|
92
|
+
size: String(next.size),
|
|
93
|
+
offset: String(next.offset ?? normalizedOffset),
|
|
94
|
+
});
|
|
95
|
+
if (Object.keys(next.filters ?? {}).length > 0) {
|
|
96
|
+
params.set("filters", JSON.stringify(next.filters));
|
|
97
|
+
}
|
|
98
|
+
return `${pathname}?${params.toString()}`;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export const matchesCollectionFilters = (
|
|
102
|
+
values: Record<string, string[]>,
|
|
103
|
+
filters: Record<string, string>,
|
|
104
|
+
): boolean =>
|
|
105
|
+
Object.entries(filters).every(([key, value]) => {
|
|
106
|
+
const candidates = values[key] ?? [];
|
|
107
|
+
return candidates.includes(value);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
export const buildPageSequence = (
|
|
111
|
+
totalPages: number,
|
|
112
|
+
currentPage: number,
|
|
113
|
+
maxButtons = 7,
|
|
114
|
+
): Array<number | "…"> => {
|
|
115
|
+
if (totalPages <= maxButtons) {
|
|
116
|
+
return Array.from({ length: totalPages }, (_, index) => index + 1);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const innerSlots = Math.max(1, maxButtons - 2);
|
|
120
|
+
const left = Math.max(2, currentPage - Math.floor(innerSlots / 2));
|
|
121
|
+
const right = Math.min(totalPages - 1, left + innerSlots - 1);
|
|
122
|
+
const adjustedLeft = Math.max(2, right - innerSlots + 1);
|
|
123
|
+
|
|
124
|
+
const sequence: Array<number | "…"> = [1];
|
|
125
|
+
if (adjustedLeft > 2) sequence.push("…");
|
|
126
|
+
for (let page = adjustedLeft; page <= right; page += 1) sequence.push(page);
|
|
127
|
+
if (right < totalPages - 1) sequence.push("…");
|
|
128
|
+
sequence.push(totalPages);
|
|
129
|
+
return sequence;
|
|
130
|
+
};
|
package/src/core.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Opinionated core surface: forms utilities
|
|
2
|
+
export * from "./forms/index";
|
|
3
|
+
export * from "./tag-colors";
|
|
4
|
+
export * from "./date-format";
|
|
5
|
+
export * from "./collection-query";
|
|
6
|
+
|
|
7
|
+
// Namespaced access for clearer call sites
|
|
8
|
+
export * as forms from "./forms/index";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface DateFormatOptions {
|
|
2
|
+
/** BCP 47 locale tag used by `Intl.DateTimeFormat`. */
|
|
3
|
+
locale?: string;
|
|
4
|
+
/** Intl formatting options merged with defaults. */
|
|
5
|
+
options?: Intl.DateTimeFormatOptions;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const DEFAULT_OPTIONS: Intl.DateTimeFormatOptions = {
|
|
9
|
+
year: "numeric",
|
|
10
|
+
month: "long",
|
|
11
|
+
day: "numeric",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Format dates consistently for cards and article metadata.
|
|
16
|
+
*/
|
|
17
|
+
export const formatDisplayDate = (
|
|
18
|
+
date: Date | string | number,
|
|
19
|
+
config: DateFormatOptions = {},
|
|
20
|
+
): string => {
|
|
21
|
+
const { locale = "en-GB", options = DEFAULT_OPTIONS } = config;
|
|
22
|
+
return new Intl.DateTimeFormat(locale, options).format(new Date(date));
|
|
23
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./core";
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export interface TagPalette {
|
|
2
|
+
/** Soft background fill for the tag surface. */
|
|
3
|
+
bg: string;
|
|
4
|
+
/** Border color paired with `bg` for tag outlines. */
|
|
5
|
+
border: string;
|
|
6
|
+
/** Foreground text color with readable contrast on `bg`. */
|
|
7
|
+
text: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface TagColorOptions {
|
|
11
|
+
/** Optional explicit tag-key to hex color map (expects lowercase keys). */
|
|
12
|
+
overrides?: Record<string, string>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const DEFAULT_OVERRIDES: Record<string, string> = {
|
|
16
|
+
shoots: "#dc3545",
|
|
17
|
+
tech: "#8a95a5",
|
|
18
|
+
ai: "#6b7b8d",
|
|
19
|
+
sites: "#7dd3c0",
|
|
20
|
+
flow: "#b367e0",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const hashStringToHue = (input: string): number => {
|
|
24
|
+
let hash = 0;
|
|
25
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
26
|
+
hash = (hash * 31 + input.charCodeAt(i)) >>> 0;
|
|
27
|
+
}
|
|
28
|
+
return hash % 360;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const hexToRgb = (hex: string): [number, number, number] | null => {
|
|
32
|
+
const value = hex.replace("#", "").trim();
|
|
33
|
+
if (!/^[0-9a-fA-F]{6}$/.test(value)) return null;
|
|
34
|
+
return [
|
|
35
|
+
Number.parseInt(value.slice(0, 2), 16),
|
|
36
|
+
Number.parseInt(value.slice(2, 4), 16),
|
|
37
|
+
Number.parseInt(value.slice(4, 6), 16),
|
|
38
|
+
];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const rgbToHsl = (r: number, g: number, b: number): [number, number, number] => {
|
|
42
|
+
const red = r / 255;
|
|
43
|
+
const green = g / 255;
|
|
44
|
+
const blue = b / 255;
|
|
45
|
+
const max = Math.max(red, green, blue);
|
|
46
|
+
const min = Math.min(red, green, blue);
|
|
47
|
+
const delta = max - min;
|
|
48
|
+
const lightness = (max + min) / 2;
|
|
49
|
+
const saturation =
|
|
50
|
+
delta === 0 ? 0 : delta / (1 - Math.abs(2 * lightness - 1));
|
|
51
|
+
|
|
52
|
+
let hue = 0;
|
|
53
|
+
if (delta !== 0) {
|
|
54
|
+
if (max === red) hue = ((green - blue) / delta) % 6;
|
|
55
|
+
else if (max === green) hue = (blue - red) / delta + 2;
|
|
56
|
+
else hue = (red - green) / delta + 4;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return [
|
|
60
|
+
Math.round((hue * 60 + 360) % 360),
|
|
61
|
+
Math.round(saturation * 100),
|
|
62
|
+
Math.round(lightness * 100),
|
|
63
|
+
];
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const resolveHue = (tag: string, options: TagColorOptions): number => {
|
|
67
|
+
const key = tag.trim().toLowerCase();
|
|
68
|
+
const color = options.overrides?.[key] ?? DEFAULT_OVERRIDES[key];
|
|
69
|
+
if (!color) return hashStringToHue(key);
|
|
70
|
+
const rgb = hexToRgb(color);
|
|
71
|
+
if (!rgb) return hashStringToHue(key);
|
|
72
|
+
return rgbToHsl(...rgb)[0];
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build a deterministic HSL-based palette for a tag label.
|
|
77
|
+
* Uses known defaults and optional overrides before falling back to hashed hue.
|
|
78
|
+
*/
|
|
79
|
+
export const getTagPalette = (
|
|
80
|
+
tag: string,
|
|
81
|
+
options: TagColorOptions = {},
|
|
82
|
+
): TagPalette => {
|
|
83
|
+
const hue = resolveHue(tag, options);
|
|
84
|
+
return {
|
|
85
|
+
bg: `hsla(${hue} 48% 58% / 0.12)`,
|
|
86
|
+
border: `hsla(${hue} 52% 58% / 0.34)`,
|
|
87
|
+
text: `hsl(${hue} 50% 66%)`,
|
|
88
|
+
};
|
|
89
|
+
};
|