@20syldev/api 4.2.0 → 4.4.0
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 +3 -3
- package/dist/app.js +4 -0
- package/dist/app.js.map +1 -1
- package/dist/config/versions.js +19 -0
- package/dist/config/versions.js.map +1 -1
- package/dist/constants.js +2 -2
- package/dist/constants.js.map +1 -1
- package/dist/modules/v3/chat.js +1 -1
- package/dist/modules/v3/chat.js.map +1 -1
- package/dist/modules/v3/domain.js +1 -1
- package/dist/modules/v3/domain.js.map +1 -1
- package/dist/modules/v3/hyperplanning.js +1 -1
- package/dist/modules/v3/hyperplanning.js.map +1 -1
- package/dist/modules/v3/personal.js +1 -1
- package/dist/modules/v3/personal.js.map +1 -1
- package/dist/modules/v3/username.js +1 -1
- package/dist/modules/v3/username.js.map +1 -1
- package/dist/modules/v4/captcha.js +60 -23
- package/dist/modules/v4/captcha.js.map +1 -1
- package/dist/modules/v4/chat.js +1 -1
- package/dist/modules/v4/chat.js.map +1 -1
- package/dist/modules/v4/color.js +44 -35
- package/dist/modules/v4/color.js.map +1 -1
- package/dist/modules/v4/colors.js +54 -0
- package/dist/modules/v4/colors.js.map +1 -0
- package/dist/modules/v4/convert.js +93 -19
- package/dist/modules/v4/convert.js.map +1 -1
- package/dist/modules/v4/domain.js +1 -1
- package/dist/modules/v4/domain.js.map +1 -1
- package/dist/modules/v4/hash.js +11 -4
- package/dist/modules/v4/hash.js.map +1 -1
- package/dist/modules/v4/hyperplanning.js +1 -1
- package/dist/modules/v4/hyperplanning.js.map +1 -1
- package/dist/modules/v4/palette.js +2 -43
- package/dist/modules/v4/palette.js.map +1 -1
- package/dist/modules/v4/personal.js +1 -1
- package/dist/modules/v4/personal.js.map +1 -1
- package/dist/modules/v4/placeholder.js +108 -52
- package/dist/modules/v4/placeholder.js.map +1 -1
- package/dist/modules/v4/qrcode.js +79 -6
- package/dist/modules/v4/qrcode.js.map +1 -1
- package/dist/modules/v4/username.js +1 -1
- package/dist/modules/v4/username.js.map +1 -1
- package/dist/modules/v4.js +6 -0
- package/dist/modules/v4.js.map +1 -1
- package/dist/routes/delete.js +16 -17
- package/dist/routes/delete.js.map +1 -1
- package/dist/routes/get.js +212 -13
- package/dist/routes/get.js.map +1 -1
- package/dist/routes/index.js +2 -2
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/patch.js +2 -3
- package/dist/routes/patch.js.map +1 -1
- package/dist/routes/post.js +10 -3
- package/dist/routes/post.js.map +1 -1
- package/dist/utils/colors.js +54 -0
- package/dist/utils/colors.js.map +1 -0
- package/dist/utils/helpers.js +26 -0
- package/dist/utils/helpers.js.map +1 -0
- package/package.json +1 -1
- package/src/app.ts +4 -0
- package/src/config/versions.ts +16 -0
- package/src/constants.ts +0 -2
- package/src/middleware/cors.ts +1 -1
- package/src/modules/v4/captcha.ts +84 -27
- package/src/modules/v4/chat.ts +25 -2
- package/src/modules/v4/color.ts +56 -52
- package/src/modules/v4/convert.ts +100 -20
- package/src/modules/v4/domain.ts +1 -1
- package/src/modules/v4/geo.ts +53 -0
- package/src/modules/v4/hash.ts +18 -4
- package/src/modules/v4/hyperplanning.ts +1 -1
- package/src/modules/v4/palette.ts +63 -0
- package/src/modules/v4/personal.ts +1 -1
- package/src/modules/v4/placeholder.ts +189 -0
- package/src/modules/v4/qrcode.ts +108 -6
- package/src/modules/v4/tic_tac_toe.ts +33 -1
- package/src/modules/v4/username.ts +1 -1
- package/src/modules/v4.ts +3 -0
- package/src/routes/delete.ts +71 -0
- package/src/routes/get.ts +147 -15
- package/src/routes/patch.ts +44 -0
- package/src/routes/post.ts +11 -3
- package/src/utils/colors.ts +91 -0
- package/src/utils/helpers.ts +90 -0
- package/src/utils/response.ts +8 -0
- package/tests/integration/api.test.ts +113 -25
- package/tests/unit/captcha.test.ts +61 -0
- package/tests/unit/chat.test.ts +53 -0
- package/tests/unit/color.test.ts +24 -4
- package/tests/unit/convert.test.ts +26 -1
- package/tests/unit/geo.test.ts +45 -0
- package/tests/unit/hash.test.ts +17 -10
- package/tests/unit/palette.test.ts +53 -0
- package/tests/unit/placeholder.test.ts +57 -0
- package/tests/unit/qrcode.test.ts +82 -0
- package/tests/unit/tic_tac_toe.test.ts +55 -0
- package/src/modules/v4/utils.ts +0 -38
|
@@ -1,38 +1,118 @@
|
|
|
1
|
-
import { MIN_TEMPERATURE, MAX_TEMPERATURE } from '../../constants.js';
|
|
2
|
-
|
|
3
1
|
type ConversionFn = (val: number) => number;
|
|
4
2
|
|
|
3
|
+
const TEMPERATURE_UNITS = new Set(['celsius', 'fahrenheit', 'kelvin']);
|
|
4
|
+
const ABSOLUTE_ZERO: Record<string, number> = { celsius: -273.15, fahrenheit: -459.67, kelvin: 0 };
|
|
5
|
+
|
|
5
6
|
const conversions: Record<string, Record<string, ConversionFn>> = {
|
|
7
|
+
// Temperature
|
|
6
8
|
celsius: {
|
|
7
|
-
fahrenheit: (
|
|
8
|
-
kelvin: (
|
|
9
|
+
fahrenheit: (v) => (v * 9) / 5 + 32,
|
|
10
|
+
kelvin: (v) => v + 273.15,
|
|
9
11
|
},
|
|
10
12
|
fahrenheit: {
|
|
11
|
-
celsius: (
|
|
12
|
-
kelvin: (
|
|
13
|
+
celsius: (v) => ((v - 32) * 5) / 9,
|
|
14
|
+
kelvin: (v) => ((v - 32) * 5) / 9 + 273.15,
|
|
13
15
|
},
|
|
14
16
|
kelvin: {
|
|
15
|
-
celsius: (
|
|
16
|
-
fahrenheit: (
|
|
17
|
+
celsius: (v) => v - 273.15,
|
|
18
|
+
fahrenheit: (v) => ((v - 273.15) * 9) / 5 + 32,
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
// Length
|
|
22
|
+
km: {
|
|
23
|
+
mi: (v) => v * 0.621371,
|
|
24
|
+
m: (v) => v * 1000,
|
|
25
|
+
ft: (v) => v * 3280.84,
|
|
26
|
+
cm: (v) => v * 100000,
|
|
27
|
+
in: (v) => v * 39370.1,
|
|
28
|
+
yd: (v) => v * 1093.61,
|
|
29
|
+
},
|
|
30
|
+
mi: {
|
|
31
|
+
km: (v) => v * 1.60934,
|
|
32
|
+
m: (v) => v * 1609.34,
|
|
33
|
+
ft: (v) => v * 5280,
|
|
34
|
+
cm: (v) => v * 160934,
|
|
35
|
+
in: (v) => v * 63360,
|
|
36
|
+
yd: (v) => v * 1760,
|
|
37
|
+
},
|
|
38
|
+
m: {
|
|
39
|
+
km: (v) => v / 1000,
|
|
40
|
+
mi: (v) => v * 0.000621371,
|
|
41
|
+
ft: (v) => v * 3.28084,
|
|
42
|
+
cm: (v) => v * 100,
|
|
43
|
+
in: (v) => v * 39.3701,
|
|
44
|
+
yd: (v) => v * 1.09361,
|
|
45
|
+
},
|
|
46
|
+
ft: {
|
|
47
|
+
km: (v) => v * 0.0003048,
|
|
48
|
+
mi: (v) => v / 5280,
|
|
49
|
+
m: (v) => v * 0.3048,
|
|
50
|
+
cm: (v) => v * 30.48,
|
|
51
|
+
in: (v) => v * 12,
|
|
52
|
+
yd: (v) => v / 3,
|
|
53
|
+
},
|
|
54
|
+
cm: {
|
|
55
|
+
km: (v) => v / 100000,
|
|
56
|
+
mi: (v) => v / 160934,
|
|
57
|
+
m: (v) => v / 100,
|
|
58
|
+
ft: (v) => v / 30.48,
|
|
59
|
+
in: (v) => v / 2.54,
|
|
60
|
+
yd: (v) => v / 91.44,
|
|
17
61
|
},
|
|
62
|
+
in: {
|
|
63
|
+
km: (v) => v / 39370.1,
|
|
64
|
+
mi: (v) => v / 63360,
|
|
65
|
+
m: (v) => v * 0.0254,
|
|
66
|
+
ft: (v) => v / 12,
|
|
67
|
+
cm: (v) => v * 2.54,
|
|
68
|
+
yd: (v) => v / 36,
|
|
69
|
+
},
|
|
70
|
+
yd: {
|
|
71
|
+
km: (v) => v * 0.0009144,
|
|
72
|
+
mi: (v) => v / 1760,
|
|
73
|
+
m: (v) => v * 0.9144,
|
|
74
|
+
ft: (v) => v * 3,
|
|
75
|
+
cm: (v) => v * 91.44,
|
|
76
|
+
in: (v) => v * 36,
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
// Weight
|
|
80
|
+
kg: { lb: (v) => v * 2.20462, oz: (v) => v * 35.274, g: (v) => v * 1000, ton: (v) => v / 1000 },
|
|
81
|
+
lb: { kg: (v) => v * 0.453592, oz: (v) => v * 16, g: (v) => v * 453.592, ton: (v) => v * 0.000453592 },
|
|
82
|
+
oz: { kg: (v) => v * 0.0283495, lb: (v) => v / 16, g: (v) => v * 28.3495, ton: (v) => v * 0.0000283495 },
|
|
83
|
+
g: { kg: (v) => v / 1000, lb: (v) => v * 0.00220462, oz: (v) => v * 0.035274, ton: (v) => v / 1000000 },
|
|
84
|
+
ton: { kg: (v) => v * 1000, lb: (v) => v * 2204.62, oz: (v) => v * 35274, g: (v) => v * 1000000 },
|
|
85
|
+
|
|
86
|
+
// Data
|
|
87
|
+
b: { kb: (v) => v / 1024, mb: (v) => v / 1048576, gb: (v) => v / 1073741824, tb: (v) => v / 1099511627776 },
|
|
88
|
+
kb: { b: (v) => v * 1024, mb: (v) => v / 1024, gb: (v) => v / 1048576, tb: (v) => v / 1073741824 },
|
|
89
|
+
mb: { b: (v) => v * 1048576, kb: (v) => v * 1024, gb: (v) => v / 1024, tb: (v) => v / 1048576 },
|
|
90
|
+
gb: { b: (v) => v * 1073741824, kb: (v) => v * 1048576, mb: (v) => v * 1024, tb: (v) => v / 1024 },
|
|
91
|
+
tb: { b: (v) => v * 1099511627776, kb: (v) => v * 1073741824, mb: (v) => v * 1048576, gb: (v) => v * 1024 },
|
|
92
|
+
|
|
93
|
+
// Speed
|
|
94
|
+
'km/h': { mph: (v) => v * 0.621371, 'm/s': (v) => v / 3.6, knots: (v) => v * 0.539957 },
|
|
95
|
+
mph: { 'km/h': (v) => v * 1.60934, 'm/s': (v) => v * 0.44704, knots: (v) => v * 0.868976 },
|
|
96
|
+
'm/s': { 'km/h': (v) => v * 3.6, mph: (v) => v * 2.23694, knots: (v) => v * 1.94384 },
|
|
97
|
+
knots: { 'km/h': (v) => v * 1.852, mph: (v) => v * 1.15078, 'm/s': (v) => v * 0.514444 },
|
|
18
98
|
};
|
|
19
99
|
|
|
20
100
|
export default function convert(
|
|
21
|
-
value:
|
|
101
|
+
value: number,
|
|
22
102
|
from: string,
|
|
23
103
|
to: string,
|
|
24
104
|
): { from: string; to: string; value: number; result: number } {
|
|
25
|
-
const
|
|
105
|
+
const fromKey = from.toLowerCase();
|
|
106
|
+
const toKey = to.toLowerCase();
|
|
107
|
+
const convertFn = conversions[fromKey]?.[toKey];
|
|
26
108
|
|
|
27
109
|
if (!convertFn) throw new Error('Invalid conversion unit');
|
|
28
|
-
if (isNaN(
|
|
29
|
-
|
|
30
|
-
if (
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
result: convertFn(parseFloat(String(value))),
|
|
37
|
-
};
|
|
110
|
+
if (isNaN(value)) throw new Error('Value must be a number');
|
|
111
|
+
|
|
112
|
+
if (TEMPERATURE_UNITS.has(fromKey)) {
|
|
113
|
+
const minValue = ABSOLUTE_ZERO[fromKey]!;
|
|
114
|
+
if (value < minValue) throw new Error('Value must be greater than absolute zero');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { from, to, value, result: convertFn(value) };
|
|
38
118
|
}
|
package/src/modules/v4/domain.ts
CHANGED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export interface GeoResult {
|
|
2
|
+
distance: { km: number; miles: number; nauticalMiles: number };
|
|
3
|
+
bearing: { degrees: number; cardinal: string };
|
|
4
|
+
from: { lat: number; lon: number };
|
|
5
|
+
to: { lat: number; lon: number };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const EARTH_RADIUS_KM = 6371;
|
|
9
|
+
const CARDINALS = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];
|
|
10
|
+
|
|
11
|
+
function toRad(deg: number): number {
|
|
12
|
+
return (deg * Math.PI) / 180;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function toDeg(rad: number): number {
|
|
16
|
+
return (rad * 180) / Math.PI;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function parseCoord(value: string, name: string, min: number, max: number): number {
|
|
20
|
+
const n = Number(value);
|
|
21
|
+
if (isNaN(n)) throw new Error(`${name} must be a number`);
|
|
22
|
+
if (n < min || n > max) throw new Error(`${name} must be between ${min} and ${max}`);
|
|
23
|
+
return n;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default function geo(lat1: string, lon1: string, lat2: string, lon2: string): GeoResult {
|
|
27
|
+
const a = parseCoord(lat1, 'lat1', -90, 90);
|
|
28
|
+
const b = parseCoord(lon1, 'lon1', -180, 180);
|
|
29
|
+
const c = parseCoord(lat2, 'lat2', -90, 90);
|
|
30
|
+
const d = parseCoord(lon2, 'lon2', -180, 180);
|
|
31
|
+
|
|
32
|
+
const dLat = toRad(c - a);
|
|
33
|
+
const dLon = toRad(d - b);
|
|
34
|
+
const sa = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a)) * Math.cos(toRad(c)) * Math.sin(dLon / 2) ** 2;
|
|
35
|
+
const distance = 2 * EARTH_RADIUS_KM * Math.asin(Math.sqrt(sa));
|
|
36
|
+
|
|
37
|
+
const y = Math.sin(toRad(d - b)) * Math.cos(toRad(c));
|
|
38
|
+
const x =
|
|
39
|
+
Math.cos(toRad(a)) * Math.sin(toRad(c)) - Math.sin(toRad(a)) * Math.cos(toRad(c)) * Math.cos(toRad(d - b));
|
|
40
|
+
const bearing = (toDeg(Math.atan2(y, x)) + 360) % 360;
|
|
41
|
+
const cardinal = CARDINALS[Math.round(bearing / 22.5) % 16]!;
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
distance: {
|
|
45
|
+
km: +distance.toFixed(3),
|
|
46
|
+
miles: +(distance * 0.621371).toFixed(3),
|
|
47
|
+
nauticalMiles: +(distance * 0.539957).toFixed(3),
|
|
48
|
+
},
|
|
49
|
+
bearing: { degrees: +bearing.toFixed(2), cardinal },
|
|
50
|
+
from: { lat: a, lon: b },
|
|
51
|
+
to: { lat: c, lon: d },
|
|
52
|
+
};
|
|
53
|
+
}
|
package/src/modules/v4/hash.ts
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
import { getHashes, createHash } from 'crypto';
|
|
2
2
|
|
|
3
|
-
export
|
|
3
|
+
export interface HashResult {
|
|
4
|
+
method: string;
|
|
5
|
+
hash: string;
|
|
6
|
+
encoding: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const ENCODINGS = new Set(['hex', 'base64']);
|
|
10
|
+
|
|
11
|
+
export default function hash(text: string, method: string, encoding: string = 'hex'): HashResult {
|
|
12
|
+
if (!text) throw new Error('Text is required');
|
|
13
|
+
|
|
4
14
|
const methods = getHashes();
|
|
5
|
-
if (!methods.includes(method))
|
|
15
|
+
if (!methods.includes(method)) throw new Error(`Unsupported method. Use one of: ${methods.join(', ')}`);
|
|
16
|
+
|
|
17
|
+
if (!ENCODINGS.has(encoding)) throw new Error('Encoding must be one of: hex, base64');
|
|
6
18
|
|
|
7
|
-
const result = createHash(method)
|
|
8
|
-
|
|
19
|
+
const result = createHash(method)
|
|
20
|
+
.update(text)
|
|
21
|
+
.digest(encoding as 'hex' | 'base64');
|
|
22
|
+
return { method, hash: result, encoding };
|
|
9
23
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { hexToRgb, rgbToHsl, hslToRgb, rgbToHex } from '../../utils/colors.js';
|
|
2
|
+
|
|
3
|
+
export interface PaletteColor {
|
|
4
|
+
hex: string;
|
|
5
|
+
rgb: string;
|
|
6
|
+
hsl: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface PaletteResult {
|
|
10
|
+
base: PaletteColor;
|
|
11
|
+
type: string;
|
|
12
|
+
colors: PaletteColor[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function format(r: number, g: number, b: number): PaletteColor {
|
|
16
|
+
const [h, s, l] = rgbToHsl(r, g, b);
|
|
17
|
+
return {
|
|
18
|
+
hex: rgbToHex(r, g, b),
|
|
19
|
+
rgb: `rgb(${r}, ${g}, ${b})`,
|
|
20
|
+
hsl: `hsl(${h.toFixed(1)}, ${(s * 100).toFixed(1)}%, ${(l * 100).toFixed(1)}%)`,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function fromHueShifts(base: [number, number, number], shifts: number[]): PaletteColor[] {
|
|
25
|
+
const [h, s, l] = base;
|
|
26
|
+
return shifts.map((shift) => {
|
|
27
|
+
const newH = (h + shift + 360) % 360;
|
|
28
|
+
const [r, g, b] = hslToRgb(newH, s, l);
|
|
29
|
+
return format(r, g, b);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default function palette(color: string, type: string): PaletteResult {
|
|
34
|
+
if (!color) throw new Error('A base color is required');
|
|
35
|
+
if (!type) throw new Error('A palette type is required');
|
|
36
|
+
|
|
37
|
+
const [r, g, b] = hexToRgb(color);
|
|
38
|
+
const base = format(r, g, b);
|
|
39
|
+
const hsl = rgbToHsl(r, g, b);
|
|
40
|
+
|
|
41
|
+
let colors: PaletteColor[];
|
|
42
|
+
switch (type) {
|
|
43
|
+
case 'complementary':
|
|
44
|
+
colors = fromHueShifts(hsl, [0, 180]);
|
|
45
|
+
break;
|
|
46
|
+
case 'triadic':
|
|
47
|
+
colors = fromHueShifts(hsl, [0, 120, 240]);
|
|
48
|
+
break;
|
|
49
|
+
case 'analogous':
|
|
50
|
+
colors = fromHueShifts(hsl, [-60, -30, 0, 30, 60]);
|
|
51
|
+
break;
|
|
52
|
+
case 'tetradic':
|
|
53
|
+
colors = fromHueShifts(hsl, [0, 90, 180, 270]);
|
|
54
|
+
break;
|
|
55
|
+
case 'split-complementary':
|
|
56
|
+
colors = fromHueShifts(hsl, [0, 150, 210]);
|
|
57
|
+
break;
|
|
58
|
+
default:
|
|
59
|
+
throw new Error('Type must be one of: complementary, triadic, analogous, tetradic, split-complementary');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { base, type, colors };
|
|
63
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { normalizeColor } from '../../utils/colors.js';
|
|
2
|
+
|
|
3
|
+
type AvatarShape = 'circle' | 'rounded' | 'square';
|
|
4
|
+
type AnimateMode = 'shimmer' | 'pulse' | 'none';
|
|
5
|
+
|
|
6
|
+
export interface PlaceholderOptions {
|
|
7
|
+
width: number;
|
|
8
|
+
height: number;
|
|
9
|
+
bg?: string;
|
|
10
|
+
color?: string;
|
|
11
|
+
text?: string;
|
|
12
|
+
rows?: number;
|
|
13
|
+
avatar?: AvatarShape;
|
|
14
|
+
lines?: number;
|
|
15
|
+
animate?: AnimateMode;
|
|
16
|
+
speed?: number;
|
|
17
|
+
radius?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function parseSize(value: string | undefined, name: string, def: number): number {
|
|
21
|
+
if (value === undefined) return def;
|
|
22
|
+
const n = Number(value);
|
|
23
|
+
if (isNaN(n)) throw new Error(`${name} must be a number`);
|
|
24
|
+
if (n < 1 || n > 4000) throw new Error(`${name} must be between 1 and 4000`);
|
|
25
|
+
return Math.floor(n);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function escapeXml(str: string): string {
|
|
29
|
+
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function generateImage(opts: PlaceholderOptions): string {
|
|
33
|
+
const { width, height } = opts;
|
|
34
|
+
const bg = normalizeColor(opts.bg, '#cccccc');
|
|
35
|
+
const color = normalizeColor(opts.color, '#333333');
|
|
36
|
+
const text = escapeXml(opts.text ?? `${width}\u00d7${height}`);
|
|
37
|
+
const fontSize = Math.max(12, Math.min(width, height) / 8);
|
|
38
|
+
|
|
39
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
|
40
|
+
<rect width="${width}" height="${height}" fill="${bg}" rx="4" />
|
|
41
|
+
<text x="50%" y="50%" dominant-baseline="central" text-anchor="middle" font-family="system-ui,-apple-system,sans-serif" font-weight="600" font-size="${fontSize}" fill="${color}">${text}</text>
|
|
42
|
+
</svg>`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function avatarShape(shape: AvatarShape, x: number, y: number, size: number): string {
|
|
46
|
+
const r = size / 2;
|
|
47
|
+
switch (shape) {
|
|
48
|
+
case 'circle':
|
|
49
|
+
return `<circle cx="${x + r}" cy="${y + r}" r="${r}" />`;
|
|
50
|
+
case 'rounded':
|
|
51
|
+
return `<rect x="${x}" y="${y}" width="${size}" height="${size}" rx="${Math.round(size * 0.25)}" />`;
|
|
52
|
+
case 'square':
|
|
53
|
+
return `<rect x="${x}" y="${y}" width="${size}" height="${size}" rx="2" />`;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function animationDefs(mode: AnimateMode, bg: string, highlight: string, speed: number): string {
|
|
58
|
+
if (mode === 'none') return '';
|
|
59
|
+
|
|
60
|
+
if (mode === 'pulse') {
|
|
61
|
+
return `<style>
|
|
62
|
+
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
|
|
63
|
+
.sk { animation: pulse ${speed}s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
|
|
64
|
+
</style>`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return `<linearGradient id="g">
|
|
68
|
+
<stop offset="0.1" stop-color="${bg}" />
|
|
69
|
+
<stop offset="0.5" stop-color="${highlight}" />
|
|
70
|
+
<stop offset="0.9" stop-color="${bg}" />
|
|
71
|
+
<animateTransform attributeName="gradientTransform" type="translate" values="-2 0; 2 0" dur="${speed}s" repeatCount="indefinite" />
|
|
72
|
+
</linearGradient>`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function generateSkeleton(opts: PlaceholderOptions): string {
|
|
76
|
+
const { width, height } = opts;
|
|
77
|
+
const bg = normalizeColor(opts.bg, '#e5e5e5');
|
|
78
|
+
const highlight = normalizeColor(opts.color, '#f5f5f5');
|
|
79
|
+
const rows = Math.max(0, Math.min(20, opts.rows ?? 3));
|
|
80
|
+
const avatar = opts.avatar;
|
|
81
|
+
const animate = opts.animate ?? 'shimmer';
|
|
82
|
+
const speed = opts.speed ?? 1.5;
|
|
83
|
+
const rx = opts.radius ?? 4;
|
|
84
|
+
|
|
85
|
+
const pad = Math.round(width * 0.06);
|
|
86
|
+
const slots = rows + (avatar ? 4 : 0);
|
|
87
|
+
const available = height - pad * 2;
|
|
88
|
+
const lineH = Math.round(Math.max(8, (available / Math.max(1, slots)) * 0.65));
|
|
89
|
+
const gap = Math.round(Math.max(6, (available / Math.max(1, slots)) * 0.35));
|
|
90
|
+
|
|
91
|
+
const shapes: string[] = [];
|
|
92
|
+
let y = pad;
|
|
93
|
+
|
|
94
|
+
if (avatar) {
|
|
95
|
+
const size = Math.round(Math.min(width * 0.14, height * 0.24, 48));
|
|
96
|
+
shapes.push(avatarShape(avatar, pad, y, size));
|
|
97
|
+
|
|
98
|
+
const lx = pad + size + gap;
|
|
99
|
+
const lw = width - lx - pad;
|
|
100
|
+
const count = Math.max(0, Math.min(2, opts.lines ?? 2));
|
|
101
|
+
|
|
102
|
+
if (count === 2) {
|
|
103
|
+
const nameY = Math.max(pad, Math.round(y + size / 2 - (lineH * 2 + gap) / 2));
|
|
104
|
+
shapes.push(`<rect x="${lx}" y="${nameY}" width="${lw * 0.55}" height="${lineH}" rx="${rx}" />`);
|
|
105
|
+
shapes.push(
|
|
106
|
+
`<rect x="${lx}" y="${nameY + lineH + gap}" width="${lw * 0.35}" height="${lineH}" rx="${rx}" />`,
|
|
107
|
+
);
|
|
108
|
+
} else if (count === 1) {
|
|
109
|
+
const nameY = Math.max(pad, Math.round(y + size / 2 - lineH / 2));
|
|
110
|
+
shapes.push(`<rect x="${lx}" y="${nameY}" width="${lw * 0.55}" height="${lineH}" rx="${rx}" />`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
y += size + gap * 2;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (let i = 0; i < rows; i++) {
|
|
117
|
+
const ratio = i === rows - 1 && rows > 1 ? 0.55 : i % 2 === 0 ? 0.92 : 0.78;
|
|
118
|
+
const w = Math.round((width - pad * 2) * ratio);
|
|
119
|
+
shapes.push(`<rect x="${pad}" y="${y}" width="${w}" height="${lineH}" rx="${rx}" />`);
|
|
120
|
+
y += lineH + gap;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const defs = animationDefs(animate, bg, highlight, speed);
|
|
124
|
+
const fill = animate === 'shimmer' ? 'url(#g)' : bg;
|
|
125
|
+
const cls = animate === 'pulse' ? ' class="sk"' : '';
|
|
126
|
+
|
|
127
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
|
128
|
+
<defs>
|
|
129
|
+
<clipPath id="c">
|
|
130
|
+
${shapes.join('\n ')}
|
|
131
|
+
</clipPath>
|
|
132
|
+
${defs}
|
|
133
|
+
</defs>
|
|
134
|
+
<rect width="${width}" height="${height}" clip-path="url(#c)" fill="${fill}"${cls} />
|
|
135
|
+
</svg>`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface PlaceholderResult {
|
|
139
|
+
type: string;
|
|
140
|
+
contentType: string;
|
|
141
|
+
body: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const AVATAR_SHAPES = new Set(['circle', 'rounded', 'square']);
|
|
145
|
+
const ANIMATE_MODES = new Set(['shimmer', 'pulse', 'none']);
|
|
146
|
+
|
|
147
|
+
function parseAvatar(value: string | undefined): AvatarShape | undefined {
|
|
148
|
+
if (!value) return undefined;
|
|
149
|
+
if (value === 'true' || value === '1') return 'circle';
|
|
150
|
+
if (AVATAR_SHAPES.has(value)) return value as AvatarShape;
|
|
151
|
+
throw new Error('avatar must be one of: circle, rounded, square');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function parseAnimate(value: string | undefined): AnimateMode {
|
|
155
|
+
if (!value) return 'shimmer';
|
|
156
|
+
if (ANIMATE_MODES.has(value)) return value as AnimateMode;
|
|
157
|
+
throw new Error('animate must be one of: shimmer, pulse, none');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export default function placeholder(type: string, query: Record<string, string | undefined>): PlaceholderResult {
|
|
161
|
+
const speed = query.speed ? parseFloat(query.speed) : 1.5;
|
|
162
|
+
if (isNaN(speed) || speed < 0.1 || speed > 10) throw new Error('speed must be between 0.1 and 10');
|
|
163
|
+
|
|
164
|
+
const radius = query.radius ? parseInt(query.radius, 10) : 4;
|
|
165
|
+
if (isNaN(radius) || radius < 0 || radius > 50) throw new Error('radius must be between 0 and 50');
|
|
166
|
+
|
|
167
|
+
const opts: PlaceholderOptions = {
|
|
168
|
+
width: parseSize(query.width, 'width', 800),
|
|
169
|
+
height: parseSize(query.height, 'height', 600),
|
|
170
|
+
bg: query.bg,
|
|
171
|
+
color: query.color,
|
|
172
|
+
text: query.text,
|
|
173
|
+
rows: query.rows ? parseInt(query.rows, 10) : undefined,
|
|
174
|
+
avatar: parseAvatar(query.avatar),
|
|
175
|
+
lines: query.lines ? parseInt(query.lines, 10) : 2,
|
|
176
|
+
animate: parseAnimate(query.animate),
|
|
177
|
+
speed,
|
|
178
|
+
radius,
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
switch (type) {
|
|
182
|
+
case 'image':
|
|
183
|
+
return { type, contentType: 'image/svg+xml', body: generateImage(opts) };
|
|
184
|
+
case 'skeleton':
|
|
185
|
+
return { type, contentType: 'image/svg+xml', body: generateSkeleton(opts) };
|
|
186
|
+
default:
|
|
187
|
+
throw new Error('Type must be one of: image, skeleton');
|
|
188
|
+
}
|
|
189
|
+
}
|
package/src/modules/v4/qrcode.ts
CHANGED
|
@@ -1,11 +1,113 @@
|
|
|
1
|
-
import { toDataURL } from 'qrcode';
|
|
1
|
+
import { toBuffer, toDataURL, toCanvas as qrToCanvas } from 'qrcode';
|
|
2
|
+
import { createCanvas, loadImage } from 'canvas';
|
|
3
|
+
import { normalizeColor } from '../../utils/colors.js';
|
|
2
4
|
|
|
3
|
-
export
|
|
5
|
+
export interface QRCodeOptions {
|
|
6
|
+
url: string;
|
|
7
|
+
size?: number;
|
|
8
|
+
margin?: number;
|
|
9
|
+
correction?: 'L' | 'M' | 'Q' | 'H';
|
|
10
|
+
dark?: string;
|
|
11
|
+
light?: string;
|
|
12
|
+
icon?: string;
|
|
13
|
+
iconSize?: number;
|
|
14
|
+
iconPadding?: number;
|
|
15
|
+
iconRadius?: number;
|
|
16
|
+
format?: 'png' | 'base64';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface QRCodeResult {
|
|
20
|
+
contentType: string;
|
|
21
|
+
body: Buffer | string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const CORRECTIONS = new Set(['L', 'M', 'Q', 'H']);
|
|
25
|
+
const FORMATS = new Set(['png', 'base64']);
|
|
26
|
+
const MAX_LOGO_BYTES = 2 * 1024 * 1024;
|
|
27
|
+
|
|
28
|
+
function clamp(value: number | undefined, name: string, def: number, min: number, max: number): number {
|
|
29
|
+
if (value === undefined) return def;
|
|
30
|
+
if (isNaN(value)) throw new Error(`${name} must be a number`);
|
|
31
|
+
if (value < min || value > max) throw new Error(`${name} must be between ${min} and ${max}`);
|
|
32
|
+
return Math.floor(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function fetchIcon(url: string): Promise<Buffer> {
|
|
36
|
+
if (!/^https:\/\//i.test(url)) throw new Error('Icon URL must use HTTPS');
|
|
37
|
+
|
|
38
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(5000) });
|
|
39
|
+
|
|
40
|
+
if (!response.ok) throw new Error(`Failed to fetch icon: HTTP ${response.status}`);
|
|
41
|
+
|
|
42
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
43
|
+
if (!contentType.startsWith('image/')) throw new Error('Icon URL must point to an image');
|
|
44
|
+
|
|
45
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
46
|
+
if (buffer.length > MAX_LOGO_BYTES) throw new Error('Icon must be under 2MB');
|
|
47
|
+
|
|
48
|
+
return buffer;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export default async function qrcode(options: QRCodeOptions): Promise<QRCodeResult> {
|
|
52
|
+
const { url } = options;
|
|
4
53
|
if (!url || typeof url !== 'string') throw new Error('Please provide a valid URL');
|
|
5
54
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
55
|
+
const format = options.format ?? 'png';
|
|
56
|
+
if (!FORMATS.has(format)) throw new Error('format must be one of: png, base64');
|
|
57
|
+
|
|
58
|
+
const size = clamp(options.size, 'size', 200, 50, 2000);
|
|
59
|
+
const margin = clamp(options.margin, 'margin', 4, 0, 10);
|
|
60
|
+
const dark = normalizeColor(options.dark, '#000000');
|
|
61
|
+
const light = normalizeColor(options.light, '#ffffff');
|
|
62
|
+
|
|
63
|
+
let correction = options.correction ?? 'M';
|
|
64
|
+
if (!CORRECTIONS.has(correction)) throw new Error('correction must be one of: L, M, Q, H');
|
|
65
|
+
if (options.icon) correction = 'H';
|
|
66
|
+
|
|
67
|
+
const qrOpts = {
|
|
68
|
+
width: size,
|
|
69
|
+
margin,
|
|
70
|
+
errorCorrectionLevel: correction as 'L' | 'M' | 'Q' | 'H',
|
|
71
|
+
color: { dark, light },
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
if (!options.icon) {
|
|
75
|
+
if (format === 'base64') {
|
|
76
|
+
return { contentType: 'application/json', body: await toDataURL(url, qrOpts) };
|
|
77
|
+
}
|
|
78
|
+
return { contentType: 'image/png', body: await toBuffer(url, qrOpts) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const canvas = createCanvas(size, size);
|
|
82
|
+
await qrToCanvas(canvas as unknown as HTMLCanvasElement, url, qrOpts);
|
|
83
|
+
|
|
84
|
+
const iconBuffer = await fetchIcon(options.icon);
|
|
85
|
+
const iconImg = await loadImage(iconBuffer);
|
|
86
|
+
|
|
87
|
+
const iconSize = clamp(options.iconSize, 'iconSize', Math.round(size * 0.2), 10, Math.round(size * 0.4));
|
|
88
|
+
const iconPadding = clamp(options.iconPadding, 'iconPadding', 5, 0, 50);
|
|
89
|
+
const maxRadius = Math.floor((iconSize + iconPadding * 2) / 2);
|
|
90
|
+
const iconRadius = clamp(options.iconRadius, 'iconRadius', 0, 0, maxRadius);
|
|
91
|
+
|
|
92
|
+
const ctx = canvas.getContext('2d');
|
|
93
|
+
const totalSize = iconSize + iconPadding * 2;
|
|
94
|
+
const x = (canvas.width - totalSize) / 2;
|
|
95
|
+
const y = (canvas.height - totalSize) / 2;
|
|
96
|
+
|
|
97
|
+
ctx.beginPath();
|
|
98
|
+
ctx.roundRect(x, y, totalSize, totalSize, iconRadius);
|
|
99
|
+
ctx.fillStyle = light;
|
|
100
|
+
ctx.fill();
|
|
101
|
+
|
|
102
|
+
ctx.save();
|
|
103
|
+
ctx.beginPath();
|
|
104
|
+
ctx.roundRect(x + iconPadding, y + iconPadding, iconSize, iconSize, Math.max(0, iconRadius - iconPadding));
|
|
105
|
+
ctx.clip();
|
|
106
|
+
ctx.drawImage(iconImg, x + iconPadding, y + iconPadding, iconSize, iconSize);
|
|
107
|
+
ctx.restore();
|
|
108
|
+
|
|
109
|
+
if (format === 'base64') {
|
|
110
|
+
return { contentType: 'application/json', body: canvas.toDataURL('image/png') };
|
|
10
111
|
}
|
|
112
|
+
return { contentType: 'image/png', body: canvas.toBuffer('image/png') };
|
|
11
113
|
}
|
|
@@ -44,11 +44,43 @@ export default function tic_tac_toe(action: string, params: TicTacToeParams): Re
|
|
|
44
44
|
return playMove(params, games, sessions, u, now);
|
|
45
45
|
} else if (action === 'fetch') {
|
|
46
46
|
return fetchGame(params, games, u);
|
|
47
|
+
} else if (action === 'forfeit') {
|
|
48
|
+
return forfeitGame(params, games, sessions);
|
|
47
49
|
} else {
|
|
48
|
-
throw new Error('Invalid action. Use "play", "fetch", or "
|
|
50
|
+
throw new Error('Invalid action. Use "play", "fetch", "list", or "forfeit"');
|
|
49
51
|
}
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
function forfeitGame(
|
|
55
|
+
params: TicTacToeParams,
|
|
56
|
+
games: Record<string, TicTacToeGame>,
|
|
57
|
+
sessions: Record<string, { user: string; last: number }>,
|
|
58
|
+
): Record<string, unknown> {
|
|
59
|
+
const { game, session } = params;
|
|
60
|
+
|
|
61
|
+
if (!game) throw new Error('Please provide a valid game ID');
|
|
62
|
+
if (!session) throw new Error('Please provide a valid session ID');
|
|
63
|
+
if (!games[game]) throw new Error('Game not found');
|
|
64
|
+
|
|
65
|
+
const u = params.username!.toLowerCase();
|
|
66
|
+
if (sessions[u] && sessions[u].user !== session) {
|
|
67
|
+
throw new Error('Session ID mismatch');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const players = games[game]!.players;
|
|
71
|
+
if (!players.includes(u)) throw new Error('You are not a player in this game');
|
|
72
|
+
|
|
73
|
+
const winner = players.find((p) => p !== u) ?? null;
|
|
74
|
+
delete games[game];
|
|
75
|
+
delete sessions[u];
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
message: `${params.username} forfeited the game.${winner ? ` ${winner} wins.` : ''}`,
|
|
79
|
+
winner,
|
|
80
|
+
loser: params.username,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
52
84
|
function playMove(
|
|
53
85
|
params: TicTacToeParams,
|
|
54
86
|
games: Record<string, TicTacToeGame>,
|