mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
vippfp
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,7 +11,7 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
|
||||
<title>Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-CZZyVOuh.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-CfrFiCzL.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CR1Y7EEc.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -11,6 +11,7 @@ import VipIdentityCard from '../vip/VipIdentityCard.jsx';
|
||||
import VipPrivateRoverAccessCard from '../vip/VipPrivateRoverAccessCard.jsx';
|
||||
import VipNeatoCard from '../vip/VipNeatoCard.jsx';
|
||||
import VipLiftCard from '../vip/VipLiftCard.jsx';
|
||||
import VipProfileImageCard from '../vip/VipProfileImageCard.jsx';
|
||||
|
||||
export default function VipPanel({ isActive = true }) {
|
||||
const session = useSessionSelector((state) => state.session);
|
||||
@@ -131,6 +132,14 @@ export default function VipPanel({ isActive = true }) {
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<VipProfileImageCard
|
||||
isVerified={isVerified}
|
||||
onMessage={setMessage}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message ? (
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// Vip Profile Image Card
|
||||
// Purpose: Lets verified VIP users persist a profile image URL used for chat message avatars.
|
||||
// Scope: Owns local URL validation UX and writes to the existing profile settings namespace.
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { fieldClass, flowWrapClass, innerFlowClass } from './constants.js';
|
||||
|
||||
function normalizeHttpUrl(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) return '';
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
const protocol = String(parsed.protocol || '').toLowerCase();
|
||||
if (protocol !== 'http:' && protocol !== 'https:') return '';
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export default function VipProfileImageCard({ isVerified = false, fullWidth = false, onMessage }) {
|
||||
const { value: profile, save: saveProfile } = useSettingsNamespace('profile', {
|
||||
nickname: '',
|
||||
profileImageUrl: '',
|
||||
});
|
||||
const stored = String(profile?.profileImageUrl || '').trim();
|
||||
const [input, setInput] = useState(stored);
|
||||
const [working, setWorking] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setInput(stored);
|
||||
}, [stored]);
|
||||
|
||||
const normalizedInput = useMemo(() => normalizeHttpUrl(input), [input]);
|
||||
const hasUnsaved = String(input || '').trim() !== stored;
|
||||
const canSave = Boolean(isVerified && hasUnsaved && (normalizedInput || !String(input || '').trim()));
|
||||
const wrapClass = fullWidth ? 'w-full' : flowWrapClass;
|
||||
|
||||
const handleSave = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!isVerified) {
|
||||
onMessage?.('Verify your account before setting a profile image URL.');
|
||||
return;
|
||||
}
|
||||
setWorking(true);
|
||||
onMessage?.('');
|
||||
try {
|
||||
const next = normalizedInput || '';
|
||||
saveProfile((current) => ({ ...(current || {}), profileImageUrl: next }));
|
||||
onMessage?.(next ? 'Profile image URL saved.' : 'Profile image URL cleared.');
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={`surface text-sm text-slate-300 ${wrapClass}`}>
|
||||
<form className={innerFlowClass} onSubmit={handleSave}>
|
||||
<p className="text-sm text-slate-300">Chat profile image URL</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
Verified users can set a custom avatar for chat and Discord bridge messages.
|
||||
</p>
|
||||
<input
|
||||
className={fieldClass}
|
||||
type="url"
|
||||
name="vip_profile_image_url"
|
||||
placeholder="https://example.com/avatar.png"
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
disabled={!isVerified || working}
|
||||
/>
|
||||
{!normalizedInput && String(input || '').trim() ? (
|
||||
<p className="text-xs text-amber-300">Enter a valid http/https image URL.</p>
|
||||
) : null}
|
||||
<div className="flex justify-center gap-0.5">
|
||||
<button type="submit" className="button-dark text-sm disabled:opacity-50" disabled={!canSave || working}>
|
||||
{working ? 'Saving...' : 'Save URL'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button-dark text-sm disabled:opacity-50"
|
||||
disabled={!isVerified || working || !stored}
|
||||
onClick={() => setInput('')}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,7 @@ export function ChatProvider({ children }) {
|
||||
const session = useSessionSelector((state) => state.session);
|
||||
const { pushAlert } = useSessionActions();
|
||||
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
|
||||
const { value: profileSettings } = useSettingsNamespace('profile', { nickname: '', profileImageUrl: '' });
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [typing, setTyping] = useState([]);
|
||||
const [isChatFocused, setIsChatFocused] = useState(false);
|
||||
@@ -39,6 +40,8 @@ export function ChatProvider({ children }) {
|
||||
const masterVolume = Number.isFinite(audioSettings?.masterVolume) ? audioSettings.masterVolume : AUDIO_SETTINGS_DEFAULTS.masterVolume;
|
||||
const alertVolume = Number.isFinite(audioSettings?.alertVolume) ? audioSettings.alertVolume : AUDIO_SETTINGS_DEFAULTS.alertVolume;
|
||||
const effectiveAlertVolume = Math.max(0, Math.min(1, masterVolume * alertVolume));
|
||||
const isVerified = Boolean(session?.isVerified);
|
||||
const profileImage = isVerified ? String(profileSettings?.profileImageUrl || '').trim() : '';
|
||||
|
||||
const rebuildTyping = useCallback(() => {
|
||||
const entries = Array.from(typingRef.current.values())
|
||||
@@ -182,7 +185,9 @@ export function ChatProvider({ children }) {
|
||||
const sendMessage = useCallback(
|
||||
(text, tts = null) =>
|
||||
new Promise((resolve, reject) => {
|
||||
socket.emit('chat:send', { text, tts }, (resp = {}) => {
|
||||
const payload = { text, tts };
|
||||
if (profileImage) payload.profileImage = profileImage;
|
||||
socket.emit('chat:send', payload, (resp = {}) => {
|
||||
if (resp.error) {
|
||||
reject(new Error(resp.error));
|
||||
} else {
|
||||
@@ -190,7 +195,7 @@ export function ChatProvider({ children }) {
|
||||
}
|
||||
});
|
||||
}),
|
||||
[socket],
|
||||
[profileImage, socket],
|
||||
);
|
||||
|
||||
const registerInputRef = useCallback((el, options = {}) => {
|
||||
|
||||
Reference in New Issue
Block a user