Browse Source

init

pull/1/head
SCLeo 3 years ago
commit
7762acccbe
  1. 1
      .gitignore
  2. 28
      README.md
  3. 7
      meta.json
  4. 56
      src/AccessAnswer.ts
  5. 9
      src/RootState.ts
  6. 37
      src/actions/answers.ts
  7. 43
      src/actions/compare.ts
  8. 24
      src/actions/currentView.ts
  9. 21
      src/components/Button.tsx
  10. 15
      src/components/Card.tsx
  11. 183
      src/components/ComparisonTable.tsx
  12. 121
      src/components/Cover.tsx
  13. 86
      src/components/FillBlankAnswer.tsx
  14. 33
      src/components/Header.tsx
  15. 58
      src/components/InputField.tsx
  16. 17
      src/components/LinkButton.tsx
  17. 146
      src/components/MultipleChoiceAnswer.tsx
  18. 36
      src/components/SimpleFormat.tsx
  19. 155
      src/content.scss
  20. 28
      src/contents/About.tsx
  21. 150
      src/contents/Compare.tsx
  22. 26
      src/contents/Content.tsx
  23. 25
      src/contents/ContentType.ts
  24. 40
      src/contents/Export.tsx
  25. 113
      src/contents/Import.tsx
  26. 46
      src/contents/Language.tsx
  27. 323
      src/contents/Profile.tsx
  28. 12
      src/data/Enums.ts
  29. 144
      src/data/categories.ts
  30. 116
      src/data/category0.ts
  31. 256
      src/data/category1.ts
  32. 153
      src/data/category2.ts
  33. 396
      src/data/category3.ts
  34. 211
      src/data/category4.ts
  35. 188
      src/data/category5.ts
  36. 54
      src/data/category6.ts
  37. 65
      src/data/category7.ts
  38. 28
      src/data/lang/en_US/general.lang
  39. 34
      src/data/lang/zh_CN/about.lang
  40. 46
      src/data/lang/zh_CN/category0.lang
  41. 132
      src/data/lang/zh_CN/category1.lang
  42. 99
      src/data/lang/zh_CN/category2.lang
  43. 187
      src/data/lang/zh_CN/category3.lang
  44. 111
      src/data/lang/zh_CN/category4.lang
  45. 94
      src/data/lang/zh_CN/category5.lang
  46. 25
      src/data/lang/zh_CN/category6.lang
  47. 43
      src/data/lang/zh_CN/category7.lang
  48. 115
      src/data/lang/zh_CN/general.lang
  49. 1
      src/entry.js
  50. 167
      src/external.cson
  51. 145
      src/general.scss
  52. 46
      src/index.tsx
  53. 49
      src/persistence.ts
  54. 47
      src/reducers/answers.ts
  55. 59
      src/reducers/compare.ts
  56. 32
      src/reducers/currentView.ts
  57. 10
      src/reducers/index.ts
  58. 18
      src/sm.pug

1
.gitignore

@ -0,0 +1 @@
/src/external/

28
README.md

