首个版本

main
jasonfoknxu 3 years ago
commit 96700f7d03

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

12
.env

@ -0,0 +1,12 @@
# 是否使用后端?
REACT_APP_USE_BACKEND=true
# 预设的直播间号
REACT_APP_ROOM_ID=196
# B站API的链接
REACT_APP_BILIBILI_LIVE_INFO_API=https://api.live.bilibili.com/room/v1/Room/get_info_by_id?ids[]={ROOM_ID}
# 后端端口
REACT_APP_BACKEND_PORT=3001
# 前端端口
PORT=3000
# 浏览器
BROWSER=none

26
.gitignore vendored

@ -0,0 +1,26 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# Sample file
__sample/
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 YUKARI FAN CLUB
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

@ -0,0 +1,78 @@
# Bilibili 直播间信息 API 演示
获取B站直播间的基本信息简单前端展示概念
![预览图](.assets/preview1.png)
![预览图2](.assets/preview2.png)
## 注意事项
- :warning: 这只是演示,供研究用途,不建议实际使用
- :no_entry_sign: 请勿同一时间多次存取B站API短时间内发起过多请求会被B站暂时封禁
- :arrows_counterclockwise: B站API可能会随时更改本文档及代码不一定会随着更新
- :no_entry: 由于浏览器直接请求B站API会有跨域限制因此弄了个简单的后端作转接如想直接在浏览器请求**不建议!**请自行在浏览器上设置好停用CORS策略
## Bilibili API 解析
> https://api.live.bilibili.com/room/v1/Room/get_info_by_id?ids[]={直播间号}
*如要更多信息可使用 https://api.live.bilibili.com/room/v1/Room/get_info?room_id={直播间号}*
| 字段 | 类型 | 内容 |
| ----------------- |:------:|:--------------------------------------------------------- |
| roomid | string | 房间号 |
| uid | string | 主播 UID |
| uname | string | 主播名 |
| cover | string | 直播间封面 |
| live_time | string | 开播时间(*只在开播时显示,下播后会返回 0000-00-00 00:00:00* |
| title | string | 直播间标题 |
| tags | string | 直播间标签 |
| user_cover | string | 自定直播间封面 |
| short_id | string | 直播间短号 |
| online | string | 人气值 |
| area_v2_id | string | 分区 ID |
| area_v2_parent_id | string | 大分区 ID |
| background | string | 直播间背景图 |
| area_v2_name | string | 分区名称 |
| first_live_time | number | 首播时间 unix 时间戳) |
| live_id | number | 直播场次 ID |
| live_status | number | 开播状态0 为未开播1 为直播中2 为轮播中) |
## 安装及使用
1. `git clone` 或手动下载源码
2. `yarn``npm install` 安装依赖的模块
3. (选项)打开 `.env` 更改演示的设置
- `REACT_APP_USE_BACKEND` 是否使用后端,预设`true`,设置`false`就会以浏览器直接请求B站API需要先在浏览器设置好停用CORS限制
- `REACT_APP_ROOM_ID` 预设的直播间号,预设`196`
- `REACT_APP_BILIBILI_LIVE_INFO_API` B站API的链接使用`{ROOM_ID}`为直播间号占位符
- `REACT_APP_BACKEND_PORT` 后端使用的端口,预设`3001`
- `PORT` 前端使用的端口Create React App 的参数,预设`3000`
- `BROWSER` 前端运行时是否自动打开浏览器Create React App 的参数,预设`none`为不打开浏览器,删除可自动打开预设浏览器,或自行指定喜欢的浏览器([CRA 设置文档](https://create-react-app.dev/docs/advanced-configuration/)
4. 如不使用后端,请跳到第 6 步。
5. `yarn run backend``npm run backend` 启动后端
6. `yarn start``npm start` 运行前端,也可以使用 `yarn run build``npm run build` 编译成静态档案
## 技术栈
- TypeScript
- React
- Node.js
- Create React App
- SASS
---
```
__ ___ _ _ __ _____ _____ ______ _ _ _____ _ _ _ ____
\ \ / / | | | |/ / /\ | __ \|_ _| | ____/\ | \ | | / ____| | | | | | _ \
\ \_/ /| | | | ' / / \ | |__) | | | | |__ / \ | \| | | | | | | | | | |_) |
\ / | | | | < / /\ \ | _ / | | | __/ /\ \ | . ` | | | | | | | | | _ <
| | | |__| | . \ / ____ \| | \ \ _| |_ | | / ____ \| |\ | | |____| |___| |__| | |_) |
|_| \____/|_|\_\/_/ \_\_| \_\_____| |_|/_/ \_\_| \_| \_____|______\____/|____/
小缘粉丝俱乐部 YUKARI FAN CLUB || https://yukari.top/ || https://xiaoyuan.club
```
小缘粉丝俱乐部 YUKARI FAN CLUB

@ -0,0 +1,45 @@
{
"name": "bilibili-live-info",
"version": "1.0.0",
"author": "YUKARI FAN CLUB <admin@yukari.top> (https://yukari.top)",
"license": "MIT",
"repository": "github:YUKARI-FAN-CLUB/bilibili-live-info",
"private": false,
"dependencies": {
"@types/node": "^16.7.13",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1",
"typescript": "^4.4.2"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"backend": "node src/backend/main.js",
"build-backend": "tsc src/backend/main.ts --module 'node16'"
},
"eslintConfig": {
"extends": [
"react-app"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"sass": "^1.57.1"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="zh-hans">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="referrer" content="no-referrer"/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#00A0D0" />
<meta
name="description"
content="Bilibili 直播间信息 API 演示"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>B站直播间信息</title>
</head>
<body>
<noscript>请启用 JavaScript 来打开本页面。</noscript>
<div id="root"></div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

@ -0,0 +1,25 @@
{
"short_name": "B站直播间信息",
"name": "Bilibili 直播间信息 API 演示",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#00A0D0",
"background_color": "#ffffff"
}

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

@ -0,0 +1,74 @@
import React, { useEffect, useState } from 'react';
import './styles/App.scss';
import SmallCard from './components/SmallCard';
import Card from './components/Card';
import SimpleTable from './components/SimpleTable';
import LiveInfo from './interface/LiveInfo';
const setRoomId = (roomId: number | string, url: string) => {
return url.replace(/\{ROOM_ID\}/g, roomId.toString() ?? '196');
};
const getInfo = async (roomId: number): Promise<LiveInfo | undefined> => {
if (!process.env.REACT_APP_BILIBILI_LIVE_INFO_API) {
return;
}
let url = setRoomId(roomId, process.env.REACT_APP_BILIBILI_LIVE_INFO_API);
if (process.env.REACT_APP_USE_BACKEND?.toLowerCase() === 'true') {
url = setRoomId(roomId, `http://localhost:${process.env.REACT_APP_BACKEND_PORT}/?room={ROOM_ID}`);
}
const response = await fetch(url, {
method: 'GET',
mode: 'cors',
headers: { 'Content-Type': 'application/json' },
});
const json = await response.json();
if (!json.data) {
return;
}
const result: LiveInfo[] = Object.values(json.data);
return result[0];
};
const App = () => {
const [room, setRoom] = useState(196);
const [liveInfo, setLiveInfo] = useState<LiveInfo>();
const changeRoom = async () => {
const info = await getInfo(room);
setLiveInfo(info);
};
useEffect(() => {
(async () => {
await changeRoom();
})();
}, []);
return (
<div>
<div className='input'>
<label htmlFor='roomId'></label>
<input
type={'number'}
id='roomId'
name='roomId'
value={room}
min='0'
onChange={(e) => setRoom(parseInt(e.target.value))}
/>
<button onClick={changeRoom}></button>
</div>
<h1></h1>
{liveInfo && <SmallCard roomId={liveInfo.short_id?.toString() ?? liveInfo.roomid.toString()} title={liveInfo.title} uname={liveInfo.uname} cover={liveInfo.room_cover ?? liveInfo.user_cover ?? liveInfo.cover} status={parseInt(liveInfo.live_status.toString())} />}
{liveInfo && <Card roomId={liveInfo.short_id?.toString() ?? liveInfo.roomid.toString()} title={liveInfo.title} uname={liveInfo.uname} cover={liveInfo.room_cover ?? liveInfo.user_cover ?? liveInfo.cover} status={parseInt(liveInfo.live_status.toString())} startTime={liveInfo.live_time?.toString() === '0000-00-00 00:00:00' ? '目前没有直播' : liveInfo.live_time?.toString()} />}
<h1></h1>
{liveInfo && <SimpleTable data={liveInfo} />}
</div>
);
};
export default App;

