feat(vpn-app-desktop): add main screen (#4206)

This commit is contained in:
Pierre Dommerc
2023-12-01 17:55:17 +01:00
committed by GitHub
parent d8c531c294
commit 4f818b0c2e
29 changed files with 397 additions and 157 deletions
+1
View File
@@ -45,5 +45,6 @@ module.exports = {
groups: ['builtin', 'external', 'parent', 'sibling', 'index'],
},
],
'import/extensions': ['error', 'never', { json: 'always', svg: 'always' }],
},
};
+2 -2
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="en" class="text-[12px]">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
@@ -8,7 +8,7 @@
</head>
<body class="h-screen">
<div id="root" class="w-[390px] h-[820px] font-sans"></div>
<div id="root" class="h-full font-sans"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2
View File
@@ -18,6 +18,8 @@
"tauri": "tauri"
},
"dependencies": {
"@headlessui/react": "^1.7.17",
"@headlessui/tailwindcss": "^0.2.0",
"@mui/base": "^5.0.0-beta.24",
"@tauri-apps/api": "^1.5.0",
"clsx": "^2.0.0",
@@ -3,11 +3,14 @@ use std::time::Duration;
use tauri::{Manager, State};
use time::OffsetDateTime;
use tokio::time::sleep;
use tracing::{debug, instrument, trace};
use tracing::{debug, error, instrument, trace};
use crate::{
error::{CmdError, CmdErrorSource},
states::{app::ConnectionState, SharedAppState},
states::{
app::{ConnectionState, VpnMode},
SharedAppData, SharedAppState,
},
};
const EVENT_CONNECTION_STATE: &str = "connection-state";
@@ -178,3 +181,37 @@ pub async fn get_connection_start_time(
let app_state = state.lock().await;
Ok(app_state.connection_start_time.map(|t| t.unix_timestamp()))
}
#[instrument(skip_all)]
#[tauri::command]
pub async fn set_vpn_mode(
app_state: State<'_, SharedAppState>,
data_state: State<'_, SharedAppData>,
mode: VpnMode,
) -> Result<(), CmdError> {
debug!("set_vpn_mode");
let mut state = app_state.lock().await;
if let ConnectionState::Disconnected = state.state {
} else {
let err_message = format!("cannot change vpn mode from state {:?}", state.state);
error!(err_message);
return Err(CmdError::new(CmdErrorSource::CallerError, err_message));
}
state.vpn_mode = mode.clone();
// save the selected mode to disk
let mut app_data_store = data_state.lock().await;
let mut app_data = app_data_store
.read()
.await
.map_err(|e| CmdError::new(CmdErrorSource::InternalError, e.to_string()))?;
app_data.vpn_mode = Some(mode);
app_data_store.data = app_data;
app_data_store
.write()
.await
.map_err(|e| CmdError::new(CmdErrorSource::InternalError, e.to_string()))?;
Ok(())
}
+4 -1
View File
@@ -25,7 +25,10 @@ pub struct CmdError {
impl CmdError {
pub fn new(error: CmdErrorSource, message: String) -> Self {
Self { message, source: error }
Self {
message,
source: error,
}
}
}
+4 -4
View File
@@ -1,11 +1,11 @@
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::states::app::{NodeConfig, PrivacyMode};
use crate::states::app::{NodeConfig, VpnMode};
#[derive(Default, Serialize, Deserialize, Debug, Clone, TS)]
#[ts(export)]
pub enum UiMode {
pub enum UiTheme {
Dark,
#[default]
Light,
@@ -17,8 +17,8 @@ pub struct AppData {
pub monitoring: Option<bool>,
pub autoconnect: Option<bool>,
pub killswitch: Option<bool>,
pub ui_mode: Option<UiMode>,
pub privacy_mode: Option<PrivacyMode>,
pub ui_theme: Option<UiTheme>,
pub vpn_mode: Option<VpnMode>,
pub entry_node: Option<NodeConfig>,
pub exit_node: Option<NodeConfig>,
}
+1
View File
@@ -51,6 +51,7 @@ fn main() -> Result<()> {
Ok(())
})
.invoke_handler(tauri::generate_handler![
connection::set_vpn_mode,
connection::get_connection_state,
connection::connect,
connection::disconnect,
+4 -5
View File
@@ -22,11 +22,10 @@ pub enum ConnectionState {
#[derive(Default, Debug, Serialize, Deserialize, TS, Clone)]
#[ts(export)]
pub enum PrivacyMode {
High,
Medium,
pub enum VpnMode {
Mixnet,
#[default]
Low,
TwoHop,
}
#[derive(Debug, Serialize, Deserialize, TS)]
@@ -40,7 +39,7 @@ pub struct TunnelConfig {
pub struct AppState {
pub state: ConnectionState,
pub error: Option<String>,
pub privacy_mode: PrivacyMode,
pub vpn_mode: VpnMode,
pub entry_node: Option<NodeConfig>,
pub exit_node: Option<NodeConfig>,
pub tunnel: Option<TunnelConfig>,
+3 -3
View File
@@ -44,10 +44,10 @@
"windows": [
{
"fullscreen": false,
"resizable": false,
"resizable": true,
"title": "NymVPN",
"width": 390,
"height": 820
"width": 440,
"height": 920
}
]
}
+2 -2
View File
@@ -38,8 +38,8 @@ export function mockTauriIPC() {
monitoring: false,
autoconnect: false,
killswitch: false,
ui_mode: 'Dark',
privacy_mode: 'High',
ui_theme: 'Dark',
vpn_mode: 'TwoHop',
entry_node: null,
exit_node: null,
}),
+2 -2
View File
@@ -10,13 +10,13 @@
},
"connecting-message": "Establishing connection",
"connection-time": "Connection time",
"select-network-title": "Select network",
"select-network-label": "Select network",
"select-node-title": "Connect to",
"mixnet-mode": {
"title": "5-hop mixnet",
"desc": "Best for payments, emails, messages"
},
"wg-mode": {
"twohop-mode": {
"title": "2-hop WireGuard",
"desc": "Best for browsing, streaming, sharing"
},
-109
View File
@@ -1,109 +0,0 @@
import { useTranslation } from 'react-i18next';
import { invoke } from '@tauri-apps/api';
import clsx from 'clsx';
import { useMainDispatch, useMainState } from '../contexts';
import { ConnectionState, StateDispatch } from '../types';
import { ConnectionTimer } from '../ui';
function Home() {
const state = useMainState();
const dispatch = useMainDispatch() as StateDispatch;
const { t } = useTranslation('home');
const handleClick = async () => {
if (state.state === 'Connected') {
dispatch({ type: 'disconnect' });
invoke('disconnect').then((result) => {
console.log(result);
});
} else if (state.state === 'Disconnected') {
dispatch({ type: 'connect' });
invoke('connect').then((result) => {
console.log(result);
});
}
};
const statusBadgeDynStyles = {
Connected: [
'bg-blanc-nacre-icicle',
'text-vert-menthe',
'dark:bg-baltic-sea-quartzite',
],
Disconnected: [
'bg-blanc-nacre-platinum',
'text-coal-mine-light',
'dark:bg-baltic-sea-oil',
'dark:text-coal-mine-dark',
],
Connecting: [
'bg-blanc-nacre-platinum',
'text-baltic-sea',
'dark:bg-baltic-sea-oil',
'dark:text-white',
],
Disconnecting: [
'bg-blanc-nacre-platinum',
'text-baltic-sea',
'dark:bg-baltic-sea-oil',
'dark:text-white',
],
Unknown: [
'bg-blanc-nacre-platinum',
'text-coal-mine-light',
'dark:bg-baltic-sea-oil',
'dark:text-coal-mine-dark',
],
};
const getStatusText = (state: ConnectionState) => {
switch (state) {
case 'Connected':
return t('status.connected');
case 'Disconnected':
return t('status.disconnected');
case 'Connecting':
return t('status.connecting');
case 'Disconnecting':
return t('status.disconnecting');
case 'Unknown':
return t('status.unknown');
}
};
return (
<div className="h-full">
<div className="h-80 flex flex-col justify-center items-center gap-y-2">
<div className="flex flex-1 items-end">
<div
className={clsx([
...statusBadgeDynStyles[state.state],
'font-bold py-4 px-6 rounded-full text-lg',
])}
>
{getStatusText(state.state)}
</div>
</div>
<div className="flex flex-1">
{state.loading && state.progressMessages.length > 0 && (
<p className="text-dim-gray dark:text-mercury-mist font-bold">
{state.progressMessages[state.progressMessages.length - 1]}
</p>
)}
{state.state === 'Connected' && <ConnectionTimer />}
{state.error && (
<p className="text-teaberry font-bold">{state.error}</p>
)}
</div>
</div>
<div className="flex flex-col justify-center items-center gap-y-2">
<button className="justify-self-end" onClick={handleClick}>
{state.state === 'Disconnected' ? t('connect') : t('disconnect')}
</button>
</div>
</div>
);
}
export default Home;
+1 -1
View File
@@ -3,7 +3,7 @@ import { TopBar } from '../ui';
function NavLayout() {
return (
<div className="h-full bg-blanc-nacre dark:bg-baltic-sea text-baltic-sea dark:text-white">
<div className="h-full flex flex-col bg-blanc-nacre dark:bg-baltic-sea text-baltic-sea dark:text-white">
<TopBar />
<Outlet />
</div>
@@ -0,0 +1,84 @@
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
import { ConnectionState } from '../../types';
import { useMainState } from '../../contexts';
import ConnectionTimer from './ConnectionTimer';
function ConnectionStatus() {
const state = useMainState();
const { t } = useTranslation('home');
const statusBadgeDynStyles = {
Connected: [
'bg-blanc-nacre-icicle',
'text-vert-menthe',
'dark:bg-baltic-sea-quartzite',
],
Disconnected: [
'bg-blanc-nacre-platinum',
'text-coal-mine-light',
'dark:bg-baltic-sea-oil',
'dark:text-coal-mine-dark',
],
Connecting: [
'bg-blanc-nacre-platinum',
'text-baltic-sea',
'dark:bg-baltic-sea-oil',
'dark:text-white',
],
Disconnecting: [
'bg-blanc-nacre-platinum',
'text-baltic-sea',
'dark:bg-baltic-sea-oil',
'dark:text-white',
],
Unknown: [
'bg-blanc-nacre-platinum',
'text-coal-mine-light',
'dark:bg-baltic-sea-oil',
'dark:text-coal-mine-dark',
],
};
const getStatusText = (state: ConnectionState) => {
switch (state) {
case 'Connected':
return t('status.connected');
case 'Disconnected':
return t('status.disconnected');
case 'Connecting':
return t('status.connecting');
case 'Disconnecting':
return t('status.disconnecting');
case 'Unknown':
return t('status.unknown');
}
};
return (
<div className="h-64 flex flex-col justify-center items-center gap-y-2">
<div className="flex flex-1 items-end">
<div
className={clsx([
...statusBadgeDynStyles[state.state],
'font-bold py-4 px-6 rounded-full text-lg',
])}
>
{getStatusText(state.state)}
</div>
</div>
<div className="flex flex-1">
{state.loading && state.progressMessages.length > 0 && (
<p className="text-dim-gray dark:text-mercury-mist font-bold">
{state.progressMessages[state.progressMessages.length - 1]}
</p>
)}
{state.state === 'Connected' && <ConnectionTimer />}
{state.error && (
<p className="text-teaberry font-bold">{state.error}</p>
)}
</div>
</div>
);
}
export default ConnectionStatus;
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs';
import { useMainState } from '../contexts';
import { useMainState } from '../../contexts';
function ConnectionTimer() {
const { sessionStartDate } = useMainState();
+79
View File
@@ -0,0 +1,79 @@
import { useTranslation } from 'react-i18next';
import { invoke } from '@tauri-apps/api';
import clsx from 'clsx';
import { Button } from '@mui/base';
import { useMainDispatch, useMainState } from '../../contexts';
import { ConnectionState, StateDispatch } from '../../types';
import NetworkModeSelect from './NetworkModeSelect';
import ConnectionStatus from './ConnectionStatus';
function Home() {
const state = useMainState();
const dispatch = useMainDispatch() as StateDispatch;
const { t } = useTranslation('home');
const handleClick = async () => {
if (state.state === 'Connected') {
dispatch({ type: 'disconnect' });
invoke('disconnect').then((result) => {
console.log(result);
});
} else if (state.state === 'Disconnected') {
dispatch({ type: 'connect' });
invoke('connect').then((result) => {
console.log(result);
});
}
};
const getButtonText = (state: ConnectionState) => {
switch (state) {
case 'Connected':
return t('disconnect');
case 'Disconnected':
return t('connect');
case 'Connecting':
return (
<div className="flex justify-center items-center animate-spin">
<span className="font-icon text-2xl font-medium">autorenew</span>
</div>
);
case 'Disconnecting':
return (
<div className="flex justify-center items-center animate-spin">
<span className="font-icon text-2xl font-medium">autorenew</span>
</div>
);
case 'Unknown':
return t('status.unknown');
}
};
return (
<div className="h-full flex flex-col p-4">
<ConnectionStatus />
<div className="flex grow flex-col justify-between gap-y-2">
<div className="flex flex-col justify-between">
<NetworkModeSelect />
<div></div>
</div>
<Button
className={clsx([
'rounded-lg text-lg font-bold py-4 px-6 h-16 focus:outline-none focus:ring-4 focus:ring-black focus:dark:ring-white shadow',
(state.state === 'Disconnected' || state.state === 'Connecting') &&
'bg-melon text-white dark:text-baltic-sea',
(state.state === 'Connected' || state.state === 'Disconnecting') &&
'bg-cornflower text-white dark:text-baltic-sea',
])}
onClick={handleClick}
disabled={state.loading}
>
{getButtonText(state.state)}
</Button>
</div>
</div>
);
}
export default Home;
@@ -0,0 +1,116 @@
import { useState } from 'react';
import { RadioGroup } from '@headlessui/react';
import { invoke } from '@tauri-apps/api';
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
import { useMainDispatch, useMainState } from '../../contexts';
import { StateDispatch, VpnMode } from '../../types';
type VpnModeOption = { name: VpnMode; title: string; desc: string };
function NetworkModeSelect() {
const state = useMainState();
const dispatch = useMainDispatch() as StateDispatch;
const [selected, setSelected] = useState(state.vpnMode);
const [loading, setLoading] = useState(false);
const { t } = useTranslation('home');
const handleNetworkModeChange = async (value: VpnMode) => {
if (state.state === 'Disconnected' && value !== state.vpnMode) {
setLoading(true);
try {
await invoke<void>('set_vpn_mode', { mode: value });
dispatch({ type: 'set-vpn-mode', mode: value });
} catch (e) {
console.log(e);
} finally {
setLoading(false);
}
}
};
const vpnModes: VpnModeOption[] = [
{
name: 'Mixnet',
title: t('mixnet-mode.title'),
desc: t('mixnet-mode.desc'),
},
{
name: 'TwoHop',
title: t('twohop-mode.title'),
desc: t('twohop-mode.desc'),
},
];
const handleSelect = (value: VpnMode) => {
setSelected(value);
handleNetworkModeChange(value);
};
return (
<div className="">
<RadioGroup value={selected} onChange={handleSelect}>
<RadioGroup.Label
as="div"
className="font-semibold text-lg text-baltic-sea dark:text-white mb-4"
>
{t('select-network-label')}
</RadioGroup.Label>
<div className="space-y-4">
{vpnModes.map((mode) => (
<RadioGroup.Option
key={mode.name}
value={mode.name}
className={({ checked }) =>
clsx([
'bg-white dark:bg-baltic-sea-jaguar relative flex rounded-lg px-5 py-3 shadow-md focus:outline-none',
(state.state !== 'Disconnected' || loading) &&
'cursor-not-allowed',
checked &&
'ring-0 ring-melon ring-offset-2 ring-offset-melon',
state.state === 'Disconnected' && 'cursor-pointer',
])
}
disabled={state.state !== 'Disconnected' || loading}
>
{({ checked }) => {
return (
<div className="flex flex-1 items-center justify-between gap-4">
{checked ? (
<span className="font-icon text-2xl text-melon">
radio_button_checked
</span>
) : (
<span className="font-icon text-2xl text-mercury-pinkish">
radio_button_unchecked
</span>
)}
<div className="flex flex-1 items-center">
<div className="text-sm">
<RadioGroup.Label
as="p"
className="font-semibold text-lg text-baltic-sea dark:text-mercury-pinkish"
>
{mode.title}
</RadioGroup.Label>
<RadioGroup.Description
as="span"
className="text-base text-ciment-feet dark:text-mercury-mist"
>
<span>{mode.desc}</span>
</RadioGroup.Description>
</div>
</div>
</div>
);
}}
</RadioGroup.Option>
))}
</div>
</RadioGroup>
</div>
);
}
export default NetworkModeSelect;
+1
View File
@@ -0,0 +1 @@
export { default as Home } from './Home';
+1 -1
View File
@@ -1,5 +1,5 @@
export * from './home';
export { default as NavLayout } from './NavLayout';
export { default as Home } from './Home';
export { default as Settings } from './Settings';
export { default as NodeLocation } from './NodeLocation';
export { default as Error } from './Error';
+12 -5
View File
@@ -1,9 +1,10 @@
import dayjs from 'dayjs';
import { AppData, AppState, ConnectionState } from '../types';
import { AppData, AppState, ConnectionState, VpnMode } from '../types';
export type StateAction =
| { type: 'set-partial-state'; partialState: Partial<AppState> }
| { type: 'change-connection-state'; state: ConnectionState }
| { type: 'set-vpn-mode'; mode: VpnMode }
| { type: 'set-error'; error: string }
| { type: 'reset-error' }
| { type: 'new-progress-message'; message: string }
@@ -18,16 +19,16 @@ export type StateAction =
export const initialState: AppState = {
state: 'Disconnected',
loading: false,
privacyMode: 'High',
vpnMode: 'TwoHop',
tunnel: { name: 'nym', id: 'nym' },
uiMode: 'Light',
uiTheme: 'Light',
progressMessages: [],
localAppData: {
monitoring: false,
autoconnect: false,
killswitch: false,
uiMode: 'Light',
privacyMode: 'High',
uiTheme: 'Light',
vpnMode: 'TwoHop',
entryNode: null,
exitNode: null,
},
@@ -35,6 +36,12 @@ export const initialState: AppState = {
export function reducer(state: AppState, action: StateAction): AppState {
switch (action.type) {
case 'set-vpn-mode':
return {
...state,
vpnMode: action.mode,
localAppData: { ...state.localAppData, vpnMode: action.mode },
};
case 'set-partial-state': {
return { ...state, ...action.partialState };
}
+4 -4
View File
@@ -48,8 +48,8 @@ export function MainStateProvider({ children }: Props) {
autoconnect: data.autoconnect || false,
monitoring: data.monitoring || false,
killswitch: data.killswitch || false,
uiMode: data.ui_mode || 'Light',
privacyMode: data.privacy_mode || 'High',
uiTheme: data.ui_theme || 'Light',
vpnMode: data.vpn_mode || 'TwoHop',
entryNode: data.entry_node,
exitNode: data.exit_node,
},
@@ -57,8 +57,8 @@ export function MainStateProvider({ children }: Props) {
dispatch({
type: 'set-partial-state',
partialState: {
uiMode: data.ui_mode || 'Light',
privacyMode: data.privacy_mode || 'High',
uiTheme: data.ui_theme || 'Light',
vpnMode: data.vpn_mode || 'TwoHop',
},
});
})
+6 -6
View File
@@ -1,6 +1,6 @@
import { PrivacyMode } from './app-state';
import { VpnMode } from './app-state';
export type UiMode = 'Dark' | 'Light';
export type UiTheme = 'Dark' | 'Light';
export interface NodeConfig {
id: string;
@@ -11,8 +11,8 @@ export interface AppData {
monitoring: boolean;
autoconnect: boolean;
killswitch: boolean;
uiMode: UiMode;
privacyMode: PrivacyMode;
uiTheme: UiTheme;
vpnMode: VpnMode;
entryNode?: NodeConfig | null;
exitNode?: NodeConfig | null;
}
@@ -22,8 +22,8 @@ export interface AppDataFromBackend {
monitoring: boolean | null;
autoconnect: boolean | null;
killswitch: boolean | null;
ui_mode: UiMode | null;
privacy_mode: PrivacyMode;
ui_theme: UiTheme | null;
vpn_mode: VpnMode;
entry_node: NodeConfig | null;
exit_node: NodeConfig | null;
}
+3 -3
View File
@@ -10,7 +10,7 @@ export type ConnectionState =
| 'Disconnecting'
| 'Unknown';
export type PrivacyMode = 'High' | 'Medium' | 'Low';
export type VpnMode = 'TwoHop' | 'Mixnet';
export interface TunnelConfig {
id: string;
@@ -23,9 +23,9 @@ export type AppState = {
error?: string | null;
progressMessages: string[];
sessionStartDate?: Dayjs | null;
privacyMode: PrivacyMode;
vpnMode: VpnMode;
tunnel: TunnelConfig;
uiMode: 'Light' | 'Dark';
uiTheme: 'Light' | 'Dark';
localAppData: AppData;
};
+1 -1
View File
@@ -1,3 +1,3 @@
import { routes } from '../constants.ts';
import { routes } from '../constants';
export type Routes = (typeof routes)[keyof typeof routes];
+2 -2
View File
@@ -7,10 +7,10 @@ export default function ThemeSetter({
}: {
children: React.ReactNode;
}) {
const { uiMode } = useMainState();
const { uiTheme } = useMainState();
return (
<div className={clsx([uiMode === 'Dark' && 'dark', 'h-full'])}>
<div className={clsx([uiTheme === 'Dark' && 'dark', 'h-full'])}>
{children}
</div>
);
+1 -1
View File
@@ -67,7 +67,7 @@ export default function TopBar() {
}, [location.pathname, navBarData]);
return (
<nav className="flex flex-row flex-nowrap justify-between items-center bg-white text-baltic-sea dark:bg-baltic-sea-jaguar dark:text-mercury-pinkish h-16 text-xl">
<nav className="flex flex-row flex-nowrap justify-between items-center shrink-0 bg-white text-baltic-sea dark:bg-baltic-sea-jaguar dark:text-mercury-pinkish h-16 text-xl">
{currentNavLocation?.leftIcon ? (
<button className="w-6 mx-4" onClick={currentNavLocation.handleLeftNav}>
<span className="font-icon dark:text-laughing-jack text-2xl">
-1
View File
@@ -1,3 +1,2 @@
export { default as ThemeSetter } from './ThemeSetter';
export { default as TopBar } from './TopBar';
export { default as ConnectionTimer } from './ConnectionTimer';
+4 -1
View File
@@ -1,5 +1,6 @@
import type { Config } from 'tailwindcss';
import defaultTheme from 'tailwindcss/defaultTheme';
import headlessui from '@headlessui/tailwindcss';
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
@@ -66,6 +67,8 @@ export default {
'gun-powder': '#49454F',
// [D] top-bar icon
'laughing-jack': '#CAC4D0',
// [L] button bg in disabled state
'wind-chime': '#DEDEE1',
},
extend: {
fontFamily: {
@@ -79,7 +82,7 @@ export default {
},
},
},
plugins: [],
plugins: [headlessui],
// Toggling dark mode manually
darkMode: 'class',
} satisfies Config;
+17
View File
@@ -188,6 +188,18 @@
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.1.6.tgz#22958c042e10b67463997bd6ea7115fe28cbcaf9"
integrity sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==
"@headlessui/react@^1.7.17":
version "1.7.17"
resolved "https://registry.yarnpkg.com/@headlessui/react/-/react-1.7.17.tgz#a0ec23af21b527c030967245fd99776aa7352bc6"
integrity sha512-4am+tzvkqDSSgiwrsEpGWqgGo9dz8qU5M3znCkC4PgkpY4HcCZzEDEvozltGGGHIKl9jbXbZPSH5TWn4sWJdow==
dependencies:
client-only "^0.0.1"
"@headlessui/tailwindcss@^0.2.0":
version "0.2.0"
resolved "https://registry.yarnpkg.com/@headlessui/tailwindcss/-/tailwindcss-0.2.0.tgz#2c55c98fd8eee4b4f21ec6eb35a014b840059eec"
integrity sha512-fpL830Fln1SykOCboExsWr3JIVeQKieLJ3XytLe/tt1A0XzqUthOftDmjcCYLW62w7mQI7wXcoPXr3tZ9QfGxw==
"@humanwhocodes/config-array@^0.11.13":
version "0.11.13"
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.13.tgz#075dc9684f40a531d9b26b0822153c1e832ee297"
@@ -932,6 +944,11 @@ chokidar@^3.5.3:
optionalDependencies:
fsevents "~2.3.2"
client-only@^0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1"
integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==
clsx@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.0.0.tgz#12658f3fd98fafe62075595a5c30e43d18f3d00b"