This commit is contained in:
sup39 2022-06-18 19:25:01 +09:00
commit d71716b35b
24 changed files with 9549 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
dist/

21
LICENSE Normal file
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,3 @@
node_modules/
build/
yarn-error.log

View 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
View 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"
]
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

BIN
images/icon128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

BIN
images/icon16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
images/icon32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
images/icon48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

1
main.css Symbolic link
View file

@ -0,0 +1 @@
frontend/build/main.css

1
main.js Symbolic link
View file

@ -0,0 +1 @@
frontend/build/main.js

23
manifest.json Normal file
View 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"
}
}