Лендинг

This commit is contained in:
RoyceDa
2026-02-16 16:58:53 +02:00
commit 20fc9eed8a
30 changed files with 4818 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

23
eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

BIN
favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Rosetta.IM</title>
<link rel="icon" href="favicon.ico" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3871
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

43
package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "rosetta-landing",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@mantine/core": "^8.3.9",
"@mantine/hooks": "^8.3.9",
"@mantinex/dev-icons": "^2.0.0",
"@tabler/icons": "^3.35.0",
"@tabler/icons-react": "^3.35.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-icons": "^5.5.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/events": "^3.0.3",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

14
postcss.config.cjs Normal file
View File

@@ -0,0 +1,14 @@
module.exports = {
plugins: {
'postcss-preset-mantine': {},
'postcss-simple-vars': {
variables: {
'mantine-breakpoint-xs': '36em',
'mantine-breakpoint-sm': '48em',
'mantine-breakpoint-md': '62em',
'mantine-breakpoint-lg': '75em',
'mantine-breakpoint-xl': '88em',
},
},
},
};

20
src/App.tsx Normal file
View File

@@ -0,0 +1,20 @@
// Import styles of packages that you've installed.
// All packages except `@mantine/hooks` require styles imports
import '@mantine/core/styles.css';
import { MantineProvider } from '@mantine/core';
import { HeroTitle } from './components/HeroTitle/HeroTitle';
import { DownloadCenter } from './components/DownloadCenter/DownloadCenter';
import { FeaturesGrid } from './components/FeaturesGrid/FeaturesGrid';
import './style.css'
import { MessageSteps } from './components/MessageSteps/MessageSteps';
export default function App() {
return <MantineProvider>
<HeroTitle></HeroTitle>
<FeaturesGrid></FeaturesGrid>
<MessageSteps></MessageSteps>
<DownloadCenter></DownloadCenter>
</MantineProvider>;
}

View File

@@ -0,0 +1,12 @@
.control {
height: 54px;
padding-left: 38px;
padding-right: 38px;
@media (max-width: $mantine-breakpoint-sm) {
height: 54px;
padding-left: 18px;
padding-right: 18px;
flex: 1;
}
}

View File

@@ -0,0 +1,50 @@
import { Button } from "@mantine/core";
import classes from './Download.module.css';
import { RosettaLogo } from "../RosettaLogo/RosettaLogo";
interface DownloadProps {
href: string;
}
export function Download(props: DownloadProps) {
/*const protocol = useMemo(() => {
let protocol = new Protocol('ws://127.0.0.1:3000');
protocol.addSupportedPacket(new PacketRequestUpdate());
protocol.addSupportedPacket(new PacketKernelUpdate());
return protocol;
}, []);
useEffect(() => {
if(!protocol){
return;
}
const platform = getPlatformTranslated();
let packet = new PacketRequestUpdate();
packet.setPlatform(platform);
packet.setKernelVersion("0.0.0");
packet.setArch("x64");
protocol.sendPacket(packet);
}, [protocol]);
protocol.waitPacket(0x0D, (packet : PacketKernelUpdate) => {
console.info(packet);
});*/
return (
<Button.Group>
<Button
size="xl"
component="a"
className={classes.control}
variant={'default'}
href={props.href}
leftSection={
<RosettaLogo size={25}></RosettaLogo>
}
>
Get Rosetta
</Button>
</Button.Group>
);
}

View File

@@ -0,0 +1,41 @@
.wrapper {
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding-top: var(--mantine-spacing-xl);
padding-bottom: var(--mantine-spacing-xl);
background-color: #0066ff05;
}
.title {
font-size: 34px;
font-weight: 500;
@media (max-width: $mantine-breakpoint-sm) {
font-size: 24px;
}
}
.description {
max-width: 600px;
margin: auto;
}
.card {
width: 300px;
height: 300px;
border: 1px solid light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
}
.cardTitle {
&::after {
content: '';
display: block;
background-color: var(--mantine-color-blue-filled);
width: 45px;
height: 2px;
margin-top: var(--mantine-spacing-sm);
}
}

View File

