init
This commit is contained in:
commit
d71716b35b
24 changed files with 9549 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
dist/
|
21
LICENSE
Normal file
21
LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2022 sup39
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
24
README.md
Normal file
24
README.md
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
# いぬぼき学習進捗管理
|
||||||
|
[いぬぼき学習サイト](https://inuboki.com/)で学習進捗を管理するための拡張機能
|
||||||
|
|
||||||
|
## 対応ブラウザ
|
||||||
|
- [Brave](https://brave.com/ja/download/)
|
||||||
|
- Chrome
|
||||||
|
|
||||||
|
## 機能
|
||||||
|
- 文章一覧ページで各文章の学習状況(なし、0%、50%、100%)を記録
|
||||||
|
- 文章一覧ページで学習状況の集計(学習済み・未学習の数と割合)
|
||||||
|
|
||||||
|
### TODO
|
||||||
|
- 文章ページで学習状況を記録
|
||||||
|
|
||||||
|
## インストール
|
||||||
|
まず、releaseページでzipファイルをダウンロードして解凍します。
|
||||||
|
|
||||||
|
次に、Braveなら[brave://extensions](brave://extensions)、
|
||||||
|
Chromeなら[chrome://extensions](chrome://extensions)をアクセスし、
|
||||||
|
右上の「デベロッパーモード」スイッチを有効にすると、
|
||||||
|
左上に三つのボタンが出てくるはずです。
|
||||||
|
|
||||||
|
「パッケージ化されていない拡張機能を読み込む」ボタンを押し、
|
||||||
|
先程解凍したフォルダを選択するとインストールされます。
|
20
background.js
Normal file
20
background.js
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
/* chrome.runtime.onInstalled.addListener(() => {
|
||||||
|
chrome.storage.sync.set({});
|
||||||
|
}); */
|
||||||
|
|
||||||
|
chrome.runtime.onConnect.addListener(port => {
|
||||||
|
const {name: pageKey} = port;
|
||||||
|
port.onMessage.addListener(({topic, payload}) => {
|
||||||
|
if (topic === 'setProgress') {
|
||||||
|
chrome.storage.sync.get(pageKey, ({[pageKey]: data}) => {
|
||||||
|
const {key, item} = payload;
|
||||||
|
chrome.storage.sync.set({[pageKey]: {...data, [key]: item}});
|
||||||
|
port.postMessage({topic, payload});
|
||||||
|
});
|
||||||
|
} else if (topic === 'getAllProgress') {
|
||||||
|
chrome.storage.sync.get(pageKey, ({[pageKey]: data}) => {
|
||||||
|
port.postMessage({topic: 'setAllProgress', payload: data ?? {}});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
5
deploy
Executable file
5
deploy
Executable file
|
@ -0,0 +1,5 @@
|
||||||
|
#!/bin/sh
|
||||||
|
cd "$(realpath "$(dirname "$0")")"
|
||||||
|
|
||||||
|
mkdir -p dist/
|
||||||
|
cp -Lr manifest.json background.js images/ main.js main.css dist/
|
3
frontend/.gitignore
vendored
Normal file
3
frontend/.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
yarn-error.log
|
20
frontend/config-overrides.js
Normal file
20
frontend/config-overrides.js
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
module.exports = function override(config, _env) {
|
||||||
|
// Get rid of hash for js files
|
||||||
|
config.output.filename = '[name].js';
|
||||||
|
config.output.chunkFilename = '[name].chunk.js';
|
||||||
|
|
||||||
|
// Get rid of hash for css files
|
||||||
|
const cssPlugin = config.plugins.find(element => element.constructor.name === 'MiniCssExtractPlugin');
|
||||||
|
cssPlugin.options.filename = '[name].css';
|
||||||
|
cssPlugin.options.chunkFilename = '[name].css';
|
||||||
|
|
||||||
|
// Get rid of hash for media files
|
||||||
|
/* config.module.rules[1]?.oneOf.forEach(oneOf => {
|
||||||
|
if (!oneOf.options || oneOf.options.name !== 'static/media/[name].[hash:8].[ext]') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
oneOf.options.name = 'static/media/[name].[ext]';
|
||||||
|
}); */
|
||||||
|
|
||||||
|
return config;
|
||||||
|
};
|
58
frontend/package.json
Normal file
58
frontend/package.json
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
{
|
||||||
|
"name": "inuboki-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"homepage": ".",
|
||||||
|
"license": "MIT",
|
||||||
|
"author": "sup39 <dev@sup39.dev> (https://github.com/sup39/supStudy-inuboki)",
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "^1.8.2",
|
||||||
|
"@sup39/eslint-config-typescript": "^0.1.1",
|
||||||
|
"@testing-library/jest-dom": "^5.14.1",
|
||||||
|
"@testing-library/react": "^12.0.0",
|
||||||
|
"@testing-library/user-event": "^13.2.1",
|
||||||
|
"@types/chrome": "^0.0.190",
|
||||||
|
"@types/jest": "^27.0.1",
|
||||||
|
"@types/node": "^16.7.13",
|
||||||
|
"@types/react": "^17.0.20",
|
||||||
|
"@types/react-dom": "^17.0.9",
|
||||||
|
"dotenv-cli": "^5.1.0",
|
||||||
|
"react": "^17.0.2",
|
||||||
|
"react-app-rewired": "^2.2.1",
|
||||||
|
"react-dom": "^17.0.2",
|
||||||
|
"react-redux": "^8.0.2",
|
||||||
|
"react-scripts": "5.0.0",
|
||||||
|
"sass": "^1.51.0",
|
||||||
|
"typescript": "^4.4.2",
|
||||||
|
"web-vitals": "^2.1.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "react-scripts start",
|
||||||
|
"build": "GENERATE_SOURCEMAP=false react-app-rewired build",
|
||||||
|
"build:dev": "GENERATE_SOURCEMAP=false dotenv -e .env.development react-app-rewired build",
|
||||||
|
"test": "react-app-rewired test",
|
||||||
|
"eject": "react-scripts eject"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": [
|
||||||
|
"react-app",
|
||||||
|
"react-app/jest",
|
||||||
|
"@sup39/eslint-config-typescript"
|
||||||
|
],
|
||||||
|
"globals": {
|
||||||
|
"chrome": false
|
||||||
|
},
|
||||||
|
"rules": {}
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
6
frontend/public/index.html
Normal file
6
frontend/public/index.html
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
<div class="sidelong__article">
|
||||||
|
<a href="https://inuboki.com/2q-syoubo-kouza/chapter0-1/">0-1</a>
|
||||||
|
</div>
|
||||||
|
<div class="sidelong__article">
|
||||||
|
<a href="https://inuboki.com/2q-syoubo-kouza/chapter0-2/">0-2</a>
|
||||||
|
</div>
|
19
frontend/src/App.tsx
Normal file
19
frontend/src/App.tsx
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import {useSelector} from './store';
|
||||||
|
import {ProgressItem, selectProgress} from './reducer';
|
||||||
|
|
||||||
|
type Port = ReturnType<typeof chrome.runtime.connect>;
|
||||||
|
|
||||||
|
const progressValues = [null, 0, 1, 2] as (null|ProgressItem['progress'])[];
|
||||||
|
export default function App({itemKey, port}: {itemKey: string, port: Port}) {
|
||||||
|
const progress = useSelector(selectProgress)[itemKey]?.progress ?? null;
|
||||||
|
|
||||||
|
return <table className="sidelong__link ProgressItemRoot"><tr>{progressValues.map(val =>
|
||||||
|
<td key={val}
|
||||||
|
className={'ProgressItem_'+val+(val===progress ? ' selected' : '')}
|
||||||
|
onClick={() => port.postMessage({topic: 'setProgress', payload: {
|
||||||
|
key: itemKey,
|
||||||
|
item: val == null ? null : {progress: val, lastStudy: new Date()},
|
||||||
|
}})}
|
||||||
|
/>,
|
||||||
|
)}</tr></table>;
|
||||||
|
}
|
25
frontend/src/Summary.tsx
Normal file
25
frontend/src/Summary.tsx
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import {useSelector} from './store';
|
||||||
|
import {selectProgress} from './reducer';
|
||||||
|
|
||||||
|
export default function Summary({totalCount}: {totalCount: number}) {
|
||||||
|
const doneCount = Object.values(useSelector(selectProgress))
|
||||||
|
.reduce((a, v)=>a+(v?.progress??0), 0)/2;
|
||||||
|
const leftCount = totalCount-doneCount;
|
||||||
|
|
||||||
|
return <p>
|
||||||
|
<table className="ProgressSummary">
|
||||||
|
<tr>
|
||||||
|
<td>完成</td>
|
||||||
|
<td><span>{doneCount.toFixed(1)}</span> / <span>{totalCount.toFixed(1)}</span></td>
|
||||||
|
<td>=</td>
|
||||||
|
<td><span>{(doneCount/totalCount*100).toFixed(2)}%</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>残り</td>
|
||||||
|
<td><span>{leftCount.toFixed(1)}</span> / <span>{totalCount.toFixed(1)}</span></td>
|
||||||
|
<td>=</td>
|
||||||
|
<td><span>{(leftCount/totalCount*100).toFixed(2)}%</span></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</p>;
|
||||||
|
}
|
56
frontend/src/index.sass
Normal file
56
frontend/src/index.sass
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
.ProgressItemRoot
|
||||||
|
border-collapse: collapse
|
||||||
|
margin: 0
|
||||||
|
padding: 0 10px 10px
|
||||||
|
td
|
||||||
|
width: 4em
|
||||||
|
height: 4em
|
||||||
|
position: relative
|
||||||
|
border: 1px solid #666
|
||||||
|
cursor: pointer
|
||||||
|
td:before
|
||||||
|
content: ''
|
||||||
|
position: absolute
|
||||||
|
top: 50%
|
||||||
|
left: 50%
|
||||||
|
transform: translate(-50%, -50%)
|
||||||
|
width: 34%
|
||||||
|
height: 34%
|
||||||
|
border-radius: 8px
|
||||||
|
|
||||||
|
/* gray */
|
||||||
|
.ProgressItem_null:before
|
||||||
|
background: #bbb
|
||||||
|
.ProgressItem_null:hover
|
||||||
|
background: #eee
|
||||||
|
.ProgressItem_null:active, .ProgressItem_null.selected
|
||||||
|
background: #ddd
|
||||||
|
/* red */
|
||||||
|
.ProgressItem_0:before
|
||||||
|
background: #fbb
|
||||||
|
.ProgressItem_0:hover
|
||||||
|
background: #fee
|
||||||
|
.ProgressItem_0:active, .ProgressItem_0.selected
|
||||||
|
background: #fdd
|
||||||
|
/* yellow */
|
||||||
|
.ProgressItem_1:before
|
||||||
|
background: #f4f494
|
||||||
|
.ProgressItem_1:hover
|
||||||
|
background: #ffe
|
||||||
|
.ProgressItem_1:active, .ProgressItem_1.selected
|
||||||
|
background: #ffc
|
||||||
|
/* blue */
|
||||||
|
.ProgressItem_2:before
|
||||||
|
background: #bff
|
||||||
|
.ProgressItem_2:hover
|
||||||
|
background: #eff
|
||||||
|
.ProgressItem_2:active, .ProgressItem_2.selected
|
||||||
|
background: #dff
|
||||||
|
|
||||||
|
.ProgressSummary
|
||||||
|
border: none !important
|
||||||
|
width: unset !important
|
||||||
|
tr, td
|
||||||
|
border: none !important
|
||||||
|
td
|
||||||
|
text-align: right
|
62
frontend/src/index.tsx
Normal file
62
frontend/src/index.tsx
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom';
|
||||||
|
import {Provider} from 'react-redux';
|
||||||
|
import store from './store';
|
||||||
|
import {setAllProgress, setProgress} from './reducer';
|
||||||
|
import './index.sass';
|
||||||
|
import App from './App';
|
||||||
|
import Summary from './Summary';
|
||||||
|
|
||||||
|
const articles = document.querySelectorAll('.sidelong__article');
|
||||||
|
if (articles.length) {
|
||||||
|
const pageKey = window.location.pathname.replace(/^\/|\/$/g, '');
|
||||||
|
|
||||||
|
const port = chrome.runtime.connect({name: pageKey});
|
||||||
|
port.onMessage.addListener(({topic, payload}) => {
|
||||||
|
if (topic === 'setAllProgress') {
|
||||||
|
store.dispatch(setAllProgress(payload));
|
||||||
|
} else if (topic === 'setProgress') {
|
||||||
|
store.dispatch(setProgress(payload));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
port.postMessage({topic: 'getAllProgress'});
|
||||||
|
|
||||||
|
/* const port: any = {
|
||||||
|
postMessage({topic, payload}: {topic: string, payload: any}) {
|
||||||
|
if (topic !== 'setProgress') return;
|
||||||
|
setTimeout(() => {
|
||||||
|
store.dispatch(setProgress(payload));
|
||||||
|
}, 1000);
|
||||||
|
},
|
||||||
|
}; */
|
||||||
|
|
||||||
|
document.querySelectorAll('.sidelong__article').forEach(p => {
|
||||||
|
const href = p.querySelector('a')?.href;
|
||||||
|
if (href == null) return;
|
||||||
|
const itemKey = href.split('/').slice(-2)[0];
|
||||||
|
|
||||||
|
const div = document.createElement('div');
|
||||||
|
p.appendChild(div);
|
||||||
|
|
||||||
|
ReactDOM.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<Provider store={store}>
|
||||||
|
<App itemKey={itemKey} port={port}/>
|
||||||
|
</Provider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
div,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const summary = document.querySelector('.entry-content > p');
|
||||||
|
if (summary) { // TODO
|
||||||
|
ReactDOM.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<Provider store={store}>
|
||||||
|
<Summary totalCount={articles.length} />
|
||||||
|
</Provider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
summary,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
31
frontend/src/reducer.ts
Normal file
31
frontend/src/reducer.ts
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
import {createSlice, PayloadAction} from '@reduxjs/toolkit';
|
||||||
|
import {RootState} from './store';
|
||||||
|
|
||||||
|
export type ProgressItem = {
|
||||||
|
lastStudy: Date,
|
||||||
|
progress: 0 | 1 | 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
items: {} as {[key: string]: ProgressItem|null},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mainSlice = createSlice({
|
||||||
|
name: 'main',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
setProgress(state, {payload: {key, item}}: PayloadAction<{
|
||||||
|
key: string
|
||||||
|
item: null|ProgressItem
|
||||||
|
}>) {
|
||||||
|
state.items[key] = item;
|
||||||
|
},
|
||||||
|
setAllProgress(state, {payload}: PayloadAction<typeof state.items>) {
|
||||||
|
state.items = payload;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const mainReducer = mainSlice.reducer;
|
||||||
|
export const {setProgress, setAllProgress} = mainSlice.actions;
|
||||||
|
export const selectProgress = (state: RootState) => state.main.items;
|
13
frontend/src/store.ts
Normal file
13
frontend/src/store.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import {configureStore} from '@reduxjs/toolkit';
|
||||||
|
import {mainReducer} from './reducer';
|
||||||
|
import * as Redux from 'react-redux';
|
||||||
|
|
||||||
|
const store = configureStore({
|
||||||
|
reducer: {
|
||||||
|
main: mainReducer,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
export type RootState = ReturnType<typeof store.getState>;
|
||||||
|
export const useDispatch = () => Redux.useDispatch<typeof store.dispatch>();
|
||||||
|
export const useSelector: Redux.TypedUseSelectorHook<RootState> = Redux.useSelector;
|
||||||
|
export default store;
|
27
frontend/tsconfig.json
Normal file
27
frontend/tsconfig.json
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"downlevelIteration": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
]
|
||||||
|
}
|
9133
frontend/yarn.lock
Normal file
9133
frontend/yarn.lock
Normal file
File diff suppressed because it is too large
Load diff
BIN
images/icon128.png
Normal file
BIN
images/icon128.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.1 KiB |
BIN
images/icon16.png
Normal file
BIN
images/icon16.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.3 KiB |
BIN
images/icon32.png
Normal file
BIN
images/icon32.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.2 KiB |
BIN
images/icon48.png
Normal file
BIN
images/icon48.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.8 KiB |
1
main.css
Symbolic link
1
main.css
Symbolic link
|
@ -0,0 +1 @@
|
||||||
|
frontend/build/main.css
|
1
main.js
Symbolic link
1
main.js
Symbolic link
|
@ -0,0 +1 @@
|
||||||
|
frontend/build/main.js
|
23
manifest.json
Normal file
23
manifest.json
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"name": "いぬぼき学習進捗管理",
|
||||||
|
"description": "いぬぼき学習サイトで学習進捗を管理するための拡張機能",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"manifest_version": 3,
|
||||||
|
"background": {
|
||||||
|
"service_worker": "background.js"
|
||||||
|
},
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": ["https://inuboki.com/*"],
|
||||||
|
"css": ["main.css"],
|
||||||
|
"js": ["main.js"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"permissions": ["storage"],
|
||||||
|
"icons": {
|
||||||
|
"16": "/images/icon16.png",
|
||||||
|
"32": "/images/icon32.png",
|
||||||
|
"48": "/images/icon48.png",
|
||||||
|
"128": "/images/icon128.png"
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue