project initiate.
This commit is contained in:
81
src/Layout.module.css
Normal file
81
src/Layout.module.css
Normal file
@@ -0,0 +1,81 @@
|
||||
@layer pages {
|
||||
.layout {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
padding-block-start: calc(var(--spacing));
|
||||
padding-block-end: calc(var(--spacing) * 4);
|
||||
padding-inline: calc(var(--spacing) * 4);
|
||||
margin: 0;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: calc(var(--spacing) * 3);
|
||||
header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
height: calc(var(--spacing) * 6);
|
||||
z-index: 10;
|
||||
&.mac_titlebar {
|
||||
padding-left: calc(var(--spacing) * 14);
|
||||
}
|
||||
h1 {
|
||||
line-height: 1em;
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
gap: calc(var(--spacing) * 3);
|
||||
menu {
|
||||
flex: 1;
|
||||
padding: calc(var(--spacing) * 4) 0;
|
||||
max-width: calc(var(--spacing) * 12);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: calc(var(--spacing) * 4);
|
||||
}
|
||||
.main_content {
|
||||
flex: 1;
|
||||
border-radius: calc(var(--border-radius) * 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
.window_move_handler {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: calc(var(--spacing) * 8);
|
||||
z-index: 300;
|
||||
}
|
||||
.route_link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: calc(var(--spacing));
|
||||
div {
|
||||
text-align: center;
|
||||
&.filled {
|
||||
color: var(--color-dark-on-surface);
|
||||
background-color: transparent;
|
||||
border-radius: calc(var(--border-radius) * 2);
|
||||
padding-block: calc(var(--spacing));
|
||||
}
|
||||
}
|
||||
&.inactive {
|
||||
color: var(--color-dark-on-surface-variant);
|
||||
}
|
||||
&.active {
|
||||
color: var(--color-dark-on-surface);
|
||||
div.filled {
|
||||
color: var(--color-dark-on-secondary-container);
|
||||
background-color: var(--color-dark-secondary-container);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
70
src/Layout.tsx
Normal file
70
src/Layout.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Icon } from '@iconify/react/dist/iconify.js';
|
||||
import { platform } from '@tauri-apps/plugin-os';
|
||||
import cx from 'clsx';
|
||||
import { FC, ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import styles from './Layout.module.css';
|
||||
import { defaultIconProps } from './icons/shared-props';
|
||||
import StateBar from './page-components/state-bar/StateBar';
|
||||
|
||||
type FunctionLinkProps = {
|
||||
name: string;
|
||||
url: string;
|
||||
end?: boolean;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const FunctionLink: FC<FunctionLinkProps> = ({ name, url, end, children }) => {
|
||||
return (
|
||||
<NavLink
|
||||
to={url}
|
||||
className={({ isActive }) =>
|
||||
cx(styles.route_link, isActive ? styles.active : styles.inactive)
|
||||
}
|
||||
end={end}>
|
||||
<div className={styles.filled}>{children}</div>
|
||||
<div>{name}</div>
|
||||
</NavLink>
|
||||
);
|
||||
};
|
||||
const Layout: FC = () => {
|
||||
const os = platform();
|
||||
|
||||
return (
|
||||
<main className={styles.layout}>
|
||||
<header className={cx({ [styles.mac_titlebar]: os === 'macos' })}>
|
||||
<h1>ESTIM Remote</h1>
|
||||
<StateBar />
|
||||
</header>
|
||||
<section>
|
||||
<menu>
|
||||
<FunctionLink name="Device" url="/" end>
|
||||
<Icon icon="material-symbols-light:device-unknown" {...defaultIconProps} />
|
||||
</FunctionLink>
|
||||
<FunctionLink name="Play" url="/play">
|
||||
<Icon icon="material-symbols-light:motion-play" {...defaultIconProps} />
|
||||
</FunctionLink>
|
||||
<FunctionLink name="Pattern Library" url="/library">
|
||||
<Icon icon="material-symbols-light:menu-book" {...defaultIconProps} />
|
||||
</FunctionLink>
|
||||
<FunctionLink name="Pattern Editor" url="/pattern-editor">
|
||||
<Icon icon="material-symbols-light:movie-edit" {...defaultIconProps} />
|
||||
</FunctionLink>
|
||||
<FunctionLink name="Settings" url="/settings">
|
||||
<Icon icon="material-symbols-light:settings" {...defaultIconProps} />
|
||||
</FunctionLink>
|
||||
</menu>
|
||||
<div className={styles.main_content}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</section>
|
||||
{createPortal(
|
||||
<div data-tauri-drag-region className={styles.window_move_handler} />,
|
||||
document.body,
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
203
src/components.css
Normal file
203
src/components.css
Normal file
@@ -0,0 +1,203 @@
|
||||
@layer base {
|
||||
:root {
|
||||
--button-text: var(--color-dark-on-primary);
|
||||
--button-surface: var(--color-dark-primary);
|
||||
--button-outline: var(--color-dark-outline);
|
||||
}
|
||||
|
||||
:where(ul, menu) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:where(a) {
|
||||
text-decoration: none;
|
||||
color: var(--color-dark-on-surface);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-dark-primary);
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: var(--color-dark-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
:is(h1, h2, h3, h4, h5, h6) {
|
||||
font-weight: bold;
|
||||
line-height: 1.2em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.6em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.8em;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.center {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1 1;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: calc(var(--spacing) * 6);
|
||||
padding: calc(var(--spacing) * 2) calc(var(--spacing) * 2);
|
||||
&.veritcal {
|
||||
flex-direction: column;
|
||||
}
|
||||
&.horizontal {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
:where(button, .button) {
|
||||
border: none;
|
||||
border-radius: calc(var(--border-radius) * 2);
|
||||
padding: calc(var(--spacing) * 1) calc(var(--spacing) * 3);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: calc(var(--spacing) * 2);
|
||||
font-size: calc(var(--font-size) * 1.2);
|
||||
line-height: 1.3em;
|
||||
color: var(--button-text);
|
||||
background-color: var(--button-surface);
|
||||
box-shadow: var(--elevation-dark-0);
|
||||
&.smaller {
|
||||
font-size: calc(var(--font-size) * 0.8);
|
||||
}
|
||||
&.small {
|
||||
font-size: calc(var(--font-size) * 1);
|
||||
}
|
||||
&.large {
|
||||
font-size: calc(var(--font-size) * 1.4);
|
||||
}
|
||||
&.larger {
|
||||
font-size: calc(var(--font-size) * 1.6);
|
||||
}
|
||||
&:hover:not(:disabled) {
|
||||
--button-text: var(--color-dark-on-primary);
|
||||
box-shadow: var(--elevation-dark-1-ambient), var(--elevation-dark-1-umbra);
|
||||
}
|
||||
&:active:not(:disabled) {
|
||||
color: color-mix(in oklch, var(--button-text) 12%, transparent);
|
||||
}
|
||||
&.tonal:not(:disabled) {
|
||||
--button-text: var(--color-dark-on-primary-container);
|
||||
--button-surface: var(--color-dark-primary-container);
|
||||
}
|
||||
&.danger:not(:disabled) {
|
||||
--button-text: var(--color-dark-on-error);
|
||||
--button-surface: var(--color-dark-error);
|
||||
}
|
||||
&.warn:not(:disabled) {
|
||||
--button-text: var(--color-dark-on-warning);
|
||||
--button-surface: var(--color-dark-warning);
|
||||
}
|
||||
&.success:not(:disabled) {
|
||||
--button-text: var(--color-dark-on-success);
|
||||
--button-surface: var(--color-dark-success);
|
||||
}
|
||||
&.info:not(:disabled) {
|
||||
--button-text: var(--color-dark-on-info);
|
||||
--button-surface: var(--color-dark-info);
|
||||
}
|
||||
&:disabled {
|
||||
--button-text: color-mix(in oklch, var(--color-dark-on-surface) 38%, transparent);
|
||||
--button-surface: color-mix(in oklch, var(--color-dark-on-surface) 12%, transparent);
|
||||
--button-outline: color-mix(in oklch, var(--color-dark-on-surface) 12%, transparent);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
&.outline {
|
||||
--button-text: var(--color-dark-primary);
|
||||
--button-surface: transparent;
|
||||
border: 1px solid var(--button-outline);
|
||||
&:hover:not(:disabled) {
|
||||
--button-text: var(--color-dark-primary);
|
||||
--button-surface: color-mix(in oklch, var(--button-text) 8%, transparent);
|
||||
box-shadow: var(--elevation-dark-0);
|
||||
}
|
||||
&:active:not(:disabled) {
|
||||
--button-text: var(--color-dark-primary);
|
||||
--button-surface: color-mix(in oklch, var(--button-text) 10%, transparent);
|
||||
box-shadow: var(--elevation-dark-0);
|
||||
}
|
||||
&.danger:not(:disabled) {
|
||||
--button-text: var(--color-dark-error);
|
||||
}
|
||||
&.warn:not(:disabled) {
|
||||
--button-text: var(--color-dark-warning);
|
||||
}
|
||||
&.success:not(:disabled) {
|
||||
--button-text: var(--color-dark-success);
|
||||
}
|
||||
&.info:not(:disabled) {
|
||||
--button-text: var(--color-dark-info);
|
||||
}
|
||||
&:disabled {
|
||||
--button-text: color-mix(in oklch, var(--color-dark-on-surface) 38%, transparent);
|
||||
}
|
||||
}
|
||||
&.text {
|
||||
--button-text: --color-dark-primary;
|
||||
--button-surface: transparent;
|
||||
border: none;
|
||||
&:hover:not(:disabled) {
|
||||
--button-surface: color-mix(in oklch, var(--color-dark-primary) 8%, transparent);
|
||||
}
|
||||
&:active:not(:disabled) {
|
||||
--button-surface: color-mix(in oklch, var(--color-dark-primary) 10%, transparent);
|
||||
}
|
||||
&.danger:not(:disabled) {
|
||||
--button-text: var(--color-dark-error);
|
||||
--button-surface: transparent;
|
||||
}
|
||||
&.warn:not(:disabled) {
|
||||
--button-text: var(--color-dark-warning);
|
||||
--button-surface: transparent;
|
||||
}
|
||||
&.success:not(:disabled) {
|
||||
--button-text: var(--color-dark-success);
|
||||
--button-surface: transparent;
|
||||
}
|
||||
&.info:not(:disabled) {
|
||||
--button-text: var(--color-dark-info);
|
||||
--button-surface: transparent;
|
||||
}
|
||||
&:disabled {
|
||||
--button-text: color-mix(in oklch, var(--color-dark-on-surface) 38%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
43
src/components/ScrollArea.module.css
Normal file
43
src/components/ScrollArea.module.css
Normal file
@@ -0,0 +1,43 @@
|
||||
@layer components {
|
||||
.scroll_area {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
grid-template-columns: auto calc(var(--spacing) * 3);
|
||||
grid-template-rows: auto calc(var(--spacing) * 3);
|
||||
}
|
||||
.content {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
.v_scrollbar {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
.v_thumb {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 3;
|
||||
position: absolute;
|
||||
border-radius: calc(var(--border-radius) * 2);
|
||||
background-color: oklch(from var(--color-dark-primary) l c h / 70%);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
.h_scrollbar {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
.h_thumb {
|
||||
height: 100%;
|
||||
aspect-ratio: 3 / 1;
|
||||
position: absolute;
|
||||
border-radius: calc(var(--border-radius) * 2);
|
||||
background-color: oklch(from var(--color-dark-primary) l c h / 70%);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
195
src/components/ScrollArea.tsx
Normal file
195
src/components/ScrollArea.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import { clamp } from 'lodash-es';
|
||||
import { MouseEvent, RefObject, useEffect, useRef, useState, WheelEvent } from 'react';
|
||||
import styles from './ScrollArea.module.css';
|
||||
|
||||
type ScrollBarProps = {
|
||||
containerRef: RefObject<HTMLDivElement> | null;
|
||||
};
|
||||
|
||||
function VerticalScrollBar({ containerRef }: ScrollBarProps) {
|
||||
const [thumbPos, setThumbPos] = useState(0);
|
||||
const trackRef = useRef<HTMLDivElement | null>(null);
|
||||
const thumbRef = useRef<HTMLDivElement | null>(null);
|
||||
const handleMouseDown = (evt: MouseEvent) => {
|
||||
evt.preventDefault();
|
||||
//@ts-expect-error TS2769
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
//@ts-expect-error TS2769
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
const handleMouseMove = (evt: MouseEvent<HTMLDivElement>) => {
|
||||
evt.preventDefault();
|
||||
const container = containerRef?.current;
|
||||
const scrollbar = trackRef.current;
|
||||
const thumb = thumbRef.current;
|
||||
|
||||
if (container && scrollbar && thumb) {
|
||||
const trackRect = scrollbar.getBoundingClientRect();
|
||||
const thumbRect = thumb.getBoundingClientRect();
|
||||
const offsetY = evt.clientY - trackRect.top - thumbRect.height / 2;
|
||||
const thumbPosition = clamp(offsetY, 0, trackRect.height - thumbRect.height);
|
||||
setThumbPos(thumbPosition);
|
||||
|
||||
const scrollPercentage = thumbPosition / (trackRect.height - thumbRect.height);
|
||||
container.scrollTop = scrollPercentage * (container.scrollHeight - container.clientHeight);
|
||||
}
|
||||
};
|
||||
const handleMouseUp = (evt: MouseEvent) => {
|
||||
evt.preventDefault();
|
||||
//@ts-expect-error TS2769
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
//@ts-expect-error TS2769
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
const updateThumbPosition = () => {
|
||||
const container = containerRef?.current;
|
||||
const scrollbar = trackRef.current;
|
||||
const scrollPercentage =
|
||||
container.scrollTop / (container.scrollHeight - container.clientHeight);
|
||||
const thumbPosition =
|
||||
scrollPercentage * (scrollbar.clientHeight - thumbRef.current!.clientHeight);
|
||||
setThumbPos(thumbPosition);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef?.current;
|
||||
const scrollbar = trackRef.current;
|
||||
if (container && scrollbar) {
|
||||
container.addEventListener('scroll', updateThumbPosition);
|
||||
return () => {
|
||||
container.removeEventListener('scroll', updateThumbPosition);
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.v_scrollbar} ref={trackRef}>
|
||||
<div
|
||||
className={styles.v_thumb}
|
||||
ref={thumbRef}
|
||||
style={{ top: thumbPos }}
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HorizontalScrollBar({ containerRef }: ScrollBarProps) {
|
||||
const [thumbPos, setThumbPos] = useState(0);
|
||||
const trackRef = useRef<HTMLDivElement | null>(null);
|
||||
const thumbRef = useRef<HTMLDivElement | null>(null);
|
||||
const handleMouseDown = (evt: MouseEvent) => {
|
||||
evt.preventDefault();
|
||||
//@ts-expect-error TS2769
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
//@ts-expect-error TS2769
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
const handleMouseMove = (evt: MouseEvent) => {
|
||||
evt.preventDefault();
|
||||
const container = containerRef?.current;
|
||||
const scrollbar = trackRef.current;
|
||||
const thumb = thumbRef.current;
|
||||
|
||||
if (container && scrollbar && thumb) {
|
||||
const trackRect = scrollbar.getBoundingClientRect();
|
||||
const thumbRect = thumb.getBoundingClientRect();
|
||||
const offsetX = evt.clientX - trackRect.left - thumbRect.width / 2;
|
||||
const thumbPosition = clamp(offsetX, 0, trackRect.width - thumbRect.width);
|
||||
setThumbPos(thumbPosition);
|
||||
|
||||
const scrollPercentage = thumbPosition / (trackRect.width - thumbRect.width);
|
||||
container.scrollLeft = scrollPercentage * (container.scrollWidth - container.clientWidth);
|
||||
}
|
||||
};
|
||||
const handleMouseUp = (evt: MouseEvent) => {
|
||||
evt.preventDefault();
|
||||
//@ts-expect-error TS2769
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
//@ts-expect-error TS2769
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
const updateThumbPosition = () => {
|
||||
const container = containerRef?.current;
|
||||
const scrollbar = trackRef.current;
|
||||
const scrollPercentage = container.scrollLeft / (container.scrollWidth - container.clientWidth);
|
||||
const thumbPosition =
|
||||
scrollPercentage * (scrollbar.clientWidth - thumbRef.current!.clientWidth);
|
||||
setThumbPos(thumbPosition);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef?.current;
|
||||
const scrollbar = trackRef.current;
|
||||
if (container && scrollbar) {
|
||||
container.addEventListener('scroll', updateThumbPosition);
|
||||
return () => {
|
||||
container.removeEventListener('scroll', updateThumbPosition);
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.h_scrollbar} ref={trackRef}>
|
||||
<div
|
||||
className={styles.h_thumb}
|
||||
ref={thumbRef}
|
||||
style={{ left: thumbPos }}
|
||||
onMouseDown={(e) => handleMouseDown(e)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ScrollAreaProps = {
|
||||
children?: React.ReactNode;
|
||||
enableX?: boolean;
|
||||
enableY?: boolean;
|
||||
normalizedScroll?: boolean;
|
||||
};
|
||||
|
||||
export function ScrollArea({
|
||||
children,
|
||||
enableX = false,
|
||||
enableY = false,
|
||||
normalizedScroll = false,
|
||||
}: ScrollAreaProps) {
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [xScrollNeeded, setXScrollNeeded] = useState(false);
|
||||
const [yScrollNeeded, setYScrollNeeded] = useState(false);
|
||||
const handleWheel = (evt: WheelEvent<HTMLDivElement>) => {
|
||||
const container = scrollContainerRef?.current;
|
||||
if (enableY && container) {
|
||||
const delta = evt.deltaY;
|
||||
const normalizedDelta = normalizedScroll ? clamp(delta, -1, 1) * 30 : delta;
|
||||
const newScrollTop = container.scrollTop + normalizedDelta;
|
||||
container.scrollTop = clamp(newScrollTop, 0, container.scrollHeight - container.clientHeight);
|
||||
}
|
||||
if (enableX && container) {
|
||||
const delta = evt.deltaX;
|
||||
const normalizedDelta = normalizedScroll ? clamp(delta, -1, 1) * 30 : delta;
|
||||
const newScrollLeft = container.scrollLeft + normalizedDelta;
|
||||
container.scrollLeft = clamp(newScrollLeft, 0, container.scrollWidth - container.clientWidth);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (container) {
|
||||
setXScrollNeeded(container.scrollWidth > container.clientWidth);
|
||||
setYScrollNeeded(container.scrollHeight > container.clientHeight);
|
||||
}
|
||||
}, [children]);
|
||||
|
||||
return (
|
||||
<div className={styles.scroll_area}>
|
||||
<div className={styles.content} ref={scrollContainerRef} onWheel={(e) => handleWheel(e)}>
|
||||
{children}
|
||||
</div>
|
||||
{enableY && yScrollNeeded && <VerticalScrollBar containerRef={scrollContainerRef} />}
|
||||
{enableX && xScrollNeeded && <HorizontalScrollBar containerRef={scrollContainerRef} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
src/context/EstimContext.tsx
Normal file
96
src/context/EstimContext.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Event, listen, UnlistenFn } from '@tauri-apps/api/event';
|
||||
import { message } from '@tauri-apps/plugin-dialog';
|
||||
import { atom, PrimitiveAtom, useSetAtom } from 'jotai';
|
||||
import { atomFamily } from 'jotai/utils';
|
||||
import { FC, ReactNode, useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
export type Channels = 'a' | 'b';
|
||||
type ChannelState = {
|
||||
playing: boolean;
|
||||
playMode: 'shuffle' | 'repeat' | 'repeat-one';
|
||||
strength: number;
|
||||
maxStrength: number;
|
||||
boosting: boolean;
|
||||
boostLevel: number;
|
||||
maxBoostLevel: number;
|
||||
};
|
||||
type DeviceState = {
|
||||
rssi: number | null;
|
||||
battery: number | null;
|
||||
};
|
||||
type BluetoothState = {
|
||||
ready: boolean | null;
|
||||
searching: boolean | null;
|
||||
connected: string | null;
|
||||
};
|
||||
|
||||
export const BleState = atom<BluetoothState>({
|
||||
ready: null,
|
||||
searching: null,
|
||||
connected: null,
|
||||
});
|
||||
export const DeviceState = atom<DeviceState>({
|
||||
rssi: null,
|
||||
battery: null,
|
||||
});
|
||||
const Channels: Record<Channels, PrimitiveAtom<ChannelState>> = {
|
||||
a: atom<ChannelState>({
|
||||
playing: false,
|
||||
playMode: 'repeat-one',
|
||||
strength: 0,
|
||||
maxStrength: 100,
|
||||
boosting: false,
|
||||
boostLevel: 0,
|
||||
maxBoostLevel: 100,
|
||||
}),
|
||||
b: atom<ChannelState>({
|
||||
playing: false,
|
||||
playMode: 'repeat-one',
|
||||
strength: 0,
|
||||
maxStrength: 100,
|
||||
boosting: false,
|
||||
boostLevel: 0,
|
||||
maxBoostLevel: 100,
|
||||
}),
|
||||
};
|
||||
export const ChannelState = atomFamily((channel: Channels) => Channels[channel]);
|
||||
|
||||
const EstimWatchProvider: FC<{ children?: ReactNode }> = ({ children }) => {
|
||||
const unlisten = useRef<UnlistenFn | null>(null);
|
||||
const setBleState = useSetAtom(BleState);
|
||||
const handleAppStateRefresh = useCallback(async (event: Event<unknown>) => {
|
||||
try {
|
||||
const newState = await invoke('refresh_application_state');
|
||||
setBleState({
|
||||
ready: newState.central.is_ready,
|
||||
searching: newState.central.is_scanning,
|
||||
connected: newState.central.connected,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[Answer refresh state]', e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
(async function () {
|
||||
try {
|
||||
unlisten.current = await listen('app_state_updated', handleAppStateRefresh);
|
||||
await invoke('activate_central_adapter');
|
||||
} catch (e) {
|
||||
console.error('[Activate Adapter]', e);
|
||||
await message('Fail to activate Bluetooth adapter.', {
|
||||
title: 'Bluetooth Error',
|
||||
kind: 'error',
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
unlisten.current?.();
|
||||
};
|
||||
}, []);
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default EstimWatchProvider;
|
||||
42
src/icons/IconBattery.tsx
Normal file
42
src/icons/IconBattery.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Icon } from '@iconify/react/dist/iconify.js';
|
||||
import { FC, useMemo } from 'react';
|
||||
import { SharedIconProps } from './shared-props';
|
||||
|
||||
type IconBatteryProps = SharedIconProps & {
|
||||
level?: number | null;
|
||||
};
|
||||
|
||||
const IconBattery: FC<IconBatteryProps> = ({
|
||||
height = 24,
|
||||
aspect = 1,
|
||||
stroke = 0.5,
|
||||
level = null,
|
||||
}) => {
|
||||
const width = useMemo(() => height * aspect, [height, aspect]);
|
||||
const batteryIcon = useMemo(() => {
|
||||
if (level !== null && level >= 90) {
|
||||
return 'material-symbols-light:battery-full';
|
||||
} else if (level !== null && level >= 80) {
|
||||
return 'material-symbols-light:battery-6-bar';
|
||||
} else if (level !== null && level >= 70) {
|
||||
return 'material-symbols-light:battery-5-bar';
|
||||
} else if (level !== null && level >= 50) {
|
||||
return 'material-symbols-light:battery-4-bar';
|
||||
} else if (level !== null && level >= 30) {
|
||||
return 'material-symbols-light:battery-3-bar';
|
||||
} else if (level !== null && level >= 20) {
|
||||
return 'material-symbols-light:battery-2-bar';
|
||||
} else if (level !== null && level >= 10) {
|
||||
return 'material-symbols-light:battery-1-bar';
|
||||
} else if (level !== null && level >= 0) {
|
||||
return 'material-symbols-light:battery-0-bar';
|
||||
} else {
|
||||
return 'material-symbols-light:battery-error';
|
||||
}
|
||||
}, [level]);
|
||||
console.debug('[icon battery]', level, batteryIcon);
|
||||
|
||||
return <Icon icon={batteryIcon} width={width} height={height} stroke={stroke} />;
|
||||
};
|
||||
|
||||
export default IconBattery;
|
||||
35
src/icons/IconBluetooth.tsx
Normal file
35
src/icons/IconBluetooth.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Icon } from '@iconify/react/dist/iconify.js';
|
||||
import { FC, useMemo } from 'react';
|
||||
import { SharedIconProps } from './shared-props';
|
||||
|
||||
type IconBluetoothProps = SharedIconProps & {
|
||||
ready?: boolean | null;
|
||||
searching?: boolean | null;
|
||||
connected?: boolean | null;
|
||||
};
|
||||
|
||||
const IconBluetooth: FC<IconBluetoothProps> = ({
|
||||
height = 24,
|
||||
aspect = 1,
|
||||
stroke = 0.5,
|
||||
ready = false,
|
||||
searching = false,
|
||||
connected = false,
|
||||
}) => {
|
||||
const width = useMemo(() => height * aspect, [height, aspect]);
|
||||
const bleIcon = useMemo(() => {
|
||||
if (ready && !searching && !connected) {
|
||||
return 'material-symbols-light:bluetooth';
|
||||
} else if (ready && searching) {
|
||||
return 'material-symbols-light:bluetooth-searching';
|
||||
} else if (ready && !searching && connected) {
|
||||
return 'material-symbols-light:bluetooth-connected';
|
||||
} else {
|
||||
return 'material-symbols-light:bluetooth-disabled';
|
||||
}
|
||||
}, [ready, searching, connected]);
|
||||
|
||||
return <Icon icon={bleIcon} width={width} height={height} stroke={stroke} />;
|
||||
};
|
||||
|
||||
export default IconBluetooth;
|
||||
30
src/icons/IconRssi.tsx
Normal file
30
src/icons/IconRssi.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Icon } from '@iconify/react/dist/iconify.js';
|
||||
import { FC, useMemo } from 'react';
|
||||
import { SharedIconProps } from './shared-props';
|
||||
|
||||
type IconRssiProps = SharedIconProps & {
|
||||
level?: number | null;
|
||||
};
|
||||
|
||||
const IconRssi: FC<IconRssiProps> = ({ height = 24, aspect = 1, stroke = 0.5, level = null }) => {
|
||||
const width = useMemo(() => height * aspect, [height, aspect]);
|
||||
const rssiIcon = useMemo(() => {
|
||||
if (level === null) {
|
||||
return 'material-symbols-light:signal-cellular-nodata';
|
||||
} else if (level <= -80) {
|
||||
return 'material-symbols-light:signal-cellular-4-bar';
|
||||
} else if (level <= -70) {
|
||||
return 'material-symbols-light:signal-cellular-3-bar';
|
||||
} else if (level <= -60) {
|
||||
return 'material-symbols-light:signal-cellular-2-bar';
|
||||
} else if (level <= -50) {
|
||||
return 'material-symbols-light:signal-cellular-1-bar';
|
||||
} else {
|
||||
return 'material-symbols-light:signal-cellular-null';
|
||||
}
|
||||
}, [level]);
|
||||
|
||||
return <Icon icon={rssiIcon} width={width} height={height} stroke={stroke} />;
|
||||
};
|
||||
|
||||
export default IconRssi;
|
||||
17
src/icons/shared-props.ts
Normal file
17
src/icons/shared-props.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { IconProps } from '@iconify/react/dist/iconify.js';
|
||||
|
||||
export type SharedIconProps = {
|
||||
height?: number | null;
|
||||
aspect?: number | null;
|
||||
stroke?: number | null;
|
||||
};
|
||||
|
||||
export const defaultIconProps: Partial<IconProps> = {
|
||||
height: 24,
|
||||
stroke: 0.5,
|
||||
};
|
||||
|
||||
export const smallIconProps: Partial<IconProps> = {
|
||||
height: 16,
|
||||
stroke: 0.5,
|
||||
};
|
||||
5
src/index.css
Normal file
5
src/index.css
Normal file
@@ -0,0 +1,5 @@
|
||||
@layer theme, base, components, utilities, pages;
|
||||
@import 'sanitize.css' layer(base);
|
||||
@import 'sanitize.css/forms.css' layer(base);
|
||||
@import './theme.css';
|
||||
@import './components.css';
|
||||
31
src/main.tsx
Normal file
31
src/main.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
// Load global styles
|
||||
import './index.css';
|
||||
// Load foundations
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||
import Layout from './Layout';
|
||||
import EstimWatchProvider from './context/EstimContext';
|
||||
import Device from './pages/Device';
|
||||
import PatternEditor from './pages/PatternEditor';
|
||||
import PatternLibrary from './pages/PatternLibrary';
|
||||
import PlayControl from './pages/Play';
|
||||
import Settings from './pages/Settings';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<EstimWatchProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Device />} />
|
||||
<Route path="/play" element={<PlayControl />} />
|
||||
<Route path="/library" element={<PatternLibrary />} />
|
||||
<Route path="/pattern-editor" element={<PatternEditor />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</EstimWatchProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
11
src/page-components/device/BleControl.module.css
Normal file
11
src/page-components/device/BleControl.module.css
Normal file
@@ -0,0 +1,11 @@
|
||||
@layer pages {
|
||||
.ble_control {
|
||||
height: calc(var(--spacing) * 12);
|
||||
padding-inline: calc(var(--spacing) * 2);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: calc(var(--spacing) * 2);
|
||||
}
|
||||
}
|
||||
17
src/page-components/device/BleControl.tsx
Normal file
17
src/page-components/device/BleControl.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { FC } from 'react';
|
||||
import { BleState } from '../../context/EstimContext';
|
||||
import styles from './BleControl.module.css';
|
||||
|
||||
const BleControl: FC = () => {
|
||||
const bleState = useAtomValue(BleState);
|
||||
|
||||
return (
|
||||
<div className={styles.ble_control}>
|
||||
<button disabled={!bleState.ready || bleState.searching}>Scan</button>
|
||||
<button disabled={!bleState.ready || !bleState.connected}>Disconnect</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BleControl;
|
||||
8
src/page-components/device/DeviceDetail.module.css
Normal file
8
src/page-components/device/DeviceDetail.module.css
Normal file
@@ -0,0 +1,8 @@
|
||||
@layer pages {
|
||||
.device_detail {
|
||||
flex: 2;
|
||||
border-radius: calc(var(--border-radius) * 2);
|
||||
padding: calc(var(--spacing) * 2);
|
||||
background-color: var(--color-dark-surface-container);
|
||||
}
|
||||
}
|
||||
8
src/page-components/device/DeviceDetail.tsx
Normal file
8
src/page-components/device/DeviceDetail.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { FC } from 'react';
|
||||
import styles from './DeviceDetail.module.css';
|
||||
|
||||
const DeviceDetail: FC = () => {
|
||||
return <div className={styles.device_detail}></div>;
|
||||
};
|
||||
|
||||
export default DeviceDetail;
|
||||
8
src/page-components/device/DeviceList.module.css
Normal file
8
src/page-components/device/DeviceList.module.css
Normal file
@@ -0,0 +1,8 @@
|
||||
@layer pages {
|
||||
.devices {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
13
src/page-components/device/DeviceList.tsx
Normal file
13
src/page-components/device/DeviceList.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { FC } from 'react';
|
||||
import { ScrollArea } from '../../components/ScrollArea';
|
||||
import styles from './DeviceList.module.css';
|
||||
|
||||
const DeviceList: FC = () => {
|
||||
return (
|
||||
<div className={styles.devices}>
|
||||
<ScrollArea enableY></ScrollArea>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeviceList;
|
||||
11
src/page-components/play-control/ChannelHost.module.css
Normal file
11
src/page-components/play-control/ChannelHost.module.css
Normal file
@@ -0,0 +1,11 @@
|
||||
@layer pages {
|
||||
.channel_host {
|
||||
flex: 1;
|
||||
padding: calc(var(--spacing) * 2) calc(var(--spacing) * 3);
|
||||
border-radius: calc(var(--border-radius) * 2);
|
||||
background-color: var(--color-dark-surface-container);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--spacing));
|
||||
}
|
||||
}
|
||||
17
src/page-components/play-control/ChannelHost.tsx
Normal file
17
src/page-components/play-control/ChannelHost.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { FC } from 'react';
|
||||
import { Channels } from '../../context/EstimContext';
|
||||
import styles from './ChannelHost.module.css';
|
||||
|
||||
type ChannelHostProps = {
|
||||
channel: Channels;
|
||||
};
|
||||
|
||||
const ChannelHost: FC<ChannelHostProps> = ({ channel }) => {
|
||||
return (
|
||||
<div className={styles.channel_host}>
|
||||
<h3>Channel {channel.toUpperCase()}</h3>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelHost;
|
||||
30
src/page-components/state-bar/BleState.tsx
Normal file
30
src/page-components/state-bar/BleState.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { FC, useMemo } from 'react';
|
||||
import { BleState } from '../../context/EstimContext';
|
||||
import IconBluetooth from '../../icons/IconBluetooth';
|
||||
|
||||
const BleStates: FC = () => {
|
||||
const ble = useAtomValue(BleState);
|
||||
const bleIcon = useMemo(() => {
|
||||
if (ble.ready && !ble.searching && !ble.connected) {
|
||||
return 'material-symbols-light:bluetooth';
|
||||
} else if (ble.ready && ble.searching) {
|
||||
return 'material-symbols-light:bluetooth-searching';
|
||||
} else if (ble.ready && !ble.searching && ble.connected) {
|
||||
return 'material-symbols-light:bluetooth-connected';
|
||||
} else {
|
||||
return 'material-symbols-light:bluetooth-disabled';
|
||||
}
|
||||
}, [ble]);
|
||||
|
||||
return (
|
||||
<IconBluetooth
|
||||
height={16}
|
||||
ready={ble.ready}
|
||||
searching={ble.searching}
|
||||
connected={ble.connected?.length > 0}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default BleStates;
|
||||
10
src/page-components/state-bar/ChannelStates.module.css
Normal file
10
src/page-components/state-bar/ChannelStates.module.css
Normal file
@@ -0,0 +1,10 @@
|
||||
@layer pages {
|
||||
.channel_state {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: calc(var(--spacing));
|
||||
font-size: calc(var(--font-size) * 1.4);
|
||||
}
|
||||
}
|
||||
35
src/page-components/state-bar/ChannelStates.tsx
Normal file
35
src/page-components/state-bar/ChannelStates.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Icon } from '@iconify/react/dist/iconify.js';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { FC } from 'react';
|
||||
import { Channels, ChannelState } from '../../context/EstimContext';
|
||||
import { smallIconProps } from '../../icons/shared-props';
|
||||
import styles from './ChannelStates.module.css';
|
||||
|
||||
const ChannelStates: FC<{ channel: Channels }> = ({ channel }) => {
|
||||
const chState = useAtomValue(ChannelState(channel));
|
||||
|
||||
return (
|
||||
<div className={styles.channel_state}>
|
||||
<span>Ch {channel.toUpperCase()}</span>
|
||||
{chState.playing ? (
|
||||
<Icon icon="material-symbols-light:electric-bolt" {...smallIconProps} />
|
||||
) : (
|
||||
<Icon icon="material-symbols-light:stop" {...smallIconProps} />
|
||||
)}
|
||||
<span>{chState.strength}</span>
|
||||
<Icon icon="material-symbols-light:arrow-upload-progress" {...smallIconProps} />
|
||||
<span>{chState.boostLevel}</span>
|
||||
{chState.playMode === 'shuffle' && (
|
||||
<Icon icon="material-symbols-light:shuffle" {...smallIconProps} />
|
||||
)}
|
||||
{chState.playMode === 'repeat' && (
|
||||
<Icon icon="material-symbols-light:repeat" {...smallIconProps} />
|
||||
)}
|
||||
{chState.playMode === 'repeat-one' && (
|
||||
<Icon icon="material-symbols-light:repeat-one" {...smallIconProps} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelStates;
|
||||
18
src/page-components/state-bar/DeviceStates.tsx
Normal file
18
src/page-components/state-bar/DeviceStates.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { FC } from 'react';
|
||||
import { DeviceState } from '../../context/EstimContext';
|
||||
import IconBattery from '../../icons/IconBattery';
|
||||
import IconRssi from '../../icons/IconRssi';
|
||||
|
||||
const DeviceStates: FC = () => {
|
||||
const deviceState = useAtomValue(DeviceState);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconRssi height={16} level={deviceState.rssi} />
|
||||
<IconBattery height={16} level={deviceState.battery} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeviceStates;
|
||||
13
src/page-components/state-bar/StateBar.module.css
Normal file
13
src/page-components/state-bar/StateBar.module.css
Normal file
@@ -0,0 +1,13 @@
|
||||
@layer pages {
|
||||
.state_bar {
|
||||
flex: 1;
|
||||
padding-block-start: calc(var(--spacing));
|
||||
padding-inline: calc(var(--spacing) * 2);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: calc(var(--spacing) * 4);
|
||||
font-size: calc(var(--font-size) * 1.6);
|
||||
}
|
||||
}
|
||||
18
src/page-components/state-bar/StateBar.tsx
Normal file
18
src/page-components/state-bar/StateBar.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { FC } from 'react';
|
||||
import BleStates from './BleState';
|
||||
import ChannelStates from './ChannelStates';
|
||||
import DeviceStates from './DeviceStates';
|
||||
import styles from './StateBar.module.css';
|
||||
|
||||
const StateBar: FC = () => {
|
||||
return (
|
||||
<div className={styles.state_bar}>
|
||||
<BleStates />
|
||||
<ChannelStates channel="a" />
|
||||
<ChannelStates channel="b" />
|
||||
<DeviceStates />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StateBar;
|
||||
11
src/pages/Device.module.css
Normal file
11
src/pages/Device.module.css
Normal file
@@ -0,0 +1,11 @@
|
||||
@layer pages {
|
||||
.device_list {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: calc(var(--spacing) * 2);
|
||||
border-radius: calc(var(--border-radius) * 2);
|
||||
background-color: var(--color-dark-surface-container);
|
||||
}
|
||||
}
|
||||
19
src/pages/Device.tsx
Normal file
19
src/pages/Device.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { FC } from 'react';
|
||||
import BleControl from '../page-components/device/BleControl';
|
||||
import DeviceDetail from '../page-components/device/DeviceDetail';
|
||||
import DeviceList from '../page-components/device/DeviceList';
|
||||
import styles from './Device.module.css';
|
||||
|
||||
const Device: FC = () => {
|
||||
return (
|
||||
<div className="workspace horizontal">
|
||||
<div className={styles.device_list}>
|
||||
<BleControl />
|
||||
<DeviceList />
|
||||
</div>
|
||||
<DeviceDetail />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Device;
|
||||
7
src/pages/PatternEditor.tsx
Normal file
7
src/pages/PatternEditor.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { FC } from 'react';
|
||||
|
||||
const PatternEditor: FC = () => {
|
||||
return <div className="workspace"></div>;
|
||||
};
|
||||
|
||||
export default PatternEditor;
|
||||
7
src/pages/PatternLibrary.tsx
Normal file
7
src/pages/PatternLibrary.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { FC } from 'react';
|
||||
|
||||
const PatternLibrary: FC = () => {
|
||||
return <div className="workspace"></div>;
|
||||
};
|
||||
|
||||
export default PatternLibrary;
|
||||
10
src/pages/Play.module.css
Normal file
10
src/pages/Play.module.css
Normal file
@@ -0,0 +1,10 @@
|
||||
@layer pages {
|
||||
.play_control {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
gap: calc(var(--spacing) * 2);
|
||||
}
|
||||
}
|
||||
13
src/pages/Play.tsx
Normal file
13
src/pages/Play.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { FC } from 'react';
|
||||
import ChannelHost from '../page-components/play-control/ChannelHost';
|
||||
|
||||
const PlayControl: FC = () => {
|
||||
return (
|
||||
<div className="workspace horizontal">
|
||||
<ChannelHost channel="a" />
|
||||
<ChannelHost channel="b" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlayControl;
|
||||
7
src/pages/Settings.tsx
Normal file
7
src/pages/Settings.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { FC } from 'react';
|
||||
|
||||
const Settings: FC = () => {
|
||||
return <div className="workspace vertical"></div>;
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
75
src/theme.css
Normal file
75
src/theme.css
Normal file
@@ -0,0 +1,75 @@
|
||||
@import './variables.css' layer(theme);
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
font-family: var(--font-family);
|
||||
font-size: var(--font-size);
|
||||
line-height: var(--line-height);
|
||||
font-weight: 400;
|
||||
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
:where(html, body) {
|
||||
color-scheme: dark;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
body {
|
||||
position: relative;
|
||||
color: var(--color-dark-on-surface);
|
||||
background-color: var(--color-dark-surface);
|
||||
}
|
||||
|
||||
:where(h1, h2, h3, h4, h5, h6, p, div, span) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:where(menu) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
z-index: 15;
|
||||
}
|
||||
|
||||
.evelation-0 {
|
||||
box-shadow: var(--elevation-dark-0);
|
||||
}
|
||||
|
||||
.evelation-1 {
|
||||
box-shadow: var(--elevation-dark-1-ambient), --var(--elevation-dark-1-umbra);
|
||||
}
|
||||
|
||||
.evelation-2 {
|
||||
box-shadow: var(--elevation-dark-2-ambient), var(--elevation-dark-2-umbra);
|
||||
}
|
||||
|
||||
.evelation-3 {
|
||||
box-shadow: var(--elevation-dark-3-ambient), var(--elevation-dark-3-umbra);
|
||||
}
|
||||
|
||||
.evelation-4 {
|
||||
box-shadow: var(--elevation-dark-4-ambient), var(--elevation-dark-4-umbra);
|
||||
}
|
||||
|
||||
.evelation-5 {
|
||||
box-shadow: var(--elevation-dark-5-ambient), var(--elevation-dark-5-umbra);
|
||||
}
|
||||
}
|
||||
271
src/variables.css
Normal file
271
src/variables.css
Normal file
@@ -0,0 +1,271 @@
|
||||
@layer theme {
|
||||
:root {
|
||||
--font-family: 'Inter', system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
|
||||
--color-white: #ffffff;
|
||||
--color-black: #000000;
|
||||
--color-light-primary: #744c1f;
|
||||
--color-light-on-primary: #fff5c0;
|
||||
--color-light-primary-container: #95693b;
|
||||
--color-light-on-primary-container: #fff5c0;
|
||||
--color-light-primary-fixed: #95693b;
|
||||
--color-light-primary-fixed-dim: #7a5124;
|
||||
--color-light-on-primary-fixed: #fff5c0;
|
||||
--color-light-on-primary-fixed-variant: #fff5c0;
|
||||
--color-light-inverse-primary: #6f481b;
|
||||
--color-light-secondary: #67513b;
|
||||
--color-light-on-secondary: #fffae0;
|
||||
--color-light-secondary-container: #866e57;
|
||||
--color-light-on-secondary-container: #fffae0;
|
||||
--color-light-secondary-fixed: #866e57;
|
||||
--color-light-secondary-fixed-dim: #6c5540;
|
||||
--color-light-on-secondary-fixed: #fffae0;
|
||||
--color-light-on-secondary-fixed-variant: #fffae0;
|
||||
--color-light-inverse-secondary: #dec1a8;
|
||||
--color-light-tertiary: #5c561a;
|
||||
--color-light-on-tertiary: #ffffbb;
|
||||
--color-light-tertiary-container: #7b7436;
|
||||
--color-light-on-tertiary-container: #ffffbb;
|
||||
--color-light-tertiary-fixed: #7b7436;
|
||||
--color-light-tertiary-fixed-dim: #615b1f;
|
||||
--color-light-on-tertiary-fixed: #ffffbb;
|
||||
--color-light-on-tertiary-fixed-variant: #ffffbb;
|
||||
--color-light-inverse-tertiary: #d3c886;
|
||||
--color-light-error: #b20000;
|
||||
--color-light-on-error: #ffc89b;
|
||||
--color-light-error-container: #d92f18;
|
||||
--color-light-on-error-container: #ffc89b;
|
||||
--color-light-error-fixed: #ffab80;
|
||||
--color-light-error-fixed-dim: #ff8e66;
|
||||
--color-light-on-error-fixed: #640000;
|
||||
--color-light-on-error-fixed-variant: #9f0000;
|
||||
--color-light-inverse-error: #ff8e66;
|
||||
--color-light-surface: #fff8f1;
|
||||
--color-light-surface-dim: #bfb7b1;
|
||||
--color-light-surface-bright: #fff8f1;
|
||||
--color-light-surface-variant: #efe0d4;
|
||||
--color-light-surface-container: #e9e1db;
|
||||
--color-light-surface-container-lowest: #fffef7;
|
||||
--color-light-surface-container-low: #f7efe9;
|
||||
--color-light-surface-container-high: #dbd3cd;
|
||||
--color-light-surface-container-highest: #cdc5bf;
|
||||
--color-light-on-surface: #090000;
|
||||
--color-light-on-surface-variant: #41362d;
|
||||
--color-light-inverse-surface: #f7efe9;
|
||||
--color-light-inverse-on-surface: #090000;
|
||||
--color-light-outline: #5f5349;
|
||||
--color-light-outline-variant: #7d7065;
|
||||
--color-light-scrim: #090000;
|
||||
--color-light-shadow: #090000;
|
||||
--color-light-success: #1c6300;
|
||||
--color-light-on-success: #d6ff6a;
|
||||
--color-light-success-container: #3f8200;
|
||||
--color-light-on-success-container: #d6ff6a;
|
||||
--color-light-success-fixed: #3f8200;
|
||||
--color-light-success-fixed-dim: #226800;
|
||||
--color-light-on-success-fixed: #d6ff6a;
|
||||
--color-light-on-success-fixed-variant: #d6ff6a;
|
||||
--color-light-inverse-success: #053f00;
|
||||
--color-light-defensive: #7131b4;
|
||||
--color-light-on-defensive: #ffdeff;
|
||||
--color-light-defensive-container: #9350d6;
|
||||
--color-light-on-defensive-container: #ffdeff;
|
||||
--color-light-defensive-fixed: #9350d6;
|
||||
--color-light-defensive-fixed-dim: #7737ba;
|
||||
--color-light-on-defensive-fixed: #ffdeff;
|
||||
--color-light-on-defensive-fixed-variant: #ffdeff;
|
||||
--color-light-inverse-defensive: #47038c;
|
||||
--color-light-gentle: #7c4b00;
|
||||
--color-light-on-gentle: #fff25f;
|
||||
--color-light-gentle-container: #9e6700;
|
||||
--color-light-on-gentle-container: #fff25f;
|
||||
--color-light-gentle-fixed: #9e6700;
|
||||
--color-light-gentle-fixed-dim: #814f00;
|
||||
--color-light-on-gentle-fixed: #fff25f;
|
||||
--color-light-on-gentle-fixed-variant: #fff25f;
|
||||
--color-light-inverse-gentle: #562900;
|
||||
--color-light-aggressive: #b6002d;
|
||||
--color-light-on-aggressive: #ffc3cc;
|
||||
--color-light-aggressive-container: #dc2247;
|
||||
--color-light-on-aggressive-container: #ffc3cc;
|
||||
--color-light-aggressive-fixed: #dc2247;
|
||||
--color-light-aggressive-fixed-dim: #bd0031;
|
||||
--color-light-on-aggressive-fixed: #ffc3cc;
|
||||
--color-light-on-aggressive-fixed-variant: #ffc3cc;
|
||||
--color-light-inverse-aggressive: #88000e;
|
||||
--color-light-info: #225694;
|
||||
--color-light-on-info: #ddffff;
|
||||
--color-light-info-container: #4973b4;
|
||||
--color-light-on-info-container: #ddffff;
|
||||
--color-light-info-fixed: #4973b4;
|
||||
--color-light-info-fixed-dim: #2a5a99;
|
||||
--color-light-on-info-fixed: #ddffff;
|
||||
--color-light-on-info-fixed-variant: #ddffff;
|
||||
--color-light-inverse-info: #00346e;
|
||||
--color-light-warning: #834600;
|
||||
--color-light-on-warning: #ffed93;
|
||||
--color-light-warning-container: #a66204;
|
||||
--color-light-on-warning-container: #ffed93;
|
||||
--color-light-warning-fixed: #a66204;
|
||||
--color-light-warning-fixed-dim: #884a00;
|
||||
--color-light-on-warning-fixed: #ffed93;
|
||||
--color-light-on-warning-fixed-variant: #ffed93;
|
||||
--color-light-inverse-warning: #5b2300;
|
||||
--color-dark-primary: #ffe6b1;
|
||||
--color-dark-on-primary: #280000;
|
||||
--color-dark-primary-container: #ecb986;
|
||||
--color-dark-on-primary-container: #2b0000;
|
||||
--color-dark-primary-fixed: #ffd8a5;
|
||||
--color-dark-primary-fixed-dim: #f1bd8a;
|
||||
--color-dark-on-primary-fixed: #280000;
|
||||
--color-dark-on-primary-fixed-variant: #2d0600;
|
||||
--color-dark-inverse-primary: #e8b482;
|
||||
--color-dark-secondary: #ffebd1;
|
||||
--color-dark-on-secondary: #1a0000;
|
||||
--color-dark-secondary-container: #dabea5;
|
||||
--color-dark-on-secondary-container: #1f0500;
|
||||
--color-dark-secondary-fixed: #fbddc4;
|
||||
--color-dark-secondary-fixed-dim: #dec1a8;
|
||||
--color-dark-on-secondary-fixed: #1a0000;
|
||||
--color-dark-on-secondary-fixed-variant: #220c00;
|
||||
--color-dark-inverse-secondary: #715a44;
|
||||
--color-dark-tertiary: #fef1ad;
|
||||
--color-dark-on-tertiary: #150100;
|
||||
--color-dark-tertiary-container: #cfc482;
|
||||
--color-dark-on-tertiary-container: #1b0c00;
|
||||
--color-dark-tertiary-fixed: #f0e4a0;
|
||||
--color-dark-tertiary-fixed-dim: #d3c886;
|
||||
--color-dark-on-tertiary-fixed: #150100;
|
||||
--color-dark-on-tertiary-fixed-variant: #1e1200;
|
||||
--color-dark-inverse-tertiary: #666023;
|
||||
--color-dark-error: #ffb88d;
|
||||
--color-dark-on-error: #4e0000;
|
||||
--color-dark-error-container: #ff8a63;
|
||||
--color-dark-on-error-container: #540000;
|
||||
--color-dark-error-fixed: #ffab80;
|
||||
--color-dark-error-fixed-dim: #ff8e66;
|
||||
--color-dark-on-error-fixed: #640000;
|
||||
--color-dark-on-error-fixed-variant: #9f0000;
|
||||
--color-dark-inverse-error: #bf0902;
|
||||
--color-dark-surface: #18120c;
|
||||
--color-dark-surface-dim: #18120c;
|
||||
--color-dark-surface-bright: #554f4a;
|
||||
--color-dark-surface-variant: #50453b;
|
||||
--color-dark-surface-container: #352f2a;
|
||||
--color-dark-surface-container-lowest: #090000;
|
||||
--color-dark-surface-container-low: #241f1a;
|
||||
--color-dark-surface-container-high: #403a35;
|
||||
--color-dark-surface-container-highest: #4c4640;
|
||||
--color-dark-on-surface: #fffef7;
|
||||
--color-dark-on-surface-variant: #fffdf0;
|
||||
--color-dark-inverse-surface: #352f2a;
|
||||
--color-dark-inverse-on-surface: #fffef7;
|
||||
--color-dark-outline: #fcede1;
|
||||
--color-dark-outline-variant: #cec0b4;
|
||||
--color-dark-scrim: #090000;
|
||||
--color-dark-shadow: #090000;
|
||||
--color-dark-defensive: #ffcfff;
|
||||
--color-dark-on-defensive: #050055;
|
||||
--color-dark-defensive-container: #eca2ff;
|
||||
--color-dark-on-defensive-container: #0d005d;
|
||||
--color-dark-defensive-fixed: #ffc1ff;
|
||||
--color-dark-defensive-fixed-dim: #f0a5ff;
|
||||
--color-dark-on-defensive-fixed: #050055;
|
||||
--color-dark-on-defensive-fixed-variant: #140062;
|
||||
--color-dark-inverse-defensive: #ffcbff;
|
||||
--color-dark-info: #cef0ff;
|
||||
--color-dark-on-info: #00013a;
|
||||
--color-dark-info-container: #9fc3ff;
|
||||
--color-dark-on-info-container: #000d41;
|
||||
--color-dark-info-fixed: #c0e2ff;
|
||||
--color-dark-info-fixed-dim: #a3c6ff;
|
||||
--color-dark-on-info-fixed: #00013a;
|
||||
--color-dark-on-info-fixed-variant: #001346;
|
||||
--color-dark-inverse-info: #caecff;
|
||||
--color-dark-gentle: #ffe350;
|
||||
--color-dark-on-gentle: #370000;
|
||||
--color-dark-gentle-container: #fcb61a;
|
||||
--color-dark-on-gentle-container: #390000;
|
||||
--color-dark-gentle-fixed: #ffd642;
|
||||
--color-dark-gentle-fixed-dim: #ffba20;
|
||||
--color-dark-on-gentle-fixed: #370000;
|
||||
--color-dark-on-gentle-fixed-variant: #3b0000;
|
||||
--color-dark-inverse-gentle: #ffdf4c;
|
||||
--color-dark-success: #c7ff5b;
|
||||
--color-dark-on-success: #001600;
|
||||
--color-dark-success-container: #97d529;
|
||||
--color-dark-on-success-container: #001c00;
|
||||
--color-dark-success-fixed: #b9f54d;
|
||||
--color-dark-success-fixed-dim: #9cd92e;
|
||||
--color-dark-on-success-fixed: #001600;
|
||||
--color-dark-on-success-fixed-variant: #001f00;
|
||||
--color-dark-inverse-success: #c2ff57;
|
||||
--color-dark-warning: #ffde85;
|
||||
--color-dark-on-warning: #350000;
|
||||
--color-dark-warning-container: #ffb15b;
|
||||
--color-dark-on-warning-container: #380000;
|
||||
--color-dark-warning-fixed: #ffd179;
|
||||
--color-dark-warning-fixed-dim: #ffb55e;
|
||||
--color-dark-on-warning-fixed: #350000;
|
||||
--color-dark-on-warning-fixed-variant: #3a0000;
|
||||
--color-dark-inverse-warning: #ffda82;
|
||||
--color-dark-aggressive: #ffb3bd;
|
||||
--color-dark-on-aggressive: #4d0000;
|
||||
--color-dark-aggressive-container: #ff8492;
|
||||
--color-dark-on-aggressive-container: #540000;
|
||||
--color-dark-aggressive-fixed: #ffa6b0;
|
||||
--color-dark-aggressive-fixed-dim: #ff8896;
|
||||
--color-dark-on-aggressive-fixed: #4d0000;
|
||||
--color-dark-on-aggressive-fixed-variant: #590000;
|
||||
--color-dark-inverse-aggressive: #ffafba;
|
||||
|
||||
--spacing: 4px;
|
||||
--border-radius: 2px;
|
||||
--font-size: 10px;
|
||||
--line-height: 1.2em;
|
||||
|
||||
--elevation-light-0: none;
|
||||
--elevation-light-1-ambient: 0 1px 3px 1px
|
||||
color-mix(in oklch, var(--color-light-shadow) 15%, transparent);
|
||||
--elevation-light-1-umbra: 0 1px 2px 0px
|
||||
color-mix(in oklch, var(--color-light-shadow) 30%, transparent);
|
||||
--elevation-light-2-ambient: 0 2px 6px 2px
|
||||
color-mix(in oklch, var(--color-light-shadow) 15%, transparent);
|
||||
--elevation-light-2-umbra: 0 1px 2px 0px
|
||||
color-mix(in oklch, var(--color-light-shadow) 30%, transparent);
|
||||
--elevation-light-3-ambient: 0 4px 8px 3px
|
||||
color-mix(in oklch, var(--color-light-shadow) 15%, transparent);
|
||||
--elevation-light-3-umbra: 0 1px 3px 0px
|
||||
color-mix(in oklch, var(--color-light-shadow) 30%, transparent);
|
||||
--elevation-light-4-ambient: 0 6px 10px 4px
|
||||
color-mix(in oklch, var(--color-light-shadow) 15%, transparent);
|
||||
--elevation-light-4-umbra: 0 2px 3px 0px
|
||||
color-mix(in oklch, var(--color-light-shadow) 30%, transparent);
|
||||
--elevation-light-5-ambient: 0 8px 12px 6px
|
||||
color-mix(in oklch, var(--color-light-shadow) 15%, transparent);
|
||||
--elevation-light-5-umbra: 0 4px 4px 0px
|
||||
color-mix(in oklch, var(--color-light-shadow) 30%, transparent);
|
||||
|
||||
--elevation-dark-0: none;
|
||||
--elevation-dark-1-ambient: 0 1px 3px 1px
|
||||
color-mix(in oklch, var(--color-dark-shadow) 15%, transparent);
|
||||
--elevation-dark-1-umbra: 0 1px 2px 0px
|
||||
color-mix(in oklch, var(--color-dark-shadow) 30%, transparent);
|
||||
--elevation-dark-2-ambient: 0 2px 6px 2px
|
||||
color-mix(in oklch, var(--color-dark-shadow) 15%, transparent);
|
||||
--elevation-dark-2-umbra: 0 1px 2px 0px
|
||||
color-mix(in oklch, var(--color-dark-shadow) 30%, transparent);
|
||||
--elevation-dark-3-ambient: 0 4px 8px 3px
|
||||
color-mix(in oklch, var(--color-dark-shadow) 15%, transparent);
|
||||
--elevation-dark-3-umbra: 0 1px 3px 0px
|
||||
color-mix(in oklch, var(--color-dark-shadow) 30%, transparent);
|
||||
--elevation-dark-4-ambient: 0 6px 10px 4px
|
||||
color-mix(in oklch, var(--color-dark-shadow) 15%, transparent);
|
||||
--elevation-dark-4-umbra: 0 2px 3px 0px
|
||||
color-mix(in oklch, var(--color-dark-shadow) 30%, transparent);
|
||||
--elevation-dark-5-ambient: 0 8px 12px 6px
|
||||
color-mix(in oklch, var(--color-dark-shadow) 15%, transparent);
|
||||
--elevation-dark-5-umbra: 0 4px 4px 0px
|
||||
color-mix(in oklch, var(--color-dark-shadow) 30%, transparent);
|
||||
}
|
||||
}
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user