fluxy-bot 0.1.21 → 0.1.23
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/client/public/fluxy_say_hi.webm +0 -0
- package/client/src/components/Onboard/OnboardWizard.tsx +26 -26
- package/client/src/hooks/useWebSocket.ts +3 -5
- package/client/src/lib/ws-client.ts +67 -3
- package/client/src/styles/globals.css +56 -1
- package/dist/assets/{index-CKCTHjT9.js → index-CW6a7xtX.js} +29 -29
- package/dist/assets/index-Dpj8titN.css +1 -0
- package/dist/fluxy_say_hi.webm +0 -0
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/package.json +1 -1
- package/supervisor/index.ts +36 -1
- package/supervisor/tunnel.ts +14 -0
- package/worker/index.ts +6 -1
- package/dist/assets/index-3x-dxlA1.css +0 -1
|
Binary file
|
|
@@ -35,7 +35,7 @@ const MODELS: Record<string, { id: string; label: string }[]> = {
|
|
|
35
35
|
const TOTAL_STEPS = 6; // 0..5
|
|
36
36
|
|
|
37
37
|
const HANDLES = [
|
|
38
|
-
{ tier: 'premium', label: (n: string) => `${n}.fluxy.bot`, badge: '$5', badgeCls: 'bg-
|
|
38
|
+
{ tier: 'premium', label: (n: string) => `${n}.fluxy.bot`, badge: '$5', badgeCls: 'bg-[#AF27E3]/15 text-[#AF27E3] border-[#AF27E3]/20', highlight: true },
|
|
39
39
|
{ tier: 'at', label: (n: string) => `${n}.at.fluxy.bot`, badge: 'Free', badgeCls: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20', highlight: false },
|
|
40
40
|
] as const;
|
|
41
41
|
|
|
@@ -61,7 +61,7 @@ function ModelDropdown({ models, value, onChange }: { models: { id: string; labe
|
|
|
61
61
|
<button
|
|
62
62
|
type="button"
|
|
63
63
|
onClick={() => setOpen((o) => !o)}
|
|
64
|
-
className="w-full flex items-center justify-between bg-white/[0.03] border border-white/[0.08] text-white rounded-xl px-4 py-2.5 text-[13px] outline-none hover:border-white/15 focus:border-
|
|
64
|
+
className="w-full flex items-center justify-between bg-white/[0.03] border border-white/[0.08] text-white rounded-xl px-4 py-2.5 text-[13px] outline-none hover:border-white/15 focus:border-[#AF27E3]/30 transition-colors"
|
|
65
65
|
>
|
|
66
66
|
<span className={selected ? 'text-white' : 'text-white/20'}>
|
|
67
67
|
{selected ? selected.label : 'Choose a model...'}
|
|
@@ -76,7 +76,7 @@ function ModelDropdown({ models, value, onChange }: { models: { id: string; labe
|
|
|
76
76
|
onClick={() => { onChange(m.id); setOpen(false); }}
|
|
77
77
|
className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
|
|
78
78
|
value === m.id
|
|
79
|
-
? 'text-
|
|
79
|
+
? 'text-[#AF27E3] bg-[#AF27E3]/10'
|
|
80
80
|
: 'text-white/70 hover:bg-white/[0.04] hover:text-white'
|
|
81
81
|
}`}
|
|
82
82
|
>
|
|
@@ -495,9 +495,9 @@ export default function OnboardWizard({ onComplete }: Props) {
|
|
|
495
495
|
/* ── Styles ── */
|
|
496
496
|
|
|
497
497
|
const inputCls =
|
|
498
|
-
'w-full bg-white/[0.05] border border-white/[0.08] text-white rounded-xl px-4 py-3 text-base outline-none
|
|
498
|
+
'w-full bg-white/[0.05] border border-white/[0.08] text-white rounded-xl px-4 py-3 text-base outline-none input-glow placeholder:text-white/20 transition-all';
|
|
499
499
|
const inputSmCls =
|
|
500
|
-
'w-full bg-white/[0.03] border border-white/[0.08] text-white rounded-xl px-4 py-2.5 text-[13px] outline-none
|
|
500
|
+
'w-full bg-white/[0.03] border border-white/[0.08] text-white rounded-xl px-4 py-2.5 text-[13px] outline-none input-glow placeholder:text-white/20 transition-all';
|
|
501
501
|
|
|
502
502
|
return (
|
|
503
503
|
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4">
|
|
@@ -516,9 +516,9 @@ export default function OnboardWizard({ onComplete }: Props) {
|
|
|
516
516
|
key={i}
|
|
517
517
|
className={`h-1.5 rounded-full transition-all duration-300 ${
|
|
518
518
|
i === step
|
|
519
|
-
? 'w-7 bg-
|
|
519
|
+
? 'w-7 bg-gradient-brand'
|
|
520
520
|
: i < step
|
|
521
|
-
? 'w-1.5 bg-
|
|
521
|
+
? 'w-1.5 bg-gradient-brand opacity-60'
|
|
522
522
|
: 'w-1.5 bg-white/10'
|
|
523
523
|
}`}
|
|
524
524
|
/>
|
|
@@ -538,7 +538,7 @@ export default function OnboardWizard({ onComplete }: Props) {
|
|
|
538
538
|
{/* ── Step 0: Welcome ── */}
|
|
539
539
|
{step === 0 && (
|
|
540
540
|
<div className="flex flex-col items-center text-center">
|
|
541
|
-
<
|
|
541
|
+
<video src="/fluxy_say_hi.webm" autoPlay loop muted playsInline className="h-[180px] mb-4" />
|
|
542
542
|
<h1 className="text-2xl font-bold text-white tracking-tight">
|
|
543
543
|
Welcome to Fluxy
|
|
544
544
|
</h1>
|
|
@@ -547,7 +547,7 @@ export default function OnboardWizard({ onComplete }: Props) {
|
|
|
547
547
|
</p>
|
|
548
548
|
<button
|
|
549
549
|
onClick={next}
|
|
550
|
-
className="mt-6 px-7 py-3 bg-
|
|
550
|
+
className="mt-6 px-7 py-3 bg-gradient-brand hover:opacity-90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center gap-2"
|
|
551
551
|
>
|
|
552
552
|
Get Started
|
|
553
553
|
<ArrowRight className="h-4 w-4" />
|
|
@@ -577,7 +577,7 @@ export default function OnboardWizard({ onComplete }: Props) {
|
|
|
577
577
|
<button
|
|
578
578
|
onClick={next}
|
|
579
579
|
disabled={!canNext}
|
|
580
|
-
className="shrink-0 h-12 w-12 flex items-center justify-center rounded-full bg-
|
|
580
|
+
className="shrink-0 h-12 w-12 flex items-center justify-center rounded-full bg-gradient-brand hover:opacity-90 text-white transition-colors disabled:opacity-30"
|
|
581
581
|
>
|
|
582
582
|
<ArrowRight className="h-5 w-5" />
|
|
583
583
|
</button>
|
|
@@ -608,7 +608,7 @@ export default function OnboardWizard({ onComplete }: Props) {
|
|
|
608
608
|
<div className="flex gap-2 mt-4">
|
|
609
609
|
<button
|
|
610
610
|
onClick={next}
|
|
611
|
-
className="flex-1 py-3 bg-
|
|
611
|
+
className="flex-1 py-3 bg-gradient-brand hover:opacity-90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2"
|
|
612
612
|
>
|
|
613
613
|
Continue
|
|
614
614
|
<ArrowRight className="h-4 w-4" />
|
|
@@ -659,7 +659,7 @@ export default function OnboardWizard({ onComplete }: Props) {
|
|
|
659
659
|
{handleStatus && botName.length > 0 && !registered && (
|
|
660
660
|
<div className="absolute right-4 top-1/2 -translate-y-1/2">
|
|
661
661
|
{handleStatus === 'checking' && (
|
|
662
|
-
<div className="w-5 h-5 border-2 border-white/10 border-t-
|
|
662
|
+
<div className="w-5 h-5 border-2 border-white/10 border-t-[#04D1FE] rounded-full animate-spin" />
|
|
663
663
|
)}
|
|
664
664
|
{handleStatus === 'invalid' && (
|
|
665
665
|
<div className="w-6 h-6 rounded-full bg-amber-500/15 flex items-center justify-center">
|
|
@@ -693,8 +693,8 @@ export default function OnboardWizard({ onComplete }: Props) {
|
|
|
693
693
|
? 'border-white/[0.04] bg-transparent opacity-50 cursor-not-allowed'
|
|
694
694
|
: selectedTier === h.tier
|
|
695
695
|
? h.highlight
|
|
696
|
-
? 'border-
|
|
697
|
-
: 'border-
|
|
696
|
+
? 'border-[#AF27E3]/40 bg-[#AF27E3]/[0.06]'
|
|
697
|
+
: 'border-[#AF27E3]/30 bg-white/[0.04]'
|
|
698
698
|
: 'border-white/[0.06] bg-transparent hover:border-white/10 hover:bg-white/[0.02]'
|
|
699
699
|
}`}
|
|
700
700
|
>
|
|
@@ -732,7 +732,7 @@ export default function OnboardWizard({ onComplete }: Props) {
|
|
|
732
732
|
<button
|
|
733
733
|
onClick={showChangeConfirm ? onChangeHandle : onClaimHandle}
|
|
734
734
|
disabled={registering || changingHandle}
|
|
735
|
-
className="w-full mt-4 py-3 bg-
|
|
735
|
+
className="w-full mt-4 py-3 bg-gradient-brand hover:opacity-90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2 disabled:opacity-40"
|
|
736
736
|
>
|
|
737
737
|
{(registering || changingHandle) ? (
|
|
738
738
|
<><LoaderCircle className="h-4 w-4 animate-spin" />{showChangeConfirm ? 'Changing...' : 'Claiming...'}</>
|
|
@@ -746,7 +746,7 @@ export default function OnboardWizard({ onComplete }: Props) {
|
|
|
746
746
|
{registered && (
|
|
747
747
|
<button
|
|
748
748
|
onClick={next}
|
|
749
|
-
className="w-full mt-4 py-3 bg-
|
|
749
|
+
className="w-full mt-4 py-3 bg-gradient-brand hover:opacity-90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2"
|
|
750
750
|
>
|
|
751
751
|
Continue
|
|
752
752
|
<ArrowRight className="h-4 w-4" />
|
|
@@ -930,7 +930,7 @@ export default function OnboardWizard({ onComplete }: Props) {
|
|
|
930
930
|
<button
|
|
931
931
|
onClick={next}
|
|
932
932
|
disabled={!canNext}
|
|
933
|
-
className="w-full mt-5 py-3 bg-
|
|
933
|
+
className="w-full mt-5 py-3 bg-gradient-brand hover:opacity-90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2 disabled:opacity-40"
|
|
934
934
|
>
|
|
935
935
|
Continue
|
|
936
936
|
<ArrowRight className="h-4 w-4" />
|
|
@@ -956,7 +956,7 @@ export default function OnboardWizard({ onComplete }: Props) {
|
|
|
956
956
|
onClick={() => handleProviderChange(p.id)}
|
|
957
957
|
className={`flex-1 relative rounded-xl border transition-all duration-200 p-3 text-left ${
|
|
958
958
|
provider === p.id
|
|
959
|
-
? 'bg-white/[0.04] border-
|
|
959
|
+
? 'bg-white/[0.04] border-[#AF27E3]/40'
|
|
960
960
|
: 'bg-transparent border-white/[0.06] hover:border-white/10 hover:bg-white/[0.02]'
|
|
961
961
|
}`}
|
|
962
962
|
>
|
|
@@ -978,7 +978,7 @@ export default function OnboardWizard({ onComplete }: Props) {
|
|
|
978
978
|
<Check className="h-2.5 w-2.5 text-emerald-400" />
|
|
979
979
|
</div>
|
|
980
980
|
) : provider === p.id ? (
|
|
981
|
-
<div className="absolute top-2 right-2 w-2 h-2 rounded-full bg-
|
|
981
|
+
<div className="absolute top-2 right-2 w-2 h-2 rounded-full bg-gradient-brand" />
|
|
982
982
|
) : null}
|
|
983
983
|
</button>
|
|
984
984
|
))}
|
|
@@ -1018,7 +1018,7 @@ export default function OnboardWizard({ onComplete }: Props) {
|
|
|
1018
1018
|
|
|
1019
1019
|
<button
|
|
1020
1020
|
onClick={handleAnthropicAuth}
|
|
1021
|
-
className="w-full py-2.5 px-4 bg-
|
|
1021
|
+
className="w-full py-2.5 px-4 bg-gradient-brand hover:opacity-90 text-white text-[13px] font-medium rounded-xl transition-colors flex items-center justify-center gap-2"
|
|
1022
1022
|
>
|
|
1023
1023
|
{oauthStarted ? (
|
|
1024
1024
|
<><ExternalLink className="h-3.5 w-3.5 opacity-60" />Open authentication page again</>
|
|
@@ -1047,7 +1047,7 @@ export default function OnboardWizard({ onComplete }: Props) {
|
|
|
1047
1047
|
<button
|
|
1048
1048
|
onClick={handleAnthropicConnect}
|
|
1049
1049
|
disabled={!anthropicCode.trim() || isExchanging}
|
|
1050
|
-
className="w-full py-2.5 px-4 bg-
|
|
1050
|
+
className="w-full py-2.5 px-4 bg-gradient-brand hover:opacity-90 text-white text-[13px] font-medium rounded-xl transition-colors flex items-center justify-center gap-2 disabled:opacity-40"
|
|
1051
1051
|
>
|
|
1052
1052
|
{isExchanging ? (<><LoaderCircle className="h-3.5 w-3.5 animate-spin" />Verifying...</>) : 'Connect'}
|
|
1053
1053
|
</button>
|
|
@@ -1164,7 +1164,7 @@ export default function OnboardWizard({ onComplete }: Props) {
|
|
|
1164
1164
|
<button
|
|
1165
1165
|
onClick={next}
|
|
1166
1166
|
disabled={!canNext}
|
|
1167
|
-
className="w-full mt-4 py-3 bg-
|
|
1167
|
+
className="w-full mt-4 py-3 bg-gradient-brand hover:opacity-90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2 disabled:opacity-40"
|
|
1168
1168
|
>
|
|
1169
1169
|
Continue
|
|
1170
1170
|
<ArrowRight className="h-4 w-4" />
|
|
@@ -1192,7 +1192,7 @@ export default function OnboardWizard({ onComplete }: Props) {
|
|
|
1192
1192
|
onClick={() => setWhisperEnabled((v) => !v)}
|
|
1193
1193
|
className={`w-full mt-5 rounded-xl border transition-all duration-200 p-4 text-left ${
|
|
1194
1194
|
whisperEnabled
|
|
1195
|
-
? 'bg-white/[0.04] border-
|
|
1195
|
+
? 'bg-white/[0.04] border-[#AF27E3]/40'
|
|
1196
1196
|
: 'bg-transparent border-white/[0.06] hover:border-white/10 hover:bg-white/[0.02]'
|
|
1197
1197
|
}`}
|
|
1198
1198
|
>
|
|
@@ -1205,7 +1205,7 @@ export default function OnboardWizard({ onComplete }: Props) {
|
|
|
1205
1205
|
</div>
|
|
1206
1206
|
</div>
|
|
1207
1207
|
<div className={`w-10 h-[22px] rounded-full transition-colors duration-200 flex items-center px-0.5 shrink-0 ${
|
|
1208
|
-
whisperEnabled ? 'bg-
|
|
1208
|
+
whisperEnabled ? 'bg-gradient-brand' : 'bg-white/[0.08]'
|
|
1209
1209
|
}`}>
|
|
1210
1210
|
<div className={`w-[18px] h-[18px] rounded-full bg-white shadow-sm transition-transform duration-200 ${
|
|
1211
1211
|
whisperEnabled ? 'translate-x-[18px]' : 'translate-x-0'
|
|
@@ -1232,7 +1232,7 @@ export default function OnboardWizard({ onComplete }: Props) {
|
|
|
1232
1232
|
<p className="text-amber-400/70 text-[11px] mt-1">Key looks too short</p>
|
|
1233
1233
|
)}
|
|
1234
1234
|
<div className="flex items-start gap-2.5 mt-3 bg-white/[0.02] border border-white/[0.06] rounded-xl px-4 py-3">
|
|
1235
|
-
<Mic className="h-4 w-4 text-
|
|
1235
|
+
<Mic className="h-4 w-4 text-[#AF27E3]/60 mt-0.5 shrink-0" />
|
|
1236
1236
|
<p className="text-white/35 text-[12px] leading-relaxed">
|
|
1237
1237
|
Users will see a microphone button in the chat. Audio is sent to OpenAI's Whisper API for transcription, then processed as a regular text message.
|
|
1238
1238
|
</p>
|
|
@@ -1243,7 +1243,7 @@ export default function OnboardWizard({ onComplete }: Props) {
|
|
|
1243
1243
|
<button
|
|
1244
1244
|
onClick={handleComplete}
|
|
1245
1245
|
disabled={saving || (whisperEnabled && (!whisperKey.startsWith('sk-') || whisperKey.length < 20))}
|
|
1246
|
-
className="w-full mt-5 py-3 bg-
|
|
1246
|
+
className="w-full mt-5 py-3 bg-gradient-brand hover:opacity-90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2 disabled:opacity-40"
|
|
1247
1247
|
>
|
|
1248
1248
|
{saving ? (
|
|
1249
1249
|
<><LoaderCircle className="h-4 w-4 animate-spin" />Setting up...</>
|
|
@@ -8,14 +8,12 @@ export function useWebSocket() {
|
|
|
8
8
|
useEffect(() => {
|
|
9
9
|
const client = new WsClient();
|
|
10
10
|
clientRef.current = client;
|
|
11
|
-
client.connect();
|
|
12
11
|
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
}, 1000);
|
|
12
|
+
const unsub = client.onStatus(setConnected);
|
|
13
|
+
client.connect();
|
|
16
14
|
|
|
17
15
|
return () => {
|
|
18
|
-
|
|
16
|
+
unsub();
|
|
19
17
|
client.disconnect();
|
|
20
18
|
};
|
|
21
19
|
}, []);
|
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
type MessageHandler = (msg: any) => void;
|
|
2
|
+
type StatusHandler = (connected: boolean) => void;
|
|
3
|
+
|
|
4
|
+
interface QueuedMessage {
|
|
5
|
+
type: string;
|
|
6
|
+
data: any;
|
|
7
|
+
}
|
|
2
8
|
|
|
3
9
|
export class WsClient {
|
|
4
10
|
private ws: WebSocket | null = null;
|
|
5
11
|
private handlers = new Map<string, Set<MessageHandler>>();
|
|
12
|
+
private statusHandlers = new Set<StatusHandler>();
|
|
6
13
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
14
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
7
15
|
private url: string;
|
|
16
|
+
private queue: QueuedMessage[] = [];
|
|
17
|
+
private intentionalClose = false;
|
|
18
|
+
private reconnectDelay = 1000;
|
|
19
|
+
private static MAX_RECONNECT_DELAY = 8000;
|
|
8
20
|
|
|
9
21
|
constructor(url?: string) {
|
|
10
22
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
@@ -13,23 +25,42 @@ export class WsClient {
|
|
|
13
25
|
}
|
|
14
26
|
|
|
15
27
|
connect(): void {
|
|
28
|
+
this.intentionalClose = false;
|
|
16
29
|
this.ws = new WebSocket(this.url);
|
|
17
30
|
|
|
31
|
+
this.ws.onopen = () => {
|
|
32
|
+
this.reconnectDelay = 1000;
|
|
33
|
+
this.notifyStatus(true);
|
|
34
|
+
this.flushQueue();
|
|
35
|
+
this.startHeartbeat();
|
|
36
|
+
};
|
|
37
|
+
|
|
18
38
|
this.ws.onmessage = (e) => {
|
|
39
|
+
// Ignore pong frames
|
|
40
|
+
if (e.data === 'pong') return;
|
|
19
41
|
const msg = JSON.parse(e.data);
|
|
20
42
|
const handlers = this.handlers.get(msg.type);
|
|
21
43
|
handlers?.forEach((h) => h(msg.data));
|
|
22
44
|
};
|
|
23
45
|
|
|
24
46
|
this.ws.onclose = () => {
|
|
25
|
-
this.
|
|
47
|
+
this.stopHeartbeat();
|
|
48
|
+
this.notifyStatus(false);
|
|
49
|
+
if (!this.intentionalClose) {
|
|
50
|
+
this.reconnectTimer = setTimeout(() => {
|
|
51
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, WsClient.MAX_RECONNECT_DELAY);
|
|
52
|
+
this.connect();
|
|
53
|
+
}, this.reconnectDelay);
|
|
54
|
+
}
|
|
26
55
|
};
|
|
27
56
|
|
|
28
57
|
this.ws.onerror = () => this.ws?.close();
|
|
29
58
|
}
|
|
30
59
|
|
|
31
60
|
disconnect(): void {
|
|
32
|
-
|
|
61
|
+
this.intentionalClose = true;
|
|
62
|
+
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
|
|
63
|
+
this.stopHeartbeat();
|
|
33
64
|
this.ws?.close();
|
|
34
65
|
this.ws = null;
|
|
35
66
|
}
|
|
@@ -40,13 +71,46 @@ export class WsClient {
|
|
|
40
71
|
return () => this.handlers.get(type)?.delete(handler);
|
|
41
72
|
}
|
|
42
73
|
|
|
74
|
+
onStatus(handler: StatusHandler): () => void {
|
|
75
|
+
this.statusHandlers.add(handler);
|
|
76
|
+
return () => this.statusHandlers.delete(handler);
|
|
77
|
+
}
|
|
78
|
+
|
|
43
79
|
send(type: string, data: any): void {
|
|
80
|
+
const message = { type, data };
|
|
44
81
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
45
|
-
this.ws.send(JSON.stringify(
|
|
82
|
+
this.ws.send(JSON.stringify(message));
|
|
83
|
+
} else {
|
|
84
|
+
// Queue for delivery on reconnect
|
|
85
|
+
this.queue.push(message);
|
|
46
86
|
}
|
|
47
87
|
}
|
|
48
88
|
|
|
49
89
|
get connected(): boolean {
|
|
50
90
|
return this.ws?.readyState === WebSocket.OPEN;
|
|
51
91
|
}
|
|
92
|
+
|
|
93
|
+
private flushQueue(): void {
|
|
94
|
+
while (this.queue.length > 0 && this.ws?.readyState === WebSocket.OPEN) {
|
|
95
|
+
const msg = this.queue.shift()!;
|
|
96
|
+
this.ws.send(JSON.stringify(msg));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private notifyStatus(connected: boolean): void {
|
|
101
|
+
this.statusHandlers.forEach((h) => h(connected));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private startHeartbeat(): void {
|
|
105
|
+
this.stopHeartbeat();
|
|
106
|
+
this.heartbeatTimer = setInterval(() => {
|
|
107
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
108
|
+
this.ws.send('ping');
|
|
109
|
+
}
|
|
110
|
+
}, 25_000);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private stopHeartbeat(): void {
|
|
114
|
+
if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; }
|
|
115
|
+
}
|
|
52
116
|
}
|
|
@@ -46,10 +46,65 @@ body {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
::selection {
|
|
49
|
-
background-color: rgba(
|
|
49
|
+
background-color: rgba(175, 39, 227, 0.25);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
::-webkit-scrollbar { width: 6px; }
|
|
53
53
|
::-webkit-scrollbar-track { background: transparent; }
|
|
54
54
|
::-webkit-scrollbar-thumb { background: #3a3a3a; border-radius: 3px; }
|
|
55
55
|
::-webkit-scrollbar-thumb:hover { background: #4a4a4a; }
|
|
56
|
+
|
|
57
|
+
.text-gradient {
|
|
58
|
+
background-clip: text;
|
|
59
|
+
-webkit-background-clip: text;
|
|
60
|
+
color: transparent;
|
|
61
|
+
-webkit-text-fill-color: transparent;
|
|
62
|
+
background-image: linear-gradient(135deg, #04D1FE, #AF27E3, #FB4072);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.bg-gradient-brand {
|
|
66
|
+
background-image: linear-gradient(135deg, #04D1FE, #AF27E3, #FB4072);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.glow-border {
|
|
70
|
+
box-shadow: 0 0 0 1px rgba(175, 39, 227, 0.1),
|
|
71
|
+
0 0 20px -5px rgba(175, 39, 227, 0.15);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.animated-border {
|
|
75
|
+
position: relative;
|
|
76
|
+
overflow: hidden;
|
|
77
|
+
}
|
|
78
|
+
.animated-border::before {
|
|
79
|
+
content: '';
|
|
80
|
+
position: absolute;
|
|
81
|
+
inset: -150%;
|
|
82
|
+
background: conic-gradient(
|
|
83
|
+
from 0deg,
|
|
84
|
+
#04D1FE,
|
|
85
|
+
#AF27E3,
|
|
86
|
+
#FB4072,
|
|
87
|
+
#04D1FE
|
|
88
|
+
);
|
|
89
|
+
animation: border-spin 3s linear infinite;
|
|
90
|
+
}
|
|
91
|
+
.animated-border > * {
|
|
92
|
+
position: relative;
|
|
93
|
+
z-index: 1;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.animated-border-slow::before {
|
|
97
|
+
animation-duration: 5s;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.input-glow:focus {
|
|
101
|
+
border-color: rgba(175, 39, 227, 0.4);
|
|
102
|
+
box-shadow: 0 0 0 1px rgba(175, 39, 227, 0.15),
|
|
103
|
+
0 0 20px -5px rgba(175, 39, 227, 0.25),
|
|
104
|
+
0 0 4px -1px rgba(4, 209, 254, 0.1);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
@keyframes border-spin {
|
|
108
|
+
0% { transform: rotate(0deg); }
|
|
109
|
+
100% { transform: rotate(360deg); }
|
|
110
|
+
}
|