now frontend can change voice type
This commit is contained in:
@@ -12,7 +12,8 @@ import {
|
|||||||
} from "@livekit/components-react";
|
} from "@livekit/components-react";
|
||||||
import { ConnectionState, Track, LocalParticipant, Room } from "livekit-client";
|
import { ConnectionState, Track, LocalParticipant, Room } from "livekit-client";
|
||||||
import { useEffect, useMemo, useState, useRef } from "react";
|
import { useEffect, useMemo, useState, useRef } from "react";
|
||||||
import { BatteryIcon, ImageIcon, MicIcon, MicOffIcon, PhoneIcon, PhoneOffIcon, WifiIcon, SwitchCameraIcon } from "./icons";
|
import { BatteryIcon, ImageIcon, MicIcon, MicOffIcon, PhoneIcon, PhoneOffIcon, WifiIcon, SwitchCameraIcon, VoiceIcon, CheckIcon } from "./icons";
|
||||||
|
import { useToast } from "@/components/toast/ToasterProvider";
|
||||||
|
|
||||||
export interface PhoneSimulatorProps {
|
export interface PhoneSimulatorProps {
|
||||||
onConnect: () => void;
|
onConnect: () => void;
|
||||||
@@ -21,7 +22,8 @@ export interface PhoneSimulatorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PhoneSimulator({ onConnect, phoneMode = "normal", onCapture }: PhoneSimulatorProps) {
|
export function PhoneSimulator({ onConnect, phoneMode = "normal", onCapture }: PhoneSimulatorProps) {
|
||||||
const { config } = useConfig();
|
const { config, setUserSettings } = useConfig();
|
||||||
|
const { setToastMessage } = useToast();
|
||||||
const room = useRoomContext();
|
const room = useRoomContext();
|
||||||
const roomState = useConnectionState();
|
const roomState = useConnectionState();
|
||||||
const { localParticipant } = useLocalParticipant();
|
const { localParticipant } = useLocalParticipant();
|
||||||
@@ -31,12 +33,22 @@ export function PhoneSimulator({ onConnect, phoneMode = "normal", onCapture }: P
|
|||||||
const phoneContainerRef = useRef<HTMLDivElement>(null);
|
const phoneContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const visualizerRef = useRef<HTMLDivElement>(null);
|
const visualizerRef = useRef<HTMLDivElement>(null);
|
||||||
const [showCameraMenu, setShowCameraMenu] = useState(false);
|
const [showCameraMenu, setShowCameraMenu] = useState(false);
|
||||||
|
const [showVoiceMenu, setShowVoiceMenu] = useState(false);
|
||||||
const [cameras, setCameras] = useState<MediaDeviceInfo[]>([]);
|
const [cameras, setCameras] = useState<MediaDeviceInfo[]>([]);
|
||||||
const [processingImage, setProcessingImage] = useState<string | null>(null);
|
const [processingImage, setProcessingImage] = useState<string | null>(null);
|
||||||
|
const [currentVoiceId, setCurrentVoiceId] = useState<string>("BV001_streaming"); // Default voice ID
|
||||||
const [isCapturing, setIsCapturing] = useState(false);
|
const [isCapturing, setIsCapturing] = useState(false);
|
||||||
const [processingSource, setProcessingSource] = useState<
|
const [processingSource, setProcessingSource] = useState<
|
||||||
"camera" | "upload" | null
|
"camera" | "upload" | null
|
||||||
>(null);
|
>(null);
|
||||||
|
const [lastVoiceChangeAt, setLastVoiceChangeAt] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const voiceAttr = config.settings.attributes?.find(a => a.key === "voice");
|
||||||
|
if (voiceAttr) {
|
||||||
|
setCurrentVoiceId(voiceAttr.value);
|
||||||
|
}
|
||||||
|
}, [config.settings.attributes]);
|
||||||
|
|
||||||
const [currentTime, setCurrentTime] = useState("");
|
const [currentTime, setCurrentTime] = useState("");
|
||||||
|
|
||||||
@@ -104,14 +116,17 @@ export function PhoneSimulator({ onConnect, phoneMode = "normal", onCapture }: P
|
|||||||
if (showCameraMenu) {
|
if (showCameraMenu) {
|
||||||
setShowCameraMenu(false);
|
setShowCameraMenu(false);
|
||||||
}
|
}
|
||||||
|
if (showVoiceMenu) {
|
||||||
|
setShowVoiceMenu(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
if (showCameraMenu) {
|
if (showCameraMenu || showVoiceMenu) {
|
||||||
document.addEventListener("click", handleClickOutside);
|
document.addEventListener("click", handleClickOutside);
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("click", handleClickOutside);
|
document.removeEventListener("click", handleClickOutside);
|
||||||
};
|
};
|
||||||
}, [showCameraMenu]);
|
}, [showCameraMenu, showVoiceMenu]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (voiceAssistant.state === "speaking") {
|
if (voiceAssistant.state === "speaking") {
|
||||||
@@ -299,6 +314,29 @@ export function PhoneSimulator({ onConnect, phoneMode = "normal", onCapture }: P
|
|||||||
setShowCameraMenu(false);
|
setShowCameraMenu(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleChangeVoice = (voiceId: string) => {
|
||||||
|
const newSettings = { ...config.settings };
|
||||||
|
const attributes = newSettings.attributes ? [...newSettings.attributes] : [];
|
||||||
|
const voiceAttrIndex = attributes.findIndex(a => a.key === "voice");
|
||||||
|
|
||||||
|
if (voiceAttrIndex >= 0) {
|
||||||
|
attributes[voiceAttrIndex] = { ...attributes[voiceAttrIndex], value: voiceId };
|
||||||
|
} else {
|
||||||
|
attributes.push({ id: "voice", key: "voice", value: voiceId });
|
||||||
|
}
|
||||||
|
|
||||||
|
newSettings.attributes = attributes;
|
||||||
|
setUserSettings(newSettings);
|
||||||
|
setCurrentVoiceId(voiceId);
|
||||||
|
setLastVoiceChangeAt(Date.now());
|
||||||
|
setTimeout(() => setShowVoiceMenu(false), 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVoiceMenuToggle = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowVoiceMenu(!showVoiceMenu);
|
||||||
|
};
|
||||||
|
|
||||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
if (file && onCapture) {
|
if (file && onCapture) {
|
||||||
@@ -312,22 +350,84 @@ export function PhoneSimulator({ onConnect, phoneMode = "normal", onCapture }: P
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const videoContent = useMemo(() => {
|
const videoContent = (() => {
|
||||||
if (roomState === ConnectionState.Disconnected) {
|
if (roomState === ConnectionState.Disconnected) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full w-full bg-gray-900 text-gray-500 text-sm p-4 text-center">
|
<div className="flex items-center justify-center h-full w-full bg-gray-900 text-gray-500 text-sm p-4 text-center">
|
||||||
<button
|
<div className="flex flex-col items-center gap-6">
|
||||||
onClick={onConnect}
|
<button
|
||||||
className="flex flex-col items-center gap-4 hover:opacity-80 transition-opacity"
|
onClick={(e) => {
|
||||||
>
|
e.stopPropagation();
|
||||||
<div
|
// Guard against accidental call when just changing voice
|
||||||
className="w-16 h-16 rounded-full flex items-center justify-center text-white"
|
if (showVoiceMenu) return;
|
||||||
style={{ backgroundColor: `var(--lk-theme-color)` }}
|
if (lastVoiceChangeAt && Date.now() - lastVoiceChangeAt < 400) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onConnect();
|
||||||
|
}}
|
||||||
|
disabled={showVoiceMenu}
|
||||||
|
className={`flex flex-col items-center gap-4 transition-opacity ${showVoiceMenu ? 'opacity-50 cursor-not-allowed' : 'hover:opacity-80 cursor-pointer'}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-16 h-16 rounded-full flex items-center justify-center text-white"
|
||||||
|
style={{ backgroundColor: `var(--lk-theme-color)` }}
|
||||||
|
>
|
||||||
|
<PhoneIcon className="w-8 h-8" />
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-white">Call Agent</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={handleVoiceMenuToggle}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-full bg-gray-800 text-white hover:bg-gray-700 transition-colors text-xs"
|
||||||
>
|
>
|
||||||
<PhoneIcon className="w-8 h-8" />
|
<VoiceIcon className="w-3 h-3" />
|
||||||
</div>
|
<span>
|
||||||
<span className="font-medium text-white">Call Agent</span>
|
{currentVoiceId === "BV001_streaming" ? "Female Voice" : "Male Voice"}
|
||||||
</button>
|
</span>
|
||||||
|
</button>
|
||||||
|
{showVoiceMenu && (
|
||||||
|
<div
|
||||||
|
className="absolute top-full mt-2 left-1/2 -translate-x-1/2 bg-gray-800 border border-gray-700 rounded-lg shadow-xl py-1 w-40 z-50"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleChangeVoice("BV001_streaming");
|
||||||
|
}}
|
||||||
|
className={`w-full text-left px-4 py-2 text-xs hover:bg-gray-700 transition-colors flex items-center justify-between ${
|
||||||
|
currentVoiceId === "BV001_streaming"
|
||||||
|
? "text-blue-400 font-bold"
|
||||||
|
: "text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>Female Voice</span>
|
||||||
|
{currentVoiceId === "BV001_streaming" && <CheckIcon />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleChangeVoice("BV002_streaming");
|
||||||
|
}}
|
||||||
|
className={`w-full text-left px-4 py-2 text-xs hover:bg-gray-700 transition-colors flex items-center justify-between ${
|
||||||
|
currentVoiceId === "BV002_streaming"
|
||||||
|
? "text-blue-400 font-bold"
|
||||||
|
: "text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>Male Voice</span>
|
||||||
|
{currentVoiceId === "BV002_streaming" && (
|
||||||
|
<CheckIcon />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -348,7 +448,7 @@ export function PhoneSimulator({ onConnect, phoneMode = "normal", onCapture }: P
|
|||||||
className="w-full h-full object-cover mirror-video"
|
className="w-full h-full object-cover mirror-video"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}, [roomState, localCameraTrack, onConnect]);
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-auto max-w-full h-full aspect-[9/19.5] max-h-full bg-black rounded-[40px] border-[12px] border-gray-900 overflow-hidden relative shadow-2xl flex flex-col shrink-0">
|
<div className="w-auto max-w-full h-full aspect-[9/19.5] max-h-full bg-black rounded-[40px] border-[12px] border-gray-900 overflow-hidden relative shadow-2xl flex flex-col shrink-0">
|
||||||
@@ -449,55 +549,87 @@ export function PhoneSimulator({ onConnect, phoneMode = "normal", onCapture }: P
|
|||||||
|
|
||||||
{/* Call Controls Overlay */}
|
{/* Call Controls Overlay */}
|
||||||
{roomState === ConnectionState.Connected && (
|
{roomState === ConnectionState.Connected && (
|
||||||
<div className="absolute bottom-8 left-0 w-full px-8 z-20">
|
<div className="absolute bottom-[5%] left-0 w-full px-[8%] z-20">
|
||||||
{phoneMode === "capture" ? (
|
{phoneMode === "capture" ? (
|
||||||
<div className="w-full grid grid-cols-3 items-center">
|
<div className="absolute top-0 left-0 w-full h-full flex flex-col justify-end pb-[5%] px-[8%] z-20">
|
||||||
<div className="flex justify-start">
|
{/* Camera Controls Row */}
|
||||||
<button
|
<div className="w-full flex items-center justify-evenly mb-8">
|
||||||
className="p-4 rounded-full bg-gray-800/50 text-white hover:bg-gray-800/70 transition-colors shrink-0"
|
{/* Left: Upload */}
|
||||||
onClick={handleUpload}
|
<button
|
||||||
>
|
className="p-3 rounded-full bg-gray-800/50 text-white hover:bg-gray-800/70 transition-colors"
|
||||||
<ImageIcon className="w-6 h-6" />
|
onClick={handleUpload}
|
||||||
</button>
|
>
|
||||||
</div>
|
<ImageIcon className="w-6 h-6" />
|
||||||
<div className="flex justify-center">
|
</button>
|
||||||
<button
|
|
||||||
className="w-20 h-20 rounded-full border-4 border-white p-1 hover:scale-105 transition-transform shrink-0 aspect-square"
|
{/* Center: Capture */}
|
||||||
onClick={handleCapture}
|
<button
|
||||||
>
|
className="w-16 h-16 rounded-full border-4 border-white p-1 hover:scale-105 transition-transform shrink-0"
|
||||||
<div className="w-full h-full bg-white rounded-full"></div>
|
onClick={handleCapture}
|
||||||
</button>
|
>
|
||||||
</div>
|
<div className="w-full h-full bg-white rounded-full"></div>
|
||||||
<div className="flex justify-end relative">
|
</button>
|
||||||
<button
|
|
||||||
className="p-4 rounded-full bg-gray-800/50 text-white hover:bg-gray-800/70 transition-colors shrink-0"
|
{/* Right: Switch Camera */}
|
||||||
onClick={handleSwitchCamera}
|
<div className="relative">
|
||||||
>
|
<button
|
||||||
<SwitchCameraIcon className="w-6 h-6" />
|
className="p-3 rounded-full bg-gray-800/50 text-white hover:bg-gray-800/70 transition-colors"
|
||||||
</button>
|
onClick={handleSwitchCamera}
|
||||||
{showCameraMenu && (
|
>
|
||||||
<div className="absolute bottom-16 right-0 bg-gray-900 border border-gray-800 rounded-lg shadow-xl py-2 w-48 z-50">
|
<SwitchCameraIcon className="w-6 h-6" />
|
||||||
{cameras.length === 0 ? (
|
</button>
|
||||||
<div className="px-4 py-2 text-gray-500 text-sm">
|
{showCameraMenu && (
|
||||||
No cameras found
|
<div className="absolute bottom-full mb-2 right-0 bg-gray-900 border border-gray-800 rounded-lg shadow-xl py-2 w-48 z-50">
|
||||||
|
{cameras.length === 0 ? (
|
||||||
|
<div className="px-4 py-2 text-gray-500 text-sm">
|
||||||
|
No cameras found
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
cameras.map((device) => (
|
||||||
|
<button
|
||||||
|
key={device.deviceId}
|
||||||
|
onClick={() => handleSelectCamera(device.deviceId)}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm text-white hover:bg-gray-800 transition-colors truncate"
|
||||||
|
>
|
||||||
|
{device.label ||
|
||||||
|
`Camera ${cameras.indexOf(device) + 1}`}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
cameras.map((device) => (
|
|
||||||
<button
|
|
||||||
key={device.deviceId}
|
|
||||||
onClick={() => handleSelectCamera(device.deviceId)}
|
|
||||||
className="w-full text-left px-4 py-2 text-sm text-white hover:bg-gray-800 transition-colors truncate"
|
|
||||||
>
|
|
||||||
{device.label ||
|
|
||||||
`Camera ${cameras.indexOf(device) + 1}`}
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
|
{/* Call Controls Row */}
|
||||||
|
<div className="w-full flex items-center justify-center gap-8">
|
||||||
|
{/* Mic Toggle */}
|
||||||
|
<button
|
||||||
|
className={`p-4 rounded-full backdrop-blur-md transition-colors ${
|
||||||
|
!isMicEnabled
|
||||||
|
? "bg-white text-black"
|
||||||
|
: "bg-gray-600/50 text-white hover:bg-gray-600/70"
|
||||||
|
}`}
|
||||||
|
onClick={handleMicToggle}
|
||||||
|
>
|
||||||
|
{isMicEnabled ? (
|
||||||
|
<MicIcon className="w-6 h-6" />
|
||||||
|
) : (
|
||||||
|
<MicOffIcon className="w-6 h-6" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* End Call */}
|
||||||
|
<button
|
||||||
|
className="p-4 rounded-full bg-red-500 text-white hover:bg-red-600 transition-colors"
|
||||||
|
onClick={handleDisconnect}
|
||||||
|
>
|
||||||
|
<PhoneOffIcon className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<div className="absolute bottom-[5%] left-0 w-full px-[8%] z-20">
|
||||||
<div className="w-full flex items-center justify-center gap-8">
|
<div className="w-full flex items-center justify-center gap-8">
|
||||||
<button
|
<button
|
||||||
className={`p-4 rounded-full backdrop-blur-md transition-colors ${
|
className={`p-4 rounded-full backdrop-blur-md transition-colors ${
|
||||||
@@ -521,6 +653,7 @@ export function PhoneSimulator({ onConnect, phoneMode = "normal", onCapture }: P
|
|||||||
<PhoneOffIcon className="w-6 h-6" />
|
<PhoneOffIcon className="w-6 h-6" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
useVoiceAssistant,
|
useVoiceAssistant,
|
||||||
useRoomContext,
|
useRoomContext,
|
||||||
useParticipantAttributes,
|
useParticipantAttributes,
|
||||||
|
useChat,
|
||||||
} from "@livekit/components-react";
|
} from "@livekit/components-react";
|
||||||
import { ConnectionState, LocalParticipant, Track, RpcError, RpcInvocationData } from "livekit-client";
|
import { ConnectionState, LocalParticipant, Track, RpcError, RpcInvocationData } from "livekit-client";
|
||||||
import { QRCodeSVG } from "qrcode.react";
|
import { QRCodeSVG } from "qrcode.react";
|
||||||
@@ -57,6 +58,7 @@ export default function Playground({
|
|||||||
const { name } = useRoomInfo();
|
const { name } = useRoomInfo();
|
||||||
const [transcripts, setTranscripts] = useState<ChatMessageType[]>([]);
|
const [transcripts, setTranscripts] = useState<ChatMessageType[]>([]);
|
||||||
const { localParticipant } = useLocalParticipant();
|
const { localParticipant } = useLocalParticipant();
|
||||||
|
const { send: sendChat } = useChat();
|
||||||
|
|
||||||
const voiceAssistant = useVoiceAssistant();
|
const voiceAssistant = useVoiceAssistant();
|
||||||
|
|
||||||
@@ -74,7 +76,7 @@ export default function Playground({
|
|||||||
localParticipant.setCameraEnabled(config.settings.inputs.camera);
|
localParticipant.setCameraEnabled(config.settings.inputs.camera);
|
||||||
localParticipant.setMicrophoneEnabled(config.settings.inputs.mic);
|
localParticipant.setMicrophoneEnabled(config.settings.inputs.mic);
|
||||||
}
|
}
|
||||||
}, [config, localParticipant, roomState]);
|
}, [config.settings.inputs.camera, config.settings.inputs.mic, localParticipant, roomState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!localParticipant || roomState !== ConnectionState.Connected) {
|
if (!localParticipant || roomState !== ConnectionState.Connected) {
|
||||||
@@ -596,9 +598,10 @@ export default function Playground({
|
|||||||
<PhoneSimulator
|
<PhoneSimulator
|
||||||
onConnect={() => onConnect(true)}
|
onConnect={() => onConnect(true)}
|
||||||
phoneMode={phoneMode}
|
phoneMode={phoneMode}
|
||||||
onCapture={(content: File) => {
|
onCapture={async (content: File) => {
|
||||||
if (localParticipant) {
|
if (localParticipant) {
|
||||||
localParticipant.sendFile(content, { topic: "image" });
|
await localParticipant.sendFile(content, { topic: "image" });
|
||||||
|
await sendChat("用户上传了照片" );
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -667,9 +670,10 @@ export default function Playground({
|
|||||||
<PhoneSimulator
|
<PhoneSimulator
|
||||||
onConnect={() => onConnect(true)}
|
onConnect={() => onConnect(true)}
|
||||||
phoneMode={phoneMode}
|
phoneMode={phoneMode}
|
||||||
onCapture={(content: File) => {
|
onCapture={async (content: File) => {
|
||||||
if (localParticipant) {
|
if (localParticipant) {
|
||||||
localParticipant.sendFile(content, { topic: "image" });
|
await localParticipant.sendFile(content, { topic: "image" });
|
||||||
|
await sendChat("用户上传了一张照片");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -188,3 +188,22 @@ export const SwitchCameraIcon = ({ className }: { className?: string }) => (
|
|||||||
<path d="M20 12v3a3 3 0 0 1-3 3H4m3 3-3-3 3-3" />
|
<path d="M20 12v3a3 3 0 0 1-3 3H4m3 3-3-3 3-3" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const VoiceIcon = ({ className }: { className?: string }) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
|
||||||
|
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
||||||
|
<line x1="12" y1="19" x2="12" y2="22" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user