wip
This commit is contained in:
parent
2c2385ac59
commit
d94f2c91fb
11 changed files with 160 additions and 30 deletions
|
@ -1,18 +1,7 @@
|
||||||
import { config } from '../../config';
|
import { config } from '../../config';
|
||||||
import { User } from '../models/entities/user';
|
import { User } from '../models/entities/user';
|
||||||
import { Score } from '../../common/types/score';
|
import { Score } from '../../common/types/score';
|
||||||
|
import { defaultTemplate } from '../../common/default-template';
|
||||||
/**
|
|
||||||
* デフォルトの投稿用テンプレート
|
|
||||||
*/
|
|
||||||
export const defaultTemplate = `昨日のMisskeyの活動は
|
|
||||||
|
|
||||||
ノート: {notesCount}({notesDelta})
|
|
||||||
フォロー : {followingCount}({followingDelta})
|
|
||||||
フォロワー :{followersCount}({followersDelta})
|
|
||||||
|
|
||||||
でした。
|
|
||||||
{url}`;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 埋め込み変数の型
|
* 埋め込み変数の型
|
||||||
|
|
|
@ -9,6 +9,7 @@ import { toSignedString } from './to-signed-string';
|
||||||
* @returns ユーザーのスコア
|
* @returns ユーザーのスコア
|
||||||
*/
|
*/
|
||||||
export const getScores = async (user: User): Promise<Score> => {
|
export const getScores = async (user: User): Promise<Score> => {
|
||||||
|
// TODO 毎回取ってくるのも微妙なので、ある程度キャッシュしたいかも
|
||||||
const miUser = await api<Record<string, number>>(user.host, 'users/show', { username: user.username }, user.token);
|
const miUser = await api<Record<string, number>>(user.host, 'users/show', { username: user.username }, user.token);
|
||||||
if (miUser.error) {
|
if (miUser.error) {
|
||||||
throw miUser.error;
|
throw miUser.error;
|
||||||
|
|
|
@ -13,7 +13,7 @@ import { AlertMode, alertModes } from '../common/types/alert-mode';
|
||||||
import { Users } from './models';
|
import { Users } from './models';
|
||||||
import { sendAlert } from './services/send-alert';
|
import { sendAlert } from './services/send-alert';
|
||||||
import { visibilities, Visibility } from '../common/types/visibility';
|
import { visibilities, Visibility } from '../common/types/visibility';
|
||||||
import { defaultTemplate } from './functions/format';
|
import { defaultTemplate } from '../common/default-template';
|
||||||
import { die } from './die';
|
import { die } from './die';
|
||||||
|
|
||||||
export const router = new Router<DefaultState, Context>();
|
export const router = new Router<DefaultState, Context>();
|
||||||
|
|
|
@ -14,6 +14,7 @@ html
|
||||||
meta(name='twitter:card' content='summary')
|
meta(name='twitter:card' content='summary')
|
||||||
meta(name='twitter:site' content='@Xeltica')
|
meta(name='twitter:site' content='@Xeltica')
|
||||||
meta(name='twitter:creator' content='@Xeltica')
|
meta(name='twitter:creator' content='@Xeltica')
|
||||||
|
link(rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css")
|
||||||
style.
|
style.
|
||||||
.loading {
|
.loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -25,7 +26,7 @@ html
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
body.dark
|
body
|
||||||
#app: .loading Loading...
|
#app: .loading Loading...
|
||||||
|
|
||||||
script(src=`/assets/fe.${version}.js` async defer)
|
script(src=`/assets/fe.${version}.js` async defer)
|
||||||
|
|
11
src/common/default-template.ts
Normal file
11
src/common/default-template.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
/**
|
||||||
|
* デフォルトの投稿用テンプレート
|
||||||
|
*/
|
||||||
|
export const defaultTemplate = `昨日のMisskeyの活動は
|
||||||
|
|
||||||
|
ノート: {notesCount}({notesDelta})
|
||||||
|
フォロー : {followingCount}({followingDelta})
|
||||||
|
フォロワー :{followersCount}({followersDelta})
|
||||||
|
|
||||||
|
でした。
|
||||||
|
{url}`;
|
|
@ -1,12 +1,18 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useGetScoreQuery, useGetSessionQuery } from '../services/session';
|
import { useGetScoreQuery, useGetSessionQuery } from '../services/session';
|
||||||
|
import { Skeleton } from './Skeleton';
|
||||||
|
|
||||||
export const SessionData: React.VFC = () => {
|
export const SessionDataPage: React.VFC = () => {
|
||||||
const session = useGetSessionQuery(undefined);
|
const session = useGetSessionQuery(undefined);
|
||||||
const score = useGetScoreQuery(undefined);
|
const score = useGetScoreQuery(undefined);
|
||||||
|
|
||||||
return session.isLoading || score.isLoading ? (
|
return session.isLoading || score.isLoading ? (
|
||||||
<div>Loading...</div>
|
<div className="vstack">
|
||||||
|
<Skeleton width="100%" height="1rem" />
|
||||||
|
<Skeleton width="100%" height="1rem" />
|
||||||
|
<Skeleton width="100%" height="2rem" />
|
||||||
|
<Skeleton width="100%" height="160px" />
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{session.data && (
|
{session.data && (
|
93
src/frontend/components/SettingPage.tsx
Normal file
93
src/frontend/components/SettingPage.tsx
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
import React, { useEffect, useReducer } from 'react';
|
||||||
|
import { AlertMode } from '../../common/types/alert-mode';
|
||||||
|
import { IUser } from '../../common/types/user';
|
||||||
|
import { Visibility } from '../../common/types/visibility';
|
||||||
|
import { useGetSessionQuery } from '../services/session';
|
||||||
|
import { defaultTemplate } from '../../common/default-template';
|
||||||
|
|
||||||
|
type SettingDraftType = Pick<IUser,
|
||||||
|
| 'alertMode'
|
||||||
|
| 'visibility'
|
||||||
|
| 'localOnly'
|
||||||
|
| 'remoteFollowersOnly'
|
||||||
|
| 'template'
|
||||||
|
>;
|
||||||
|
|
||||||
|
type DraftReducer = React.Reducer<SettingDraftType, Partial<SettingDraftType>>;
|
||||||
|
|
||||||
|
export const SettingPage: React.VFC = () => {
|
||||||
|
const session = useGetSessionQuery(undefined);
|
||||||
|
|
||||||
|
const data = session.data;
|
||||||
|
|
||||||
|
const [draft, dispatchDraft] = useReducer<DraftReducer>((state, action) => {
|
||||||
|
return { ...state, ...action };
|
||||||
|
}, {
|
||||||
|
alertMode: data?.alertMode ?? 'note',
|
||||||
|
visibility: data?.visibility ?? 'public',
|
||||||
|
localOnly: data?.localOnly ?? false,
|
||||||
|
remoteFollowersOnly: data?.remoteFollowersOnly ?? false,
|
||||||
|
template: data?.template ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
dispatchDraft({
|
||||||
|
alertMode: data.alertMode,
|
||||||
|
visibility: data.visibility,
|
||||||
|
localOnly: data.localOnly,
|
||||||
|
remoteFollowersOnly: data.remoteFollowersOnly,
|
||||||
|
template: data.template,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [session.data]);
|
||||||
|
|
||||||
|
return session.isLoading || !data ? (
|
||||||
|
<div className="skeleton" style={{width: '100%', height: '128px'}}></div>
|
||||||
|
) : (
|
||||||
|
<div className="vstack">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="alertMode" className="input-field">スコア通知方法</label>
|
||||||
|
<select name="alertMode" className="input-field" value={draft.alertMode} onChange={(e) => {
|
||||||
|
dispatchDraft({ alertMode: e.target.value as AlertMode });
|
||||||
|
}}>
|
||||||
|
<option value="note">自動的にノートを投稿</option>
|
||||||
|
<option value="notification">Misskeyに通知(標準)</option>
|
||||||
|
<option value="nothing">通知しない</option>
|
||||||
|
</select>
|
||||||
|
{draft.alertMode === 'notification' && (
|
||||||
|
<div className="alert bg-danger mt-2">
|
||||||
|
<i className="icon bi bi-exclamation-circle"></i>
|
||||||
|
「Misskey に通知」オプションは古いMisskeyでは動作しません。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{ draft.alertMode === 'note' && (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="visibility" className="input-field">公開範囲</label>
|
||||||
|
<select name="visibility" className="input-field" value={draft.visibility} onChange={(e) => {
|
||||||
|
dispatchDraft({ visibility: e.target.value as Visibility });
|
||||||
|
}}>
|
||||||
|
<option value="public">パブリック</option>
|
||||||
|
<option value="home">ホーム</option>
|
||||||
|
<option value="followers">フォロワー</option>
|
||||||
|
</select>
|
||||||
|
<label className="input-switch mt-2">
|
||||||
|
<input type="checkbox" />
|
||||||
|
<div className="switch"></div>
|
||||||
|
<span>ローカル限定</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="template" className="input-field">テンプレート</label>
|
||||||
|
<textarea className="input-field" value={draft.template ?? defaultTemplate} style={{height: 256}} onChange={(e) => {
|
||||||
|
dispatchDraft({ template: e.target.value });
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
<button className="btn primary">
|
||||||
|
設定を保存する
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
12
src/frontend/components/Skeleton.tsx
Normal file
12
src/frontend/components/Skeleton.tsx
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export type SkeletonProps = {
|
||||||
|
width?: string | number;
|
||||||
|
height?: string | number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Skeleton: React.VFC<SkeletonProps> = (p) => {
|
||||||
|
return (
|
||||||
|
<div className="skeleton" style={{width: p.width, height: p.height}}></div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -8,9 +8,6 @@ const token = document.cookie
|
||||||
.find(row => row.startsWith('token'))
|
.find(row => row.startsWith('token'))
|
||||||
?.split('=')[1];
|
?.split('=')[1];
|
||||||
|
|
||||||
console.log(document.cookie);
|
|
||||||
console.log(token);
|
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
localStorage['token'] = token;
|
localStorage['token'] = token;
|
||||||
// 今の所はcookieをトークン以外に使用しないため全消去する
|
// 今の所はcookieをトークン以外に使用しないため全消去する
|
||||||
|
|
|
@ -1,29 +1,25 @@
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { Header } from '../components/Header';
|
import { Header } from '../components/Header';
|
||||||
import { SessionData } from '../components/SessionData';
|
import { SessionDataPage } from '../components/SessionDataPage';
|
||||||
import { Ranking } from '../components/Ranking';
|
import { Ranking } from '../components/Ranking';
|
||||||
import { Tab, TabItem } from '../components/Tab';
|
import { Tab, TabItem } from '../components/Tab';
|
||||||
|
import { SettingPage } from '../components/SettingPage';
|
||||||
|
|
||||||
export const IndexSessionPage: React.VFC = () => {
|
export const IndexSessionPage: React.VFC = () => {
|
||||||
const [selectedTab, setSelectedTab] = useState<number>(0);
|
const [selectedTab, setSelectedTab] = useState<number>(0);
|
||||||
|
|
||||||
const items = useMemo<TabItem[]>(() => ([
|
const items = useMemo<TabItem[]>(() => ([
|
||||||
{
|
{ label: 'データ' },
|
||||||
label: 'データ',
|
{ label: 'ランキング' },
|
||||||
},
|
{ label: '設定' },
|
||||||
{
|
|
||||||
label: 'ランキング',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '設定',
|
|
||||||
},
|
|
||||||
]), []);
|
]), []);
|
||||||
|
|
||||||
const component = useMemo(() => {
|
const component = useMemo(() => {
|
||||||
switch (selectedTab) {
|
switch (selectedTab) {
|
||||||
case 0: return <SessionData />;
|
case 0: return <SessionDataPage />;
|
||||||
case 1: return <Ranking limit={10}/>;
|
case 1: return <Ranking limit={10}/>;
|
||||||
|
case 2: return <SettingPage/>;
|
||||||
default: return null;
|
default: return null;
|
||||||
}
|
}
|
||||||
}, [selectedTab]);
|
}, [selectedTab]);
|
||||||
|
|
|
@ -9,6 +9,20 @@ body {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--tone-1);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
&::after {
|
||||||
|
content: "";
|
||||||
|
inset: 0;
|
||||||
|
position: absolute;
|
||||||
|
background: linear-gradient(90deg, transparent, var(--tone-2) 50%, transparent 100%);
|
||||||
|
animation: 1.2s ease-out 0s infinite skeleton;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.tab {
|
.tab {
|
||||||
display: flex;
|
display: flex;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
|
@ -18,6 +32,7 @@ body {
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
padding: calc(var(--margin) / 2) var(--margin);
|
padding: calc(var(--margin) / 2) var(--margin);
|
||||||
|
transition: background 0.2s ease;
|
||||||
&.active {
|
&.active {
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
&::after {
|
&::after {
|
||||||
|
@ -43,3 +58,12 @@ body {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes skeleton {
|
||||||
|
from {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(300%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue