@hadoku/task-ui-components 4.23.12 → 4.23.14
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/components/AvatarBuilder.d.ts.map +1 -1
- package/dist/components/AvatarBuilder.js +65 -76
- package/dist/components/AvatarBuilder.js.map +1 -1
- package/dist/components/AvatarPicker.d.ts +3 -2
- package/dist/components/AvatarPicker.d.ts.map +1 -1
- package/dist/components/AvatarPicker.js +27 -9
- package/dist/components/AvatarPicker.js.map +1 -1
- package/dist/components/LinkedAccounts.d.ts +19 -0
- package/dist/components/LinkedAccounts.d.ts.map +1 -1
- package/dist/components/LinkedAccounts.js +41 -24
- package/dist/components/LinkedAccounts.js.map +1 -1
- package/dist/vendor/wolffm/prefs-client/avatar.js.map +1 -1
- package/dist/vendor/wolffm/prefs-client/cosmetics.js.map +1 -1
- package/package.json +2 -2
- package/src/components/AvatarBuilder.tsx +57 -74
- package/src/components/AvatarPicker.tsx +31 -13
- package/src/components/LinkedAccounts.tsx +63 -31
|
@@ -201,7 +201,12 @@ const S = {
|
|
|
201
201
|
gap: 5,
|
|
202
202
|
padding: 6,
|
|
203
203
|
borderRadius: 8,
|
|
204
|
-
border:
|
|
204
|
+
// Longhands, not the `border` shorthand: the selected styles override
|
|
205
|
+
// borderColor, and when one comes off React clears borderColor to '', which
|
|
206
|
+
// would reset it to currentColor and leave a text-coloured ring behind.
|
|
207
|
+
borderWidth: 1,
|
|
208
|
+
borderStyle: 'solid',
|
|
209
|
+
borderColor: 'var(--color-border)',
|
|
205
210
|
background: 'var(--color-bg)',
|
|
206
211
|
color: 'var(--color-text)',
|
|
207
212
|
cursor: 'pointer',
|
|
@@ -225,16 +230,7 @@ const S = {
|
|
|
225
230
|
fontSize: '0.8rem'
|
|
226
231
|
},
|
|
227
232
|
tileName: { fontSize: '0.8rem', fontWeight: 500, display: 'flex', alignItems: 'center', gap: 5 },
|
|
228
|
-
tileEarn: { fontSize: '0.7rem', lineHeight: 1.3, color: 'var(--color-text-muted)' }
|
|
229
|
-
foot: {
|
|
230
|
-
display: 'flex',
|
|
231
|
-
justifyContent: 'space-between',
|
|
232
|
-
alignItems: 'center',
|
|
233
|
-
gap: 8,
|
|
234
|
-
flexWrap: 'wrap',
|
|
235
|
-
fontSize: '0.75rem',
|
|
236
|
-
color: 'var(--color-text-muted)'
|
|
237
|
-
}
|
|
233
|
+
tileEarn: { fontSize: '0.7rem', lineHeight: 1.3, color: 'var(--color-text-muted)' }
|
|
238
234
|
} satisfies Record<string, React.CSSProperties>
|
|
239
235
|
|
|
240
236
|
/**
|
|
@@ -867,77 +863,64 @@ function SlotGrid({
|
|
|
867
863
|
onTry
|
|
868
864
|
}: SlotGridProps) {
|
|
869
865
|
const items = COSMETICS.filter(i => i.slot === slot && (!i.hidden || isAdmin))
|
|
870
|
-
const locked = items.filter(i => !i.hidden && !owned.has(i.id))
|
|
871
866
|
|
|
872
867
|
return (
|
|
873
|
-
|
|
874
|
-
<
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
if (item.hidden) {
|
|
886
|
-
return (
|
|
887
|
-
<button
|
|
888
|
-
key={item.id}
|
|
889
|
-
type="button"
|
|
890
|
-
disabled
|
|
891
|
-
aria-label={`${item.label}, archived`}
|
|
892
|
-
style={{ ...S.tile, cursor: 'not-allowed', opacity: 0.45 }}
|
|
893
|
-
>
|
|
894
|
-
<Html html={preview(item.id)} style={{ ...S.tilePv, ...colour }} />
|
|
895
|
-
<span style={S.tileName}>{item.label}</span>
|
|
896
|
-
<span style={S.tileEarn}>Archived</span>
|
|
897
|
-
</button>
|
|
898
|
-
)
|
|
899
|
-
}
|
|
900
|
-
const own = owned.has(item.id)
|
|
901
|
-
const on = equipped === item.id
|
|
902
|
-
const trying = tryOn?.slot === slot && tryOn.id === item.id
|
|
868
|
+
<div style={S.grid}>
|
|
869
|
+
<button
|
|
870
|
+
type="button"
|
|
871
|
+
aria-pressed={equipped === null}
|
|
872
|
+
style={{ ...S.tile, ...(equipped === null ? S.tileOn : {}) }}
|
|
873
|
+
onClick={() => onEquip(null)}
|
|
874
|
+
>
|
|
875
|
+
<Html html={preview(null)} style={{ ...S.tilePv, ...colour }} />
|
|
876
|
+
<span style={S.tileName}>None</span>
|
|
877
|
+
</button>
|
|
878
|
+
{items.map(item => {
|
|
879
|
+
if (item.hidden) {
|
|
903
880
|
return (
|
|
904
881
|
<button
|
|
905
882
|
key={item.id}
|
|
906
883
|
type="button"
|
|
907
|
-
|
|
908
|
-
aria-label={
|
|
909
|
-
|
|
910
|
-
}
|
|
911
|
-
style={{
|
|
912
|
-
...S.tile,
|
|
913
|
-
...(on ? S.tileOn : {}),
|
|
914
|
-
...(trying ? S.tileTrying : {})
|
|
915
|
-
}}
|
|
916
|
-
onClick={() => (own ? onEquip(item.id) : onTry(item.id))}
|
|
917
|
-
// A message reveal plays once; hovering its tile plays it again.
|
|
918
|
-
onMouseEnter={e => slot === 'message' && replayPreview(e.currentTarget)}
|
|
884
|
+
disabled
|
|
885
|
+
aria-label={`${item.label}, archived`}
|
|
886
|
+
style={{ ...S.tile, cursor: 'not-allowed', opacity: 0.45 }}
|
|
919
887
|
>
|
|
920
|
-
<Html
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
/>
|
|
924
|
-
<span style={{ ...S.tileName, color: own ? undefined : 'var(--color-text-muted)' }}>
|
|
925
|
-
{!own && <Icon name="lock" size={12} />}
|
|
926
|
-
{item.label}
|
|
927
|
-
</span>
|
|
928
|
-
{!own && <span style={S.tileEarn}>{item.earn}</span>}
|
|
888
|
+
<Html html={preview(item.id)} style={{ ...S.tilePv, ...colour }} />
|
|
889
|
+
<span style={S.tileName}>{item.label}</span>
|
|
890
|
+
<span style={S.tileEarn}>Archived</span>
|
|
929
891
|
</button>
|
|
930
892
|
)
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
893
|
+
}
|
|
894
|
+
const own = owned.has(item.id)
|
|
895
|
+
const on = equipped === item.id
|
|
896
|
+
const trying = tryOn?.slot === slot && tryOn.id === item.id
|
|
897
|
+
return (
|
|
898
|
+
<button
|
|
899
|
+
key={item.id}
|
|
900
|
+
type="button"
|
|
901
|
+
aria-pressed={own ? on : trying}
|
|
902
|
+
aria-label={own ? item.label : `${item.label}, locked. ${item.earn ?? ''}. Try it on.`}
|
|
903
|
+
style={{
|
|
904
|
+
...S.tile,
|
|
905
|
+
...(on ? S.tileOn : {}),
|
|
906
|
+
...(trying ? S.tileTrying : {})
|
|
907
|
+
}}
|
|
908
|
+
onClick={() => (own ? onEquip(item.id) : onTry(item.id))}
|
|
909
|
+
// A message reveal plays once; hovering its tile plays it again.
|
|
910
|
+
onMouseEnter={e => slot === 'message' && replayPreview(e.currentTarget)}
|
|
911
|
+
>
|
|
912
|
+
<Html
|
|
913
|
+
html={preview(item.id)}
|
|
914
|
+
style={{ ...S.tilePv, ...colour, opacity: own ? 1 : 0.55 }}
|
|
915
|
+
/>
|
|
916
|
+
<span style={{ ...S.tileName, color: own ? undefined : 'var(--color-text-muted)' }}>
|
|
917
|
+
{!own && <Icon name="lock" size={12} />}
|
|
918
|
+
{item.label}
|
|
919
|
+
</span>
|
|
920
|
+
{!own && <span style={S.tileEarn}>{item.earn}</span>}
|
|
921
|
+
</button>
|
|
922
|
+
)
|
|
923
|
+
})}
|
|
924
|
+
</div>
|
|
942
925
|
)
|
|
943
926
|
}
|
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
* protocol cannot drift from the callback page).
|
|
8
8
|
*
|
|
9
9
|
* Everything happens in place: picking, uploading and removing are requests
|
|
10
|
-
* from here.
|
|
11
|
-
* (LinkedAccounts);
|
|
10
|
+
* from here. Discord and Steam are managed in the popout's Account tab
|
|
11
|
+
* (LinkedAccounts); one not linked yet can also be linked from its tile here,
|
|
12
|
+
* with the same consent step, and its picture is then worn.
|
|
12
13
|
*
|
|
13
14
|
* AvatarBuilder owns the settings and the character table and loads them only
|
|
14
15
|
* when the dialog opens; this component draws them and sends changes through
|
|
@@ -21,6 +22,7 @@
|
|
|
21
22
|
import React, { useEffect, useState } from 'react'
|
|
22
23
|
import { type AvatarSettings, type AvatarSource } from '@wolffm/prefs-client/avatar'
|
|
23
24
|
import { avatarClient, portraitUrl, type CharacterSet } from '../lib/avatarData'
|
|
25
|
+
import { LinkConsent, linkAndWear } from './LinkedAccounts'
|
|
24
26
|
|
|
25
27
|
const LABEL: Record<AvatarSource, string> = {
|
|
26
28
|
sprite: 'Character',
|
|
@@ -40,7 +42,12 @@ const S = {
|
|
|
40
42
|
gap: 4,
|
|
41
43
|
padding: 6,
|
|
42
44
|
borderRadius: 8,
|
|
43
|
-
border:
|
|
45
|
+
// Longhands, not the `border` shorthand: the selected styles override
|
|
46
|
+
// borderColor, and when one comes off React clears borderColor to '', which
|
|
47
|
+
// would reset it to currentColor and leave a text-coloured ring behind.
|
|
48
|
+
borderWidth: 1,
|
|
49
|
+
borderStyle: 'solid',
|
|
50
|
+
borderColor: 'var(--color-border)',
|
|
44
51
|
background: 'var(--color-bg)',
|
|
45
52
|
color: 'var(--color-text)',
|
|
46
53
|
fontSize: '0.72rem',
|
|
@@ -128,11 +135,10 @@ export function AvatarPicker({ settings, sets, busy, run }: AvatarPickerProps) {
|
|
|
128
135
|
}
|
|
129
136
|
const shown = settings.avatar
|
|
130
137
|
const current = sets?.[setIdx]
|
|
131
|
-
// The tab on show. Picking an available source also wears it;
|
|
132
|
-
//
|
|
138
|
+
// The tab on show. Picking an available source also wears it; one that is
|
|
139
|
+
// not yet opens to get it: Upload to choose a file, Discord or Steam to link.
|
|
133
140
|
const [view, setView] = useState<AvatarSource>(shown.source)
|
|
134
141
|
useEffect(() => setView(shown.source), [shown.source])
|
|
135
|
-
const openable = (src: AvatarSource) => src === 'upload' || available[src]
|
|
136
142
|
|
|
137
143
|
return (
|
|
138
144
|
<div style={S.stack}>
|
|
@@ -149,13 +155,8 @@ export function AvatarPicker({ settings, sets, busy, run }: AvatarPickerProps) {
|
|
|
149
155
|
type="button"
|
|
150
156
|
role="radio"
|
|
151
157
|
aria-checked={view === src}
|
|
152
|
-
disabled={busy
|
|
153
|
-
|
|
154
|
-
style={{
|
|
155
|
-
...S.source,
|
|
156
|
-
...(view === src ? S.sourceOn : {}),
|
|
157
|
-
opacity: openable(src) ? 1 : 0.5
|
|
158
|
-
}}
|
|
158
|
+
disabled={busy}
|
|
159
|
+
style={{ ...S.source, ...(view === src ? S.sourceOn : {}) }}
|
|
159
160
|
onClick={() => {
|
|
160
161
|
setView(src)
|
|
161
162
|
if (available[src] && shown.source !== src) {
|
|
@@ -213,6 +214,23 @@ export function AvatarPicker({ settings, sets, busy, run }: AvatarPickerProps) {
|
|
|
213
214
|
<span className="settings-popout__hint">…</span>
|
|
214
215
|
))}
|
|
215
216
|
|
|
217
|
+
{(view === 'discord' || view === 'steam') &&
|
|
218
|
+
!available[view] &&
|
|
219
|
+
(!settings.providers[view] ? (
|
|
220
|
+
<span className="settings-popout__hint">{LABEL[view]} is not available yet.</span>
|
|
221
|
+
) : settings.linked[view] ? (
|
|
222
|
+
<span className="settings-popout__hint">Your {LABEL[view]} account has no picture.</span>
|
|
223
|
+
) : (
|
|
224
|
+
<LinkConsent
|
|
225
|
+
key={view}
|
|
226
|
+
provider={view}
|
|
227
|
+
busy={busy}
|
|
228
|
+
onLink={() => {
|
|
229
|
+
run(() => linkAndWear(view)).catch(reportErr)
|
|
230
|
+
}}
|
|
231
|
+
/>
|
|
232
|
+
))}
|
|
233
|
+
|
|
216
234
|
{view === 'upload' && (
|
|
217
235
|
<div style={S.line}>
|
|
218
236
|
<span className="settings-popout__label" style={S.grow}>
|
|
@@ -51,6 +51,60 @@ const S = {
|
|
|
51
51
|
list: { margin: 0, paddingLeft: 18, fontSize: '0.75rem', lineHeight: 1.45 }
|
|
52
52
|
} satisfies Record<string, React.CSSProperties>
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Link `provider` in a popup, and wear its picture when it came back with one:
|
|
56
|
+
* linking from either place means "use this". Call it inside the click, with
|
|
57
|
+
* nothing awaited first — the popup opens before its first await, which is
|
|
58
|
+
* what popup blockers allow.
|
|
59
|
+
*/
|
|
60
|
+
export function linkAndWear(provider: LinkProvider): Promise<AvatarSettings> {
|
|
61
|
+
return avatarClient
|
|
62
|
+
.linkInPopup(provider)
|
|
63
|
+
.then(next => (next.linked[provider]?.avatarUrl ? avatarClient.choose(provider) : next))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* What linking reads and keeps, then Continue. Shared by Account → Linked
|
|
68
|
+
* accounts and the avatar dialog's Picture tab, so the consent is one text.
|
|
69
|
+
*/
|
|
70
|
+
export function LinkConsent({
|
|
71
|
+
provider,
|
|
72
|
+
busy,
|
|
73
|
+
onLink,
|
|
74
|
+
onCancel
|
|
75
|
+
}: {
|
|
76
|
+
provider: LinkProvider
|
|
77
|
+
busy: boolean
|
|
78
|
+
/** Must open the popup synchronously: call {@link linkAndWear} directly. */
|
|
79
|
+
onLink: () => void
|
|
80
|
+
onCancel?: () => void
|
|
81
|
+
}) {
|
|
82
|
+
return (
|
|
83
|
+
<div style={S.stack}>
|
|
84
|
+
<ul style={S.list}>
|
|
85
|
+
{CONSENT[provider].points.map(t => (
|
|
86
|
+
<li key={t}>{t}</li>
|
|
87
|
+
))}
|
|
88
|
+
</ul>
|
|
89
|
+
<span style={S.line}>
|
|
90
|
+
<button
|
|
91
|
+
type="button"
|
|
92
|
+
className="settings-popout__btn settings-popout__btn--primary"
|
|
93
|
+
disabled={busy}
|
|
94
|
+
onClick={onLink}
|
|
95
|
+
>
|
|
96
|
+
{CONSENT[provider].go}
|
|
97
|
+
</button>
|
|
98
|
+
{onCancel && (
|
|
99
|
+
<button type="button" className="settings-popout__btn" onClick={onCancel}>
|
|
100
|
+
Cancel
|
|
101
|
+
</button>
|
|
102
|
+
)}
|
|
103
|
+
</span>
|
|
104
|
+
</div>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
54
108
|
export function LinkedAccounts() {
|
|
55
109
|
const [settings, setSettings] = useState<AvatarSettings | null>(null)
|
|
56
110
|
const [busy, setBusy] = useState(false)
|
|
@@ -92,17 +146,6 @@ export function LinkedAccounts() {
|
|
|
92
146
|
)
|
|
93
147
|
}
|
|
94
148
|
|
|
95
|
-
// Must stay synchronous up to linkInPopup: the popup opens before its first
|
|
96
|
-
// await, inside this click, which is what popup blockers allow.
|
|
97
|
-
const link = (provider: LinkProvider) => {
|
|
98
|
-
setConsent(null)
|
|
99
|
-
void run(async () => {
|
|
100
|
-
const next = await avatarClient.linkInPopup(provider)
|
|
101
|
-
// Linking is choosing, when the account came back with a picture.
|
|
102
|
-
return next.linked[provider]?.avatarUrl ? avatarClient.choose(provider) : next
|
|
103
|
-
})
|
|
104
|
-
}
|
|
105
|
-
|
|
106
149
|
return (
|
|
107
150
|
<div style={S.stack}>
|
|
108
151
|
{(['discord', 'steam'] as const).map(p => {
|
|
@@ -144,26 +187,15 @@ export function LinkedAccounts() {
|
|
|
144
187
|
})}
|
|
145
188
|
|
|
146
189
|
{consent && (
|
|
147
|
-
<
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
)
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
className="settings-popout__btn settings-popout__btn--primary"
|
|
157
|
-
disabled={busy}
|
|
158
|
-
onClick={() => link(consent)}
|
|
159
|
-
>
|
|
160
|
-
{CONSENT[consent].go}
|
|
161
|
-
</button>
|
|
162
|
-
<button type="button" className="settings-popout__btn" onClick={() => setConsent(null)}>
|
|
163
|
-
Cancel
|
|
164
|
-
</button>
|
|
165
|
-
</span>
|
|
166
|
-
</div>
|
|
190
|
+
<LinkConsent
|
|
191
|
+
provider={consent}
|
|
192
|
+
busy={busy}
|
|
193
|
+
onLink={() => {
|
|
194
|
+
setConsent(null)
|
|
195
|
+
void run(() => linkAndWear(consent))
|
|
196
|
+
}}
|
|
197
|
+
onCancel={() => setConsent(null)}
|
|
198
|
+
/>
|
|
167
199
|
)}
|
|
168
200
|
|
|
169
201
|
{error && (
|