@ -0,0 +1,116 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const http_1 = __importDefault(require("http"));
const https_1 = __importDefault(require("https"));
const url_1 = __importDefault(require("url"));
const dotenv = __importStar(require("dotenv"));
dotenv.config();
const setRoomId = (roomId, url) => {
return url.replace(/\{ROOM_ID\}/g, roomId.toString() ?? '196');
};
const getLiveInfo = async (room) => {
return new Promise((resolve, reject) => {
if (!process.env.REACT_APP_BILIBILI_LIVE_INFO_API) {
return { message: '请先设置 Bilibili API URL' };
}
const url = setRoomId(room, process.env.REACT_APP_BILIBILI_LIVE_INFO_API);
let req = https_1.default.get(url, (res) => {
console.log(`正在请求: ${url}`);
let data = '';
res.on('error', (err) => {
console.error(err);
reject(err);
});
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
try {
let result;
if (res.statusCode === 200) {
result = JSON.parse(data);
}
resolve(result);
}
catch (err) {
console.error(err);
reject(err);
}
});
});
req.on('timeout', () => {
console.error('请求超时');
reject();
});
req.on('uncaughtException', (err) => {
console.error('错误:' + err);
reject(err);
});
req.on('error', (err) => {
console.error('错误:' + err.message);
reject(err);
});
req.end();
});
};
const server = http_1.default.createServer(async (req, res) => {
if (req.headers.origin) {
res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
}
else {
res.setHeader('Access-Control-Allow-Origin', '*');
}
res.setHeader('Access-Control-Request-Method', '*');
res.setHeader('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
res.setHeader('Access-Control-Allow-Methods', 'OPTIONS, GET, POST, PUT');
if (req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json' });
let query;
if (req.url) {
query = url_1.default.parse(req.url, true).query ?? undefined;
}
let result = await getLiveInfo(query?.room ?? '196');
res.write(JSON.stringify(result));
res.end();
}
else if (req.method === 'OPTIONS') {
res.writeHead(200);
res.end();
return;
}
else {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ message: '错误' }));
}
});
(() => {
server.listen(parseInt(process.env.REACT_APP_BACKEND_PORT ?? '3001'), () => {
console.log(`后端服务器已启动! (HTTP 端口: ${process.env.REACT_APP_BACKEND_PORT ?? '3001'})`);
});
})();