@@ -0,0 +1,160 @@
import {
Button,
Card,
Container,
Flex,
Text,
Title,
useComputedColorScheme,
useMantineTheme,
} from '@mantine/core';
import classes from './DownloadCenter.module.css';
import { RosettaLogo } from '../RosettaLogo/RosettaLogo';
import { useEffect, useState } from 'react';
import { FaApple, FaWindows } from 'react-icons/fa';
import { FcLinux } from 'react-icons/fc';
import { Switch } from '../Switch/Switch';
import { IconDownload } from '@tabler/icons-react';
interface DownloadFeature {
platform: string;
arch: string;
link: string;
version: string;
}
interface UpdateItem {
platform: string;
arch: string;
version: string;
downloadUrl: string;
}
const fetchUpdates = async (): Promise<DownloadFeature[]> => {
try {
const response = await fetch('https://sdu.rosetta.im/updates/all');
const data = await response.json();
return (data.items || []).map((item: UpdateItem) => ({
platform: item.platform,
arch: item.arch,
version: item.version,
link: new URL(item.downloadUrl, 'https://sdu.rosetta.im').toString(),
}));
} catch (error) {
console.error('Failed to fetch updates:', error);
return [];
}
};
export function DownloadCenter() {
const theme = useMantineTheme();
const colorScheme = useComputedColorScheme();
const [kernels, setKernels] = useState<DownloadFeature[]>([]);
const [targetPlatforms, setTargetPlatforms] = useState<string[]>(['darwin', 'linux', 'win32']);
useEffect(() => {
fetchUpdates().then(setKernels);
}, []);
const switchTarget = (value: string) => {
let targetPlatforms: string[] = [];
switch(value){
case 'All':
targetPlatforms = ['darwin', 'linux', 'win32'];
break;
case 'Windows':
targetPlatforms = ['win32'];
break;
case 'macOS':
targetPlatforms = ['darwin'];
break;
case 'Linux':
targetPlatforms = ['linux'];
break;
}
setTargetPlatforms(targetPlatforms);
}
const translatePlatformToOsName = (platform: string) => {
switch(platform){
case 'darwin':
return 'macOS';
case 'linux':
return 'Linux';
case 'win32':
return 'Windows';
default:
return platform;
}
}
const translatePlatformAndArchToCpuName = (platform : string, arch: string) => {
switch(platform){
case 'darwin':
return arch === 'arm64' ? 'Apple Silicon' : 'Intel';
case 'linux':
return arch === 'arm64' ? 'ARM64' : 'x64';
case 'win32':
return arch === 'arm64' ? 'ARM64' : 'x64';
default:
return arch;
}
}
const features = kernels.filter((v) => targetPlatforms.includes(v.platform)).map((feature) => (
<Card key={feature.link} radius="md" className={classes.card} padding="xl">
<Flex direction={'column'} align="center" justify={'center'}>
{feature.platform === 'darwin' && <FaApple size={50} color={
colorScheme == 'dark' ? 'white' : 'black'
} />}
{feature.platform === 'linux' && <FcLinux size={50} />}
{feature.platform === 'win32' && <FaWindows size={50} color={theme.colors.blue[6]} />}
<Text fz="lg" ta={'center'} fw={500} mt="md">
{translatePlatformToOsName(feature.platform)} - {translatePlatformAndArchToCpuName(feature.platform, feature.arch)}
</Text>
<Text c="dimmed" fz="sm" ta={'center'} mt="xs">
{feature.platform == 'darwin' && <>
{feature.arch == 'arm64' && <>
Download this version if you have a Mac with Apple Silicon (M1, M2 chips).
</>}
{feature.arch == 'x64' && <>
Download this version if you have a Mac with an Intel processor, which was most likely manufactured before 2020.
</>}
</>}
{feature.platform == 'linux' && <>This version is for Linux and comes as an AppImage for the GUI versions.</>}
{feature.platform == 'win32' && <>Download this version of you have computer on Windows 10 or later.</>}
</Text>
<Button component={'a'} href={feature.link} variant={'subtle'} mt={'sm'} leftSection={
<IconDownload size={16}></IconDownload>
}>Get {feature.version} now</Button>
</Flex>
</Card>
));
return (
<div className={classes.wrapper}>
<Container id={'kernels'} mb={'xl'} size="lg" mt={'xl'}>
<Flex justify="center" align="center" direction="row" gap={'md'}>
<Title order={2} className={classes.title} ta="center">
Download
</Title>
<RosettaLogo size={35}></RosettaLogo>
</Flex>
<Text c="dimmed" className={classes.description} ta="center" mt="md">
Choose your platform and get started with Rosetta today. Enjoy secure and fast messaging across all your devices.
</Text>
<Flex justify={'center'} mt={'lg'}>
<Switch onChange={switchTarget} data={['All', 'Windows', 'macOS', 'Linux']}></Switch>
</Flex>
<Flex gap="xl" justify={'center'} wrap={'wrap'} align={'center'} mt={50}>
{features}
</Flex>
</Container>
</div>
);
}

View File