@ -0,0 +1,28 @@
# SM Contract
## 前言
> 表面上来看,S 可以肆意对 M 做任何自己喜欢的事情,实际上,是 S 将 M 希望的事情,给予 M。如果 M 被对方胡乱的对待,那样叧能称作单纯的虐待吧。
>
> 就像这样,SM 不仅是 S 对 M 的关系,还有着 S 要做 M 喜欢的事情这种逆向的关系存在其中。
>
> 现实中的 SM,S 对 M 的性趣和嗜好的了解是不可欠缺的,M 也必须完全的信任 S,才能安心的接受 S 的各种行为。所以,SM 中的 S 应该是服务(Service)才对,没有对 M 的服务精神和爱情,这种关系是无法成立的。
>
> <div align="right">——《我的身体,我的心》</div>
由于 SM 玩法众多,主奴之间需要经过长时间的沟通才能弄明白双方对什么类型的 play 感兴趣。为了简化这个过程,SM Contract 诞生了。
SM Contract 是一份专为 SM 爱好者设计的调查表,涵盖了大量在 play 之前主奴之间应该沟通好的项目。填写完成后,您可以使用「导出数据」功能将您填写的信息转换为一个 base64 字符串并发送给您的搭档,并由您的搭档使用「对比」功能来生成一份直观的对比表格。当然,您也可以要求您的搭档发送其填写的信息,并由您来生成这份表格。
您填写的所有信息都会被自动保存到您的浏览器中。没有任何信息被上传至 SM Contract 服务器。
[立刻开始填写](https://www.minegeck.net/lab/sm)
## 贡献
如果搞不懂的话就发 Issue 我来改。
### 添加/补全翻译
所有语言文件位于 data/lang 文件夹。SM Contract 的语言文件是树状结构,您只需要修改冒号后的值。若要为新的语言添加支持,将现有的语言文件复制一份即可。
### 修改数据(修改/添加问题,语言润色等)
SM Contract 的数据在 data 文件夹内,修改时请同时修改位于 data 中的 ts 文件和位于 data/lang 中的语言文件。在添加的时候请在别的语言下也添加对应条目。
顺便说一句,如果觉得我的语言很枯燥,请务必告诉我怎么修改,多谢。

7
meta.json

@ -0,0 +1,7 @@
[
{
"type": "website",
"source": "./src",
"target": "./sm"
}
]

56
src/AccessAnswer.ts

@ -0,0 +1,56 @@
import { Answer, FBAnswer, MCAnswer } from './actions/answers';
import {
categories,
IQuestion,
questionByIds,
} from './data/categories';
import { QuestionType } from './data/Enums';
import { IAnswersState } from './reducers/answers';
const blankBianswerMC: MCAnswer = [null, null];
const blankBianswerFB: FBAnswer = ['', ''];
const getBlankAnswer = (question: IQuestion): Answer => question.bianswer
? question.type === QuestionType.MULTIPLE_CHOICE
? blankBianswerMC
: blankBianswerFB
: question.type === QuestionType.MULTIPLE_CHOICE
? null
: '';
export function accessAnswerByIndex(
answers: IAnswersState,
categoryIndex: number,
questionIndex: number,
): Answer {
const category = categories[categoryIndex];
const categoryId = category.categoryId;
const answersCategory = answers[categoryId];
const question = category.questions[questionIndex];
const questionId = question.questionId;
if (answersCategory === undefined) {
return getBlankAnswer(question);
}
const answer = answersCategory[questionId];
if (answer === undefined) {
return getBlankAnswer(question);
}
return answer;
}
export function accessAnswerById(
answers: IAnswersState,
categoryId: number,
questionId: number,
): Answer {
const question = questionByIds[categoryId][questionId];
const answersCategory = answers[categoryId];
if (answersCategory === undefined) {
return getBlankAnswer(question);
}
const answer = answersCategory[questionId];
if (answer === undefined) {
return getBlankAnswer(question);
}
return answer;
}

9
src/RootState.ts

@ -0,0 +1,9 @@
import { IAnswersState } from './reducers/answers';
import { ICompareState } from './reducers/compare';
import { ICurrentViewState } from './reducers/currentView';
export interface IRootState {
readonly answers: IAnswersState;
readonly compare: ICompareState;
readonly currentView: ICurrentViewState;
}

37
src/actions/answers.ts

@ -0,0 +1,37 @@
import { IAnswersState } from '../reducers/answers';
export const ACTION_SET_ANSWER = 'A_SET_ANSWER';
export const ACTION_REPLACE_ANSWERS = 'A_REPLACE_ANSWERS';
export type MCAnswer = number | null | [number | null, number | null];
export type FBAnswer = string | [string, string];
export type Answer = MCAnswer | FBAnswer;
export interface IActionSetAnswer {
type: typeof ACTION_SET_ANSWER;
categoryId: number;
questionId: number;
answer: Answer;
}
export interface IActionReplaceAnswers {
type: typeof ACTION_REPLACE_ANSWERS;
answers: IAnswersState;
}
export function createSetAnswerAction(
categoryId: number,
questionId: number,
answer: Answer,
): IActionSetAnswer {
return {
type: ACTION_SET_ANSWER,
categoryId,
questionId,
answer,
};
}
export function createReplaceAnswersAction(
answers: IAnswersState,
): IActionReplaceAnswers {
return {
type: ACTION_REPLACE_ANSWERS,
answers,
};
}

43
src/actions/compare.ts

@ -0,0 +1,43 @@
import { IAnswersState } from '../reducers/answers';
export const ACTION_SET_COMPARE_FORM = 'C_SET_COMPARE_FORM';
export const ACTION_START_COMPARE = 'C_START_COMPARE';
export const ACTION_CLEAR_COMPARE = 'C_CLEAR_COMPARE';
export interface IActionSetCompareForm {
type: typeof ACTION_SET_COMPARE_FORM;
role: 's' | 'm';
value: string;
}
export interface IActionStartCompare {
type: typeof ACTION_START_COMPARE;
s: IAnswersState;
m: IAnswersState;
}
export interface IActionClearCompare {
type: typeof ACTION_CLEAR_COMPARE;
}
export function createSetCompareFormAction(
role: 's' | 'm',
value: string,
): IActionSetCompareForm {
return {
type: ACTION_SET_COMPARE_FORM,
role,
value,
};
}
export function createStartCompareAction(
s: IAnswersState,
m: IAnswersState,
): IActionStartCompare {
return {
type: ACTION_START_COMPARE,
s,
m,
};
}
export function createClearCompareAction(): IActionClearCompare {
return {
type: ACTION_CLEAR_COMPARE,
};
}

24
src/actions/currentView.ts

@ -0,0 +1,24 @@
import { ContentType } from './../contents/ContentType';
export const ACTION_LOWER_COVER = 'CV_LOWER_COVER';
export const ACTION_SET_CONTENT = 'CV_SET_CONTENT';
export interface IActionLowerCover {
type: typeof ACTION_LOWER_COVER;
}
export interface IActionSetContent {
type: typeof ACTION_SET_CONTENT;
content: ContentType;
}
export function createLowerCoverAction(): IActionLowerCover {
return {
type: ACTION_LOWER_COVER,
};
}
export function createSetContentAction(
content: ContentType,
): IActionSetContent {
return {
type: ACTION_SET_CONTENT,
content,
};
}

21
src/components/Button.tsx

@ -0,0 +1,21 @@
import * as React from '../external/react.js';
interface IButtonProps {
readonly icon: string;
readonly text: string;
readonly class: string;
readonly onClick: React.EventHandler<React.MouseEvent<any>>;
}
export class Button extends React.PureComponent<IButtonProps> {
public render() {
return (
<div
className={ 'button ' + this.props.class }
onClick={ this.props.onClick }
>
<i className='material-icons'>{ this.props.icon }</i>
<span>{ this.props.text }</span>
</div>
);
}
}

15
src/components/Card.tsx

@ -0,0 +1,15 @@
import * as React from '../external/react';
interface ICardProps {
readonly children: React.ReactNode;
}
export class Card extends React.PureComponent<ICardProps> {
public render() {
return (
<div className='card'>
{ this.props.children }
</div>
);
}
}
}

183
src/components/ComparisonTable.tsx