@ -0,0 +1,100 @@
import http from 'http';
import https from 'https';
import url from 'url';
import * as dotenv from 'dotenv';
dotenv.config();
const setRoomId = (roomId: string, url: string) => {
return url.replace(/\{ROOM_ID\}/g, roomId.toString() ?? '196');
};
const getLiveInfo = async (room: string) => {
return new Promise((resolve, reject) => {
if (!process.env.REACT_APP_BILIBILI_LIVE_INFO_API) {
return { message: '请先设置 Bilibili API URL' };
}
const url = setRoomId(room, process.env.REACT_APP_BILIBILI_LIVE_INFO_API);
let req = https.get(url, (res) => {
console.log(`正在请求: ${url}`);
let data = '';
res.on('error', (err) => {
console.error(err);
reject(err);
});
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
try {
let result;
if (res.statusCode === 200) {
result = JSON.parse(data);
}
resolve(result);
} catch (err) {
console.error(err);
reject(err);
}
});
});
req.on('timeout', () => {
console.error('请求超时');
reject();
});
req.on('uncaughtException', (err) => {
console.error('错误:' + err);
reject(err);
});
req.on('error', (err) => {
console.error('错误:' + err.message);
reject(err);
});
req.end();
});
};
const server = http.createServer(async (req, res) => {
if (req.headers.origin) {
res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
} else {
res.setHeader('Access-Control-Allow-Origin', '*');
}
res.setHeader('Access-Control-Request-Method', '*');
res.setHeader('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
res.setHeader('Access-Control-Allow-Methods', 'OPTIONS, GET, POST, PUT');
if (req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json' });
let query: { room?: string } | undefined;
if (req.url) {
query = url.parse(req.url, true).query ?? undefined;
}
let result = await getLiveInfo(query?.room ?? '196');
res.write(JSON.stringify(result));
res.end();
} else if (req.method === 'OPTIONS') {
res.writeHead(200);
res.end();
return;
} else {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ message: '错误' }));
}
});
(() => {
server.listen(parseInt(process.env.REACT_APP_BACKEND_PORT ?? '3001'), () => {
console.log(`后端服务器已启动! (HTTP 端口: ${process.env.REACT_APP_BACKEND_PORT ?? '3001'})`);
});
})();

