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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
.root { .root {
background: #2d2d30; background: var(--color-bg-sidebar);
// width: 220px; position: relative;
} }
.logo { .logo {
@ -48,3 +48,21 @@
.label { .label {
padding-left: 14px; 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 prettyBytes from 'm/pretty-bytes';
import { fetchData } from '../api/traffic'; import { fetchData } from '../api/traffic';
import { unstable_createResource as createResource } from 'react-cache'; 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 delay = ms => new Promise(r => setTimeout(r, ms));
const chartJSResource = createResource(() => { const chartJSResource = createResource(() => {
@ -119,9 +121,11 @@ const chartWrapperStyle = {
export default function TrafficChart() { export default function TrafficChart() {
const Chart = chartJSResource.read(); const Chart = chartJSResource.read();
useEffect(() => { const { hostname, port, secret } = useComponentState(getClashAPIConfig);
useEffect(
() => {
const ctx = document.getElementById('trafficChart').getContext('2d'); const ctx = document.getElementById('trafficChart').getContext('2d');
const traffic = fetchData(); const traffic = fetchData({ hostname, port, secret });
const data = { const data = {
labels: traffic.labels, labels: traffic.labels,
datasets: [ datasets: [
@ -141,7 +145,9 @@ export default function TrafficChart() {
options options
}); });
return traffic.subscribe(() => c.update()); return traffic.subscribe(() => c.update());
}, []); },
[hostname, port, secret]
);
return ( return (
<div style={chartWrapperStyle}> <div style={chartWrapperStyle}>

View file

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

View file

@ -3,10 +3,12 @@ import { fetchConfigs } from 'd/configs';
import { closeModal } from 'd/modals'; import { closeModal } from 'd/modals';
const UpdateClashAPIConfig = 'app/UpdateClashAPIConfig'; const UpdateClashAPIConfig = 'app/UpdateClashAPIConfig';
const SwitchTheme = 'app/SwitchTheme';
const StorageKey = 'yacd.haishan.me'; const StorageKey = 'yacd.haishan.me';
export const getClashAPIConfig = s => s.app.clashAPIConfig; export const getClashAPIConfig = s => s.app.clashAPIConfig;
export const getTheme = s => s.app.theme;
// TODO to support secret // TODO to support secret
export function updateClashAPIConfig({ hostname: iHostname, port, 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 = { const defaultState = {
clashAPIConfig: { clashAPIConfig: {
hostname: '127.0.0.1', hostname: '127.0.0.1',
@ -36,16 +47,20 @@ const defaultState = {
function getInitialState() { function getInitialState() {
let s = loadState(StorageKey); let s = loadState(StorageKey);
if (!s) s = defaultState; 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) { switch (type) {
case UpdateClashAPIConfig: { case UpdateClashAPIConfig: {
return { ...state, clashAPIConfig: { ...payload } }; return { ...state, clashAPIConfig: { ...payload } };
} }
case SwitchTheme: {
return { ...state, ...payload };
}
default: default:
return state; return state;
} }

View file

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

View file

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

View file

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