@ -0,0 +1,183 @@
import { accessAnswerById } from '../AccessAnswer';
import {
getBianswerQuestionDisplayInfo,
getNonBianswerQuestionDisplayInfo,
} from '../contents/Profile';
import {
categories,
ICategory,
IQuestion,
QuestionType,
} from '../data/categories';
import * as React from '../external/react';
import { IAnswersState } from '../reducers/answers';
import { LinkButton } from './LinkButton';
interface IComparisonTableProps {
readonly s: IAnswersState;
readonly m: IAnswersState;
}
interface IComparisonTableState {
readonly showAll: boolean;
}
export class ComparisonTable extends React.PureComponent<
IComparisonTableProps,
IComparisonTableState
> {
public state = {
showAll: false,
};
private onClickSwitchShowMode = () => {
this.setState(state => ({
showAll: !state.showAll,
}));
}
private convertAnswerToSchrodinger(
question: IQuestion,
answerRaw: any,
): string | null {
if (answerRaw === null || answerRaw === '') {
return null;
} else if (question.type === QuestionType.MULTIPLE_CHOICE) {
return question.choices[answerRaw];
} else {
return answerRaw;
}
}
private convertSchrodingerToElement(schrodinger: string | null | undefined) {
if (schrodinger) {
return <td>{ schrodinger }</td>;
} else if (schrodinger === null) {
return <td className='empty'>{ '< @{lab.sm.compare.table.empty} >' }</td>;
} else {
return (
<td className='unavailable'>
{ '< @{lab.sm.compare.table.unavailable} >' }
</td>
);
}
}
private renderRows(): Array<React.ReactNode> {
const sAnswers = this.props.s;
const mAnswers = this.props.m;
const results: Array<React.ReactNode> = [];
const showAll = this.state.showAll;
for (const category of categories) {
const cid = category.categoryId;
for (const question of category.questions) {
const qid = question.questionId;
let sAnswerRaw: string | number | null = null;
let mAnswerRaw: string | number | null = null;
// null = empty
// undefined = unavailable
let sAnswer: string | null | undefined;
let mAnswer: string | null | undefined;
// This part of the code tried to extract the answers of both role.
// I know it is terrible. I failed the desgin. I am sorry.
if (question.bianswer) {
const { showS } = getBianswerQuestionDisplayInfo(sAnswers, question);
if (!showS) {
sAnswer = undefined;
} else {
sAnswer = this.convertAnswerToSchrodinger(
question,
sAnswerRaw = ((accessAnswerById(sAnswers, cid, qid) as any)[0]),
);
}
const { showM } = getBianswerQuestionDisplayInfo(mAnswers, question);
if (!showM) {
mAnswer = undefined;
} else {
mAnswer = this.convertAnswerToSchrodinger(
question,
mAnswerRaw = ((accessAnswerById(mAnswers, cid, qid) as any)[1]),
);
}
} else {
const showS = getNonBianswerQuestionDisplayInfo(sAnswers, question);
if (!showS) {
sAnswer = undefined;
} else {
sAnswer = this.convertAnswerToSchrodinger(
question,
sAnswerRaw = (accessAnswerById(sAnswers, cid, qid) as any),
);
}
const showM = getNonBianswerQuestionDisplayInfo(mAnswers, question);
if (!showM) {
mAnswer = undefined;
} else {
mAnswer = this.convertAnswerToSchrodinger(
question,
mAnswerRaw = (accessAnswerById(mAnswers, cid, qid) as any),
);
}
}
const shouldHideIfNotShowAll = (question.uncomparable
? sAnswer === undefined && mAnswer === undefined
: sAnswer === undefined || mAnswer === undefined
);
if (!showAll && shouldHideIfNotShowAll) {
continue;
}
const sTd = this.convertSchrodingerToElement(sAnswer);
const mTd = this.convertSchrodingerToElement(mAnswer);
let color = shouldHideIfNotShowAll ? '#eee' : 'white';
if ( // The question is comparable and answered
(!question.uncomparable) &&
(sAnswer) &&
(mAnswer)
) {
const sDist = 3 - (sAnswerRaw as number);
const mDist = 3 - (mAnswerRaw as number);
color = `rgb(255,255,${255 - sDist * mDist * 10})`;
}
results.push(
<tr key={ cid + ',' + qid } style={{
backgroundColor: color,
}}>
<td>{ question.title }</td>
{ sTd }
{ mTd }
</tr>,
);
}
}
return results;
}
public render() {
return (
<React.Fragment>
<LinkButton
children={
this.state.showAll
? '@{lab.sm.compare.table.switchToShowAvailable}'
: '@{lab.sm.compare.table.switchToShowAll}'
}
onClick={ this.onClickSwitchShowMode }
/>
<table>
<thead>
<tr>
<th>{ '@{lab.sm.compare.table.item}' }</th>
<th>{ '@{lab.sm.compare.table.sChoice}' }</th>
<th>{ '@{lab.sm.compare.table.mChoice}' }</th>
</tr>
</thead>
<tbody>
{ this.renderRows() }
</tbody>
</table>
</React.Fragment>
);
}
}

121
src/components/Cover.tsx

@ -0,0 +1,121 @@
import { createSetContentAction, IActionSetContent } from '../actions/currentView';
import { getContent } from '../contents/Content';
import { ContentType } from '../contents/ContentType';
import * as React from '../external/react.js';
import { connect } from '../external/reactRedux';
import { IRootState } from '../RootState';
import { Button } from './Button';
import { Header } from './Header';
const Title = () => (
<div className='title-container'>
<h1>SM</h1>
<h2>CONTRACT</h2>
</div>
);
const PrivacyNotice = () => (
<p
className='privacy-notice'
children='@{lab.sm.landing.privacyNotice}'
/>
);
interface IButtonPanelProps {
readonly setContent: (content: ContentType) => IActionSetContent;
}
class ButtonPanelUW extends React.PureComponent<IButtonPanelProps> {
public onClickStart = () => {
this.props.setContent(ContentType.PROFILE);
}
public onClickCompare = () => {
this.props.setContent(ContentType.COMPARE);
}
public onClickLanguage = () => {
this.props.setContent(ContentType.LANGUAGE);
}
public onClickImport = () => {
this.props.setContent(ContentType.IMPORT);
}
public onClickExport = () => {
this.props.setContent(ContentType.EXPORT);
}
public onClickAbout = () => {
this.props.setContent(ContentType.ABOUT);
}
public render() {
return (
<div className='button-panel'>
<Button
class='start'
icon='edit'
text='@{lab.sm.landing.start}'
onClick={ this.onClickStart }
/>
<Button
class='compare'
icon='compare'
text='@{lab.sm.landing.compare}'
onClick={ this.onClickCompare }
/>
<Button
class='language'
icon='language'
text='Language'
onClick={ this.onClickLanguage }
/>
<Button
class='import'
icon='file_download'
text='@{lab.sm.landing.import}'
onClick={ this.onClickImport }
/>
<Button
class='export'
icon='file_upload'
text='@{lab.sm.landing.export}'
onClick={ this.onClickExport }
/>
<Button
class='about'
icon='info'
text='@{lab.sm.landing.about}'
onClick={ this.onClickAbout }
/>
</div>
);
}
}
const ButtonPanel = connect(null, {
setContent: createSetContentAction,
})(ButtonPanelUW);
const Landing = () => (
<div className='landing'>
<Header/>
<Title/>
<PrivacyNotice/>
<ButtonPanel/>
</div>
);
interface ICoverProps {
readonly lifted: boolean;
readonly content: ContentType;
}
class CoverUW extends React.Component<ICoverProps> {
public render() {
return (
<div>
<div className='content-padding'/>
{ getContent(this.props.content) }
<div className={ 'cover' + (this.props.lifted ? ' lifted' : '') }>
<Landing/>
</div>
</div>
);
}
}
export const Cover = connect((state: IRootState) => ({
lifted: !state.currentView.cover,
content: state.currentView.content,
}))(CoverUW);

86
src/components/FillBlankAnswer.tsx

@ -0,0 +1,86 @@
import { accessAnswerByIndex } from '../AccessAnswer';
import { createSetAnswerAction, FBAnswer } from '../actions/answers';
import { ICategory, IFillBlankQuestion } from '../data/categories';
import * as React from '../external/react';
import { connect } from '../external/reactRedux';
import { IRootState } from '../RootState';
import { InputField } from './InputField';
interface IFillBlankAnswerOwnProps {
readonly categoryIndex: number;
readonly category: ICategory;
readonly questionIndex: number;
readonly question: IFillBlankQuestion;
readonly bianswer: boolean;
readonly showS: boolean;
readonly showM: boolean;
}
interface IFillBlankAnswerProps extends IFillBlankAnswerOwnProps {
readonly currentAnswer: FBAnswer;
readonly setAnswer: typeof createSetAnswerAction;
}
class FillBlankAnswerUW extends React.PureComponent<
IFillBlankAnswerProps
> {
private setAnswer = (answer: FBAnswer) => {
this.props.setAnswer(
this.props.category.categoryId,
this.props.question.questionId,
answer,
);
}
private readonly onChange0 = (newValue: string) => {
if (this.props.bianswer) {
this.setAnswer([
newValue,
this.props.currentAnswer[1],
]);
} else {
this.setAnswer(newValue);
}
}
private readonly onChange1 = (newValue: string) => {
this.setAnswer([
this.props.currentAnswer[0],
newValue,
]);
}
public render() {
const showCol0 = !this.props.bianswer || this.props.showS;
const showCol1 = this.props.bianswer && this.props.showM;
return (
<div>
{ showCol0 && <InputField
label={ this.props.bianswer
? `@{lab.sm.fbq.asS,${ this.props.question.label }}`
: this.props.question.label
}
value={
Array.isArray(this.props.currentAnswer)
? this.props.currentAnswer[0]
: this.props.currentAnswer
}
onChange={ this.onChange0 }
/> }
{ showCol1 && <InputField
label={ `@{lab.sm.fbq.asM,${ this.props.question.label }}` }
value={ this.props.currentAnswer[1] }
onChange={ this.onChange1 }
/> }
</div>
);
}
}
export const FillBlankAnswer = connect((
state: IRootState,
ownProps: IFillBlankAnswerOwnProps,
) => ({
currentAnswer: accessAnswerByIndex(
state.answers,
ownProps.categoryIndex,
ownProps.questionIndex,
),
}), {
setAnswer: createSetAnswerAction,
})(FillBlankAnswerUW);

33
src/components/Header.tsx

@ -0,0 +1,33 @@
import { createLowerCoverAction } from '../actions/currentView';
import { getContentTitle } from '../contents/ContentType';
import * as React from '../external/react';
import { connect } from '../external/reactRedux';
import { IRootState } from '../RootState';
interface IHeaderProps {
readonly subtitle: string;
readonly lowerCover: typeof createLowerCoverAction;
}
class HeaderUW extends React.PureComponent<IHeaderProps> {
public render() {
return (
<div className='header-container'>
<div className='header'>
<span className='clickable' onClick={ this.props.lowerCover }>
SM Contract
</span>
<span>
{ ' · ' + this.props.subtitle }
</span>
</div>
</div>
);
}
}
export const Header = connect((
state: IRootState,
) => ({
subtitle: getContentTitle(state.currentView.content),
}), {
lowerCover: createLowerCoverAction,
})(HeaderUW);