@@ -0,0 +1,29 @@
.wrapper {
display: flex;
justify-content: flex-start;
flex-direction: column;
background-color: #0066ff05;
min-height: 100vh;
padding-top: var(--mantine-spacing-xl);
padding-bottom: var(--mantine-spacing-xl);
}
.title {
font-family: Outfit, var(--mantine-font-family);
font-weight: 500;
margin-bottom: var(--mantine-spacing-md);
text-align: center;
@media (max-width: $mantine-breakpoint-sm) {
font-size: 28px;
text-align: left;
}
}
.description {
text-align: center;
@media (max-width: $mantine-breakpoint-sm) {
text-align: left;
}
}

View File

@@ -0,0 +1,97 @@
import { IconGauge, IconLock, IconMessage2, IconServer, IconUser, IconUsersGroup } from '@tabler/icons-react';
import { Container, Flex, SimpleGrid, Text, ThemeIcon, Title } from '@mantine/core';
import classes from './FeaturesGrid.module.css';
import { RosettaLogo } from '../RosettaLogo/RosettaLogo';
export const MOCKDATA = [
{
icon: IconGauge,
title: 'Performance',
description:
'Despite encryption, the messenger remains fast and convenient.',
},
{
icon: IconUser,
title: 'Privacy focused',
description:
'We encrypt everything, even media files, so that no one can access your files.',
},
{
icon: IconServer,
title: 'Server does not save messages',
description:
'The server does not store messages. All messages are stored only on your devices.',
},
{
icon: IconLock,
title: 'Secure by default',
description:
'The messenger uses asymmetric encryption to hide message keys. ChaCha20 is used for message encryption, and AES-256 is used for encrypting your media files.',
},
{
icon: IconMessage2,
title: 'Regular Updates',
description:
'We strive to update the messenger as often as possible to fix bugs and add new features. This is not an abandoned project.',
},
{
icon: IconUsersGroup,
title: 'No trackers or ads',
description:
'Registration without phone number, email, or other personal data. Only public or private keys. We do not use trackers and do not show ads.',
}
];
interface FeatureProps {
icon: React.FC<any>;
title: React.ReactNode;
description: React.ReactNode;
}
export function Feature({ icon: Icon, title, description }: FeatureProps) {
return (
<div>
<ThemeIcon variant="light" size={40} radius={40}>
<Icon size={18} stroke={1.5} />
</ThemeIcon>
<Text mt="sm" mb={7}>
{title}
</Text>
<Text size="sm" c="dimmed" lh={1.6}>
{description}
</Text>
</div>
);
}
export function FeaturesGrid() {
const features = MOCKDATA.map((feature, index) => <Feature {...feature} key={index} />);
return (
<div className={classes.wrapper}>
<Container>
<Flex justify={'center'} mb={'lg'} align={'center'}>
<RosettaLogo size={50}></RosettaLogo>
</Flex>
<Title ta="center" className={classes.title}>
Heres what we offer
</Title>
<Container size={560} p={0}>
<Text size="sm" className={classes.description}>
Rosetta offers a wide range of features that ensure security, privacy, and ease of use. Explore the key features that make Rosetta an excellent choice for messaging.
</Text>
</Container>
<SimpleGrid
mt={60}
cols={{ base: 1, sm: 2, md: 3 }}
spacing={{ base: 'xl', md: 50 }}
verticalSpacing={{ base: 'xl', md: 50 }}
>
{features}
</SimpleGrid>
</Container>
</div>
);
}

View File

@@ -0,0 +1,59 @@
.wrapper {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
box-sizing: border-box;
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-8));
height: 100vh;
}
.inner {
position: relative;
}
.title {
font-family: Outfit, var(--mantine-font-family);
font-size: 62px;
font-weight: 500;
line-height: 1.1;
margin: 0;
word-wrap: normal;
padding: 0;
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
@media (max-width: $mantine-breakpoint-sm) {
font-size: 42px;
line-height: 1.2;
}
}
.description {
margin-top: var(--mantine-spacing-xl);
font-size: 24px;
@media (max-width: $mantine-breakpoint-sm) {
font-size: 18px;
}
}
.controls {
margin-top: calc(var(--mantine-spacing-xl) * 2);
@media (max-width: $mantine-breakpoint-sm) {
margin-top: var(--mantine-spacing-xl);
}
}
.control {
height: 54px;
padding-left: 38px;
padding-right: 38px;
@media (max-width: $mantine-breakpoint-sm) {
height: 54px;
padding-left: 18px;
padding-right: 18px;
flex: 1;
}
}

View File

