feat: 6-digit pair code login + responsive layout for narrow car browsers
Pair code: - POST /api/auth/owner/pair-code (owner-gated) mints a single-use, 5-minute, 6-digit code held in memory. - POST /api/auth/owner/pair-redeem swaps the code for an owner cookie, with basic IP-based rate limiting (max 8 attempts/min). - OwnerLoginModal gains a tab: "Use 6-digit code" (default) vs the long secret. The PIN input is big, monospaced, numeric-only. - Small smartphone icon next to the connected Tesla chip opens OwnerPairCodeDisplay, which shows the code in 56px type with a live countdown so it's tappable on the touchscreen of the other device while you stare at it. Responsive layout: - TopBar wraps when narrower than the chip row. minWidth on OD strip drops 380→280 and chat composer drops 360→240 so they stack cleanly. - Main body switches to flex-col below the lg breakpoint (Tailwind 1024px), so the map sits on top of the stops rail on Tesla 3/Y portrait and any window not maximised. Map gets 40vh min-height so it never disappears.
This commit is contained in:
@@ -38,6 +38,28 @@ export async function logoutOwner(): Promise<void> {
|
||||
await fetch('/api/auth/owner/logout', { method: 'POST' });
|
||||
}
|
||||
|
||||
export interface OwnerPairCode {
|
||||
code: string;
|
||||
expiresInS: number;
|
||||
}
|
||||
|
||||
export async function mintOwnerPairCode(): Promise<OwnerPairCode | null> {
|
||||
const res = await fetch('/api/auth/owner/pair-code', { method: 'POST' });
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
if (!data?.code) return null;
|
||||
return { code: data.code, expiresInS: data.expiresInS ?? 300 };
|
||||
}
|
||||
|
||||
export async function redeemOwnerPairCode(code: string): Promise<boolean> {
|
||||
const res = await fetch('/api/auth/owner/pair-redeem', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
export interface TeslaActiveRoute {
|
||||
destination: string;
|
||||
lat: number | null;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { MapContainer, TileLayer, Marker, Polyline, Popup, useMap } from 'react-leaflet';
|
||||
import { useTesla, startTeslaConnect, disconnectTesla, sendToTeslaNav, wakeTesla, loginOwner, type TeslaActiveRoute } from '../lib/tesla';
|
||||
import { useTesla, startTeslaConnect, disconnectTesla, sendToTeslaNav, wakeTesla, loginOwner, mintOwnerPairCode, redeemOwnerPairCode, type TeslaActiveRoute } from '../lib/tesla';
|
||||
import { isMockEnabled, getMockScenario, setMockScenario, resetMockDrive } from '../lib/teslaMock';
|
||||
import { detectInCar } from '../lib/incar';
|
||||
import { computeBatteryPlan, type BatteryAtStop, type BatteryRisk } from '../lib/batteryPlan';
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
Zap, Bed, Flag, Home as HomeIcon, Coffee, Utensils, Camera,
|
||||
ShoppingBag, Footprints, Wifi, MapPin, Route, Clock, TreePine, Euro,
|
||||
CalendarDays, Ship, Users, ExternalLink, Crosshair, ArrowUp, ArrowDown,
|
||||
Car, Battery, Navigation,
|
||||
Car, Battery, Navigation, Smartphone,
|
||||
} from 'lucide-react';
|
||||
|
||||
// Fix Leaflet default icons (we still need pins for non-active stops)
|
||||
@@ -1411,7 +1411,7 @@ function TopBar({
|
||||
chatInput, setChatInput, onChatSubmit, chips, onRemoveChip,
|
||||
vehicle, onOpenVehiclePanel, grokStatus, onOpenGpx,
|
||||
travelDates, onOpenDates, onUseMyLocation,
|
||||
teslaStatus, teslaState, onConnectTesla, onDisconnectTesla, inCar,
|
||||
teslaStatus, teslaState, onConnectTesla, onDisconnectTesla, onOpenPairCode, inCar,
|
||||
}: {
|
||||
origin: string; destination: string;
|
||||
onOriginChange: (v: string) => void;
|
||||
@@ -1430,6 +1430,7 @@ function TopBar({
|
||||
teslaState: ReturnType<typeof useTesla>['state'];
|
||||
onConnectTesla: () => void;
|
||||
onDisconnectTesla: () => void;
|
||||
onOpenPairCode: () => void;
|
||||
inCar: boolean;
|
||||
}) {
|
||||
// Tesla connected → we know the car (and where it is), so the vehicle picker
|
||||
@@ -1448,8 +1449,8 @@ function TopBar({
|
||||
const datesEmpty = !travelDates.outbound;
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-4 px-5 flex-shrink-0"
|
||||
style={{ height: 62, background: 'var(--gd-bg)', borderBottom: '1px solid var(--gd-border)' }}
|
||||
className="flex items-center flex-wrap gap-x-3 gap-y-2 px-4 py-2 flex-shrink-0"
|
||||
style={{ minHeight: 62, background: 'var(--gd-bg)', borderBottom: '1px solid var(--gd-border)' }}
|
||||
>
|
||||
{/* Brand */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
@@ -1479,7 +1480,7 @@ function TopBar({
|
||||
) : (
|
||||
<div
|
||||
className="flex items-center rounded-[10px] overflow-hidden"
|
||||
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)', height: 38, minWidth: 380 }}
|
||||
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)', height: 38, minWidth: 280 }}
|
||||
>
|
||||
<div className="px-3.5 flex items-center gap-2 h-full flex-1" style={{ borderRight: '1px solid var(--gd-border)' }}>
|
||||
<div className="w-1.5 h-1.5 rounded-full" style={{ background: 'var(--gd-text-2)' }} />
|
||||
@@ -1522,12 +1523,12 @@ function TopBar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
<div className="flex-1 hidden md:block" />
|
||||
|
||||
{/* Chat composer with chips */}
|
||||
<div
|
||||
className="flex items-center pl-3 rounded-[10px]"
|
||||
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)', height: 38, minWidth: 360 }}
|
||||
className="flex items-center pl-3 rounded-[10px] flex-1 min-w-[240px]"
|
||||
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)', height: 38 }}
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" style={{ color: 'var(--gd-red)' }} />
|
||||
{chips.length > 0 && (
|
||||
@@ -1602,22 +1603,34 @@ function TopBar({
|
||||
|
||||
{teslaStatus?.available && (
|
||||
teslaStatus.connected ? (
|
||||
<button
|
||||
onClick={onDisconnectTesla}
|
||||
title="Disconnect Tesla"
|
||||
className="h-[38px] px-3 inline-flex items-center gap-2 rounded-[10px] cursor-pointer"
|
||||
<div
|
||||
className="h-[38px] inline-flex items-center rounded-[10px] overflow-hidden"
|
||||
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)' }}
|
||||
>
|
||||
<Car className="w-3.5 h-3.5" style={{ color: 'var(--gd-red)' }} />
|
||||
<div className="flex flex-col items-start leading-[1.15]">
|
||||
<div className="text-[11.5px] font-medium">
|
||||
{teslaState?.battery != null ? `${teslaState.battery}%` : 'Connected'}
|
||||
<button
|
||||
onClick={onDisconnectTesla}
|
||||
title="Disconnect Tesla"
|
||||
className="h-full px-3 inline-flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<Car className="w-3.5 h-3.5" style={{ color: 'var(--gd-red)' }} />
|
||||
<div className="flex flex-col items-start leading-[1.15]">
|
||||
<div className="text-[11.5px] font-medium">
|
||||
{teslaState?.battery != null ? `${teslaState.battery}%` : 'Connected'}
|
||||
</div>
|
||||
<div className="text-[9.5px] num" style={{ color: 'var(--gd-text-3)' }}>
|
||||
{teslaState?.rangeKm != null ? `${teslaState.rangeKm} km` : (teslaState?.asleep ? 'asleep' : '—')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[9.5px] num" style={{ color: 'var(--gd-text-3)' }}>
|
||||
{teslaState?.rangeKm != null ? `${teslaState.rangeKm} km` : (teslaState?.asleep ? 'asleep' : '—')}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</button>
|
||||
<button
|
||||
onClick={onOpenPairCode}
|
||||
title="Pair another device with a 6-digit code"
|
||||
className="h-full px-2 grid place-items-center cursor-pointer"
|
||||
style={{ borderLeft: '1px solid var(--gd-border)' }}
|
||||
>
|
||||
<Smartphone className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-2)' }} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={onConnectTesla}
|
||||
@@ -1875,6 +1888,7 @@ export default function TeslaTripPlanner() {
|
||||
const [datePickerOpen, setDatePickerOpen] = useState(false);
|
||||
const [dateAnchor, setDateAnchor] = useState<DOMRect | null>(null);
|
||||
const [ownerLoginOpen, setOwnerLoginOpen] = useState(false);
|
||||
const [pairCodeOpen, setPairCodeOpen] = useState(false);
|
||||
// After successful owner login, fire this pending action.
|
||||
const ownerLoginThenRef = React.useRef<null | (() => void)>(null);
|
||||
const lastODSent = React.useRef<{ from: string; to: string } | null>(null);
|
||||
@@ -2454,6 +2468,7 @@ export default function TeslaTripPlanner() {
|
||||
await tesla.refreshStatus();
|
||||
toast.success('Tesla disconnected');
|
||||
}}
|
||||
onOpenPairCode={() => setPairCodeOpen(true)}
|
||||
inCar={inCar}
|
||||
/>
|
||||
|
||||
@@ -2469,10 +2484,12 @@ export default function TeslaTripPlanner() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Body: map left, rail right */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Map */}
|
||||
<div className="flex-1 relative" style={{ background: '#0a0f1a' }}>
|
||||
{/* Body: map left + rail right on wide screens, map top + rail below
|
||||
on narrow ones (tablets, Tesla 3/Y portrait, smaller car browsers). */}
|
||||
<div className="flex flex-col lg:flex-row flex-1 overflow-hidden">
|
||||
{/* Map — fills width on narrow; ~50vh tall on narrow so the rail
|
||||
gets meaningful space below. */}
|
||||
<div className="relative flex-1 min-h-[40vh] lg:min-h-0" style={{ background: '#0a0f1a' }}>
|
||||
<MapContainer
|
||||
center={[51.5, 2.0]} zoom={5}
|
||||
style={{ height: '100%', width: '100%', background: '#0a0f1a' }}
|
||||
@@ -2649,10 +2666,11 @@ export default function TeslaTripPlanner() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stops rail */}
|
||||
{/* Stops rail — full width below the map on narrow screens, 540px
|
||||
fixed right column on wide screens. */}
|
||||
<aside
|
||||
className="flex flex-col overflow-hidden flex-shrink-0"
|
||||
style={{ width: 540, background: 'var(--gd-bg-2)', borderLeft: '1px solid var(--gd-border)' }}
|
||||
className="flex flex-col overflow-hidden flex-shrink-0 w-full lg:w-[540px]"
|
||||
style={{ background: 'var(--gd-bg-2)' }}
|
||||
>
|
||||
{/* Trip header */}
|
||||
<div className="px-5 py-3.5 flex items-center gap-2.5" style={{ borderBottom: '1px solid var(--gd-border)' }}>
|
||||
@@ -2949,6 +2967,11 @@ export default function TeslaTripPlanner() {
|
||||
if (fn) fn();
|
||||
}}
|
||||
/>
|
||||
|
||||
<OwnerPairCodeDisplay
|
||||
open={pairCodeOpen}
|
||||
onClose={() => setPairCodeOpen(false)}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -3104,31 +3127,40 @@ function DrivingMode({ tesla, nextStop, vehicle, onSendToNav, onExit }: {
|
||||
function OwnerLoginModal({ open, onClose, onSuccess }: {
|
||||
open: boolean; onClose: () => void; onSuccess: () => void;
|
||||
}) {
|
||||
const [mode, setMode] = React.useState<'pair' | 'secret'>('pair');
|
||||
const [secret, setSecret] = React.useState('');
|
||||
const [pin, setPin] = React.useState('');
|
||||
const [pending, setPending] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
setPin('');
|
||||
setSecret('');
|
||||
setMode('pair');
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [open, onClose]);
|
||||
if (!open) return null;
|
||||
const submit = async () => {
|
||||
|
||||
const submitSecret = async () => {
|
||||
if (!secret) return;
|
||||
setPending(true);
|
||||
try {
|
||||
const ok = await loginOwner(secret);
|
||||
if (ok) {
|
||||
toast.success('Logged in as owner');
|
||||
setSecret('');
|
||||
onSuccess();
|
||||
} else {
|
||||
toast.error('Invalid owner secret');
|
||||
}
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
if (ok) { toast.success('Logged in as owner'); setSecret(''); onSuccess(); }
|
||||
else toast.error('Invalid owner secret');
|
||||
} finally { setPending(false); }
|
||||
};
|
||||
const submitPin = async () => {
|
||||
if (pin.length !== 6) return;
|
||||
setPending(true);
|
||||
try {
|
||||
const ok = await redeemOwnerPairCode(pin);
|
||||
if (ok) { toast.success('Device paired'); setPin(''); onSuccess(); }
|
||||
else toast.error('Invalid or expired code');
|
||||
} finally { setPending(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
@@ -3137,7 +3169,7 @@ function OwnerLoginModal({ open, onClose, onSuccess }: {
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-[440px] max-w-full overflow-hidden"
|
||||
className="w-[480px] max-w-full overflow-hidden"
|
||||
style={{
|
||||
background: 'var(--gd-bg-2)',
|
||||
border: '1px solid var(--gd-border-2)',
|
||||
@@ -3148,36 +3180,163 @@ function OwnerLoginModal({ open, onClose, onSuccess }: {
|
||||
<div className="px-5 py-4" style={{ borderBottom: '1px solid var(--gd-border)' }}>
|
||||
<div className="text-[14px] font-semibold">Owner login required</div>
|
||||
<div className="text-[11.5px] mt-0.5" style={{ color: 'var(--gd-text-3)' }}>
|
||||
The Tesla integration is restricted to the deploying user. Enter the OWNER_SECRET set in the deploy environment.
|
||||
{mode === 'pair'
|
||||
? 'Type the 6-digit code from a logged-in device. Get one by clicking your Tesla chip on the desktop and choosing "Pair another device".'
|
||||
: 'Or enter the long OWNER_SECRET set in the deploy environment.'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<input
|
||||
type="password"
|
||||
autoFocus
|
||||
value={secret}
|
||||
onChange={(e) => setSecret(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}
|
||||
placeholder="Owner secret"
|
||||
className="w-full text-[13.5px] px-3 py-2.5 rounded-lg outline-none"
|
||||
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)', color: 'var(--gd-text)' }}
|
||||
/>
|
||||
|
||||
<div className="px-5 pt-4 pb-1 flex gap-1.5">
|
||||
{(['pair', 'secret'] as const).map(m => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
className="flex-1 h-9 text-[12px] rounded-lg transition"
|
||||
style={{
|
||||
background: mode === m ? 'var(--gd-red-soft)' : 'transparent',
|
||||
border: `1px solid ${mode === m ? 'var(--gd-red-line)' : 'var(--gd-border)'}`,
|
||||
color: mode === m ? 'var(--gd-red)' : 'var(--gd-text-2)',
|
||||
fontWeight: mode === m ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{m === 'pair' ? 'Use 6-digit code' : 'Use owner secret'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4">
|
||||
{mode === 'pair' ? (
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="\d*"
|
||||
autoFocus
|
||||
value={pin}
|
||||
maxLength={6}
|
||||
onChange={(e) => setPin(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') submitPin(); }}
|
||||
placeholder="••••••"
|
||||
className="w-full text-center font-semibold tracking-[0.4em] outline-none num"
|
||||
style={{
|
||||
fontSize: 36,
|
||||
padding: '14px 10px',
|
||||
background: 'var(--gd-panel)',
|
||||
border: '1px solid var(--gd-border)',
|
||||
color: 'var(--gd-text)',
|
||||
borderRadius: 12,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="password"
|
||||
autoFocus
|
||||
value={secret}
|
||||
onChange={(e) => setSecret(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') submitSecret(); }}
|
||||
placeholder="Owner secret"
|
||||
className="w-full text-[13.5px] px-3 py-3 rounded-lg outline-none"
|
||||
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)', color: 'var(--gd-text)' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4 flex items-center gap-2" style={{ borderTop: '1px solid var(--gd-border)' }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 h-10 text-[12.5px] rounded-lg"
|
||||
className="flex-1 h-12 text-[14px] rounded-lg"
|
||||
style={{ background: 'transparent', border: '1px solid var(--gd-border)', color: 'var(--gd-text-2)' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={!secret || pending}
|
||||
className="flex-[2] h-10 text-[12.5px] rounded-lg disabled:opacity-40"
|
||||
onClick={mode === 'pair' ? submitPin : submitSecret}
|
||||
disabled={pending || (mode === 'pair' ? pin.length !== 6 : !secret)}
|
||||
className="flex-[2] h-12 text-[14px] rounded-lg disabled:opacity-40 font-semibold"
|
||||
style={{ background: 'var(--gd-red)', color: '#fff' }}
|
||||
>
|
||||
{pending ? 'Logging in…' : 'Log in'}
|
||||
{pending ? (mode === 'pair' ? 'Pairing…' : 'Logging in…') : (mode === 'pair' ? 'Pair this device' : 'Log in')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Modal shown on an already-logged-in device to mint a fresh 6-digit code
|
||||
// the user can type into a touchscreen device (e.g. their Tesla browser).
|
||||
function OwnerPairCodeDisplay({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const [code, setCode] = React.useState<string | null>(null);
|
||||
const [secondsLeft, setSecondsLeft] = React.useState(0);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
setCode(null); setError(null); setSecondsLeft(0);
|
||||
(async () => {
|
||||
const c = await mintOwnerPairCode();
|
||||
if (!c) { setError('Could not mint a pair code'); return; }
|
||||
setCode(c.code);
|
||||
setSecondsLeft(c.expiresInS);
|
||||
})();
|
||||
}, [open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || !code || secondsLeft <= 0) return;
|
||||
const t = setInterval(() => setSecondsLeft(s => Math.max(0, s - 1)), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, [open, code, secondsLeft]);
|
||||
|
||||
if (!open) return null;
|
||||
const mins = Math.floor(secondsLeft / 60);
|
||||
const secs = secondsLeft % 60;
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 grid place-items-center p-6"
|
||||
style={{ zIndex: 9999, background: 'rgba(5,5,8,0.72)', backdropFilter: 'blur(8px)' }}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-[480px] max-w-full overflow-hidden text-center"
|
||||
style={{
|
||||
background: 'var(--gd-bg-2)',
|
||||
border: '1px solid var(--gd-border-2)',
|
||||
borderRadius: 16,
|
||||
boxShadow: '0 24px 60px rgba(0,0,0,0.55)',
|
||||
}}
|
||||
>
|
||||
<div className="px-6 py-5" style={{ borderBottom: '1px solid var(--gd-border)' }}>
|
||||
<div className="text-[13px] font-semibold mb-1">Pair another device</div>
|
||||
<div className="text-[11.5px]" style={{ color: 'var(--gd-text-3)' }}>
|
||||
On the new device (e.g. your Tesla browser) open https://roadtrip.tony.codes and click <b>Connect Tesla</b> → <b>Use 6-digit code</b>, then enter:
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-8">
|
||||
{error ? (
|
||||
<div className="text-[14px]" style={{ color: 'var(--gd-red)' }}>{error}</div>
|
||||
) : code ? (
|
||||
<>
|
||||
<div
|
||||
className="font-semibold tracking-[0.45em] num"
|
||||
style={{ fontSize: 56, color: 'var(--gd-red)' }}
|
||||
>
|
||||
{code}
|
||||
</div>
|
||||
<div className="text-[12px] mt-3" style={{ color: 'var(--gd-text-3)' }}>
|
||||
Expires in {mins}:{String(secs).padStart(2, '0')} · single-use
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-[14px]" style={{ color: 'var(--gd-text-3)' }}>Generating code…</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-6 py-4" style={{ borderTop: '1px solid var(--gd-border)' }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full h-11 text-[13px] rounded-lg"
|
||||
style={{ background: 'var(--gd-panel-2)', border: '1px solid var(--gd-border)', color: 'var(--gd-text-2)' }}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user