58
src/components/InputField.tsx

@ -0,0 +1,58 @@
import * as React from '../external/react';
interface IInputFieldProps {
readonly label: string;
readonly value: string;
readonly onChange: (newValue: string) => void;
}
interface IInputFieldState {
readonly focused: boolean;
readonly raised: boolean;
}
export class InputField extends React.PureComponent<
IInputFieldProps,
IInputFieldState
> {
public constructor(props: IInputFieldProps) {
super(props);
this.state = {
focused: false,
raised: props.value !== '',
};
}
private readonly onBlur = () => {
this.setState({
focused: false,
raised: this.props.value !== '',
});
}
private readonly onChange: React.EventHandler<
React.FormEvent<any>
> = event => {
const value: string = (event.target as HTMLInputElement).value;
this.props.onChange(value);
}
private readonly onFocus = () => {
this.setState({
focused: true,
raised: true,
});
}
public render() {
return (
<div className={ 'input-field' + (this.state.focused ? ' focused' : '') }>
<div className={ 'label' + (this.state.raised ? ' raised' : '') }>
{ this.props.label }
</div>
<input
onBlur={ this.onBlur }
onChange={ this.onChange }
onFocus={ this.onFocus }
type='text'
value={ this.props.value }
/>
<div className='underline'/>
</div>
);
}
}

17
src/components/LinkButton.tsx

@ -0,0 +1,17 @@
import * as React from '../external/react';
interface ILinkButtonProps {
readonly children: string;
readonly onClick: React.EventHandler<React.MouseEvent<any>>;
}
export class LinkButton extends React.PureComponent<ILinkButtonProps> {
public render() {
return (
<div className='link-container'>
<a onClick={ this.props.onClick }>
{ this.props.children }
</a>
</div>
);
}
}

146
src/components/MultipleChoiceAnswer.tsx

@ -0,0 +1,146 @@
import { accessAnswerByIndex } from '../AccessAnswer';
import { createSetAnswerAction, IActionSetAnswer, MCAnswer } from '../actions/answers';
import { categories, ICategory, IMultipleChoiceQuestion } from '../data/categories';
import * as React from '../external/react';
import { connect } from '../external/reactRedux';
import { IRootState } from '../RootState';
interface IOneChoiceProps {
readonly selected: boolean;
}
const OneChoice = ({ selected }: IOneChoiceProps) => (
<i className='material-icons'>
{ selected
? 'radio_button_checked'
: 'radio_button_unchecked' }
</i>
);
interface IMultipleChoiceAnswerOwnProps {
readonly categoryIndex: number;
readonly category: ICategory;
readonly questionIndex: number;
readonly question: IMultipleChoiceQuestion;
readonly bianswer: boolean;
readonly showS: boolean;
readonly showM: boolean;
}
interface IMultipleChoiceAnswerProps extends IMultipleChoiceAnswerOwnProps {
readonly currentAnswer: MCAnswer;
readonly setAnswer: typeof createSetAnswerAction;
}
class MultipleChoiceAnswerUW extends React.PureComponent<
IMultipleChoiceAnswerProps
> {
private onClick: React.MouseEventHandler<any> = event => {
const target = event.target as any;
const td = target.closest('td') as HTMLTableDataCellElement;
if (td === null) {
return;
}
let type = td.dataset.type;
if (type === undefined) {
return;
}
if (
(this.props.bianswer) &&
(this.props.showS !== this.props.showM) &&
(type === 'all')
) {
type = this.props.showS ? 'col0' : 'col1';
}
const choiceIndex = +((
td.closest('tr') as HTMLTableRowElement
).dataset.index as string);
let answer: MCAnswer;
if (this.props.bianswer) {
const beforeAnswer = this.props.currentAnswer as [
number | null,
number | null
];
if (type === 'all') {
if (
choiceIndex === beforeAnswer[0] && choiceIndex === beforeAnswer[1]
) {
answer = [ null, null ];
} else {
answer = [ choiceIndex, choiceIndex ];
}
} else if (type === 'col0') {
answer = [
choiceIndex === beforeAnswer[0] ? null : choiceIndex,
beforeAnswer[1],
];
} else {
answer = [
beforeAnswer[0],
choiceIndex === beforeAnswer[1] ? null : choiceIndex,
];
}
} else {
const beforeAnswer = this.props.currentAnswer as number | null;
if (beforeAnswer === choiceIndex) {
answer = null;
} else {
answer = choiceIndex;
}
}
this.props.setAnswer(
this.props.category.categoryId,
this.props.question.questionId,
answer,
);
}
public render() {
const choices = this.props.question.choices;
const showCol0 = !this.props.bianswer || this.props.showS;
const showCol1 = this.props.bianswer && this.props.showM;
return (
<table onClick={ this.onClick }>
<thead>
<tr>
<th>{ '@{lab.sm.mcq.items}' }</th>
{ showCol0 && <th>
{ this.props.bianswer
? '@{lab.sm.mcq.asS}'
: '' }
</th> }
{ showCol1 && <th>{ '@{lab.sm.mcq.asM}' }</th> }
</tr>
</thead>
<tbody>
{ choices.map((choice, choiceIndex) => (
<tr data-index={ String(choiceIndex) } key={ choiceIndex }>
<td data-type='all'>{ choice }</td>
{ showCol0 && <td data-type='col0'>
<OneChoice selected={
Array.isArray(this.props.currentAnswer)
? this.props.currentAnswer[0] === choiceIndex
: this.props.currentAnswer === choiceIndex
}/>
</td> }
{ showCol1 && <td data-type='col1'>
<OneChoice selected={
Array.isArray(this.props.currentAnswer) &&
(this.props.currentAnswer[1] === choiceIndex)
}/>
</td> }
</tr>
)) }
</tbody>
</table>
);
}
}
export const MultipleChoiceAnswer = connect((
state: IRootState,
ownProps: IMultipleChoiceAnswerOwnProps,
) => ({
currentAnswer: accessAnswerByIndex(
state.answers,
ownProps.categoryIndex,
ownProps.questionIndex,
),
}), {
setAnswer: createSetAnswerAction,
})(MultipleChoiceAnswerUW);