@@ -0,0 +1,27 @@
import { Container, Group, Text } from '@mantine/core';
import classes from './HeroTitle.module.css';
import { Download } from '../Download/Download';
export function HeroTitle() {
return (
<div className={classes.wrapper}>
<Container className={classes.inner}>
<h1 className={classes.title}>
Rosetta is{' '}
<Text component="span" variant="gradient" gradient={{ from: 'blue', to: 'cyan' }} inherit>
secure
</Text>{' '}
messaging for everyone
</h1>
<Text className={classes.description} c="dimmed">
Rosetta is designed to ensure secure messaging. We use comprehensive encryption, which keeps the app fast and convenient, yet secure.
</Text>
<Group className={classes.controls}>
<Download href="#kernels"></Download>
</Group>
</Container>
</div>
);
}

View File

@@ -0,0 +1,28 @@
.wrapper {
display: flex;
justify-content: flex-start;
flex-direction: column;
min-height: 100vh;
padding-top: var(--mantine-spacing-xl);
padding-bottom: var(--mantine-spacing-xl);
}
.title {
font-family: Outfit, var(--mantine-font-family);
font-weight: 500;
margin-bottom: var(--mantine-spacing-md);
text-align: center;
@media (max-width: $mantine-breakpoint-sm) {
font-size: 28px;
text-align: left;
}
}
.description {
text-align: center;
@media (max-width: $mantine-breakpoint-sm) {
text-align: left;
}
}

View File

@@ -0,0 +1,45 @@
import { Container, Flex, Text, Title } from '@mantine/core';
import classes from './MessageSteps.module.css';
import { RosettaLogo } from '../RosettaLogo/RosettaLogo';
import { Switch } from '../Switch/Switch';
import { useState } from 'react';
import { Sender } from '../Sender/Sender';
import { Recipient } from '../Recipient/Recipient';
export function MessageSteps() {
const [show, setShow] = useState('Sender');
return (
<div className={classes.wrapper}>
<Container>
<Flex justify={'center'} mb={'lg'} align={'center'}>
<RosettaLogo size={50}></RosettaLogo>
</Flex>
<Title ta="center" className={classes.title}>
How is my message sent?
</Title>
<Container size={560} p={0}>
<Text size="sm" className={classes.description}>
See why Rosetta is truly secure. Notice how your message is sent and received from you to the recipient. The steps highlighted in blue are what you see, while the steps not highlighted are what happens behind the scenes.
</Text>
</Container>
<Flex align={'center'} justify={'center'} mt={'xl'}>
<Switch data={[
'Sender',
'Recipient'
]} onChange={(v) => setShow(v)}></Switch>
</Flex>
<Flex justify={'center'} align={'center'} mt={'xl'} gap={'xl'}>
<Flex>
{show == 'Sender' && <Sender></Sender>}
{show == 'Recipient' && <Recipient></Recipient>}
</Flex>
</Flex>
</Container>
</div>
);
}

View File