@ -0,0 +1,44 @@
import CardData from '../interface/CardData';
import '../styles/card.scss';
const liveStatus = (status: number | string) => {
if (typeof status === 'string') {
status = parseInt(status);
}
switch (status) {
case 0:
return <><span className='status off'></span></>;
case 1:
return <><span className='status on'></span></>;
case 2:
return <><span className='status play'></span></>;
default:
return <><span className='status unknown'></span></>;
}
};
const Card = ({ roomId, title, uname, cover, status, startTime }: CardData) => {
return (
<div className='card'>
<img src={cover} alt='直播间封面' />
<div className='card-content'>
<h2>{title}</h2>
<div>{uname}</div>
<div>
{!startTime || startTime?.toString() === '0000-00-00 00:00:00' ? (
<span></span>
) : (
startTime.toString()
)}
</div>
</div>
<div className='card-bottom'>
<div className='card-status'>{liveStatus(status)}</div>
<div><span>{roomId}</span></div>
</div>
</div>
);
};
export default Card;

@ -0,0 +1,94 @@
import LiveInfo from '../interface/LiveInfo';
import '../styles/simpleTable.scss'
const liveStatus = (status: number | string) => {
if (typeof status === 'string') {
status = parseInt(status);
}
switch (status) {
case 0:
return '未开播';
case 1:
return '直播中';
case 2:
return '轮播中';
default:
return '未知';
}
};
const image = (url: string | undefined) => {
if (!url) {
return undefined;
}
return <img src={url} alt={url} />;
};
const SimpleTable = ({ data }: { data: LiveInfo }) => {
return (
<div className='table'>
<div>
<div></div>
<div>{data.roomid?.toString() ?? ''}</div>
</div>
<div>
<div></div>
<div>{data.short_id?.toString() ?? <span></span>}</div>
</div>
<div>
<div>UID</div>
<div>
{data.uname ?? ''}
{data.uid ? `${data.uid}` : ''}
</div>
</div>
<div>
<div></div>
<div>{data.title ?? ''}</div>
</div>
<div>
<div></div>
<div>{image(data.room_cover) ?? image(data.user_cover) ?? image(data.cover) ?? <span></span>}</div>
</div>
<div>
<div></div>
<div>{image(data.cover) ?? <span></span>}</div>
</div>
<div>
<div></div>
<div>
{data.area_v2_name?.toString() ?? ''}
{data.area_v2_id ? `ID: ${data.area_v2_id}` : ''}
</div>
</div>
<div>
<div></div>
<div><span>{liveStatus(data.live_status)}</span></div>
</div>
<div>
<div></div>
<div>
{!data.live_time || data.live_time?.toString() === '0000-00-00 00:00:00' ? (
<span></span>
) : (
data.live_time.toString()
)}
</div>
</div>
<div>
<div></div>
<div>{data.online?.toString() ?? ''}</div>
</div>
<div>
<div></div>
<div>{data.tags ? data.tags.split(',').map((t,idx) => <span key={idx}>{t}</span>) : ''}</div>
</div>
<div>
<div></div>
<div>{data.first_live_time ? new Date(data.first_live_time * 1000).toLocaleString() : ''}</div>
</div>
</div>
);
};
export default SimpleTable;