36
src/components/SimpleFormat.tsx

@ -0,0 +1,36 @@
import * as React from '../external/react';
const mapLineToElement = (className?: string) => (line: string, i: number) => {
if (line.startsWith('/ ')) {
return <p className={ className } key={ i }>{ line.substr(2) }</p>;
}
if (line.startsWith('! ')) {
return <h3 className={ className } key={ i }>{ line.substr(2) }</h3>;
}
const style: any = {};
if (line.startsWith('>')) {
style.textAlign = 'right';
line = line.substr(1);
}
if (line.startsWith('i')) {
style.color = '#555';
style.fontStyle = 'italic';
line = line.substr(1);
}
if (line.startsWith(' ')) {
line = line.substr(1);
}
return <p className={ className } style={ style } key={ i }>{ line }</p>;
};
interface ISimpleFormatProps {
readonly children: string;
readonly className?: string;
}
export class SimpleFormat extends React.PureComponent<ISimpleFormatProps> {
public render() {
return this.props.children.split('\n').map(
mapLineToElement(this.props.className),
);
}
}

155
src/content.scss

@ -0,0 +1,155 @@
.content {
h1, h2, h3, h4 {
margin: 0
}
p {
margin: 0 0 6px 0;
}
h1 {
font-size: 32px;
}
h2 {
font-size: 24px;
}
h2 {
font-size: 18px;
}
h4 {
color: #aaa;
font-size: 16px;
font-weight: normal;
}
div.link-container {
display: block;
margin: 10px 0 0 0;
}
a {
color: #4285f4;
cursor: pointer;
text-decoration: none;
user-select: none;
&:hover {
text-decoration: underline,
}
}
.input-field {
overflow-x: hidden;
margin-top: 4px;
>.label {
color: #555;
font-size: 12px;
pointer-events: none;
position: relative;
transform-origin: 0;
transition:
transform 0.2s,
color 0.2s;
&:not(.raised) {
color: #aaa;
transform: scale(1.60) translateY(14px);
}
}
>input {
border: none;
border-bottom: 1px solid #aaa;
font-size: 20px;
outline: none;
padding: 3px 0 5px 0;
width: 100%;
}
>.underline {
background-color: #00bcd4;
height: 2px;
position: relative;
top: -1px;
transform: scaleX(0);
transform-origin: 0;
transition: transform 0.5s;
width: 100%;
}
&.focused {
>.label {
color: #00bcd4;
}
>.underline {
transform: scaleX(1);
}
}
}
&.compare {
p.error {
color: red;
}
table {
width: 100%;
border-spacing: 0;
th, td {
padding: 2px 0;
text-align: left;
}
td.empty, td.unavailable {
color: #aaa;
}
}
}
&.export {
code {
background-color: #eee;
display: block;
margin-top: 20px;
padding: 20px;
word-wrap: break-word;
}
}
&.profile {
.question {
margin-top: 10px;
.info {
color: #aaa;
float: right;
font-size: 10px;
margin: 6px 0 0 0;
text-align: right;
}
.hardcoreness-meter {
font-size: 16px;
font-weight: normal;
margin-left: 10px;
}
.warning {
color: red;
}
table {
@media (max-width: 568px) {
width: 100%;
}
border-spacing: 0;
user-select: none;
>tbody>tr:nth-child(even), thead>tr {
background-color: rgba(242, 242, 242, 0.5);
}
th, td {
@media (min-width: 568px) {
padding: 8px 40px;
}
@media (max-width: 568px) {
padding: 12px 0;
}
text-align: center;
}
>tbody {
cursor: pointer;
>tr>td:first-child {
font-weight: bold;
&:hover {
text-decoration: underline;
}
}
>tr>td:not(:first-child) {
transform: translateY(2px);
}
}
}
}
}
}

28
src/contents/About.tsx

@ -0,0 +1,28 @@
import { Card } from '../components/Card';
import { SimpleFormat } from '../components/SimpleFormat';
import * as React from './../external/react';
export class About extends React.PureComponent {
public render() {
return (
<div className='content about'>
<Card>
<h1>{ '@{lab.sm.about.openSource.title}' }</h1>
<p>
{ '@{lab.sm.about.openSource.link}: ' }
<a
target='_blank'
href='https://github.com/SCLeoX/sm-contract'
children='https://github.com/SCLeoX/sm-contract'
/>
</p>
<SimpleFormat>{ '@{lab.sm.about.openSource.desc}' }</SimpleFormat>
</Card>
<Card>
<h1>{ '@{lab.sm.about.faq.title}' }</h1>
<SimpleFormat>{ '@{lab.sm.about.faq.content}' }</SimpleFormat>
</Card>
</div>
);
}
}

150
src/contents/Compare.tsx

