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-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
|
||||||
<title>Roomba Rover</title>
|
<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">
|
<link rel="stylesheet" crossorigin href="/assets/index-CR1Y7EEc.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import VipIdentityCard from '../vip/VipIdentityCard.jsx';
|
|||||||
import VipPrivateRoverAccessCard from '../vip/VipPrivateRoverAccessCard.jsx';
|
import VipPrivateRoverAccessCard from '../vip/VipPrivateRoverAccessCard.jsx';
|
||||||
import VipNeatoCard from '../vip/VipNeatoCard.jsx';
|
import VipNeatoCard from '../vip/VipNeatoCard.jsx';
|
||||||
import VipLiftCard from '../vip/VipLiftCard.jsx';
|
import VipLiftCard from '../vip/VipLiftCard.jsx';
|
||||||
|
import VipProfileImageCard from '../vip/VipProfileImageCard.jsx';
|
||||||
|
|
||||||
export default function VipPanel({ isActive = true }) {
|
export default function VipPanel({ isActive = true }) {
|
||||||
const session = useSessionSelector((state) => state.session);
|
const session = useSessionSelector((state) => state.session);
|
||||||
@@ -131,6 +132,14 @@ export default function VipPanel({ isActive = true }) {
|
|||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<VipProfileImageCard
|
||||||
|
isVerified={isVerified}
|
||||||
|
onMessage={setMessage}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{message ? (
|
{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 session = useSessionSelector((state) => state.session);
|
||||||
const { pushAlert } = useSessionActions();
|
const { pushAlert } = useSessionActions();
|
||||||
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
|
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
|
||||||
|
const { value: profileSettings } = useSettingsNamespace('profile', { nickname: '', profileImageUrl: '' });
|
||||||
const [messages, setMessages] = useState([]);
|
const [messages, setMessages] = useState([]);
|
||||||
const [typing, setTyping] = useState([]);
|
const [typing, setTyping] = useState([]);
|
||||||
const [isChatFocused, setIsChatFocused] = useState(false);
|
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 masterVolume = Number.isFinite(audioSettings?.masterVolume) ? audioSettings.masterVolume : AUDIO_SETTINGS_DEFAULTS.masterVolume;
|
||||||
const alertVolume = Number.isFinite(audioSettings?.alertVolume) ? audioSettings.alertVolume : AUDIO_SETTINGS_DEFAULTS.alertVolume;
|
const alertVolume = Number.isFinite(audioSettings?.alertVolume) ? audioSettings.alertVolume : AUDIO_SETTINGS_DEFAULTS.alertVolume;
|
||||||
const effectiveAlertVolume = Math.max(0, Math.min(1, masterVolume * 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 rebuildTyping = useCallback(() => {
|
||||||
const entries = Array.from(typingRef.current.values())
|
const entries = Array.from(typingRef.current.values())
|
||||||
@@ -182,7 +185,9 @@ export function ChatProvider({ children }) {
|
|||||||
const sendMessage = useCallback(
|
const sendMessage = useCallback(
|
||||||
(text, tts = null) =>
|
(text, tts = null) =>
|
||||||
new Promise((resolve, reject) => {
|
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) {
|
if (resp.error) {
|
||||||
reject(new Error(resp.error));
|
reject(new Error(resp.error));
|
||||||
} else {
|
} else {
|
||||||
@@ -190,7 +195,7 @@ export function ChatProvider({ children }) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
[socket],
|
[profileImage, socket],
|
||||||
);
|
);
|
||||||
|
|
||||||
const registerInputRef = useCallback((el, options = {}) => {
|
const registerInputRef = useCallback((el, options = {}) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user