@@ -0,0 +1,45 @@
import { Text, Timeline } from "@mantine/core";
import { IconDeviceDesktop, IconKey, IconLockAccess, IconMessage, IconMessage2, IconServer } from "@tabler/icons-react";
export function Recipient() {
return (
<Timeline reverseActive active={0} bulletSize={40} lineWidth={2}>
<Timeline.Item bullet={<IconServer size={20} />} title="Retranslate">
<Text c="dimmed" size="sm">
Server retranslate message to recipient.
</Text>
<Text size="xs" mt={4}>After retranslate server deletes message from memory.</Text>
</Timeline.Item>
<Timeline.Item bullet={<IconMessage size={20} />} title="Receive message">
<Text c="dimmed" size="sm">
Recipient device receives encrypted message and encrypted ChaCha20 key from server.
</Text>
<Text size="xs" mt={4}>Message now encrypted.</Text>
</Timeline.Item>
<Timeline.Item bullet={<IconLockAccess size={20} />} title="Decrypting ChaCha20 key">
<Text c="dimmed" size="sm">
Decrypt ChaCha20 key with recipient private key using RSA-4096 decryption.
</Text>
<Text size="xs" mt={4}>Message still encrypted.</Text>
</Timeline.Item>
<Timeline.Item bullet={<IconKey size={20} />} title="Decrypting message using ChaCha20">
<Text c="dimmed" size="sm">
Decrypting text of message or attachments using decrypted ChaCha20 key from previous step.
</Text>
<Text size="xs" mt={4}>Message text is available.</Text>
</Timeline.Item>
<Timeline.Item bullet={<IconDeviceDesktop size={20} />} title="Save message on your device">
<Text c="dimmed" size="sm">
Encrypt decrypted text with AES-256 for save in your device.
</Text>
<Text size="xs" mt={4}>Need for future access, because message not save on server.</Text>
</Timeline.Item>
<Timeline.Item bullet={<IconMessage2 size={20} />} title="Show message">
<Text c="dimmed" size="sm">
Message available for reading in your chat.
</Text>
<Text size="xs" mt={4}>Final step.</Text>
</Timeline.Item>
</Timeline>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,7 @@
import logo from './512x512.png';
export function RosettaLogo({size}: {size?: number}) {
return (
<img src={logo} alt="Rosetta Logo" width={size || 100} height={size || 100} />
);
}

View File

@@ -0,0 +1,38 @@
import { Text, Timeline } from "@mantine/core";
import { IconDeviceDesktop, IconKering, IconKey, IconMessage, IconServer } from "@tabler/icons-react";
export function Sender() {
return (
<Timeline active={0} bulletSize={40} lineWidth={2}>
<Timeline.Item bullet={<IconMessage size={20} />} title="Enter message">
<Text c="dimmed" size="sm">
You enter message text in chat.
</Text>
</Timeline.Item>
<Timeline.Item bullet={<IconDeviceDesktop size={20} />} title="Encrypt for you">
<Text c="dimmed" size="sm">
Message encrypted for your device using AES-256 encryption.
</Text>
<Text size="xs" mt={4}>Need for safety save message on your device.</Text>
</Timeline.Item>
<Timeline.Item bullet={<IconKey size={20} />} title="Encrypt for opponent">
<Text c="dimmed" size="sm">
Encrypt message for recipient using ChaCha20 encryption.
</Text>
<Text size="xs" mt={4}>Encrypt message with chacha20 with key.</Text>
</Timeline.Item>
<Timeline.Item bullet={<IconKering size={20} />} title="Encrypt ChaCha20 key to send">
<Text c="dimmed" size="sm">
Encrypt key of previous step with recipient public key using RSA-4096 encryption.
</Text>
<Text size="xs" mt={4}>Needs for send encrypted key to opponent.</Text>
</Timeline.Item>
<Timeline.Item bullet={<IconServer size={20} />} title="Send encrypted data to server">
<Text c="dimmed" size="sm">
Send encrypted message and encrypted ChaCha20 key to server for delivery to recipient.
</Text>
<Text size="xs" mt={4}>See next steps in recipient timeline.</Text>
</Timeline.Item>
</Timeline>
)
}

View File

@@ -0,0 +1,37 @@
.root {
position: relative;
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
width: fit-content;
border-radius: var(--mantine-radius-md);
padding: 5px;
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
}
.control {
padding: 7px 12px;
line-height: 1;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-2));
border-radius: var(--mantine-radius-md);
font-size: var(--mantine-font-size-sm);
transition: color 100ms ease;
font-weight: 500;
@mixin hover {
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-7));
}
&[data-active] {
color: var(--mantine-color-white);
}
}
.controlLabel {
position: relative;
z-index: 1;
}
.indicator {
background-color: var(--mantine-primary-color-filled);
border-radius: var(--mantine-radius-md);
}

View File

@@ -0,0 +1,51 @@
import { useState } from 'react';
import { FloatingIndicator, UnstyledButton } from '@mantine/core';
import classes from './Switch.module.css';
interface SwitchProps {
data: string[];
onChange?: (value: string) => void;
}
export function Switch(props: SwitchProps) {
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
const [controlsRefs, setControlsRefs] = useState<Record<string, HTMLButtonElement | null>>({});
const [active, setActive] = useState(0);
const setControlRef = (index: number) => (node: HTMLButtonElement) => {
controlsRefs[index] = node;
setControlsRefs(controlsRefs);
};
const changeActive = (index: number) => {
setActive(index);
if(!props.onChange){
return;
}
props.onChange(props.data[index]);
}
const controls = props.data.map((item, index) => (
<UnstyledButton
key={item}
className={classes.control}
ref={setControlRef(index)}
onClick={() => changeActive(index)}
mod={{ active: active === index }}
>
<span className={classes.controlLabel}>{item}</span>
</UnstyledButton>
));
return (
<div className={classes.root} ref={setRootRef}>
{controls}
<FloatingIndicator
target={controlsRefs[active]}
parent={rootRef}
className={classes.indicator}
/>
</div>
);
}

7
src/main.tsx Normal file
View File

@@ -0,0 +1,7 @@
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<App />
)

3
src/style.css Normal file
View File

@@ -0,0 +1,3 @@
*{
scroll-behavior: smooth;
}

28
tsconfig.app.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

13
vite.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [
react({
babel: {
plugins: [['babel-plugin-react-compiler']],
},
}),
],
})