Compare commits

...
8 Commits
Author SHA1 Message Date
khairul169 166c96caf2 feat: add device select & cmd exec 2026-09-22 14:42:36 +08:00
khairul169 b7bbce7374 fix: filter only authenticated socket 2026-09-22 14:21:10 +08:00
khairul169 21c30393fc feat: server update 2026-09-22 14:14:13 +08:00
khairul169 999ecc1a61 feat: add select deviceId 2026-09-22 07:45:40 +08:00
khairul169 495ee9c040 fix: hide overflow reply msg 2026-09-21 09:28:50 +08:00
khairul169 6513dc39b6 feat: add reply msg 2026-09-21 09:21:18 +08:00
khairul169 0ece6bbb63 fix: duplicated msg event fix 2026-09-21 08:59:35 +08:00
khairul169 07dfb7d443 fix: add msg skeleton padding 2026-09-20 10:29:38 +08:00
10 changed files with 393 additions and 64 deletions
+16 -4
View File
@@ -16,6 +16,7 @@ export class WhatsAppClient {
private url: string = ""; private url: string = "";
private socket: WebSocket | null = null; private socket: WebSocket | null = null;
private deviceId: string | undefined = undefined;
private pending = new Map< private pending = new Map<
string, string,
@@ -28,8 +29,13 @@ export class WhatsAppClient {
private listeners: Map<string, (...args: any[]) => void> = new Map(); private listeners: Map<string, (...args: any[]) => void> = new Map();
constructor(url = "ws://127.0.0.1:8787") { constructor(url = "ws://127.0.0.1:8787", deviceId?: string) {
this.url = url; this.url = url;
this.deviceId = deviceId;
}
setDeviceId(id?: string | null) {
this.deviceId = id || undefined;
} }
async connect() { async connect() {
@@ -83,12 +89,17 @@ export class WhatsAppClient {
}); });
} }
private request<T>(method: string, params?: unknown): Promise<T> { private request<T>(
method: string,
params?: unknown,
devId?: string | null,
): Promise<T> {
if (!this.isConnected || !this.socket) { if (!this.isConnected || !this.socket) {
throw new Error("WhatsApp is not connected"); throw new Error("WhatsApp is not connected");
} }
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const deviceId = devId || this.deviceId;
return new Promise<T>((resolve, reject) => { return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
@@ -106,6 +117,7 @@ export class WhatsAppClient {
this.socket!.send( this.socket!.send(
JSON.stringify({ JSON.stringify({
type: "request", type: "request",
deviceId,
id, id,
method, method,
params, params,
@@ -167,7 +179,7 @@ export class WhatsAppClient {
return this.request<string>("getMedia", msgId); return this.request<string>("getMedia", msgId);
} }
exec(cmd: string) { exec(cmd: string, deviceId?: string | null) {
return this.request<any>("exec", cmd); return this.request<any>("exec", cmd, deviceId);
} }
} }
+53 -12
View File
@@ -28,15 +28,37 @@ function requestId() {
return crypto.randomUUID(); return crypto.randomUUID();
} }
function getDevices() {
return [
...new Map(
[...bridgeSockets].reverse().map((ws) => [
ws.deviceId,
{
id: ws.deviceId,
name: ws.deviceName,
lastAlive: ws.lastAlive,
lastSuccessfulCall: ws.lastSuccessfulCall,
},
]),
).values(),
];
}
function getWhatsApp(deviceId?: string): WebSocket | null { function getWhatsApp(deviceId?: string): WebSocket | null {
if (deviceId) {
for (const ws of [...bridgeSockets].reverse()) { for (const ws of [...bridgeSockets].reverse()) {
if ( if (ws.readyState === WebSocket.OPEN && ws.deviceId === deviceId) {
ws.readyState === WebSocket.OPEN &&
(!deviceId || ws.deviceId === deviceId)
) {
return ws; return ws;
} }
} }
}
// find the most recent active bridge
const sock = [...bridgeSockets]
.sort((a, b) => (b.lastSuccessfulCall || 0) - (a.lastSuccessfulCall || 0))
.find((i) => i.readyState === WebSocket.OPEN && i.isAuthenticated);
if (sock) return sock;
return null; return null;
} }
@@ -75,6 +97,18 @@ function call<T>(
}); });
} }
const forwardedMessages = new Map<string, number>();
function cleanupForwardedMessages() {
const now = Date.now();
for (const [id, timestamp] of forwardedMessages) {
if (now - timestamp > 60_000) {
forwardedMessages.delete(id);
}
}
}
function handleWhatsAppMessage(ws: WebSocket, data: string) { function handleWhatsAppMessage(ws: WebSocket, data: string) {
const message = JSON.parse(data) as const message = JSON.parse(data) as
| RpcResponse | RpcResponse
@@ -119,21 +153,29 @@ function handleWhatsAppMessage(ws: WebSocket, data: string) {
console.log("[WhatsApp event]", event, data); console.log("[WhatsApp event]", event, data);
if (event === "ready") { if (event === "ready") {
ws.isAuthenticated = data.authenticated || false;
ws.deviceId = data.deviceId; ws.deviceId = data.deviceId;
ws.deviceName = data.deviceName; ws.deviceName = data.deviceName;
} }
} }
if (message.event === "message") { if (message.event === "message") {
const m = message.data as MessageDTO;
const msgId = m.id.id || m.id._serialized || "";
if (forwardedMessages.has(msgId)) {
return;
}
forwardedMessages.set(msgId, Date.now());
// forward to clients // forward to clients
clientSockets.forEach((ws) => { clientSockets.forEach((ws) => {
ws.send(JSON.stringify(message)); ws.send(JSON.stringify(message));
}); });
const m = message.data as MessageDTO;
insertMessage({ insertMessage({
id: m.id.id || m.id._serialized || "", id: msgId,
chatId: m.chatId || "", chatId: m.chatId || "",
from: typeof m.from === "string" ? m.from : m.from?._serialized || "", from: typeof m.from === "string" ? m.from : m.from?._serialized || "",
to: typeof m.to === "string" ? m.to : m.to?._serialized || "", to: typeof m.to === "string" ? m.to : m.to?._serialized || "",
@@ -204,11 +246,7 @@ wss.on("connection", (ws) => {
switch (method) { switch (method) {
case "listDevices": case "listDevices":
result = [...bridgeSockets].map((ws) => ({ result = getDevices();
id: ws.deviceId,
name: ws.deviceName,
lastAlive: ws.lastAlive,
}));
break; break;
// cached // cached
@@ -240,6 +278,8 @@ wss.on("connection", (ws) => {
throw new Error(`Unsupported method: ${(request as any).method}`); throw new Error(`Unsupported method: ${(request as any).method}`);
} }
ws.lastSuccessfulCall = Date.now();
ws.send( ws.send(
JSON.stringify({ JSON.stringify({
type: "response", type: "response",
@@ -276,4 +316,5 @@ wss.on("connection", (ws) => {
}); });
initDb(); initDb();
setInterval(cleanupForwardedMessages, 30_000);
console.log(`WhatsApp bridge listening on ws://127.0.0.1:${PORT}`); console.log(`WhatsApp bridge listening on ws://127.0.0.1:${PORT}`);
+3 -1
View File
@@ -2,8 +2,10 @@ import "ws";
declare module "ws" { declare module "ws" {
interface WebSocket { interface WebSocket {
lastAlive?: number; isAuthenticated?: boolean;
deviceId?: string; deviceId?: string;
deviceName?: string; deviceName?: string;
lastAlive?: number;
lastSuccessfulCall?: number;
} }
} }
+7 -4
View File
@@ -5,10 +5,12 @@ import { getId } from "./ui/src/lib/utils";
const wa = new WhatsAppClient(process.env.WS_URL); const wa = new WhatsAppClient(process.env.WS_URL);
await wa.connect(); await wa.connect();
// console.log("status", await wa.status()); const devices = await wa.listDevices();
console.log("devices", devices);
// const devices = await wa.listDevices(); wa.setDeviceId("1789898358415");
// console.log("devices", devices); // await wa.exec("window.location.reload(); return 1;");
console.log("status", await wa.status());
// const data = JSON.stringify({ // const data = JSON.stringify({
// DEVICE_ID: "1789636162707", // DEVICE_ID: "1789636162707",
@@ -19,7 +21,8 @@ await wa.connect();
// const test = await wa.exec(`return localStorage.getItem('wa-bridge-consts');`); // const test = await wa.exec(`return localStorage.getItem('wa-bridge-consts');`);
// const test = await wa.exec(`return document.hasFocus();`); // const test = await wa.exec(`return document.hasFocus();`);
const test = await wa.exec(`window.location.reload(); return 1;`); // const test = await wa.exec(`window.location.reload(); return 1;`);
const test = await wa.listChats();
console.log("test", test); console.log("test", test);
// const test = await wa.exec(`return GM_info.script.version;`); // const test = await wa.exec(`return GM_info.script.version;`);
+105 -4
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo } from "react"; import { useEffect, useMemo, useRef } from "react";
import { useWAClient } from "./context"; import { useWAClient } from "./context";
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -8,12 +8,27 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { createDisclosure } from "@/lib/utils";
import { FileTerminalIcon } from "lucide-react";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
type Props = { type Props = {
curDeviceId: string; curDeviceId: string;
setDeviceId: (id: string) => void; setDeviceId: (id: string) => void;
}; };
const commandDlg = createDisclosure<{ deviceId: string }>();
export default function DevicesSection({ curDeviceId, setDeviceId }: Props) { export default function DevicesSection({ curDeviceId, setDeviceId }: Props) {
const { client, isReady } = useWAClient(); const { client, isReady } = useWAClient();
const { data: devices } = useQuery({ const { data: devices } = useQuery({
@@ -35,7 +50,7 @@ export default function DevicesSection({ curDeviceId, setDeviceId }: Props) {
const isOnline = Date.now() - lastAlive <= 60_000; const isOnline = Date.now() - lastAlive <= 60_000;
return { return {
label: i.name || "Unknown", label: i.name || i.id || "Unknown",
value: i.id || String(idx), value: i.id || String(idx),
lastAlive, lastAlive,
isOnline, isOnline,
@@ -48,7 +63,7 @@ export default function DevicesSection({ curDeviceId, setDeviceId }: Props) {
}, [deviceList, curDeviceId]); }, [deviceList, curDeviceId]);
return ( return (
<div className="border-t p-4"> <div className="border-t p-4 flex items-center gap-2">
<Select <Select
items={deviceList} items={deviceList}
value={curDeviceId} value={curDeviceId}
@@ -86,6 +101,92 @@ export default function DevicesSection({ curDeviceId, setDeviceId }: Props) {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<Button
onClick={() => commandDlg.onOpen({ deviceId: curDeviceId })}
variant="outline"
>
<FileTerminalIcon />
</Button>
<CommandModal />
</div> </div>
); );
} }
const cmdPreset = [
{ title: "Status", command: "status" },
{ title: "Has Focus", command: "return document.hasFocus();" },
{ title: "Reload", command: "window.location.reload(); return true;" },
];
function CommandModal() {
const { data, isOpen } = commandDlg.useStore();
const { client } = useWAClient();
const cmdTextRef = useRef<HTMLTextAreaElement>(null);
const cmd = useMutation({
async mutationFn(cmd: string) {
if (cmd === "status") {
return client.status();
}
return client.exec(cmd, data?.deviceId);
},
});
const onSubmit = () => {
const command = cmdTextRef.current?.value || "";
cmd.mutate(command);
};
return (
<Dialog open={isOpen} onOpenChange={commandDlg.onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Execute Command</DialogTitle>
</DialogHeader>
<div>
<Textarea ref={cmdTextRef} placeholder="cmd..." />
<div className="flex items-center gap-2 overflow-x-auto mt-2">
{cmdPreset.map((i) => (
<button
key={i.command}
onClick={() => (cmdTextRef.current!.value = i.command)}
>
<Badge className="cursor-pointer">{i.title}</Badge>
</button>
))}
</div>
{cmd.error ? (
<div className="mt-3">
<p className="text-sm font-medium leading-tight">Error:</p>
<pre className="text-sm text-destructive mt-1">
{JSON.stringify(cmd.error, null, 2)}
</pre>
</div>
) : null}
{cmd.data !== undefined ? (
<div className="mt-3">
<p className="text-sm font-medium leading-tight">Result:</p>
<pre className="text-sm mt-1">
{JSON.stringify((cmd as any).data, null, 2)}
</pre>
</div>
) : null}
</div>
<DialogFooter>
<DialogClose>
<Button variant="ghost">Cancel</Button>
</DialogClose>
<Button onClick={onSubmit} disabled={cmd.isPending}>
Execute
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+117 -34
View File
@@ -1,5 +1,10 @@
import { useWAClient } from "./context"; import { useWAClient } from "./context";
import { getFilesAsBase64, getFormattedName, getId } from "@/lib/utils"; import {
copyToClipboard,
getFilesAsBase64,
getFormattedName,
getId,
} from "@/lib/utils";
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import { cn } from "cn"; import { cn } from "cn";
import { import {
@@ -7,11 +12,13 @@ import {
CheckCheckIcon, CheckCheckIcon,
CheckIcon, CheckIcon,
ChevronDown, ChevronDown,
CopyIcon,
CornerUpRightIcon, CornerUpRightIcon,
Loader2Icon, Loader2Icon,
MousePointer2Icon, MousePointer2Icon,
PaperclipIcon, PaperclipIcon,
PhoneCallIcon, PhoneCallIcon,
ReplyIcon,
SendIcon, SendIcon,
Trash2Icon, Trash2Icon,
XIcon, XIcon,
@@ -39,6 +46,7 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { forwardDlg } from "./stores"; import { forwardDlg } from "./stores";
import type { ContactDTO, MessageDTO } from "@shared/dto";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
@@ -47,6 +55,11 @@ type Props = {
maxCount?: number; maxCount?: number;
}; };
type MessageDTOWithStrID = Omit<MessageDTO, "id" | "from"> & {
id: string;
from?: ContactDTO;
};
export default function MessagesSection({ chatId, maxCount = 20 }: Props) { export default function MessagesSection({ chatId, maxCount = 20 }: Props) {
const { client, isReady } = useWAClient(); const { client, isReady } = useWAClient();
const router = useHashRouter(); const router = useHashRouter();
@@ -58,6 +71,7 @@ export default function MessagesSection({ chatId, maxCount = 20 }: Props) {
const { data: contacts } = useContacts(); const { data: contacts } = useContacts();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [selectedIds, setSelectedIds] = useState<Set<string> | null>(null); const [selectedIds, setSelectedIds] = useState<Set<string> | null>(null);
const [replyTo, setReplyTo] = useState<MessageDTOWithStrID | null>(null);
const { const {
data: messages, data: messages,
@@ -153,6 +167,7 @@ export default function MessagesSection({ chatId, maxCount = 20 }: Props) {
const quotedMsg = i.quotedMsg const quotedMsg = i.quotedMsg
? { ? {
...i.quotedMsg, ...i.quotedMsg,
fromMe: i.fromMe,
from: contacts?.find((c) => { from: contacts?.find((c) => {
return ( return (
getId(c.id) === getId(c.id) ===
@@ -349,10 +364,31 @@ export default function MessagesSection({ chatId, maxCount = 20 }: Props) {
<ChevronDown /> <ChevronDown />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setReplyTo(item as never)}>
<ReplyIcon />
Reply
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onSelectMsg(item.id)}> <DropdownMenuItem onClick={() => onSelectMsg(item.id)}>
<MousePointer2Icon /> <MousePointer2Icon />
Select Select
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
copyToClipboard(
item.caption ||
(item.type === "chat" ? item.body : null) ||
"",
)
}
>
<CopyIcon />
Copy
</DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
forwardDlg.onOpen({ messageIds: [item.id] }) forwardDlg.onOpen({ messageIds: [item.id] })
@@ -361,6 +397,7 @@ export default function MessagesSection({ chatId, maxCount = 20 }: Props) {
<CornerUpRightIcon /> <CornerUpRightIcon />
Forward Forward
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onDeleteMsg(item.id)}> <DropdownMenuItem onClick={() => onDeleteMsg(item.id)}>
<Trash2Icon /> <Trash2Icon />
@@ -383,39 +420,11 @@ export default function MessagesSection({ chatId, maxCount = 20 }: Props) {
["chat", "image", "document"].includes( ["chat", "image", "document"].includes(
item.quotedMsg.type as string, item.quotedMsg.type as string,
) ? ( ) ? (
<button <QuotedMsg
type="button" data={item.quotedMsg as never}
onClick={() => {
const id = getId(item.quotedMsgId).replace(
/^(?:false_|true_)/,
"",
);
const el = document.getElementById(id);
if (el) {
el.scrollIntoView({ behavior: "smooth" });
}
}}
className={cn(
"bg-card/30 px-2 py-1.5 rounded-lg mb-2 mt-1 cursor-pointer block text-left",
!item.fromMe && "bg-[#d8f5e8]/30 dark:bg-[#26855c]/30",
)}
>
<p className="text-xs text-muted-foreground">
{getFormattedName(item.quotedMsg.from)}
</p>
<Paragraph
text={
item.quotedMsg.filename ||
item.quotedMsg.caption ||
(item.quotedMsg.type === "chat"
? item.quotedMsg.body
: "Quoted")
}
getMentionLabel={getMentionLabel} getMentionLabel={getMentionLabel}
onMentionClick={onMentionClick} onMentionClick={onMentionClick}
/> />
</button>
) : null} ) : null}
{item.type === "image" || {item.type === "image" ||
@@ -468,7 +477,7 @@ export default function MessagesSection({ chatId, maxCount = 20 }: Props) {
<div <div
key={i} key={i}
className={cn( className={cn(
"mb-4 shrink-0 w-full max-w-[44%]", "mb-4 shrink-0 px-4 sm:px-8 w-full max-w-[44%]",
i % 2 === 0 ? "self-start" : "self-end", i % 2 === 0 ? "self-start" : "self-end",
)} )}
> >
@@ -501,17 +510,72 @@ export default function MessagesSection({ chatId, maxCount = 20 }: Props) {
</div> </div>
</div> </div>
<MessageInputSection chatId={chatId} refetch={refetch} /> <MessageInputSection
chatId={chatId}
refetch={refetch}
replyTo={replyTo}
setReplyTo={setReplyTo}
/>
</section> </section>
); );
} }
function QuotedMsg({
data,
getMentionLabel,
onMentionClick,
className,
}: {
data: MessageDTOWithStrID;
getMentionLabel?: (username: string) => string;
onMentionClick?: (username: string) => void;
className?: string;
}) {
return (
<button
type="button"
onClick={() => {
const id = getId(data.id).replace(/^(?:false_|true_)/, "");
const el = document.getElementById(id);
if (el) {
el.scrollIntoView({ behavior: "smooth" });
}
}}
className={cn(
"bg-card/30 px-2 py-1.5 rounded-lg mb-2 mt-1 cursor-pointer block text-left",
!data.fromMe && "bg-[#d8f5e8]/30 dark:bg-[#26855c]/30",
className,
)}
>
<p className="text-xs text-muted-foreground">
{getFormattedName(data.from)}
</p>
<Paragraph
text={
data.filename ||
data.caption ||
(data.type === "chat" ? data.body : "Quoted")
}
getMentionLabel={getMentionLabel}
onMentionClick={onMentionClick}
/>
</button>
);
}
function MessageInputSection({ function MessageInputSection({
chatId, chatId,
refetch, refetch,
replyTo,
setReplyTo,
getMentionLabel,
}: { }: {
chatId?: string | null; chatId?: string | null;
refetch: () => void; refetch: () => void;
replyTo?: MessageDTOWithStrID | null;
setReplyTo?: (id: MessageDTOWithStrID | null) => void;
getMentionLabel?: (username: string) => string;
}) { }) {
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const { client, isReady } = useWAClient(); const { client, isReady } = useWAClient();
@@ -559,7 +623,7 @@ function MessageInputSection({
try { try {
setPending(true); setPending(true);
const options = { waitForAck: true }; const options = { waitForAck: true, quotedMsg: replyTo?.id || undefined };
if (files.length > 0) { if (files.length > 0) {
const fileList = await getFilesAsBase64(files.map((f) => f.file)); const fileList = await getFilesAsBase64(files.map((f) => f.file));
@@ -583,6 +647,7 @@ function MessageInputSection({
setTimeout(refetch, 1000); setTimeout(refetch, 1000);
setFiles([]); setFiles([]);
setMessage(""); setMessage("");
setReplyTo?.(null);
} catch (err) { } catch (err) {
alert((err as Error).message); alert((err as Error).message);
} finally { } finally {
@@ -596,6 +661,24 @@ function MessageInputSection({
return ( return (
<div className="border-t border-border bg-card px-3 py-3 sm:py-4"> <div className="border-t border-border bg-card px-3 py-3 sm:py-4">
{replyTo != null && (
<div className="flex items-center gap-1 -mt-2 mb-1">
<ReplyIcon className="size-5" />
<QuotedMsg
data={replyTo}
getMentionLabel={getMentionLabel}
className="text-sm max-h-32 overflow-hidden"
/>
<Button
type="button"
variant="ghost"
onClick={() => setReplyTo?.(null)}
>
<XIcon />
</Button>
</div>
)}
<input <input
type="file" type="file"
ref={fileRef} ref={fileRef}
+9
View File
@@ -4,12 +4,21 @@ import { useLocalStorage } from "@/hooks/useLocalStorage";
import { useHashRouter } from "@/hooks/useHashRouter"; import { useHashRouter } from "@/hooks/useHashRouter";
import MessagesSection from "./messages"; import MessagesSection from "./messages";
import ForwardDialog from "@/components/forward-dialog"; import ForwardDialog from "@/components/forward-dialog";
import { useEffect } from "react";
import wa from "@/lib/wa-client";
import { useQueryClient } from "@tanstack/react-query";
export default function Page() { export default function Page() {
const router = useHashRouter(); const router = useHashRouter();
const queryClient = useQueryClient();
const [curDeviceId, setDeviceId] = useLocalStorage<string>("app/device", ""); const [curDeviceId, setDeviceId] = useLocalStorage<string>("app/device", "");
const chatId = router.query.get("chatId"); const chatId = router.query.get("chatId");
useEffect(() => {
wa.setDeviceId(curDeviceId);
queryClient.invalidateQueries();
}, [curDeviceId]);
return ( return (
<main className="bg-background text-foreground flex overflow-hidden h-screen max-h-dvh items-stretch"> <main className="bg-background text-foreground flex overflow-hidden h-screen max-h-dvh items-stretch">
{/* <div className="mx-auto flex h-dvh max-w-360 overflow-hidden bg-card shadow-xl sm:h-[calc(100dvh-2rem)] sm:rounded-2xl lg:h-[calc(100dvh-3rem)]"> */} {/* <div className="mx-auto flex h-dvh max-w-360 overflow-hidden bg-card shadow-xl sm:h-[calc(100dvh-2rem)] sm:rounded-2xl lg:h-[calc(100dvh-3rem)]"> */}
+51
View File
@@ -0,0 +1,51 @@
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "cn";
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props,
),
render,
state: {
slot: "badge",
variant,
},
});
}
export { Badge, badgeVariants };
+17
View File
@@ -0,0 +1,17 @@
import * as React from "react";
import { cn } from "cn";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-2.5 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
export { Textarea };
+10
View File
@@ -59,3 +59,13 @@ export function createDisclosure<T = any>(data?: T | null) {
useStore: () => useStore(store), useStore: () => useStore(store),
}); });
} }
export async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
console.error("Failed to copy text: ", err);
return false;
}
}