feat: initial theming support

This commit is contained in:
Haishan 2018-12-04 23:39:26 +08:00
parent a265c62020
commit 3584ff6179
19 changed files with 223 additions and 150 deletions

View file

@ -1,29 +1,19 @@
import {
getAPIConfig,
getURLAndInit,
genCommonHeaders,
getAPIBaseURL
} from 'm/request-helper';
const endpoint = '/configs';
function getURLAndInit() {
const c = getAPIConfig();
const baseURL = getAPIBaseURL(c);
const headers = genCommonHeaders(c);
return {
url: baseURL + endpoint,
init: { headers }
};
export async function fetchConfigs(apiConfig) {
const { url, init } = getURLAndInit(apiConfig);
return await fetch(url + endpoint, init);
}
export async function fetchConfigs() {
const { url, init } = getURLAndInit();
return await fetch(url, init);
}
export async function updateConfigs(o) {
const { url, init } = getURLAndInit();
return await fetch(url, {
export async function updateConfigs(apiConfig, o) {
const { url, init } = getURLAndInit(apiConfig);
return await fetch(url + endpoint, {
...init,
method: 'PUT',
// mode: 'cors',

View file

@ -1,5 +1,5 @@
import {
getAPIConfig,
getURLAndInit,
genCommonHeaders,
getAPIBaseURL
} from 'm/request-helper';
@ -10,16 +10,6 @@ const getRandomStr = () => {
return Math.floor((1 + Math.random()) * 0x10000).toString(16);
};
function getURLAndInit() {
const c = getAPIConfig();
const baseURL = getAPIBaseURL(c);
const headers = genCommonHeaders(c);
return {
url: baseURL + endpoint,
init: { headers }
};
}
const Size = 300;
let even = false;
@ -71,11 +61,11 @@ function pump(reader) {
});
}
function fetchLogs() {
function fetchLogs(apiConfig) {
if (store.fetched) return store;
store.fetched = true;
const { url, init } = getURLAndInit();
fetch(url, init)
const { url, init } = getURLAndInit(apiConfig);
fetch(url + endpoint, init)
.then(response => {
const reader = response.body.getReader();
pump(reader);

View file

@ -1,20 +1,10 @@
import {
getAPIConfig,
getURLAndInit,
genCommonHeaders,
getAPIBaseURL
} from 'm/request-helper';
const endpoint = '/proxies';
function getURLAndInit() {
const c = getAPIConfig();
const baseURL = getAPIBaseURL(c);
const headers = genCommonHeaders(c);
return {
url: baseURL + endpoint,
init: { headers }
};
}
/*
$ curl "http://127.0.0.1:8080/proxies/Proxy" -XPUT -d '{ "name": "ss3" }' -i
HTTP/1.1 400 Bad Request
@ -32,16 +22,16 @@ Vary: Origin
Date: Tue, 16 Oct 2018 16:38:33 GMT
*/
async function fetchProxies() {
const { url, init } = getURLAndInit();
const res = await fetch(url, init);
async function fetchProxies(config) {
const { url, init } = getURLAndInit(config);
const res = await fetch(url + endpoint, init);
return await res.json();
}
async function requestToSwitchProxy(name1, name2) {
async function requestToSwitchProxy(apiConfig, name1, name2) {
const body = { name: name2 };
const { url, init } = getURLAndInit();
const fullURL = `${url}/${name1}`;
const { url, init } = getURLAndInit(apiConfig);
const fullURL = `${url}${endpoint}/${name1}`;
return await fetch(fullURL, {
...init,
method: 'PUT',
@ -49,10 +39,10 @@ async function requestToSwitchProxy(name1, name2) {
});
}
async function requestDelayForProxy(name) {
const { url, init } = getURLAndInit();
async function requestDelayForProxy(apiConfig, name) {
const { url, init } = getURLAndInit(apiConfig);
const qs = 'timeout=5000&url=http://www.google.com/generate_204';
const fullURL = `${url}/${name}/delay?${qs}`;
const fullURL = `${url}${endpoint}/${name}/delay?${qs}`;
return await fetch(fullURL, init);
}

View file

@ -1,21 +1,11 @@
import {
getAPIConfig,
getURLAndInit,
genCommonHeaders,
getAPIBaseURL
} from 'm/request-helper';
const endpoint = '/traffic';
const textDecoder = new TextDecoder('utf-8', { stream: true });
function getURLAndInit() {
const c = getAPIConfig();
const baseURL = getAPIBaseURL(c);
const headers = genCommonHeaders(c);
return {
url: baseURL + endpoint,
init: { headers }
};
}
const Size = 150;
const traffic = {
@ -69,10 +59,10 @@ function pump(reader) {
});
}
function fetchData() {
function fetchData(apiConfig) {
if (fetched) return traffic;
const { url, init } = getURLAndInit();
fetch(url, init).then(response => {
const { url, init } = getURLAndInit(apiConfig);
fetch(url + endpoint, init).then(response => {
if (response.ok) {
fetched = true;
const reader = response.body.getReader();

View file

@ -6,7 +6,6 @@
.h1 {
padding: 0 40px;
color: #ddd;
text-align: left;
margin: 0;
}

View file

@ -1,9 +1,12 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import cx from 'classnames';
import { useComponentState } from 'm/store';
import { getClashAPIConfig } from 'd/app';
import Icon from 'c/Icon';
import ContentHeader from 'c/ContentHeader';
// TODO move this into a redux action
import { fetchLogs } from '../api/logs';
import yacd from 's/yacd.svg';
@ -42,12 +45,16 @@ LogLine.propTypes = {
export default function Logs() {
const [logs, setLogs] = useState([]);
const { apiConfig } = useComponentState(getClashAPIConfig);
useEffect(() => {
const x = fetchLogs();
setLogs(x.logs);
return x.subscribe(() => setLogs(x.logs));
}, []);
useEffect(
() => {
const x = fetchLogs(apiConfig);
setLogs(x.logs);
return x.subscribe(() => setLogs(x.logs));
},
[apiConfig.hostname, apiConfig.port, apiConfig.secret]
);
return (
<div>

View file

@ -4,7 +4,7 @@ import { HashRouter as Router, Route } from 'react-router-dom';
// import { hot } from 'react-hot-loader';
// import createHistory from 'history/createHashHistory';
// import createHistory from 'history/createBrowserHistory';
import Theme from 'c/Theme';
import SideBar from 'c/SideBar';
import Home from 'c/Home';
import Logs from 'c/Logs';
@ -23,23 +23,27 @@ import s0 from './Root.module.scss';
window.store = store;
const Root = () => (
<Provider store={store}>
<Router>
<div className={s0.app}>
<APIDiscovery />
<Route path="/" render={() => <SideBar />} />
<div className={s0.content}>
<Route exact path="/" render={() => <Home />} />
<Route exact path="/overview" render={() => <Home />} />
<Route exact path="/configs" render={() => <Config />} />
<Route exact path="/logs" render={() => <Logs />} />
<Route exact path="/proxies" render={() => <Proxies />} />
</div>
</div>
</Router>
</Provider>
);
const Root = () => {
return (
<Provider store={store}>
<Theme>
<Router>
<div className={s0.app}>
<APIDiscovery />
<Route path="/" render={() => <SideBar />} />
<div className={s0.content}>
<Route exact path="/" render={() => <Home />} />
<Route exact path="/overview" render={() => <Home />} />
<Route exact path="/configs" render={() => <Config />} />
<Route exact path="/logs" render={() => <Logs />} />
<Route exact path="/proxies" render={() => <Proxies />} />
</div>
</div>
</Router>
</Theme>
</Provider>
);
};
// <Route exact path="/__0" component={StyleGuide} />
// <Route exact path="/__1" component={Loading} />

View file

@ -1,8 +1,8 @@
.app {
display: flex;
color: #ddd;
background: #202020;
background: var(--color-background);
color: var(--color-text);
min-height: 300px;
height: 100vh;
}
@ -10,6 +10,7 @@
.content {
flex-grow: 1;
overflow: scroll;
// background: #202020;
// $w: 7px;
// &::-webkit-scrollbar {

View file

@ -2,6 +2,9 @@ import React from 'react';
import PropTypes from 'prop-types';
import { NavLink } from 'react-router-dom';
import { useActions } from 'm/store';
import { switchTheme } from 'd/app';
import Icon from 'c/Icon';
import activity from 's/activity.svg';
@ -9,6 +12,7 @@ import settings from 's/settings.svg';
import globe from 's/globe.svg';
import file from 's/file.svg';
import yacd from 's/yacd.svg';
import moon from 's/moon.svg';
import s from 'c/SideBar.module.scss';
@ -27,7 +31,10 @@ SideBarRow.propTypes = {
labelText: PropTypes.string
};
const actions = { switchTheme };
function SideBar() {
const { switchTheme } = useActions(actions);
return (
<div className={s.root}>
<div className={s.logo}>
@ -40,6 +47,10 @@ function SideBar() {
<SideBarRow to="/configs" iconId={settings.id} labelText="Config" />
<SideBarRow to="/logs" iconId={file.id} labelText="Logs" />
</div>
<div className={s.themeSwitchContainer} onClick={switchTheme}>
<Icon id={moon.id} width={20} height={20} />
</div>
</div>
);
}

View file

@ -1,6 +1,6 @@
.root {
background: #2d2d30;
// width: 220px;
background: var(--color-bg-sidebar);
position: relative;
}
.logo {
@ -48,3 +48,21 @@
.label {
padding-left: 14px;
}
.themeSwitchContainer {
$sz: 50px;
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: $sz;
height: $sz;
padding: 20px 0;
display: flex;
justify-content: center;
align-items: center;
svg {
display: block;
}
}

17
src/components/Theme.js Normal file
View file

@ -0,0 +1,17 @@
import React, { memo, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { useComponentState } from 'm/store';
import { getTheme } from 'd/app';
import s0 from './Theme.module.scss';
const mapStateToProps = s => ({ theme: getTheme(s) });
function Theme({ children }) {
const { theme } = useComponentState(mapStateToProps);
const className = theme === 'dark' ? s0.dark : s0.light;
return <div className={className}>{children}</div>;
}
export default memo(Theme);

View file

@ -0,0 +1,11 @@
.dark {
--color-background: #202020;
--color-text: #ddd;
--color-bg-sidebar: #2d2d30;
}
.light {
--color-background: #eee;
--color-text: #222;
--color-bg-sidebar: #2d2d30;
}

View file

@ -2,6 +2,8 @@ import React, { useEffect } from 'react';
import prettyBytes from 'm/pretty-bytes';
import { fetchData } from '../api/traffic';
import { unstable_createResource as createResource } from 'react-cache';
import { useComponentState } from 'm/store';
import { getClashAPIConfig } from 'd/app';
// const delay = ms => new Promise(r => setTimeout(r, ms));
const chartJSResource = createResource(() => {
@ -119,29 +121,33 @@ const chartWrapperStyle = {
export default function TrafficChart() {
const Chart = chartJSResource.read();
useEffect(() => {
const ctx = document.getElementById('trafficChart').getContext('2d');
const traffic = fetchData();
const data = {
labels: traffic.labels,
datasets: [
{
...upProps,
data: traffic.up
},
{
...downProps,
data: traffic.down
}
]
};
const c = new Chart(ctx, {
type: 'line',
data,
options
});
return traffic.subscribe(() => c.update());
}, []);
const { hostname, port, secret } = useComponentState(getClashAPIConfig);
useEffect(
() => {
const ctx = document.getElementById('trafficChart').getContext('2d');
const traffic = fetchData({ hostname, port, secret });
const data = {
labels: traffic.labels,
datasets: [
{
...upProps,
data: traffic.up
},
{
...downProps,
data: traffic.down
}
]
};
const c = new Chart(ctx, {
type: 'line',
data,
options
});
return traffic.subscribe(() => c.update());
},
[hostname, port, secret]
);
return (
<div style={chartWrapperStyle}>

View file

@ -1,6 +1,8 @@
import React, { useState, useEffect } from 'react';
import prettyBytes from 'm/pretty-bytes';
import { useComponentState } from 'm/store';
import { getClashAPIConfig } from 'd/app';
import { fetchData } from '../api/traffic';
import s0 from 'c/TrafficNow.module.scss';
@ -23,13 +25,21 @@ export default function TrafficNow() {
function useSpeed() {
const [speed, setSpeed] = useState({ upStr: '0 B/s', downStr: '0 B/s' });
useEffect(() => {
return fetchData().subscribe(o =>
setSpeed({
upStr: prettyBytes(o.up) + '/s',
downStr: prettyBytes(o.down) + '/s'
})
);
});
const { hostname, port, secret } = useComponentState(getClashAPIConfig);
useEffect(
() => {
return fetchData({
hostname,
port,
secret
}).subscribe(o =>
setSpeed({
upStr: prettyBytes(o.up) + '/s',
downStr: prettyBytes(o.down) + '/s'
})
);
},
[hostname, port, secret]
);
return speed;
}

View file

@ -3,10 +3,12 @@ import { fetchConfigs } from 'd/configs';
import { closeModal } from 'd/modals';
const UpdateClashAPIConfig = 'app/UpdateClashAPIConfig';
const SwitchTheme = 'app/SwitchTheme';
const StorageKey = 'yacd.haishan.me';
export const getClashAPIConfig = s => s.app.clashAPIConfig;
export const getTheme = s => s.app.theme;
// TODO to support secret
export function updateClashAPIConfig({ hostname: iHostname, port, secret }) {
@ -25,6 +27,15 @@ export function updateClashAPIConfig({ hostname: iHostname, port, secret }) {
};
}
export function switchTheme() {
return (dispatch, getState) => {
const currentTheme = getTheme(getState());
const theme = currentTheme === 'light' ? 'dark' : 'light';
dispatch({ type: SwitchTheme, payload: { theme } });
};
}
// type Theme = 'light' | 'dark';
const defaultState = {
clashAPIConfig: {
hostname: '127.0.0.1',
@ -36,16 +47,20 @@ const defaultState = {
function getInitialState() {
let s = loadState(StorageKey);
if (!s) s = defaultState;
return s;
// TODO flat clashAPIConfig?
return { theme: 'dark', ...s };
}
const initialState = getInitialState();
export default function reducer(state = initialState, { type, payload }) {
export default function reducer(state = getInitialState(), { type, payload }) {
switch (type) {
case UpdateClashAPIConfig: {
return { ...state, clashAPIConfig: { ...payload } };
}
case SwitchTheme: {
return { ...state, ...payload };
}
default:
return state;
}

View file

@ -1,6 +1,7 @@
import * as configsAPI from 'a/configs';
import { openModal } from 'd/modals';
import * as trafficAPI from 'a/traffic';
import { openModal } from 'd/modals';
import { getClashAPIConfig } from 'd/app';
const CompletedFetchConfigs = 'configs/CompletedFetchConfigs';
const OptimisticUpdateConfigs = 'configs/OptimisticUpdateConfigs';
@ -12,7 +13,8 @@ export function fetchConfigs() {
return async (dispatch, getState) => {
let res;
try {
res = await configsAPI.fetchConfigs();
const apiSetup = getClashAPIConfig(getState());
res = await configsAPI.fetchConfigs(apiSetup);
} catch (err) {
// FIXME
// eslint-disable-next-line no-console
@ -44,7 +46,8 @@ export function fetchConfigs() {
// normally user will land on the "traffic chart" page first
// calling this here will let the data start streaming
// the traffic chart should already subscribed to the streaming
trafficAPI.fetchData();
const apiSetup = getClashAPIConfig(getState());
trafficAPI.fetchData(apiSetup);
} else {
dispatch(markHaveFetchedConfig());
}
@ -62,8 +65,9 @@ function markHaveFetchedConfig() {
export function updateConfigs(partialConfg) {
return async (dispatch, getState) => {
const apiSetup = getClashAPIConfig(getState());
configsAPI
.updateConfigs(partialConfg)
.updateConfigs(apiSetup, partialConfg)
.then(
res => {
if (res.ok === false) {

View file

@ -1,5 +1,6 @@
import { createSelector } from 'reselect';
import * as proxiesAPI from 'a/proxies';
import { getClashAPIConfig } from 'd/app';
// see all types:
// https://github.com/Dreamacro/clash/blob/master/constant/adapters.go
@ -10,15 +11,18 @@ const ProxyGroupTypes = ['Fallback', 'URLTest', 'Selector'];
export const getProxies = s => s.proxies.proxies;
export const getDelay = s => s.proxies.delay;
export const getProxyGroupNames = s => s.proxies.groupNames;
export const getUserProxies = createSelector(getProxies, proxies => {
let o = {};
for (const prop in proxies) {
if (ProxyTypeBuiltin.indexOf(prop) < 0) {
o[prop] = proxies[prop];
export const getUserProxies = createSelector(
getProxies,
proxies => {
let o = {};
for (const prop in proxies) {
if (ProxyTypeBuiltin.indexOf(prop) < 0) {
o[prop] = proxies[prop];
}
}
return o;
}
return o;
});
);
const CompletedFetchProxies = 'proxies/CompletedFetchProxies';
const OptimisticSwitchProxy = 'proxies/OptimisticSwitchProxy';
@ -43,12 +47,14 @@ export function fetchProxies() {
return async (dispatch, getState) => {
// TODO handle errors
const proxiesCurr = getProxies(getState());
const state = getState();
const proxiesCurr = getProxies(state);
// TODO this is too aggressive...
if (Object.keys(proxiesCurr).length > 0) return;
const apiConfig = getClashAPIConfig(state);
// TODO show loading animation?
const json = await proxiesAPI.fetchProxies();
const json = await proxiesAPI.fetchProxies(apiConfig);
let { proxies = {} } = json;
const groupNames = retrieveGroupNamesFrom(proxies);
@ -63,9 +69,10 @@ export function fetchProxies() {
export function switchProxy(name1, name2) {
return async (dispatch, getState) => {
const apiConfig = getClashAPIConfig(getState());
// TODO display error message
proxiesAPI
.requestToSwitchProxy(name1, name2)
.requestToSwitchProxy(apiConfig, name1, name2)
.then(
res => {
if (res.ok === false) {
@ -97,7 +104,8 @@ export function switchProxy(name1, name2) {
function requestDelayForProxyOnce(name) {
return async (dispatch, getState) => {
const res = await proxiesAPI.requestDelayForProxy(name);
const apiConfig = getClashAPIConfig(getState());
const res = await proxiesAPI.requestDelayForProxy(apiConfig, name);
let error = '';
if (res.ok === false) {
error = res.statusText;

View file

@ -1,15 +1,7 @@
import { store } from '../store/configureStore';
import { getClashAPIConfig } from 'd/app';
const headersCommon = {
'Content-Type': 'application/json'
};
export function getAPIConfig() {
// this is cheating...
return getClashAPIConfig(store.getState());
}
export function genCommonHeaders({ secret }) {
const h = { ...headersCommon };
if (secret) {
@ -21,3 +13,12 @@ export function genCommonHeaders({ secret }) {
export function getAPIBaseURL({ hostname, port }) {
return `http://${hostname}:${port}`;
}
export function getURLAndInit({ hostname, port, secret }) {
const baseURL = getAPIBaseURL({ hostname, port });
const headers = genCommonHeaders({ secret });
return {
url: baseURL,
init: { headers }
};
}

1
src/svg/moon.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg>

After

Width:  |  Height:  |  Size: 253 B