@ -0,0 +1,33 @@
import CardData from '../interface/CardData';
import '../styles/smallCard.scss';
const liveStatus = (status: number | string) => {
if (typeof status === 'string') {
status = parseInt(status);
}
switch (status) {
case 0:
return <span className='status off'><span>&#9679;</span> </span>;
case 1:
return <span className='status on'><span>&#9679;</span> </span>;
case 2:
return <span className='status play'><span>&#9679;</span> </span>;
default:
return <span className='status unknown'><span>&#9679;</span> </span>;
}
};
const SmallCard = ({ roomId, title, uname, cover, status }: CardData) => {
return (
<div className='small-card'>
<div className='small-card-body small-card-cover'><img src={cover} alt='直播间封面' /></div>
<div className='small-card-body'>
<div className='small-card-caption'><span>{uname} &#9474; {roomId}</span></div>
<h3>{title}</h3>
<div className='small-card-status'>{liveStatus(status)}</div>
</div>
</div>
);
};
export default SmallCard;

@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './styles/index.css';
import App from './App';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

@ -0,0 +1,11 @@
interface CardData {
roomId: string,
title: string,
uname?: string,
cover: string,
status: number,
startTime?: string
}
export default CardData;

@ -0,0 +1,19 @@
interface LiveInfo {
roomid: string | number;
uid: string | number;
uname: string;
cover: string;
title: string;
live_status: number | string;
tags?: string;
user_cover?: string;
short_id?: string | number;
online?: string | number;
area_v2_name?: string;
live_time?: string;
first_live_time?: number;
room_cover?: string;
[key: string]: string | Number | undefined;
}
export default LiveInfo;

@ -0,0 +1,20 @@
.input {
display: inline-block;
padding: 0.5rem;
background: #ccc;
border-radius: 5px;
input {
font-size: 110%;
}
button {
background: #06f;
color: #fff;
border: none;
padding: 8px 20px;
text-align: center;
cursor: pointer;
border-radius: 8px;
}
}

@ -0,0 +1,52 @@
.card {
display: inline-block;
padding: 0;
background: #f5f5f5;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
text-align: center;
img {
object-fit: cover;
}
.card-content {
padding: 0 1rem 1.5rem 1rem;
}
.card-bottom {
display: flex;
background: rgba(0, 0, 0, 0.05);
border-top: 1px solid rgba(0, 0, 0, 0.1);
padding: 0.3rem 1rem;
div {
flex: 1;
text-align: right;
}
.card-status {
text-align: left;
.status {
display: inline-block;
padding: 0.5rem;
border-radius: 50%;
margin-right: 5px;
vertical-align: middle;
&.on {
background: #090;
}
&.off {
background: #aaa;
}
&.play {
background: #f90;
}
&.unknown {
background: #c00;
}
}
}
}
}

@ -0,0 +1,7 @@
body {
margin: 0;
padding: 10px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: #E5E5E5;
}

@ -0,0 +1,29 @@
.table {
margin: 2rem;
display: table;
div {
display: table-row;
padding: 0.5rem;
div {
display: table-cell;
border: 1px solid #000;
&:first-child {
text-align: right;
}
}
span {
display: inline-block;
padding: 8px;
background: #CCC;
border-radius: 3rem;
margin-left: 5px;
margin-right: 5px;
}
}
img {
max-width: 50vw;
width: 100%;
}
}

@ -0,0 +1,63 @@
.small-card {
display: flex;
width: 700px;
padding: 0;
background: #f5f5f5;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
text-align: center;
margin: 15px 0;
.small-card-body {
flex: 1;
h3 {
margin-top: 0;
margin-bottom: 0.5em;
}
}
.small-card-cover {
flex: none;
text-align: left;
padding: 0;
width: 30%;
img {
width: 100%;
height:100%;
}
}
.small-card-caption {
color: #666;
text-align: right;
span {
display: inline-block;
background: #ddd;
padding: 3px 12px;
border-bottom-left-radius: 8px;
}
}
.small-card-status {
.status {
span {
font-size: 200%;
vertical-align: sub;
}
&.on {
color: #090;
}
&.off {
color: #aaa;
}
&.play {
color: #f90;
}
&.unknown {
color: #c00;
}
}
}
}

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "es2020",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save