First commit
This commit is contained in:
3335
web/package-lock.json
generated
Normal file
3335
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
web/package.json
Normal file
44
web/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "mirakurun-rs-web",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "webpack --mode production",
|
||||
"build:development": "webpack --mode development",
|
||||
"typecheck": "tsc --project src/tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@blueprintjs/core": "^5.17.6",
|
||||
"@blueprintjs/icons": "^5.20.0",
|
||||
"buffer": "^6.0.3",
|
||||
"eventemitter3": "4.0.7",
|
||||
"ip-num": "1.3.4",
|
||||
"jsonrpc2-ws": "1.0.0-beta23",
|
||||
"luxon": "^3.6.1",
|
||||
"normalize.css": "^8.0.1",
|
||||
"process": "^0.11.10",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-indiana-drag-scroll": "^2.2.1",
|
||||
"react-router-dom": "7.18.2",
|
||||
"sift": "15.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/luxon": "^3.6.2",
|
||||
"@types/node": "22",
|
||||
"@types/react": "^18.2.66",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@types/ws": "^7.4.7",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"css-loader": "5.2.7",
|
||||
"sass": "^1.89.0",
|
||||
"sass-loader": "^16.0.5",
|
||||
"style-loader": "^2.0.0",
|
||||
"ts-loader": "9.5.2",
|
||||
"typescript": "5.7",
|
||||
"webpack": "^5.107.0",
|
||||
"webpack-cli": "^6.0.1"
|
||||
},
|
||||
"overrides": {
|
||||
"uuid": "11.1.1"
|
||||
}
|
||||
}
|
||||
33
web/src/components/DateTimeRange.sass
Normal file
33
web/src/components/DateTimeRange.sass
Normal file
@@ -0,0 +1,33 @@
|
||||
@use "~@blueprintjs/colors/lib/scss/colors"
|
||||
|
||||
.component-date-time-range
|
||||
position: relative
|
||||
display: inline-block
|
||||
|
||||
span.relative
|
||||
opacity: 0.7
|
||||
|
||||
span.progress
|
||||
position: absolute
|
||||
display: block
|
||||
left: 0
|
||||
right: 0
|
||||
bottom: 0
|
||||
height: 16%
|
||||
opacity: 0.7
|
||||
background-color: rgba(colors.$black, 0.25)
|
||||
|
||||
span
|
||||
position: absolute
|
||||
top: 0
|
||||
left: 0
|
||||
bottom: 0
|
||||
width: 0
|
||||
transition: width 0.5s ease
|
||||
background-color: colors.$orange4
|
||||
|
||||
.bp5-dark &
|
||||
background-color: rgba(colors.$white, 0.25)
|
||||
|
||||
span
|
||||
background-color: colors.$orange5
|
||||
105
web/src/components/DateTimeRange.tsx
Normal file
105
web/src/components/DateTimeRange.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
Copyright 2026 kanreisa
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { DateTime } from "luxon";
|
||||
import { inRange } from "../modules/common";
|
||||
import { clearSchedule, setSchedule } from "../modules/at";
|
||||
|
||||
import "./DateTimeRange.sass";
|
||||
|
||||
type DateTimeRangeProps = {
|
||||
start: number;
|
||||
end?: number;
|
||||
};
|
||||
export const DateTimeRange: React.FC<DateTimeRangeProps> = ({ start, end }) => {
|
||||
console.debug("components", "DateTimeRange");
|
||||
|
||||
const [update, setUpdate] = useState(0);
|
||||
|
||||
const nowDate = DateTime.now();
|
||||
const startDate = DateTime.fromMillis(start);
|
||||
const endDate = end ? DateTime.fromMillis(end) : undefined;
|
||||
const durationS = endDate ? endDate.diff(startDate, "seconds").seconds : 0;
|
||||
const deltaS = nowDate.diff(startDate, "seconds").seconds;
|
||||
const progress = end && inRange(nowDate, startDate, endDate) ? deltaS / durationS : undefined;
|
||||
const relative = progress ? "放送中" : `@${startDate.toRelative({ style: "narrow" })}`;
|
||||
|
||||
useEffect(() => {
|
||||
const schedules: ReturnType<typeof setSchedule>[] = [];
|
||||
if (nowDate <= startDate) {
|
||||
schedules.push(setSchedule(startDate.toMillis(), () => setUpdate(Date.now())));
|
||||
}
|
||||
if (nowDate <= endDate) {
|
||||
schedules.push(setSchedule(endDate.toMillis(), () => setUpdate(Date.now())));
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const id of schedules) {
|
||||
clearSchedule(id);
|
||||
}
|
||||
};
|
||||
}, [start, end]);
|
||||
|
||||
useEffect(() => {
|
||||
let ms = 10000;
|
||||
if (!progress) {
|
||||
const diff = Math.abs(deltaS);
|
||||
if (diff < 15) {
|
||||
ms = 1000;
|
||||
} else if (diff < 30) {
|
||||
ms = 5000;
|
||||
} else if (diff < 60) {
|
||||
ms = 10000;
|
||||
} else if (diff < 60 * 2) {
|
||||
ms = 20000;
|
||||
} else if (diff < 60 * 5) {
|
||||
ms = 30000;
|
||||
} else if (diff < 60 * 60) {
|
||||
ms = 60000;
|
||||
} else {
|
||||
ms = 180000;
|
||||
}
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => setUpdate(Date.now()), ms);
|
||||
|
||||
return () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}, [update]);
|
||||
|
||||
return (
|
||||
<span className="component-date-time-range" title={startDate.toISO()}>
|
||||
{startDate.toFormat("M/d (ccc) HH:mm")}
|
||||
–
|
||||
{end && <>
|
||||
{endDate.toFormat("HH:mm")}
|
||||
({durationS / 60}分間)
|
||||
</>}
|
||||
|
||||
<span className="relative">{relative}</span>
|
||||
|
||||
{progress && (
|
||||
<span className="progress">
|
||||
<span style={{ width: `${progress * 100}%` }} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
333
web/src/components/EPGTable.sass
Normal file
333
web/src/components/EPGTable.sass
Normal file
@@ -0,0 +1,333 @@
|
||||
@use "sass:color"
|
||||
@use "~@blueprintjs/colors/lib/scss/colors"
|
||||
|
||||
$header-height: 40px
|
||||
$timescale-width: 24px
|
||||
$block-width: 170px
|
||||
$block-height: 240px
|
||||
$timetable-border-color: colors.$gray5
|
||||
|
||||
.component-epg-table
|
||||
position: absolute
|
||||
top: 0
|
||||
right: 0
|
||||
bottom: 0
|
||||
left: 0
|
||||
background: colors.$light-gray2
|
||||
|
||||
.hide
|
||||
opacity: 0
|
||||
pointer-events: none
|
||||
|
||||
> *
|
||||
position: absolute
|
||||
|
||||
> button.bp5-button
|
||||
z-index: 2
|
||||
backdrop-filter: blur(8px) brightness(1.1)
|
||||
border-color: colors.$gray1 !important
|
||||
transition: all 0.1s ease 0s
|
||||
box-shadow: 0 0 0 1px rgba(colors.$white, 0.5) !important
|
||||
color: colors.$black !important
|
||||
|
||||
.bp5-icon > svg:not([fill])
|
||||
color: colors.$gray1 !important
|
||||
|
||||
&.jump-to-timeline
|
||||
right: 25px
|
||||
bottom: 25px
|
||||
|
||||
> .header
|
||||
z-index: 1
|
||||
top: 0
|
||||
right: 0
|
||||
left: 0
|
||||
height: $header-height
|
||||
white-space: nowrap
|
||||
overflow: hidden
|
||||
padding-left: $timescale-width
|
||||
margin-left: 0px // for scroll
|
||||
background: colors.$dark-gray5
|
||||
|
||||
.bp5-dark &
|
||||
background: colors.$dark-gray1
|
||||
|
||||
.epg-table-header-item
|
||||
vertical-align: top
|
||||
display: inline-flex
|
||||
align-items: center
|
||||
overflow: hidden
|
||||
font-size: 13px
|
||||
font-weight: 500
|
||||
line-height: $header-height
|
||||
width: $block-width
|
||||
height: $header-height
|
||||
padding: 0 5px
|
||||
color: colors.$light-gray4
|
||||
|
||||
&.date
|
||||
font-weight: 600
|
||||
|
||||
.bp5-dark &
|
||||
color: colors.$light-gray3
|
||||
|
||||
&:last-child
|
||||
margin-right: 24px
|
||||
|
||||
&:not(.loading)
|
||||
opacity: 0
|
||||
animation: 0.4s ease 0.2s 1 normal forwards running fade-in
|
||||
|
||||
&:hover
|
||||
background: color.adjust(colors.$dark-gray5, $lightness: -8%)
|
||||
|
||||
.bp5-dark &
|
||||
background: color.adjust(colors.$dark-gray1, $lightness: 7%)
|
||||
|
||||
> img,
|
||||
> div.img
|
||||
width: 32px
|
||||
height: 18px
|
||||
margin-right: 5px
|
||||
border-radius: 1px
|
||||
filter: saturate(80%)
|
||||
|
||||
&:hover:not(.loading) > img
|
||||
filter: none
|
||||
|
||||
> span
|
||||
text-overflow: ellipsis
|
||||
font-feature-settings: "palt" 1
|
||||
overflow: hidden
|
||||
|
||||
&.bp5-skeleton
|
||||
display: inline-block
|
||||
width: 100px
|
||||
height: 14px
|
||||
|
||||
> .timescale
|
||||
z-index: 1
|
||||
top: $header-height
|
||||
right: 0
|
||||
bottom: 0
|
||||
left: 0
|
||||
overflow: hidden
|
||||
user-select: none
|
||||
pointer-events: none
|
||||
margin-top: 0px // for scroll
|
||||
|
||||
.timeline
|
||||
position: absolute
|
||||
top: -2px
|
||||
right: 0
|
||||
left: calc($timescale-width)
|
||||
height: 2px
|
||||
opacity: 0.5
|
||||
pointer-events: all
|
||||
box-shadow: 0 0 4px colors.$gray3
|
||||
background: colors.$gray4
|
||||
|
||||
&.today
|
||||
background: colors.$orange5
|
||||
|
||||
&,
|
||||
> .clock
|
||||
transition: all 0.4s ease 4s
|
||||
|
||||
&:hover,
|
||||
&.show,
|
||||
&:hover > .clock,
|
||||
&.show > .clock
|
||||
opacity: 1
|
||||
transition: opacity 0.1s linear 0s
|
||||
|
||||
> .clock
|
||||
position: absolute
|
||||
top: -8px
|
||||
left: 0
|
||||
padding: 0 8px
|
||||
line-height: 18px
|
||||
font-size: 13px
|
||||
font-weight: 500
|
||||
background: inherit
|
||||
opacity: 0
|
||||
pointer-events: none
|
||||
color: colors.$black
|
||||
|
||||
.timescale-item
|
||||
height: $block-height
|
||||
border-bottom: 1px dashed rgba($timetable-border-color, 0.5)
|
||||
|
||||
> div
|
||||
width: $timescale-width
|
||||
height: calc(100% + 1px)
|
||||
padding-top: 6px
|
||||
writing-mode: vertical-rl
|
||||
text-orientation: sideways
|
||||
font-size: 13px
|
||||
font-weight: 400
|
||||
letter-spacing: 0.1em
|
||||
line-height: $timescale-width
|
||||
pointer-events: all
|
||||
border-bottom: 1px solid colors.$gray3
|
||||
color: colors.$light-gray5
|
||||
background: colors.$black
|
||||
|
||||
&:first-child > div
|
||||
border-top: 1px solid colors.$gray3
|
||||
|
||||
// todo: to variables
|
||||
&.hour-0 > div,
|
||||
&.hour-1 > div,
|
||||
&.hour-2 > div
|
||||
background: rgb(0,51,127)
|
||||
&.hour-3 > div,
|
||||
&.hour-4 > div,
|
||||
&.hour-5 > div
|
||||
background: rgb(0,102,127)
|
||||
&.hour-6 > div,
|
||||
&.hour-7 > div,
|
||||
&.hour-8 > div
|
||||
background: rgb(0,127,102)
|
||||
&.hour-9 > div,
|
||||
&.hour-10 > div,
|
||||
&.hour-11 > div
|
||||
background: rgb(102,127,0)
|
||||
&.hour-12 > div,
|
||||
&.hour-13 > div,
|
||||
&.hour-14 > div
|
||||
background: rgb(127,102,0)
|
||||
&.hour-15 > div,
|
||||
&.hour-16 > div,
|
||||
&.hour-17 > div
|
||||
background: rgb(127,51,0)
|
||||
&.hour-18 > div,
|
||||
&.hour-19 > div,
|
||||
&.hour-20 > div
|
||||
background: rgb(127,0,102)
|
||||
&.hour-21 > div,
|
||||
&.hour-22 > div,
|
||||
&.hour-23 > div
|
||||
background: rgb(102,0,127)
|
||||
|
||||
> .timetable
|
||||
display: flex
|
||||
position: absolute
|
||||
top: $header-height
|
||||
left: $timescale-width
|
||||
right: 0
|
||||
bottom: 0
|
||||
overflow: auto
|
||||
|
||||
> .bp5-spinner
|
||||
position: absolute
|
||||
top: calc(50% - 20px)
|
||||
left: calc(50% - 20px)
|
||||
opacity: 0
|
||||
|
||||
.bp5-spinner-track
|
||||
stroke: rgba(95, 107, 124, 0.2)
|
||||
|
||||
.bp5-spinner-head
|
||||
stroke: rgba(95, 107, 124, 0.8)
|
||||
|
||||
.timetable-col
|
||||
position: relative
|
||||
width: $block-width // for skeleton
|
||||
flex-shrink: 0
|
||||
overflow: hidden
|
||||
border-right: 1px solid $timetable-border-color
|
||||
background: colors.$light-gray3
|
||||
opacity: 0
|
||||
animation: 0.2s ease 0.1s 1 normal forwards running fade-in
|
||||
|
||||
button.timetable-cell
|
||||
position: absolute
|
||||
width: 100%
|
||||
border: 0 transparent
|
||||
text-align: left
|
||||
padding: 0
|
||||
overflow: hidden
|
||||
border-bottom: 1px solid $timetable-border-color
|
||||
background: #fff
|
||||
color: colors.$dark-gray2
|
||||
|
||||
&.no-data
|
||||
cursor: default
|
||||
|
||||
&:last-child
|
||||
height: auto !important
|
||||
bottom: 0
|
||||
|
||||
&:not(.no-data):hover
|
||||
filter: brightness(0.97)
|
||||
|
||||
&.bp5-active
|
||||
z-index: 2
|
||||
box-shadow: inset 0 0 0 4px rgba(colors.$black, 0.15)
|
||||
|
||||
&.event-group-shared
|
||||
color: colors.$blue2
|
||||
|
||||
&.event-group-shared,
|
||||
&.no-data
|
||||
opacity: 0.45
|
||||
|
||||
> div
|
||||
position: absolute
|
||||
top: 8px
|
||||
right: 8px
|
||||
bottom: 8px
|
||||
left: 8px
|
||||
line-height: 16px
|
||||
font-size: 13px
|
||||
word-break: break-all
|
||||
overflow: hidden
|
||||
|
||||
time
|
||||
margin-right: 4px
|
||||
font-size: 10px
|
||||
font-weight: 700
|
||||
vertical-align: top
|
||||
color: colors.$gray2
|
||||
|
||||
.description
|
||||
margin-top: 4px
|
||||
font-size: 12px
|
||||
font-weight: 400
|
||||
font-feature-settings: "palt" 1, "pwid" 1
|
||||
line-height: 1.5
|
||||
color: colors.$dark-gray5
|
||||
|
||||
.component-program-genres
|
||||
margin-top: 4px
|
||||
|
||||
.caution
|
||||
padding: 0
|
||||
background: none
|
||||
color: colors.$orange4
|
||||
|
||||
&.short > div
|
||||
position: relative
|
||||
top: auto
|
||||
right: auto
|
||||
bottom: auto
|
||||
left: auto
|
||||
margin: 0 8px
|
||||
white-space: nowrap
|
||||
font-size: 11px
|
||||
|
||||
&.x-short > div
|
||||
top: 0
|
||||
font-size: 10px
|
||||
line-height: 10px
|
||||
|
||||
time
|
||||
vertical-align: inherit
|
||||
|
||||
&.xx-short > div
|
||||
> *
|
||||
display: none
|
||||
|
||||
&.long > div
|
||||
word-break: normal
|
||||
654
web/src/components/EPGTable.tsx
Normal file
654
web/src/components/EPGTable.tsx
Normal file
@@ -0,0 +1,654 @@
|
||||
/*
|
||||
Copyright 2026 kanreisa
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import ScrollContainer from "react-indiana-drag-scroll";
|
||||
import { Button, Spinner, NonIdealState } from "@blueprintjs/core";
|
||||
import { DateTime } from "luxon";
|
||||
import sift, { Query } from "sift";
|
||||
import { LazyCaller } from "../modules/common";
|
||||
import { state } from "../modules/state";
|
||||
import * as ui from "../modules/ui";
|
||||
import * as regexp from "../modules/regexp";
|
||||
import { GenreUN2Map } from "../modules/constants";
|
||||
import { Error, ChannelType, Service, Program, ProgramGenre } from "../../../api.d";
|
||||
|
||||
import { ProgramTitle } from "./ProgramTitle";
|
||||
import { ProgramPopover } from "./ProgramPopover";
|
||||
import { ProgramGenres } from "./ProgramGenres";
|
||||
|
||||
import "./EPGTable.sass";
|
||||
|
||||
const scrollState = {
|
||||
left: {
|
||||
GR: -1,
|
||||
BS: -1,
|
||||
CS: -1,
|
||||
SKY: -1,
|
||||
},
|
||||
top: -1,
|
||||
};
|
||||
|
||||
interface Dimensions {
|
||||
// headerHeight: number;
|
||||
// timescaleWidth: number;
|
||||
timescaleHeight: number;
|
||||
blockWidth: number;
|
||||
// blockHeight: number;
|
||||
scaleFactor: number;
|
||||
}
|
||||
|
||||
type EPGTableProps = {
|
||||
date: DateTime;
|
||||
channelType?: ChannelType;
|
||||
globalServiceId?: number;
|
||||
defaultProgramId?: number;
|
||||
defaultTime?: number;
|
||||
};
|
||||
export const EPGTable: React.FC<EPGTableProps> = ({ date, channelType, globalServiceId, defaultProgramId, defaultTime }) => {
|
||||
console.debug("components", "EPGTable");
|
||||
|
||||
const startTime = date.toMillis();
|
||||
|
||||
const headerRef = useRef<HTMLDivElement>();
|
||||
const timescaleRef = useRef<HTMLDivElement>();
|
||||
const timelineRef = useRef<HTMLDivElement>();
|
||||
const clockRef = useRef<HTMLDivElement>();
|
||||
const timetableRef = useRef<HTMLDivElement>();
|
||||
const headerItemRef = useRef<HTMLDivElement>();
|
||||
const timescaleItemRef = useRef<HTMLDivElement>();
|
||||
const jumpToTimelineRef = useRef<HTMLButtonElement>();
|
||||
|
||||
const [reload, setReload] = useState(0); // リロード用
|
||||
const [dimensions, setDimensions] = useState<Dimensions>(null);
|
||||
const [error, setError] = useState<Error>(null);
|
||||
|
||||
// null = loading, [] = empty
|
||||
const [programId, setProgramId] = useState<number>(defaultProgramId || null);
|
||||
const [time, setTime] = useState<number>(defaultTime || null);
|
||||
const [services, setServices] = useState<Service[]>(null);
|
||||
const [serviceItems, setServiceItems] = useState<JSX.Element[]>(null);
|
||||
const [timetableCols, setTimetableCols] = useState<JSX.Element[]>(null);
|
||||
|
||||
if (globalServiceId) {
|
||||
// 週間番組表
|
||||
if (services) {
|
||||
ui.setTitle(services[0]?.name);
|
||||
} else if (error) {
|
||||
ui.setTitle("エラー");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const onUpdated = () => {
|
||||
setReload(Date.now());
|
||||
}
|
||||
const onUpdatedLazy = new LazyCaller(0, 1000, onUpdated);
|
||||
|
||||
state.on("services", onUpdatedLazy.caller);
|
||||
state.on("programs", onUpdatedLazy.caller);
|
||||
|
||||
state.subscribePrograms(true);
|
||||
|
||||
return () => {
|
||||
state.off("services", onUpdatedLazy.caller);
|
||||
state.off("programs", onUpdatedLazy.caller);
|
||||
onUpdatedLazy.destroy();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setError(null);
|
||||
};
|
||||
}, [state.location, reload]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setServices(null);
|
||||
setServiceItems(null);
|
||||
};
|
||||
}, [channelType]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setTimetableCols(null);
|
||||
};
|
||||
}, [channelType]);
|
||||
|
||||
// 採寸
|
||||
useEffect(() => {
|
||||
const timescale = timescaleRef.current;
|
||||
const headerItem = headerItemRef.current;
|
||||
const timescaleItem = timescaleItemRef.current;
|
||||
|
||||
// 採寸
|
||||
setDimensions({
|
||||
// headerHeight: headerItem.offsetHeight,
|
||||
// timescaleWidth: timescaleItem.offsetWidth,
|
||||
timescaleHeight: timescale.scrollHeight,
|
||||
blockWidth: headerItem.offsetWidth,
|
||||
// blockHeight: timescaleItem.offsetHeight,
|
||||
scaleFactor: timescaleItem.offsetHeight / 60,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// スクロール連動・現在時刻・現在線の描画
|
||||
useEffect(() => {
|
||||
if (!dimensions) {
|
||||
return;
|
||||
}
|
||||
|
||||
const header = headerRef.current;
|
||||
const timescale = timescaleRef.current;
|
||||
const timeline = timelineRef.current;
|
||||
const clock = clockRef.current;
|
||||
const timetable = timetableRef.current;
|
||||
const jumpToTimeline = jumpToTimelineRef.current;
|
||||
|
||||
if (!timetableCols) {
|
||||
return;
|
||||
}
|
||||
|
||||
const getPosition = () => Math.floor((Date.now() - state.todayTime) / 1000 / 60 * dimensions.scaleFactor);
|
||||
|
||||
// スクロール連動
|
||||
const onScroll = () => {
|
||||
scrollState.left[channelType] = header.scrollLeft = timetable.scrollLeft;
|
||||
scrollState.top = timescale.scrollTop = timetable.scrollTop;
|
||||
};
|
||||
timetable.addEventListener("scroll", onScroll);
|
||||
|
||||
// 現在時刻を表示する
|
||||
timeline.style.opacity = "";
|
||||
let showClockTimeout: ReturnType<typeof setTimeout>;
|
||||
const showClock = () => {
|
||||
showClockTimeout = setTimeout(() => timeline.classList.remove("show"), 1000);
|
||||
timeline.classList.add("show");
|
||||
};
|
||||
showClock();
|
||||
|
||||
// スクロール初期位置セット
|
||||
timetable.scrollLeft = Math.round(
|
||||
scrollState.left[channelType] > -1
|
||||
? scrollState.left[channelType]
|
||||
: 0
|
||||
);
|
||||
if (time) {
|
||||
// スクロール時間指定
|
||||
const position = Math.floor(time / 1000 / 60 * dimensions.scaleFactor);
|
||||
timetable.scrollTop = Math.round(position - timetable.clientHeight / 4);
|
||||
setTime(null);
|
||||
} else {
|
||||
// 現在時刻
|
||||
timetable.scrollTop = Math.round(
|
||||
scrollState.top > -1
|
||||
? scrollState.top
|
||||
: (getPosition() - timetable.clientHeight / 4)
|
||||
);
|
||||
}
|
||||
|
||||
// 現在線の描画
|
||||
const updateTimeline = () => {
|
||||
const position = getPosition();
|
||||
const { scrollTop, clientHeight } = timetable;
|
||||
|
||||
// 色切り替え
|
||||
if (state.todayTime === startTime) {
|
||||
// 今日
|
||||
timeline.classList.add("today");
|
||||
} else {
|
||||
// 今日じゃない
|
||||
timeline.classList.remove("today");
|
||||
}
|
||||
|
||||
// 現在線位置
|
||||
timeline.style.top = `${position}px`;
|
||||
|
||||
// 時刻表示
|
||||
const clockText = DateTime.now().toFormat("HH:mm");
|
||||
if (clock.innerText !== clockText) {
|
||||
clock.innerText = clockText;
|
||||
showClock();
|
||||
}
|
||||
|
||||
// ボタン表示
|
||||
if (position > scrollTop && position < scrollTop + clientHeight) {
|
||||
jumpToTimeline.classList.add("hide");
|
||||
} else {
|
||||
jumpToTimeline.classList.remove("hide");
|
||||
}
|
||||
};
|
||||
const updateTimelineInterval = setInterval(updateTimeline, 1500);
|
||||
updateTimeline();
|
||||
|
||||
return () => {
|
||||
timetable.removeEventListener("scroll", onScroll);
|
||||
clearInterval(updateTimelineInterval);
|
||||
clearTimeout(showClockTimeout);
|
||||
};
|
||||
}, [startTime, timetableCols]);
|
||||
|
||||
// サービス一覧の取得
|
||||
useEffect(() => {
|
||||
if (!dimensions) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (globalServiceId) {
|
||||
// 週間番組表
|
||||
const _service = state.services.find(s => s.id === globalServiceId);
|
||||
if (!_service) {
|
||||
setError({ code: 404, reason: "サービスが見つかりません" });
|
||||
return;
|
||||
}
|
||||
setServices([_service]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 全体番組表
|
||||
const _services = state.services
|
||||
.filter(s => s.type === 1)
|
||||
.filter(s => channelType ? s.channel.type === channelType : true);
|
||||
|
||||
// ソート
|
||||
_services.sort((a, b) => {
|
||||
if (a.remoteControlKeyId && b.remoteControlKeyId) {
|
||||
return a.remoteControlKeyId - b.remoteControlKeyId;
|
||||
}
|
||||
if (a.remoteControlKeyId && !b.remoteControlKeyId) {
|
||||
return -1;
|
||||
}
|
||||
if (!a.remoteControlKeyId && b.remoteControlKeyId) {
|
||||
return 1;
|
||||
}
|
||||
return a.id - b.id;
|
||||
});
|
||||
|
||||
setServices(_services);
|
||||
}, [channelType, dimensions, reload]);
|
||||
|
||||
// 番組一覧
|
||||
useEffect(() => {
|
||||
if (!services || services.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug("EPGTable", "services", services);
|
||||
|
||||
const query: Query<Program> = {
|
||||
startAt: {
|
||||
$gte: startTime - 60 * 60 * 2 * 1000,
|
||||
$lt: startTime + 60 * 60 * 28 * 1000
|
||||
}
|
||||
};
|
||||
if (channelType) {
|
||||
query.serviceId = { $in: services.map(s => s.serviceId) };
|
||||
} else if (globalServiceId) {
|
||||
query.serviceId = services[0].serviceId;
|
||||
query.startAt["$lt"] = startTime + 60 * 60 * 24 * 8 * 1000;
|
||||
}
|
||||
const filteredPrograms = state.programs.filter(sift(query));
|
||||
|
||||
console.debug("EPGTable", "filteredPrograms", filteredPrograms);
|
||||
|
||||
const programMap = new Map<string, Program>(); // イベントグループ検索用
|
||||
const _serviceItems: JSX.Element[] = [];
|
||||
const cols: JSX.Element[] = [];
|
||||
|
||||
// サービスごとにループ
|
||||
for (let i = 0; i < services.length; i++) {
|
||||
const service = services[i];
|
||||
const servicePrograms: Program[] = [];
|
||||
let count = 0;
|
||||
|
||||
// サービス絞り込み
|
||||
for (const program of filteredPrograms) {
|
||||
if (program.serviceId !== service.serviceId || program.networkId !== service.networkId) {
|
||||
continue;
|
||||
}
|
||||
if (program.relatedItems?.filter(item => item.type === "shared").length !== 1) {
|
||||
// オリジナルイベントのみをカウント
|
||||
count++;
|
||||
// イベントグループ被参照対象
|
||||
programMap.set(`${program.serviceId}.${program.eventId}`, program);
|
||||
}
|
||||
servicePrograms.push(program);
|
||||
}
|
||||
|
||||
if (count === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// ソート
|
||||
servicePrograms.sort((a, b) => {
|
||||
return a.startAt - b.startAt;
|
||||
});
|
||||
|
||||
if (!globalServiceId) {
|
||||
// 全体番組表
|
||||
let className = "epg-table-header-item";
|
||||
|
||||
_serviceItems.push(
|
||||
<button className={className} key={service.id}
|
||||
onClick={() => {
|
||||
state.navigate(`/epg/services/${service.id}?date=${date.toISODate()}`);
|
||||
}}
|
||||
>
|
||||
{service.hasLogoData && <img src={`/api/services/${service.id}/logo`} />}
|
||||
<span>{service.name}</span>
|
||||
</button>
|
||||
);
|
||||
} else {
|
||||
// 週間番組表
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const cur = date.plus({ days: i });
|
||||
|
||||
_serviceItems.push(
|
||||
<button className="epg-table-header-item date" key={`${service.id}-${i}`}
|
||||
onClick={() => {
|
||||
state.navigate(`/epg?type=${service.channel.type}&date=${cur.toISODate()}`);
|
||||
}}
|
||||
>
|
||||
<span>{cur.toFormat("M月d日(ccc)")}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 放送終了ダミーデータ挿入
|
||||
const last = servicePrograms[servicePrograms.length - 1];
|
||||
servicePrograms.push({
|
||||
id: last.id + 0.1,
|
||||
eventId: last.eventId + 0.1,
|
||||
serviceId: last.serviceId,
|
||||
networkId: last.networkId,
|
||||
startAt: last.startAt + last.duration,
|
||||
duration: 60 * 15 * 1000,
|
||||
isFree: false,
|
||||
name: service.epgReady ? "(放送休止・未定)" : "(未受信)",
|
||||
description: "no-data",
|
||||
genres: [],
|
||||
});
|
||||
|
||||
const cells: JSX.Element[] = [];
|
||||
// 週間番組表用
|
||||
const splitIndexes: number[] = [];
|
||||
const maxHeight = 60 * 24 * dimensions.scaleFactor;
|
||||
let topOffset = 0;
|
||||
|
||||
for (let i = 0; i < servicePrograms.length; i++) {
|
||||
let program = { ...servicePrograms[i] };
|
||||
let className = "timetable-cell";
|
||||
|
||||
const prev = servicePrograms[i - 1];
|
||||
if (prev && (prev.startAt + prev.duration) !== program.startAt) {
|
||||
// 放送未定ダミーデータ挿入
|
||||
program = {
|
||||
id: prev.id + 0.1,
|
||||
eventId: prev.eventId + 0.1,
|
||||
serviceId: prev.serviceId,
|
||||
networkId: prev.networkId,
|
||||
startAt: prev.startAt + prev.duration,
|
||||
duration: program.startAt - (prev.startAt + prev.duration),
|
||||
isFree: false,
|
||||
name: service.epgReady ? "(放送休止・未定)" : "(未受信)",
|
||||
description: "no-data",
|
||||
genres: [],
|
||||
};
|
||||
|
||||
servicePrograms.splice(i, 0, program);
|
||||
}
|
||||
|
||||
if (program.description === "no-data") {
|
||||
className += " no-data";
|
||||
program.description = undefined;
|
||||
}
|
||||
|
||||
const programStartTime = program.startAt;
|
||||
const programStartDate = new Date(program.startAt);
|
||||
|
||||
let top = Math.floor((programStartTime - startTime) / 1000 / 60 * dimensions.scaleFactor) + topOffset;
|
||||
let height = Math.floor(program.duration / 1000 / 60 * dimensions.scaleFactor);
|
||||
if (globalServiceId) {
|
||||
// 週間番組表用
|
||||
if (top + height >= maxHeight) {
|
||||
// 日付跨ぎ
|
||||
splitIndexes.push(i + 1);
|
||||
topOffset -= maxHeight;
|
||||
|
||||
// 日付の最後の番組を24時の位置に合わせる
|
||||
height -= top + height - maxHeight;
|
||||
|
||||
// 分割
|
||||
servicePrograms.splice(i, 0, {
|
||||
...program,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (top < 0) {
|
||||
// 日付の最初の番組を0時の位置に合わせる
|
||||
height += top;
|
||||
top = 0;
|
||||
}
|
||||
|
||||
const isShort = height <= 40;
|
||||
if (isShort) {
|
||||
className += " short";
|
||||
|
||||
if (height <= 16) {
|
||||
className += " x-short";
|
||||
}
|
||||
if (height <= 10) {
|
||||
className += " xx-short";
|
||||
}
|
||||
} else {
|
||||
if (height >= 240) {
|
||||
className += " long";
|
||||
}
|
||||
}
|
||||
|
||||
if (program.relatedItems && program.relatedItems.filter(item => item.type === "shared").length === 1) {
|
||||
className += " event-group-shared";
|
||||
|
||||
const ref = programMap.get(`${program.relatedItems[0].serviceId}.${program.relatedItems[0].eventId}`)
|
||||
if (ref) {
|
||||
program.name = program.name || ref.name;
|
||||
program.genres = program.genres || ref.genres;
|
||||
program.description = program.description || "(イベント共有)";
|
||||
}
|
||||
}
|
||||
|
||||
const cautions: ProgramGenre[] = [];
|
||||
|
||||
if (program.genres && program.genres[0]) {
|
||||
className += ` bg-genre-lv1-${program.genres[0].lv1}`;
|
||||
|
||||
for (const genre of program.genres) {
|
||||
const un2Text = GenreUN2Map[(genre.lv1 * 0x1000) + (genre.lv2 * 0x100) + (genre.un1 * 0x10) + genre.un2];
|
||||
if (un2Text) {
|
||||
cautions.push(genre);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const defaultIsOpen = programId === program.id;
|
||||
if (defaultIsOpen) {
|
||||
setProgramId(null);
|
||||
}
|
||||
|
||||
cells.push(
|
||||
<ProgramPopover
|
||||
key={`event-${program.eventId}-${program.startAt}`}
|
||||
className={className}
|
||||
program={program}
|
||||
defaultIsOpen={defaultIsOpen}
|
||||
renderTarget={({ isOpen, ...props }) => (
|
||||
<button style={{ top, height }} {...props}>
|
||||
<div>
|
||||
<time dateTime={programStartDate.toISOString()}>{programStartDate.getMinutes()}</time>
|
||||
<ProgramTitle program={program} />
|
||||
{!isShort && program.description && (
|
||||
<div className="description">{program.description.replace(regexp.enclosedAttributeUnicode, "")}</div>
|
||||
)}
|
||||
{!isShort && cautions.length > 0 && <ProgramGenres genres={cautions} />}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!globalServiceId) {
|
||||
// 全体番組表
|
||||
cols.push(
|
||||
<div key={service.id}
|
||||
className="timetable-col"
|
||||
style={{ width: dimensions.blockWidth, height: dimensions.timescaleHeight }}
|
||||
>
|
||||
{cells}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
// 週間番組表
|
||||
for (let i = 0; i < splitIndexes.length; i++) {
|
||||
cols.push(
|
||||
<div key={`${service.id}-${i}`}
|
||||
className="timetable-col"
|
||||
style={{ width: dimensions.blockWidth, height: maxHeight }}
|
||||
>
|
||||
{cells.slice(splitIndexes[i - 1] || 0, splitIndexes[i] || cells.length)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setServiceItems(_serviceItems);
|
||||
setTimetableCols(cols);
|
||||
}, [startTime, services]);
|
||||
|
||||
const timescaleDateShort = date.toFormat("M/d(ccc)");
|
||||
const timescaleDateExtended = date.plus({ days: 1 }).toFormat("M/d(ccc)");
|
||||
|
||||
return (
|
||||
<div className="component-epg-table">
|
||||
<div className="header" ref={headerRef}>
|
||||
{!serviceItems && !error && <>
|
||||
<div className="epg-table-header-item loading" ref={headerItemRef}><div className="bp5-skeleton img" /><span className="bp5-skeleton" /></div>
|
||||
<div className="epg-table-header-item loading"><div className="bp5-skeleton img" /><span className="bp5-skeleton" /></div>
|
||||
<div className="epg-table-header-item loading"><div className="bp5-skeleton img" /><span className="bp5-skeleton" /></div>
|
||||
<div className="epg-table-header-item loading"><div className="bp5-skeleton img" /><span className="bp5-skeleton" /></div>
|
||||
<div className="epg-table-header-item loading"><div className="bp5-skeleton img" /><span className="bp5-skeleton" /></div>
|
||||
<div className="epg-table-header-item loading"><div className="bp5-skeleton img" /><span className="bp5-skeleton" /></div>
|
||||
<div className="epg-table-header-item loading"><div className="bp5-skeleton img" /><span className="bp5-skeleton" /></div>
|
||||
<div className="epg-table-header-item loading"><div className="bp5-skeleton img" /><span className="bp5-skeleton" /></div>
|
||||
<div className="epg-table-header-item loading"><div className="bp5-skeleton img" /><span className="bp5-skeleton" /></div>
|
||||
<div className="epg-table-header-item loading"><div className="bp5-skeleton img" /><span className="bp5-skeleton" /></div>
|
||||
<div className="epg-table-header-item loading"><div className="bp5-skeleton img" /><span className="bp5-skeleton" /></div>
|
||||
<div className="epg-table-header-item loading"><div className="bp5-skeleton img" /><span className="bp5-skeleton" /></div>
|
||||
</>}
|
||||
{serviceItems}
|
||||
</div>
|
||||
|
||||
<div className="timescale" ref={timescaleRef}>
|
||||
<div className="timeline" ref={timelineRef}>
|
||||
<div className="clock" ref={clockRef}>00:00</div>
|
||||
</div>
|
||||
<div className="timescale-item hour-0" ref={timescaleItemRef}><div>{timescaleDateShort} 0時</div></div>
|
||||
<div className="timescale-item hour-1"><div>1</div></div>
|
||||
<div className="timescale-item hour-2"><div>2</div></div>
|
||||
<div className="timescale-item hour-3"><div>{timescaleDateShort} 3時</div></div>
|
||||
<div className="timescale-item hour-4"><div>4</div></div>
|
||||
<div className="timescale-item hour-5"><div>5</div></div>
|
||||
<div className="timescale-item hour-6"><div>{timescaleDateShort} 6時</div></div>
|
||||
<div className="timescale-item hour-7"><div>7</div></div>
|
||||
<div className="timescale-item hour-8"><div>8</div></div>
|
||||
<div className="timescale-item hour-9"><div>{timescaleDateShort} 9時</div></div>
|
||||
<div className="timescale-item hour-10"><div>10</div></div>
|
||||
<div className="timescale-item hour-11"><div>11</div></div>
|
||||
<div className="timescale-item hour-12"><div>{timescaleDateShort} 12時</div></div>
|
||||
<div className="timescale-item hour-13"><div>13</div></div>
|
||||
<div className="timescale-item hour-14"><div>14</div></div>
|
||||
<div className="timescale-item hour-15"><div>{timescaleDateShort} 15時</div></div>
|
||||
<div className="timescale-item hour-16"><div>16</div></div>
|
||||
<div className="timescale-item hour-17"><div>17</div></div>
|
||||
<div className="timescale-item hour-18"><div>{timescaleDateShort} 18時</div></div>
|
||||
<div className="timescale-item hour-19"><div>19</div></div>
|
||||
<div className="timescale-item hour-20"><div>20</div></div>
|
||||
<div className="timescale-item hour-21"><div>{timescaleDateShort} 21時</div></div>
|
||||
<div className="timescale-item hour-22"><div>22</div></div>
|
||||
<div className="timescale-item hour-23"><div>23</div></div>
|
||||
<div className="timescale-item hour-0"><div>{timescaleDateExtended} 0時 (24)</div></div>
|
||||
<div className="timescale-item hour-1"><div>1 (25)</div></div>
|
||||
<div className="timescale-item hour-2"><div>2 (26)</div></div>
|
||||
<div className="timescale-item hour-3"><div>{timescaleDateExtended} 3時 (27)</div></div>
|
||||
{timetableCols && <div className="timescale-item reserve"><div></div></div>}
|
||||
</div>
|
||||
|
||||
<Button variant="outlined" className="jump-to-timeline hide" ref={jumpToTimelineRef} icon="selection"
|
||||
text="現在時刻へ"
|
||||
onClick={() => {
|
||||
ui.blur();
|
||||
jumpToTimelineRef.current.classList.add("hide");
|
||||
|
||||
const { clientHeight } = timetableRef.current;
|
||||
const { offsetTop } = timelineRef.current;
|
||||
timetableRef.current.scrollTop = offsetTop - clientHeight / 4;
|
||||
}}
|
||||
/>
|
||||
|
||||
<ScrollContainer className="timetable" innerRef={timetableRef} hideScrollbars={false}
|
||||
onClick={(a) => {
|
||||
ui.blur();
|
||||
}}
|
||||
>
|
||||
{!error && timetableCols === null
|
||||
? <Spinner intent="none" size={40} />
|
||||
: timetableCols
|
||||
}
|
||||
</ScrollContainer>
|
||||
|
||||
{(state.programs.length === 0 || state.services.length === 0) && !error && <>
|
||||
<NonIdealState
|
||||
icon={<Spinner />}
|
||||
title="ロード中"
|
||||
description="データを待機しています..."
|
||||
/>
|
||||
</> || services?.length === 0 && !error && <>
|
||||
<NonIdealState
|
||||
icon="satellite"
|
||||
title="放送サービスなし"
|
||||
description="指定された放送波のチャンネルが見つかりません"
|
||||
/>
|
||||
</> || timetableCols?.length === 0 && !error && <>
|
||||
<NonIdealState
|
||||
icon="satellite"
|
||||
title="放送イベントなし"
|
||||
description="指定された日付と放送波の番組情報が見つかりません"
|
||||
/>
|
||||
</>}
|
||||
|
||||
{error && <>
|
||||
<NonIdealState
|
||||
icon="warning-sign"
|
||||
title={`${error.code} Error`}
|
||||
description={error.reason || "エラーが発生しました"}
|
||||
/>
|
||||
</>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
82
web/src/components/Nav.sass
Normal file
82
web/src/components/Nav.sass
Normal file
@@ -0,0 +1,82 @@
|
||||
@use "~@blueprintjs/colors/lib/scss/colors"
|
||||
@use "../vars"
|
||||
|
||||
.component-nav.bp5-navbar
|
||||
> .bp5-navbar-group
|
||||
> img.product-icon
|
||||
width: 28px
|
||||
height: 28px
|
||||
margin-right: 10px
|
||||
|
||||
> .bp5-navbar-heading.product-name
|
||||
font-size: 18px
|
||||
font-weight: 300
|
||||
|
||||
> .version
|
||||
font-size: 10px
|
||||
font-weight: 400
|
||||
top: -1em
|
||||
color: vars.$theme-light-primary
|
||||
|
||||
.bp5-dark &
|
||||
color: vars.$theme-dark-primary
|
||||
|
||||
> .bp5-input-group
|
||||
.bp5-input
|
||||
&:not(:hover,:focus)
|
||||
box-shadow: none
|
||||
|
||||
> .bp5-button
|
||||
span.badge
|
||||
margin-left: 5px
|
||||
font-size: 11px
|
||||
color: vars.$theme-light-primary
|
||||
|
||||
.bp5-dark &
|
||||
color: vars.$theme-dark-primary
|
||||
|
||||
> .bp5-button.active
|
||||
border-bottom-right-radius: 0
|
||||
border-bottom-left-radius: 0
|
||||
box-shadow: 0 2px vars.$theme-light-primary
|
||||
|
||||
.bp5-dark &
|
||||
box-shadow: 0 2px vars.$theme-dark-primary
|
||||
|
||||
//&:hover:not(.bp5-popover-target)
|
||||
// background: none
|
||||
// cursor: default
|
||||
|
||||
// responsive
|
||||
|
||||
.component-nav.bp5-navbar
|
||||
> .bp5-navbar-group.bp5-align-left
|
||||
.bp5-navbar-heading
|
||||
@media (max-width: 800px)
|
||||
display: none
|
||||
|
||||
.bp5-input-group
|
||||
width: 200px
|
||||
|
||||
@media (max-width: 1000px)
|
||||
width: 180px
|
||||
|
||||
@media (max-width: 450px)
|
||||
width: 130px
|
||||
|
||||
> .bp5-navbar-group.bp5-align-right
|
||||
@media (max-width: 950px)
|
||||
button
|
||||
.bp5-icon:not(:last-child)
|
||||
margin: 0 -7px
|
||||
|
||||
.bp5-button-text
|
||||
display: none
|
||||
|
||||
@media (max-width: 950px) and (min-width: 600px)
|
||||
button:hover
|
||||
.bp5-icon:not(:last-child)
|
||||
margin: 0 7px 0 0
|
||||
|
||||
.bp5-button-text
|
||||
display: block
|
||||
169
web/src/components/Nav.tsx
Normal file
169
web/src/components/Nav.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
Copyright 2026 kanreisa
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Alignment, Button, ButtonProps, Navbar, Menu, MenuItem, MenuDivider, Popover, PopoverTargetProps } from "@blueprintjs/core";
|
||||
import { state } from "../modules/state";
|
||||
import { useLocalStorageState } from "../hooks/useWebStorageState";
|
||||
import { VersionStatus } from "./VersionStatus";
|
||||
import { Restart } from "./Restart";
|
||||
|
||||
import "./Nav.sass";
|
||||
|
||||
type NavProps = {
|
||||
pathLv1: string;
|
||||
};
|
||||
export const Nav: React.FC<NavProps> = ({ pathLv1 }) => {
|
||||
console.debug("components", "Nav", pathLv1);
|
||||
|
||||
const { navigate, searchParams } = state;
|
||||
const query = searchParams.get("q") || null;
|
||||
|
||||
const [icon, setIcon] = useState<string>(state.statusIconSrc);
|
||||
useEffect(() => {
|
||||
const onStatusIconKey = () => {
|
||||
console.log("Nav", "onStatusIconKey", state.statusIconKey, state.statusIconSrc);
|
||||
setIcon(state.statusIconSrc);
|
||||
};
|
||||
state.on("statusIconKey", onStatusIconKey);
|
||||
return () => {
|
||||
state.off("statusIconKey", onStatusIconKey);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [version, setVersion] = useState<string>(state.version);
|
||||
useEffect(() => {
|
||||
const onVersion = () => {
|
||||
console.log("Nav", "onVersion", state.version);
|
||||
setVersion(state.version);
|
||||
};
|
||||
state.on("version", onVersion);
|
||||
return () => {
|
||||
state.off("version", onVersion);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [dark, setDark] = useLocalStorageState<boolean>("dark", true);
|
||||
useEffect(() => {
|
||||
document.body.classList.toggle("bp5-dark", dark);
|
||||
}, [dark]);
|
||||
|
||||
const getNavbarButtonProps = useCallback((name: string, className = "") => {
|
||||
const props: ButtonProps = {
|
||||
onClick: () => {
|
||||
state.navigate("/" + name);
|
||||
}
|
||||
};
|
||||
|
||||
if (name === pathLv1) {
|
||||
props.className = `${className} active`.trim();
|
||||
}
|
||||
|
||||
return props;
|
||||
}, [pathLv1]);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState<string>(query || "");
|
||||
const executeSearch = useCallback(() => {
|
||||
state.navigate(`/epg/search?q=${encodeURIComponent(searchQuery.trim())}`);
|
||||
}, [searchQuery]);
|
||||
|
||||
const [runningJobs, setRunningJobs] = useState<number>(state.jobs.filter((job) => job.status === "running").length);
|
||||
|
||||
const [restartDialogOpen, setRestartDialogOpen] = useState<boolean>(false);
|
||||
useEffect(() => {
|
||||
const onJobs = () => {
|
||||
setRunningJobs(state.jobs.filter((job) => job.status === "running").length);
|
||||
};
|
||||
state.on("jobs", onJobs);
|
||||
return () => {
|
||||
state.off("jobs", onJobs);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Navbar className="component-nav">
|
||||
<Navbar.Group align={Alignment.START}>
|
||||
<img className="product-icon" src={icon} alt={state.statusName} />
|
||||
<Navbar.Heading className="product-name">
|
||||
Mirakurun
|
||||
<sup className="version">{version}</sup>
|
||||
</Navbar.Heading>
|
||||
<div className="bp5-input-group">
|
||||
<span className="bp5-icon bp5-icon-search"></span>
|
||||
<input
|
||||
type="text"
|
||||
className="bp5-input"
|
||||
placeholder="番組検索..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
executeSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="minimal"
|
||||
className="bp5-intent-primary"
|
||||
icon="arrow-right"
|
||||
title="検索"
|
||||
onClick={executeSearch}
|
||||
/>
|
||||
</div>
|
||||
</Navbar.Group>
|
||||
<Navbar.Group align={Alignment.END}>
|
||||
<Button variant="minimal" {...getNavbarButtonProps("")} icon="home" title="Home" text="Home" />
|
||||
<Button variant="minimal" {...getNavbarButtonProps("epg")} icon="timeline-events" title="EPG" text="EPG" />
|
||||
<Button variant="minimal" {...getNavbarButtonProps("jobs")} icon="ninja" title="ジョブ" text={
|
||||
<>
|
||||
ジョブ
|
||||
{runningJobs !== 0 && <span className="badge">{runningJobs}</span>}
|
||||
</>
|
||||
} />
|
||||
<Navbar.Divider />
|
||||
<Button variant="minimal" {...getNavbarButtonProps("logs")} icon="pulse" title="ログ" />
|
||||
<Popover
|
||||
minimal
|
||||
interactionKind="hover"
|
||||
placement="bottom-end"
|
||||
modifiers={{ offset: { enabled: true } }}
|
||||
content={
|
||||
<Menu>
|
||||
{dark
|
||||
? <MenuItem icon="flash" text="ライトテーマ" onClick={() => { setDark(false); }} />
|
||||
: <MenuItem icon="moon" text="ダークテーマ" onClick={() => { setDark(true); }} />
|
||||
}
|
||||
<MenuDivider />
|
||||
<MenuItem onClick={() => { state.navigate("/config/server"); }} icon="wrench" text="サーバー設定" />
|
||||
<MenuItem onClick={() => { state.navigate("/config/tuners"); }} icon="wrench" text="チューナー設定" />
|
||||
<MenuItem onClick={() => { state.navigate("/config/channels"); }} icon="wrench" text="チャンネル設定" />
|
||||
<MenuDivider />
|
||||
<MenuItem onClick={() => { window.open("/api/debug", "_blank"); }} icon="document" text="API Docs" />
|
||||
<MenuDivider />
|
||||
<MenuItem onClick={() => { state.navigate("/about"); }} icon="info-sign" textClassName="product-name" text={`Mirakurun ${version} について`} />
|
||||
<VersionStatus asMenuItem />
|
||||
<MenuItem icon="power" intent="danger" text="再起動..." onClick={() => setRestartDialogOpen(true)} />
|
||||
</Menu>
|
||||
}
|
||||
renderTarget={({ isOpen, ref, ...props }: PopoverTargetProps) => (
|
||||
<Button {...props} active={isOpen} ref={ref} variant="minimal" icon="cog" />
|
||||
)}
|
||||
/>
|
||||
<Restart isOpen={restartDialogOpen} onClose={() => setRestartDialogOpen(false)} />
|
||||
</Navbar.Group>
|
||||
</Navbar>
|
||||
);
|
||||
};
|
||||
23
web/src/components/ProgramAVInfo.sass
Normal file
23
web/src/components/ProgramAVInfo.sass
Normal file
@@ -0,0 +1,23 @@
|
||||
@use "~@blueprintjs/colors/lib/scss/colors"
|
||||
|
||||
.component-program-av-info
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
gap: 5px
|
||||
font-size: 10px
|
||||
font-weight: 600
|
||||
color: colors.$gray1
|
||||
|
||||
span
|
||||
padding: 2px 4px
|
||||
border-radius: 2px
|
||||
border: 1px solid
|
||||
|
||||
&.video
|
||||
color: colors.$gold4
|
||||
|
||||
&.type
|
||||
text-transform: uppercase
|
||||
|
||||
&.audio
|
||||
color: colors.$vermilion4
|
||||
63
web/src/components/ProgramAVInfo.tsx
Normal file
63
web/src/components/ProgramAVInfo.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
Copyright 2026 kanreisa
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { langMap, audioModeMap } from "../modules/constants";
|
||||
import { ProgramVideo, ProgramAudio } from "../../../api.d";;
|
||||
|
||||
import "./ProgramAVInfo.sass";
|
||||
|
||||
type ProgramAVInfoProps = {
|
||||
video: ProgramVideo;
|
||||
audios: ProgramAudio[];
|
||||
};
|
||||
export const ProgramAVInfo: React.FC<ProgramAVInfoProps> = ({ video, audios }) => {
|
||||
// console.debug("components", "ProgramAVInfo");
|
||||
|
||||
const labels: JSX.Element[] = [];
|
||||
|
||||
if (video) {
|
||||
if (video.type !== "mpeg2") {
|
||||
labels.push(<span key="video.type" className="video type">{video.type}</span>);
|
||||
}
|
||||
labels.push(<span key="video.resolution" className="video resolution">{video.resolution}</span>);
|
||||
}
|
||||
|
||||
if (audios) {
|
||||
let count = 0;
|
||||
for (const audio of audios) {
|
||||
const trackPrefix = count === 0 ? "主" : "副";
|
||||
const type8 = audio.componentType.toString(2).padStart(8, "0");
|
||||
|
||||
const mode = audioModeMap[type8.slice(-5)] || "不明なモード";
|
||||
const lang = audio.langs.map(lang => langMap[lang]).join("+");
|
||||
|
||||
labels.push(
|
||||
<span key={`audios.${count}`} className="audio">
|
||||
{audios.length > 1 && <>{trackPrefix}: </>}
|
||||
{mode}
|
||||
{lang !== "日本語" && <> / {lang}</>}
|
||||
</span>
|
||||
);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="component-program-av-info">
|
||||
{labels}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
44
web/src/components/ProgramCardBase.sass
Normal file
44
web/src/components/ProgramCardBase.sass
Normal file
@@ -0,0 +1,44 @@
|
||||
@use "~@blueprintjs/colors/lib/scss/colors"
|
||||
|
||||
.component-program-card-base
|
||||
> div:not(:last-child):not(:first-child)
|
||||
margin: 10px 0
|
||||
|
||||
.component-service-link
|
||||
margin-bottom: 10px
|
||||
|
||||
p.title
|
||||
margin-top: 0
|
||||
|
||||
.component-program-title
|
||||
font-size: 14px
|
||||
line-height: 17px
|
||||
|
||||
p.datetime
|
||||
font-size: 12px
|
||||
font-weight: 600
|
||||
color: colors.$gray2
|
||||
|
||||
.bp5-dark &
|
||||
color: colors.$gray4
|
||||
|
||||
p.description
|
||||
font-size: 13px
|
||||
font-feature-settings: "palt" 1
|
||||
|
||||
.bp5-dark &
|
||||
color: colors.$light-gray2
|
||||
|
||||
.actions
|
||||
margin-top: 15px
|
||||
|
||||
> div
|
||||
display: flex
|
||||
column-gap: 5px
|
||||
margin-top: 10px
|
||||
|
||||
> a.more
|
||||
flex-grow: 2
|
||||
|
||||
> button
|
||||
width: 100%
|
||||
120
web/src/components/ProgramCardBase.tsx
Normal file
120
web/src/components/ProgramCardBase.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
Copyright 2026 kanreisa
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Button } from "@blueprintjs/core";
|
||||
import { DateTime } from "luxon";
|
||||
import { getGlobalServiceId } from "../modules/common";
|
||||
import { setSchedule, clearSchedule } from "../modules/at";
|
||||
import * as regexp from "../modules/regexp";
|
||||
import { Program } from "../../../api.d";
|
||||
|
||||
import { ServiceLink } from "./ServiceLink";
|
||||
import { ProgramTitle } from "./ProgramTitle";
|
||||
import { DateTimeRange } from "./DateTimeRange";
|
||||
import { ProgramGenres } from "./ProgramGenres";
|
||||
import { ProgramAVInfo } from "./ProgramAVInfo";
|
||||
import { WatchButton } from "./WatchButton";
|
||||
|
||||
import "./ProgramCardBase.sass";
|
||||
|
||||
type ProgramCardBaseProps = {
|
||||
program: Program;
|
||||
noAVInfo?: boolean;
|
||||
noActions?: boolean;
|
||||
} & React.HTMLAttributes<HTMLDivElement>;
|
||||
export const ProgramCardBase: React.FC<ProgramCardBaseProps> = ({ program, noAVInfo, noActions, ...props }) => {
|
||||
console.debug("components", "ProgramCardBase", program);
|
||||
|
||||
const now = Date.now();
|
||||
const endAt = program.startAt + program.duration;
|
||||
const isDummy = !Number.isInteger(program.id);
|
||||
const date = DateTime.fromMillis(program.startAt).set({ hour: 0, minute: 0, second: 0, millisecond: 0 });
|
||||
const time = DateTime.fromMillis(program.startAt).diff(date).toMillis();
|
||||
const timeForServiceLink = (time > (1000 * 60 * 60 * 24 - 1000 * 60 * 5)) ? 1 : time;
|
||||
|
||||
const [isOnAir, setIsOnAir] = useState(!isDummy && program.startAt <= now && endAt >= now);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDummy || noActions) {
|
||||
return;
|
||||
}
|
||||
|
||||
const schedules: ReturnType<typeof setSchedule>[] = [];
|
||||
if (now <= program.startAt) {
|
||||
schedules.push(setSchedule(program.startAt, () => setIsOnAir(true)));
|
||||
}
|
||||
if (now <= endAt) {
|
||||
schedules.push(setSchedule(endAt, () => setIsOnAir(false)));
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const id of schedules) {
|
||||
clearSchedule(id);
|
||||
}
|
||||
};
|
||||
}, [program]);
|
||||
|
||||
return (
|
||||
<div className="component-program-card-base" {...props}>
|
||||
<ServiceLink
|
||||
globalId={getGlobalServiceId(program.networkId, program.serviceId)}
|
||||
date={date.toISODate()}
|
||||
time={timeForServiceLink}
|
||||
/>
|
||||
|
||||
<p className="title">
|
||||
{noActions && (
|
||||
<Link to={`/epg/programs/${program.id}`}>
|
||||
<ProgramTitle program={program} />
|
||||
</Link>
|
||||
)}
|
||||
{!noActions && (
|
||||
<ProgramTitle program={program} />
|
||||
)}
|
||||
</p>
|
||||
|
||||
<p className="datetime">
|
||||
<DateTimeRange start={program.startAt} end={endAt} />
|
||||
</p>
|
||||
|
||||
{program.description && <p className="description">{program.description.replace(regexp.enclosedAttributeUnicode, "")}</p>}
|
||||
|
||||
{program.genres?.length > 0 && (
|
||||
<ProgramGenres genres={program.genres} />
|
||||
)}
|
||||
|
||||
{!noAVInfo && (program.video || program.audios) && (
|
||||
<ProgramAVInfo video={program.video} audios={program.audios} />
|
||||
)}
|
||||
|
||||
{!noActions && !isDummy && (
|
||||
<div className="actions">
|
||||
<div>
|
||||
<Link className="more" to={`/epg/programs/${program.id}`}>
|
||||
<Button variant="outlined" intent="primary" icon="arrow-right" text="番組詳細" />
|
||||
</Link>
|
||||
|
||||
{(isOnAir) && (
|
||||
<WatchButton variant="outlined" popoverPlacement="top-start" globalServiceId={getGlobalServiceId(program.networkId, program.serviceId)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
20
web/src/components/ProgramGenres.sass
Normal file
20
web/src/components/ProgramGenres.sass
Normal file
@@ -0,0 +1,20 @@
|
||||
@use "~@blueprintjs/colors/lib/scss/colors"
|
||||
@use "../vars"
|
||||
|
||||
.component-program-genres
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
gap: 5px
|
||||
font-size: 11px
|
||||
font-weight: 600
|
||||
font-feature-settings: "palt" 1
|
||||
color: #000
|
||||
|
||||
span
|
||||
padding: 2px 4px
|
||||
border-radius: 2px
|
||||
filter: vars.$invert-filter
|
||||
|
||||
&.caution
|
||||
filter: none
|
||||
background: colors.$orange5
|
||||
60
web/src/components/ProgramGenres.tsx
Normal file
60
web/src/components/ProgramGenres.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
Copyright 2026 kanreisa
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { Genre1Map, Genre2Map, GenreUN1Map, GenreUN2Map } from "../modules/constants";
|
||||
import { ProgramGenre } from "../../../api.d";
|
||||
|
||||
import "./ProgramGenres.sass";
|
||||
|
||||
type ProgramGenresProps = {
|
||||
genres: ProgramGenre[];
|
||||
};
|
||||
export const ProgramGenres: React.FC<ProgramGenresProps> = ({ genres }) => {
|
||||
// console.debug("components", "ProgramGenres");
|
||||
|
||||
const lv1Set = new Set<number>();
|
||||
const labels: JSX.Element[] = [];
|
||||
for (const genre of genres) {
|
||||
const lv1Text = Genre1Map[genre.lv1];
|
||||
if (!lv1Text) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const un2Text = GenreUN2Map[(genre.lv1 * 0x1000) + (genre.lv2 * 0x100) + (genre.un1 * 0x10) + genre.un2];
|
||||
if (un2Text) {
|
||||
const key = (genre.lv1 * 0x1000) + (genre.lv2 * 0x100) + (genre.un1 * 0x10) + genre.un2;
|
||||
if (key < 0xE000 || key > 0xE020) {
|
||||
continue;
|
||||
}
|
||||
labels.push(<span key={key} className="caution">{un2Text}</span>);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!lv1Set.has(genre.lv1)) {
|
||||
lv1Set.add(genre.lv1);
|
||||
labels.push(<span key={genre.lv1} className={`bg-genre-lv1-${genre.lv1}`}>{lv1Text}</span>);
|
||||
}
|
||||
|
||||
const lv2Text = Genre2Map[(genre.lv1 * 0x10) + genre.lv2];
|
||||
labels.push(<span key={`${genre.lv1}.${genre.lv2}`} className={`bg-genre-lv1-${genre.lv1}`}>{lv2Text}</span>);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="component-program-genres">
|
||||
{labels}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
4
web/src/components/ProgramPopover.sass
Normal file
4
web/src/components/ProgramPopover.sass
Normal file
@@ -0,0 +1,4 @@
|
||||
.component-program-popover
|
||||
padding: 15px
|
||||
min-width: 300px
|
||||
max-width: 380px
|
||||
70
web/src/components/ProgramPopover.tsx
Normal file
70
web/src/components/ProgramPopover.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
Copyright 2026 kanreisa
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Popover, PopoverTargetProps } from "@blueprintjs/core";
|
||||
import { Program } from "../../../api.d";
|
||||
|
||||
import { ProgramCardBase } from "./ProgramCardBase";
|
||||
|
||||
import "./ProgramPopover.sass";
|
||||
|
||||
type ProgramPopoverProps<T = {}> = {
|
||||
program: Program;
|
||||
key?: string;
|
||||
className?: string;
|
||||
portalContainer?: HTMLElement;
|
||||
defaultIsOpen?: boolean;
|
||||
renderTarget: (props: PopoverTargetProps & T) => JSX.Element;
|
||||
};
|
||||
export const ProgramPopover: React.FC<ProgramPopoverProps> = ({ program, renderTarget, className = "", defaultIsOpen = false, ...props }) => {
|
||||
// console.debug("components", "ProgramPopover");
|
||||
|
||||
const [active, setActive] = useState(defaultIsOpen);
|
||||
const [content, setContent] = useState<React.JSX.Element>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setContent(<ProgramCardBase program={program} />);
|
||||
|
||||
return () => {
|
||||
setContent(null);
|
||||
};
|
||||
}, [active]);
|
||||
|
||||
if (className) {
|
||||
className += " ";
|
||||
}
|
||||
className += "bp5-dark";
|
||||
|
||||
return (
|
||||
<Popover {...props}
|
||||
className={className}
|
||||
renderTarget={renderTarget}
|
||||
defaultIsOpen={active}
|
||||
onOpening={() => setActive(true)}
|
||||
onClosed={() => setActive(false)}
|
||||
content={
|
||||
<div className="component-program-popover">
|
||||
{content}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
16
web/src/components/ProgramRelatedLinks.sass
Normal file
16
web/src/components/ProgramRelatedLinks.sass
Normal file
@@ -0,0 +1,16 @@
|
||||
@use "~@blueprintjs/colors/lib/scss/colors"
|
||||
|
||||
.component-program-related-links
|
||||
display: flex
|
||||
gap: 10px
|
||||
|
||||
> .bp5-section
|
||||
min-width: 300px
|
||||
max-width: 380px
|
||||
background-color: colors.$light-gray4
|
||||
|
||||
.bp5-dark &
|
||||
background-color: colors.$dark-gray4
|
||||
|
||||
.component-program-card-base
|
||||
padding: 15px
|
||||
104
web/src/components/ProgramRelatedLinks.tsx
Normal file
104
web/src/components/ProgramRelatedLinks.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
Copyright 2026 kanreisa
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Section } from "@blueprintjs/core";
|
||||
import { relatedItemTypeMap, relatedItemTypeIconMap } from "../modules/constants";
|
||||
import { state } from "../modules/state";
|
||||
import { Program } from "../../../api.d";
|
||||
|
||||
import { ProgramCardBase } from "./ProgramCardBase";
|
||||
|
||||
import "./ProgramRelatedLinks.sass";
|
||||
|
||||
type ProgramRelatedLinksProps = {
|
||||
program: Program;
|
||||
};
|
||||
export const ProgramRelatedLinks: React.FC<ProgramRelatedLinksProps> = ({ program }) => {
|
||||
console.debug("components", "ProgramRelatedLinks");
|
||||
|
||||
const [links, setLinks] = useState<React.JSX.Element[]>([]);
|
||||
|
||||
const relatedItems = useMemo(() => {
|
||||
return program?.relatedItems?.filter(item => {
|
||||
if (item.networkId) {
|
||||
return item.eventId !== program.eventId || item.serviceId !== program.serviceId || item.networkId !== program.networkId;
|
||||
}
|
||||
return item.eventId !== program.eventId || item.serviceId !== program.serviceId;
|
||||
});
|
||||
}, [program]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!relatedItems || relatedItems.length === 0) {
|
||||
setLinks([]);
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug("ProgramRelatedLinks", "relatedItems", relatedItems);
|
||||
|
||||
let abort = false;
|
||||
|
||||
(async () => {
|
||||
const _links: React.JSX.Element[] = [];
|
||||
const programs = state.programs.length > 0 ? state.programs : await state.fetchPrograms();
|
||||
|
||||
if (abort) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const item of relatedItems) {
|
||||
const p = programs.find(p => {
|
||||
if (item.networkId) {
|
||||
return p.eventId === item.eventId && p.serviceId === item.serviceId && p.networkId === item.networkId;
|
||||
}
|
||||
return p.eventId === item.eventId && p.serviceId === item.serviceId;
|
||||
});
|
||||
if (!p) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const link = (
|
||||
<Section
|
||||
key={`${item.type}-${item.eventId}-${item.serviceId}`}
|
||||
className={`related-item-type-${item.type}`}
|
||||
icon={relatedItemTypeIconMap[item.type]}
|
||||
title={relatedItemTypeMap[item.type]}
|
||||
compact
|
||||
>
|
||||
<ProgramCardBase program={p} />
|
||||
</Section>
|
||||
);
|
||||
_links.push(link);
|
||||
}
|
||||
|
||||
setLinks(_links);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
abort = true;
|
||||
}
|
||||
}, [relatedItems]);
|
||||
|
||||
if (links.length === 0) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="component-program-related-links">
|
||||
{links}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
38
web/src/components/ProgramTitle.sass
Normal file
38
web/src/components/ProgramTitle.sass
Normal file
@@ -0,0 +1,38 @@
|
||||
@use "~@blueprintjs/colors/lib/scss/colors"
|
||||
|
||||
.component-program-title
|
||||
font-size: inherit
|
||||
line-height: inherit
|
||||
|
||||
.attribute,
|
||||
.name,
|
||||
.bp5-icon
|
||||
margin-right: 2px
|
||||
|
||||
.bp5-alert-body &
|
||||
margin-right: 2px
|
||||
|
||||
.name
|
||||
font-weight: 600
|
||||
font-feature-settings: "palt" 1
|
||||
|
||||
.attribute
|
||||
border-radius: 1px
|
||||
padding: 0 1px
|
||||
font-size: 80%
|
||||
font-weight: 500
|
||||
vertical-align: 1px
|
||||
background: colors.$gray2
|
||||
color: #fff
|
||||
|
||||
.bp5-icon
|
||||
vertical-align: -10%
|
||||
|
||||
&-tick
|
||||
color: colors.$green4
|
||||
&-flag
|
||||
color: colors.$red4
|
||||
&-record
|
||||
color: colors.$vermilion4
|
||||
&-small-cross
|
||||
color: colors.$gray3
|
||||
101
web/src/components/ProgramTitle.tsx
Normal file
101
web/src/components/ProgramTitle.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
Copyright 2026 kanreisa
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
import * as regexp from "../modules/regexp";
|
||||
import { ProgramAttributeMap } from "../modules/constants";;
|
||||
import { Program } from "../../../api.d";
|
||||
|
||||
import "./ProgramTitle.sass";
|
||||
|
||||
type ProgramTitleProps = {
|
||||
program: Program;
|
||||
};
|
||||
export const ProgramTitle: React.FC<ProgramTitleProps> = ({ program }) => {
|
||||
// console.debug("components", "ProgramTitle");
|
||||
|
||||
let name = program.name;
|
||||
if (!name) {
|
||||
if (program.relatedItems) {
|
||||
const isShared = program.relatedItems.some(item => item.type === "shared");
|
||||
if (isShared) {
|
||||
name = "(イベント共有)";
|
||||
} else {
|
||||
name = "(不明)";
|
||||
}
|
||||
} else {
|
||||
name = "(未定)";
|
||||
}
|
||||
}
|
||||
name = name.replace(regexp.squaredUnicode, "").replace(regexp.legacyAttributeFormat, "");
|
||||
|
||||
const attributes = useMemo(() => {
|
||||
const attrSet = new Set<keyof typeof ProgramAttributeMap>();
|
||||
const attributeSource = (program.name || "") + (program.description || "");
|
||||
if (attributeSource) {
|
||||
const items = [
|
||||
...attributeSource.match(regexp.enclosedAttributeUnicode) || [],
|
||||
...attributeSource.match(regexp.legacyAttributeFormat) || [],
|
||||
];
|
||||
if (program.networkId >= 0x01 && program.networkId <= 0x0C && program.isFree) {
|
||||
items.push("無");
|
||||
}
|
||||
for (const item of items) {
|
||||
const attrKey = item.replace(/[\[\]()[]]/g, "").normalize("NFKC");
|
||||
if (ProgramAttributeMap[attrKey]) {
|
||||
attrSet.add(attrKey as any);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...attrSet];
|
||||
}, [program.name, program.description]);
|
||||
|
||||
const labels = useMemo(() => {
|
||||
const pre: JSX.Element[] = [];
|
||||
const post: JSX.Element[] = [];
|
||||
|
||||
for (const attribute of attributes) {
|
||||
/* if (attribute === "無") {
|
||||
// 公共放送と無料放送の [無] は省略
|
||||
continue;
|
||||
} */
|
||||
|
||||
const label = (
|
||||
<span key={`attribute-${attribute}`}
|
||||
className={`attribute bg-attribute-${attribute}`}
|
||||
title={ProgramAttributeMap[attribute]}>
|
||||
{attribute}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (["新", "再", "終", "生"].includes(attribute)) {
|
||||
pre.push(label);
|
||||
} else {
|
||||
post.push(label);
|
||||
}
|
||||
}
|
||||
|
||||
return { pre, post };
|
||||
}, [attributes]);
|
||||
|
||||
return (
|
||||
<span className="component-program-title">
|
||||
{labels.pre}
|
||||
<span className="name" title={program.name}>{name}</span>
|
||||
{labels.post}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
50
web/src/components/Restart.tsx
Normal file
50
web/src/components/Restart.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
Copyright 2026 kanreisa
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { Button, Dialog, DialogBody, DialogFooter } from "@blueprintjs/core";
|
||||
|
||||
export const Restart: React.FC<{
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}> = ({ isOpen, onClose }) => {
|
||||
const handleRestart = async () => {
|
||||
await fetch("/api/restart", { method: "PUT" });
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Restart Mirakurun"
|
||||
canEscapeKeyClose
|
||||
>
|
||||
<DialogBody>
|
||||
<div>
|
||||
Do you want to restart Mirakurun?
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter
|
||||
actions={
|
||||
<>
|
||||
<Button text="Cancel" onClick={onClose} />
|
||||
<Button text="Restart" intent="danger" onClick={handleRestart} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
8
web/src/components/ServiceLink.sass
Normal file
8
web/src/components/ServiceLink.sass
Normal file
@@ -0,0 +1,8 @@
|
||||
.component-service-link
|
||||
display: flex
|
||||
gap: 10px
|
||||
align-items: center
|
||||
|
||||
> img
|
||||
max-height: 18px
|
||||
border-radius: 1px
|
||||
73
web/src/components/ServiceLink.tsx
Normal file
73
web/src/components/ServiceLink.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
Copyright 2026 kanreisa
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { channelTypeMap } from "../modules/constants";
|
||||
import { Service } from "../../../api.d";
|
||||
import { state } from "../modules/state";
|
||||
|
||||
import "./ServiceLink.sass";
|
||||
|
||||
type ServiceLinkProps = {
|
||||
globalId: number;
|
||||
date?: string;
|
||||
time?: number;
|
||||
} & React.HTMLAttributes<HTMLDivElement>;
|
||||
export const ServiceLink: React.FC<ServiceLinkProps> = ({ globalId, date, time, ...props }) => {
|
||||
console.debug("components", "ServiceLink");
|
||||
|
||||
const [service, setService] = useState<Service>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const _service = state.services.find(s => s.id === globalId);
|
||||
setService(_service);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
setService(null);
|
||||
};
|
||||
}, [globalId]);
|
||||
|
||||
let to = "#";
|
||||
let className = "component-service-link";
|
||||
|
||||
if (props.className) {
|
||||
className += ` ${props.className}`;
|
||||
}
|
||||
|
||||
if (service) {
|
||||
to = `/epg/services/${service.id}`;
|
||||
if (date && time) {
|
||||
to += `?date=${date}&time=${time}`
|
||||
} else if (time) {
|
||||
to += `?time=${time}`
|
||||
} else if (date) {
|
||||
to += `?date=${date}`
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className} {...props}>
|
||||
{service && service.hasLogoData && <img src={`/api/services/${service.id}/logo`} />}
|
||||
|
||||
<Link className={service ? null : "bp5-skeleton"} title="EPG 番組表 (週間)" to={to}>
|
||||
{service ? `${service.name.normalize("NFKC")} (${channelTypeMap[service.channel.type]})` : "サービス名..."}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
127
web/src/components/VersionStatus.tsx
Normal file
127
web/src/components/VersionStatus.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
Copyright 2026 kanreisa
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { MenuItem } from "@blueprintjs/core";
|
||||
import * as semver from "semver";
|
||||
import { state } from "../modules/state";
|
||||
|
||||
interface VersionInfo {
|
||||
current: string;
|
||||
latest: string;
|
||||
}
|
||||
|
||||
let cachedVersion: VersionInfo | null = null;
|
||||
let isFetching = false;
|
||||
const fetchListeners: Array<(version: VersionInfo | null) => void> = [];
|
||||
|
||||
const fetchVersion = async (): Promise<VersionInfo | null> => {
|
||||
if (cachedVersion) {
|
||||
return cachedVersion;
|
||||
}
|
||||
if (isFetching) {
|
||||
return new Promise((resolve) => {
|
||||
fetchListeners.push(resolve);
|
||||
});
|
||||
}
|
||||
isFetching = true;
|
||||
try {
|
||||
const res = await fetch("/api/version");
|
||||
if (res.ok) {
|
||||
const data: VersionInfo = await res.json();
|
||||
cachedVersion = data;
|
||||
const listeners = [...fetchListeners];
|
||||
fetchListeners.length = 0;
|
||||
listeners.forEach((resolve) => resolve(data));
|
||||
return data;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch version", e);
|
||||
}
|
||||
isFetching = false;
|
||||
const listeners = [...fetchListeners];
|
||||
fetchListeners.length = 0;
|
||||
listeners.forEach((resolve) => resolve(null));
|
||||
return null;
|
||||
};
|
||||
|
||||
export const VersionStatus: React.FC<{
|
||||
asMenuItem?: boolean;
|
||||
}> = ({ asMenuItem = false }) => {
|
||||
const [version, setVersion] = useState<VersionInfo | null>(cachedVersion);
|
||||
const [loading, setLoading] = useState<boolean>(!cachedVersion);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
if (!cachedVersion) {
|
||||
fetchVersion().then((data) => {
|
||||
if (isMounted) {
|
||||
setVersion(data);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const hasUpdate = version
|
||||
&& semver.valid(version.current)
|
||||
&& semver.valid(version.latest)
|
||||
&& semver.gt(version.latest, version.current);
|
||||
|
||||
if (asMenuItem) {
|
||||
if (loading) {
|
||||
return <MenuItem icon="updated" text="アップデートを確認中..." disabled />;
|
||||
}
|
||||
if (!version) {
|
||||
return <MenuItem icon="updated" text="最新版を実行中です" disabled />;
|
||||
}
|
||||
if (hasUpdate) {
|
||||
return (
|
||||
<MenuItem
|
||||
icon="updated"
|
||||
intent="primary"
|
||||
text={`最新版 (v${version.latest}) が利用可能です`}
|
||||
onClick={() => {
|
||||
state.navigate("/about");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <MenuItem icon="updated" text="最新版を実行中です" disabled />;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <span>アップデートを確認中...</span>;
|
||||
}
|
||||
if (!version) {
|
||||
return <span>不明 (取得失敗)</span>;
|
||||
}
|
||||
|
||||
if (hasUpdate) {
|
||||
return (
|
||||
<span style={{ color: "#2d72d9", fontWeight: "bold" }}>
|
||||
{version.latest} (新しいバージョンが利用可能です)
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <span>{version.latest} (最新版を実行中です)</span>;
|
||||
};
|
||||
126
web/src/components/WatchButton.tsx
Normal file
126
web/src/components/WatchButton.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
Copyright 2026 kanreisa
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button, ButtonGroup, ButtonProps, Menu, MenuItem, Popover, PopoverTargetProps, Placement } from "@blueprintjs/core";
|
||||
import { copyToClipboard } from "../modules/common";
|
||||
import { state } from "../modules/state";
|
||||
|
||||
type WatchButtonProps = {
|
||||
globalServiceId: number;
|
||||
popoverPlacement?: Placement;
|
||||
} & ButtonProps & React.HTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
export const WatchButton: React.FC<WatchButtonProps> = ({ globalServiceId, popoverPlacement, ...props }) => {
|
||||
console.debug("components", "WatchButton");
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (state.serverConfig) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await state.fetchServerConfig();
|
||||
|
||||
if (state.serverConfig) {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const tsplayDisabled = !state.serverConfig?.tsplayEndpoint || !state.serverConfig?.allowPNA;
|
||||
const streamEndpoint = `${location.protocol}//${location.host}/api/services/${globalServiceId}/stream`;
|
||||
|
||||
return (<>
|
||||
<ButtonGroup className={loading ? "bp5-skeleton" : ""}>
|
||||
<Button {...props}
|
||||
text="視聴テスト"
|
||||
icon="play"
|
||||
endIcon="lab-test"
|
||||
disabled={tsplayDisabled}
|
||||
onPointerUp={e => {
|
||||
if (tsplayDisabled) {
|
||||
return;
|
||||
}
|
||||
// e.preventDefault();
|
||||
// e.stopPropagation();
|
||||
|
||||
// マウス中クリックか ctrl 押しながら左クリックか判定
|
||||
const isMiddleButton = e.button === 1 || (e.button === 0 && e.ctrlKey);
|
||||
|
||||
let features = "noreferrer";
|
||||
if (isMiddleButton) {
|
||||
// features += "";
|
||||
} else {
|
||||
const width = 1280;
|
||||
const height = 770;
|
||||
// winPosX, winPosY は現在のブラウザウィンドウの画面上の真ん中に設定
|
||||
const top = window.screenTop + (window.innerHeight / 2) - (height / 2);
|
||||
const left = window.screenLeft + (window.innerWidth / 2) - (width / 2);
|
||||
|
||||
features += `,popup,width=${width},height=${height},top=${top},left=${left},resizable=yes`;
|
||||
}
|
||||
|
||||
const openUrl = `${state.serverConfig.tsplayEndpoint}#${streamEndpoint}`;
|
||||
window.open(openUrl, `_blank`, features);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Popover
|
||||
hasBackdrop={true}
|
||||
onClose={e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
captureDismiss={true}
|
||||
placement={popoverPlacement}
|
||||
positioningStrategy="absolute"
|
||||
content={
|
||||
<Menu>
|
||||
<MenuItem icon="clipboard" text="URL をクリップボードにコピー" onClick={() => {
|
||||
copyToClipboard(streamEndpoint);
|
||||
}} />
|
||||
<MenuItem icon="desktop" text="M3U プレイリスト..." onClick={() => {
|
||||
const m3u8Content = (
|
||||
`#EXTM3U\n` +
|
||||
`#EXTINF:-1,\n` +
|
||||
`${streamEndpoint}\n`
|
||||
);
|
||||
const blob = new Blob([m3u8Content], { type: "application/x-mpegURL" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `Mirakurun_service_${globalServiceId}.m3u8`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, 100);
|
||||
}} />
|
||||
</Menu>
|
||||
}
|
||||
renderTarget={({ isOpen, ref, ...targetProps }: PopoverTargetProps) => (
|
||||
<Button {...{...props, text: ""}} {...targetProps} active={isOpen} ref={ref} icon="more" title="再生方法..." />
|
||||
)}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
</>);
|
||||
};
|
||||
4
web/src/custom.d.ts
vendored
Normal file
4
web/src/custom.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module '*.svg' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
97
web/src/hooks/useWebStorageState.ts
Normal file
97
web/src/hooks/useWebStorageState.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
Copyright 2026 kanreisa
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import { useState, useCallback } from "react";
|
||||
import equal from "fast-deep-equal";
|
||||
|
||||
export function useLocalStorageState<T>(key: string, initState: T): [T, (newState: T) => void] {
|
||||
key = "mirakurun:state:" + key;
|
||||
|
||||
const [stored, setStored] = useState<T>();
|
||||
|
||||
if (stored === undefined) {
|
||||
const storedState = localStorage.getItem(key);
|
||||
if (storedState !== null) {
|
||||
initState = JSON.parse(storedState) as T;
|
||||
}
|
||||
|
||||
setStored(initState);
|
||||
|
||||
// debug
|
||||
console.debug("hooks", "useLocalStorageState()", "get", key, initState);
|
||||
}
|
||||
|
||||
const [state, setState] = useState(initState);
|
||||
|
||||
const setStorageState = useCallback((newState: T) => {
|
||||
if (equal(newState, state)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newState === undefined) {
|
||||
localStorage.removeItem(key);
|
||||
} else {
|
||||
localStorage.setItem(key, JSON.stringify(newState));
|
||||
}
|
||||
|
||||
setState(newState);
|
||||
setStored(newState);
|
||||
|
||||
// debug
|
||||
console.debug("hooks", "useLocalStorageState()", "set", key, newState);
|
||||
}, [state]);
|
||||
|
||||
return [state, setStorageState];
|
||||
}
|
||||
|
||||
export function useSessionStorageState<T>(key: string, initState: T): [T, (newState: T) => void] {
|
||||
key = "mirakurun:state:" + key;
|
||||
|
||||
const [stored, setStored] = useState<T>();
|
||||
|
||||
if (stored === undefined) {
|
||||
const storedState = sessionStorage.getItem(key);
|
||||
if (storedState !== null) {
|
||||
initState = JSON.parse(storedState) as T;
|
||||
}
|
||||
|
||||
setStored(initState);
|
||||
|
||||
// debug
|
||||
console.debug("hooks", "useSessionStorageState()", "get", key, initState);
|
||||
}
|
||||
|
||||
const [state, setState] = useState(initState);
|
||||
|
||||
const setStorageState = useCallback((newState: T) => {
|
||||
if (equal(newState, state)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newState === undefined) {
|
||||
sessionStorage.removeItem(key);
|
||||
} else {
|
||||
sessionStorage.setItem(key, JSON.stringify(newState));
|
||||
}
|
||||
|
||||
setState(newState);
|
||||
setStored(newState);
|
||||
|
||||
// debug
|
||||
console.debug("hooks", "useSessionStorageState()", "set", key, newState);
|
||||
}, [state]);
|
||||
|
||||
return [state, setStorageState];
|
||||
}
|
||||
50
web/src/icon-active.svg
Normal file
50
web/src/icon-active.svg
Normal file
@@ -0,0 +1,50 @@
|
||||
<!--
|
||||
Copyright 2020 kanreisa
|
||||
CC BY-SA 4.0
|
||||
This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License.
|
||||
https://creativecommons.org/licenses/by-sa/4.0/
|
||||
-->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 16 16">
|
||||
<defs>
|
||||
<style>
|
||||
.a {
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.b {
|
||||
fill: #ffe18a;
|
||||
}
|
||||
|
||||
.c {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.d {
|
||||
clip-path: url(#a);
|
||||
}
|
||||
|
||||
.e {
|
||||
fill: #ffd56c;
|
||||
}
|
||||
|
||||
.f {
|
||||
fill: #ce3851;
|
||||
}
|
||||
</style>
|
||||
<clipPath id="a">
|
||||
<path class="a" d="M8.24573.88939c2.11158,4.25885,2.46212,4.53568,7.21136,5.23685a.274.274,0,0,1,.14747.466c-3.39789,3.32429-3.55286,3.74321-2.75211,8.47668a.274.274,0,0,1-.39761.28426c-4.21153-2.2043-4.65789-2.22225-8.91207.00192a.27254.27254,0,0,1-.39408-.28618c.79574-4.69042.67579-5.1197-2.75527-8.47946A.274.274,0,0,1,.548,6.12578c4.70292-.69218,5.07452-.94,7.209-5.24013A.274.274,0,0,1,8.24573.88939Z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g>
|
||||
<path class="b" d="M8.24573.88939c2.11158,4.25885,2.46212,4.53568,7.21136,5.23685a.274.274,0,0,1,.14747.466c-3.39789,3.32429-3.55286,3.74321-2.75211,8.47668a.274.274,0,0,1-.39761.28426c-4.21153-2.2043-4.65789-2.22225-8.91207.00192a.27254.27254,0,0,1-.39408-.28618c.79574-4.69042.67579-5.1197-2.75527-8.47946A.274.274,0,0,1,.548,6.12578c4.70292-.69218,5.07452-.94,7.209-5.24013A.274.274,0,0,1,8.24573.88939Z"/>
|
||||
<path class="c" d="M7.88012,12.67977C6.85,10.60206,6.679,10.467,4.362,10.12493a.13366.13366,0,0,1-.07195-.22734C5.94775,8.27582,6.02335,8.07144,5.6327,5.76218a.13366.13366,0,0,1,.194-.13867c2.05463,1.07538,2.27239,1.08414,4.34782-.00094a.133.133,0,0,1,.19226.13962c-.38821,2.28825-.32969,2.49768,1.34418,4.13676a.13367.13367,0,0,1-.07542.22621c-2.29436.33768-2.47564.45859-3.51695,2.55643A.13366.13366,0,0,1,7.88012,12.67977Z"/>
|
||||
<g class="d">
|
||||
<circle class="e" cx="5.13755" cy="4.86342" r="0.79995"/>
|
||||
<circle class="e" cx="10.86245" cy="4.86342" r="0.8"/>
|
||||
<circle class="e" cx="8" cy="13.69361" r="0.8"/>
|
||||
<circle class="e" cx="3.34339" cy="10.31627" r="0.8"/>
|
||||
<circle class="e" cx="12.65661" cy="10.31627" r="0.8"/>
|
||||
</g>
|
||||
</g>
|
||||
<circle class="f" cx="13.87538" cy="2.54867" r="1.81129"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
45
web/src/icon-gray.svg
Normal file
45
web/src/icon-gray.svg
Normal file
@@ -0,0 +1,45 @@
|
||||
<!--
|
||||
Copyright 2020 kanreisa
|
||||
CC BY-SA 4.0
|
||||
This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License.
|
||||
https://creativecommons.org/licenses/by-sa/4.0/
|
||||
-->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 16 16">
|
||||
<defs>
|
||||
<style>
|
||||
.a {
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.b {
|
||||
fill: #e0e0e0;
|
||||
}
|
||||
|
||||
.c {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.d {
|
||||
clip-path: url(#a);
|
||||
}
|
||||
|
||||
.e {
|
||||
fill: #d6d6d6;
|
||||
}
|
||||
</style>
|
||||
<clipPath id="a">
|
||||
<path class="a" d="M8.24573.88939c2.11158,4.25885,2.46212,4.53568,7.21136,5.23685a.274.274,0,0,1,.14747.466c-3.39789,3.32429-3.55286,3.74321-2.75211,8.47668a.274.274,0,0,1-.39761.28426c-4.21153-2.2043-4.65789-2.22225-8.91207.00192a.27254.27254,0,0,1-.39408-.28618c.79574-4.69042.67579-5.1197-2.75527-8.47946A.274.274,0,0,1,.548,6.12578c4.70292-.69218,5.07452-.94,7.209-5.24013A.274.274,0,0,1,8.24573.88939Z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g>
|
||||
<path class="b" d="M8.24573.88939c2.11158,4.25885,2.46212,4.53568,7.21136,5.23685a.274.274,0,0,1,.14747.466c-3.39789,3.32429-3.55286,3.74321-2.75211,8.47668a.274.274,0,0,1-.39761.28426c-4.21153-2.2043-4.65789-2.22225-8.91207.00192a.27254.27254,0,0,1-.39408-.28618c.79574-4.69042.67579-5.1197-2.75527-8.47946A.274.274,0,0,1,.548,6.12578c4.70292-.69218,5.07452-.94,7.209-5.24013A.274.274,0,0,1,8.24573.88939Z"/>
|
||||
<path class="c" d="M7.88012,12.67977C6.85,10.60206,6.679,10.467,4.362,10.12493a.13366.13366,0,0,1-.07195-.22734C5.94775,8.27582,6.02335,8.07144,5.6327,5.76218a.13366.13366,0,0,1,.194-.13867c2.05463,1.07538,2.27239,1.08414,4.34782-.00094a.133.133,0,0,1,.19226.13962c-.38821,2.28825-.32969,2.49768,1.34418,4.13676a.13367.13367,0,0,1-.07542.22621c-2.29436.33768-2.47564.45859-3.51695,2.55643A.13366.13366,0,0,1,7.88012,12.67977Z"/>
|
||||
<g class="d">
|
||||
<circle class="e" cx="5.13755" cy="4.86342" r="0.79995"/>
|
||||
<circle class="e" cx="10.86245" cy="4.86342" r="0.8"/>
|
||||
<circle class="e" cx="8" cy="13.69361" r="0.8"/>
|
||||
<circle class="e" cx="3.34339" cy="10.31627" r="0.8"/>
|
||||
<circle class="e" cx="12.65661" cy="10.31627" r="0.8"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
45
web/src/icon.svg
Normal file
45
web/src/icon.svg
Normal file
@@ -0,0 +1,45 @@
|
||||
<!--
|
||||
Copyright 2020 kanreisa
|
||||
CC BY-SA 4.0
|
||||
This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License.
|
||||
https://creativecommons.org/licenses/by-sa/4.0/
|
||||
-->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 16 16">
|
||||
<defs>
|
||||
<style>
|
||||
.a {
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.b {
|
||||
fill: #ffe18a;
|
||||
}
|
||||
|
||||
.c {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.d {
|
||||
clip-path: url(#a);
|
||||
}
|
||||
|
||||
.e {
|
||||
fill: #ffd56c;
|
||||
}
|
||||
</style>
|
||||
<clipPath id="a">
|
||||
<path class="a" d="M8.24573.88939c2.11158,4.25885,2.46212,4.53568,7.21136,5.23685a.274.274,0,0,1,.14747.466c-3.39789,3.32429-3.55286,3.74321-2.75211,8.47668a.274.274,0,0,1-.39761.28426c-4.21153-2.2043-4.65789-2.22225-8.91207.00192a.27254.27254,0,0,1-.39408-.28618c.79574-4.69042.67579-5.1197-2.75527-8.47946A.274.274,0,0,1,.548,6.12578c4.70292-.69218,5.07452-.94,7.209-5.24013A.274.274,0,0,1,8.24573.88939Z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g>
|
||||
<path class="b" d="M8.24573.88939c2.11158,4.25885,2.46212,4.53568,7.21136,5.23685a.274.274,0,0,1,.14747.466c-3.39789,3.32429-3.55286,3.74321-2.75211,8.47668a.274.274,0,0,1-.39761.28426c-4.21153-2.2043-4.65789-2.22225-8.91207.00192a.27254.27254,0,0,1-.39408-.28618c.79574-4.69042.67579-5.1197-2.75527-8.47946A.274.274,0,0,1,.548,6.12578c4.70292-.69218,5.07452-.94,7.209-5.24013A.274.274,0,0,1,8.24573.88939Z"/>
|
||||
<path class="c" d="M7.88012,12.67977C6.85,10.60206,6.679,10.467,4.362,10.12493a.13366.13366,0,0,1-.07195-.22734C5.94775,8.27582,6.02335,8.07144,5.6327,5.76218a.13366.13366,0,0,1,.194-.13867c2.05463,1.07538,2.27239,1.08414,4.34782-.00094a.133.133,0,0,1,.19226.13962c-.38821,2.28825-.32969,2.49768,1.34418,4.13676a.13367.13367,0,0,1-.07542.22621c-2.29436.33768-2.47564.45859-3.51695,2.55643A.13366.13366,0,0,1,7.88012,12.67977Z"/>
|
||||
<g class="d">
|
||||
<circle class="e" cx="5.13755" cy="4.86342" r="0.79995"/>
|
||||
<circle class="e" cx="10.86245" cy="4.86342" r="0.8"/>
|
||||
<circle class="e" cx="8" cy="13.69361" r="0.8"/>
|
||||
<circle class="e" cx="3.34339" cy="10.31627" r="0.8"/>
|
||||
<circle class="e" cx="12.65661" cy="10.31627" r="0.8"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
15
web/src/index.html
Normal file
15
web/src/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0">
|
||||
<title>...</title>
|
||||
<link id="icon" rel="icon" type="image/svg+xml" href="/icon.svg">
|
||||
<script defer src="/vendors.bundle.js"></script>
|
||||
<script defer src="/index.bundle.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
217
web/src/index.sass
Normal file
217
web/src/index.sass
Normal file
@@ -0,0 +1,217 @@
|
||||
@use "~@blueprintjs/colors/lib/scss/colors"
|
||||
@use "./vars"
|
||||
|
||||
.bp5-spinner
|
||||
animation: 0.2s ease 0.3s 1 normal forwards running fade-in
|
||||
|
||||
.bp5-navbar
|
||||
display: flex
|
||||
|
||||
.bp5-navbar-group
|
||||
&.bp5-align-left
|
||||
flex: 1 1 0
|
||||
|
||||
&,
|
||||
.bp5-navbar-heading
|
||||
overflow: hidden
|
||||
text-overflow: ellipsis
|
||||
white-space: nowrap
|
||||
|
||||
.bp5-non-ideal-state
|
||||
position: absolute
|
||||
z-index: 2
|
||||
top: 0
|
||||
left: 0
|
||||
right: 0
|
||||
bottom: 0
|
||||
width: auto
|
||||
height: auto
|
||||
overflow: auto
|
||||
background: colors.$light-gray4
|
||||
|
||||
.bp5-dark &
|
||||
background: colors.$dark-gray4
|
||||
|
||||
body
|
||||
font-family: vars.$font-base
|
||||
overflow: hidden
|
||||
|
||||
&.indiana-dragging
|
||||
cursor: default
|
||||
|
||||
.bp5-button > .bp5-button-text,
|
||||
.bp5-menu-item
|
||||
font-family: vars.$font-ui
|
||||
|
||||
select,
|
||||
button,
|
||||
.bp5-tabs
|
||||
user-select: none
|
||||
|
||||
button:not(.bp5-button)
|
||||
border: none
|
||||
background: inherit
|
||||
|
||||
&:not(:disabled)
|
||||
cursor: pointer
|
||||
|
||||
a[target="_blank"]::after
|
||||
font-family: "blueprint-icons-16"
|
||||
content: ""
|
||||
margin-left: 4px
|
||||
font-size: 10px
|
||||
|
||||
.font-bolder
|
||||
font-weight: bolder !important
|
||||
|
||||
.color-warning
|
||||
color: colors.$orange3 !important
|
||||
.color-danger
|
||||
color: colors.$red3 !important
|
||||
|
||||
.color-dow-6
|
||||
color: colors.$blue3 !important
|
||||
.color-dow-7
|
||||
color: colors.$red3 !important
|
||||
|
||||
.bg-genre-lv1-0
|
||||
background: rgb(255,255,224) !important
|
||||
.bg-genre-lv1-1
|
||||
background: rgb(224,224,255) !important
|
||||
.bg-genre-lv1-2
|
||||
background: rgb(255,224,240) !important
|
||||
.bg-genre-lv1-3
|
||||
background: rgb(255,224,224) !important
|
||||
.bg-genre-lv1-4
|
||||
background: rgb(224,255,224) !important
|
||||
.bg-genre-lv1-5
|
||||
background: rgb(224,255,255) !important
|
||||
.bg-genre-lv1-6
|
||||
background: rgb(255,240,224) !important
|
||||
.bg-genre-lv1-7
|
||||
background: rgb(255,224,255) !important
|
||||
.bg-genre-lv1-8
|
||||
background: rgb(255,255,224) !important
|
||||
.bg-genre-lv1-9
|
||||
background: rgb(255,240,224) !important
|
||||
.bg-genre-lv1-10
|
||||
background: rgb(224,240,255) !important
|
||||
.bg-genre-lv1-11
|
||||
background: rgb(224,240,255) !important
|
||||
.bg-genre-lv1-15
|
||||
background: rgb(240,240,240) !important
|
||||
|
||||
.bg-attribute-新
|
||||
background: colors.$forest3 !important
|
||||
.bg-attribute-再
|
||||
background: colors.$cerulean3 !important
|
||||
.bg-attribute-終
|
||||
background: colors.$vermilion3 !important
|
||||
.bg-attribute-生
|
||||
background: colors.$rose4 !important
|
||||
.bg-attribute-多
|
||||
background: colors.$rose3 !important
|
||||
.bg-attribute-解
|
||||
background: colors.$sepia4 !important
|
||||
.bg-attribute-初
|
||||
background: colors.$lime3 !important
|
||||
.bg-attribute-手
|
||||
background: colors.$sepia3 !important
|
||||
.bg-attribute-字
|
||||
background: colors.$gray3 !important
|
||||
.bg-attribute-デ,
|
||||
.bg-attribute-双
|
||||
background: colors.$violet3 !important
|
||||
.bg-attribute-二
|
||||
background: colors.$gold3 !important
|
||||
.bg-attribute-無
|
||||
background: colors.$turquoise3 !important
|
||||
|
||||
#root
|
||||
display: flex
|
||||
flex-direction: column
|
||||
width: 100vw
|
||||
height: 100vh
|
||||
overflow: hidden
|
||||
|
||||
#dev-header
|
||||
position: absolute
|
||||
top: 0
|
||||
left: 5px
|
||||
font-size: 10px
|
||||
z-index: 9999
|
||||
opacity: 0.5
|
||||
|
||||
#main
|
||||
flex-direction: column
|
||||
flex-grow: 1
|
||||
// overflow-y: auto
|
||||
position: relative
|
||||
|
||||
#page
|
||||
position: absolute
|
||||
top: 0
|
||||
right: 0
|
||||
bottom: 0
|
||||
left: 0
|
||||
background: colors.$light-gray4
|
||||
|
||||
.bp5-dark &
|
||||
background: colors.$dark-gray4
|
||||
|
||||
> .route
|
||||
position: absolute
|
||||
top: 0
|
||||
right: 0
|
||||
bottom: 0
|
||||
left: 0
|
||||
display: flex
|
||||
flex-direction: column
|
||||
flex-grow: 1
|
||||
|
||||
> .toolbar.bp5-navbar
|
||||
z-index: 9
|
||||
background: none
|
||||
box-shadow: none
|
||||
|
||||
.bp5-navbar-heading
|
||||
font-weight: 400
|
||||
font-size: 18px
|
||||
|
||||
font-feature-settings: "palt" 1
|
||||
|
||||
.component-program-title
|
||||
.name
|
||||
font-weight: inherit
|
||||
.attribute
|
||||
font-size: 12px
|
||||
vertical-align: 14%
|
||||
|
||||
.bp5-breadcrumb
|
||||
font-weight: inherit
|
||||
font-size: inherit
|
||||
|
||||
> .bp5-navbar-group
|
||||
&.bp5-align-right
|
||||
column-gap: 10px
|
||||
|
||||
.bp5-tab-list
|
||||
column-gap: 20px
|
||||
|
||||
> .bp5-tab
|
||||
&[aria-selected="true"]
|
||||
cursor: default
|
||||
|
||||
sup[class*="color-dow-"]
|
||||
font-weight: 600
|
||||
margin-left: 2px
|
||||
|
||||
> .content
|
||||
flex-grow: 1
|
||||
overflow: auto
|
||||
position: relative
|
||||
margin: 0
|
||||
padding: 10px 25px
|
||||
|
||||
&.no-margin
|
||||
padding: 0
|
||||
101
web/src/index.tsx
Normal file
101
web/src/index.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
Copyright 2026 kanreisa
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter, Routes, Route, NavLink, useNavigate, useLocation } from "react-router-dom";
|
||||
import { FocusStyleManager } from "@blueprintjs/core";
|
||||
FocusStyleManager.onlyShowFocusOnTabs();
|
||||
import { DateTime, Settings as LuxonSettings } from "luxon";
|
||||
LuxonSettings.defaultZone = "Asia/Tokyo";
|
||||
LuxonSettings.defaultLocale = "ja";
|
||||
|
||||
import { state } from "./modules/state";
|
||||
import * as ui from "./modules/ui";
|
||||
import * as at from "./modules/at";
|
||||
at.init(5000);
|
||||
|
||||
import { Nav } from "./components/Nav";
|
||||
import { EPGView } from "./routes/EPGView";
|
||||
import { ProgramView } from "./routes/ProgramView";
|
||||
import { SearchView } from "./routes/SearchView";
|
||||
import { JobsView } from "./routes/JobsView";
|
||||
import { LogsView } from "./routes/LogsView";
|
||||
import { ServerConfigView } from "./routes/ServerConfigView";
|
||||
import { TunersConfigView } from "./routes/TunersConfigView";
|
||||
import { ChannelsConfigView } from "./routes/ChannelsConfigView";
|
||||
import { HomeView } from "./routes/HomeView";
|
||||
import { AboutView } from "./routes/AboutView";
|
||||
|
||||
import "normalize.css";
|
||||
import "@blueprintjs/core/lib/css/blueprint.css";
|
||||
import "@blueprintjs/icons/lib/css/blueprint-icons.css";
|
||||
import "./index.sass";
|
||||
|
||||
const Index: React.FC = () => {
|
||||
console.debug("Index");
|
||||
|
||||
const navigate = state.navigate = useNavigate();
|
||||
const location = state.location = useLocation();
|
||||
const searchParams = state.searchParams = new URLSearchParams(location.search);
|
||||
const pathname = state.pathname = location.pathname;
|
||||
const pathLv1 = pathname.split("/")[1];
|
||||
|
||||
useEffect(() => {
|
||||
ui.blur();
|
||||
}, [location.pathname]);
|
||||
|
||||
return <>
|
||||
{state.isDev && /* 開発用 */ <>
|
||||
<div id="dev-header">
|
||||
[dev] {JSON.stringify({
|
||||
pathname,
|
||||
pathLv1,
|
||||
searchParams: searchParams.toString()
|
||||
})}
|
||||
</div>
|
||||
</>}
|
||||
|
||||
<Nav pathLv1={pathLv1} />
|
||||
|
||||
<div id="main">
|
||||
<div id="page">
|
||||
<Routes>
|
||||
<Route path="/*" element={<div>not found</div>} />
|
||||
|
||||
<Route path="/" element={<HomeView />} />
|
||||
|
||||
<Route path="epg" element={<EPGView />} />
|
||||
<Route path="epg/services/:globalServiceId" element={<EPGView />} />
|
||||
<Route path="epg/programs/:programId" element={<ProgramView />} />
|
||||
<Route path="epg/search" element={<SearchView />} />
|
||||
<Route path="jobs" element={<JobsView />} />
|
||||
<Route path="logs" element={<LogsView />} />
|
||||
<Route path="config/server" element={<ServerConfigView />} />
|
||||
<Route path="config/tuners" element={<TunersConfigView />} />
|
||||
<Route path="config/channels" element={<ChannelsConfigView />} />
|
||||
<Route path="about" element={<AboutView />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</div>
|
||||
</>;
|
||||
};
|
||||
|
||||
{
|
||||
const basename = state.isDev ? "/dev/" : "";
|
||||
const root = createRoot(document.getElementById("root"));
|
||||
root.render(<BrowserRouter basename={basename}><Index /></BrowserRouter>);
|
||||
}
|
||||
63
web/src/modules/at.ts
Normal file
63
web/src/modules/at.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
Copyright 2026 kanreisa
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
export type ScheduleId = string;
|
||||
export type ScheduleTask = () => void;
|
||||
|
||||
const map: Record<ScheduleId, [number, ScheduleTask]> = {};
|
||||
let count = 0;
|
||||
let intervalId: ReturnType<typeof setInterval>;
|
||||
|
||||
export function setSchedule(time: number, task: ScheduleTask): ScheduleId {
|
||||
const id = (++count).toString(10);
|
||||
map[id] = [time, task];
|
||||
|
||||
console.debug("at", "setSchedule()", id, map[id]);
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
export function clearSchedule(id: ScheduleId): void {
|
||||
console.debug("at", "clearSchedule()", id, map[id]);
|
||||
|
||||
delete map[id];
|
||||
}
|
||||
|
||||
export function init(interval = 1000) {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
intervalId = setInterval(() => run(), interval);
|
||||
}
|
||||
|
||||
export function deinit() {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
}
|
||||
|
||||
function run() {
|
||||
const now = Date.now();
|
||||
|
||||
for (const id in map) {
|
||||
const [time, task] = map[id];
|
||||
if (time <= now) {
|
||||
console.debug("at", "run()", id, map[id]);
|
||||
|
||||
delete map[id];
|
||||
task();
|
||||
}
|
||||
}
|
||||
}
|
||||
119
web/src/modules/common.ts
Normal file
119
web/src/modules/common.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
Copyright 2026 kanreisa
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import * as regexp from "./regexp";
|
||||
|
||||
export async function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
export function inRange<T = number>(value: T, min: T, max: T): boolean {
|
||||
return value >= min && value <= max;
|
||||
}
|
||||
|
||||
export function getGlobalServiceId(networkId: number, serviceId: number): number {
|
||||
return parseInt(networkId + (serviceId / 100000).toFixed(5).slice(2), 10);
|
||||
}
|
||||
|
||||
export function getIdWithHex(id: number): string {
|
||||
return `0x${id.toString(16).toUpperCase()} (${id})`;
|
||||
}
|
||||
|
||||
export function textMatch(text: string, queryNormalized: string): boolean {
|
||||
if (normalizeText(text).includes(queryNormalized)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function normalizeText(text: string): string {
|
||||
return katakanaToHiragana(squaredUnicodeToBrackets(text).normalize("NFKC")).toLowerCase();
|
||||
}
|
||||
|
||||
export function squaredUnicodeToBrackets(text: string): string {
|
||||
return text.replace(regexp.squaredUnicode, (s) => {
|
||||
return `[${s.normalize("NFKC")}]`;
|
||||
});
|
||||
}
|
||||
|
||||
export function katakanaToHiragana(text: string): string {
|
||||
return text.replace(regexp.katakana, (s) => {
|
||||
const code = s.charCodeAt(0) - 0x60;
|
||||
return String.fromCharCode(code);
|
||||
});
|
||||
}
|
||||
|
||||
export class LazyCaller<T extends Function> {
|
||||
caller: T;
|
||||
|
||||
private _delayTimeout: NodeJS.Timeout;
|
||||
private _activate: null | any[] = null; // args
|
||||
private _running = false;
|
||||
|
||||
/**
|
||||
* 遅延時間が経過するまでコールされなかった時に指定関数を実行
|
||||
* @param msDelay 最低遅延時間
|
||||
* @param msSleep 実行後待機時間
|
||||
* @param fn 実行関数 (Promise の場合は重複を避けて遅延実行する)
|
||||
*/
|
||||
constructor(public msDelay: number, public msSleep: number, public fn: T) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias, unicorn/no-this-assignment
|
||||
const _lazy = this;
|
||||
|
||||
this.caller = function lazyCaller(this: never, ...args: any[]) {
|
||||
if (_lazy._running) {
|
||||
_lazy._activate = args || [];
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(_lazy._delayTimeout);
|
||||
_lazy._delayTimeout = setTimeout(async () => {
|
||||
_lazy._activate = null;
|
||||
_lazy._running = true;
|
||||
|
||||
if (_lazy.fn) {
|
||||
await Reflect.apply(_lazy.fn, this, args);
|
||||
}
|
||||
|
||||
await sleep(_lazy.msSleep);
|
||||
|
||||
_lazy._running = false;
|
||||
if (_lazy._activate && _lazy.caller) {
|
||||
setTimeout(_lazy.caller.apply(this, _lazy._activate), 0);
|
||||
}
|
||||
}, _lazy.msDelay);
|
||||
} as any as T;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
clearTimeout(this._delayTimeout);
|
||||
this._activate = null;
|
||||
delete this.fn;
|
||||
delete this.caller;
|
||||
}
|
||||
}
|
||||
|
||||
export function copyToClipboard(text: string) {
|
||||
// secure context ではないため execCommand を使用する
|
||||
const input = document.createElement("input");
|
||||
input.setAttribute("readonly", "readonly");
|
||||
input.setAttribute("value", text);
|
||||
document.body.appendChild(input);
|
||||
input.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(input);
|
||||
}
|
||||
292
web/src/modules/constants.ts
Normal file
292
web/src/modules/constants.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
/*
|
||||
Copyright 2026 kanreisa
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import { IconName } from "@blueprintjs/core";
|
||||
|
||||
export const channelTypeMap = {
|
||||
GR: "地上",
|
||||
BS: "BS",
|
||||
CS: "CS",
|
||||
SKY: "SKY",
|
||||
};
|
||||
|
||||
export const relatedItemTypeMap = {
|
||||
shared: "イベント共有",
|
||||
relay: "イベントリレー",
|
||||
movement: "イベント移動"
|
||||
};
|
||||
|
||||
export const relatedItemTypeIconMap: Record<string, IconName> = {
|
||||
shared: "duplicate",
|
||||
relay: "one-to-one",
|
||||
movement: "flow-linear"
|
||||
};
|
||||
|
||||
export const langMap = {
|
||||
jpn: "日本語",
|
||||
eng: "英語",
|
||||
deu: "ドイツ語",
|
||||
fra: "フランス語",
|
||||
ita: "イタリア語",
|
||||
rus: "ロシア語",
|
||||
zho: "中国語",
|
||||
kor: "韓国語",
|
||||
spa: "スペイン語",
|
||||
etc: "その他",
|
||||
};
|
||||
|
||||
export const audioModeMap = {
|
||||
"00001": "モノラル",
|
||||
"00010": "デュアルモノ",
|
||||
"00011": "ステレオ",
|
||||
"00100": "2/1モード",
|
||||
"00101": "3/0モード",
|
||||
"00110": "2/2モード",
|
||||
"00111": "3/1モード",
|
||||
"01000": "3/2モード",
|
||||
"01001": "5.1ch",
|
||||
"01010": "3/3.1モード",
|
||||
"01011": "2/0/0-2/0/2-0.1モード",
|
||||
"01100": "5/2.1モード",
|
||||
"01101": "3/2/2.1モード",
|
||||
"01110": "2/0/0-3/0/2-0.1モード",
|
||||
"01111": "0/2/0-3/0/2-0.1モード",
|
||||
"10000": "2/0/0-3/2/3-0.2モード",
|
||||
"10001": "3/3/3-5/2/3-3/0/0.2モード",
|
||||
};
|
||||
|
||||
export const ProgramAttributeMap = {
|
||||
字: "字幕放送", // 字幕放送
|
||||
新: "新番組", // 新番組
|
||||
初: "初回放送", // 初回放送
|
||||
終: "最終回", // 最終回
|
||||
再: "再放送", // 再放送
|
||||
デ: "番組連動データ放送", // 番組連動データ放送
|
||||
双: "双方向放送", // 双方向放送
|
||||
無: "無料放送", // 無料放送
|
||||
二: "二ヶ国語放送", // 二ヶ国語放送
|
||||
多: "音声多重放送", // 音声多重放送
|
||||
SS: "サラウンドステレオ", // サラウンドステレオ
|
||||
生: "生放送", // 生放送
|
||||
前: "前編", // 前編
|
||||
後: "後編", // 後編
|
||||
解: "音声解説", // 音声解説
|
||||
PPV: "PPV", // PPV
|
||||
手: "手話通訳放送", // 手話通訳放送
|
||||
};
|
||||
|
||||
export const Genre1Map = {
|
||||
0x0: "ニュース/報道",
|
||||
0x1: "スポーツ",
|
||||
0x2: "情報/ワイドショー",
|
||||
0x3: "ドラマ",
|
||||
0x4: "音楽",
|
||||
0x5: "バラエティ",
|
||||
0x6: "映画",
|
||||
0x7: "アニメ/特撮",
|
||||
0x8: "ドキュメンタリー/教養",
|
||||
0x9: "劇場/公演",
|
||||
0xA: "趣味/教育",
|
||||
0xB: "福祉",
|
||||
0xC: "予備",
|
||||
0xD: "予備",
|
||||
0xE: "拡張",
|
||||
0xF: "その他",
|
||||
};
|
||||
|
||||
export const Genre2Map = {
|
||||
0x00: "定時・総合",
|
||||
0x01: "天気",
|
||||
0x02: "特集・ドキュメント",
|
||||
0x03: "政治・国会",
|
||||
0x04: "経済・市況",
|
||||
0x05: "海外・国際",
|
||||
0x06: "解説",
|
||||
0x07: "討論・会談",
|
||||
0x08: "報道特番",
|
||||
0x09: "ローカル・地域",
|
||||
0x0A: "交通",
|
||||
0x0F: "その他",
|
||||
|
||||
0x10: "スポーツニュース",
|
||||
0x11: "野球",
|
||||
0x12: "サッカー",
|
||||
0x13: "ゴルフ",
|
||||
0x14: "その他の球技",
|
||||
0x15: "相撲・格闘技",
|
||||
0x16: "オリンピック・国際大会",
|
||||
0x17: "マラソン・陸上・水泳",
|
||||
0x18: "モータースポーツ",
|
||||
0x19: "マリン・ウィンタースポーツ",
|
||||
0x1A: "競馬・公営競技",
|
||||
0x1F: "その他",
|
||||
|
||||
0x20: "芸能・ワイドショー",
|
||||
0x21: "ファッション",
|
||||
0x22: "暮らし・住まい",
|
||||
0x23: "健康・医療",
|
||||
0x24: "ショッピング・通販",
|
||||
0x25: "グルメ・料理",
|
||||
0x26: "イベント",
|
||||
0x27: "番組紹介・お知らせ",
|
||||
0x2F: "その他",
|
||||
|
||||
0x30: "国内ドラマ",
|
||||
0x31: "海外ドラマ",
|
||||
0x32: "時代劇",
|
||||
0x3F: "その他",
|
||||
|
||||
0x40: "国内ロック・ポップス",
|
||||
0x41: "海外ロック・ポップス",
|
||||
0x42: "クラシック・オペラ",
|
||||
0x43: "ジャズ・フュージョン",
|
||||
0x44: "歌謡曲・演歌",
|
||||
0x45: "ライブ・コンサート",
|
||||
0x46: "ランキング・リクエスト",
|
||||
0x47: "カラオケ・のど自慢",
|
||||
0x48: "民謡・邦楽",
|
||||
0x49: "童謡・キッズ",
|
||||
0x4A: "民族音楽・ワールドミュージック",
|
||||
0x4F: "その他",
|
||||
|
||||
0x50: "クイズ",
|
||||
0x51: "ゲーム",
|
||||
0x52: "トークバラエティ",
|
||||
0x53: "お笑い・コメディ",
|
||||
0x54: "音楽バラエティ",
|
||||
0x55: "旅バラエティ",
|
||||
0x56: "料理バラエティ",
|
||||
0x5F: "その他",
|
||||
|
||||
0x60: "洋画",
|
||||
0x61: "邦画",
|
||||
0x62: "アニメ",
|
||||
0x6F: "その他",
|
||||
|
||||
0x70: "国内アニメ",
|
||||
0x71: "海外アニメ",
|
||||
0x72: "特撮",
|
||||
0x7F: "その他",
|
||||
|
||||
0x80: "社会・時事",
|
||||
0x81: "歴史・紀行",
|
||||
0x82: "自然・動物・環境",
|
||||
0x83: "宇宙・科学・医学",
|
||||
0x84: "カルチャー・伝統文化",
|
||||
0x85: "文学・文芸",
|
||||
0x86: "スポーツ",
|
||||
0x87: "ドキュメンタリー全般",
|
||||
0x88: "インタビュー・討論",
|
||||
0x8F: "その他",
|
||||
|
||||
0x90: "現代劇・新劇",
|
||||
0x91: "ミュージカル",
|
||||
0x92: "ダンス・バレエ",
|
||||
0x93: "落語・演芸",
|
||||
0x94: "歌舞伎・古典",
|
||||
0x9F: "その他",
|
||||
|
||||
0xA0: "旅・釣り・アウトドア",
|
||||
0xA1: "園芸・ペット・手芸",
|
||||
0xA2: "音楽・美術・工芸",
|
||||
0xA3: "囲碁・将棋",
|
||||
0xA4: "麻雀・パチンコ",
|
||||
0xA5: "車・オートバイ",
|
||||
0xA6: "コンピュータ・TVゲーム",
|
||||
0xA7: "会話・語学",
|
||||
0xA8: "幼児・小学生",
|
||||
0xA9: "中学生・高校生",
|
||||
0xAA: "大学生・受験",
|
||||
0xAB: "生涯教育・資格",
|
||||
0xAC: "教育問題",
|
||||
0xAF: "その他",
|
||||
|
||||
0xB0: "高齢者",
|
||||
0xB1: "障害者",
|
||||
0xB2: "社会福祉",
|
||||
0xB3: "ボランティア",
|
||||
0xB4: "手話",
|
||||
0xB5: "文字(字幕)",
|
||||
0xB6: "音声解説",
|
||||
0xBF: "その他",
|
||||
|
||||
0xC0: "予備",
|
||||
0xD0: "予備",
|
||||
|
||||
0xE0: "BS/地上デジタル放送用番組付属情報",
|
||||
0xE1: "広帯域CSデジタル放送用拡張",
|
||||
0xE2: "衛星デジタル音声放送用拡張",
|
||||
0xE3: "サーバー型番組付属情報",
|
||||
0xE4: "IP放送用番組付属情報",
|
||||
0xF0: "その他",
|
||||
0xFF: "その他",
|
||||
};
|
||||
|
||||
export const GenreUN1Map = {
|
||||
0xE10: "スポーツ",
|
||||
0xE11: "洋画",
|
||||
0xE12: "邦画",
|
||||
};
|
||||
|
||||
export const GenreUN2Map = {
|
||||
0xE000: "中止の可能性あり",
|
||||
0xE001: "延長の可能性あり",
|
||||
0xE002: "中断の可能性あり",
|
||||
0xE003: "同一シリーズの別話数放送の可能性あり",
|
||||
0xE004: "編成未定枠",
|
||||
0xE005: "繰り上げの可能性あり",
|
||||
|
||||
0xE010: "中断ニュースあり",
|
||||
0xE011: "当該イベントに関連する臨時サービスあり",
|
||||
0xE020: "当該イベント中に3D映像あり",
|
||||
|
||||
0xE100: "テニス",
|
||||
0xE101: "バスケットボール",
|
||||
0xE102: "ラグビー",
|
||||
0xE103: "アメリカンフットボール",
|
||||
0xE104: "ボクシング",
|
||||
0xE105: "プロレス",
|
||||
0xE10F: "その他",
|
||||
|
||||
0xE110: "アクション",
|
||||
0xE111: "SF/ファンタジー",
|
||||
0xE112: "コメディー",
|
||||
0xE113: "サスペンス/ミステリー",
|
||||
0xE114: "恋愛/ロマンス",
|
||||
0xE115: "ホラー/スリラー",
|
||||
0xE116: "ウエスタン",
|
||||
0xE117: "ドラマ/社会派ドラマ",
|
||||
0xE118: "アニメーション",
|
||||
0xE119: "ドキュメンタリー",
|
||||
0xE11A: "アドベンチャー/冒険",
|
||||
0xE11B: "ミュージカル/音楽映画",
|
||||
0xE11C: "ホームドラマ",
|
||||
0xE11F: "その他",
|
||||
|
||||
0xE120: "アクション",
|
||||
0xE121: "SF/ファンタジー",
|
||||
0xE122: "お笑い/コメディー",
|
||||
0xE123: "サスペンス/ミステリー",
|
||||
0xE124: "恋愛/ロマンス",
|
||||
0xE125: "ホラー/スリラー",
|
||||
0xE126: "青春/学園/アイドル",
|
||||
0xE127: "任侠/時代劇",
|
||||
0xE128: "アニメーション",
|
||||
0xE129: "ドキュメンタリー",
|
||||
0xE12A: "アドベンチャー/冒険",
|
||||
0xE12B: "ミュージカル/音楽映画",
|
||||
0xE12C: "ホームドラマ",
|
||||
0xE12F: "その他",
|
||||
};
|
||||
35
web/src/modules/regexp.ts
Normal file
35
web/src/modules/regexp.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
Copyright 2026 kanreisa
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
/** カタカナ */
|
||||
export const katakana = /[\u30A1-\u30F6]/ug;
|
||||
|
||||
/** Unicode 囲み文字 (四角形) */
|
||||
export const squaredUnicode = /[\u{1F130}-\u{1F14E}\u{1F201}-\u{1F23B}]/ug;
|
||||
|
||||
/** Unicode 囲み文字 (属性用) */
|
||||
export const enclosedAttributeUnicode = /[\u{1F14D}-\u{1F14E}\u{1F210}-\u{1F222}]/ug;
|
||||
|
||||
/** レガシー属性フォーマット */
|
||||
export const legacyAttributeFormat = /(?:[\[[][新生無][\]]]|\([二字]\)|[\[【]無料[\]】])/g;
|
||||
|
||||
/** EPG オートリンク用 */
|
||||
export const epgHTTPLinkFormat = /(?:(https?:\/\/[\x21-\x7e]+)|(https?://[\uFF01-\uFF5E]+)|(www\.[\x21-\x7e]+))/gi;
|
||||
|
||||
/** EPG オートリンク用 */
|
||||
export const epgXLinkFormat = /(?:Twitter|Twitter|X|X)[\s\S]{0,14}([@@][\da-z_]+)/gi
|
||||
|
||||
/** EPG オートリンク用 */
|
||||
export const epgInstagramLinkFormat = /(?:Instagram|Instagram|インスタグラム)[\s\S]{0,14}([@@][\da-z_]+)/gi
|
||||
427
web/src/modules/state.ts
Normal file
427
web/src/modules/state.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
/*
|
||||
Copyright 2026 kanreisa
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import { EventEmitter } from "eventemitter3";
|
||||
import { DateTime } from "luxon";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { Client as RPCClient } from "jsonrpc2-ws";
|
||||
import { setSchedule } from "./at";
|
||||
import * as ui from "./ui";
|
||||
import { Event, Service, Program, TunerDevice, Status, JobItem, JobScheduleItem, ConfigServer } from "../../../api.d";
|
||||
import { JoinParams, NotifyParams } from "../../types/rpc";
|
||||
|
||||
type StatusIconKey = "normal" | "offline" | "active";
|
||||
|
||||
type StateEventTypes = {
|
||||
"todayTime": [number];
|
||||
"statusName": [string];
|
||||
"statusIconKey": [StatusIconKey];
|
||||
"version": [string];
|
||||
"status": [Status];
|
||||
"services": [Service[]];
|
||||
"tuners": [TunerDevice[]];
|
||||
"jobs": [JobItem[]];
|
||||
"jobSchedules": [JobScheduleItem[]];
|
||||
"programs": [Program[]];
|
||||
"logs": [string[], boolean];
|
||||
};
|
||||
|
||||
import normalIcon from "../icon.svg";
|
||||
import offlineIcon from "../icon-gray.svg";
|
||||
import activeIcon from "../icon-active.svg";
|
||||
const iconSrcMap = {
|
||||
normal: normalIcon,
|
||||
offline: offlineIcon,
|
||||
active: activeIcon
|
||||
};
|
||||
|
||||
const jobStatusOrderMap = {
|
||||
queued: 0,
|
||||
standby: 1,
|
||||
running: 2,
|
||||
finished: 3
|
||||
};
|
||||
|
||||
class State extends EventEmitter<StateEventTypes> {
|
||||
isDev: boolean = /^\/dev\/.*$/.test(location.pathname);
|
||||
|
||||
navigate?: ReturnType<typeof useNavigate>;
|
||||
location?: ReturnType<typeof useLocation>;
|
||||
pathname?: string;
|
||||
searchParams?: URLSearchParams;
|
||||
|
||||
todayTime?: number;
|
||||
|
||||
version = "..";
|
||||
statusName = "Loading";
|
||||
statusIconKey: StatusIconKey = "offline";
|
||||
|
||||
status?: Status;
|
||||
services: Service[] = [];
|
||||
tuners: TunerDevice[] = [];
|
||||
jobs: JobItem[] = [];
|
||||
jobSchedules: JobScheduleItem[] = [];
|
||||
programs: Program[] = [];
|
||||
serverConfig?: ConfigServer;
|
||||
|
||||
private _rpc?: RPCClient;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
if (!this.isDev) {
|
||||
const emptyFunction = function () {};
|
||||
if (console?.debug) {
|
||||
console.debug = emptyFunction;
|
||||
}
|
||||
if (console?.log) {
|
||||
console.log = emptyFunction;
|
||||
}
|
||||
}
|
||||
|
||||
this._setTodayTime();
|
||||
this._initRPC();
|
||||
|
||||
this.on("tuners", () => this._updateIdleStatus());
|
||||
this.on("statusIconKey", key => ui.setFavicon(iconSrcMap[key]));
|
||||
}
|
||||
|
||||
get statusIconSrc() {
|
||||
return iconSrcMap[this.statusIconKey];
|
||||
}
|
||||
|
||||
async fetchStatus(): Promise<Status> {
|
||||
this.status = await this._rpc.call("getStatus");
|
||||
this.emit("status", this.status);
|
||||
|
||||
// version
|
||||
if (this.version !== ".." && this.version !== this.status.version) {
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
this.version = this.status.version;
|
||||
this.emit("version", this.version);
|
||||
|
||||
return this.status;
|
||||
}
|
||||
|
||||
async fetchServices(): Promise<Service[]> {
|
||||
this.services = await this._rpc.call("getServices");
|
||||
if (this.services.length > 0) {
|
||||
this.emit("services", this.services);
|
||||
}
|
||||
return this.services;
|
||||
}
|
||||
|
||||
async fetchTuners(): Promise<TunerDevice[]> {
|
||||
this.tuners.splice(0, this.tuners.length, ...await this._rpc.call("getTuners"));
|
||||
if (this.tuners.length > 0) {
|
||||
this.emit("tuners", this.tuners);
|
||||
}
|
||||
return this.tuners;
|
||||
}
|
||||
|
||||
async fetchJobs(): Promise<JobItem[]> {
|
||||
this.jobs.splice(0, this.jobs.length, ...await this._rpc.call("getJobs"));
|
||||
if (this.jobs.length > 0) {
|
||||
this._handleJobs();
|
||||
this.emit("jobs", this.jobs);
|
||||
}
|
||||
return this.jobs;
|
||||
}
|
||||
|
||||
async fetchJobSchedules(): Promise<JobScheduleItem[]> {
|
||||
this.jobSchedules = await this._rpc.call("getJobSchedules");
|
||||
if (this.jobSchedules.length > 0) {
|
||||
this.emit("jobSchedules", this.jobSchedules);
|
||||
}
|
||||
return this.jobSchedules;
|
||||
}
|
||||
|
||||
async fetchPrograms(): Promise<Program[]> {
|
||||
this.programs = await (await fetch("/api/programs")).json();
|
||||
if (this.programs.length > 0) {
|
||||
this.emit("programs", this.programs);
|
||||
}
|
||||
return this.programs;
|
||||
}
|
||||
|
||||
private _joinProgramEvents: () => void;
|
||||
async subscribePrograms(forceEmit = false): Promise<void> {
|
||||
if (this._joinProgramEvents) {
|
||||
if (forceEmit) {
|
||||
this.emit("programs", this.programs);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this._joinProgramEvents = () => {
|
||||
this.fetchPrograms();
|
||||
this._rpc.call("join", {
|
||||
rooms: ["events:program"],
|
||||
} as JoinParams);
|
||||
};
|
||||
|
||||
this._rpc.on("connected", this._joinProgramEvents);
|
||||
|
||||
if (this._rpc.isConnected()) {
|
||||
this._joinProgramEvents();
|
||||
}
|
||||
}
|
||||
|
||||
async unsubscribePrograms(): Promise<void> {
|
||||
if (this._rpc.isConnected()) {
|
||||
this._rpc.call("leave", {
|
||||
rooms: ["events:program"],
|
||||
} as JoinParams);
|
||||
}
|
||||
|
||||
if (this._joinProgramEvents) {
|
||||
this._rpc.off("connected", this._joinProgramEvents);
|
||||
this._joinProgramEvents = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchServerConfig(): Promise<ConfigServer> {
|
||||
this.serverConfig = await (await fetch("/api/config/server")).json();
|
||||
return this.serverConfig;
|
||||
}
|
||||
|
||||
private _setTodayTime() {
|
||||
console.debug("state", "setTodayTime()");
|
||||
|
||||
const init = !this.todayTime;
|
||||
|
||||
this.todayTime = DateTime.now().startOf("day").toMillis();
|
||||
|
||||
if (!init) {
|
||||
this.emit("todayTime", this.todayTime);
|
||||
}
|
||||
|
||||
// update daily
|
||||
setSchedule(this.todayTime + 86400000, () => this._setTodayTime());
|
||||
}
|
||||
|
||||
private _initRPC() {
|
||||
const rpc = this._rpc = new RPCClient(`${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/rpc`, {
|
||||
protocols: null,
|
||||
bufferSendingMessages: false
|
||||
});
|
||||
|
||||
rpc.on("connecting", () => {
|
||||
console.debug("rpc:connecting");
|
||||
|
||||
this.statusName = "Connecting";
|
||||
this.statusIconKey = "offline";
|
||||
this.emit("statusName", this.statusName);
|
||||
this.emit("statusIconKey", this.statusIconKey);
|
||||
});
|
||||
|
||||
let _statusRefreshInterval: NodeJS.Timeout | undefined;
|
||||
let _servicesRefreshInterval: NodeJS.Timeout | undefined;
|
||||
rpc.on("connected", async () => {
|
||||
console.debug("rpc:connected");
|
||||
|
||||
this.programs = [];
|
||||
|
||||
this.statusName = "Connected";
|
||||
this.statusIconKey = "normal";
|
||||
this.emit("statusName", this.statusName);
|
||||
this.emit("statusIconKey", this.statusIconKey);
|
||||
|
||||
await rpc.call("join", {
|
||||
rooms: [
|
||||
"events:service",
|
||||
"events:tuner",
|
||||
"events:job",
|
||||
"events:job_schedule"
|
||||
],
|
||||
} as JoinParams);
|
||||
|
||||
await this.fetchStatus();
|
||||
await this.fetchServices();
|
||||
await this.fetchTuners();
|
||||
await this.fetchJobs();
|
||||
await this.fetchJobSchedules();
|
||||
|
||||
// periodic refresh (every 5s)
|
||||
_statusRefreshInterval = setInterval(async () => {
|
||||
if (document.hidden) { return; }
|
||||
await this.fetchStatus();
|
||||
}, 1000 * 5);
|
||||
|
||||
// periodic refresh (every 60s, for data not covered by push events like logo)
|
||||
_servicesRefreshInterval = setInterval(async () => {
|
||||
if (document.hidden) { return; }
|
||||
await this.fetchServices();
|
||||
}, 1000 * 60);
|
||||
});
|
||||
|
||||
rpc.on("disconnect", () => {
|
||||
console.debug("rpc:disconnected");
|
||||
|
||||
if (_statusRefreshInterval) {
|
||||
clearInterval(_statusRefreshInterval);
|
||||
_statusRefreshInterval = undefined;
|
||||
}
|
||||
if (_servicesRefreshInterval) {
|
||||
clearInterval(_servicesRefreshInterval);
|
||||
_servicesRefreshInterval = undefined;
|
||||
}
|
||||
|
||||
this.statusName = "Disconnected";
|
||||
this.statusIconKey = "offline";
|
||||
this.emit("statusName", this.statusName);
|
||||
this.emit("statusIconKey", this.statusIconKey);
|
||||
});
|
||||
|
||||
rpc.methods.set("events", async (socket, { array }: NotifyParams<Event>) => {
|
||||
let programsUpdated = false;
|
||||
let servicesUpdated = false;
|
||||
let tunersUpdated = false;
|
||||
let jobsUpdated = false;
|
||||
let jobSchedulesUpdated = false;
|
||||
|
||||
for (const event of array) {
|
||||
switch (event.resource) {
|
||||
case "program": {
|
||||
const program = event.data as Program;
|
||||
const index = this.programs.findIndex(p => p.id === program.id);
|
||||
if (event.type === "remove") {
|
||||
if (index !== -1) {
|
||||
this.programs.splice(index, 1);
|
||||
}
|
||||
} else {
|
||||
if (index === -1) {
|
||||
this.programs.push(program);
|
||||
} else {
|
||||
this.programs.splice(index, 1, program);
|
||||
}
|
||||
}
|
||||
programsUpdated = true;
|
||||
break;
|
||||
}
|
||||
case "service": {
|
||||
const service = event.data as Service;
|
||||
const index = this.services.findIndex(s => s.id === service.id);
|
||||
if (index === -1) {
|
||||
this.services.push(service);
|
||||
} else {
|
||||
this.services[index] = {
|
||||
...this.services[index],
|
||||
...service
|
||||
};
|
||||
}
|
||||
servicesUpdated = true;
|
||||
break;
|
||||
}
|
||||
case "tuner": {
|
||||
const tuner = event.data as TunerDevice;
|
||||
this.tuners[this.tuners.findIndex(value => value.index === tuner.index)] = tuner;
|
||||
tunersUpdated = true;
|
||||
break;
|
||||
}
|
||||
case "job": {
|
||||
const job = event.data as JobItem;
|
||||
const index = this.jobs.findIndex(j => j.id === job.id);
|
||||
if (index === -1) {
|
||||
this.jobs.unshift(job);
|
||||
} else {
|
||||
this.jobs.splice(index, 1, job);
|
||||
}
|
||||
jobsUpdated = true;
|
||||
break;
|
||||
}
|
||||
case "job_schedule": {
|
||||
const jobSchedule = event.data as JobScheduleItem;
|
||||
const index = this.jobSchedules.findIndex(j => j.key === jobSchedule.key);
|
||||
if (index === -1) {
|
||||
this.jobSchedules.push(jobSchedule);
|
||||
} else {
|
||||
this.jobSchedules.splice(index, 1, jobSchedule);
|
||||
}
|
||||
jobSchedulesUpdated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (programsUpdated) {
|
||||
this.emit("programs", this.programs);
|
||||
}
|
||||
if (servicesUpdated) {
|
||||
this.emit("services", this.services);
|
||||
}
|
||||
if (tunersUpdated) {
|
||||
this.emit("tuners", this.tuners);
|
||||
}
|
||||
if (jobsUpdated) {
|
||||
this._handleJobs();
|
||||
this.emit("jobs", this.jobs);
|
||||
}
|
||||
if (jobSchedulesUpdated) {
|
||||
this.emit("jobSchedules", this.jobSchedules);
|
||||
}
|
||||
});
|
||||
|
||||
// ログイベントの処理
|
||||
rpc.methods.set("logs", async (socket, { array }: NotifyParams<string>) => {
|
||||
// 配列から文字列を抽出し、unshift=false(末尾に追加)でイベントを発行
|
||||
this.emit("logs", array, false);
|
||||
});
|
||||
}
|
||||
|
||||
private _handleJobs() {
|
||||
this.jobs.sort((a, b) => {
|
||||
if (a.status === b.status) {
|
||||
if (a.finishedAt && b.finishedAt) {
|
||||
return b.finishedAt - a.finishedAt;
|
||||
}
|
||||
if (a.startedAt && b.startedAt) {
|
||||
return b.startedAt - a.startedAt;
|
||||
}
|
||||
if (a.createdAt && b.createdAt) {
|
||||
return b.createdAt - a.createdAt;
|
||||
}
|
||||
return b.id.localeCompare(a.id);
|
||||
}
|
||||
return jobStatusOrderMap[a.status] - jobStatusOrderMap[b.status];
|
||||
});
|
||||
|
||||
// drop old jobs
|
||||
if (this.jobs.length > 200) {
|
||||
this.jobs.splice(200, this.jobs.length - 200);
|
||||
}
|
||||
}
|
||||
|
||||
private _updateIdleStatus() {
|
||||
let statusName = "Standby";
|
||||
let statusIconKey: StatusIconKey = "normal";
|
||||
|
||||
const isActive = this.tuners.some(tuner => tuner.isUsing === true && tuner.users.some(user => user.priority !== -1));
|
||||
if (isActive) {
|
||||
statusName = "Active";
|
||||
statusIconKey = "active";
|
||||
}
|
||||
|
||||
if (this.statusName !== statusName) {
|
||||
this.statusName = statusName;
|
||||
this.statusIconKey = statusIconKey;
|
||||
this.emit("statusName", this.statusName);
|
||||
this.emit("statusIconKey", this.statusIconKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const state = new State();
|
||||
80
web/src/modules/ui.ts
Normal file
80
web/src/modules/ui.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
Copyright 2026 kanreisa
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import { LazyCaller } from "./common";
|
||||
import * as regexp from "./regexp";
|
||||
|
||||
export function blur(): void {
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}
|
||||
|
||||
export const setTitle = (() => {
|
||||
const lazy = new LazyCaller(100, 0, _setTitle);
|
||||
return lazy.caller.bind(this) as typeof _setTitle;
|
||||
})();
|
||||
|
||||
export function _setTitle(title: string, loading?: boolean): void {
|
||||
|
||||
if (!title) {
|
||||
return;
|
||||
}
|
||||
|
||||
title = title.replace(regexp.squaredUnicode, "").replace(regexp.legacyAttributeFormat, "");
|
||||
|
||||
if (!title) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!loading) {
|
||||
const elements = document.querySelectorAll(".heading-title");
|
||||
elements.forEach(element => {
|
||||
element.classList.remove("bp5-skeleton");
|
||||
element.textContent = title;
|
||||
});
|
||||
}
|
||||
|
||||
document.title = `${title.normalize("NFKC")} | Mirakurun`;
|
||||
}
|
||||
|
||||
let _faviconElement: HTMLLinkElement;
|
||||
export function setFavicon(src: string) {
|
||||
if (!_faviconElement) {
|
||||
_faviconElement = document.querySelector("link[rel*='icon']") as HTMLLinkElement;
|
||||
}
|
||||
if (_faviconElement) {
|
||||
_faviconElement.href = src;
|
||||
}
|
||||
}
|
||||
|
||||
export function autoLink(text: string): string {
|
||||
return text
|
||||
.replace(regexp.epgHTTPLinkFormat, text => {
|
||||
let url = text.normalize("NFKC");
|
||||
if (!/^http/.test(url)) {
|
||||
url = `https://${url}`;
|
||||
}
|
||||
return `<a referrerpolicy="no-referrer" target="_blank" href="${url}" title="外部サイト">${text}</a>`;
|
||||
})
|
||||
.replace(regexp.epgXLinkFormat, (text, username) => {
|
||||
return text.replace(username,
|
||||
`<a referrerpolicy="no-referrer" target="_blank" href="https://x.com/${username.slice(1)}" title="X">${username}</a>`
|
||||
);
|
||||
})
|
||||
.replace(regexp.epgInstagramLinkFormat, (text, username) => {
|
||||
return text.replace(username,
|
||||
`<a referrerpolicy="no-referrer" target="_blank" href="https://instagram.com/${username.slice(1)}" title="Instagram">${username}</a>`
|
||||
);
|
||||
});
|
||||
}
|
||||
37
web/src/redoc-ui.html
Normal file
37
web/src/redoc-ui.html
Normal file
@@ -0,0 +1,37 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0">
|
||||
<title>Mirakurun API Documentation</title>
|
||||
<link id="icon" rel="icon" type="image/svg+xml" href="/icon.svg">
|
||||
<script src="/redoc/redoc.standalone.js#/redoc@999.9.9+dummy-for-redoc-try/"></script>
|
||||
<script src="/redoc-try/try.js"></script>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="redoc-container"></div>
|
||||
<script>
|
||||
initTry({
|
||||
openApi: "/api/docs",
|
||||
redocOptions: {
|
||||
scrollYOffset: 0,
|
||||
hideDownloadButton: true,
|
||||
theme: {
|
||||
colors: {
|
||||
primary: {
|
||||
main: "#1976d2"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
108
web/src/routes/AboutView.sass
Normal file
108
web/src/routes/AboutView.sass
Normal file
@@ -0,0 +1,108 @@
|
||||
@use "~@blueprintjs/colors/lib/scss/colors"
|
||||
|
||||
#route-about-view
|
||||
.about-container
|
||||
max-width: 922px // opencollective img width is 890 + card padding
|
||||
margin: 0 auto
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 16px
|
||||
padding-bottom: 24px
|
||||
|
||||
.about-card, .heart-card
|
||||
padding: 24px
|
||||
|
||||
.about-header
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 16px
|
||||
margin-bottom: 16px
|
||||
|
||||
.product-icon
|
||||
width: 48px
|
||||
height: 48px
|
||||
|
||||
h3
|
||||
margin: 0
|
||||
|
||||
.about-info
|
||||
margin-top: 16px
|
||||
margin-bottom: 16px
|
||||
|
||||
.info-table
|
||||
width: 100%
|
||||
td:first-child
|
||||
width: 150px
|
||||
font-weight: bold
|
||||
|
||||
.warranty-warning
|
||||
background: colors.$light-gray5
|
||||
border-left: 4px solid colors.$red3
|
||||
padding: 12px 16px
|
||||
margin-bottom: 16px
|
||||
border-radius: 0 4px 4px 0
|
||||
|
||||
.warranty-text
|
||||
font-weight: bold
|
||||
color: colors.$red2
|
||||
|
||||
.links
|
||||
display: flex
|
||||
gap: 8px
|
||||
|
||||
.consent
|
||||
margin-top: 16px
|
||||
padding: 16px
|
||||
background: colors.$light-gray5
|
||||
border-radius: 4px
|
||||
p
|
||||
margin-bottom: 12px
|
||||
|
||||
.contributors-list
|
||||
margin-top: 16px
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 24px
|
||||
|
||||
.section
|
||||
h5
|
||||
margin-bottom: 8px
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 8px
|
||||
|
||||
.text-small
|
||||
font-size: 0.85em
|
||||
|
||||
.image-container
|
||||
margin-top: 12px
|
||||
overflow-x: auto
|
||||
|
||||
.opencollective-img
|
||||
max-width: 100%
|
||||
height: auto
|
||||
display: block
|
||||
|
||||
.sponsors-avatars
|
||||
margin-top: 12px
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
gap: 8px
|
||||
|
||||
.sponsor-avatar
|
||||
padding: 4px
|
||||
transition: transform 0.2s, box-shadow 0.2s
|
||||
|
||||
&:hover
|
||||
transform: scale(1.05)
|
||||
|
||||
body.bp5-dark
|
||||
#route-about-view
|
||||
.warranty-warning
|
||||
background: colors.$dark-gray4
|
||||
.warranty-text
|
||||
color: colors.$red4
|
||||
|
||||
.consent
|
||||
background: colors.$dark-gray4
|
||||
|
||||
188
web/src/routes/AboutView.tsx
Normal file
188
web/src/routes/AboutView.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
Copyright 2026 kanreisa
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Alignment, Breadcrumbs, Button, Card, Divider, Elevation, H3, H5, Navbar, Text } from "@blueprintjs/core";
|
||||
import { state } from "../modules/state";
|
||||
import * as ui from "../modules/ui";
|
||||
import { VersionStatus } from "../components/VersionStatus";
|
||||
|
||||
import "./AboutView.sass";
|
||||
|
||||
export const AboutView: React.FC = () => {
|
||||
console.debug("routes", "AboutView");
|
||||
|
||||
ui.setTitle("Mirakurun について");
|
||||
|
||||
const [version, setVersion] = useState<string>(state.version);
|
||||
useEffect(() => {
|
||||
const onVersion = () => {
|
||||
setVersion(state.version);
|
||||
};
|
||||
state.on("version", onVersion);
|
||||
return () => {
|
||||
state.off("version", onVersion);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [consented, setConsented] = useState<boolean>(false);
|
||||
|
||||
const toolbar = (
|
||||
<Navbar className="toolbar">
|
||||
<Navbar.Group align={Alignment.START}>
|
||||
<Navbar.Heading>
|
||||
<Breadcrumbs items={[
|
||||
{
|
||||
text: "Mirakurun について"
|
||||
}
|
||||
]} />
|
||||
</Navbar.Heading>
|
||||
</Navbar.Group>
|
||||
</Navbar>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="route" id="route-about-view">
|
||||
{toolbar}
|
||||
|
||||
<div className="content">
|
||||
<div className="about-container">
|
||||
<Card elevation={Elevation.ONE} className="about-card">
|
||||
<div className="about-header">
|
||||
<img className="product-icon" src={state.statusIconSrc} alt={state.statusName} />
|
||||
<H3>Mirakurun</H3>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div className="about-info">
|
||||
<table className="bp5-html-table bp5-html-table-striped bp5-html-table-condensed info-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Current</td>
|
||||
<td>{version}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Latest</td>
|
||||
<td><VersionStatus /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>License</td>
|
||||
<td>Apache License 2.0</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Copyright</td>
|
||||
<td>Copyright © 2016-2026 <a href="https://github.com/kanreisa" target="_blank" rel="noreferrer">kanreisa</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="warranty-warning">
|
||||
<Text className="warranty-text">
|
||||
Mirakurun comes with ABSOLUTELY NO WARRANTY. USE AT YOUR OWN RISK.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="links">
|
||||
<Button
|
||||
icon="git-branch"
|
||||
text="GitHub Repository"
|
||||
onClick={() => window.open("https://github.com/Chinachu/Mirakurun", "_blank")}
|
||||
variant="minimal"
|
||||
/>
|
||||
<Button
|
||||
icon="globe"
|
||||
text="Chinachu Project"
|
||||
onClick={() => window.open("https://chinachu.moe/", "_blank")}
|
||||
variant="minimal"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card elevation={Elevation.ONE} className="heart-card">
|
||||
<H5>Special Thanks</H5>
|
||||
{consented === false ? (
|
||||
<div className="consent">
|
||||
<p>We sincerely thank you for your continued support.</p>
|
||||
<p>
|
||||
This page is attempting to retrieve images from your browser by going directly to{" "}
|
||||
<a href="https://opencollective.com/" target="_blank" rel="noreferrer">
|
||||
opencollective.com
|
||||
</a>{" "}
|
||||
in order to display a list of contributors.
|
||||
</p>
|
||||
<Button
|
||||
intent="primary"
|
||||
text="Continue"
|
||||
onClick={() => setConsented(true)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="contributors-list">
|
||||
<div className="section">
|
||||
<H5>Contributors</H5>
|
||||
<p>This project exists thanks to all the people who contribute.</p>
|
||||
<div className="image-container">
|
||||
<a href="https://github.com/Chinachu/Mirakurun/graphs/contributors" target="_blank" rel="noreferrer">
|
||||
<img src="https://opencollective.com/Mirakurun/contributors.svg?width=890&button=false" alt="Contributors" className="opencollective-img" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div className="section">
|
||||
<H5>
|
||||
Backers{" "}
|
||||
<span className="bp5-text-muted text-small">
|
||||
[<a href="https://opencollective.com/Mirakurun#backer" target="_blank" rel="noreferrer">Become a backer</a>]
|
||||
</span>
|
||||
</H5>
|
||||
<p>Thank you to all our backers! 🙏</p>
|
||||
<div className="image-container">
|
||||
<a href="https://opencollective.com/Mirakurun#backers" target="_blank" rel="noreferrer">
|
||||
<img src="https://opencollective.com/Mirakurun/backers.svg?width=890" alt="Backers" className="opencollective-img" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div className="section">
|
||||
<H5>
|
||||
Sponsors{" "}
|
||||
<span className="bp5-text-muted text-small">
|
||||
[<a href="https://opencollective.com/Mirakurun#sponsor" target="_blank" rel="noreferrer">Become a sponsor</a>]
|
||||
</span>
|
||||
</H5>
|
||||
<p>Support this project by becoming a sponsor. Your logo will show up here with a link to your website.</p>
|
||||
<div className="sponsors-avatars">
|
||||
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((i) => (
|
||||
<a key={i} href={`https://opencollective.com/Mirakurun/sponsor/${i}/website`} target="_blank" rel="noreferrer">
|
||||
<img src={`https://opencollective.com/Mirakurun/sponsor/${i}/avatar.svg`} alt={`Sponsor ${i}`} className="sponsor-avatar" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
78
web/src/routes/ChannelsConfigView.sass
Normal file
78
web/src/routes/ChannelsConfigView.sass
Normal file
@@ -0,0 +1,78 @@
|
||||
@use "~@blueprintjs/colors/lib/scss/colors"
|
||||
|
||||
#route-channels-config-view
|
||||
.content
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 16px
|
||||
padding: 20px
|
||||
overflow-y: auto
|
||||
|
||||
.channels-table
|
||||
width: 100%
|
||||
border-collapse: collapse
|
||||
|
||||
th, td
|
||||
vertical-align: top !important
|
||||
padding: 12px 8px !important
|
||||
|
||||
td
|
||||
.bp5-form-group
|
||||
margin-bottom: 8px
|
||||
&:last-child
|
||||
margin-bottom: 0
|
||||
|
||||
.bp5-label
|
||||
margin-bottom: 3px
|
||||
font-weight: 600
|
||||
font-size: 11px
|
||||
color: colors.$gray1
|
||||
|
||||
.bp5-dark &
|
||||
color: colors.$gray4
|
||||
|
||||
.channel-options-grid
|
||||
display: flex
|
||||
gap: 12px
|
||||
align-items: flex-start
|
||||
flex-wrap: wrap
|
||||
|
||||
.cmd-vars-container
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 4px
|
||||
min-width: 200px
|
||||
flex: 1
|
||||
|
||||
.cmd-vars-title
|
||||
font-weight: 600
|
||||
font-size: 11px
|
||||
margin-bottom: 4px
|
||||
color: colors.$gray1
|
||||
.bp5-dark &
|
||||
color: colors.$gray4
|
||||
|
||||
.cmd-vars-list
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
gap: 6px
|
||||
align-items: center
|
||||
|
||||
.cmd-var-pair
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 4px
|
||||
background-color: rgba(colors.$light-gray1, 0.4)
|
||||
padding: 2px 6px
|
||||
border-radius: 4px
|
||||
.bp5-dark &
|
||||
background-color: rgba(colors.$dark-gray5, 0.4)
|
||||
|
||||
.cmd-var-key, .cmd-var-value
|
||||
width: 75px
|
||||
|
||||
.controls-cell
|
||||
display: flex
|
||||
gap: 4px
|
||||
justify-content: flex-end
|
||||
align-items: center
|
||||
915
web/src/routes/ChannelsConfigView.tsx
Normal file
915
web/src/routes/ChannelsConfigView.tsx
Normal file
@@ -0,0 +1,915 @@
|
||||
/*
|
||||
Copyright 2026 kanreisa
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Alignment,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
Callout,
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogFooter,
|
||||
FormGroup,
|
||||
HTMLSelect,
|
||||
HTMLTable,
|
||||
InputGroup,
|
||||
Navbar,
|
||||
NonIdealState,
|
||||
ProgressBar,
|
||||
Spinner,
|
||||
Switch
|
||||
} from "@blueprintjs/core";
|
||||
import equal from "fast-deep-equal";
|
||||
import { state } from "../modules/state";
|
||||
import * as ui from "../modules/ui";
|
||||
import { ConfigChannels, ConfigChannelsItem, ChannelType, ChannelScanStatus } from "../../../api.d";
|
||||
|
||||
import "./ChannelsConfigView.sass";
|
||||
|
||||
const configAPI = "/api/config/channels";
|
||||
const typesIndex = ["GR", "BS", "CS", "SKY"];
|
||||
|
||||
function sortTypes(types: ChannelType[]): ChannelType[] {
|
||||
return types.sort((a, b) => typesIndex.indexOf(a) - typesIndex.indexOf(b));
|
||||
}
|
||||
|
||||
// チャンネル範囲を展開する関数(例: "14-16,18" → "14,15,16,18")
|
||||
function expandChannelRanges(input: string): string {
|
||||
if (!input) return "";
|
||||
|
||||
const parts = input.split(",");
|
||||
const result: number[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.includes("-")) {
|
||||
const [start, end] = part.split("-").map(n => parseInt(n.trim(), 10));
|
||||
if (!isNaN(start) && !isNaN(end)) {
|
||||
for (let i = start; i <= end; i++) {
|
||||
result.push(i);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const num = parseInt(part.trim(), 10);
|
||||
if (!isNaN(num)) {
|
||||
result.push(num);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(result)].sort((a, b) => a - b).join(",");
|
||||
}
|
||||
|
||||
const migrateChannels = (channels: ConfigChannels): ConfigChannels => {
|
||||
return channels.map(ch => {
|
||||
if ((ch.satellite || ch.space !== undefined || ch.freq !== undefined || ch.polarity) && (!ch.commandVars || Object.keys(ch.commandVars).length === 0)) {
|
||||
const commandVars: Record<string, string | number> = {};
|
||||
if (ch.satellite) {
|
||||
commandVars["satellite"] = ch.satellite;
|
||||
}
|
||||
if (ch.space !== undefined) {
|
||||
commandVars["space"] = ch.space;
|
||||
}
|
||||
if (ch.freq !== undefined) {
|
||||
commandVars["freq"] = ch.freq;
|
||||
}
|
||||
if (ch.polarity) {
|
||||
commandVars["polarity"] = ch.polarity;
|
||||
}
|
||||
return {
|
||||
...ch,
|
||||
commandVars
|
||||
};
|
||||
}
|
||||
return ch;
|
||||
});
|
||||
};
|
||||
|
||||
export const ChannelsConfigView: React.FC = () => {
|
||||
console.debug("routes", "ChannelsConfigView");
|
||||
|
||||
const [current, setCurrent] = useState<ConfigChannels | null>(null);
|
||||
const [editing, setEditing] = useState<ConfigChannels | null>(null);
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// チャンネルスキャンのためのステート
|
||||
const [showScanDialog, setShowScanDialog] = useState(false);
|
||||
const [scanType, setScanType] = useState<ChannelType>("GR");
|
||||
const [scanMinCh, setScanMinCh] = useState("13");
|
||||
const [scanMaxCh, setScanMaxCh] = useState("62");
|
||||
const [scanSkipCh, setScanSkipCh] = useState("");
|
||||
const [scanMinSubCh, setScanMinSubCh] = useState("0");
|
||||
const [scanMaxSubCh, setScanMaxSubCh] = useState("3");
|
||||
const [scanUseSubCh, setScanUseSubCh] = useState(true);
|
||||
const [scanChannelNameFormatEnabled, setScanChannelNameFormatEnabled] = useState(false);
|
||||
const [scanChannelNameFormat, setScanChannelNameFormat] = useState("");
|
||||
const [scanSetDisabledOnAdd, setScanSetDisabledOnAdd] = useState(false);
|
||||
const [scanAutoApply, setScanAutoApply] = useState(false);
|
||||
const [scanRefresh, setScanRefresh] = useState(false);
|
||||
const [scanStatus, setScanStatus] = useState<ChannelScanStatus | null>(null);
|
||||
const [scanInProgress, setScanInProgress] = useState(false);
|
||||
const [showScanResultDialog, setShowScanResultDialog] = useState(false);
|
||||
|
||||
ui.setTitle("チャンネル設定", isLoading);
|
||||
|
||||
// スキャンステータスを取得する
|
||||
const fetchScanStatus = async () => {
|
||||
try {
|
||||
const res: ChannelScanStatus = await (await fetch("/api/config/channels/scan")).json();
|
||||
console.log("ChannelsConfigView", "GET", "/api/config/channels/scan", "->", res);
|
||||
setScanStatus(res);
|
||||
|
||||
setScanInProgress(prev => {
|
||||
if (res.status === "completed" && prev && !res.isScanning) {
|
||||
setShowScanResultDialog(true);
|
||||
}
|
||||
return res.isScanning;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch scan status:", e);
|
||||
}
|
||||
};
|
||||
|
||||
// スキャンを開始する
|
||||
const startScan = async () => {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.append("type", scanType);
|
||||
params.append("minCh", scanMinCh);
|
||||
params.append("maxCh", scanMaxCh);
|
||||
|
||||
if (scanSkipCh.trim()) {
|
||||
const expandedSkipCh = expandChannelRanges(scanSkipCh.trim());
|
||||
params.append("skipCh", expandedSkipCh);
|
||||
}
|
||||
|
||||
if (scanType === "BS" && scanUseSubCh) {
|
||||
params.append("minSubCh", scanMinSubCh);
|
||||
params.append("maxSubCh", scanMaxSubCh);
|
||||
params.append("useSubCh", "true");
|
||||
}
|
||||
|
||||
if (!scanAutoApply) {
|
||||
params.append("dryRun", "true");
|
||||
}
|
||||
|
||||
if (scanChannelNameFormatEnabled && scanChannelNameFormat.trim()) {
|
||||
params.append("channelNameFormat", scanChannelNameFormat.trim());
|
||||
}
|
||||
|
||||
params.append("setDisabledOnAdd", scanSetDisabledOnAdd ? "true" : "false");
|
||||
|
||||
if (scanRefresh) {
|
||||
params.append("refresh", "true");
|
||||
}
|
||||
|
||||
params.append("async", "true");
|
||||
|
||||
const url = `/api/config/channels/scan?${params.toString()}`;
|
||||
console.log("ChannelsConfigView", "PUT", url);
|
||||
|
||||
const response = await fetch(url, { method: "PUT" });
|
||||
const result = await response.json();
|
||||
|
||||
if (response.status === 202) {
|
||||
console.log("Scan started:", result);
|
||||
setScanInProgress(true);
|
||||
setShowScanDialog(false);
|
||||
await fetchScanStatus();
|
||||
} else {
|
||||
console.error("Failed to start scan:", result);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error starting scan:", e);
|
||||
}
|
||||
};
|
||||
|
||||
// スキャンを停止する
|
||||
const stopScan = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/config/channels/scan", { method: "DELETE" });
|
||||
console.log("ChannelsConfigView", "DELETE", "/api/config/channels/scan", "->", await response.json());
|
||||
setScanInProgress(false);
|
||||
} catch (e) {
|
||||
console.error("Error stopping scan:", e);
|
||||
}
|
||||
};
|
||||
|
||||
// スキャン結果を適用する
|
||||
const applyScanResult = () => {
|
||||
if (scanStatus && scanStatus.result) {
|
||||
setEditing(JSON.parse(JSON.stringify(scanStatus.result)));
|
||||
setShowScanResultDialog(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 初期データ読み込み
|
||||
useEffect(() => {
|
||||
if (saved === true) {
|
||||
setTimeout(() => {
|
||||
// Restart notification will be emitted in production when requested
|
||||
}, 500);
|
||||
setSaved(false);
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await (await fetch(configAPI)).json();
|
||||
console.log("ChannelsConfigView", "GET", configAPI, "->", res);
|
||||
const migrated = migrateChannels(res);
|
||||
setEditing(JSON.parse(JSON.stringify(migrated)));
|
||||
setCurrent(JSON.parse(JSON.stringify(migrated)));
|
||||
setIsLoading(false);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setIsLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [saved]);
|
||||
|
||||
// スキャン状態の定期チェック
|
||||
useEffect(() => {
|
||||
fetchScanStatus();
|
||||
}, [current]);
|
||||
|
||||
useEffect(() => {
|
||||
let intervalId: NodeJS.Timeout;
|
||||
if (scanInProgress) {
|
||||
intervalId = setInterval(fetchScanStatus, 5000);
|
||||
} else {
|
||||
intervalId = setInterval(fetchScanStatus, 30000);
|
||||
}
|
||||
return () => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
};
|
||||
}, [scanInProgress]);
|
||||
|
||||
const hasChanges = editing !== null && current !== null && !equal(editing, current);
|
||||
|
||||
const handleCancel = () => {
|
||||
if (current) {
|
||||
setEditing(JSON.parse(JSON.stringify(current)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editing) return;
|
||||
setShowSaveDialog(false);
|
||||
try {
|
||||
console.log("ChannelsConfigView", "PUT", configAPI, "<-", editing);
|
||||
await fetch(configAPI, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
body: JSON.stringify(editing)
|
||||
});
|
||||
setSaved(true);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddChannel = () => {
|
||||
if (!editing) return;
|
||||
const i = editing.length;
|
||||
const newChannel: ConfigChannelsItem = {
|
||||
name: `ch${i}`,
|
||||
type: "GR",
|
||||
channel: "0",
|
||||
isDisabled: true
|
||||
};
|
||||
setEditing([...editing, newChannel]);
|
||||
};
|
||||
|
||||
const updateChannel = (index: number, updated: Partial<ConfigChannelsItem>) => {
|
||||
if (!editing) return;
|
||||
const newEditing = [...editing];
|
||||
newEditing[index] = { ...newEditing[index], ...updated };
|
||||
setEditing(newEditing);
|
||||
};
|
||||
|
||||
const deleteChannelProperty = (index: number, key: keyof ConfigChannelsItem) => {
|
||||
if (!editing) return;
|
||||
const newEditing = [...editing];
|
||||
const updated = { ...newEditing[index] };
|
||||
delete updated[key];
|
||||
newEditing[index] = updated;
|
||||
setEditing(newEditing);
|
||||
};
|
||||
|
||||
const handleUp = (i: number) => {
|
||||
if (!editing || i === 0) return;
|
||||
const newEditing = [...editing];
|
||||
const temp = newEditing[i];
|
||||
newEditing[i] = newEditing[i - 1];
|
||||
newEditing[i - 1] = temp;
|
||||
setEditing(newEditing);
|
||||
};
|
||||
|
||||
const handleDown = (i: number) => {
|
||||
if (!editing || i === editing.length - 1) return;
|
||||
const newEditing = [...editing];
|
||||
const temp = newEditing[i];
|
||||
newEditing[i] = newEditing[i + 1];
|
||||
newEditing[i + 1] = temp;
|
||||
setEditing(newEditing);
|
||||
};
|
||||
|
||||
const handleRemove = (i: number) => {
|
||||
if (!editing) return;
|
||||
const newEditing = [...editing];
|
||||
newEditing.splice(i, 1);
|
||||
setEditing(newEditing);
|
||||
};
|
||||
|
||||
// Command Var Helpers
|
||||
const updateCommandVarKey = (chIndex: number, oldKey: string, newKey: string) => {
|
||||
if (!editing) return;
|
||||
const newEditing = [...editing];
|
||||
const ch = { ...newEditing[chIndex] };
|
||||
const commandVars = { ...(ch.commandVars || {}) };
|
||||
|
||||
const updatedVars: Record<string, string | number> = {};
|
||||
Object.entries(commandVars).forEach(([k, v]) => {
|
||||
if (k === oldKey) {
|
||||
updatedVars[newKey] = v;
|
||||
} else {
|
||||
updatedVars[k] = v;
|
||||
}
|
||||
});
|
||||
ch.commandVars = updatedVars;
|
||||
newEditing[chIndex] = ch;
|
||||
setEditing(newEditing);
|
||||
};
|
||||
|
||||
const updateCommandVarValue = (chIndex: number, key: string, newValue: string) => {
|
||||
if (!editing) return;
|
||||
const newEditing = [...editing];
|
||||
const ch = { ...newEditing[chIndex] };
|
||||
const commandVars = { ...(ch.commandVars || {}) };
|
||||
|
||||
if (newValue === "") {
|
||||
commandVars[key] = "";
|
||||
} else if (newValue === "0") {
|
||||
commandVars[key] = 0;
|
||||
} else if (/^[0-9]+(\.[0-9]+)?$/.test(newValue)) {
|
||||
commandVars[key] = parseFloat(newValue);
|
||||
} else {
|
||||
commandVars[key] = newValue;
|
||||
}
|
||||
ch.commandVars = commandVars;
|
||||
newEditing[chIndex] = ch;
|
||||
setEditing(newEditing);
|
||||
};
|
||||
|
||||
const removeCommandVar = (chIndex: number, key: string) => {
|
||||
if (!editing) return;
|
||||
const newEditing = [...editing];
|
||||
const ch = { ...newEditing[chIndex] };
|
||||
const commandVars = { ...(ch.commandVars || {}) };
|
||||
delete commandVars[key];
|
||||
if (Object.keys(commandVars).length === 0) {
|
||||
delete ch.commandVars;
|
||||
} else {
|
||||
ch.commandVars = commandVars;
|
||||
}
|
||||
newEditing[chIndex] = ch;
|
||||
setEditing(newEditing);
|
||||
};
|
||||
|
||||
const addCommandVar = (chIndex: number) => {
|
||||
if (!editing) return;
|
||||
const newEditing = [...editing];
|
||||
const ch = { ...newEditing[chIndex] };
|
||||
const commandVars = { ...(ch.commandVars || {}) };
|
||||
|
||||
let newKey = "arg";
|
||||
let counter = 1;
|
||||
while (commandVars[newKey] !== undefined) {
|
||||
newKey = `arg${counter}`;
|
||||
counter++;
|
||||
}
|
||||
commandVars[newKey] = "";
|
||||
ch.commandVars = commandVars;
|
||||
newEditing[chIndex] = ch;
|
||||
setEditing(newEditing);
|
||||
};
|
||||
|
||||
const toolbar = (
|
||||
<Navbar className="toolbar">
|
||||
<Navbar.Group align={Alignment.START}>
|
||||
<Navbar.Heading>
|
||||
<Breadcrumbs items={[
|
||||
{
|
||||
text: "チャンネル設定"
|
||||
}
|
||||
]} />
|
||||
</Navbar.Heading>
|
||||
</Navbar.Group>
|
||||
|
||||
<Navbar.Group align={Alignment.END}>
|
||||
<Button
|
||||
minimal
|
||||
intent="success"
|
||||
icon="add"
|
||||
text="Add Channel"
|
||||
onClick={handleAddChannel}
|
||||
/>
|
||||
<Button
|
||||
minimal
|
||||
intent="warning"
|
||||
icon="search"
|
||||
text="Channel Scan"
|
||||
onClick={() => setShowScanDialog(true)}
|
||||
disabled={scanInProgress}
|
||||
/>
|
||||
|
||||
<Navbar.Divider />
|
||||
|
||||
<Button
|
||||
minimal
|
||||
intent="danger"
|
||||
icon="undo"
|
||||
text="Cancel"
|
||||
disabled={!hasChanges}
|
||||
onClick={handleCancel}
|
||||
/>
|
||||
<Button
|
||||
intent="primary"
|
||||
icon="saved"
|
||||
text="Save"
|
||||
disabled={!hasChanges}
|
||||
onClick={() => setShowSaveDialog(true)}
|
||||
/>
|
||||
</Navbar.Group>
|
||||
</Navbar>
|
||||
);
|
||||
|
||||
if (isLoading || !editing) {
|
||||
return (
|
||||
<div className="route" id="route-channels-config-view">
|
||||
{toolbar}
|
||||
<NonIdealState
|
||||
icon={<Spinner />}
|
||||
title="ロード中"
|
||||
description="設定を読み込んでいます..."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="route" id="route-channels-config-view">
|
||||
{toolbar}
|
||||
|
||||
<div className="content">
|
||||
{/* スキャン進行中/完了時のステータス表示 */}
|
||||
{scanInProgress && scanStatus && (
|
||||
<Callout intent="primary" title={`チャンネルスキャン中 (${scanStatus.type})`}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px", marginTop: "8px" }}>
|
||||
<ProgressBar value={(scanStatus.progress || 0) / 100} />
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<div>
|
||||
現在のチャンネル: <strong>{scanStatus.currentChannel || "初期化中..."}</strong> (進捗: {scanStatus.progress || 0}%)
|
||||
<span style={{ marginLeft: "16px" }}>新規: {scanStatus.newCount || 0} / 引き継ぎ: {scanStatus.takeoverCount || 0}</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
<Button small icon="refresh" onClick={fetchScanStatus}>更新</Button>
|
||||
<Button small intent="danger" icon="stop" onClick={stopScan}>スキャン停止</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{!scanInProgress && scanStatus && (scanStatus.status === "completed" || (scanStatus.scanLog && scanStatus.scanLog.length > 0)) && (
|
||||
<Callout
|
||||
intent={scanStatus.status === "completed" ? "success" : "warning"}
|
||||
title={`前回のスキャン結果 (${scanStatus.type})`}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: "8px" }}>
|
||||
<div>
|
||||
ステータス: <strong>{scanStatus.status}</strong>
|
||||
<span style={{ marginLeft: "16px" }}>新規: {scanStatus.newCount || 0} / 引き継ぎ: {scanStatus.takeoverCount || 0}</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
{scanStatus.status === "completed" && scanStatus.result && (
|
||||
<Button small intent="success" icon="tick" onClick={applyScanResult}>スキャン結果を適用</Button>
|
||||
)}
|
||||
<Button small icon="document" onClick={() => setShowScanResultDialog(true)}>ログを表示</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
<HTMLTable className="channels-table" striped interactive>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: "80px" }}>Enable</th>
|
||||
<th style={{ width: "160px" }}>Name</th>
|
||||
<th style={{ width: "100px" }}>Type</th>
|
||||
<th style={{ width: "120px" }}>Channel</th>
|
||||
<th>Options</th>
|
||||
<th style={{ width: "140px", textAlign: "right" }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{editing.map((ch, i) => (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
<Switch
|
||||
checked={!ch.isDisabled}
|
||||
onChange={(e) => {
|
||||
updateChannel(i, { isDisabled: !e.currentTarget.checked });
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<InputGroup
|
||||
value={ch.name || ""}
|
||||
onChange={(e) => {
|
||||
updateChannel(i, { name: e.target.value });
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (ch.name === "") {
|
||||
updateChannel(i, { name: `ch${i}` });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<HTMLSelect
|
||||
value={ch.type}
|
||||
onChange={(e) => {
|
||||
updateChannel(i, { type: e.target.value as ChannelType });
|
||||
}}
|
||||
options={[
|
||||
{ value: "GR", label: "GR" },
|
||||
{ value: "BS", label: "BS" },
|
||||
{ value: "CS", label: "CS" },
|
||||
{ value: "SKY", label: "SKY" }
|
||||
]}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<InputGroup
|
||||
value={ch.channel || ""}
|
||||
onChange={(e) => {
|
||||
updateChannel(i, { channel: e.target.value });
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (ch.channel === "") {
|
||||
updateChannel(i, { channel: "0" });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="channel-options-grid">
|
||||
<FormGroup label="Service ID" style={{ width: "90px", marginBottom: 0 }}>
|
||||
<InputGroup
|
||||
placeholder="SID"
|
||||
value={`${ch.serviceId || ""}`}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (val === "") {
|
||||
deleteChannelProperty(i, "serviceId");
|
||||
} else if (/^[0-9]+$/.test(val)) {
|
||||
const sid = parseInt(val, 10);
|
||||
if (sid <= 65535 && sid > 0) {
|
||||
updateChannel(i, { serviceId: sid });
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label="TsmfRelTs" style={{ width: "90px", marginBottom: 0 }}>
|
||||
<InputGroup
|
||||
placeholder="TsmfRelTs"
|
||||
value={`${ch.tsmfRelTs || ""}`}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (val === "") {
|
||||
deleteChannelProperty(i, "tsmfRelTs");
|
||||
} else if (/^[0-9]+$/.test(val)) {
|
||||
const tsmfRelTs = parseInt(val, 10);
|
||||
updateChannel(i, { tsmfRelTs });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<div className="cmd-vars-container">
|
||||
<div className="cmd-vars-title">Command Vars</div>
|
||||
<div className="cmd-vars-list">
|
||||
{ch.commandVars && Object.entries(ch.commandVars).map(([key, value]) => (
|
||||
<div key={key} className="cmd-var-pair">
|
||||
<InputGroup
|
||||
small
|
||||
className="cmd-var-key"
|
||||
value={key}
|
||||
onChange={(e) => updateCommandVarKey(i, key, e.target.value)}
|
||||
/>
|
||||
<span className="cmd-var-separator">:</span>
|
||||
<InputGroup
|
||||
small
|
||||
className="cmd-var-value"
|
||||
value={`${value}`}
|
||||
onChange={(e) => updateCommandVarValue(i, key, e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
small
|
||||
minimal
|
||||
intent="danger"
|
||||
icon="cross"
|
||||
onClick={() => removeCommandVar(i, key)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
small
|
||||
minimal
|
||||
intent="primary"
|
||||
icon="plus"
|
||||
text="Add Var"
|
||||
onClick={() => addCommandVar(i)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="controls-cell">
|
||||
<Button
|
||||
disabled={i === 0}
|
||||
icon="chevron-up"
|
||||
onClick={() => handleUp(i)}
|
||||
minimal
|
||||
/>
|
||||
<Button
|
||||
disabled={i === editing.length - 1}
|
||||
icon="chevron-down"
|
||||
onClick={() => handleDown(i)}
|
||||
minimal
|
||||
/>
|
||||
<Button
|
||||
icon="trash"
|
||||
intent="danger"
|
||||
onClick={() => handleRemove(i)}
|
||||
minimal
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</HTMLTable>
|
||||
</div>
|
||||
|
||||
{/* 保存確認ダイアログ */}
|
||||
<Dialog
|
||||
isOpen={showSaveDialog}
|
||||
onClose={() => setShowSaveDialog(false)}
|
||||
title="Save"
|
||||
>
|
||||
<DialogBody>
|
||||
<p>設定を保存しますか?</p>
|
||||
<p className="bp5-text-muted">適用するには再起動が必要です。</p>
|
||||
</DialogBody>
|
||||
<DialogFooter
|
||||
actions={
|
||||
<>
|
||||
<Button onClick={() => setShowSaveDialog(false)}>キャンセル</Button>
|
||||
<Button
|
||||
intent="primary"
|
||||
disabled={!hasChanges}
|
||||
onClick={handleSave}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Dialog>
|
||||
|
||||
{/* スキャン設定ダイアログ */}
|
||||
<Dialog
|
||||
isOpen={showScanDialog}
|
||||
onClose={() => setShowScanDialog(false)}
|
||||
title="Channel Scan"
|
||||
style={{ width: "450px" }}
|
||||
>
|
||||
<DialogBody>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
|
||||
<FormGroup label="Channel Type">
|
||||
<HTMLSelect
|
||||
value={scanType}
|
||||
onChange={(e) => {
|
||||
const newType = e.target.value as ChannelType;
|
||||
setScanType(newType);
|
||||
switch (newType) {
|
||||
case "GR":
|
||||
setScanMinCh("13");
|
||||
setScanMaxCh("62");
|
||||
break;
|
||||
case "BS":
|
||||
setScanMinCh("1");
|
||||
setScanMaxCh("23");
|
||||
break;
|
||||
case "CS":
|
||||
setScanMinCh("2");
|
||||
setScanMaxCh("24");
|
||||
break;
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{ value: "GR", label: "GR" },
|
||||
{ value: "BS", label: "BS" },
|
||||
{ value: "CS", label: "CS" }
|
||||
]}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<div style={{ display: "flex", gap: "16px" }}>
|
||||
<FormGroup label="Min Channel" style={{ flex: 1 }}>
|
||||
<InputGroup
|
||||
value={scanMinCh}
|
||||
onChange={(e) => setScanMinCh(e.target.value)}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup label="Max Channel" style={{ flex: 1 }}>
|
||||
<InputGroup
|
||||
value={scanMaxCh}
|
||||
onChange={(e) => setScanMaxCh(e.target.value)}
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
|
||||
<FormGroup
|
||||
label="Skip Channels (comma separated integers)"
|
||||
helperText="Enter channel numbers to skip. Range notation (e.g. 14-16) is supported."
|
||||
>
|
||||
<InputGroup
|
||||
placeholder="Example: 13,14-16,18"
|
||||
value={scanSkipCh}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (val === "" || /^[0-9,\-]+$/.test(val)) {
|
||||
setScanSkipCh(val);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{scanType === "BS" && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
||||
<Switch
|
||||
label="Use Subchannel Style (BS01_0)"
|
||||
checked={scanUseSubCh}
|
||||
onChange={(e) => setScanUseSubCh(e.currentTarget.checked)}
|
||||
/>
|
||||
{scanUseSubCh && (
|
||||
<div style={{ display: "flex", gap: "16px" }}>
|
||||
<FormGroup label="Min Subchannel" style={{ flex: 1 }}>
|
||||
<InputGroup
|
||||
value={scanMinSubCh}
|
||||
onChange={(e) => setScanMinSubCh(e.target.value)}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup label="Max Subchannel" style={{ flex: 1 }}>
|
||||
<InputGroup
|
||||
value={scanMaxSubCh}
|
||||
onChange={(e) => setScanMaxSubCh(e.target.value)}
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Switch
|
||||
label="Use Channel Name Format"
|
||||
checked={scanChannelNameFormatEnabled}
|
||||
onChange={(e) => setScanChannelNameFormatEnabled(e.currentTarget.checked)}
|
||||
/>
|
||||
|
||||
{scanChannelNameFormatEnabled && (
|
||||
<FormGroup
|
||||
label="Channel Name Format"
|
||||
helperText="Format to use for channel names. Supports placeholders like {ch}, {ch00}, {subch}."
|
||||
>
|
||||
<InputGroup
|
||||
placeholder="Example: {ch}, BS{ch00}_{subch}"
|
||||
value={scanChannelNameFormat}
|
||||
onChange={(e) => setScanChannelNameFormat(e.target.value)}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
|
||||
<Switch
|
||||
label="Auto Apply Results (Restart required)"
|
||||
checked={scanAutoApply}
|
||||
onChange={(e) => setScanAutoApply(e.currentTarget.checked)}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label="Set Disabled on Add"
|
||||
checked={scanSetDisabledOnAdd}
|
||||
onChange={(e) => setScanSetDisabledOnAdd(e.currentTarget.checked)}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label="Refresh (Update existing channels)"
|
||||
checked={scanRefresh}
|
||||
onChange={(e) => setScanRefresh(e.currentTarget.checked)}
|
||||
/>
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter
|
||||
actions={
|
||||
<>
|
||||
<Button onClick={() => setShowScanDialog(false)}>Cancel</Button>
|
||||
<Button intent="primary" onClick={startScan}>Start Scan</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Dialog>
|
||||
|
||||
{/* スキャン結果/ログダイアログ */}
|
||||
<Dialog
|
||||
isOpen={showScanResultDialog}
|
||||
onClose={() => setShowScanResultDialog(false)}
|
||||
title="Scan Results"
|
||||
style={{ width: "600px" }}
|
||||
>
|
||||
<DialogBody>
|
||||
{scanStatus && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
|
||||
{scanStatus.status === "completed" && (
|
||||
<Callout intent="success" title="スキャン完了">
|
||||
スキャンが正常に完了しました!
|
||||
<div>新規: {scanStatus.newCount} | 引き継ぎ: {scanStatus.takeoverCount}</div>
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
maxHeight: "300px",
|
||||
overflowY: "auto",
|
||||
border: "1px solid rgba(0,0,0,0.1)",
|
||||
padding: "8px",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "12px",
|
||||
whiteSpace: "pre-wrap",
|
||||
backgroundColor: "rgba(0,0,0,0.02)"
|
||||
}}>
|
||||
{scanStatus.scanLog && scanStatus.scanLog.length > 0 ? (
|
||||
scanStatus.scanLog.join("\n")
|
||||
) : (
|
||||
<div>ログがありません。</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{scanStatus.status === "completed" && scanStatus.result && (
|
||||
<Callout intent="primary">
|
||||
「適用」ボタンをクリックすると、現在のスキャン結果を設定に反映します。
|
||||
</Callout>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogBody>
|
||||
<DialogFooter
|
||||
actions={
|
||||
<>
|
||||
<Button onClick={() => setShowScanResultDialog(false)}>閉じる</Button>
|
||||
<Button
|
||||
intent="primary"
|
||||
onClick={applyScanResult}
|
||||
disabled={scanStatus?.status !== "completed" || !scanStatus?.result}
|
||||
>
|
||||
スキャン結果を適用
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
198
web/src/routes/EPGView.tsx
Normal file
198
web/src/routes/EPGView.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
/*
|
||||
Copyright 2026 kanreisa
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Alignment, Button, Navbar, Tabs, Tab, HTMLSelect, Breadcrumbs } from "@blueprintjs/core";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
import { state } from "../modules/state";
|
||||
import * as ui from "../modules/ui";
|
||||
import { useLocalStorageState } from "../hooks/useWebStorageState";
|
||||
|
||||
import { ChannelType } from "../../../api";
|
||||
|
||||
import { WatchButton } from "../components/WatchButton";
|
||||
import { EPGTable } from "../components/EPGTable";
|
||||
|
||||
export const EPGView: React.FC = () => {
|
||||
console.debug("routes", "EPG");
|
||||
|
||||
const params = useParams();
|
||||
const { navigate, searchParams } = state;
|
||||
|
||||
const [channelType, setChannelType] = useLocalStorageState<ChannelType>("EPG.channelType", "GR");
|
||||
const [programId, setProgramId] = useState<number>(null);
|
||||
const [time, setTime] = useState<number>(null);
|
||||
const globalServiceId = parseInt(params.globalServiceId, 10) || null;
|
||||
const programIdQuery = searchParams.get("programId");
|
||||
const typeQuery = searchParams.get("type");
|
||||
const dateQuery = searchParams.get("date");
|
||||
const timeQuery = searchParams.get("time");
|
||||
const now = DateTime.now();
|
||||
const isoDate = /^\d{4}-\d{2}-\d{2}$/.test(dateQuery) ? dateQuery : now.toISODate();
|
||||
const startDate = now.startOf("day");
|
||||
const endDate = startDate.plus({ days: 7 });
|
||||
|
||||
let date = DateTime.fromISO(isoDate);
|
||||
|
||||
if (globalServiceId) {
|
||||
date = date.set({ day: now.day });
|
||||
}
|
||||
|
||||
if (typeQuery) {
|
||||
if (typeQuery === "ALL") {
|
||||
if (channelType !== null) {
|
||||
setChannelType(null);
|
||||
return;
|
||||
}
|
||||
} else if (typeQuery !== channelType) {
|
||||
setChannelType(typeQuery as ChannelType);
|
||||
return;
|
||||
}
|
||||
} else if (!globalServiceId && !programIdQuery && !timeQuery) {
|
||||
let to = `?type=${channelType || "ALL"}&date=${isoDate}`;
|
||||
setTimeout(() => navigate(to, { replace: true }), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
let hasRemovedTempParams = false;
|
||||
if (programIdQuery) {
|
||||
setProgramId(parseInt(programIdQuery, 10));
|
||||
searchParams.delete("programId");
|
||||
hasRemovedTempParams = true;
|
||||
}
|
||||
if (timeQuery) {
|
||||
setTime(parseInt(timeQuery, 10));
|
||||
searchParams.delete("time");
|
||||
hasRemovedTempParams = true;
|
||||
}
|
||||
if (hasRemovedTempParams) {
|
||||
setTimeout(() => navigate(`?${searchParams.toString()}`, { replace: true }), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!globalServiceId) {
|
||||
ui.setTitle("EPG");
|
||||
}
|
||||
|
||||
const toolbarTabs: JSX.Element[] = [];
|
||||
if (date >= startDate && date <= endDate && !globalServiceId) {
|
||||
for (let i = 0; i <= 7; i++) {
|
||||
const cur = startDate.plus({ days: i });
|
||||
const id = `epg-toolbar-tabs-item-${cur.toISODate()}`;
|
||||
const d = i === 0 ? cur.toFormat("M/d") : cur.toFormat("d");
|
||||
const c = cur.toFormat("ccc");
|
||||
toolbarTabs.push(<Tab key={id} id={id} title={<>{d}<sup className={`color-dow-${cur.weekday}`}>{c}</sup></>} />);
|
||||
}
|
||||
} else {
|
||||
const id = `epg-toolbar-tabs-item-${date.toISODate()}`;
|
||||
const title = date.toFormat("yyyy/MM/dd(ccc)");
|
||||
toolbarTabs.push(<Tab key={id} id={id} title={title} />);
|
||||
}
|
||||
|
||||
const showTodayButton = (!globalServiceId && toolbarTabs.length === 1) || (globalServiceId && date.toMillis() !== startDate.toMillis());
|
||||
|
||||
return (
|
||||
<div className="route" id="route-epg">
|
||||
<Navbar className="toolbar">
|
||||
<Navbar.Group align={Alignment.START}>
|
||||
<Navbar.Heading>
|
||||
{globalServiceId
|
||||
? <Breadcrumbs items={[
|
||||
{ text: "EPG 番組表", onClick: () => {
|
||||
let to = `/epg?type=${channelType || ""}`;
|
||||
if (isoDate) {
|
||||
to += `&date=${isoDate}`;
|
||||
}
|
||||
if (time) {
|
||||
to += `&time=${time}`;
|
||||
}
|
||||
navigate(to)
|
||||
} },
|
||||
{ text: "週間" },
|
||||
{ text: "放送サービス...", className: "heading-title bp5-skeleton" }
|
||||
]} />
|
||||
: "EPG 番組表"
|
||||
}
|
||||
</Navbar.Heading>
|
||||
</Navbar.Group>
|
||||
|
||||
<Navbar.Group align={Alignment.END}>
|
||||
{globalServiceId && (
|
||||
<>
|
||||
<WatchButton variant="outlined" popoverPlacement="bottom-start" globalServiceId={globalServiceId} />
|
||||
</>
|
||||
)}
|
||||
{!globalServiceId && (
|
||||
<>
|
||||
{showTodayButton && (
|
||||
<Button variant="minimal" icon="reset" text="今日" onClick={() => {
|
||||
navigate("?", { replace: true });
|
||||
}} />
|
||||
)}
|
||||
|
||||
<Tabs id="epg-toolbar-tabs"
|
||||
selectedTabId={`epg-toolbar-tabs-item-${isoDate}`}
|
||||
onChange={(tabId: string) => {
|
||||
const to = tabId.replace(/^epg-toolbar-tabs-item-/, "");
|
||||
if (isoDate !== to) {
|
||||
navigate(`?date=${to}&type=${channelType || "ALL"}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{toolbarTabs}
|
||||
</Tabs>
|
||||
|
||||
<Navbar.Divider />
|
||||
|
||||
<HTMLSelect
|
||||
className="bp5-outlined"
|
||||
options={[
|
||||
{ value: "ALL", label: "全波" },
|
||||
{ value: "GR", label: "地上" },
|
||||
{ value: "BS" },
|
||||
{ value: "CS" },
|
||||
{ value: "SKY" },
|
||||
]}
|
||||
value={channelType || ""}
|
||||
onChange={event => {
|
||||
ui.blur();
|
||||
|
||||
const type = event.currentTarget.value;
|
||||
|
||||
let to = `/epg?type=${type}`;
|
||||
if (isoDate) {
|
||||
to += `&date=${isoDate}`;
|
||||
}
|
||||
|
||||
navigate(to);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Navbar.Group>
|
||||
</Navbar>
|
||||
|
||||
<div className="content no-margin">
|
||||
{globalServiceId
|
||||
? <EPGTable date={date} defaultTime={time} globalServiceId={globalServiceId} />
|
||||
: <EPGTable date={date} defaultTime={time} defaultProgramId={programId} channelType={channelType} />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
238
web/src/routes/HomeView.sass
Normal file
238
web/src/routes/HomeView.sass
Normal file
@@ -0,0 +1,238 @@
|
||||
@use "~@blueprintjs/colors/lib/scss/colors"
|
||||
@use "../vars"
|
||||
|
||||
#route-home-view
|
||||
.home-container
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 20px
|
||||
|
||||
.home-section
|
||||
flex-shrink: 0
|
||||
background: rgba(colors.$light-gray5, 0.2)
|
||||
|
||||
&:last-child
|
||||
margin-bottom: 20px
|
||||
|
||||
.bp5-dark &
|
||||
background: rgba(colors.$black, 0.2)
|
||||
|
||||
.home-section-content
|
||||
padding: 15px
|
||||
|
||||
.bp5-section-header
|
||||
min-height: 40px
|
||||
|
||||
// --- Status Section ---
|
||||
.status-grid
|
||||
display: grid
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr))
|
||||
gap: 10px
|
||||
|
||||
.status-item
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 5px
|
||||
padding: 5px 10px
|
||||
border-radius: 4px
|
||||
|
||||
.status-label
|
||||
font-size: 12px
|
||||
color: colors.$gray3
|
||||
font-weight: 500
|
||||
|
||||
.bp5-dark &
|
||||
color: colors.$gray5
|
||||
|
||||
.status-value
|
||||
font-size: 14px
|
||||
font-family: vars.$font-ui
|
||||
word-break: break-all
|
||||
|
||||
// --- Services Section ---
|
||||
.service-filters
|
||||
display: flex
|
||||
gap: 10px
|
||||
margin-bottom: 10px
|
||||
|
||||
.service-grid
|
||||
display: grid
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr))
|
||||
gap: 5px
|
||||
|
||||
.service-item
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 5px
|
||||
padding: 5px 10px
|
||||
border-radius: 4px
|
||||
transition: background 0.15s
|
||||
|
||||
&:hover
|
||||
background: colors.$light-gray3
|
||||
|
||||
.bp5-dark &
|
||||
background: colors.$dark-gray2
|
||||
|
||||
.bp5-icon
|
||||
vertical-align: baseline
|
||||
|
||||
.service-item-main
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 5px
|
||||
flex: 1
|
||||
min-width: 0
|
||||
color: inherit
|
||||
text-decoration: none
|
||||
cursor: pointer
|
||||
|
||||
.service-logo
|
||||
width: 32px
|
||||
height: auto
|
||||
border-radius: 1px
|
||||
flex-shrink: 0
|
||||
|
||||
.service-name
|
||||
flex: 1
|
||||
min-width: 0
|
||||
font-size: 13px
|
||||
font-weight: 500
|
||||
white-space: nowrap
|
||||
overflow: hidden
|
||||
text-overflow: ellipsis
|
||||
|
||||
.service-epg-status
|
||||
flex-shrink: 0
|
||||
|
||||
.service-play
|
||||
flex-shrink: 0
|
||||
padding: 4px
|
||||
border-radius: 4px
|
||||
cursor: pointer
|
||||
transition: background 0.15s
|
||||
|
||||
&:hover
|
||||
background: colors.$light-gray3
|
||||
|
||||
.bp5-dark &
|
||||
background: colors.$dark-gray2
|
||||
|
||||
.service-tooltip
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 5px
|
||||
font-size: 12px
|
||||
font-family: vars.$font-ui
|
||||
line-height: 1.5
|
||||
|
||||
.bp5-dark &
|
||||
color: colors.$gray1
|
||||
|
||||
// --- Tuners Section ---
|
||||
.tuner-tree
|
||||
|
||||
.bp5-tree-node
|
||||
.bp5-tree-node-content
|
||||
height: 35px
|
||||
gap: 10px
|
||||
|
||||
.bp5-tree-node-content:hover
|
||||
background: none
|
||||
|
||||
.bp5-tree-node-label
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 10px
|
||||
|
||||
.tuner-label
|
||||
font-size: 13px
|
||||
font-weight: 600
|
||||
white-space: nowrap
|
||||
|
||||
.tuner-device-info
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 10px
|
||||
font-size: 12px
|
||||
font-family: vars.$font-ui
|
||||
color: colors.$gray1
|
||||
white-space: nowrap
|
||||
|
||||
.bp5-button
|
||||
margin-left: 4px
|
||||
padding: 0 4px
|
||||
min-width: unset
|
||||
min-height: unset
|
||||
|
||||
.bp5-dark &
|
||||
color: colors.$gray2
|
||||
|
||||
.tuner-user-info
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 16px
|
||||
font-size: 12px
|
||||
font-family: vars.$font-ui
|
||||
white-space: nowrap
|
||||
|
||||
.tuner-user-info-item
|
||||
display: inline-flex
|
||||
align-items: center
|
||||
gap: 4px
|
||||
|
||||
.stream-info-link
|
||||
color: colors.$blue3
|
||||
text-decoration: none
|
||||
cursor: pointer
|
||||
|
||||
&:hover
|
||||
text-decoration: underline
|
||||
|
||||
// --- EPG Ready Color ---
|
||||
.color-epg-ready
|
||||
color: colors.$green3 !important
|
||||
|
||||
// Stream Info Dialog (rendered via Portal outside #route-home-view)
|
||||
.stream-info-table
|
||||
width: 100%
|
||||
table-layout: fixed
|
||||
font-size: 12px
|
||||
border-collapse: collapse
|
||||
|
||||
th, td
|
||||
padding: 4px 12px
|
||||
|
||||
th:nth-child(1), td:nth-child(1)
|
||||
text-align: left
|
||||
|
||||
th:nth-child(2), td:nth-child(2),
|
||||
th:nth-child(3), td:nth-child(3)
|
||||
text-align: right
|
||||
font-variant-numeric: tabular-nums
|
||||
|
||||
td.color-danger
|
||||
color: colors.$red3
|
||||
|
||||
// Dark mode adjustments
|
||||
body.bp5-dark
|
||||
#route-home-view
|
||||
.service-item
|
||||
|
||||
&:hover
|
||||
background: colors.$dark-gray2
|
||||
|
||||
.service-tooltip
|
||||
color: colors.$gray1
|
||||
|
||||
.tuner-device-info
|
||||
color: colors.$gray2
|
||||
|
||||
.color-epg-ready
|
||||
color: colors.$green4 !important
|
||||
|
||||
.stream-info-link
|
||||
color: colors.$blue4
|
||||
|
||||
.stream-info-table td.color-danger
|
||||
color: colors.$red4
|
||||
583
web/src/routes/HomeView.tsx
Normal file
583
web/src/routes/HomeView.tsx
Normal file
@@ -0,0 +1,583 @@
|
||||
/*
|
||||
Copyright 2026 kanreisa
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
Alignment,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogFooter,
|
||||
Icon,
|
||||
Navbar,
|
||||
NonIdealState,
|
||||
Section,
|
||||
Spinner,
|
||||
Tooltip,
|
||||
Tree,
|
||||
TreeNodeInfo
|
||||
} from "@blueprintjs/core";
|
||||
import { state } from "../modules/state";
|
||||
import * as ui from "../modules/ui";
|
||||
import { Service, Status, StreamInfo, TunerDevice } from "../../../api.d";
|
||||
|
||||
import "./HomeView.sass";
|
||||
|
||||
const summarizeStreamInfo = (streamInfo: StreamInfo): string => {
|
||||
if (!streamInfo) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
let packets = 0;
|
||||
let drops = 0;
|
||||
for (const pid in streamInfo) {
|
||||
packets += streamInfo[pid].packet;
|
||||
drops += streamInfo[pid].drop;
|
||||
}
|
||||
|
||||
return `Dropped Pkts: ${drops} / ${packets}`;
|
||||
};
|
||||
|
||||
const isEmptyStreamInfo = (streamInfo: StreamInfo): boolean => {
|
||||
if (!streamInfo) {
|
||||
return true;
|
||||
}
|
||||
return Object.keys(streamInfo).length === 0;
|
||||
};
|
||||
|
||||
// --- Status Section ---
|
||||
|
||||
const StatusSection: React.FC<{ status: Status }> = ({ status }) => {
|
||||
if (!status) {
|
||||
return <Spinner size={20} />;
|
||||
}
|
||||
|
||||
const dockerStat = status.process?.env?.DOCKER === "YES" ? " 🐋" : "";
|
||||
|
||||
const items: { label: string; text: string }[] = [
|
||||
{ label: "Platform", text: `${status.process?.platform} (${status.process?.arch})${dockerStat}` },
|
||||
{ label: "Rust Version", text: status.process?.versions?.rust },
|
||||
{ label: "Memory (RSS)", text: `${Math.round(status.process?.memoryUsage?.rss / 1024 / 1024)} MB` },
|
||||
{ label: "EPG Gathering Network IDs", text: status.epg.gatheringNetworks.map(id => `0x${id.toString(16).toUpperCase()}`).join(", ") || "-" },
|
||||
{ label: "EPG Stored Events", text: `${status.epg.storedEvents} Events` },
|
||||
{ label: "TunerDevice Streams", text: `${status.streamCount.tunerDevice}` },
|
||||
{ label: "TSFilter Streams", text: `${status.streamCount.tsFilter}` },
|
||||
{ label: "Decoder Streams", text: `${status.streamCount.decoder}` },
|
||||
{ label: "RPC Connections", text: `${status.rpcCount}` }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="status-grid">
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className="status-item">
|
||||
<span className="status-label">{item.label}</span>
|
||||
<span className="status-value">{item.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Services Section ---
|
||||
|
||||
const ServicesSection: React.FC<{
|
||||
status: Status;
|
||||
services: Service[];
|
||||
allowPNA: boolean;
|
||||
tsplayEndpoint: string;
|
||||
}> = ({ status, services, allowPNA, tsplayEndpoint }) => {
|
||||
const [showDTV, setShowDTV] = useState<boolean>(true);
|
||||
const [showData, setShowData] = useState<boolean>(false);
|
||||
const [showOthers, setShowOthers] = useState<boolean>(false);
|
||||
|
||||
const filteredServices = services.filter(service => {
|
||||
if (service.type === 0x01 || service.type === 0xAD) {
|
||||
return showDTV;
|
||||
} else if (service.type === 0xC0) {
|
||||
return showData;
|
||||
}
|
||||
return showOthers;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="service-filters">
|
||||
<Checkbox
|
||||
label="DTV"
|
||||
checked={showDTV}
|
||||
onChange={() => setShowDTV(!showDTV)}
|
||||
inline
|
||||
/>
|
||||
<Checkbox
|
||||
label="Data"
|
||||
checked={showData}
|
||||
onChange={() => setShowData(!showData)}
|
||||
inline
|
||||
/>
|
||||
<Checkbox
|
||||
label="Others"
|
||||
checked={showOthers}
|
||||
onChange={() => setShowOthers(!showOthers)}
|
||||
inline
|
||||
/>
|
||||
</div>
|
||||
<div className="service-grid">
|
||||
{filteredServices.map((service) => (
|
||||
<Tooltip
|
||||
key={service.id}
|
||||
content={
|
||||
<div className="service-tooltip">
|
||||
<div>#{service.id}</div>
|
||||
<div>SID: 0x{service.serviceId.toString(16).toUpperCase()} ({service.serviceId})</div>
|
||||
<div>NID: 0x{service.networkId.toString(16).toUpperCase()} ({service.networkId})</div>
|
||||
<div>Type: 0x{service.type.toString(16).toUpperCase()} ({service.type})</div>
|
||||
<div>Channel: {service.channel?.type} / {service.channel?.channel}</div>
|
||||
</div>
|
||||
}
|
||||
placement="bottom"
|
||||
hoverOpenDelay={300}
|
||||
>
|
||||
<div className="service-item">
|
||||
<Link className="service-item-main" to={`/epg/services/${service.id}`}>
|
||||
{service.hasLogoData && (
|
||||
<img
|
||||
className="service-logo"
|
||||
src={`/api/services/${service.id}/logo`}
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
<span className="service-name">{service.name}</span>
|
||||
<span className="service-epg-status">
|
||||
{
|
||||
status?.epg.gatheringNetworks.includes(service.networkId) && <Icon icon="refresh" className="color-warning" size={12} /> ||
|
||||
service.epgReady && <Icon icon="tick" className="color-epg-ready" size={12} /> ||
|
||||
<Icon icon="time" className="bp5-text-muted" size={12} />
|
||||
}
|
||||
</span>
|
||||
</Link>
|
||||
{service.type === 0x01 && allowPNA && tsplayEndpoint && (
|
||||
<span
|
||||
className="service-play"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(
|
||||
`${tsplayEndpoint}#${location.protocol}//${location.host}/api/services/${service.id}/stream?decode=1`,
|
||||
"_blank",
|
||||
"popup"
|
||||
);
|
||||
}}
|
||||
title="TSPlay (Experimental)"
|
||||
>
|
||||
<Icon icon="play" intent="primary" size={12} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Stream Info Table (for dialog) ---
|
||||
|
||||
const StreamInfoTable: React.FC<{
|
||||
userId: string;
|
||||
tuners: TunerDevice[];
|
||||
initialInfo: StreamInfo;
|
||||
}> = ({ userId, tuners, initialInfo }) => {
|
||||
let currentInfo = initialInfo;
|
||||
for (const tuner of tuners) {
|
||||
const user = tuner.users.find(u => u.id === userId);
|
||||
if (user?.streamInfo) {
|
||||
currentInfo = user.streamInfo;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const entries = Object.entries(currentInfo || {});
|
||||
if (entries.length === 0) {
|
||||
return <NonIdealState icon="info-sign" description="No stream info available." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="bp5-html-table bp5-html-table-striped bp5-html-table-condensed stream-info-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>PID</th>
|
||||
<th className="numeric">Packets</th>
|
||||
<th className="numeric">Drops</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map(([pid, data]) => (
|
||||
<tr key={pid}>
|
||||
<td>{pid}</td>
|
||||
<td className="numeric">{data.packet.toLocaleString()}</td>
|
||||
<td className={`numeric${data.drop > 0 ? " color-danger" : ""}`}>{data.drop.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Tuners Section ---
|
||||
|
||||
const TunersSection: React.FC<{
|
||||
tuners: TunerDevice[];
|
||||
}> = ({ tuners }) => {
|
||||
const [killTarget, setKillTarget] = useState<number>(null);
|
||||
const [tunersEx, setTunersEx] = useState<TunerDevice[]>([]);
|
||||
const [streamDetail, setStreamDetail] = useState<{ userId: string; info: StreamInfo }>(null);
|
||||
|
||||
// get streamInfo periodically
|
||||
useEffect(() => {
|
||||
const interval = setInterval(async () => {
|
||||
if (document.hidden) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await (await fetch("/api/tuners")).json();
|
||||
setTunersEx(result);
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
}, 1000 * 5);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// merge streamInfo from tunersEx into tuners
|
||||
const mergedTuners = tuners.map(tuner => {
|
||||
const tunerEx = tunersEx.find(t => t.index === tuner.index);
|
||||
if (tunerEx) {
|
||||
return {
|
||||
...tuner,
|
||||
users: tuner.users.map(user => {
|
||||
const userEx = tunerEx.users.find(u => u.id === user.id);
|
||||
if (userEx?.streamInfo) {
|
||||
return { ...user, streamInfo: userEx.streamInfo };
|
||||
}
|
||||
return user;
|
||||
})
|
||||
};
|
||||
}
|
||||
return tuner;
|
||||
});
|
||||
|
||||
const treeNodes: TreeNodeInfo[] = mergedTuners.map((tuner) => {
|
||||
const tunerLabel = `#${tuner.index}: ${tuner.name} (${tuner.types.join(", ")})`;
|
||||
const hasUsers = tuner.users.length > 0;
|
||||
|
||||
let tunerIcon: TreeNodeInfo["icon"];
|
||||
if (tuner.isFault) {
|
||||
tunerIcon = <Icon icon="error" intent="danger" />;
|
||||
} else if (!tuner.isAvailable) {
|
||||
tunerIcon = <Icon icon="disable" className="bp5-text-muted" />;
|
||||
} else if (tuner.isUsing) {
|
||||
tunerIcon = <Icon icon="dot" className="color-epg-ready" />;
|
||||
} else {
|
||||
tunerIcon = <Icon icon="dot" className="bp5-text-muted" />;
|
||||
}
|
||||
|
||||
const childNodes: TreeNodeInfo[] = [];
|
||||
|
||||
// device info node
|
||||
if (tuner.command || tuner.pid) {
|
||||
childNodes.push({
|
||||
id: `tuner-${tuner.index}-device`,
|
||||
icon: <Icon icon="console" className="bp5-text-muted" />,
|
||||
label: (
|
||||
<span className="tuner-device-info">
|
||||
<span>{tuner.command || "-"}</span>
|
||||
{tuner.pid ? <span className="bp5-text-muted"> (pid={tuner.pid})</span> : null}
|
||||
{tuner.command && (
|
||||
<Button
|
||||
variant="minimal"
|
||||
icon="cross"
|
||||
intent="danger"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setKillTarget(tuner.index);
|
||||
}}
|
||||
title="Kill Tuner Process..."
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
hasCaret: false
|
||||
});
|
||||
}
|
||||
|
||||
// user nodes
|
||||
for (let i = 0; i < tuner.users.length; i++) {
|
||||
const user = tuner.users[i];
|
||||
const isMirakurun = /Mirakurun/.test(user.id);
|
||||
|
||||
const userInfoItems: JSX.Element[] = [
|
||||
<span key="priority" className="tuner-user-info-item">
|
||||
<Icon icon="sort" className="bp5-text-muted" size={12} />
|
||||
<span>{user.priority}</span>
|
||||
</span>,
|
||||
<span key="user" className="tuner-user-info-item">
|
||||
<Icon icon={isMirakurun ? "cog" : "person"} className="bp5-text-muted" size={12} />
|
||||
<span>{user.id}</span>
|
||||
</span>,
|
||||
<span key="ch" className="tuner-user-info-item">
|
||||
<Icon icon="mobile-video" className="bp5-text-muted" size={12} />
|
||||
<span>{user.streamSetting?.channel?.type} / {user.streamSetting?.channel?.channel}</span>
|
||||
</span>,
|
||||
<span key="sid" className="tuner-user-info-item">
|
||||
<Icon icon="filter" className="bp5-text-muted" size={12} />
|
||||
<span>{user.streamSetting?.serviceId ? `0x${user.streamSetting.serviceId.toString(16).toUpperCase()} (${user.streamSetting.serviceId})` : "-"}</span>
|
||||
</span>
|
||||
];
|
||||
|
||||
// stream info
|
||||
if (!isEmptyStreamInfo(user.streamInfo)) {
|
||||
userInfoItems.push(
|
||||
<span key="stream" className="tuner-user-info-item">
|
||||
<Icon icon="cube" className="bp5-text-muted" size={12} />
|
||||
<a
|
||||
className="stream-info-link"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setStreamDetail({ userId: user.id, info: user.streamInfo });
|
||||
}}
|
||||
>
|
||||
{summarizeStreamInfo(user.streamInfo)}
|
||||
</a>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
childNodes.push({
|
||||
id: `tuner-${tuner.index}-user-${i}`,
|
||||
label: <span className="tuner-user-info">{userInfoItems}</span>,
|
||||
hasCaret: false
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: `tuner-${tuner.index}`,
|
||||
icon: tunerIcon,
|
||||
label: <span className="tuner-label">{tunerLabel}</span>,
|
||||
isExpanded: hasUsers || !!tuner.command,
|
||||
childNodes: childNodes.length > 0 ? childNodes : undefined,
|
||||
hasCaret: childNodes.length > 0
|
||||
};
|
||||
});
|
||||
|
||||
const handleNodeCollapse = useCallback((_node: TreeNodeInfo) => {
|
||||
// Tree is stateless; for now we allow expand/collapse via Tree's own behavior
|
||||
}, []);
|
||||
|
||||
const handleNodeExpand = useCallback((_node: TreeNodeInfo) => {
|
||||
// Tree is stateless
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{mergedTuners.length === 0 ? (
|
||||
<Spinner size={20} />
|
||||
) : (
|
||||
<Tree
|
||||
contents={treeNodes}
|
||||
onNodeCollapse={handleNodeCollapse}
|
||||
onNodeExpand={handleNodeExpand}
|
||||
className="tuner-tree"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Kill Tuner Process Dialog */}
|
||||
<Dialog
|
||||
isOpen={killTarget !== null}
|
||||
onClose={() => setKillTarget(null)}
|
||||
title="Kill Tuner Process"
|
||||
icon="warning-sign"
|
||||
>
|
||||
<DialogBody>
|
||||
<p>Do you want to kill this running tuner process?</p>
|
||||
</DialogBody>
|
||||
<DialogFooter
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
text="Cancel"
|
||||
onClick={() => setKillTarget(null)}
|
||||
/>
|
||||
<Button
|
||||
intent="danger"
|
||||
text="Kill"
|
||||
onClick={() => {
|
||||
(async () => {
|
||||
await fetch(`/api/tuners/${killTarget}/process`, { method: "DELETE" });
|
||||
})();
|
||||
setKillTarget(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Dialog>
|
||||
|
||||
{/* Stream Info Detail Dialog */}
|
||||
<Dialog
|
||||
isOpen={!!streamDetail}
|
||||
onClose={() => setStreamDetail(null)}
|
||||
title="Stream Info"
|
||||
icon="cube"
|
||||
style={{ width: 500 }}
|
||||
>
|
||||
<DialogBody>
|
||||
{streamDetail && (
|
||||
<>
|
||||
<p className="bp5-text-muted">{streamDetail.userId}</p>
|
||||
<StreamInfoTable
|
||||
userId={streamDetail.userId}
|
||||
tuners={tunersEx}
|
||||
initialInfo={streamDetail.info}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DialogBody>
|
||||
<DialogFooter
|
||||
actions={
|
||||
<Button
|
||||
text="Close"
|
||||
onClick={() => setStreamDetail(null)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// --- HomeView ---
|
||||
|
||||
export const HomeView: React.FC = () => {
|
||||
console.debug("routes", "HomeView");
|
||||
|
||||
ui.setTitle("Home");
|
||||
|
||||
const [status, setStatus] = useState<Status>(state.status);
|
||||
const [services, setServices] = useState<Service[]>(state.services);
|
||||
const [tuners, setTuners] = useState<TunerDevice[]>(state.tuners);
|
||||
const [allowPNA, setAllowPNA] = useState<boolean>(false);
|
||||
const [tsplayEndpoint, setTsplayEndpoint] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
// fetch server config
|
||||
(async () => {
|
||||
try {
|
||||
if (!state.serverConfig) {
|
||||
await state.fetchServerConfig();
|
||||
}
|
||||
if (state.serverConfig) {
|
||||
setAllowPNA(state.serverConfig.allowPNA);
|
||||
setTsplayEndpoint(state.serverConfig.tsplayEndpoint);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onStatus = () => {
|
||||
setStatus({ ...state.status });
|
||||
};
|
||||
state.on("status", onStatus);
|
||||
|
||||
const onServices = () => {
|
||||
setServices([...state.services]);
|
||||
};
|
||||
state.on("services", onServices);
|
||||
|
||||
const onTuners = () => {
|
||||
setTuners([...state.tuners]);
|
||||
};
|
||||
state.on("tuners", onTuners);
|
||||
|
||||
return () => {
|
||||
state.off("status", onStatus);
|
||||
state.off("services", onServices);
|
||||
state.off("tuners", onTuners);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toolbar = (
|
||||
<Navbar className="toolbar">
|
||||
<Navbar.Group align={Alignment.START}>
|
||||
<Navbar.Heading>
|
||||
<Breadcrumbs items={[
|
||||
{
|
||||
text: "Home"
|
||||
}
|
||||
]} />
|
||||
</Navbar.Heading>
|
||||
</Navbar.Group>
|
||||
</Navbar>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="route" id="route-home-view">
|
||||
{toolbar}
|
||||
|
||||
<div className="content">
|
||||
<div className="home-container">
|
||||
<Section className="home-section" title="Status" icon="dashboard" compact>
|
||||
<div className="home-section-content">
|
||||
<StatusSection status={status} />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
className="home-section"
|
||||
title={`Services${services.length > 0 ? ` (${services.length})` : ""}`}
|
||||
icon="globe-network"
|
||||
compact
|
||||
>
|
||||
<div className="home-section-content">
|
||||
<ServicesSection
|
||||
status={status}
|
||||
services={services}
|
||||
allowPNA={allowPNA}
|
||||
tsplayEndpoint={tsplayEndpoint}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
className="home-section"
|
||||
title={`Tuners${tuners.length > 0 ? ` (${tuners.length})` : ""}`}
|
||||
icon="antenna"
|
||||
compact
|
||||
>
|
||||
<div className="home-section-content">
|
||||
<TunersSection tuners={tuners} />
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
48
web/src/routes/JobsView.sass
Normal file
48
web/src/routes/JobsView.sass
Normal file
@@ -0,0 +1,48 @@
|
||||
@use "~@blueprintjs/colors/lib/scss/colors"
|
||||
|
||||
#route-jobs-view
|
||||
.content
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 20px
|
||||
|
||||
> *
|
||||
flex-shrink: 0
|
||||
|
||||
.bp5-section
|
||||
background: rgba(colors.$light-gray5, 0.2)
|
||||
|
||||
.bp5-dark &
|
||||
background: rgba(colors.$black, 0.2)
|
||||
|
||||
.bp5-section-header
|
||||
|
||||
.bp5-dark &
|
||||
|
||||
&:hover,
|
||||
&:active
|
||||
background: rgba(colors.$black, 0.1)
|
||||
|
||||
.bp5-collapse-body
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 10px
|
||||
padding: 10px
|
||||
|
||||
.bp5-navbar,
|
||||
.bp5-navbar-group
|
||||
height: 30px
|
||||
|
||||
.bp5-navbar
|
||||
box-shadow: none
|
||||
background: none
|
||||
padding: 0 5px
|
||||
|
||||
.bp5-navbar-group
|
||||
gap: 10px
|
||||
|
||||
> span
|
||||
opacity: 0.75
|
||||
|
||||
&:hover .bp5-navbar-group > span
|
||||
opacity: 1
|
||||
510
web/src/routes/JobsView.tsx
Normal file
510
web/src/routes/JobsView.tsx
Normal file
@@ -0,0 +1,510 @@
|
||||
/*
|
||||
Copyright 2026 kanreisa
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Alignment, Spinner, Breadcrumbs, Navbar, NonIdealState, NonIdealStateProps, Section, Button, Dialog, DialogBody, DialogFooter, Tooltip, Icon } from "@blueprintjs/core";
|
||||
import { DateTime } from "luxon";
|
||||
import { useLocalStorageState } from "../hooks/useWebStorageState";
|
||||
import { LazyCaller } from "../modules/common";
|
||||
import { state } from "../modules/state";
|
||||
import * as ui from "../modules/ui";
|
||||
import { JobScheduleItem, JobItem, Error as ApiError } from "../../../api.d";
|
||||
|
||||
import "./JobsView.sass";
|
||||
|
||||
export const JobsView: React.FC = () => {
|
||||
console.debug("routes", "JobsView");
|
||||
|
||||
const [nonIdealState, setNonIdealState] = useState<NonIdealStateProps | null>(null);
|
||||
const [reload, setReload] = useState<number>(0);
|
||||
const [jobScheduleIsOpen, setJobScheduleIsOpen] = useLocalStorageState<boolean>("JobsView.jobScheduleIsOpen", true);
|
||||
const [queuedIsOpen, setQueuedIsOpen] = useLocalStorageState<boolean>("JobsView.queuedIsOpen", true);
|
||||
const [standbyIsOpen, setStandbyIsOpen] = useLocalStorageState<boolean>("JobsView.standbyIsOpen", true);
|
||||
const [runningIsOpen, setRunningIsOpen] = useLocalStorageState<boolean>("JobsView.runningIsOpen", true);
|
||||
const [finishedIsOpen, setFinishedIsOpen] = useLocalStorageState<boolean>("JobsView.finishedIsOpen", true);
|
||||
const [jobScheduleItems, setJobScheduleItems] = useState<JSX.Element[]>([]);
|
||||
const [queuedJobItems, setQueuedJobItems] = useState<JSX.Element[]>([]);
|
||||
const [standbyJobItems, setStandbyJobItems] = useState<JSX.Element[]>([]);
|
||||
const [runningJobItems, setRunningJobItems] = useState<JSX.Element[]>([]);
|
||||
const [finishedJobItems, setFinishedJobItems] = useState<JSX.Element[]>([]);
|
||||
const [title, setTitle] = useState<string>("ジョブ");
|
||||
// const isLoading = !programs && !error;
|
||||
|
||||
// Action dialog state
|
||||
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
|
||||
const [dialogType, setDialogType] = useState<"run_schedule" | "abort_job" | "rerun_job" | null>(null);
|
||||
const [selectedScheduleKey, setSelectedScheduleKey] = useState<string | null>(null);
|
||||
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
|
||||
const [isActionLoading, setIsActionLoading] = useState<boolean>(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const isLoading = !state.jobs && !state.jobSchedules;
|
||||
ui.setTitle(title, isLoading);
|
||||
|
||||
// API handlers for job operations
|
||||
const runJobSchedule = async (key: string): Promise<boolean> => {
|
||||
try {
|
||||
const res = await fetch(`/api/job-schedules/${encodeURIComponent(key)}/run`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json() as ApiError;
|
||||
setActionError(errorData.reason || `Error: ${res.status}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Re-fetch jobs and job schedules
|
||||
await state.fetchJobs();
|
||||
await state.fetchJobSchedules();
|
||||
setActionError(null);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setActionError(`リクエスト失敗: ${message}`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const abortJob = async (jobId: string): Promise<boolean> => {
|
||||
try {
|
||||
const res = await fetch(`/api/jobs/${encodeURIComponent(jobId)}/abort`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json() as ApiError;
|
||||
setActionError(errorData.reason || `Error: ${res.status}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Re-fetch jobs
|
||||
await state.fetchJobs();
|
||||
setActionError(null);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setActionError(`リクエスト失敗: ${message}`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const rerunJob = async (jobId: string): Promise<boolean> => {
|
||||
try {
|
||||
const res = await fetch(`/api/jobs/${encodeURIComponent(jobId)}/rerun`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json() as ApiError;
|
||||
setActionError(errorData.reason || `Error: ${res.status}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Re-fetch jobs
|
||||
await state.fetchJobs();
|
||||
setActionError(null);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setActionError(`リクエスト失敗: ${message}`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Dialog action handler
|
||||
const handleConfirm = async () => {
|
||||
setIsActionLoading(true);
|
||||
let success = false;
|
||||
|
||||
try {
|
||||
if (dialogType === "run_schedule" && selectedScheduleKey) {
|
||||
success = await runJobSchedule(selectedScheduleKey);
|
||||
} else if (dialogType === "abort_job" && selectedJobId) {
|
||||
success = await abortJob(selectedJobId);
|
||||
} else if (dialogType === "rerun_job" && selectedJobId) {
|
||||
success = await rerunJob(selectedJobId);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
setIsDialogOpen(false);
|
||||
setDialogType(null);
|
||||
setSelectedScheduleKey(null);
|
||||
setSelectedJobId(null);
|
||||
}
|
||||
} finally {
|
||||
setIsActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openDialog = (type: "run_schedule" | "abort_job" | "rerun_job", key?: string) => {
|
||||
setActionError(null);
|
||||
setDialogType(type);
|
||||
|
||||
if (type === "run_schedule" && key) {
|
||||
setSelectedScheduleKey(key);
|
||||
} else if (type !== "run_schedule" && key) {
|
||||
setSelectedJobId(key);
|
||||
}
|
||||
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onUpdated = () => {
|
||||
setReload(Date.now());
|
||||
};
|
||||
const onUpdatedLazy = new LazyCaller(0, 500, onUpdated);
|
||||
state.on("jobs", onUpdatedLazy.caller);
|
||||
state.on("jobSchedules", onUpdatedLazy.caller);
|
||||
|
||||
return () => {
|
||||
state.off("jobs", onUpdatedLazy.caller);
|
||||
state.off("jobSchedules", onUpdatedLazy.caller);
|
||||
onUpdatedLazy.destroy();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) {
|
||||
setTitle("ジョブ...");
|
||||
setNonIdealState({
|
||||
icon: <Spinner />,
|
||||
title: "ロード中",
|
||||
description: "ジョブを読み込んでいます..."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setJobScheduleItems(state.jobSchedules.map(jobSchedule => createJobScheduleItemElement(jobSchedule)));
|
||||
|
||||
setQueuedJobItems(state.jobs.filter(job => job.status === "queued").map(job => createJobItemElement(job)));
|
||||
setStandbyJobItems(state.jobs.filter(job => job.status === "standby").map(job => createJobItemElement(job)));
|
||||
setRunningJobItems(state.jobs.filter(job => job.status === "running").map(job => createJobItemElement(job)));
|
||||
setFinishedJobItems(state.jobs.filter(job => job.status === "finished").map(job => createJobItemElement(job)));
|
||||
|
||||
setNonIdealState(null);
|
||||
|
||||
return () => {
|
||||
setNonIdealState(null);
|
||||
};
|
||||
}, [reload]);
|
||||
|
||||
// Helper functions to create job/schedule items with closures over openDialog
|
||||
const createJobScheduleItemElement = (jobSchedule: JobScheduleItem) => {
|
||||
return (
|
||||
<Navbar key={jobSchedule.key}>
|
||||
<Navbar.Group align={Alignment.START}>
|
||||
<code className="bp5-code">
|
||||
{jobSchedule.schedule}
|
||||
</code>
|
||||
<span>
|
||||
{jobSchedule.job.name}
|
||||
</span>
|
||||
</Navbar.Group>
|
||||
|
||||
<Navbar.Group align={Alignment.END}>
|
||||
<Button
|
||||
variant="minimal"
|
||||
intent="warning"
|
||||
icon="play"
|
||||
text="実行..."
|
||||
onClick={() => openDialog("run_schedule", jobSchedule.key)}
|
||||
/>
|
||||
</Navbar.Group>
|
||||
</Navbar>
|
||||
);
|
||||
};
|
||||
|
||||
const createJobItemElement = (job: JobItem) => {
|
||||
const statusLabel = getJobStatusLabel(job);
|
||||
const statusIcon = getJobStatusIcon(job);
|
||||
const statusIntent = getJobStatusIntent(job);
|
||||
|
||||
// Build detail tooltip content
|
||||
const detailLines: string[] = [
|
||||
`ID: ${job.id}`,
|
||||
`Key: ${job.key}`
|
||||
];
|
||||
|
||||
if (job.retryMax) {
|
||||
detailLines.push(`リトライ: ${job.retryCount}/${job.retryMax}`);
|
||||
}
|
||||
|
||||
if (job.startedAt) {
|
||||
detailLines.push(`開始: ${DateTime.fromMillis(job.startedAt).toFormat("yyyy/MM/dd HH:mm:ss")}`);
|
||||
}
|
||||
|
||||
if (job.finishedAt) {
|
||||
detailLines.push(`終了: ${DateTime.fromMillis(job.finishedAt).toFormat("yyyy/MM/dd HH:mm:ss")}`);
|
||||
}
|
||||
|
||||
if (job.duration) {
|
||||
const durationSec = Math.round(job.duration / 1000);
|
||||
detailLines.push(`実行時間: ${durationSec}秒`);
|
||||
}
|
||||
|
||||
if (job.hasFailed && job.error) {
|
||||
detailLines.push(`エラー: ${job.error}`);
|
||||
}
|
||||
|
||||
if (job.hasAborted) {
|
||||
detailLines.push("状態: 中止済み");
|
||||
}
|
||||
|
||||
if (job.hasSkipped) {
|
||||
detailLines.push("状態: スキップ");
|
||||
}
|
||||
|
||||
const detailTooltip = detailLines.join("\n");
|
||||
|
||||
return (
|
||||
<Navbar key={job.id}>
|
||||
<Navbar.Group align={Alignment.START}>
|
||||
<Tooltip content={detailTooltip} position="right">
|
||||
<span className="bp5-text-muted" style={{ cursor: "help" }} title="詳細">
|
||||
{job.id.split(".").slice(-1)[0]}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span title={job.key} style={{ marginLeft: "0.5rem" }}>
|
||||
{job.name}
|
||||
</span>
|
||||
</Navbar.Group>
|
||||
|
||||
<Navbar.Group align={Alignment.END}>
|
||||
<span className="bp5-text-muted" style={{ marginLeft: "0.5rem" }}>
|
||||
<Icon icon={statusIcon} intent={statusIntent} />
|
||||
<span style={{ marginLeft: "0.35rem" }}>{statusLabel}</span>
|
||||
</span>
|
||||
|
||||
<Tooltip content={DateTime.fromMillis(job.updatedAt).toFormat("yyyy/MM/dd HH:mm:ss")}>
|
||||
<span className="bp5-text-muted">
|
||||
{DateTime.fromMillis(job.updatedAt).toRelative()}
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
{job.status !== "finished" && (
|
||||
<Button
|
||||
minimal
|
||||
small
|
||||
icon="stop"
|
||||
intent="danger"
|
||||
onClick={() => openDialog("abort_job", job.id)}
|
||||
title="ジョブを中止リクエスト"
|
||||
disabled={job.isAborting}
|
||||
/>
|
||||
)}
|
||||
|
||||
{job.status === "finished" && (
|
||||
<Button
|
||||
minimal
|
||||
small
|
||||
icon="refresh"
|
||||
intent="primary"
|
||||
onClick={() => openDialog("rerun_job", job.id)}
|
||||
title="ジョブを再実行"
|
||||
style={{ visibility: job.isRerunnable ? undefined : "hidden" }}
|
||||
/>
|
||||
)}
|
||||
</Navbar.Group>
|
||||
</Navbar>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="route" id="route-jobs-view">
|
||||
<Navbar className="toolbar">
|
||||
<Navbar.Group align={Alignment.START}>
|
||||
<Navbar.Heading>
|
||||
<Breadcrumbs items={[
|
||||
{
|
||||
text: "ジョブ"
|
||||
}
|
||||
]} />
|
||||
</Navbar.Heading>
|
||||
</Navbar.Group>
|
||||
|
||||
<Navbar.Group align={Alignment.END}>
|
||||
</Navbar.Group>
|
||||
</Navbar>
|
||||
|
||||
<div className="content">
|
||||
{!nonIdealState && <>
|
||||
<Section
|
||||
title="スケジュール"
|
||||
icon="time"
|
||||
collapsible
|
||||
collapseProps={{
|
||||
isOpen: jobScheduleIsOpen,
|
||||
onToggle: () => setJobScheduleIsOpen(!jobScheduleIsOpen),
|
||||
}}
|
||||
compact
|
||||
>
|
||||
{jobScheduleItems.map((item) => item)}
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="ジョブ"
|
||||
icon="ninja"
|
||||
collapsible
|
||||
compact
|
||||
>
|
||||
{queuedJobItems.length > 0 && (
|
||||
<Section
|
||||
title={`queued (${queuedJobItems.length})`}
|
||||
icon="time"
|
||||
collapsible
|
||||
collapseProps={{
|
||||
isOpen: queuedIsOpen,
|
||||
onToggle: () => setQueuedIsOpen(!queuedIsOpen),
|
||||
}}
|
||||
compact
|
||||
>
|
||||
{queuedJobItems.map((item) => item)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{standbyJobItems.length > 0 && (
|
||||
<Section
|
||||
title={`standby (${standbyJobItems.length})`}
|
||||
icon="stopwatch"
|
||||
collapsible
|
||||
collapseProps={{
|
||||
isOpen: standbyIsOpen,
|
||||
onToggle: () => setStandbyIsOpen(!standbyIsOpen),
|
||||
}}
|
||||
compact
|
||||
>
|
||||
{standbyJobItems.map((item) => item)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{runningJobItems.length > 0 && (
|
||||
<Section
|
||||
title={`running (${runningJobItems.length})`}
|
||||
icon="play"
|
||||
collapsible
|
||||
collapseProps={{
|
||||
isOpen: runningIsOpen,
|
||||
onToggle: () => setRunningIsOpen(!runningIsOpen),
|
||||
}}
|
||||
compact
|
||||
>
|
||||
{runningJobItems.map((item) => item)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{finishedJobItems.length > 0 && (
|
||||
<Section
|
||||
title={`finished (${finishedJobItems.length})`}
|
||||
icon="tick"
|
||||
collapsible
|
||||
collapseProps={{
|
||||
isOpen: finishedIsOpen,
|
||||
onToggle: () => setFinishedIsOpen(!finishedIsOpen),
|
||||
}}
|
||||
compact
|
||||
>
|
||||
{finishedJobItems.map((item) => item)}
|
||||
</Section>
|
||||
)}
|
||||
</Section>
|
||||
</>}
|
||||
|
||||
{nonIdealState && <>
|
||||
<NonIdealState {...nonIdealState} />
|
||||
</>}
|
||||
</div>
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
<Dialog
|
||||
isOpen={isDialogOpen}
|
||||
onClose={() => setIsDialogOpen(false)}
|
||||
title={
|
||||
dialogType === "run_schedule" ? "スケジュール実行" :
|
||||
dialogType === "abort_job" ? "ジョブ中止" :
|
||||
dialogType === "rerun_job" ? "ジョブ再実行" :
|
||||
"確認"
|
||||
}
|
||||
canEscapeKeyClose={!isActionLoading}
|
||||
>
|
||||
<DialogBody>
|
||||
{actionError && (
|
||||
<div className="bp5-text-intent-danger" style={{ marginBottom: "16px" }}>
|
||||
{actionError}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{dialogType === "run_schedule" && "このスケジュールのジョブを実行してもよろしいですか?"}
|
||||
{dialogType === "abort_job" && "このジョブの中止をリクエストしてもよろしいですか?"}
|
||||
{dialogType === "rerun_job" && "このジョブを再実行してもよろしいですか?"}
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter
|
||||
actions={
|
||||
<>
|
||||
<Button text="キャンセル" onClick={() => setIsDialogOpen(false)} disabled={isActionLoading} />
|
||||
<Button text="実行" intent="primary" onClick={handleConfirm} loading={isActionLoading} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function getJobStatusLabel(job: JobItem): string {
|
||||
if (job.status === "queued") {
|
||||
return "Queued...";
|
||||
}
|
||||
if (job.status === "standby") {
|
||||
return "Standby...";
|
||||
}
|
||||
if (job.status === "running") {
|
||||
return "Running...";
|
||||
}
|
||||
if (job.hasFailed) {
|
||||
return `Failed${job.duration ? ` (${Math.round(job.duration / 1000)}s)` : ""}`;
|
||||
}
|
||||
if (job.hasAborted) {
|
||||
return "Aborted";
|
||||
}
|
||||
if (job.hasSkipped) {
|
||||
return "Skipped";
|
||||
}
|
||||
return `Finished${job.duration ? ` (${Math.round(job.duration / 1000)}s)` : ""}`;
|
||||
}
|
||||
|
||||
function getJobStatusIcon(job: JobItem): any {
|
||||
if (job.status === "queued") return "time";
|
||||
if (job.status === "standby") return "stopwatch";
|
||||
if (job.status === "running") return "play";
|
||||
if (job.hasFailed) return "error";
|
||||
if (job.hasAborted) return "cross";
|
||||
if (job.hasSkipped) return "disable";
|
||||
return "tick";
|
||||
}
|
||||
|
||||
function getJobStatusIntent(job: JobItem): "none" | "primary" | "success" | "warning" | "danger" {
|
||||
if (job.status === "running") return "primary";
|
||||
if (job.status === "standby") return "warning";
|
||||
if (job.hasFailed) return "danger";
|
||||
if (job.hasAborted) return "warning";
|
||||
return "success";
|
||||
}
|
||||
44
web/src/routes/LogsView.sass
Normal file
44
web/src/routes/LogsView.sass
Normal file
@@ -0,0 +1,44 @@
|
||||
@use "~@blueprintjs/colors/lib/scss/colors"
|
||||
|
||||
#route-logs-view
|
||||
position: absolute
|
||||
top: 0
|
||||
right: 0
|
||||
bottom: 0
|
||||
left: 0
|
||||
overflow: scroll
|
||||
overflow-x: hidden
|
||||
background: colors.$dark-gray2
|
||||
|
||||
.logs
|
||||
padding: 8px 0
|
||||
font-family: 'Courier New', Courier, monospace
|
||||
font-size: 12px
|
||||
color: colors.$gray3
|
||||
|
||||
> div
|
||||
padding: 2px 16px
|
||||
word-break: break-all
|
||||
white-space: break-spaces
|
||||
|
||||
&:hover
|
||||
background: colors.$dark-gray1
|
||||
|
||||
> div.latest
|
||||
padding: 0
|
||||
|
||||
> div.level-debug
|
||||
color: colors.$indigo5
|
||||
|
||||
> div.level-info
|
||||
color: colors.$light-gray1
|
||||
|
||||
> div.level-warn
|
||||
color: colors.$orange3
|
||||
|
||||
> div.level-error
|
||||
color: colors.$red4
|
||||
|
||||
> div.level-fatal
|
||||
color: colors.$white
|
||||
background: colors.$red3
|
||||
104
web/src/routes/LogsView.tsx
Normal file
104
web/src/routes/LogsView.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
Copyright 2026 kanreisa
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Client as RPCClient } from "jsonrpc2-ws";
|
||||
import { JoinParams } from "../../types/rpc";
|
||||
import { state } from "../modules/state";
|
||||
import * as ui from "../modules/ui";
|
||||
|
||||
import "./LogsView.sass";
|
||||
|
||||
let _itemId = 0;
|
||||
let logListCache: JSX.Element[] = [];
|
||||
|
||||
export const LogsView: React.FC = () => {
|
||||
console.debug("routes", "LogsView");
|
||||
|
||||
ui.setTitle("ログ");
|
||||
|
||||
const [logList, setLogList] = useState<JSX.Element[]>([]);
|
||||
const latestRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const onLogs = (lines: string[], unshift: boolean) => {
|
||||
const newList: JSX.Element[] = [];
|
||||
for (const line of lines) {
|
||||
const parsed = line.match(/^[0-9.T:+-]+ ([a-z]+): /);
|
||||
const level = parsed ? parsed[1] : "other";
|
||||
newList.push(
|
||||
<div key={`logs-list-item${_itemId}`} className={`level-${level}`}>
|
||||
{line}
|
||||
</div>
|
||||
);
|
||||
++_itemId;
|
||||
}
|
||||
if (unshift === true) {
|
||||
logListCache = [...newList, ...logListCache].slice(-500);
|
||||
} else {
|
||||
logListCache = [...logListCache, ...newList].slice(-500);
|
||||
}
|
||||
setLogList(logListCache);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const rpc = (state as any)._rpc as RPCClient;
|
||||
|
||||
const join = () => {
|
||||
rpc.call("join", { rooms: ["logs"] } as JoinParams);
|
||||
};
|
||||
|
||||
rpc.on("connected", join);
|
||||
|
||||
// 既に接続済みなら即座に join、そうでなければ connected イベントを待つ
|
||||
if (rpc.isConnected()) {
|
||||
join();
|
||||
}
|
||||
|
||||
// 初期ログを取得
|
||||
(async () => {
|
||||
const lines: string = await (await fetch("/api/log")).text();
|
||||
onLogs(lines.trim().split("\n"), true);
|
||||
})();
|
||||
|
||||
// state の logs イベントをサブスクライブ
|
||||
const onLogsEvent = (lines: string[], unshift: boolean) => {
|
||||
onLogs(lines, unshift);
|
||||
};
|
||||
state.on("logs", onLogsEvent);
|
||||
|
||||
return () => {
|
||||
rpc.off("connected", join);
|
||||
if (rpc.isConnected()) {
|
||||
rpc.call("leave", { rooms: ["logs"] } as JoinParams);
|
||||
}
|
||||
state.off("logs", onLogsEvent);
|
||||
logListCache = [];
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
latestRef.current?.scrollIntoView();
|
||||
});
|
||||
|
||||
return (
|
||||
<div id="route-logs-view">
|
||||
<div className="logs">
|
||||
{logList}
|
||||
<div className="latest" ref={latestRef}></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
42
web/src/routes/ProgramView.sass
Normal file
42
web/src/routes/ProgramView.sass
Normal file
@@ -0,0 +1,42 @@
|
||||
@use "~@blueprintjs/colors/lib/scss/colors"
|
||||
|
||||
#route-program-view
|
||||
.content
|
||||
> *
|
||||
margin: 20px 0
|
||||
|
||||
&:first-child
|
||||
margin-top: 0
|
||||
|
||||
> .flex
|
||||
display: flex
|
||||
gap: 15px
|
||||
align-items: center
|
||||
|
||||
> .component-date-time-range
|
||||
color: colors.$gray2
|
||||
|
||||
.bp5-dark &
|
||||
color: colors.$gray4
|
||||
|
||||
> p,
|
||||
> .extended > p
|
||||
max-width: 650px
|
||||
white-space: pre-wrap
|
||||
font-feature-settings: "palt" 1
|
||||
|
||||
> .extended
|
||||
h4
|
||||
font-size: 13px
|
||||
font-weight: 600
|
||||
margin: 15px 0 10px
|
||||
|
||||
p
|
||||
margin-left: 15px
|
||||
|
||||
h4 + p
|
||||
margin-top: 10px
|
||||
|
||||
> p.meta
|
||||
font-size: 11px
|
||||
opacity: 0.5
|
||||
209
web/src/routes/ProgramView.tsx
Normal file
209
web/src/routes/ProgramView.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
/*
|
||||
Copyright 2026 kanreisa
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Alignment, Button, Breadcrumbs, Navbar, NonIdealState } from "@blueprintjs/core";
|
||||
import { DateTime } from "luxon";
|
||||
import { getGlobalServiceId, getIdWithHex } from "../modules/common";
|
||||
import { state } from "../modules/state";
|
||||
import * as ui from "../modules/ui";
|
||||
import * as regexp from "../modules/regexp";
|
||||
import { clearSchedule, setSchedule } from "../modules/at";
|
||||
import { Error, Program } from "../../../api.d";
|
||||
|
||||
import { ProgramTitle } from "../components/ProgramTitle";
|
||||
import { WatchButton } from "../components/WatchButton";
|
||||
import { DateTimeRange } from "../components/DateTimeRange";
|
||||
import { ServiceLink } from "../components/ServiceLink";
|
||||
import { ProgramGenres } from "../components/ProgramGenres";
|
||||
import { ProgramAVInfo } from "../components/ProgramAVInfo";
|
||||
import { ProgramRelatedLinks } from "../components/ProgramRelatedLinks";
|
||||
|
||||
import "./ProgramView.sass";
|
||||
|
||||
export const ProgramView: React.FC = () => {
|
||||
console.debug("routes", "ProgramView");
|
||||
|
||||
const [reload, setReload] = useState(Date.now()); // リロード用
|
||||
const [error, setError] = useState<Error>(null);
|
||||
const [program, setProgram] = useState<Program>(null);
|
||||
|
||||
const { navigate } = state;
|
||||
const params = useParams();
|
||||
const programId = parseInt(params.programId, 10);
|
||||
|
||||
const now = Date.now();
|
||||
const startTime = program?.startAt;
|
||||
const endTime = program ? program.startAt + program.duration : null;
|
||||
const isLoading = program === null && error === null;
|
||||
const isOnAir = program && startTime <= now && endTime >= now;
|
||||
const date = program && DateTime.fromMillis(program.startAt).set({ hour: 0, minute: 0, second: 0, millisecond: 0 });
|
||||
const time = program && DateTime.fromMillis(program.startAt).diff(date).toMillis();
|
||||
const timeForServiceLink = (time > (1000 * 60 * 60 * 24 - 1000 * 60 * 5)) ? 1 : time;
|
||||
|
||||
useEffect(() => {
|
||||
const onPrograms = (programs: Program[]) => {
|
||||
const _program = programs.find(p => p.id === programId);
|
||||
if (!_program) {
|
||||
setError({
|
||||
code: 404,
|
||||
reason: "番組が見つかりません",
|
||||
});
|
||||
setProgram(null);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setProgram(_program);
|
||||
};
|
||||
state.on("programs", onPrograms);
|
||||
state.subscribePrograms(true);
|
||||
|
||||
return () => {
|
||||
// // unsubscribe せずに差分更新を継続する
|
||||
// state.unsubscribePrograms();
|
||||
state.off("programs", onPrograms);
|
||||
}
|
||||
}, [programId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!program) {
|
||||
ui.setTitle("番組詳細...", true);
|
||||
return;
|
||||
}
|
||||
|
||||
ui.setTitle(program.name);
|
||||
|
||||
const schedules: ReturnType<typeof setSchedule>[] = [];
|
||||
if (now <= startTime) {
|
||||
schedules.push(setSchedule(startTime, () => setReload(Date.now())));
|
||||
}
|
||||
if (now <= endTime) {
|
||||
schedules.push(setSchedule(endTime, () => setReload(Date.now())));
|
||||
}
|
||||
|
||||
console.debug("ProgramView", program);
|
||||
|
||||
return () => {
|
||||
for (const id of schedules) {
|
||||
clearSchedule(id);
|
||||
}
|
||||
};
|
||||
}, [program]);
|
||||
|
||||
return (
|
||||
<div className="route" id="route-program-view">
|
||||
<Navbar className="toolbar">
|
||||
<Navbar.Group align={Alignment.START}>
|
||||
<Navbar.Heading>
|
||||
<Breadcrumbs items={[
|
||||
{
|
||||
text: "EPG",
|
||||
onClick: () => {
|
||||
navigate(`/epg?date=${date.toISODate()}&time=${time}`)
|
||||
}
|
||||
},
|
||||
{
|
||||
className: isLoading ? "bp5-skeleton" : "",
|
||||
text: isLoading ? "Loading................................." : (error ? "エラー" : (
|
||||
program ? <ProgramTitle program={program} /> : <></>
|
||||
))
|
||||
}
|
||||
]} />
|
||||
</Navbar.Heading>
|
||||
</Navbar.Group>
|
||||
|
||||
<Navbar.Group align={Alignment.END}>
|
||||
{isLoading && <>
|
||||
<Button className="bp5-skeleton" text="Loading............................" />
|
||||
</>}
|
||||
|
||||
{program && <>
|
||||
{isOnAir && <WatchButton variant="outlined" popoverPlacement="bottom-end" globalServiceId={getGlobalServiceId(program.networkId, program.serviceId)} />}
|
||||
</>}
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
intent="primary"
|
||||
icon="timeline-events"
|
||||
text="番組表で表示"
|
||||
onClick={() => {
|
||||
let to = "/epg";
|
||||
if (program) {
|
||||
to += `?date=${date.toISODate()}`;
|
||||
if (time) {
|
||||
to += `&time=${time}`;
|
||||
}
|
||||
to += `&programId=${program.id}`;
|
||||
}
|
||||
navigate(to);
|
||||
}}
|
||||
/>
|
||||
</Navbar.Group>
|
||||
</Navbar>
|
||||
|
||||
<div className="content">
|
||||
{program && <>
|
||||
<div className="flex">
|
||||
<ServiceLink
|
||||
globalId={getGlobalServiceId(program.networkId, program.serviceId)}
|
||||
date={date.toISODate()}
|
||||
time={timeForServiceLink}
|
||||
/>
|
||||
|
||||
<DateTimeRange start={program.startAt} end={program.startAt + program.duration} />
|
||||
</div>
|
||||
|
||||
{program.description && (
|
||||
<p className="description">{program.description.replace(regexp.enclosedAttributeUnicode, "")}</p>
|
||||
)}
|
||||
|
||||
{program.extended && Object.entries(program.extended).map(([head, body]) => {
|
||||
return <div className="extended" key={head}>
|
||||
<h4>{head}</h4>
|
||||
<p dangerouslySetInnerHTML={{ __html: ui.autoLink(body.trim()) }} />
|
||||
</div>;
|
||||
})}
|
||||
|
||||
{program.genres?.length > 0 && (
|
||||
<ProgramGenres genres={program.genres} />
|
||||
)}
|
||||
|
||||
{(program.video || program.audios) && (
|
||||
<ProgramAVInfo video={program.video} audios={program.audios} />
|
||||
)}
|
||||
|
||||
<p className="meta">
|
||||
Program ID: {program.id}<br />
|
||||
event_id: {getIdWithHex(program.eventId)}<br />
|
||||
SID: {getIdWithHex(program.serviceId)}<br />
|
||||
NID: {getIdWithHex(program.networkId)}
|
||||
</p>
|
||||
|
||||
<ProgramRelatedLinks program={program} />
|
||||
</>}
|
||||
|
||||
{error && <>
|
||||
<NonIdealState
|
||||
icon="warning-sign"
|
||||
title={`${error.code} Error`}
|
||||
description={error.reason || "エラーが発生しました"}
|
||||
/>
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
21
web/src/routes/SearchView.sass
Normal file
21
web/src/routes/SearchView.sass
Normal file
@@ -0,0 +1,21 @@
|
||||
@use "~@blueprintjs/colors/lib/scss/colors"
|
||||
|
||||
#route-search-view
|
||||
.content
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 15px
|
||||
|
||||
> .bp5-card
|
||||
max-width: 680px
|
||||
background-color: colors.$light-gray4
|
||||
|
||||
.bp5-dark &
|
||||
background-color: colors.$dark-gray4
|
||||
|
||||
.component-program-title
|
||||
font-size: 18px
|
||||
line-height: 1.3em
|
||||
|
||||
.name
|
||||
font-weight: 400
|
||||
160
web/src/routes/SearchView.tsx
Normal file
160
web/src/routes/SearchView.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
Copyright 2026 kanreisa
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Alignment, Spinner, Breadcrumbs, Navbar, NonIdealState, NonIdealStateProps, Card } from "@blueprintjs/core";
|
||||
import { LazyCaller, textMatch, normalizeText } from "../modules/common";
|
||||
import { state } from "../modules/state";
|
||||
import * as ui from "../modules/ui";
|
||||
import { Program } from "../../../api.d";
|
||||
|
||||
import { ProgramCardBase } from "../components/ProgramCardBase";
|
||||
|
||||
import "./SearchView.sass";
|
||||
|
||||
export const SearchView: React.FC = () => {
|
||||
console.debug("routes", "SearchView");
|
||||
|
||||
const [nonIdealState, setNonIdealState] = useState<NonIdealStateProps>(null);
|
||||
const [programs, setPrograms] = useState<Program[]>(null);
|
||||
const [result, setResult] = useState<JSX.Element[]>([]);
|
||||
const [title, setTitle] = useState<string>("検索");
|
||||
// const isLoading = !programs && !error;
|
||||
|
||||
const { navigate, searchParams } = state;
|
||||
const query = searchParams.get("q") || null;
|
||||
|
||||
const isLoading = query && !programs;
|
||||
ui.setTitle(title, isLoading);
|
||||
|
||||
useEffect(() => {
|
||||
const onPrograms = () => {
|
||||
const _programs = state.programs.filter(program => {
|
||||
// 共有イベントを除外
|
||||
if (program.relatedItems?.filter(item => item.type === "shared").length === 1) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
setPrograms(_programs);
|
||||
};
|
||||
const onProgramsLazy = new LazyCaller(0, 1000, onPrograms);
|
||||
state.on("programs", onProgramsLazy.caller);
|
||||
state.subscribePrograms(true);
|
||||
|
||||
return () => {
|
||||
// // unsubscribe せずに差分更新を継続する
|
||||
// state.unsubscribePrograms();
|
||||
state.off("programs", onProgramsLazy.caller);
|
||||
onProgramsLazy.destroy();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!query) {
|
||||
setTitle("検索");
|
||||
setResult([]);
|
||||
setNonIdealState({
|
||||
icon: "search",
|
||||
title: "検索キーワードを入力してください",
|
||||
description: "番組名、番組説明、サービス名、ジャンルなどで検索できます"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!programs) {
|
||||
setTitle("検索...");
|
||||
setNonIdealState({
|
||||
icon: <Spinner />,
|
||||
title: "ロード中",
|
||||
description: "番組一覧を読み込んでいます..."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const q = normalizeText(query.trim()).toLowerCase();
|
||||
const filteredPrograms: Program[] = [];
|
||||
|
||||
for (const p of programs) {
|
||||
if (
|
||||
(p.name && textMatch(p.name, q)) ||
|
||||
(p.description && textMatch(p.description, q)) ||
|
||||
(p.extended && textMatch(Object.entries(p.extended).flat().join(" "), q))
|
||||
) {
|
||||
filteredPrograms.push(p);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
filteredPrograms.sort((a, b) => {
|
||||
return a.startAt - b.startAt;
|
||||
});
|
||||
|
||||
const _result: JSX.Element[] = filteredPrograms.map(createResultItem);
|
||||
|
||||
setTitle(`検索 "${query}" (${_result.length}件)`);
|
||||
setResult(_result);
|
||||
setNonIdealState(null);
|
||||
|
||||
return () => {
|
||||
setNonIdealState(null);
|
||||
};
|
||||
}, [programs, query]);
|
||||
|
||||
return (
|
||||
<div className="route" id="route-search-view">
|
||||
<Navbar className="toolbar">
|
||||
<Navbar.Group align={Alignment.START}>
|
||||
<Navbar.Heading>
|
||||
<Breadcrumbs items={[
|
||||
{
|
||||
text: "EPG",
|
||||
onClick: () => navigate("/epg")
|
||||
},
|
||||
{
|
||||
text: "検索",
|
||||
className: `heading-title ${isLoading ? "bp5-skeleton" : ""}`.trim(),
|
||||
}
|
||||
]} />
|
||||
</Navbar.Heading>
|
||||
</Navbar.Group>
|
||||
|
||||
<Navbar.Group align={Alignment.END}>
|
||||
</Navbar.Group>
|
||||
</Navbar>
|
||||
|
||||
<div className="content">
|
||||
{!nonIdealState && result}
|
||||
|
||||
{nonIdealState && <>
|
||||
<NonIdealState {...nonIdealState} />
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function createResultItem(program: Program) {
|
||||
return (
|
||||
<Card key={program.id} elevation={0}>
|
||||
<ProgramCardBase
|
||||
program={program}
|
||||
noAVInfo={true}
|
||||
noActions={true}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
86
web/src/routes/ServerConfigView.sass
Normal file
86
web/src/routes/ServerConfigView.sass
Normal file
@@ -0,0 +1,86 @@
|
||||
@use "~@blueprintjs/colors/lib/scss/colors"
|
||||
|
||||
#route-server-config-view
|
||||
.content
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 16px
|
||||
padding-top: 20px
|
||||
|
||||
.config-section
|
||||
flex-shrink: 0
|
||||
background: rgba(colors.$light-gray5, 0.2)
|
||||
|
||||
&:last-child
|
||||
margin-bottom: 20px
|
||||
|
||||
.bp5-dark &
|
||||
background: rgba(colors.$black, 0.2)
|
||||
|
||||
.bp5-collapse-body
|
||||
padding: 0
|
||||
|
||||
> .bp5-section-card
|
||||
padding: 0
|
||||
|
||||
.bp5-section-header
|
||||
min-height: 44px
|
||||
|
||||
.bp5-dark &
|
||||
|
||||
&:hover,
|
||||
&:active
|
||||
background: rgba(colors.$black, 0.1)
|
||||
|
||||
.config-form-grid
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
.bp5-form-group
|
||||
padding: 14px 20px 16px
|
||||
margin: 0
|
||||
border-top: 1px solid rgba(colors.$light-gray1, 0.65)
|
||||
|
||||
.bp5-dark &
|
||||
border-top-color: rgba(colors.$dark-gray5, 0.7)
|
||||
|
||||
.bp5-label
|
||||
margin-bottom: 5px
|
||||
font-weight: 600
|
||||
|
||||
.bp5-html-select,
|
||||
.bp5-input-group
|
||||
width: min(100%, 200px)
|
||||
|
||||
.bp5-numeric-input
|
||||
width: min(100%, 100px)
|
||||
|
||||
textarea.bp5-input
|
||||
width: min(100%, 400px)
|
||||
min-height: 88px
|
||||
resize: vertical
|
||||
|
||||
.bp5-html-select select,
|
||||
.bp5-input,
|
||||
.bp5-numeric-input .bp5-input-group
|
||||
width: 100%
|
||||
|
||||
.bp5-numeric-input
|
||||
.bp5-button-group
|
||||
flex-shrink: 0
|
||||
|
||||
.bp5-control
|
||||
display: flex
|
||||
align-items: center
|
||||
min-height: 30px
|
||||
margin-bottom: 0
|
||||
|
||||
.bp5-form-helper-text
|
||||
max-width: 680px
|
||||
margin-top: 6px
|
||||
line-height: 1.45
|
||||
|
||||
.config-switch-group
|
||||
.bp5-control
|
||||
width: fit-content
|
||||
|
||||
584
web/src/routes/ServerConfigView.tsx
Normal file
584
web/src/routes/ServerConfigView.tsx
Normal file
@@ -0,0 +1,584 @@
|
||||
/*
|
||||
Copyright 2026 kanreisa
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Alignment,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogFooter,
|
||||
FormGroup,
|
||||
HTMLSelect,
|
||||
InputGroup,
|
||||
Intent,
|
||||
Navbar,
|
||||
NonIdealState,
|
||||
NumericInput,
|
||||
Section,
|
||||
Spinner,
|
||||
Switch,
|
||||
TextArea
|
||||
} from "@blueprintjs/core";
|
||||
import equal from "fast-deep-equal";
|
||||
import { Validator as IPValidator } from "ip-num/Validator";
|
||||
import { state } from "../modules/state";
|
||||
import * as ui from "../modules/ui";
|
||||
import { ConfigServer, LogLevel } from "../../../api.d";
|
||||
|
||||
import "./ServerConfigView.sass";
|
||||
|
||||
const configAPI = "/api/config/server";
|
||||
|
||||
const multilineConfigValue = (values?: string[] | null) => (values ?? []).join("\n");
|
||||
|
||||
const parseMultilineConfigValue = (value: string): string[] | null => {
|
||||
const trimmedValue = value.trim();
|
||||
if (trimmedValue === "") {
|
||||
return null;
|
||||
}
|
||||
return trimmedValue.split("\n").map(line => line.trim());
|
||||
};
|
||||
|
||||
export const ServerConfigView: React.FC = () => {
|
||||
console.debug("routes", "ServerConfigView");
|
||||
|
||||
const [current, setCurrent] = useState<ConfigServer | null>(null);
|
||||
const [editing, setEditing] = useState<ConfigServer | null>(null);
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
ui.setTitle("サーバー設定", isLoading);
|
||||
|
||||
const [allowIPv4CidrRangesText, setAllowIPv4CidrRangesText] = useState("");
|
||||
const [allowIPv6CidrRangesText, setAllowIPv6CidrRangesText] = useState("");
|
||||
const [allowOriginsText, setAllowOriginsText] = useState("");
|
||||
|
||||
const syncMultilineConfigValues = (config: ConfigServer) => {
|
||||
setAllowIPv4CidrRangesText(multilineConfigValue(config.allowIPv4CidrRanges));
|
||||
setAllowIPv6CidrRangesText(multilineConfigValue(config.allowIPv6CidrRanges));
|
||||
setAllowOriginsText(multilineConfigValue(config.allowOrigins));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (saved === true) {
|
||||
setTimeout(() => {
|
||||
// location.reload();
|
||||
}, 500);
|
||||
setSaved(false);
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await (await fetch(configAPI)).json();
|
||||
console.log("ServerConfigView", "GET", configAPI, "->", res);
|
||||
setEditing({ ...res });
|
||||
setCurrent({ ...res });
|
||||
syncMultilineConfigValues(res);
|
||||
setIsLoading(false);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setIsLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [saved]);
|
||||
|
||||
const docker = (state as any).status?.process?.env?.DOCKER === "YES";
|
||||
const ipv6Ready = docker === false || (state as any).status?.process?.env?.DOCKER_NETWORK === "host";
|
||||
|
||||
let invalid = false;
|
||||
let invalidEpgGatheringJobSchedule = false;
|
||||
let invalidAllowIPv4CidrRanges = false;
|
||||
let invalidAllowIPv6CidrRanges = false;
|
||||
|
||||
if (editing) {
|
||||
if (editing.epgGatheringJobSchedule) {
|
||||
if (!isValidCronExpression(editing.epgGatheringJobSchedule)) {
|
||||
invalid = true;
|
||||
invalidEpgGatheringJobSchedule = true;
|
||||
}
|
||||
}
|
||||
if (editing.allowIPv4CidrRanges) {
|
||||
for (const range of editing.allowIPv4CidrRanges) {
|
||||
const [valid] = IPValidator.isValidIPv4CidrRange(range);
|
||||
if (!valid) {
|
||||
invalid = true;
|
||||
invalidAllowIPv4CidrRanges = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!invalid && editing.allowIPv6CidrRanges) {
|
||||
for (const range of editing.allowIPv6CidrRanges) {
|
||||
const [valid] = IPValidator.isValidIPv6CidrRange(range);
|
||||
if (!valid) {
|
||||
invalid = true;
|
||||
invalidAllowIPv6CidrRanges = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasChanges = editing !== null && current !== null && !equal(editing, current);
|
||||
|
||||
const handleCancel = () => {
|
||||
if (current) {
|
||||
setEditing({ ...current });
|
||||
syncMultilineConfigValues(current);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editing) {
|
||||
return;
|
||||
}
|
||||
|
||||
setShowSaveDialog(false);
|
||||
try {
|
||||
const payload: { [key: string]: any } = { ...editing };
|
||||
for (const key of Object.keys(payload)) {
|
||||
if (payload[key] === null) {
|
||||
delete payload[key];
|
||||
}
|
||||
}
|
||||
console.log("ServerConfigView", "PUT", configAPI, "<-", payload);
|
||||
await fetch(configAPI, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
setSaved(true);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const toolbar = (
|
||||
<Navbar className="toolbar">
|
||||
<Navbar.Group align={Alignment.START}>
|
||||
<Navbar.Heading>
|
||||
<Breadcrumbs items={[
|
||||
{
|
||||
text: "サーバー設定"
|
||||
}
|
||||
]} />
|
||||
</Navbar.Heading>
|
||||
</Navbar.Group>
|
||||
|
||||
<Navbar.Group align={Alignment.END}>
|
||||
<Button
|
||||
minimal
|
||||
intent="danger"
|
||||
icon="undo"
|
||||
text="Cancel"
|
||||
disabled={!hasChanges}
|
||||
onClick={handleCancel}
|
||||
/>
|
||||
<Button
|
||||
intent="primary"
|
||||
icon="saved"
|
||||
text="Save"
|
||||
disabled={!hasChanges || invalid}
|
||||
onClick={() => setShowSaveDialog(true)}
|
||||
/>
|
||||
</Navbar.Group>
|
||||
</Navbar>
|
||||
);
|
||||
|
||||
if (isLoading || !editing) {
|
||||
return (
|
||||
<div className="route" id="route-server-config-view">
|
||||
{toolbar}
|
||||
<NonIdealState
|
||||
icon={<Spinner />}
|
||||
title="ロード中"
|
||||
description="設定を読み込んでいます..."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="route" id="route-server-config-view">
|
||||
{toolbar}
|
||||
|
||||
<div className="content">
|
||||
<Section
|
||||
className="config-section"
|
||||
title="Basic Config"
|
||||
icon="settings"
|
||||
compact
|
||||
>
|
||||
<div className="config-form-grid">
|
||||
<FormGroup
|
||||
label="Log Level"
|
||||
labelFor="log-level"
|
||||
helperText="ログ出力設定。通常運用では WARN を推奨します。問題が発生した時に変更し、ログを確認してください。"
|
||||
>
|
||||
<HTMLSelect
|
||||
id="log-level"
|
||||
value={editing.logLevel ?? 2}
|
||||
onChange={(e) => {
|
||||
setEditing({ ...editing, logLevel: parseInt(e.target.value, 10) as LogLevel });
|
||||
}}
|
||||
>
|
||||
<option value="-1">FATAL (-1)</option>
|
||||
<option value="0">ERROR (0)</option>
|
||||
<option value="1">WARN (1)</option>
|
||||
<option value="2">INFO (2)</option>
|
||||
<option value="3">DEBUG (3)</option>
|
||||
</HTMLSelect>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
label="Hostname"
|
||||
labelFor="hostname"
|
||||
helperText="Web UI にアクセスするためのホスト名を設定してください。任意のホスト名・ドメイン上のページからのアクセスを禁止しています。 (DNS Rebinding / CSRF 攻撃対策)"
|
||||
>
|
||||
<InputGroup
|
||||
id="hostname"
|
||||
value={editing.hostname ?? ""}
|
||||
onChange={(e) => {
|
||||
setEditing({ ...editing, hostname: e.target.value });
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{ipv6Ready && (
|
||||
<FormGroup
|
||||
className="config-switch-group"
|
||||
labelFor="disable-ipv6"
|
||||
helperText="IPv6 の無効化 (よく分からない場合は ON)"
|
||||
>
|
||||
<Switch
|
||||
id="disable-ipv6"
|
||||
checked={editing.disableIPv6 ?? false}
|
||||
label="Disable IPv6"
|
||||
onChange={(e) => {
|
||||
setEditing({ ...editing, disableIPv6: e.currentTarget.checked });
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
className="config-section"
|
||||
title="Advanced Config"
|
||||
icon="wrench"
|
||||
compact
|
||||
>
|
||||
<div className="config-form-grid">
|
||||
<FormGroup
|
||||
label="Job Max Running"
|
||||
labelFor="job-max-running"
|
||||
helperText="同時実行できる最大ジョブ数"
|
||||
>
|
||||
<NumericInput
|
||||
id="job-max-running"
|
||||
value={editing.jobMaxRunning ?? ""}
|
||||
placeholder="100"
|
||||
min={1}
|
||||
max={100}
|
||||
onValueChange={(value, _) => {
|
||||
if (value === null) {
|
||||
delete editing.jobMaxRunning;
|
||||
} else {
|
||||
editing.jobMaxRunning = value;
|
||||
}
|
||||
setEditing({ ...editing });
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
label="Job Max Standby"
|
||||
labelFor="job-max-standby"
|
||||
helperText="同時実行できる最大ジョブ準備数"
|
||||
>
|
||||
<NumericInput
|
||||
id="job-max-standby"
|
||||
value={editing.jobMaxStandby ?? ""}
|
||||
placeholder="100"
|
||||
min={1}
|
||||
max={100}
|
||||
onValueChange={(value, _) => {
|
||||
if (value === null) {
|
||||
delete editing.jobMaxStandby;
|
||||
} else {
|
||||
editing.jobMaxStandby = value;
|
||||
}
|
||||
setEditing({ ...editing });
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
label="EPG Gathering Job Schedule (Cron)"
|
||||
labelFor="epg-gathering-schedule"
|
||||
helperText={invalidEpgGatheringJobSchedule ? "Cron expression is invalid." : "EPG 収集スケジュール (cron 風形式)"}
|
||||
intent={invalidEpgGatheringJobSchedule ? Intent.DANGER : Intent.NONE}
|
||||
>
|
||||
<InputGroup
|
||||
id="epg-gathering-schedule"
|
||||
value={editing.epgGatheringJobSchedule ?? ""}
|
||||
placeholder="20,50 * * * *"
|
||||
onChange={(e) => {
|
||||
editing.epgGatheringJobSchedule = e.target.value;
|
||||
setEditing({ ...editing });
|
||||
}}
|
||||
intent={invalidEpgGatheringJobSchedule ? Intent.DANGER : Intent.NONE}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
label="Max Buffer Bytes Before Ready (MB)"
|
||||
labelFor="max-buffer-bytes"
|
||||
helperText="番組イベント検出前の最大バッファサイズ (バイト) ※番組開始の頭が欠ける場合は増やす"
|
||||
>
|
||||
<NumericInput
|
||||
id="max-buffer-bytes"
|
||||
value={editing.maxBufferBytesBeforeReady ? Math.round(editing.maxBufferBytesBeforeReady / 1024 / 1024) : ""}
|
||||
placeholder="8"
|
||||
min={1}
|
||||
max={64}
|
||||
onValueChange={(value, _) => {
|
||||
if (value === null) {
|
||||
delete editing.maxBufferBytesBeforeReady;
|
||||
} else {
|
||||
editing.maxBufferBytesBeforeReady = value * 1024 * 1024;
|
||||
}
|
||||
setEditing({ ...editing });
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
label="Event End Timeout (sec)"
|
||||
labelFor="event-end-timeout"
|
||||
helperText="番組イベント終了タイムアウト (ミリ秒) ※番組終了が誤判定される場合は長くする"
|
||||
>
|
||||
<NumericInput
|
||||
id="event-end-timeout"
|
||||
value={editing.eventEndTimeout ?? ""}
|
||||
placeholder="1000"
|
||||
min={1}
|
||||
max={10000}
|
||||
onValueChange={(value, _) => {
|
||||
if (value === null) {
|
||||
delete editing.eventEndTimeout;
|
||||
} else {
|
||||
editing.eventEndTimeout = value;
|
||||
}
|
||||
setEditing({ ...editing });
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
className="config-switch-group"
|
||||
labelFor="disable-eit-parsing"
|
||||
helperText="EIT 解析の無効化 (EPG 関連機能が無効になります)"
|
||||
intent={editing.disableEITParsing ? Intent.WARNING : Intent.NONE}
|
||||
>
|
||||
<Switch
|
||||
id="disable-eit-parsing"
|
||||
checked={editing.disableEITParsing ?? false}
|
||||
label="Disable EIT Parsing ⚠️"
|
||||
onChange={(e) => {
|
||||
setEditing({ ...editing, disableEITParsing: e.currentTarget.checked ? true : undefined });
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
className="config-section"
|
||||
title="Network Config"
|
||||
icon="globe-network"
|
||||
compact
|
||||
>
|
||||
<div className="config-form-grid">
|
||||
<FormGroup
|
||||
className="config-form-wide"
|
||||
label="Allow IPv4 CIDR Ranges ⚠️"
|
||||
labelFor="allow-ipv4-cidrs"
|
||||
helperText={invalidAllowIPv4CidrRanges ? "IPv4 CIDR range is invalid." : "アクセスを許可する IPv4 CIDR 範囲を1行に1つずつ指定 ⚠️ 最大限の注意が必要な設定です (グローバル IPv4 アドレスを指定しないでください)"}
|
||||
intent={invalidAllowIPv4CidrRanges ? Intent.DANGER : Intent.NONE}
|
||||
>
|
||||
<TextArea
|
||||
id="allow-ipv4-cidrs"
|
||||
value={allowIPv4CidrRangesText}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setAllowIPv4CidrRangesText(newValue);
|
||||
setEditing({ ...editing, allowIPv4CidrRanges: parseMultilineConfigValue(newValue) });
|
||||
}}
|
||||
rows={3}
|
||||
intent={invalidAllowIPv4CidrRanges ? Intent.DANGER : Intent.NONE}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
className="config-form-wide"
|
||||
label="Allow IPv6 CIDR Ranges ⚠️"
|
||||
labelFor="allow-ipv6-cidrs"
|
||||
helperText={invalidAllowIPv6CidrRanges ? "IPv6 CIDR range is invalid." : "アクセスを許可する IPv6 CIDR 範囲を1行に1つずつ指定 ⚠️ 最大限の注意が必要な設定です (グローバル IPv6 アドレスを指定しないでください)"}
|
||||
intent={invalidAllowIPv6CidrRanges ? Intent.DANGER : Intent.NONE}
|
||||
>
|
||||
<TextArea
|
||||
id="allow-ipv6-cidrs"
|
||||
value={allowIPv6CidrRangesText}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setAllowIPv6CidrRangesText(newValue);
|
||||
setEditing({ ...editing, allowIPv6CidrRanges: parseMultilineConfigValue(newValue) });
|
||||
}}
|
||||
rows={3}
|
||||
intent={invalidAllowIPv6CidrRanges ? Intent.DANGER : Intent.NONE}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
className="config-form-wide"
|
||||
label="Allow Origins ⚠️🧪"
|
||||
labelFor="allow-origins"
|
||||
helperText="アクセスを許可する Origin を1行に1つずつ指定"
|
||||
>
|
||||
<TextArea
|
||||
id="allow-origins"
|
||||
value={allowOriginsText}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setAllowOriginsText(newValue);
|
||||
setEditing({ ...editing, allowOrigins: parseMultilineConfigValue(newValue) });
|
||||
}}
|
||||
rows={3}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
className="config-switch-group"
|
||||
labelFor="allow-pna"
|
||||
helperText="Private Network Access / Local Network Access を許可 (ブラウザで保護されたコンテキストからのアクセスを認可できるようになります)"
|
||||
>
|
||||
<Switch
|
||||
id="allow-pna"
|
||||
checked={editing.allowPNA ?? true}
|
||||
label="Allow PNA/LNA 🧪"
|
||||
onChange={(e) => {
|
||||
setEditing({ ...editing, allowPNA: e.currentTarget.checked });
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
className="config-section"
|
||||
title="Other Config"
|
||||
icon="more"
|
||||
compact
|
||||
>
|
||||
<div className="config-form-grid">
|
||||
<FormGroup
|
||||
label="TSPlay Endpoint 🧪"
|
||||
labelFor="tsplay-endpoint"
|
||||
helperText="TSPlay で使用するエンドポイント URL (保護されたコンテキスト)"
|
||||
>
|
||||
<InputGroup
|
||||
id="tsplay-endpoint"
|
||||
value={editing.tsplayEndpoint ?? ""}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value.trim();
|
||||
if (newValue === "") {
|
||||
setEditing({ ...editing, tsplayEndpoint: null });
|
||||
} else {
|
||||
setEditing({ ...editing, tsplayEndpoint: newValue });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
{/* Save Confirmation Dialog */}
|
||||
<Dialog
|
||||
isOpen={showSaveDialog}
|
||||
onClose={() => setShowSaveDialog(false)}
|
||||
title="Save"
|
||||
>
|
||||
<DialogBody>
|
||||
<p>設定を保存しますか?</p>
|
||||
<p className="bp5-text-muted">適用するには再起動が必要です。</p>
|
||||
</DialogBody>
|
||||
<DialogFooter
|
||||
actions={
|
||||
<>
|
||||
<Button onClick={() => setShowSaveDialog(false)}>キャンセル</Button>
|
||||
<Button
|
||||
intent="primary"
|
||||
disabled={!hasChanges || invalid}
|
||||
onClick={handleSave}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// (仮) src/Mirakurun/Job.ts にある関数と同じ
|
||||
function isValidCronExpression(cronExpression: string): boolean {
|
||||
const cronParts = cronExpression.split(" ");
|
||||
if (cronParts.length !== 5) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
// 各部分のパターンを定義
|
||||
const patterns = [
|
||||
/^(\*|([0-9]|[1-5][0-9])((-[0-9]|[1-5][0-9]))?)(\/([1-9]|[1-5][0-9]))?$/, // 分 (0-59)
|
||||
/^(\*|([0-9]|1[0-9]|2[0-3])((-[0-9]|1[0-9]|2[0-3]))?)(\/([1-9]|1[0-9]|2[0-3]))?$/, // 時 (0-23)
|
||||
/^(\*|([1-9]|[12][0-9]|3[01])((-[1-9]|[12][0-9]|3[01]))?)(\/([1-9]|[12][0-9]|3[01]))?$/, // 日 (1-31)
|
||||
/^(\*|([1-9]|1[0-2])((-[1-9]|1[0-2]))?)(\/([1-9]|1[0-2]))?$/, // 月 (1-12)
|
||||
/^(\*|([0-6])((-[0-6]))?)(\/([1-6]))?$/ // 曜日 (0-6)
|
||||
];
|
||||
|
||||
// 各部分を検証
|
||||
for (let i = 0; i < 5; i++) {
|
||||
// カンマで区切られた値をすべて検証
|
||||
const parts = cronParts[i].split(",");
|
||||
for (const part of parts) {
|
||||
if (part === "" || !patterns[i].test(part)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
61
web/src/routes/TunersConfigView.sass
Normal file
61
web/src/routes/TunersConfigView.sass
Normal file
@@ -0,0 +1,61 @@
|
||||
@use "~@blueprintjs/colors/lib/scss/colors"
|
||||
|
||||
#route-tuners-config-view
|
||||
.content
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 16px
|
||||
padding: 20px
|
||||
overflow-y: auto
|
||||
|
||||
.tuner-table
|
||||
width: 100%
|
||||
border-collapse: collapse
|
||||
|
||||
th, td
|
||||
vertical-align: top !important
|
||||
padding: 12px 8px !important
|
||||
|
||||
td
|
||||
.bp5-form-group
|
||||
margin-bottom: 8px
|
||||
&:last-child
|
||||
margin-bottom: 0
|
||||
|
||||
.bp5-label
|
||||
margin-bottom: 3px
|
||||
font-weight: 600
|
||||
font-size: 11px
|
||||
color: colors.$gray1
|
||||
|
||||
.bp5-dark &
|
||||
color: colors.$gray4
|
||||
|
||||
.types-checkboxes
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 4px
|
||||
margin-top: 6px
|
||||
|
||||
.bp5-control
|
||||
margin-bottom: 0
|
||||
|
||||
.remote-mirakurun-group
|
||||
display: flex
|
||||
gap: 8px
|
||||
align-items: flex-end
|
||||
margin-bottom: 8px
|
||||
|
||||
.bp5-form-group
|
||||
margin-bottom: 0 !important
|
||||
|
||||
.tuner-options-grid
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 8px
|
||||
|
||||
.controls-cell
|
||||
display: flex
|
||||
gap: 4px
|
||||
justify-content: flex-end
|
||||
align-items: center
|
||||
431
web/src/routes/TunersConfigView.tsx
Normal file
431
web/src/routes/TunersConfigView.tsx
Normal file
@@ -0,0 +1,431 @@
|
||||
/*
|
||||
Copyright 2026 kanreisa
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Alignment,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogFooter,
|
||||
FormGroup,
|
||||
InputGroup,
|
||||
Navbar,
|
||||
NonIdealState,
|
||||
Spinner,
|
||||
Switch,
|
||||
HTMLTable
|
||||
} from "@blueprintjs/core";
|
||||
import equal from "fast-deep-equal";
|
||||
import { state } from "../modules/state";
|
||||
import * as ui from "../modules/ui";
|
||||
import { ConfigTuners, ConfigTunersItem, ChannelType } from "../../../api.d";
|
||||
|
||||
import "./TunersConfigView.sass";
|
||||
|
||||
const configAPI = "/api/config/tuners";
|
||||
const typesIndex = ["GR", "BS", "CS", "SKY"];
|
||||
|
||||
function sortTypes(types: ChannelType[]): ChannelType[] {
|
||||
return types.sort((a, b) => typesIndex.indexOf(a) - typesIndex.indexOf(b));
|
||||
}
|
||||
|
||||
export const TunersConfigView: React.FC = () => {
|
||||
console.debug("routes", "TunersConfigView");
|
||||
|
||||
const [current, setCurrent] = useState<ConfigTuners | null>(null);
|
||||
const [editing, setEditing] = useState<ConfigTuners | null>(null);
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
ui.setTitle("チューナー設定", isLoading);
|
||||
|
||||
useEffect(() => {
|
||||
if (saved === true) {
|
||||
setTimeout(() => {
|
||||
// Restart notification will be emitted in production when requested
|
||||
}, 500);
|
||||
setSaved(false);
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await (await fetch(configAPI)).json();
|
||||
console.log("TunersConfigView", "GET", configAPI, "->", res);
|
||||
setEditing(JSON.parse(JSON.stringify(res)));
|
||||
setCurrent(JSON.parse(JSON.stringify(res)));
|
||||
setIsLoading(false);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setIsLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [saved]);
|
||||
|
||||
const hasChanges = editing !== null && current !== null && !equal(editing, current);
|
||||
|
||||
const handleCancel = () => {
|
||||
if (current) {
|
||||
setEditing(JSON.parse(JSON.stringify(current)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editing) {
|
||||
return;
|
||||
}
|
||||
|
||||
setShowSaveDialog(false);
|
||||
try {
|
||||
console.log("TunersConfigView", "PUT", configAPI, "<-", editing);
|
||||
await fetch(configAPI, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
body: JSON.stringify(editing)
|
||||
});
|
||||
setSaved(true);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddTuner = () => {
|
||||
if (!editing) return;
|
||||
const i = editing.length;
|
||||
const newTuner: ConfigTunersItem = {
|
||||
name: `adapter${i}`,
|
||||
types: [],
|
||||
command: `dvbv5-zap -a ${i} -c ./config/dvbconf-for-isdb/conf/dvbv5_channels_isdbs.conf -r -P <channel>`,
|
||||
dvbDevicePath: `/dev/dvb/adapter${i}/dvr0`,
|
||||
decoder: "arib-b25-stream-test",
|
||||
isDisabled: true
|
||||
};
|
||||
setEditing([...editing, newTuner]);
|
||||
};
|
||||
|
||||
const updateTuner = (index: number, updated: Partial<ConfigTunersItem>) => {
|
||||
if (!editing) return;
|
||||
const newEditing = [...editing];
|
||||
newEditing[index] = { ...newEditing[index], ...updated };
|
||||
setEditing(newEditing);
|
||||
};
|
||||
|
||||
const deleteTunerProperty = (index: number, key: keyof ConfigTunersItem) => {
|
||||
if (!editing) return;
|
||||
const newEditing = [...editing];
|
||||
const updated = { ...newEditing[index] };
|
||||
delete updated[key];
|
||||
newEditing[index] = updated;
|
||||
setEditing(newEditing);
|
||||
};
|
||||
|
||||
const handleUp = (i: number) => {
|
||||
if (!editing || i === 0) return;
|
||||
const newEditing = [...editing];
|
||||
const temp = newEditing[i];
|
||||
newEditing[i] = newEditing[i - 1];
|
||||
newEditing[i - 1] = temp;
|
||||
setEditing(newEditing);
|
||||
};
|
||||
|
||||
const handleDown = (i: number) => {
|
||||
if (!editing || i === editing.length - 1) return;
|
||||
const newEditing = [...editing];
|
||||
const temp = newEditing[i];
|
||||
newEditing[i] = newEditing[i + 1];
|
||||
newEditing[i + 1] = temp;
|
||||
setEditing(newEditing);
|
||||
};
|
||||
|
||||
const handleRemove = (i: number) => {
|
||||
if (!editing) return;
|
||||
const newEditing = [...editing];
|
||||
newEditing.splice(i, 1);
|
||||
setEditing(newEditing);
|
||||
};
|
||||
|
||||
const toolbar = (
|
||||
<Navbar className="toolbar">
|
||||
<Navbar.Group align={Alignment.START}>
|
||||
<Navbar.Heading>
|
||||
<Breadcrumbs items={[
|
||||
{
|
||||
text: "チューナー設定"
|
||||
}
|
||||
]} />
|
||||
</Navbar.Heading>
|
||||
</Navbar.Group>
|
||||
|
||||
<Navbar.Group align={Alignment.END}>
|
||||
<Button
|
||||
minimal
|
||||
intent="success"
|
||||
icon="add"
|
||||
text="Add Tuner"
|
||||
onClick={handleAddTuner}
|
||||
/>
|
||||
|
||||
<Navbar.Divider />
|
||||
|
||||
<Button
|
||||
minimal
|
||||
intent="danger"
|
||||
icon="undo"
|
||||
text="Cancel"
|
||||
disabled={!hasChanges}
|
||||
onClick={handleCancel}
|
||||
/>
|
||||
<Button
|
||||
intent="primary"
|
||||
icon="saved"
|
||||
text="Save"
|
||||
disabled={!hasChanges}
|
||||
onClick={() => setShowSaveDialog(true)}
|
||||
/>
|
||||
</Navbar.Group>
|
||||
</Navbar>
|
||||
);
|
||||
|
||||
if (isLoading || !editing) {
|
||||
return (
|
||||
<div className="route" id="route-tuners-config-view">
|
||||
{toolbar}
|
||||
<NonIdealState
|
||||
icon={<Spinner />}
|
||||
title="ロード中"
|
||||
description="設定を読み込んでいます..."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="route" id="route-tuners-config-view">
|
||||
{toolbar}
|
||||
|
||||
<div className="content">
|
||||
<HTMLTable className="tuner-table" striped interactive>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: "80px" }}>Enable</th>
|
||||
<th style={{ width: "180px" }}>Name</th>
|
||||
<th style={{ width: "120px" }}>Types</th>
|
||||
<th>Options</th>
|
||||
<th style={{ width: "140px", textAlign: "right" }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{editing.map((tuner, i) => (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
<Switch
|
||||
checked={!tuner.isDisabled}
|
||||
onChange={(e) => {
|
||||
updateTuner(i, { isDisabled: !e.currentTarget.checked });
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<InputGroup
|
||||
value={tuner.name || ""}
|
||||
onChange={(e) => {
|
||||
updateTuner(i, { name: e.target.value });
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="types-checkboxes">
|
||||
{(["GR", "BS", "CS", "SKY"] as ChannelType[]).map((type) => {
|
||||
const checked = tuner.types?.includes(type) ?? false;
|
||||
return (
|
||||
<Checkbox
|
||||
key={type}
|
||||
label={type}
|
||||
checked={checked}
|
||||
inline
|
||||
onChange={(e) => {
|
||||
let newTypes = [...(tuner.types || [])];
|
||||
if (e.currentTarget.checked) {
|
||||
newTypes.push(type);
|
||||
newTypes = sortTypes(newTypes);
|
||||
} else {
|
||||
newTypes = newTypes.filter(t => t !== type);
|
||||
}
|
||||
updateTuner(i, { types: newTypes });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="tuner-options-grid">
|
||||
{!tuner.remoteMirakurunHost && (
|
||||
<>
|
||||
<FormGroup label="Command">
|
||||
<InputGroup
|
||||
value={tuner.command || ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (val === "") {
|
||||
deleteTunerProperty(i, "command");
|
||||
} else {
|
||||
updateTuner(i, { command: val });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup label="DVB Device Path">
|
||||
<InputGroup
|
||||
value={tuner.dvbDevicePath || ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (val === "") {
|
||||
deleteTunerProperty(i, "dvbDevicePath");
|
||||
} else {
|
||||
updateTuner(i, { dvbDevicePath: val });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
</>
|
||||
)}
|
||||
{!tuner.command && (
|
||||
<>
|
||||
<div className="remote-mirakurun-group">
|
||||
<FormGroup label="Remote Mirakurun Host" style={{ flex: 1 }}>
|
||||
<InputGroup
|
||||
value={tuner.remoteMirakurunHost || ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (val === "") {
|
||||
deleteTunerProperty(i, "remoteMirakurunHost");
|
||||
} else if (/^[0-9a-z\.]+$/.test(val)) {
|
||||
updateTuner(i, { remoteMirakurunHost: val });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup label="Port" style={{ width: "90px" }}>
|
||||
<InputGroup
|
||||
placeholder="40772"
|
||||
value={`${tuner.remoteMirakurunPort || ""}`}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (val === "") {
|
||||
deleteTunerProperty(i, "remoteMirakurunPort");
|
||||
} else if (/^[0-9]+$/.test(val)) {
|
||||
const port = parseInt(val, 10);
|
||||
if (port <= 65535 && port > 0) {
|
||||
updateTuner(i, { remoteMirakurunPort: port });
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
<div style={{ marginBottom: "8px" }}>
|
||||
<Checkbox
|
||||
label="Decode (Remote Mirakurun Decoder)"
|
||||
checked={tuner.remoteMirakurunDecoder || false}
|
||||
onChange={(e) => {
|
||||
if (e.currentTarget.checked) {
|
||||
updateTuner(i, { remoteMirakurunDecoder: true });
|
||||
} else {
|
||||
deleteTunerProperty(i, "remoteMirakurunDecoder");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{(!tuner.remoteMirakurunHost || !tuner.remoteMirakurunDecoder) && (
|
||||
<FormGroup label="Decoder">
|
||||
<InputGroup
|
||||
value={tuner.decoder || ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (val === "") {
|
||||
deleteTunerProperty(i, "decoder");
|
||||
} else {
|
||||
updateTuner(i, { decoder: val });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="controls-cell">
|
||||
<Button
|
||||
disabled={i === 0}
|
||||
icon="chevron-up"
|
||||
onClick={() => handleUp(i)}
|
||||
minimal
|
||||
/>
|
||||
<Button
|
||||
disabled={i === editing.length - 1}
|
||||
icon="chevron-down"
|
||||
onClick={() => handleDown(i)}
|
||||
minimal
|
||||
/>
|
||||
<Button
|
||||
icon="trash"
|
||||
intent="danger"
|
||||
onClick={() => handleRemove(i)}
|
||||
minimal
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</HTMLTable>
|
||||
</div>
|
||||
|
||||
{/* Save Confirmation Dialog */}
|
||||
<Dialog
|
||||
isOpen={showSaveDialog}
|
||||
onClose={() => setShowSaveDialog(false)}
|
||||
title="Save"
|
||||
>
|
||||
<DialogBody>
|
||||
<p>設定を保存しますか?</p>
|
||||
<p className="bp5-text-muted">適用するには再起動が必要です。</p>
|
||||
</DialogBody>
|
||||
<DialogFooter
|
||||
actions={
|
||||
<>
|
||||
<Button onClick={() => setShowSaveDialog(false)}>キャンセル</Button>
|
||||
<Button
|
||||
intent="primary"
|
||||
disabled={!hasChanges}
|
||||
onClick={handleSave}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
29
web/src/tsconfig.json
Normal file
29
web/src/tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"compilerOptions": {
|
||||
"alwaysStrict": true,
|
||||
"target": "es2022",
|
||||
"lib": [
|
||||
"es2022",
|
||||
"dom",
|
||||
"dom.iterable"
|
||||
],
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"removeComments": true,
|
||||
"sourceMap": true,
|
||||
"incremental": true,
|
||||
"esModuleInterop": false,
|
||||
"jsx": "react",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"outDir": "../dist"
|
||||
},
|
||||
"include": [
|
||||
"./**/*.ts",
|
||||
"./**/*.tsx",
|
||||
"./custom.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
19
web/src/vars.sass
Normal file
19
web/src/vars.sass
Normal file
@@ -0,0 +1,19 @@
|
||||
@keyframes fade-out
|
||||
from
|
||||
opacity: 1
|
||||
to
|
||||
opacity: 0
|
||||
|
||||
@keyframes fade-in
|
||||
from
|
||||
opacity: 0
|
||||
to
|
||||
opacity: 1
|
||||
|
||||
$invert-filter: brightness(52%) invert(100%) hue-rotate(180deg) saturate(300%) contrast(150%)
|
||||
|
||||
$font-base: -apple-system, "BlinkMacSystemFont", "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Open Sans", "Helvetica Neue", "Yu Gothic", sans-serif
|
||||
$font-ui: -apple-system, "BlinkMacSystemFont", "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Open Sans", "Helvetica Neue", "Yu Gothic UI", sans-serif
|
||||
|
||||
$theme-dark-primary: #ffd56c
|
||||
$theme-light-primary: #ffc126
|
||||
7
web/types/rpc.d.ts
vendored
Normal file
7
web/types/rpc.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface JoinParams {
|
||||
rooms: string[];
|
||||
}
|
||||
|
||||
export interface NotifyParams<T> {
|
||||
array: T[];
|
||||
}
|
||||
74
web/webpack.config.js
Normal file
74
web/webpack.config.js
Normal file
@@ -0,0 +1,74 @@
|
||||
const path = require("path");
|
||||
const webpack = require("webpack");
|
||||
const CopyPlugin = require("copy-webpack-plugin");
|
||||
|
||||
module.exports = {
|
||||
mode: process.env.NODE_ENV === "development" ? "development" : "production",
|
||||
devtool: process.env.NODE_ENV === "development" ? "source-map" : false,
|
||||
entry: {
|
||||
index: path.resolve(__dirname, "src/index.tsx")
|
||||
},
|
||||
output: {
|
||||
path: path.resolve(__dirname, "dist"),
|
||||
filename: "[name].bundle.js",
|
||||
clean: true
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
use: [{
|
||||
loader: "ts-loader",
|
||||
options: {
|
||||
configFile: path.resolve(__dirname, "src/tsconfig.json")
|
||||
}
|
||||
}]
|
||||
},
|
||||
{
|
||||
test: /\.s?[ac]ss$/,
|
||||
use: ["style-loader", "css-loader", "sass-loader"]
|
||||
},
|
||||
{
|
||||
test: /\.(png|woff|woff2|eot|ttf)$/,
|
||||
type: "asset/resource",
|
||||
generator: {
|
||||
filename: "assets/[hash][ext]"
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.svg$/,
|
||||
type: "asset/inline"
|
||||
}
|
||||
]
|
||||
},
|
||||
resolve: {
|
||||
extensions: [".ts", ".tsx", ".js", ".json"]
|
||||
},
|
||||
plugins: [
|
||||
new CopyPlugin({
|
||||
patterns: [{
|
||||
from: "**/*.{html,svg}",
|
||||
to: "[path][name][ext]",
|
||||
context: path.resolve(__dirname, "src")
|
||||
}]
|
||||
}),
|
||||
new webpack.ProvidePlugin({
|
||||
Buffer: ["buffer", "Buffer"]
|
||||
}),
|
||||
new webpack.ProvidePlugin({
|
||||
process: "process/browser.js"
|
||||
})
|
||||
],
|
||||
optimization: {
|
||||
splitChunks: {
|
||||
cacheGroups: {
|
||||
vendors: {
|
||||
test: /node_modules/,
|
||||
name: "vendors",
|
||||
chunks: "all",
|
||||
enforce: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user