@ -0,0 +1,150 @@
import {
createClearCompareAction,
createSetCompareFormAction,
createStartCompareAction,
} from '../actions/compare';
import { Card } from '../components/Card';
import { ComparisonTable } from '../components/ComparisonTable';
import { InputField } from '../components/InputField';
import { LinkButton } from '../components/LinkButton';
import { SimpleFormat } from '../components/SimpleFormat';
import * as React from '../external/react';
import { connect } from '../external/reactRedux';
import { decode } from '../persistence';
import { IAnswersState } from '../reducers/answers';
import { IRootState } from '../RootState';
interface ICompareFormProps {
readonly formSValue: string;
readonly formMValue: string;
readonly currentAnswers: IAnswersState;
readonly setCompareForm: typeof createSetCompareFormAction;
readonly startCompare: typeof createStartCompareAction;
}
interface ICompareFormState {
readonly error: string | null;
}
class CompareFormUW extends React.Component<
ICompareFormProps,
ICompareFormState
> {
public state = {
error: null,
};
public shouldComponentUpdate(
nextProps: ICompareFormProps,
nextState: ICompareFormState,
) {
return (
(this.props.formSValue !== nextProps.formSValue) ||
(this.props.formMValue !== nextProps.formMValue) ||
(this.state.error !== nextState.error)
);
}
private clearError = () => {
this.setState({
error: null,
});
}
private onSFormValueChange = (newValue: string) => {
this.clearError();
this.props.setCompareForm('s', newValue);
}
private onMFormValueChange = (newValue: string) => {
this.clearError();
this.props.setCompareForm('m', newValue);
}
private onClickCompare = () => {
let sAnswers: IAnswersState;
let mAnswers: IAnswersState;
try {
sAnswers = this.props.formSValue !== ''
? decode(this.props.formSValue)
: this.props.currentAnswers;
} catch (e) {
this.setState({
error: '@{lab.sm.compare.sError}',
});
return;
}
try {
mAnswers = this.props.formMValue !== ''
? decode(this.props.formMValue)
: this.props.currentAnswers;
} catch (e) {
this.setState({
error: '@{lab.sm.compare.mError}',
});
return;
}
this.props.startCompare(sAnswers, mAnswers);
}
public render() {
return (
<div>
<InputField
label='@{lab.sm.compare.label.s}'
value={ this.props.formSValue }
onChange={ this.onSFormValueChange }
/>
<InputField
label='@{lab.sm.compare.label.m}'
value={ this.props.formMValue }
onChange={ this.onMFormValueChange }
/>
<LinkButton
children='@{lab.sm.compare.start}'
onClick={ this.onClickCompare }
/>
{ this.state.error && <p className='error'>
{ this.state.error }
</p> }
</div>
);
}
}
const CompareForm = connect((state: IRootState) => ({
formSValue: state.compare.form.s,
formMValue: state.compare.form.m,
currentAnswers: state.answers,
}), {
setCompareForm: createSetCompareFormAction,
startCompare: createStartCompareAction,
})(CompareFormUW);
interface ICompareProps {
readonly comparing: null | {
s: IAnswersState,
m: IAnswersState,
};
readonly clearCompare: typeof createClearCompareAction;
}
class CompareUW extends React.PureComponent<ICompareProps> {
public render() {
return (
<div className='content compare'>
<Card>
<h1>{ '@{lab.sm.compare.title}' }</h1>
<SimpleFormat>{ '@{lab.sm.compare.desc}' }</SimpleFormat>
<CompareForm/>
</Card>
{ this.props.comparing !== null && <Card>
<h1>{ '@{lab.sm.compare.table.title}' }</h1>
<LinkButton
children='@{lab.sm.compare.table.clear}'
onClick={ this.props.clearCompare }
/>
<ComparisonTable
s={ this.props.comparing.s }
m={ this.props.comparing.m }
/>
</Card> }
</div>
);
}
}
export const Compare = connect((state: IRootState) => ({
comparing: state.compare.comparing,
}), {
clearCompare: createClearCompareAction,
})(CompareUW);

26
src/contents/Content.tsx

@ -0,0 +1,26 @@
import * as React from './../external/react';
import { About } from './About';
import { Compare } from './Compare';
import { ContentType } from './ContentType';
import { Export } from './Export';
import { Import } from './Import';
import { Language } from './Language';
import { Profile } from './Profile';
export function getContent(content: ContentType): React.ReactNode {
switch (content) {
case ContentType.PROFILE:
return <Profile/>;
case ContentType.COMPARE:
return <Compare/>;
case ContentType.LANGUAGE:
return <Language/>;
case ContentType.IMPORT:
return <Import/>;
case ContentType.EXPORT:
return <Export/>;
case ContentType.ABOUT:
return <About/>;
}
return null;
}

25
src/contents/ContentType.ts

@ -0,0 +1,25 @@
export enum ContentType {
PROFILE,
COMPARE,
LANGUAGE,
IMPORT,
EXPORT,
ABOUT,
}
export function getContentTitle(type: ContentType): string {
switch (type) {
case ContentType.PROFILE:
return '@{lab.sm.contentTitle.profile}';
case ContentType.COMPARE:
return '@{lab.sm.contentTitle.compare}';
case ContentType.LANGUAGE:
return 'Language Selection';
case ContentType.IMPORT:
return '@{lab.sm.contentTitle.import}';
case ContentType.EXPORT:
return '@{lab.sm.contentTitle.export}';
case ContentType.ABOUT:
return '@{lab.sm.contentTitle.about}';
}
}

40
src/contents/Export.tsx

