@wibly/ui-kit 0.1.1 → 0.1.3
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/CHANGELOG.md +6 -0
- package/package.json +3 -2
- package/src/components/camera-capture-modal.tsx +172 -0
- package/src/components/host-display-viewport.tsx +174 -0
- package/src/components/player-avatar-picker.tsx +222 -0
- package/src/components/player-avatar.tsx +82 -0
- package/src/components/player-roster.tsx +71 -0
- package/src/components/shell-brand-mark.tsx +33 -0
- package/src/components/shell-glass-toolbar.tsx +107 -0
- package/src/components/shell-menu-avatar.tsx +73 -0
- package/src/components/shell-session-frame.test.ts +10 -0
- package/src/components/shell-session-frame.tsx +76 -0
- package/src/index.ts +70 -0
- package/src/lib/host-canvas.test.ts +40 -0
- package/src/lib/host-canvas.ts +30 -0
- package/src/lib/host-mount.test.ts +31 -0
- package/src/lib/host-mount.ts +68 -0
- package/styles/wibly-shell-theme.css +88 -0
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wibly/ui-kit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Wibly @wibly/ui-kit",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -18,10 +18,11 @@
|
|
|
18
18
|
"access": "public"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
+
"@platform/player-profile": "workspace:*",
|
|
21
22
|
"clsx": "^2.1.1"
|
|
22
23
|
},
|
|
23
24
|
"peerDependencies": {
|
|
24
|
-
"@wibly/sdk": "0.1.
|
|
25
|
+
"@wibly/sdk": "0.1.3",
|
|
25
26
|
"react": "^19.0.0"
|
|
26
27
|
},
|
|
27
28
|
"peerDependenciesMeta": {
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ReactElement } from 'react';
|
|
4
|
+
import * as React from 'react';
|
|
5
|
+
|
|
6
|
+
import type { ProcessAvatarPhotoResult } from '@platform/player-profile';
|
|
7
|
+
|
|
8
|
+
export type CameraCaptureModalProps = {
|
|
9
|
+
readonly open: boolean;
|
|
10
|
+
readonly onClose: () => void;
|
|
11
|
+
readonly onCaptured: (dataUrl: string) => void;
|
|
12
|
+
readonly onProcessBytes: (args: {
|
|
13
|
+
readonly bytes: Uint8Array;
|
|
14
|
+
readonly mimeType: string;
|
|
15
|
+
readonly sizeBytes: number;
|
|
16
|
+
}) => ProcessAvatarPhotoResult;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const CameraCaptureModal = ({
|
|
20
|
+
open,
|
|
21
|
+
onClose,
|
|
22
|
+
onCaptured,
|
|
23
|
+
onProcessBytes,
|
|
24
|
+
}: CameraCaptureModalProps): ReactElement | null => {
|
|
25
|
+
const videoRef = React.useRef<HTMLVideoElement>(null);
|
|
26
|
+
const streamRef = React.useRef<MediaStream | null>(null);
|
|
27
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
28
|
+
const [ready, setReady] = React.useState(false);
|
|
29
|
+
|
|
30
|
+
const stopStream = React.useCallback((): void => {
|
|
31
|
+
for (const track of streamRef.current?.getTracks() ?? []) {
|
|
32
|
+
track.stop();
|
|
33
|
+
}
|
|
34
|
+
streamRef.current = null;
|
|
35
|
+
setReady(false);
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
React.useEffect(() => {
|
|
39
|
+
if (!open) {
|
|
40
|
+
stopStream();
|
|
41
|
+
setError(null);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let cancelled = false;
|
|
46
|
+
|
|
47
|
+
void (async () => {
|
|
48
|
+
if (
|
|
49
|
+
typeof navigator === 'undefined' ||
|
|
50
|
+
!navigator.mediaDevices?.getUserMedia
|
|
51
|
+
) {
|
|
52
|
+
setError('Camera access is not available in this browser.');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const stream = await navigator.mediaDevices.getUserMedia({
|
|
57
|
+
video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 640 } },
|
|
58
|
+
audio: false,
|
|
59
|
+
});
|
|
60
|
+
if (cancelled) {
|
|
61
|
+
for (const track of stream.getTracks()) track.stop();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
streamRef.current = stream;
|
|
65
|
+
const video = videoRef.current;
|
|
66
|
+
if (video) {
|
|
67
|
+
video.srcObject = stream;
|
|
68
|
+
await video.play();
|
|
69
|
+
setReady(true);
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
setError(
|
|
73
|
+
'Could not open the camera. Check permissions or try uploading a photo instead.',
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
})();
|
|
77
|
+
|
|
78
|
+
return (): void => {
|
|
79
|
+
cancelled = true;
|
|
80
|
+
stopStream();
|
|
81
|
+
};
|
|
82
|
+
}, [open, stopStream]);
|
|
83
|
+
|
|
84
|
+
const handleCapture = (): void => {
|
|
85
|
+
const video = videoRef.current;
|
|
86
|
+
if (!video || video.videoWidth === 0) return;
|
|
87
|
+
const canvas = document.createElement('canvas');
|
|
88
|
+
const size = Math.min(video.videoWidth, video.videoHeight);
|
|
89
|
+
canvas.width = size;
|
|
90
|
+
canvas.height = size;
|
|
91
|
+
const ctx = canvas.getContext('2d');
|
|
92
|
+
if (!ctx) return;
|
|
93
|
+
const offsetX = (video.videoWidth - size) / 2;
|
|
94
|
+
const offsetY = (video.videoHeight - size) / 2;
|
|
95
|
+
ctx.drawImage(video, offsetX, offsetY, size, size, 0, 0, size, size);
|
|
96
|
+
canvas.toBlob(
|
|
97
|
+
(blob) => {
|
|
98
|
+
if (!blob) {
|
|
99
|
+
setError('Could not capture a photo — try again.');
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
void blob.arrayBuffer().then((buffer) => {
|
|
103
|
+
const result = onProcessBytes({
|
|
104
|
+
bytes: new Uint8Array(buffer),
|
|
105
|
+
mimeType: blob.type || 'image/jpeg',
|
|
106
|
+
sizeBytes: blob.size,
|
|
107
|
+
});
|
|
108
|
+
if (!result.ok) {
|
|
109
|
+
setError(result.message);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
stopStream();
|
|
113
|
+
onCaptured(result.dataUrl);
|
|
114
|
+
onClose();
|
|
115
|
+
});
|
|
116
|
+
},
|
|
117
|
+
'image/jpeg',
|
|
118
|
+
0.88,
|
|
119
|
+
);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
if (!open) return null;
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div
|
|
126
|
+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
|
|
127
|
+
role="dialog"
|
|
128
|
+
aria-modal="true"
|
|
129
|
+
aria-label="Take a photo with your camera"
|
|
130
|
+
>
|
|
131
|
+
<div className="w-full max-w-md rounded-2xl bg-white p-4 text-neutral-900 shadow-xl dark:bg-neutral-900 dark:text-neutral-100">
|
|
132
|
+
<h2 className="text-lg font-semibold">Take a photo</h2>
|
|
133
|
+
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-300">
|
|
134
|
+
Position your face in the frame, then capture.
|
|
135
|
+
</p>
|
|
136
|
+
<div className="relative mt-4 aspect-square overflow-hidden rounded-xl bg-black">
|
|
137
|
+
<video
|
|
138
|
+
ref={videoRef}
|
|
139
|
+
className="h-full w-full object-cover"
|
|
140
|
+
playsInline
|
|
141
|
+
muted
|
|
142
|
+
/>
|
|
143
|
+
</div>
|
|
144
|
+
{error ? (
|
|
145
|
+
<p className="mt-3 text-sm text-red-600" role="alert">
|
|
146
|
+
{error}
|
|
147
|
+
</p>
|
|
148
|
+
) : null}
|
|
149
|
+
<div className="mt-4 flex flex-wrap justify-end gap-2">
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
className="rounded-full border border-neutral-300 px-4 py-2 text-sm font-semibold dark:border-neutral-600"
|
|
153
|
+
onClick={() => {
|
|
154
|
+
stopStream();
|
|
155
|
+
onClose();
|
|
156
|
+
}}
|
|
157
|
+
>
|
|
158
|
+
Cancel
|
|
159
|
+
</button>
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
className="rounded-full bg-indigo-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
|
|
163
|
+
disabled={!ready}
|
|
164
|
+
onClick={handleCapture}
|
|
165
|
+
>
|
|
166
|
+
Capture photo
|
|
167
|
+
</button>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
);
|
|
172
|
+
};
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Host TV display viewport — scale-to-fit 16:9 canvas with full-screen backdrop.
|
|
5
|
+
*
|
|
6
|
+
* Bundles set `HOST_BACKDROP_CSS_VAR` on the mount container (`data-host-mount`)
|
|
7
|
+
* so atmospheric backgrounds fill the physical screen outside letterboxing.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as React from 'react';
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
computeHostCanvasScale,
|
|
14
|
+
HOST_BACKDROP_CSS_VAR,
|
|
15
|
+
HOST_CANVAS_HEIGHT,
|
|
16
|
+
HOST_CANVAS_WIDTH,
|
|
17
|
+
} from '../lib/host-canvas.js';
|
|
18
|
+
import { resolveHostMountElement } from '../lib/host-mount.js';
|
|
19
|
+
import { cn } from '../lib/cn.js';
|
|
20
|
+
|
|
21
|
+
export type HostDisplayViewportProps = {
|
|
22
|
+
readonly children: React.ReactNode;
|
|
23
|
+
/** When true, backdrop is contained to the preview card (Studio / ui-kit preview). */
|
|
24
|
+
readonly embedded?: boolean;
|
|
25
|
+
readonly className?: string;
|
|
26
|
+
readonly testId?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const readHostBackdrop = (element: Element | null): string | null => {
|
|
30
|
+
if (!element) return null;
|
|
31
|
+
const value = getComputedStyle(element)
|
|
32
|
+
.getPropertyValue(HOST_BACKDROP_CSS_VAR)
|
|
33
|
+
.trim();
|
|
34
|
+
return value.length > 0 ? value : null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const HostDisplayViewport = ({
|
|
38
|
+
children,
|
|
39
|
+
embedded = false,
|
|
40
|
+
className,
|
|
41
|
+
testId = 'host-display-viewport',
|
|
42
|
+
}: HostDisplayViewportProps): React.ReactElement => {
|
|
43
|
+
const viewportRef = React.useRef<HTMLDivElement | null>(null);
|
|
44
|
+
const canvasRef = React.useRef<HTMLDivElement | null>(null);
|
|
45
|
+
const fitRef = React.useRef<HTMLDivElement | null>(null);
|
|
46
|
+
const [scale, setScale] = React.useState(1);
|
|
47
|
+
|
|
48
|
+
React.useLayoutEffect(() => {
|
|
49
|
+
const fit = fitRef.current;
|
|
50
|
+
const viewport = viewportRef.current;
|
|
51
|
+
if (!fit) return;
|
|
52
|
+
|
|
53
|
+
const updateScale = (): void => {
|
|
54
|
+
const { width, height } = fit.getBoundingClientRect();
|
|
55
|
+
const next = computeHostCanvasScale(width, height);
|
|
56
|
+
setScale(next > 0 ? next : 0);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
updateScale();
|
|
60
|
+
const observer = new ResizeObserver(updateScale);
|
|
61
|
+
observer.observe(fit);
|
|
62
|
+
if (viewport) {
|
|
63
|
+
observer.observe(viewport);
|
|
64
|
+
}
|
|
65
|
+
return () => observer.disconnect();
|
|
66
|
+
}, []);
|
|
67
|
+
|
|
68
|
+
React.useLayoutEffect(() => {
|
|
69
|
+
const viewport = viewportRef.current;
|
|
70
|
+
const canvas = canvasRef.current;
|
|
71
|
+
if (!viewport || !canvas) return;
|
|
72
|
+
|
|
73
|
+
const syncBackdrop = (): void => {
|
|
74
|
+
const mount = resolveHostMountElement(canvas);
|
|
75
|
+
const backdrop = readHostBackdrop(mount);
|
|
76
|
+
if (backdrop) {
|
|
77
|
+
viewport.style.setProperty(HOST_BACKDROP_CSS_VAR, backdrop);
|
|
78
|
+
} else {
|
|
79
|
+
viewport.style.removeProperty(HOST_BACKDROP_CSS_VAR);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const observed: MutationObserver[] = [];
|
|
84
|
+
|
|
85
|
+
const observe = (target: Node): void => {
|
|
86
|
+
const observer = new MutationObserver(syncBackdrop);
|
|
87
|
+
observer.observe(target, {
|
|
88
|
+
childList: true,
|
|
89
|
+
subtree: true,
|
|
90
|
+
attributes: true,
|
|
91
|
+
attributeFilter: ['style', 'class'],
|
|
92
|
+
});
|
|
93
|
+
observed.push(observer);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const observeIframe = (iframe: HTMLIFrameElement): void => {
|
|
97
|
+
const doc = iframe.contentDocument;
|
|
98
|
+
if (!doc) return;
|
|
99
|
+
observe(doc.documentElement);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
syncBackdrop();
|
|
103
|
+
observe(canvas);
|
|
104
|
+
|
|
105
|
+
const iframe = canvas.querySelector('iframe[data-host-mount]');
|
|
106
|
+
if (iframe instanceof HTMLIFrameElement) {
|
|
107
|
+
if (iframe.contentDocument) {
|
|
108
|
+
observeIframe(iframe);
|
|
109
|
+
}
|
|
110
|
+
iframe.addEventListener('load', () => {
|
|
111
|
+
observeIframe(iframe);
|
|
112
|
+
syncBackdrop();
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return () => {
|
|
117
|
+
for (const observer of observed) {
|
|
118
|
+
observer.disconnect();
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
}, []);
|
|
122
|
+
|
|
123
|
+
const scaledWidth = HOST_CANVAS_WIDTH * scale;
|
|
124
|
+
const scaledHeight = HOST_CANVAS_HEIGHT * scale;
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<div
|
|
128
|
+
ref={viewportRef}
|
|
129
|
+
data-testid={testId}
|
|
130
|
+
className={cn(
|
|
131
|
+
'host-display-viewport relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden',
|
|
132
|
+
embedded && 'host-display-viewport--embedded',
|
|
133
|
+
className,
|
|
134
|
+
)}
|
|
135
|
+
>
|
|
136
|
+
<div
|
|
137
|
+
aria-hidden
|
|
138
|
+
data-testid="host-display-backdrop"
|
|
139
|
+
className={cn(
|
|
140
|
+
'host-display-backdrop pointer-events-none inset-0 z-0',
|
|
141
|
+
embedded ? 'absolute' : 'fixed',
|
|
142
|
+
)}
|
|
143
|
+
/>
|
|
144
|
+
<div
|
|
145
|
+
ref={fitRef}
|
|
146
|
+
data-testid="host-display-fit"
|
|
147
|
+
className="relative z-10 flex min-h-0 min-w-0 flex-1 items-center justify-center overflow-hidden"
|
|
148
|
+
>
|
|
149
|
+
<div
|
|
150
|
+
data-testid="host-display-scaler"
|
|
151
|
+
className="host-display-scaler relative shrink-0 overflow-hidden"
|
|
152
|
+
style={{
|
|
153
|
+
width: scaledWidth,
|
|
154
|
+
height: scaledHeight,
|
|
155
|
+
}}
|
|
156
|
+
>
|
|
157
|
+
<div
|
|
158
|
+
ref={canvasRef}
|
|
159
|
+
data-testid="host-display-canvas"
|
|
160
|
+
className="host-display-canvas absolute top-0 left-0 overflow-hidden"
|
|
161
|
+
style={{
|
|
162
|
+
width: HOST_CANVAS_WIDTH,
|
|
163
|
+
height: HOST_CANVAS_HEIGHT,
|
|
164
|
+
transform: `scale(${scale})`,
|
|
165
|
+
transformOrigin: 'top left',
|
|
166
|
+
}}
|
|
167
|
+
>
|
|
168
|
+
{children}
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
};
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ReactElement } from 'react';
|
|
4
|
+
import * as React from 'react';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
letterFromDisplayName,
|
|
8
|
+
PRESET_AVATARS,
|
|
9
|
+
processAvatarPhotoBytes,
|
|
10
|
+
resolveAvatarPreviewUrl,
|
|
11
|
+
type PresetAvatarId,
|
|
12
|
+
type ProcessAvatarPhotoResult,
|
|
13
|
+
} from '@platform/player-profile';
|
|
14
|
+
|
|
15
|
+
import { CameraCaptureModal } from './camera-capture-modal.js';
|
|
16
|
+
|
|
17
|
+
export type PlayerAvatarPickerShape = 'rounded' | 'circle';
|
|
18
|
+
|
|
19
|
+
export type PlayerAvatarPickerProps = {
|
|
20
|
+
readonly displayName: string;
|
|
21
|
+
readonly selectedPresetId: PresetAvatarId;
|
|
22
|
+
readonly customAvatarUrl: string | null;
|
|
23
|
+
readonly onPresetChange: (id: PresetAvatarId) => void;
|
|
24
|
+
readonly onCustomAvatarChange: (dataUrl: string | null) => void;
|
|
25
|
+
readonly onProcessPhotoFile?: (
|
|
26
|
+
formData: FormData,
|
|
27
|
+
) => Promise<ProcessAvatarPhotoResult>;
|
|
28
|
+
readonly shape?: PlayerAvatarPickerShape;
|
|
29
|
+
readonly previewClassName?: string;
|
|
30
|
+
readonly selectClassName?: string;
|
|
31
|
+
readonly primaryButtonClassName?: string;
|
|
32
|
+
readonly secondaryButtonClassName?: string;
|
|
33
|
+
readonly errorClassName?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const defaultPreviewClass =
|
|
37
|
+
'relative h-14 w-14 shrink-0 overflow-hidden border border-neutral-200 bg-neutral-50';
|
|
38
|
+
|
|
39
|
+
const defaultSelectClass =
|
|
40
|
+
'h-10 w-full min-w-0 rounded-lg border border-neutral-200 bg-white px-3 text-sm font-medium text-neutral-900';
|
|
41
|
+
|
|
42
|
+
export const PlayerAvatarPicker = ({
|
|
43
|
+
displayName,
|
|
44
|
+
selectedPresetId,
|
|
45
|
+
customAvatarUrl,
|
|
46
|
+
onPresetChange,
|
|
47
|
+
onCustomAvatarChange,
|
|
48
|
+
onProcessPhotoFile,
|
|
49
|
+
shape = 'rounded',
|
|
50
|
+
previewClassName,
|
|
51
|
+
selectClassName,
|
|
52
|
+
primaryButtonClassName = 'text-xs font-semibold text-indigo-600 underline-offset-2 hover:underline',
|
|
53
|
+
secondaryButtonClassName = 'text-xs font-semibold text-neutral-500 underline-offset-2 hover:underline',
|
|
54
|
+
errorClassName = 'text-xs font-medium text-red-600',
|
|
55
|
+
}: PlayerAvatarPickerProps): ReactElement => {
|
|
56
|
+
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
|
57
|
+
const cameraInputRef = React.useRef<HTMLInputElement>(null);
|
|
58
|
+
const [photoError, setPhotoError] = React.useState<string | null>(null);
|
|
59
|
+
const [uploading, setUploading] = React.useState(false);
|
|
60
|
+
const [cameraOpen, setCameraOpen] = React.useState(false);
|
|
61
|
+
|
|
62
|
+
const previewRadius = shape === 'circle' ? 'rounded-full' : 'rounded-xl';
|
|
63
|
+
const letter = letterFromDisplayName(displayName);
|
|
64
|
+
|
|
65
|
+
const previewUrl =
|
|
66
|
+
customAvatarUrl ??
|
|
67
|
+
resolveAvatarPreviewUrl({
|
|
68
|
+
presetId: selectedPresetId,
|
|
69
|
+
displayName,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const applyPhotoResult = (result: ProcessAvatarPhotoResult): void => {
|
|
73
|
+
if (!result.ok) {
|
|
74
|
+
setPhotoError(result.message);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
onCustomAvatarChange(result.dataUrl);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const handlePhotoFile = async (file: File): Promise<void> => {
|
|
81
|
+
setUploading(true);
|
|
82
|
+
setPhotoError(null);
|
|
83
|
+
if (onProcessPhotoFile) {
|
|
84
|
+
const formData = new FormData();
|
|
85
|
+
formData.set('photo', file);
|
|
86
|
+
const result = await onProcessPhotoFile(formData);
|
|
87
|
+
setUploading(false);
|
|
88
|
+
applyPhotoResult(result);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const buffer = await file.arrayBuffer();
|
|
92
|
+
const result = processAvatarPhotoBytes({
|
|
93
|
+
bytes: new Uint8Array(buffer),
|
|
94
|
+
mimeType: file.type,
|
|
95
|
+
sizeBytes: file.size,
|
|
96
|
+
});
|
|
97
|
+
setUploading(false);
|
|
98
|
+
applyPhotoResult(result);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const handleFileInput = (event: React.ChangeEvent<HTMLInputElement>): void => {
|
|
102
|
+
const file = event.target.files?.[0];
|
|
103
|
+
event.target.value = '';
|
|
104
|
+
if (!file) return;
|
|
105
|
+
void handlePhotoFile(file);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const handlePresetChange = (
|
|
109
|
+
event: React.ChangeEvent<HTMLSelectElement>,
|
|
110
|
+
): void => {
|
|
111
|
+
onCustomAvatarChange(null);
|
|
112
|
+
onPresetChange(event.target.value as PresetAvatarId);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<section className="flex flex-col gap-3">
|
|
117
|
+
<div className="flex items-center gap-3">
|
|
118
|
+
<div className={`${previewClassName ?? defaultPreviewClass} ${previewRadius}`}>
|
|
119
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
120
|
+
<img
|
|
121
|
+
src={previewUrl}
|
|
122
|
+
alt="Your avatar preview"
|
|
123
|
+
className="h-full w-full object-cover"
|
|
124
|
+
/>
|
|
125
|
+
</div>
|
|
126
|
+
<div className="min-w-0 flex-1">
|
|
127
|
+
<label className="flex flex-col gap-1.5">
|
|
128
|
+
<span className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
|
129
|
+
Avatar
|
|
130
|
+
</span>
|
|
131
|
+
<select
|
|
132
|
+
className={selectClassName ?? defaultSelectClass}
|
|
133
|
+
value={selectedPresetId}
|
|
134
|
+
disabled={Boolean(customAvatarUrl)}
|
|
135
|
+
aria-label="Choose an avatar"
|
|
136
|
+
onChange={handlePresetChange}
|
|
137
|
+
>
|
|
138
|
+
{PRESET_AVATARS.map((avatar) => (
|
|
139
|
+
<option key={avatar.id} value={avatar.id}>
|
|
140
|
+
{avatar.id === 'letter'
|
|
141
|
+
? `${avatar.label} — ${letter}`
|
|
142
|
+
: avatar.label}
|
|
143
|
+
</option>
|
|
144
|
+
))}
|
|
145
|
+
</select>
|
|
146
|
+
</label>
|
|
147
|
+
{customAvatarUrl ? (
|
|
148
|
+
<p className="mt-1 text-xs text-neutral-500">Using a custom photo</p>
|
|
149
|
+
) : null}
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
|
|
154
|
+
<button
|
|
155
|
+
type="button"
|
|
156
|
+
className={primaryButtonClassName}
|
|
157
|
+
disabled={uploading}
|
|
158
|
+
onClick={() => setCameraOpen(true)}
|
|
159
|
+
>
|
|
160
|
+
Camera / webcam
|
|
161
|
+
</button>
|
|
162
|
+
<button
|
|
163
|
+
type="button"
|
|
164
|
+
className={primaryButtonClassName}
|
|
165
|
+
disabled={uploading}
|
|
166
|
+
onClick={() => cameraInputRef.current?.click()}
|
|
167
|
+
>
|
|
168
|
+
Take photo
|
|
169
|
+
</button>
|
|
170
|
+
<button
|
|
171
|
+
type="button"
|
|
172
|
+
className={primaryButtonClassName}
|
|
173
|
+
disabled={uploading}
|
|
174
|
+
onClick={() => fileInputRef.current?.click()}
|
|
175
|
+
>
|
|
176
|
+
{uploading ? 'Processing…' : 'Upload photo'}
|
|
177
|
+
</button>
|
|
178
|
+
{customAvatarUrl ? (
|
|
179
|
+
<button
|
|
180
|
+
type="button"
|
|
181
|
+
className={secondaryButtonClassName}
|
|
182
|
+
onClick={() => onCustomAvatarChange(null)}
|
|
183
|
+
>
|
|
184
|
+
Use preset instead
|
|
185
|
+
</button>
|
|
186
|
+
) : null}
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
<input
|
|
190
|
+
ref={cameraInputRef}
|
|
191
|
+
type="file"
|
|
192
|
+
accept="image/jpeg,image/png,image/webp"
|
|
193
|
+
capture="user"
|
|
194
|
+
className="hidden"
|
|
195
|
+
onChange={handleFileInput}
|
|
196
|
+
/>
|
|
197
|
+
<input
|
|
198
|
+
ref={fileInputRef}
|
|
199
|
+
type="file"
|
|
200
|
+
accept="image/jpeg,image/png,image/webp"
|
|
201
|
+
className="hidden"
|
|
202
|
+
onChange={handleFileInput}
|
|
203
|
+
/>
|
|
204
|
+
|
|
205
|
+
<CameraCaptureModal
|
|
206
|
+
open={cameraOpen}
|
|
207
|
+
onClose={() => setCameraOpen(false)}
|
|
208
|
+
onCaptured={(dataUrl) => {
|
|
209
|
+
setPhotoError(null);
|
|
210
|
+
onCustomAvatarChange(dataUrl);
|
|
211
|
+
}}
|
|
212
|
+
onProcessBytes={processAvatarPhotoBytes}
|
|
213
|
+
/>
|
|
214
|
+
|
|
215
|
+
{photoError ? (
|
|
216
|
+
<p className={errorClassName} role="alert">
|
|
217
|
+
{photoError}
|
|
218
|
+
</p>
|
|
219
|
+
) : null}
|
|
220
|
+
</section>
|
|
221
|
+
);
|
|
222
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `PlayerAvatar` — single player chip for rosters and score strips.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as React from 'react';
|
|
6
|
+
|
|
7
|
+
import { cn } from '../lib/cn.js';
|
|
8
|
+
|
|
9
|
+
export type PlayerAvatarProps = {
|
|
10
|
+
readonly playerId: string;
|
|
11
|
+
readonly displayName: string;
|
|
12
|
+
readonly imageUrl?: string | null;
|
|
13
|
+
readonly score?: number;
|
|
14
|
+
readonly connected?: boolean;
|
|
15
|
+
readonly highlighted?: boolean;
|
|
16
|
+
readonly size?: 'sm' | 'md';
|
|
17
|
+
readonly className?: string;
|
|
18
|
+
readonly testId?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const initialsFromName = (name: string): string => {
|
|
22
|
+
const parts = name.trim().split(/\s+/).filter(Boolean);
|
|
23
|
+
if (parts.length === 0) return '?';
|
|
24
|
+
return parts[0]!.slice(0, 2).toUpperCase();
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const SIZE_CLASS: Record<NonNullable<PlayerAvatarProps['size']>, string> = {
|
|
28
|
+
sm: 'size-10 text-[10px]',
|
|
29
|
+
md: 'size-14 text-xs',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const PlayerAvatar = ({
|
|
33
|
+
playerId,
|
|
34
|
+
displayName,
|
|
35
|
+
imageUrl,
|
|
36
|
+
score,
|
|
37
|
+
connected,
|
|
38
|
+
highlighted = false,
|
|
39
|
+
size = 'md',
|
|
40
|
+
className,
|
|
41
|
+
testId,
|
|
42
|
+
}: PlayerAvatarProps): React.ReactElement => (
|
|
43
|
+
<div
|
|
44
|
+
data-testid={testId ?? `player-avatar-${playerId}`}
|
|
45
|
+
className={cn(
|
|
46
|
+
'relative flex flex-col items-center gap-1',
|
|
47
|
+
highlighted ? 'opacity-100' : 'opacity-90',
|
|
48
|
+
className,
|
|
49
|
+
)}
|
|
50
|
+
title={displayName}
|
|
51
|
+
>
|
|
52
|
+
<div
|
|
53
|
+
className={cn(
|
|
54
|
+
'relative flex shrink-0 items-center justify-center overflow-hidden rounded-full border-2 bg-card font-bold text-card-foreground',
|
|
55
|
+
SIZE_CLASS[size],
|
|
56
|
+
highlighted ? 'border-primary shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_25%,transparent)]' : 'border-border',
|
|
57
|
+
connected === false ? 'opacity-50' : '',
|
|
58
|
+
)}
|
|
59
|
+
>
|
|
60
|
+
{imageUrl ? (
|
|
61
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
62
|
+
<img src={imageUrl} alt="" className="size-full object-cover" />
|
|
63
|
+
) : (
|
|
64
|
+
<span>{initialsFromName(displayName)}</span>
|
|
65
|
+
)}
|
|
66
|
+
{connected === true ? (
|
|
67
|
+
<span
|
|
68
|
+
className="absolute -right-0.5 -bottom-0.5 size-2.5 rounded-full border-2 border-card bg-[color:var(--color-success,oklch(0.62_0.17_155))]"
|
|
69
|
+
aria-label="Connected"
|
|
70
|
+
/>
|
|
71
|
+
) : null}
|
|
72
|
+
</div>
|
|
73
|
+
<span className="max-w-[5.5rem] truncate text-center text-[10px] font-medium text-foreground">
|
|
74
|
+
{displayName}
|
|
75
|
+
</span>
|
|
76
|
+
{score !== undefined ? (
|
|
77
|
+
<span className="rounded-full bg-primary/20 px-1.5 py-0.5 text-[10px] font-bold text-primary">
|
|
78
|
+
{score.toLocaleString()}
|
|
79
|
+
</span>
|
|
80
|
+
) : null}
|
|
81
|
+
</div>
|
|
82
|
+
);
|