@slidev-polls/shared 0.0.1
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/LICENSE +674 -0
- package/README.md +29 -0
- package/package.json +45 -0
- package/src/api-client.ts +105 -0
- package/src/index.ts +4 -0
- package/src/shims-vue.d.ts +5 -0
- package/src/sse-client.ts +118 -0
- package/src/types.ts +195 -0
- package/src/ui/AllowedOriginsField.vue +116 -0
- package/src/ui/Button.vue +77 -0
- package/src/ui/Chip.vue +57 -0
- package/src/ui/IconAlert.vue +15 -0
- package/src/ui/IconCheck.vue +15 -0
- package/src/ui/IconChevronDown.vue +15 -0
- package/src/ui/IconClose.vue +16 -0
- package/src/ui/IconMoon.vue +15 -0
- package/src/ui/IconSun.vue +18 -0
- package/src/ui/Input.vue +45 -0
- package/src/ui/LiveDot.vue +31 -0
- package/src/ui/Pill.vue +47 -0
- package/src/ui/ResultsPanel.vue +223 -0
- package/src/ui/Textarea.vue +55 -0
- package/src/ui/ThemeToggle.vue +41 -0
- package/src/ui/index.ts +17 -0
- package/src/ui/origin-validator.ts +28 -0
- package/src/ui/tokens.css +61 -0
- package/src/ui/useTheme.ts +78 -0
package/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# @slidev-polls/shared
|
|
2
|
+
|
|
3
|
+
Shared TypeScript types, REST/SSE clients, and Vue UI primitives consumed by
|
|
4
|
+
the [slidev-polls](https://github.com/asm0dey/slidev-polls) suite (Slidev
|
|
5
|
+
addon, voter SPA, backoffice SPA).
|
|
6
|
+
|
|
7
|
+
Ships untranspiled `.ts` and `.vue` source. Intended for projects that build
|
|
8
|
+
with Vite or another bundler that understands TypeScript and Vue SFCs.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install @slidev-polls/shared
|
|
14
|
+
# or
|
|
15
|
+
bun add @slidev-polls/shared
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`vue@^3.5` is a peer dependency.
|
|
19
|
+
|
|
20
|
+
## Exports
|
|
21
|
+
|
|
22
|
+
- `@slidev-polls/shared` — types, `apiClient`, `sseClient`
|
|
23
|
+
- `@slidev-polls/shared/ui` — Vue UI components (`ResultsPanel`,
|
|
24
|
+
`AllowedOriginsField`, theme helpers)
|
|
25
|
+
- `@slidev-polls/shared/tokens.css` — CSS design tokens
|
|
26
|
+
|
|
27
|
+
## License
|
|
28
|
+
|
|
29
|
+
GPL-3.0-or-later. See [LICENSE](./LICENSE).
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@slidev-polls/shared",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Shared types, API client, SSE client, and Vue UI primitives for the slidev-polls suite.",
|
|
6
|
+
"license": "GPL-3.0-or-later",
|
|
7
|
+
"author": "Pasha Finkelshteyn <pavel.finkelshtein@gmail.com>",
|
|
8
|
+
"homepage": "https://github.com/asm0dey/slidev-polls#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/asm0dey/slidev-polls.git",
|
|
12
|
+
"directory": "frontends/shared"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/asm0dey/slidev-polls/issues"
|
|
16
|
+
},
|
|
17
|
+
"main": "./src/index.ts",
|
|
18
|
+
"types": "./src/index.ts",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": "./src/index.ts",
|
|
21
|
+
"./ui": "./src/ui/index.ts",
|
|
22
|
+
"./tokens.css": "./src/ui/tokens.css"
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsc -p tsconfig.json --noEmit",
|
|
29
|
+
"test": "vitest run",
|
|
30
|
+
"test:watch": "vitest",
|
|
31
|
+
"prepublishOnly": "bun run test && bun run build"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"vue": "^3.5.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@vitejs/plugin-vue": "^6.0.6",
|
|
38
|
+
"@vue/test-utils": "^2.4.10",
|
|
39
|
+
"jsdom": "^29.1.1",
|
|
40
|
+
"typescript": "^6.0.3",
|
|
41
|
+
"vite": "^8.0.11",
|
|
42
|
+
"vitest": "^4.1.5",
|
|
43
|
+
"vue": "^3.5.34"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { Problem, ProblemCode, PublicPollView, VoteAccepted, VoteRequest } from "./types";
|
|
2
|
+
|
|
3
|
+
export interface ApiClientOptions {
|
|
4
|
+
baseUrl?: string;
|
|
5
|
+
/** Optional injection point for tests. */
|
|
6
|
+
fetchImpl?: typeof fetch;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Thrown by {@link ApiClient} on any non-2xx response. Carries the parsed
|
|
11
|
+
* {@link Problem} envelope so callers can branch on a typed {@code code}
|
|
12
|
+
* rather than scraping HTTP status codes.
|
|
13
|
+
*/
|
|
14
|
+
export class ApiError extends Error {
|
|
15
|
+
readonly status: number;
|
|
16
|
+
readonly problem: Problem | null;
|
|
17
|
+
|
|
18
|
+
constructor(status: number, problem: Problem | null, message: string) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "ApiError";
|
|
21
|
+
this.status = status;
|
|
22
|
+
this.problem = problem;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get code(): ProblemCode | undefined {
|
|
26
|
+
return this.problem?.code;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get correlationId(): string | undefined {
|
|
30
|
+
return this.problem?.correlationId ?? undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class ApiClient {
|
|
35
|
+
private readonly baseUrl: string;
|
|
36
|
+
private readonly fetchImpl: typeof fetch;
|
|
37
|
+
|
|
38
|
+
constructor(opts: ApiClientOptions = {}) {
|
|
39
|
+
this.baseUrl = (opts.baseUrl ?? "").replace(/\/$/, "");
|
|
40
|
+
// Browsers require window.fetch to be called with `this === window`. When we
|
|
41
|
+
// store the global `fetch` on an instance and later invoke it as
|
|
42
|
+
// `this.fetchImpl(…)`, the receiver becomes the ApiClient and Chrome rejects
|
|
43
|
+
// the call with "Failed to execute 'fetch' on 'Window': Illegal invocation"
|
|
44
|
+
// (surfaced by BUG-005). Binding to globalThis keeps the call site simple
|
|
45
|
+
// while preserving the test injection hook.
|
|
46
|
+
this.fetchImpl = opts.fetchImpl ?? fetch.bind(globalThis);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async publicPoll(slug: string): Promise<PublicPollView> {
|
|
50
|
+
const res = await this.fetchImpl(
|
|
51
|
+
`${this.baseUrl}/api/polls/by-slug/${encodeURIComponent(slug)}`,
|
|
52
|
+
{ credentials: "same-origin" }
|
|
53
|
+
);
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
throw await toApiError(res);
|
|
56
|
+
}
|
|
57
|
+
return (await res.json()) as PublicPollView;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async submitVote(slug: string, body: VoteRequest): Promise<VoteAccepted | null> {
|
|
61
|
+
const res = await this.fetchImpl(
|
|
62
|
+
`${this.baseUrl}/api/polls/${encodeURIComponent(slug)}/votes`,
|
|
63
|
+
{
|
|
64
|
+
method: "POST",
|
|
65
|
+
credentials: "same-origin",
|
|
66
|
+
headers: { "Content-Type": "application/json" },
|
|
67
|
+
body: JSON.stringify(body)
|
|
68
|
+
}
|
|
69
|
+
);
|
|
70
|
+
if (!res.ok) {
|
|
71
|
+
throw await toApiError(res);
|
|
72
|
+
}
|
|
73
|
+
// The 201 body is VoteAccepted; 204 is tolerated for back-compat.
|
|
74
|
+
if (res.status === 204) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
return (await res.json()) as VoteAccepted;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Parses a Problem envelope from a non-2xx response. The server always returns
|
|
83
|
+
* application/json for API failures, but fall back to a plain message if the
|
|
84
|
+
* body cannot be decoded (e.g., a gateway injected an HTML error page) so the
|
|
85
|
+
* caller still gets a meaningful error.
|
|
86
|
+
*/
|
|
87
|
+
async function toApiError(res: Response): Promise<ApiError> {
|
|
88
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
89
|
+
if (contentType.includes("application/json")) {
|
|
90
|
+
try {
|
|
91
|
+
const body = (await res.json()) as Partial<Problem>;
|
|
92
|
+
if (body && typeof body.code === "string" && typeof body.message === "string") {
|
|
93
|
+
const problem: Problem = {
|
|
94
|
+
code: body.code as ProblemCode,
|
|
95
|
+
message: body.message,
|
|
96
|
+
correlationId: body.correlationId
|
|
97
|
+
};
|
|
98
|
+
return new ApiError(res.status, problem, problem.message);
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
// fall through
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return new ApiError(res.status, null, `HTTP ${res.status}`);
|
|
105
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { QuestionClosedEvent, SnapshotEvent, TallyDeltaEvent } from "./types";
|
|
2
|
+
|
|
3
|
+
export interface StreamHandlers {
|
|
4
|
+
onSnapshot: (ev: SnapshotEvent) => void;
|
|
5
|
+
onTally: (ev: TallyDeltaEvent) => void;
|
|
6
|
+
onQuestionClosed?: (ev: QuestionClosedEvent) => void;
|
|
7
|
+
onConnectionStateChange?: (state: "open" | "paused") => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Factory that produces an {@link EventSource} for a given URL. Overridable in tests. */
|
|
11
|
+
export type EventSourceFactory = (url: string) => EventSource;
|
|
12
|
+
|
|
13
|
+
/** Schedules {@code fn} to run after {@code ms} milliseconds; returns a cancel handle. */
|
|
14
|
+
export type DelayedScheduler = (fn: () => void, ms: number) => () => void;
|
|
15
|
+
|
|
16
|
+
export interface OpenPollStreamOptions {
|
|
17
|
+
eventSourceFactory?: EventSourceFactory;
|
|
18
|
+
scheduler?: DelayedScheduler;
|
|
19
|
+
/** Delay for the first reconnect attempt after an error. Defaults to 500ms. */
|
|
20
|
+
minDelayMs?: number;
|
|
21
|
+
/** Ceiling for the exponentially-growing reconnect delay. Defaults to 10s. */
|
|
22
|
+
maxDelayMs?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DEFAULT_MIN_DELAY_MS = 500;
|
|
26
|
+
const DEFAULT_MAX_DELAY_MS = 10_000;
|
|
27
|
+
|
|
28
|
+
const defaultEventSourceFactory: EventSourceFactory = (url) =>
|
|
29
|
+
new EventSource(url, { withCredentials: true });
|
|
30
|
+
|
|
31
|
+
const defaultScheduler: DelayedScheduler = (fn, ms) => {
|
|
32
|
+
const id = setTimeout(fn, ms);
|
|
33
|
+
return () => clearTimeout(id);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Subscribe to the SSE stream for a single poll. The returned callback
|
|
38
|
+
* unsubscribes and also cancels any pending reconnect, so callers do not have
|
|
39
|
+
* to track timers themselves.
|
|
40
|
+
*
|
|
41
|
+
* <p>On connection failure the client flips the state to {@code "paused"},
|
|
42
|
+
* closes the broken {@link EventSource}, and schedules a reconnect with
|
|
43
|
+
* exponential backoff starting at {@code minDelayMs} and capped at
|
|
44
|
+
* {@code maxDelayMs}. A successful {@code open} event resets the backoff so a
|
|
45
|
+
* later failure starts over at {@code minDelayMs}.
|
|
46
|
+
*
|
|
47
|
+
* <p>This matches Principle IV (Live-Reliability Over Feature Depth): the UI
|
|
48
|
+
* shows a visible "paused" badge during reconnect rather than throwing, and
|
|
49
|
+
* the backoff cap keeps a long outage from blowing up to minute-long retries.
|
|
50
|
+
*/
|
|
51
|
+
export function openPollStream(
|
|
52
|
+
baseUrl: string,
|
|
53
|
+
slug: string,
|
|
54
|
+
handlers: StreamHandlers,
|
|
55
|
+
opts: OpenPollStreamOptions = {}
|
|
56
|
+
): () => void {
|
|
57
|
+
const url = `${baseUrl.replace(/\/$/, "")}/api/polls/${encodeURIComponent(slug)}/stream`;
|
|
58
|
+
const esFactory = opts.eventSourceFactory ?? defaultEventSourceFactory;
|
|
59
|
+
const schedule = opts.scheduler ?? defaultScheduler;
|
|
60
|
+
const minDelay = opts.minDelayMs ?? DEFAULT_MIN_DELAY_MS;
|
|
61
|
+
const maxDelay = opts.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
|
|
62
|
+
|
|
63
|
+
let currentEs: EventSource | null = null;
|
|
64
|
+
let cancelRetry: (() => void) | null = null;
|
|
65
|
+
let retryDelay = minDelay;
|
|
66
|
+
let stopped = false;
|
|
67
|
+
|
|
68
|
+
function connect() {
|
|
69
|
+
if (stopped) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const es = esFactory(url);
|
|
73
|
+
currentEs = es;
|
|
74
|
+
|
|
75
|
+
es.addEventListener("open", () => {
|
|
76
|
+
retryDelay = minDelay;
|
|
77
|
+
handlers.onConnectionStateChange?.("open");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
es.addEventListener("error", () => {
|
|
81
|
+
handlers.onConnectionStateChange?.("paused");
|
|
82
|
+
es.close();
|
|
83
|
+
if (currentEs === es) {
|
|
84
|
+
currentEs = null;
|
|
85
|
+
}
|
|
86
|
+
const scheduledDelay = retryDelay;
|
|
87
|
+
retryDelay = Math.min(retryDelay * 2, maxDelay);
|
|
88
|
+
cancelRetry = schedule(() => {
|
|
89
|
+
cancelRetry = null;
|
|
90
|
+
connect();
|
|
91
|
+
}, scheduledDelay);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
es.addEventListener("snapshot", (e) => {
|
|
95
|
+
handlers.onSnapshot(JSON.parse((e as MessageEvent).data) as SnapshotEvent);
|
|
96
|
+
});
|
|
97
|
+
es.addEventListener("tally", (e) => {
|
|
98
|
+
handlers.onTally(JSON.parse((e as MessageEvent).data) as TallyDeltaEvent);
|
|
99
|
+
});
|
|
100
|
+
es.addEventListener("question-closed", (e) => {
|
|
101
|
+
handlers.onQuestionClosed?.(JSON.parse((e as MessageEvent).data) as QuestionClosedEvent);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
connect();
|
|
106
|
+
|
|
107
|
+
return () => {
|
|
108
|
+
stopped = true;
|
|
109
|
+
if (cancelRetry) {
|
|
110
|
+
cancelRetry();
|
|
111
|
+
cancelRetry = null;
|
|
112
|
+
}
|
|
113
|
+
if (currentEs) {
|
|
114
|
+
currentEs.close();
|
|
115
|
+
currentEs = null;
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// Mirrors the schemas in specs/001-polling-foundation/contracts/openapi.yaml.
|
|
2
|
+
// Keep this file and the OpenAPI document in lock-step so the voter, backoffice,
|
|
3
|
+
// and slidev-addon packages all speak the same DTOs.
|
|
4
|
+
|
|
5
|
+
export type PollStatus = "DRAFT" | "OPEN" | "CLOSED";
|
|
6
|
+
export type QuestionStatus = "DRAFT" | "ACTIVE" | "CLOSED";
|
|
7
|
+
|
|
8
|
+
export interface PollStyle {
|
|
9
|
+
primaryColor?: string;
|
|
10
|
+
accentColor?: string;
|
|
11
|
+
backgroundColor?: string;
|
|
12
|
+
fontFamily?: string;
|
|
13
|
+
layout?: "BARS" | "COLUMNS" | "DONUT";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface Option {
|
|
17
|
+
id: string;
|
|
18
|
+
label: string;
|
|
19
|
+
position: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface Question {
|
|
23
|
+
id: string;
|
|
24
|
+
prompt: string;
|
|
25
|
+
ordinal: number;
|
|
26
|
+
status: QuestionStatus;
|
|
27
|
+
options: Option[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Back-compat alias for callers that imported `PollOption`; prefer `Option`. */
|
|
31
|
+
export type PollOption = Option;
|
|
32
|
+
|
|
33
|
+
/** Backoffice-side poll summary (GET /api/admin/polls). */
|
|
34
|
+
export interface Poll {
|
|
35
|
+
id: string;
|
|
36
|
+
title: string;
|
|
37
|
+
slug: string;
|
|
38
|
+
status: PollStatus;
|
|
39
|
+
publicUrl: string;
|
|
40
|
+
activeQuestionId: string | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Full poll with questions, options, and style (GET /api/admin/polls/{id}). */
|
|
44
|
+
export interface PollDetail extends Poll {
|
|
45
|
+
style: PollStyle;
|
|
46
|
+
questions: Question[];
|
|
47
|
+
allowedOrigins?: string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Public-facing poll view (GET /api/polls/by-slug/{slug}). */
|
|
51
|
+
export interface PublicPollView {
|
|
52
|
+
pollId: string;
|
|
53
|
+
slug: string;
|
|
54
|
+
title: string;
|
|
55
|
+
state: "WAITING" | "ACTIVE";
|
|
56
|
+
style: PollStyle;
|
|
57
|
+
activeQuestion?: Question;
|
|
58
|
+
alreadyVoted?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Login payload (POST /api/admin/login). */
|
|
62
|
+
export interface LoginRequest {
|
|
63
|
+
username: string;
|
|
64
|
+
password: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Option payload inside CreateQuestionRequest. */
|
|
68
|
+
export interface CreateOptionRequest {
|
|
69
|
+
label: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Question payload for create/update (POST /api/admin/polls). */
|
|
73
|
+
export interface CreateQuestionRequest {
|
|
74
|
+
prompt: string;
|
|
75
|
+
options: CreateOptionRequest[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Create-poll payload (POST /api/admin/polls). */
|
|
79
|
+
export interface CreatePollRequest {
|
|
80
|
+
title: string;
|
|
81
|
+
slug?: string;
|
|
82
|
+
style?: PollStyle;
|
|
83
|
+
questions: CreateQuestionRequest[];
|
|
84
|
+
allowedOrigins?: string[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Patch-style update payload (PATCH /api/admin/polls/{id}). */
|
|
88
|
+
export interface UpdatePollRequest {
|
|
89
|
+
title?: string;
|
|
90
|
+
slug?: string;
|
|
91
|
+
questions?: CreateQuestionRequest[];
|
|
92
|
+
allowedOrigins?: string[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Activate-question payload (POST /api/admin/polls/{id}/open). */
|
|
96
|
+
export interface ActivateQuestionRequest {
|
|
97
|
+
questionId: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Vote submission payload (POST /api/polls/{slug}/votes). */
|
|
101
|
+
export interface VoteRequest {
|
|
102
|
+
optionId: string;
|
|
103
|
+
voterToken: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Vote accepted response. */
|
|
107
|
+
export interface VoteAccepted {
|
|
108
|
+
voteId: string;
|
|
109
|
+
/** ISO-8601 timestamp at which the vote was persisted. */
|
|
110
|
+
recordedAt: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** OpenAPI Problem error envelope. */
|
|
114
|
+
export type ProblemCode =
|
|
115
|
+
| "AUTH_REQUIRED"
|
|
116
|
+
| "FORBIDDEN"
|
|
117
|
+
| "NOT_FOUND"
|
|
118
|
+
| "VALIDATION_FAILED"
|
|
119
|
+
| "ALREADY_VOTED"
|
|
120
|
+
| "QUESTION_NOT_ACTIVE"
|
|
121
|
+
| "ACTIVATION_REJECTED"
|
|
122
|
+
| "SLUG_TAKEN"
|
|
123
|
+
| "SLUG_INVALID"
|
|
124
|
+
| "SLUG_RESERVED"
|
|
125
|
+
| "DECK_TOKEN_INVALID"
|
|
126
|
+
| "DECK_TOKEN_POLL_MISMATCH"
|
|
127
|
+
| "ORIGIN_INVALID"
|
|
128
|
+
| "SETUP_LOCKED"
|
|
129
|
+
| "USERNAME_TAKEN"
|
|
130
|
+
| "TRANSPORT_FAILURE";
|
|
131
|
+
|
|
132
|
+
export interface Problem {
|
|
133
|
+
code: ProblemCode;
|
|
134
|
+
message: string;
|
|
135
|
+
correlationId?: string;
|
|
136
|
+
/** Per-field validation messages, populated only on VALIDATION_FAILED. */
|
|
137
|
+
errors?: Record<string, string[]>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Deck token as listed in the backoffice (GET /api/admin/polls/{id}/deck-tokens). */
|
|
141
|
+
export interface DeckToken {
|
|
142
|
+
id: string;
|
|
143
|
+
pollId: string;
|
|
144
|
+
label: string | null;
|
|
145
|
+
createdAt: string;
|
|
146
|
+
revokedAt: string | null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Freshly minted deck token — plaintext bearer returned once (POST .../deck-tokens). */
|
|
150
|
+
export interface DeckTokenMinted extends DeckToken {
|
|
151
|
+
plaintext: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface MintDeckTokenRequest {
|
|
155
|
+
label?: string | null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Slimmed snapshot of an active question — what the SSE `snapshot` event carries.
|
|
159
|
+
* Lacks `status` because the SSE stream is only emitted for the currently-active question. */
|
|
160
|
+
export interface SnapshotActiveQuestion {
|
|
161
|
+
id: string;
|
|
162
|
+
prompt: string;
|
|
163
|
+
ordinal: number;
|
|
164
|
+
options: Option[];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** SSE "snapshot" event — full state for a poll/question, re-emitted on (re)connect. */
|
|
168
|
+
export interface SnapshotEvent {
|
|
169
|
+
pollId: string;
|
|
170
|
+
slug: string;
|
|
171
|
+
activeQuestion: SnapshotActiveQuestion | null;
|
|
172
|
+
tally: TallyEntry[];
|
|
173
|
+
emittedAt: string;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface TallyEntry {
|
|
177
|
+
optionId: string;
|
|
178
|
+
count: number;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** SSE "tally" event — delta for a single option, fan-out on each accepted vote. */
|
|
182
|
+
export interface TallyDeltaEvent {
|
|
183
|
+
pollId: string;
|
|
184
|
+
questionId: string;
|
|
185
|
+
optionId: string;
|
|
186
|
+
count: number;
|
|
187
|
+
emittedAt: string;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** SSE "question-closed" event. */
|
|
191
|
+
export interface QuestionClosedEvent {
|
|
192
|
+
pollId: string;
|
|
193
|
+
questionId: string;
|
|
194
|
+
emittedAt: string;
|
|
195
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref } from "vue";
|
|
3
|
+
import Chip from "./Chip.vue";
|
|
4
|
+
import IconAlert from "./IconAlert.vue";
|
|
5
|
+
import { validateOrigin } from "./origin-validator";
|
|
6
|
+
|
|
7
|
+
const props = defineProps<{ modelValue: string[] }>();
|
|
8
|
+
const emit = defineEmits<{ "update:modelValue": [v: string[]] }>();
|
|
9
|
+
|
|
10
|
+
const draft = ref("");
|
|
11
|
+
const error = ref<string | null>(null);
|
|
12
|
+
|
|
13
|
+
const items = computed(() =>
|
|
14
|
+
props.modelValue.map((o) => {
|
|
15
|
+
const r = validateOrigin(o);
|
|
16
|
+
return { raw: o, invalid: !r.ok };
|
|
17
|
+
})
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
function commit() {
|
|
21
|
+
const v = draft.value;
|
|
22
|
+
if (!v.trim()) {
|
|
23
|
+
error.value = null;
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const r = validateOrigin(v);
|
|
27
|
+
if (!r.ok) {
|
|
28
|
+
error.value = r.message;
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (props.modelValue.includes(r.normalized)) {
|
|
32
|
+
error.value = `Already added`;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
emit("update:modelValue", [...props.modelValue, r.normalized]);
|
|
36
|
+
draft.value = "";
|
|
37
|
+
error.value = null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function remove(idx: number) {
|
|
41
|
+
const next = props.modelValue.slice();
|
|
42
|
+
next.splice(idx, 1);
|
|
43
|
+
emit("update:modelValue", next);
|
|
44
|
+
}
|
|
45
|
+
</script>
|
|
46
|
+
|
|
47
|
+
<template>
|
|
48
|
+
<div class="sp-aof">
|
|
49
|
+
<div class="sp-aof-box">
|
|
50
|
+
<Chip
|
|
51
|
+
v-for="(item, i) in items"
|
|
52
|
+
:key="i"
|
|
53
|
+
:label="item.raw"
|
|
54
|
+
:invalid="item.invalid"
|
|
55
|
+
:mono="true"
|
|
56
|
+
data-testid="origin-chip"
|
|
57
|
+
@remove="remove(i)"
|
|
58
|
+
>
|
|
59
|
+
<template v-if="item.invalid" #leading>
|
|
60
|
+
<IconAlert />
|
|
61
|
+
</template>
|
|
62
|
+
</Chip>
|
|
63
|
+
<input
|
|
64
|
+
v-model="draft"
|
|
65
|
+
class="sp-aof-input"
|
|
66
|
+
placeholder="https://… (Enter or Tab to add)"
|
|
67
|
+
@keydown.enter.prevent="commit"
|
|
68
|
+
@blur="commit"
|
|
69
|
+
/>
|
|
70
|
+
</div>
|
|
71
|
+
<div v-if="error" class="sp-aof-error" data-testid="aof-error" role="alert">
|
|
72
|
+
<IconAlert />
|
|
73
|
+
<span>{{ error }}</span>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
</template>
|
|
77
|
+
|
|
78
|
+
<style scoped>
|
|
79
|
+
.sp-aof {
|
|
80
|
+
display: flex;
|
|
81
|
+
flex-direction: column;
|
|
82
|
+
gap: 8px;
|
|
83
|
+
}
|
|
84
|
+
.sp-aof-box {
|
|
85
|
+
border: 1px solid var(--sp-border);
|
|
86
|
+
border-radius: var(--sp-radius);
|
|
87
|
+
padding: 8px;
|
|
88
|
+
display: flex;
|
|
89
|
+
flex-wrap: wrap;
|
|
90
|
+
gap: 6px;
|
|
91
|
+
min-height: 42px;
|
|
92
|
+
background: var(--sp-bg);
|
|
93
|
+
}
|
|
94
|
+
.sp-aof-input {
|
|
95
|
+
flex: 1;
|
|
96
|
+
min-width: 160px;
|
|
97
|
+
border: 0;
|
|
98
|
+
outline: 0;
|
|
99
|
+
background: transparent;
|
|
100
|
+
font-size: 13px;
|
|
101
|
+
font-family: var(--sp-font-mono);
|
|
102
|
+
padding: 4px 6px;
|
|
103
|
+
color: var(--sp-fg);
|
|
104
|
+
}
|
|
105
|
+
.sp-aof-error {
|
|
106
|
+
display: flex;
|
|
107
|
+
align-items: center;
|
|
108
|
+
gap: 8px;
|
|
109
|
+
padding: 8px 10px;
|
|
110
|
+
background: var(--sp-danger-bg);
|
|
111
|
+
border: 1px solid var(--sp-danger);
|
|
112
|
+
border-radius: var(--sp-radius-sm);
|
|
113
|
+
font-size: 12px;
|
|
114
|
+
color: var(--sp-danger-fg);
|
|
115
|
+
}
|
|
116
|
+
</style>
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
withDefaults(
|
|
3
|
+
defineProps<{
|
|
4
|
+
variant?: "primary" | "secondary" | "ghost" | "danger";
|
|
5
|
+
size?: "sm" | "md";
|
|
6
|
+
type?: "button" | "submit" | "reset";
|
|
7
|
+
}>(),
|
|
8
|
+
{ variant: "primary", size: "md", type: "button" }
|
|
9
|
+
);
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<template>
|
|
13
|
+
<button :type="type" :data-variant="variant" :data-size="size" class="sp-btn">
|
|
14
|
+
<slot />
|
|
15
|
+
</button>
|
|
16
|
+
</template>
|
|
17
|
+
|
|
18
|
+
<style scoped>
|
|
19
|
+
.sp-btn {
|
|
20
|
+
font-family: var(--sp-font-sans);
|
|
21
|
+
font-weight: 500;
|
|
22
|
+
border-radius: var(--sp-radius);
|
|
23
|
+
border: 1px solid transparent;
|
|
24
|
+
cursor: pointer;
|
|
25
|
+
transition:
|
|
26
|
+
background var(--sp-dur) var(--sp-ease),
|
|
27
|
+
border-color var(--sp-dur) var(--sp-ease);
|
|
28
|
+
white-space: nowrap;
|
|
29
|
+
}
|
|
30
|
+
.sp-btn[data-size="md"] {
|
|
31
|
+
padding: 11px 14px;
|
|
32
|
+
font-size: 14px;
|
|
33
|
+
}
|
|
34
|
+
.sp-btn[data-size="sm"] {
|
|
35
|
+
padding: 7px 12px;
|
|
36
|
+
font-size: 12px;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.sp-btn[data-variant="primary"] {
|
|
40
|
+
background: var(--sp-fg);
|
|
41
|
+
color: var(--sp-bg);
|
|
42
|
+
}
|
|
43
|
+
.sp-btn[data-variant="primary"]:hover {
|
|
44
|
+
opacity: 0.9;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.sp-btn[data-variant="secondary"] {
|
|
48
|
+
background: var(--sp-bg);
|
|
49
|
+
color: var(--sp-fg);
|
|
50
|
+
border-color: var(--sp-border);
|
|
51
|
+
}
|
|
52
|
+
.sp-btn[data-variant="secondary"]:hover {
|
|
53
|
+
background: var(--sp-bg-muted);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.sp-btn[data-variant="ghost"] {
|
|
57
|
+
background: transparent;
|
|
58
|
+
color: var(--sp-fg-muted);
|
|
59
|
+
}
|
|
60
|
+
.sp-btn[data-variant="ghost"]:hover {
|
|
61
|
+
background: var(--sp-bg-muted);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.sp-btn[data-variant="danger"] {
|
|
65
|
+
background: var(--sp-danger);
|
|
66
|
+
color: #fff;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.sp-btn:focus-visible {
|
|
70
|
+
outline: 2px solid var(--sp-accent-ring);
|
|
71
|
+
outline-offset: 2px;
|
|
72
|
+
}
|
|
73
|
+
.sp-btn:disabled {
|
|
74
|
+
opacity: 0.5;
|
|
75
|
+
cursor: not-allowed;
|
|
76
|
+
}
|
|
77
|
+
</style>
|