@ -0,0 +1,40 @@
import { Card } from '../components/Card';
import { SimpleFormat } from '../components/SimpleFormat';
import * as React from './../external/react';
export class Export extends React.PureComponent {
private codeRef: HTMLElement;
private updateRef = (ref: HTMLElement | null) => {
if (ref !== null) {
this.codeRef = ref;
}
}
private clicked: boolean = false;
private onClick = () => {
if (this.clicked) {
return;
}
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(this.codeRef);
selection.removeAllRanges();
selection.addRange(range);
this.clicked = true;
}
public render() {
return (
<div className='content export'>
<Card>
<h1>{ '@{lab.sm.export.title}' }</h1>
<SimpleFormat>{ '@{lab.sm.export.desc}' }</SimpleFormat>
<code
ref={ this.updateRef }
onClick={ this.onClick }
>
{ window.localStorage.getItem('encodedAnswers') }
</code>
</Card>
</div>
);
}
}

113
src/contents/Import.tsx

@ -0,0 +1,113 @@
import { createReplaceAnswersAction } from '../actions/answers';
import { Card } from '../components/Card';
import { InputField } from '../components/InputField';
import { LinkButton } from '../components/LinkButton';
import { SimpleFormat } from '../components/SimpleFormat';
import * as React from '../external/react';
import { connect } from '../external/reactRedux';
import { decode } from '../persistence';
import { IAnswersState } from '../reducers/answers';
interface IImporterState {
readonly value: string;
readonly submitText: string;
readonly hint: string | null;
readonly hintColor: string;
}
interface IImporterProps {
readonly replaceAnswers: typeof createReplaceAnswersAction;
}
class ImporterUW extends React.PureComponent<IImporterProps, IImporterState> {
public constructor(props: IImporterProps) {
super(props);
this.state = {
value: '',
submitText: '@{lab.sm.import.import}',
hint: null,
hintColor: 'black',
};
}
private onChange = (value: string) => {
this.setState({ value });
}
private changeBackTimer: number | null = null;
private changeBack = () => {
this.setState({
submitText: '@{lab.sm.import.import}',
});
if (this.changeBackTimer !== null) {
clearTimeout(this.changeBackTimer);
this.changeBackTimer = null;
}
}
private onClickSubmit: React.EventHandler<React.MouseEvent<any>> = event => {
event.preventDefault();
if (this.changeBackTimer === null) {
this.setState({
submitText: '@{lab.sm.import.confirm}',
hint: null,
});
this.changeBackTimer = window.setTimeout(this.changeBack, 2000);
} else {
this.changeBack();
let result: IAnswersState | null;
try {
result = decode(this.state.value.trim());
} catch (e) {
result = null;
}
if (result === null) {
this.setState({
hint: '@{lab.sm.import.failed}',
hintColor: 'red',
});
} else {
this.setState({
hint: '@{lab.sm.import.successful}',
hintColor: 'green',
});
this.props.replaceAnswers(result);
}
}
}
public componentWillUnmount() {
if (this.changeBackTimer !== null) {
clearTimeout(this.changeBackTimer);
}
}
public render() {
return (
<div>
<InputField
label='@{lab.sm.import.label}'
onChange={ this.onChange }
value={ this.state.value }
/>
<LinkButton onClick={ this.onClickSubmit }>
{ this.state.submitText }
</LinkButton>
{ this.state.hint && <p style={{
color: this.state.hintColor,
}}>{ this.state.hint }</p> }
</div>
);
}
}
const Importer = connect(null, {
replaceAnswers: createReplaceAnswersAction,
})(ImporterUW);
export class Import extends React.PureComponent {
public render() {
return (
<div className='content import'>
<Card>
<h1>{ '@{lab.sm.import.title}' }</h1>
<SimpleFormat>{ '@{lab.sm.import.desc}' }</SimpleFormat>
<Importer/>
</Card>
</div>
);
}
}

46
src/contents/Language.tsx

@ -0,0 +1,46 @@
import { Card } from '../components/Card';
import { LinkButton } from '../components/LinkButton';
import * as React from './../external/react';
interface ILanguageOptionProps {
readonly display: string;
readonly name: string;
}
class LanguageOption extends React.PureComponent<ILanguageOptionProps> {
public onClick: React.EventHandler<React.MouseEvent<any>> = event => {
event.preventDefault();
fetch('/api/selectLanguage', {
body: JSON.stringify({
language: this.props.name,
}),
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
}).then(() => {
window.location.reload(true);
});
}
public render() {
return (
<LinkButton onClick={ this.onClick }>
{ this.props.display }
</LinkButton>
);
}
}
export class Language extends React.PureComponent {
public render() {
return (
<div className='content language'>
<Card>
<h1>Language Selection</h1>
<LanguageOption display='简体中文' name='zh_CN'/>
<LanguageOption display='English (United States)' name='en_US'/>
</Card>
</div>
);
}
}

323
src/contents/Profile.tsx

@ -0,0 +1,323 @@
import { accessAnswerById, accessAnswerByIndex } from '../AccessAnswer';
import { MCAnswer } from '../actions/answers';
import { Card } from '../components/Card';
import { FillBlankAnswer } from '../components/FillBlankAnswer';
import { MultipleChoiceAnswer } from '../components/MultipleChoiceAnswer';
import { SimpleFormat } from '../components/SimpleFormat';
import {
categories,
ICategory,
IFillBlankQuestion,
IQuestion,
} from '../data/categories';
import { HardcorenessMetrics, QuestionType } from '../data/Enums';
import * as React from '../external/react';
import { Collapse } from '../external/reactCollapse';
import { connect } from '../external/reactRedux';
import { IAnswersState } from '../reducers/answers';
import { IRootState } from '../RootState';
interface IHardcorenessMeterProps {
readonly level: HardcorenessMetrics;
}
const HardcorenessMeter = ({ level }: IHardcorenessMeterProps) => (
level === HardcorenessMetrics.UNAVAILABLE
? null
: <span
className='hardcoreness-meter'
style={{
color: level === HardcorenessMetrics.UNDETERMINED