xpt-shared-types 1.6.2 → 1.8.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/dist/generated/inputs.d.ts +4 -0
- package/dist/generated/models.d.ts +4 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -0
- package/dist/manual/index.d.ts +1 -0
- package/dist/manual/index.js +1 -0
- package/dist/manual/lobby.d.ts +39 -0
- package/dist/manual/lobby.js +11 -0
- package/dist/stream.d.ts +77 -0
- package/dist/stream.js +217 -0
- package/package.json +1 -1
- package/src/generated/inputs.ts +4 -0
- package/src/generated/models.ts +4 -0
- package/src/index.ts +5 -1
- package/src/manual/index.ts +1 -0
- package/src/manual/lobby.ts +52 -0
- package/src/stream.ts +289 -0
|
@@ -23,12 +23,14 @@ export interface FaqInput {
|
|
|
23
23
|
export interface FeaturedGameInput {
|
|
24
24
|
featuredHeading?: string;
|
|
25
25
|
featuredSubheading?: string;
|
|
26
|
+
isActive?: boolean;
|
|
26
27
|
game?: RelationInput;
|
|
27
28
|
}
|
|
28
29
|
/** Write payload for `featured-tournament`. */
|
|
29
30
|
export interface FeaturedTournamentInput {
|
|
30
31
|
featuredHeading?: string;
|
|
31
32
|
featuredSubheading?: string;
|
|
33
|
+
isActive?: boolean;
|
|
32
34
|
tournament?: RelationInput;
|
|
33
35
|
}
|
|
34
36
|
/** Write payload for `friendship`. */
|
|
@@ -136,6 +138,8 @@ export interface MatchInput {
|
|
|
136
138
|
endedAt?: string;
|
|
137
139
|
resultType?: MatchResultType;
|
|
138
140
|
resultNote?: string;
|
|
141
|
+
homeReportedAt?: string;
|
|
142
|
+
awayReportedAt?: string;
|
|
139
143
|
disputeReason?: MatchDisputeReason;
|
|
140
144
|
disputeNote?: string;
|
|
141
145
|
disputedBy?: RelationInput;
|
|
@@ -23,12 +23,14 @@ export interface Faq extends StrapiDocument {
|
|
|
23
23
|
export interface FeaturedGame extends StrapiDocument {
|
|
24
24
|
featuredHeading?: string | null;
|
|
25
25
|
featuredSubheading?: string | null;
|
|
26
|
+
isActive?: boolean | null;
|
|
26
27
|
game?: Game | null;
|
|
27
28
|
}
|
|
28
29
|
/** `featured-tournament` */
|
|
29
30
|
export interface FeaturedTournament extends StrapiDocument {
|
|
30
31
|
featuredHeading?: string | null;
|
|
31
32
|
featuredSubheading?: string | null;
|
|
33
|
+
isActive?: boolean | null;
|
|
32
34
|
tournament?: Tournament | null;
|
|
33
35
|
}
|
|
34
36
|
/** `friendship` */
|
|
@@ -136,6 +138,8 @@ export interface Match extends StrapiDocument {
|
|
|
136
138
|
endedAt?: string | null;
|
|
137
139
|
resultType?: MatchResultType | null;
|
|
138
140
|
resultNote?: string | null;
|
|
141
|
+
homeReportedAt?: string | null;
|
|
142
|
+
awayReportedAt?: string | null;
|
|
139
143
|
disputeReason?: MatchDisputeReason | null;
|
|
140
144
|
disputeNote?: string | null;
|
|
141
145
|
disputedBy?: User | null;
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -25,6 +25,9 @@ __exportStar(require("./bracket"), exports);
|
|
|
25
25
|
// Game/platform/account catalogue — the game -> platform -> account chain,
|
|
26
26
|
// applied identically by the backend resolver and the client's pickers
|
|
27
27
|
__exportStar(require("./gameCatalogue"), exports);
|
|
28
|
+
// Stream links — one pasted URL parsed the same way by the backend that stores
|
|
29
|
+
// the derived columns and the client that embeds the player
|
|
30
|
+
__exportStar(require("./stream"), exports);
|
|
28
31
|
// Data
|
|
29
32
|
__exportStar(require("./data/countriesList"), exports);
|
|
30
33
|
__exportStar(require("./data/gameGenres"), exports);
|
package/dist/manual/index.d.ts
CHANGED
package/dist/manual/index.js
CHANGED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The match lobby as the client renders it.
|
|
3
|
+
*
|
|
4
|
+
* None of these are Strapi content types. The lobby is a view over a match
|
|
5
|
+
* row plus what `GET /matches/:id/lobby` derives for the viewer, and these
|
|
6
|
+
* name the parts of that view both apps have to agree on.
|
|
7
|
+
*/
|
|
8
|
+
import type { MatchDisputeReason, MatchRound } from '../generated/enums';
|
|
9
|
+
/**
|
|
10
|
+
* Where a lobby is in its life, from the viewer's side. Derived on the client
|
|
11
|
+
* from `lobbyStatus`, `startedAt`/`endedAt` and whether the viewer's own side
|
|
12
|
+
* has reported; `disputed` and `completed` come straight from `lobbyStatus`.
|
|
13
|
+
*/
|
|
14
|
+
export type LobbyPhase = 'waiting' | 'live' | 'reported' | 'disputed' | 'completed';
|
|
15
|
+
/**
|
|
16
|
+
* What the viewer may do in the lobby. A captain (or a solo player) acts for
|
|
17
|
+
* a side; a roster member only talks; the host and staff watch and rule from
|
|
18
|
+
* the manage page.
|
|
19
|
+
*/
|
|
20
|
+
export type LobbyViewerRole = 'captain' | 'teamMember' | 'hostOrSpectator';
|
|
21
|
+
/** The match the winner goes on to — enough to say "Round 2 — Match #2". */
|
|
22
|
+
export interface LobbyNextMatch {
|
|
23
|
+
documentId: string;
|
|
24
|
+
round: MatchRound | null;
|
|
25
|
+
matchNumber: number | null;
|
|
26
|
+
}
|
|
27
|
+
/** The reasons a player may send; a score mismatch is found by the server. */
|
|
28
|
+
export type PlayerDisputeReason = Exclude<MatchDisputeReason, 'scoreMismatch'>;
|
|
29
|
+
/** Body of `POST /matches/:id/dispute`, inside `data`. */
|
|
30
|
+
export interface MatchDisputeInput {
|
|
31
|
+
reason: PlayerDisputeReason;
|
|
32
|
+
/** Optional, trimmed, at most `DISPUTE_NOTE_MAX_LENGTH` characters. */
|
|
33
|
+
note?: string;
|
|
34
|
+
}
|
|
35
|
+
export declare const DISPUTE_NOTE_MAX_LENGTH = 500;
|
|
36
|
+
/** Body of `POST /matches/:id/ready`, inside `data`. Omitted means ready. */
|
|
37
|
+
export interface MatchReadyInput {
|
|
38
|
+
ready?: boolean;
|
|
39
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The match lobby as the client renders it.
|
|
4
|
+
*
|
|
5
|
+
* None of these are Strapi content types. The lobby is a view over a match
|
|
6
|
+
* row plus what `GET /matches/:id/lobby` derives for the viewer, and these
|
|
7
|
+
* name the parts of that view both apps have to agree on.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.DISPUTE_NOTE_MAX_LENGTH = void 0;
|
|
11
|
+
exports.DISPUTE_NOTE_MAX_LENGTH = 500;
|
package/dist/stream.d.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One pasted URL → the platform, the handle, and the player the live page
|
|
3
|
+
* embeds.
|
|
4
|
+
*
|
|
5
|
+
* A match carries three stream columns for one concept: `streamLink` (what the
|
|
6
|
+
* host pasted), and `streamHandle` + `streamPlatform` (what the live tile needs
|
|
7
|
+
* for a Twitch preview and what the live page needs to label a "watch on"
|
|
8
|
+
* button). The host types the link; the other two are derived here and never
|
|
9
|
+
* accepted from a client, so there is exactly one source of truth.
|
|
10
|
+
*
|
|
11
|
+
* An unrecognised host is still a valid stream link — it is filed as `other`
|
|
12
|
+
* with no handle, and the live page falls back to a "watch on" button, which
|
|
13
|
+
* already prefers `streamLink` when it is set.
|
|
14
|
+
*
|
|
15
|
+
* Lives here rather than in xpt-strapi because the client needs the identical
|
|
16
|
+
* parse: the host must be told, as they type, which platform their link
|
|
17
|
+
* resolved to and whether it will embed at all. Two implementations of the same
|
|
18
|
+
* URL grammar is two chances to disagree about what the backend just stored.
|
|
19
|
+
*
|
|
20
|
+
* `buildEmbedUrl` deliberately takes the **link**, not the stored handle. The
|
|
21
|
+
* handle is lossy: `youtube.com/@team` and `youtube.com/watch?v=abc` both
|
|
22
|
+
* flatten to one opaque string, yet one is a channel and the other is a video,
|
|
23
|
+
* and they take different embed URLs. Re-parsing the link is what lets the
|
|
24
|
+
* YouTube embed be correct at all.
|
|
25
|
+
*
|
|
26
|
+
* Pure on purpose: no Strapi, no DOM, no I/O, so the rules are unit-tested
|
|
27
|
+
* directly (see xpt-strapi `tests/unit/utils/streamLink.test.ts`).
|
|
28
|
+
*/
|
|
29
|
+
import type { MatchStreamPlatform } from './generated/enums';
|
|
30
|
+
/** `Match.streamPlatform`. Aliased so call sites read as stream code. */
|
|
31
|
+
export type StreamPlatform = MatchStreamPlatform;
|
|
32
|
+
export interface StreamSource {
|
|
33
|
+
platform: StreamPlatform;
|
|
34
|
+
handle: string | null;
|
|
35
|
+
}
|
|
36
|
+
export declare const STREAM_LINK_MAX_LENGTH = 255;
|
|
37
|
+
/** The URL parsed, or null when it is not an absolute http(s) URL. */
|
|
38
|
+
export declare function parseStreamUrl(raw: string): URL | null;
|
|
39
|
+
export declare function deriveStreamSource(raw: string): StreamSource | null;
|
|
40
|
+
export interface StreamFields {
|
|
41
|
+
streamLink: string | null;
|
|
42
|
+
streamHandle: string | null;
|
|
43
|
+
streamPlatform: StreamPlatform | null;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The three stream columns for a pasted link, ready to write. Empty clears all
|
|
47
|
+
* three together — a stale handle with no link would keep a dead embed alive.
|
|
48
|
+
*/
|
|
49
|
+
export declare function streamFieldsFor(raw: unknown): {
|
|
50
|
+
ok: true;
|
|
51
|
+
value: StreamFields;
|
|
52
|
+
} | {
|
|
53
|
+
ok: false;
|
|
54
|
+
message: string;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* The embeddable player URL for a stored stream link, or null when the link
|
|
58
|
+
* cannot be embedded and the viewer should be sent to the platform instead.
|
|
59
|
+
*
|
|
60
|
+
* `parentDomain` is Twitch's requirement: it must be the hostname of the page
|
|
61
|
+
* carrying the iframe, or Twitch refuses to play. Read it from
|
|
62
|
+
* `window.location.hostname` on the client rather than configuring it — the
|
|
63
|
+
* browser's own answer is correct by construction.
|
|
64
|
+
*/
|
|
65
|
+
export declare function buildEmbedUrl(streamLink: string | null | undefined, opts: {
|
|
66
|
+
parentDomain: string;
|
|
67
|
+
}): string | null;
|
|
68
|
+
/** The label for the button that leaves the site for the platform. */
|
|
69
|
+
export declare const STREAM_PLATFORM_LABELS: Record<StreamPlatform, string>;
|
|
70
|
+
/** How the platform is named in host-facing copy. */
|
|
71
|
+
export declare const STREAM_PLATFORM_NAMES: Record<StreamPlatform, string>;
|
|
72
|
+
/**
|
|
73
|
+
* Where "Watch on X" should point. The stored link is what the host pasted, so
|
|
74
|
+
* it wins whenever it is set; the handle is only a fallback for rows written
|
|
75
|
+
* before the link was kept.
|
|
76
|
+
*/
|
|
77
|
+
export declare function buildWatchUrl(streamLink: string | null | undefined, platform?: StreamPlatform | null, handle?: string | null): string | null;
|
package/dist/stream.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.STREAM_PLATFORM_NAMES = exports.STREAM_PLATFORM_LABELS = exports.STREAM_LINK_MAX_LENGTH = void 0;
|
|
4
|
+
exports.parseStreamUrl = parseStreamUrl;
|
|
5
|
+
exports.deriveStreamSource = deriveStreamSource;
|
|
6
|
+
exports.streamFieldsFor = streamFieldsFor;
|
|
7
|
+
exports.buildEmbedUrl = buildEmbedUrl;
|
|
8
|
+
exports.buildWatchUrl = buildWatchUrl;
|
|
9
|
+
exports.STREAM_LINK_MAX_LENGTH = 255;
|
|
10
|
+
/** The URL parsed, or null when it is not an absolute http(s) URL. */
|
|
11
|
+
function parseStreamUrl(raw) {
|
|
12
|
+
let url;
|
|
13
|
+
try {
|
|
14
|
+
url = new URL(raw.trim());
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
20
|
+
return null;
|
|
21
|
+
return url;
|
|
22
|
+
}
|
|
23
|
+
function hostOf(url) {
|
|
24
|
+
return url.hostname.toLowerCase().replace(/^www\./, '');
|
|
25
|
+
}
|
|
26
|
+
/** Path segments with the empties from leading and trailing slashes dropped. */
|
|
27
|
+
function segmentsOf(url) {
|
|
28
|
+
return url.pathname
|
|
29
|
+
.split('/')
|
|
30
|
+
.filter(Boolean)
|
|
31
|
+
.map((segment) => decodeURIComponent(segment));
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Twitch paths that are not a channel. A VOD or a directory page has no live
|
|
35
|
+
* preview, so it gets no handle rather than a wrong one.
|
|
36
|
+
*/
|
|
37
|
+
const TWITCH_NON_CHANNEL = new Set(['videos', 'directory', 'clip', 'p']);
|
|
38
|
+
function deriveStreamSource(raw) {
|
|
39
|
+
var _a, _b, _c;
|
|
40
|
+
const url = parseStreamUrl(raw);
|
|
41
|
+
if (!url)
|
|
42
|
+
return null;
|
|
43
|
+
const host = hostOf(url);
|
|
44
|
+
const segments = segmentsOf(url);
|
|
45
|
+
const first = (_a = segments[0]) !== null && _a !== void 0 ? _a : null;
|
|
46
|
+
if (host === 'twitch.tv' || host.endsWith('.twitch.tv')) {
|
|
47
|
+
const handle = first && !TWITCH_NON_CHANNEL.has(first) ? first : null;
|
|
48
|
+
return { platform: 'twitch', handle };
|
|
49
|
+
}
|
|
50
|
+
if (host === 'kick.com') {
|
|
51
|
+
return { platform: 'kick', handle: first };
|
|
52
|
+
}
|
|
53
|
+
if (host === 'youtu.be') {
|
|
54
|
+
return { platform: 'youtube', handle: first };
|
|
55
|
+
}
|
|
56
|
+
if (host === 'youtube.com' || host.endsWith('.youtube.com')) {
|
|
57
|
+
// /@handle, /c/name, /channel/id and /user/name all carry the identifier
|
|
58
|
+
// the live page embeds; /watch and /live carry it one segment later or in
|
|
59
|
+
// the query instead.
|
|
60
|
+
if (first === null || first === void 0 ? void 0 : first.startsWith('@'))
|
|
61
|
+
return { platform: 'youtube', handle: first };
|
|
62
|
+
if (first === 'c' || first === 'channel' || first === 'user') {
|
|
63
|
+
return { platform: 'youtube', handle: (_b = segments[1]) !== null && _b !== void 0 ? _b : null };
|
|
64
|
+
}
|
|
65
|
+
if (first === 'watch') {
|
|
66
|
+
return { platform: 'youtube', handle: url.searchParams.get('v') };
|
|
67
|
+
}
|
|
68
|
+
if (first === 'live') {
|
|
69
|
+
return { platform: 'youtube', handle: (_c = segments[1]) !== null && _c !== void 0 ? _c : null };
|
|
70
|
+
}
|
|
71
|
+
return { platform: 'youtube', handle: first };
|
|
72
|
+
}
|
|
73
|
+
return { platform: 'other', handle: null };
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The three stream columns for a pasted link, ready to write. Empty clears all
|
|
77
|
+
* three together — a stale handle with no link would keep a dead embed alive.
|
|
78
|
+
*/
|
|
79
|
+
function streamFieldsFor(raw) {
|
|
80
|
+
if (raw === null || raw === undefined || raw === '') {
|
|
81
|
+
return {
|
|
82
|
+
ok: true,
|
|
83
|
+
value: { streamLink: null, streamHandle: null, streamPlatform: null },
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
if (typeof raw !== 'string') {
|
|
87
|
+
return { ok: false, message: 'streamLink must be a string' };
|
|
88
|
+
}
|
|
89
|
+
const trimmed = raw.trim();
|
|
90
|
+
if (!trimmed) {
|
|
91
|
+
return {
|
|
92
|
+
ok: true,
|
|
93
|
+
value: { streamLink: null, streamHandle: null, streamPlatform: null },
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (trimmed.length > exports.STREAM_LINK_MAX_LENGTH) {
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
message: `streamLink must be ${exports.STREAM_LINK_MAX_LENGTH} characters or fewer`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const source = deriveStreamSource(trimmed);
|
|
103
|
+
if (!source) {
|
|
104
|
+
return { ok: false, message: 'streamLink must be a valid http(s) URL' };
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
ok: true,
|
|
108
|
+
value: {
|
|
109
|
+
streamLink: trimmed,
|
|
110
|
+
streamHandle: source.handle,
|
|
111
|
+
streamPlatform: source.platform,
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function youTubeRef(url) {
|
|
116
|
+
var _a, _b, _c;
|
|
117
|
+
const host = hostOf(url);
|
|
118
|
+
const segments = segmentsOf(url);
|
|
119
|
+
const first = (_a = segments[0]) !== null && _a !== void 0 ? _a : null;
|
|
120
|
+
if (host === 'youtu.be') {
|
|
121
|
+
return first ? { kind: 'video', id: first } : null;
|
|
122
|
+
}
|
|
123
|
+
if (first === 'watch') {
|
|
124
|
+
const id = url.searchParams.get('v');
|
|
125
|
+
return id ? { kind: 'video', id } : null;
|
|
126
|
+
}
|
|
127
|
+
if (first === 'live' || first === 'embed' || first === 'shorts') {
|
|
128
|
+
const id = (_b = segments[1]) !== null && _b !== void 0 ? _b : null;
|
|
129
|
+
return id ? { kind: 'video', id } : null;
|
|
130
|
+
}
|
|
131
|
+
if (first === 'channel') {
|
|
132
|
+
const id = (_c = segments[1]) !== null && _c !== void 0 ? _c : null;
|
|
133
|
+
return id ? { kind: 'channel', id } : null;
|
|
134
|
+
}
|
|
135
|
+
if ((first === null || first === void 0 ? void 0 : first.startsWith('@')) || first === 'c' || first === 'user') {
|
|
136
|
+
return { kind: 'vanity' };
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* The embeddable player URL for a stored stream link, or null when the link
|
|
142
|
+
* cannot be embedded and the viewer should be sent to the platform instead.
|
|
143
|
+
*
|
|
144
|
+
* `parentDomain` is Twitch's requirement: it must be the hostname of the page
|
|
145
|
+
* carrying the iframe, or Twitch refuses to play. Read it from
|
|
146
|
+
* `window.location.hostname` on the client rather than configuring it — the
|
|
147
|
+
* browser's own answer is correct by construction.
|
|
148
|
+
*/
|
|
149
|
+
function buildEmbedUrl(streamLink, opts) {
|
|
150
|
+
if (!streamLink)
|
|
151
|
+
return null;
|
|
152
|
+
const url = parseStreamUrl(streamLink);
|
|
153
|
+
if (!url)
|
|
154
|
+
return null;
|
|
155
|
+
const source = deriveStreamSource(streamLink);
|
|
156
|
+
if (!source)
|
|
157
|
+
return null;
|
|
158
|
+
switch (source.platform) {
|
|
159
|
+
case 'twitch':
|
|
160
|
+
if (!source.handle)
|
|
161
|
+
return null;
|
|
162
|
+
return `https://player.twitch.tv/?channel=${encodeURIComponent(source.handle)}&parent=${encodeURIComponent(opts.parentDomain)}&autoplay=false`;
|
|
163
|
+
case 'youtube': {
|
|
164
|
+
const ref = youTubeRef(url);
|
|
165
|
+
if (!ref)
|
|
166
|
+
return null;
|
|
167
|
+
if (ref.kind === 'video') {
|
|
168
|
+
return `https://www.youtube.com/embed/${encodeURIComponent(ref.id)}`;
|
|
169
|
+
}
|
|
170
|
+
if (ref.kind === 'channel') {
|
|
171
|
+
return `https://www.youtube.com/embed/live_stream?channel=${encodeURIComponent(ref.id)}`;
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
case 'kick':
|
|
176
|
+
if (!source.handle)
|
|
177
|
+
return null;
|
|
178
|
+
return `https://player.kick.com/${encodeURIComponent(source.handle)}`;
|
|
179
|
+
default:
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/** The label for the button that leaves the site for the platform. */
|
|
184
|
+
exports.STREAM_PLATFORM_LABELS = {
|
|
185
|
+
twitch: 'Watch on Twitch',
|
|
186
|
+
youtube: 'Watch on YouTube',
|
|
187
|
+
kick: 'Watch on Kick',
|
|
188
|
+
other: 'Watch Stream',
|
|
189
|
+
};
|
|
190
|
+
/** How the platform is named in host-facing copy. */
|
|
191
|
+
exports.STREAM_PLATFORM_NAMES = {
|
|
192
|
+
twitch: 'Twitch',
|
|
193
|
+
youtube: 'YouTube',
|
|
194
|
+
kick: 'Kick',
|
|
195
|
+
other: 'Link',
|
|
196
|
+
};
|
|
197
|
+
/**
|
|
198
|
+
* Where "Watch on X" should point. The stored link is what the host pasted, so
|
|
199
|
+
* it wins whenever it is set; the handle is only a fallback for rows written
|
|
200
|
+
* before the link was kept.
|
|
201
|
+
*/
|
|
202
|
+
function buildWatchUrl(streamLink, platform, handle) {
|
|
203
|
+
if (streamLink)
|
|
204
|
+
return streamLink;
|
|
205
|
+
if (!handle)
|
|
206
|
+
return null;
|
|
207
|
+
switch (platform) {
|
|
208
|
+
case 'twitch':
|
|
209
|
+
return `https://twitch.tv/${handle}`;
|
|
210
|
+
case 'youtube':
|
|
211
|
+
return `https://youtube.com/${handle}`;
|
|
212
|
+
case 'kick':
|
|
213
|
+
return `https://kick.com/${handle}`;
|
|
214
|
+
default:
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
package/package.json
CHANGED
package/src/generated/inputs.ts
CHANGED
|
@@ -62,6 +62,7 @@ export interface FaqInput {
|
|
|
62
62
|
export interface FeaturedGameInput {
|
|
63
63
|
featuredHeading?: string;
|
|
64
64
|
featuredSubheading?: string;
|
|
65
|
+
isActive?: boolean;
|
|
65
66
|
game?: RelationInput;
|
|
66
67
|
}
|
|
67
68
|
|
|
@@ -69,6 +70,7 @@ export interface FeaturedGameInput {
|
|
|
69
70
|
export interface FeaturedTournamentInput {
|
|
70
71
|
featuredHeading?: string;
|
|
71
72
|
featuredSubheading?: string;
|
|
73
|
+
isActive?: boolean;
|
|
72
74
|
tournament?: RelationInput;
|
|
73
75
|
}
|
|
74
76
|
|
|
@@ -184,6 +186,8 @@ export interface MatchInput {
|
|
|
184
186
|
endedAt?: string;
|
|
185
187
|
resultType?: MatchResultType;
|
|
186
188
|
resultNote?: string;
|
|
189
|
+
homeReportedAt?: string;
|
|
190
|
+
awayReportedAt?: string;
|
|
187
191
|
disputeReason?: MatchDisputeReason;
|
|
188
192
|
disputeNote?: string;
|
|
189
193
|
disputedBy?: RelationInput;
|
package/src/generated/models.ts
CHANGED
|
@@ -63,6 +63,7 @@ export interface Faq extends StrapiDocument {
|
|
|
63
63
|
export interface FeaturedGame extends StrapiDocument {
|
|
64
64
|
featuredHeading?: string | null;
|
|
65
65
|
featuredSubheading?: string | null;
|
|
66
|
+
isActive?: boolean | null;
|
|
66
67
|
game?: Game | null;
|
|
67
68
|
}
|
|
68
69
|
|
|
@@ -70,6 +71,7 @@ export interface FeaturedGame extends StrapiDocument {
|
|
|
70
71
|
export interface FeaturedTournament extends StrapiDocument {
|
|
71
72
|
featuredHeading?: string | null;
|
|
72
73
|
featuredSubheading?: string | null;
|
|
74
|
+
isActive?: boolean | null;
|
|
73
75
|
tournament?: Tournament | null;
|
|
74
76
|
}
|
|
75
77
|
|
|
@@ -185,6 +187,8 @@ export interface Match extends StrapiDocument {
|
|
|
185
187
|
endedAt?: string | null;
|
|
186
188
|
resultType?: MatchResultType | null;
|
|
187
189
|
resultNote?: string | null;
|
|
190
|
+
homeReportedAt?: string | null;
|
|
191
|
+
awayReportedAt?: string | null;
|
|
188
192
|
disputeReason?: MatchDisputeReason | null;
|
|
189
193
|
disputeNote?: string | null;
|
|
190
194
|
disputedBy?: User | null;
|
package/src/index.ts
CHANGED
|
@@ -14,6 +14,10 @@ export * from './bracket';
|
|
|
14
14
|
// applied identically by the backend resolver and the client's pickers
|
|
15
15
|
export * from './gameCatalogue';
|
|
16
16
|
|
|
17
|
+
// Stream links — one pasted URL parsed the same way by the backend that stores
|
|
18
|
+
// the derived columns and the client that embeds the player
|
|
19
|
+
export * from './stream';
|
|
20
|
+
|
|
17
21
|
// Data
|
|
18
22
|
export * from './data/countriesList';
|
|
19
|
-
export * from './data/gameGenres';
|
|
23
|
+
export * from './data/gameGenres';
|
package/src/manual/index.ts
CHANGED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The match lobby as the client renders it.
|
|
3
|
+
*
|
|
4
|
+
* None of these are Strapi content types. The lobby is a view over a match
|
|
5
|
+
* row plus what `GET /matches/:id/lobby` derives for the viewer, and these
|
|
6
|
+
* name the parts of that view both apps have to agree on.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { MatchDisputeReason, MatchRound } from '../generated/enums';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Where a lobby is in its life, from the viewer's side. Derived on the client
|
|
13
|
+
* from `lobbyStatus`, `startedAt`/`endedAt` and whether the viewer's own side
|
|
14
|
+
* has reported; `disputed` and `completed` come straight from `lobbyStatus`.
|
|
15
|
+
*/
|
|
16
|
+
export type LobbyPhase =
|
|
17
|
+
| 'waiting'
|
|
18
|
+
| 'live'
|
|
19
|
+
| 'reported'
|
|
20
|
+
| 'disputed'
|
|
21
|
+
| 'completed';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* What the viewer may do in the lobby. A captain (or a solo player) acts for
|
|
25
|
+
* a side; a roster member only talks; the host and staff watch and rule from
|
|
26
|
+
* the manage page.
|
|
27
|
+
*/
|
|
28
|
+
export type LobbyViewerRole = 'captain' | 'teamMember' | 'hostOrSpectator';
|
|
29
|
+
|
|
30
|
+
/** The match the winner goes on to — enough to say "Round 2 — Match #2". */
|
|
31
|
+
export interface LobbyNextMatch {
|
|
32
|
+
documentId: string;
|
|
33
|
+
round: MatchRound | null;
|
|
34
|
+
matchNumber: number | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The reasons a player may send; a score mismatch is found by the server. */
|
|
38
|
+
export type PlayerDisputeReason = Exclude<MatchDisputeReason, 'scoreMismatch'>;
|
|
39
|
+
|
|
40
|
+
/** Body of `POST /matches/:id/dispute`, inside `data`. */
|
|
41
|
+
export interface MatchDisputeInput {
|
|
42
|
+
reason: PlayerDisputeReason;
|
|
43
|
+
/** Optional, trimmed, at most `DISPUTE_NOTE_MAX_LENGTH` characters. */
|
|
44
|
+
note?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const DISPUTE_NOTE_MAX_LENGTH = 500;
|
|
48
|
+
|
|
49
|
+
/** Body of `POST /matches/:id/ready`, inside `data`. Omitted means ready. */
|
|
50
|
+
export interface MatchReadyInput {
|
|
51
|
+
ready?: boolean;
|
|
52
|
+
}
|
package/src/stream.ts
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One pasted URL → the platform, the handle, and the player the live page
|
|
3
|
+
* embeds.
|
|
4
|
+
*
|
|
5
|
+
* A match carries three stream columns for one concept: `streamLink` (what the
|
|
6
|
+
* host pasted), and `streamHandle` + `streamPlatform` (what the live tile needs
|
|
7
|
+
* for a Twitch preview and what the live page needs to label a "watch on"
|
|
8
|
+
* button). The host types the link; the other two are derived here and never
|
|
9
|
+
* accepted from a client, so there is exactly one source of truth.
|
|
10
|
+
*
|
|
11
|
+
* An unrecognised host is still a valid stream link — it is filed as `other`
|
|
12
|
+
* with no handle, and the live page falls back to a "watch on" button, which
|
|
13
|
+
* already prefers `streamLink` when it is set.
|
|
14
|
+
*
|
|
15
|
+
* Lives here rather than in xpt-strapi because the client needs the identical
|
|
16
|
+
* parse: the host must be told, as they type, which platform their link
|
|
17
|
+
* resolved to and whether it will embed at all. Two implementations of the same
|
|
18
|
+
* URL grammar is two chances to disagree about what the backend just stored.
|
|
19
|
+
*
|
|
20
|
+
* `buildEmbedUrl` deliberately takes the **link**, not the stored handle. The
|
|
21
|
+
* handle is lossy: `youtube.com/@team` and `youtube.com/watch?v=abc` both
|
|
22
|
+
* flatten to one opaque string, yet one is a channel and the other is a video,
|
|
23
|
+
* and they take different embed URLs. Re-parsing the link is what lets the
|
|
24
|
+
* YouTube embed be correct at all.
|
|
25
|
+
*
|
|
26
|
+
* Pure on purpose: no Strapi, no DOM, no I/O, so the rules are unit-tested
|
|
27
|
+
* directly (see xpt-strapi `tests/unit/utils/streamLink.test.ts`).
|
|
28
|
+
*/
|
|
29
|
+
import type { MatchStreamPlatform } from './generated/enums';
|
|
30
|
+
|
|
31
|
+
/** `Match.streamPlatform`. Aliased so call sites read as stream code. */
|
|
32
|
+
export type StreamPlatform = MatchStreamPlatform;
|
|
33
|
+
|
|
34
|
+
export interface StreamSource {
|
|
35
|
+
platform: StreamPlatform;
|
|
36
|
+
handle: string | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const STREAM_LINK_MAX_LENGTH = 255;
|
|
40
|
+
|
|
41
|
+
/** The URL parsed, or null when it is not an absolute http(s) URL. */
|
|
42
|
+
export function parseStreamUrl(raw: string): URL | null {
|
|
43
|
+
let url: URL;
|
|
44
|
+
try {
|
|
45
|
+
url = new URL(raw.trim());
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
|
50
|
+
return url;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function hostOf(url: URL): string {
|
|
54
|
+
return url.hostname.toLowerCase().replace(/^www\./, '');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Path segments with the empties from leading and trailing slashes dropped. */
|
|
58
|
+
function segmentsOf(url: URL): string[] {
|
|
59
|
+
return url.pathname
|
|
60
|
+
.split('/')
|
|
61
|
+
.filter(Boolean)
|
|
62
|
+
.map((segment) => decodeURIComponent(segment));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Twitch paths that are not a channel. A VOD or a directory page has no live
|
|
67
|
+
* preview, so it gets no handle rather than a wrong one.
|
|
68
|
+
*/
|
|
69
|
+
const TWITCH_NON_CHANNEL = new Set(['videos', 'directory', 'clip', 'p']);
|
|
70
|
+
|
|
71
|
+
export function deriveStreamSource(raw: string): StreamSource | null {
|
|
72
|
+
const url = parseStreamUrl(raw);
|
|
73
|
+
if (!url) return null;
|
|
74
|
+
|
|
75
|
+
const host = hostOf(url);
|
|
76
|
+
const segments = segmentsOf(url);
|
|
77
|
+
const first = segments[0] ?? null;
|
|
78
|
+
|
|
79
|
+
if (host === 'twitch.tv' || host.endsWith('.twitch.tv')) {
|
|
80
|
+
const handle = first && !TWITCH_NON_CHANNEL.has(first) ? first : null;
|
|
81
|
+
return { platform: 'twitch', handle };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (host === 'kick.com') {
|
|
85
|
+
return { platform: 'kick', handle: first };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (host === 'youtu.be') {
|
|
89
|
+
return { platform: 'youtube', handle: first };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (host === 'youtube.com' || host.endsWith('.youtube.com')) {
|
|
93
|
+
// /@handle, /c/name, /channel/id and /user/name all carry the identifier
|
|
94
|
+
// the live page embeds; /watch and /live carry it one segment later or in
|
|
95
|
+
// the query instead.
|
|
96
|
+
if (first?.startsWith('@')) return { platform: 'youtube', handle: first };
|
|
97
|
+
if (first === 'c' || first === 'channel' || first === 'user') {
|
|
98
|
+
return { platform: 'youtube', handle: segments[1] ?? null };
|
|
99
|
+
}
|
|
100
|
+
if (first === 'watch') {
|
|
101
|
+
return { platform: 'youtube', handle: url.searchParams.get('v') };
|
|
102
|
+
}
|
|
103
|
+
if (first === 'live') {
|
|
104
|
+
return { platform: 'youtube', handle: segments[1] ?? null };
|
|
105
|
+
}
|
|
106
|
+
return { platform: 'youtube', handle: first };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { platform: 'other', handle: null };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface StreamFields {
|
|
113
|
+
streamLink: string | null;
|
|
114
|
+
streamHandle: string | null;
|
|
115
|
+
streamPlatform: StreamPlatform | null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The three stream columns for a pasted link, ready to write. Empty clears all
|
|
120
|
+
* three together — a stale handle with no link would keep a dead embed alive.
|
|
121
|
+
*/
|
|
122
|
+
export function streamFieldsFor(
|
|
123
|
+
raw: unknown
|
|
124
|
+
): { ok: true; value: StreamFields } | { ok: false; message: string } {
|
|
125
|
+
if (raw === null || raw === undefined || raw === '') {
|
|
126
|
+
return {
|
|
127
|
+
ok: true,
|
|
128
|
+
value: { streamLink: null, streamHandle: null, streamPlatform: null },
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
if (typeof raw !== 'string') {
|
|
132
|
+
return { ok: false, message: 'streamLink must be a string' };
|
|
133
|
+
}
|
|
134
|
+
const trimmed = raw.trim();
|
|
135
|
+
if (!trimmed) {
|
|
136
|
+
return {
|
|
137
|
+
ok: true,
|
|
138
|
+
value: { streamLink: null, streamHandle: null, streamPlatform: null },
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
if (trimmed.length > STREAM_LINK_MAX_LENGTH) {
|
|
142
|
+
return {
|
|
143
|
+
ok: false,
|
|
144
|
+
message: `streamLink must be ${STREAM_LINK_MAX_LENGTH} characters or fewer`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
const source = deriveStreamSource(trimmed);
|
|
148
|
+
if (!source) {
|
|
149
|
+
return { ok: false, message: 'streamLink must be a valid http(s) URL' };
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
ok: true,
|
|
153
|
+
value: {
|
|
154
|
+
streamLink: trimmed,
|
|
155
|
+
streamHandle: source.handle,
|
|
156
|
+
streamPlatform: source.platform,
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* What kind of YouTube identifier a URL carries. A video can be embedded by id;
|
|
163
|
+
* a channel can be embedded through the `live_stream` player; a vanity handle
|
|
164
|
+
* (`/@name`, `/c/name`, `/user/name`) can be embedded by neither without a Data
|
|
165
|
+
* API lookup we do not make, so it embeds as nothing and the watch button
|
|
166
|
+
* carries the viewer instead.
|
|
167
|
+
*/
|
|
168
|
+
type YouTubeRef =
|
|
169
|
+
| { kind: 'video'; id: string }
|
|
170
|
+
| { kind: 'channel'; id: string }
|
|
171
|
+
| { kind: 'vanity' }
|
|
172
|
+
| null;
|
|
173
|
+
|
|
174
|
+
function youTubeRef(url: URL): YouTubeRef {
|
|
175
|
+
const host = hostOf(url);
|
|
176
|
+
const segments = segmentsOf(url);
|
|
177
|
+
const first = segments[0] ?? null;
|
|
178
|
+
|
|
179
|
+
if (host === 'youtu.be') {
|
|
180
|
+
return first ? { kind: 'video', id: first } : null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (first === 'watch') {
|
|
184
|
+
const id = url.searchParams.get('v');
|
|
185
|
+
return id ? { kind: 'video', id } : null;
|
|
186
|
+
}
|
|
187
|
+
if (first === 'live' || first === 'embed' || first === 'shorts') {
|
|
188
|
+
const id = segments[1] ?? null;
|
|
189
|
+
return id ? { kind: 'video', id } : null;
|
|
190
|
+
}
|
|
191
|
+
if (first === 'channel') {
|
|
192
|
+
const id = segments[1] ?? null;
|
|
193
|
+
return id ? { kind: 'channel', id } : null;
|
|
194
|
+
}
|
|
195
|
+
if (first?.startsWith('@') || first === 'c' || first === 'user') {
|
|
196
|
+
return { kind: 'vanity' };
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The embeddable player URL for a stored stream link, or null when the link
|
|
203
|
+
* cannot be embedded and the viewer should be sent to the platform instead.
|
|
204
|
+
*
|
|
205
|
+
* `parentDomain` is Twitch's requirement: it must be the hostname of the page
|
|
206
|
+
* carrying the iframe, or Twitch refuses to play. Read it from
|
|
207
|
+
* `window.location.hostname` on the client rather than configuring it — the
|
|
208
|
+
* browser's own answer is correct by construction.
|
|
209
|
+
*/
|
|
210
|
+
export function buildEmbedUrl(
|
|
211
|
+
streamLink: string | null | undefined,
|
|
212
|
+
opts: { parentDomain: string }
|
|
213
|
+
): string | null {
|
|
214
|
+
if (!streamLink) return null;
|
|
215
|
+
const url = parseStreamUrl(streamLink);
|
|
216
|
+
if (!url) return null;
|
|
217
|
+
|
|
218
|
+
const source = deriveStreamSource(streamLink);
|
|
219
|
+
if (!source) return null;
|
|
220
|
+
|
|
221
|
+
switch (source.platform) {
|
|
222
|
+
case 'twitch':
|
|
223
|
+
if (!source.handle) return null;
|
|
224
|
+
return `https://player.twitch.tv/?channel=${encodeURIComponent(
|
|
225
|
+
source.handle
|
|
226
|
+
)}&parent=${encodeURIComponent(opts.parentDomain)}&autoplay=false`;
|
|
227
|
+
|
|
228
|
+
case 'youtube': {
|
|
229
|
+
const ref = youTubeRef(url);
|
|
230
|
+
if (!ref) return null;
|
|
231
|
+
if (ref.kind === 'video') {
|
|
232
|
+
return `https://www.youtube.com/embed/${encodeURIComponent(ref.id)}`;
|
|
233
|
+
}
|
|
234
|
+
if (ref.kind === 'channel') {
|
|
235
|
+
return `https://www.youtube.com/embed/live_stream?channel=${encodeURIComponent(
|
|
236
|
+
ref.id
|
|
237
|
+
)}`;
|
|
238
|
+
}
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
case 'kick':
|
|
243
|
+
if (!source.handle) return null;
|
|
244
|
+
return `https://player.kick.com/${encodeURIComponent(source.handle)}`;
|
|
245
|
+
|
|
246
|
+
default:
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The label for the button that leaves the site for the platform. */
|
|
252
|
+
export const STREAM_PLATFORM_LABELS: Record<StreamPlatform, string> = {
|
|
253
|
+
twitch: 'Watch on Twitch',
|
|
254
|
+
youtube: 'Watch on YouTube',
|
|
255
|
+
kick: 'Watch on Kick',
|
|
256
|
+
other: 'Watch Stream',
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/** How the platform is named in host-facing copy. */
|
|
260
|
+
export const STREAM_PLATFORM_NAMES: Record<StreamPlatform, string> = {
|
|
261
|
+
twitch: 'Twitch',
|
|
262
|
+
youtube: 'YouTube',
|
|
263
|
+
kick: 'Kick',
|
|
264
|
+
other: 'Link',
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Where "Watch on X" should point. The stored link is what the host pasted, so
|
|
269
|
+
* it wins whenever it is set; the handle is only a fallback for rows written
|
|
270
|
+
* before the link was kept.
|
|
271
|
+
*/
|
|
272
|
+
export function buildWatchUrl(
|
|
273
|
+
streamLink: string | null | undefined,
|
|
274
|
+
platform?: StreamPlatform | null,
|
|
275
|
+
handle?: string | null
|
|
276
|
+
): string | null {
|
|
277
|
+
if (streamLink) return streamLink;
|
|
278
|
+
if (!handle) return null;
|
|
279
|
+
switch (platform) {
|
|
280
|
+
case 'twitch':
|
|
281
|
+
return `https://twitch.tv/${handle}`;
|
|
282
|
+
case 'youtube':
|
|
283
|
+
return `https://youtube.com/${handle}`;
|
|
284
|
+
case 'kick':
|
|
285
|
+
return `https://kick.com/${handle}`;
|
|
286
|
+
default:
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
}
|