();
const [resultCount, setResultCount] = React.useState(renderCount); // < 0 = don't limit
- // Get the filtered unit info and see if it needs to be sliced
- let filteredUnitInfo = getFilteredUnitInfo(inputData, charaInfo, dragonInfo, nameRef)
- .sort((unitInfo) => isUnitPrioritized(unitInfo) ? -1 : 1);
+ let unitInfoProcessed = getFilteredUnitInfo(inputData, charaInfo, dragonInfo, nameRef);
- const isSliced = resultCount > 0 && filteredUnitInfo.length > resultCount;
- if (isSliced) {
- filteredUnitInfo = filteredUnitInfo.slice(0, resultCount);
+ let prioritizedUnitInfo = unitInfoProcessed.filter((info) => isUnitPrioritized(info));
+ const otherUnitInfo = unitInfoProcessed.filter((info) => !isUnitPrioritized(info));
+ if (inputData) {
+ prioritizedUnitInfo = getSortedUnitInfo(prioritizedUnitInfo, inputData);
}
- // Separate to prioritized and others
- const prioritizedUnitInfo = filteredUnitInfo.filter((info) => isUnitPrioritized(info));
- const otherUnitInfo = filteredUnitInfo.filter((info) => !isUnitPrioritized(info));
+ unitInfoProcessed = [...prioritizedUnitInfo, ...otherUnitInfo];
+
+ const isSliced = resultCount > 0 && unitInfoProcessed.length > resultCount;
+ if (isSliced) {
+ unitInfoProcessed = unitInfoProcessed.slice(0, resultCount);
+ }
// Scroll after input data has changed
React.useEffect(() => {
@@ -92,7 +96,11 @@ export const UnitSearcher = <
{context?.session?.user.isAdmin && renderIfAdmin}
- {inputData && renderOutput({inputData, prioritizedUnitInfo, otherUnitInfo})}
+ {inputData && renderOutput({
+ inputData,
+ prioritizedUnitInfo: unitInfoProcessed.filter((info) => isUnitPrioritized(info)),
+ otherUnitInfo: unitInfoProcessed.filter((info) => !isUnitPrioritized(info)),
+ })}
{
isSliced &&
diff --git a/src/components/elements/posts/output/section.module.css b/src/components/elements/posts/output/section.module.css
new file mode 100644
index 00000000..0be4dde4
--- /dev/null
+++ b/src/components/elements/posts/output/section.module.css
@@ -0,0 +1,13 @@
+div.sectionTitle {
+ background-color: #323232;
+ align-items: center;
+ margin: 0.5rem 0;
+ padding: 0.5rem 0;
+ border-radius: 0.5rem;
+}
+div.sectionTitle:hover {
+ background-color: #505050;
+ cursor: pointer;
+}
+
+/*# sourceMappingURL=section.module.css.map */
diff --git a/src/components/elements/posts/output/section.module.css.map b/src/components/elements/posts/output/section.module.css.map
new file mode 100644
index 00000000..9694aac4
--- /dev/null
+++ b/src/components/elements/posts/output/section.module.css.map
@@ -0,0 +1 @@
+{"version":3,"sourceRoot":"","sources":["section.module.scss","../../../../../styles/colors.scss"],"names":[],"mappings":"AAGE;EACE,kBCEa;EDDb;EACA;EACA;EACA;;AAGF;EACE,kBCLa;EDMb","file":"section.module.css"}
\ No newline at end of file
diff --git a/src/components/elements/posts/output/section.module.scss b/src/components/elements/posts/output/section.module.scss
new file mode 100644
index 00000000..b6dcbc40
--- /dev/null
+++ b/src/components/elements/posts/output/section.module.scss
@@ -0,0 +1,16 @@
+@use "../../../../../styles/colors";
+
+div {
+ &.sectionTitle {
+ background-color: colors.$color-black-50;
+ align-items: center;
+ margin: 0.5rem 0;
+ padding: 0.5rem 0;
+ border-radius: 0.5rem;
+ }
+
+ &.sectionTitle:hover {
+ background-color: colors.$color-black-80;
+ cursor: pointer;
+ }
+}
diff --git a/src/components/elements/posts/output/section.test.tsx b/src/components/elements/posts/output/section.test.tsx
index 2ac22e4c..cda0cbd3 100644
--- a/src/components/elements/posts/output/section.test.tsx
+++ b/src/components/elements/posts/output/section.test.tsx
@@ -34,7 +34,7 @@ describe('Collapsible sections', () => {
/>
));
- const buttonOpenA = screen.getAllByText(translationEN.misc.collapse)[0];
+ const buttonOpenA = screen.getByText('a');
userEvent.click(buttonOpenA);
expect(screen.getByText('a')).toBeInTheDocument();
diff --git a/src/components/elements/posts/output/section.tsx b/src/components/elements/posts/output/section.tsx
index a36e7815..c2bd8ed9 100644
--- a/src/components/elements/posts/output/section.tsx
+++ b/src/components/elements/posts/output/section.tsx
@@ -6,6 +6,8 @@ import Collapse from 'react-bootstrap/Collapse';
import Row from 'react-bootstrap/Row';
import {useI18n} from '../../../../i18n/hook';
+import {IconCollapse} from '../../common/icons';
+import styles from './section.module.css';
type Props = {
@@ -41,14 +43,9 @@ export const CollapsibleSectionedContent = ({sections, getTitle, renderSect
return (
-
-
- {title}
-
-
-
+
+ setOpen({...open, [title]: !open[title]})}>
+ {title}
diff --git a/src/components/pages/gameData/story/audioControl.tsx b/src/components/pages/gameData/story/audioControl.tsx
new file mode 100644
index 00000000..da1a5847
--- /dev/null
+++ b/src/components/pages/gameData/story/audioControl.tsx
@@ -0,0 +1,58 @@
+import React from 'react';
+
+import Button from 'react-bootstrap/Button';
+import ButtonGroup from 'react-bootstrap/ButtonGroup';
+import Col from 'react-bootstrap/Col';
+import Row from 'react-bootstrap/Row';
+
+import {IconPause, IconPlay, IconStop} from '../../../elements/common/icons';
+import styles from './main.module.css';
+import {UseAudioControlReturn} from './types';
+
+
+type Props = {
+ hookReturn: UseAudioControlReturn,
+}
+
+export const AudioControl = ({hookReturn}: Props) => {
+ const {playingState, startAudio, resumeAudio, pauseAudio, stopAudio} = hookReturn;
+
+ const PlayButton = ({isStart = false}) => (
+
+ );
+
+ const PauseButton = () => (
+
+ );
+
+ const StopButton = () => (
+
+ );
+
+ if (playingState !== 'stopping') {
+ return (
+
+
+
+ {playingState === 'playing' ? : }
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ );
+};
diff --git a/src/components/pages/gameData/story/audioHook.test.ts b/src/components/pages/gameData/story/audioHook.test.ts
new file mode 100644
index 00000000..fcc8a71d
--- /dev/null
+++ b/src/components/pages/gameData/story/audioHook.test.ts
@@ -0,0 +1,203 @@
+import {renderReactHook} from '../../../../../test/render/main';
+import {StoryConversation} from '../../../../api-def/resources';
+import {useAudioControl} from './audioHook';
+
+describe('Audio control hook', () => {
+ const conversations: Array = [
+ {
+ type: 'conversation',
+ speakerName: 'speaker',
+ speakerIcon: null,
+ content: 'content',
+ isSys: false,
+ audioPaths: ['A'],
+ },
+ {
+ type: 'conversation',
+ speakerName: 'speaker',
+ speakerIcon: null,
+ content: 'content',
+ isSys: false,
+ audioPaths: ['B'],
+ },
+ {
+ type: 'conversation',
+ speakerName: 'speaker',
+ speakerIcon: null,
+ content: 'content',
+ isSys: false,
+ audioPaths: [],
+ },
+ {type: 'break'},
+ {
+ type: 'conversation',
+ speakerName: 'speaker',
+ speakerIcon: null,
+ content: 'content',
+ isSys: false,
+ audioPaths: ['C'],
+ },
+ ];
+
+ it('is not playing on load', async () => {
+ const {result} = renderReactHook(() => useAudioControl({
+ getConversationOfIndex: (idx) => conversations[idx],
+ conversationCount: conversations.length,
+ }));
+
+ expect(result.current.currentState.mainIdx).toBe(-1);
+ expect(result.current.currentState.subIdx).toBe(-1);
+ expect(result.current.currentState.isPlaying).toBeFalsy();
+ expect(result.current.playingState).toBe('stopping');
+ });
+
+ it('starts', async () => {
+ const {result} = renderReactHook(() => useAudioControl({
+ getConversationOfIndex: (idx) => conversations[idx],
+ conversationCount: conversations.length,
+ }));
+
+ result.current.startAudio();
+
+ expect(result.current.currentState.mainIdx).toBe(0);
+ expect(result.current.currentState.subIdx).toBe(0);
+ expect(result.current.currentState.isPlaying).toBeTruthy();
+ expect(result.current.playingState).toBe('playing');
+ });
+
+ it('pauses', async () => {
+ const {result} = renderReactHook(() => useAudioControl({
+ getConversationOfIndex: (idx) => conversations[idx],
+ conversationCount: conversations.length,
+ }));
+
+ result.current.startAudio();
+ result.current.advanceToNextAudio(0)();
+ result.current.pauseAudio();
+
+ expect(result.current.currentState.mainIdx).toBe(1);
+ expect(result.current.currentState.subIdx).toBe(0);
+ expect(result.current.currentState.isPlaying).toBeFalsy();
+ expect(result.current.playingState).toBe('pausing');
+ });
+
+ it('resumes', async () => {
+ const {result} = renderReactHook(() => useAudioControl({
+ getConversationOfIndex: (idx) => conversations[idx],
+ conversationCount: conversations.length,
+ }));
+
+ result.current.startAudio();
+ result.current.advanceToNextAudio(0)();
+ result.current.pauseAudio();
+ result.current.resumeAudio();
+
+ expect(result.current.currentState.mainIdx).toBe(1);
+ expect(result.current.currentState.subIdx).toBe(0);
+ expect(result.current.currentState.isPlaying).toBeTruthy();
+ expect(result.current.playingState).toBe('playing');
+ });
+
+ it('stops', async () => {
+ const {result} = renderReactHook(() => useAudioControl({
+ getConversationOfIndex: (idx) => conversations[idx],
+ conversationCount: conversations.length,
+ }));
+
+ result.current.startAudio();
+ result.current.advanceToNextAudio(0)();
+ result.current.advanceToNextAudio(1)();
+ result.current.stopAudio();
+
+ expect(result.current.currentState.mainIdx).toBe(-1);
+ expect(result.current.currentState.subIdx).toBe(-1);
+ expect(result.current.currentState.isPlaying).toBeFalsy();
+ expect(result.current.playingState).toBe('stopping');
+ });
+
+ it('advanced to the next sub-audio', async () => {
+ const {result} = renderReactHook(() => useAudioControl({
+ getConversationOfIndex: (idx) => conversations[idx],
+ conversationCount: conversations.length,
+ }));
+
+ result.current.startAudio();
+ result.current.advanceToNextSub(1);
+
+ expect(result.current.currentState.mainIdx).toBe(0);
+ expect(result.current.currentState.subIdx).toBe(1);
+ expect(result.current.currentState.isPlaying).toBeTruthy();
+ expect(result.current.playingState).toBe('playing');
+ });
+
+ it('does not stop on break', async () => {
+ const {result} = renderReactHook(() => useAudioControl({
+ getConversationOfIndex: (idx) => conversations[idx],
+ conversationCount: conversations.length,
+ }));
+
+ result.current.startAudio();
+ result.current.advanceToNextAudio(2)();
+
+ expect(result.current.currentState.mainIdx).toBe(4);
+ expect(result.current.currentState.subIdx).toBe(0);
+ expect(result.current.currentState.isPlaying).toBeTruthy();
+ expect(result.current.playingState).toBe('playing');
+ });
+
+ it('goes to the next conversation if no label', async () => {
+ const {result} = renderReactHook(() => useAudioControl({
+ getConversationOfIndex: (idx) => conversations[idx],
+ conversationCount: conversations.length,
+ }));
+
+ result.current.startAudio();
+ result.current.advanceToNextAudio(1)();
+
+ expect(result.current.currentState.mainIdx).toBe(4);
+ expect(result.current.currentState.subIdx).toBe(0);
+ expect(result.current.currentState.isPlaying).toBeTruthy();
+ expect(result.current.playingState).toBe('playing');
+ });
+
+ it('starts from the 1st audible audio label', async () => {
+ const conversationsOverride: Array = [
+ {
+ type: 'conversation',
+ speakerName: 'speaker',
+ speakerIcon: null,
+ content: 'content',
+ isSys: false,
+ audioPaths: [],
+ },
+ {
+ type: 'conversation',
+ speakerName: 'speaker',
+ speakerIcon: null,
+ content: 'content',
+ isSys: false,
+ audioPaths: [],
+ },
+ {
+ type: 'conversation',
+ speakerName: 'speaker',
+ speakerIcon: null,
+ content: 'content',
+ isSys: false,
+ audioPaths: ['A'],
+ },
+ ];
+
+ const {result} = renderReactHook(() => useAudioControl({
+ getConversationOfIndex: (idx) => conversationsOverride[idx],
+ conversationCount: conversationsOverride.length,
+ }));
+
+ result.current.startAudio();
+
+ expect(result.current.currentState.mainIdx).toBe(2);
+ expect(result.current.currentState.subIdx).toBe(0);
+ expect(result.current.currentState.isPlaying).toBeTruthy();
+ expect(result.current.playingState).toBe('playing');
+ });
+});
diff --git a/src/components/pages/gameData/story/audioHook.ts b/src/components/pages/gameData/story/audioHook.ts
new file mode 100644
index 00000000..d6e3cadd
--- /dev/null
+++ b/src/components/pages/gameData/story/audioHook.ts
@@ -0,0 +1,78 @@
+import React from 'react';
+
+import {StoryConversation} from '../../../../api-def/resources';
+import {AudioPlayingState, AudioState, UseAudioControlReturn} from './types';
+
+
+type UseAudioControlOptions = {
+ getConversationOfIndex: (index: number) => StoryConversation,
+ conversationCount: number
+}
+
+export const useAudioControl = ({
+ getConversationOfIndex,
+ conversationCount,
+}: UseAudioControlOptions): UseAudioControlReturn => {
+ const [state, setState] = React.useState({
+ mainIdx: -1,
+ subIdx: -1,
+ isPlaying: false,
+ });
+
+ let playingState: AudioPlayingState;
+ if (state.mainIdx === -1) {
+ playingState = 'stopping';
+ } else {
+ playingState = state.isPlaying ? 'playing' : 'pausing';
+ }
+
+ const isPlayingForIdx = (idx: number) => state.mainIdx === idx;
+
+ const advanceToNextAudio = (currentAudioIdx: number) => () => {
+ let newAudioIdx = currentAudioIdx + 1;
+
+ while (!hasAudioToPlay(newAudioIdx)) {
+ newAudioIdx++;
+
+ if (newAudioIdx >= conversationCount) {
+ // Audio loop complete, set back to non-playing index
+ stopAudio();
+ return;
+ }
+ }
+
+ setState({mainIdx: newAudioIdx, subIdx: 0, isPlaying: true});
+ };
+
+ const startAudio = advanceToNextAudio(-1);
+
+ const stopAudio = () => setState({mainIdx: -1, subIdx: -1, isPlaying: false});
+
+ const pauseAudio = () => setState({...state, isPlaying: false});
+
+ const resumeAudio = () => setState({...state, isPlaying: true});
+
+ const hasAudioToPlay = (idx: number) => {
+ const conversation = getConversationOfIndex(idx);
+
+ if (conversation.type !== 'conversation') {
+ return false;
+ }
+
+ return conversation.audioPaths.length > 0;
+ };
+
+ const advanceToNextSub = (subIdx: number) => setState({...state, subIdx});
+
+ return {
+ playingState,
+ isPlayingForIdx,
+ startAudio,
+ stopAudio,
+ pauseAudio,
+ resumeAudio,
+ advanceToNextAudio,
+ advanceToNextSub,
+ currentState: state,
+ };
+};
diff --git a/src/components/pages/gameData/story/chapter.tsx b/src/components/pages/gameData/story/chapter.tsx
new file mode 100644
index 00000000..a7a994c1
--- /dev/null
+++ b/src/components/pages/gameData/story/chapter.tsx
@@ -0,0 +1,45 @@
+import React from 'react';
+
+
+import {Story} from '../../../../api-def/resources';
+import {AudioControl} from './audioControl';
+import {useAudioControl} from './audioHook';
+import {StoryBreak} from './conversation/break';
+import {StoryTalk} from './conversation/talk';
+
+
+type Props = {
+ chapter: Story,
+}
+
+export const StoryChapter = ({chapter}: Props) => {
+ const hookReturn = useAudioControl({
+ getConversationOfIndex: (idx) => chapter.conversations[idx],
+ conversationCount: chapter.conversations.length,
+ });
+ const {playingState, isPlayingForIdx, advanceToNextAudio, advanceToNextSub, currentState} = hookReturn;
+
+ return (
+ <>
+
+ {chapter.conversations.map((conversation, idx) => {
+ if (conversation.type === 'conversation') {
+ return (
+
+ );
+ }
+ if (conversation.type === 'break') {
+ return ;
+ }
+ })}
+ >
+ );
+};
diff --git a/src/components/pages/gameData/story/conversation/break.tsx b/src/components/pages/gameData/story/conversation/break.tsx
new file mode 100644
index 00000000..6dc9ec0d
--- /dev/null
+++ b/src/components/pages/gameData/story/conversation/break.tsx
@@ -0,0 +1,6 @@
+import React from 'react';
+
+
+export const StoryBreak = () => {
+ return
;
+};
diff --git a/src/components/pages/gameData/story/conversation/talk.tsx b/src/components/pages/gameData/story/conversation/talk.tsx
new file mode 100644
index 00000000..7212d119
--- /dev/null
+++ b/src/components/pages/gameData/story/conversation/talk.tsx
@@ -0,0 +1,80 @@
+import React from 'react';
+
+import ReactAudioPlayer from 'react-audio-player';
+import Col from 'react-bootstrap/Col';
+import Row from 'react-bootstrap/Row';
+
+import {AudioPaths, DepotPaths, StoryTalk as StoryTalkData} from '../../../../../api-def/resources';
+import {Image} from '../../../../elements/common/image';
+import styles from '../main.module.css';
+
+
+type Props = {
+ conversation: StoryTalkData,
+ playAudio: boolean,
+ audioIdx: number,
+ isActive: boolean,
+ setAudioIdx: (newAudioIdx: number) => void,
+ onAllAudioPlayed: () => void,
+}
+
+export const StoryTalk = ({conversation, playAudio, isActive, audioIdx, setAudioIdx, onAllAudioPlayed}: Props) => {
+ const ref = React.useRef(null);
+
+ if (conversation.isSys) {
+ return (
+
+
+ {conversation.content}
+
+
+ );
+ }
+
+ if (playAudio) {
+ ref.current?.scrollIntoView({behavior: 'smooth', block: 'center', inline: 'center'});
+ }
+
+ return (
+
+ {
+ playAudio && conversation.audioPaths.length > 0 && audioIdx < conversation.audioPaths.length &&
+ {
+ const newAudioIdx = audioIdx + 1;
+ setAudioIdx(newAudioIdx);
+
+ if (newAudioIdx >= conversation.audioPaths.length) {
+ onAllAudioPlayed();
+ }
+ }}
+ />
+ }
+
+
+
+ {conversation.speakerName}
+
+
+
+
+
+ {
+ conversation.speakerIcon ?
+ :
+ <>>
+ }
+
+
+ {conversation.content}
+
+
+
+ );
+};
diff --git a/src/components/pages/gameData/story/main.module.css b/src/components/pages/gameData/story/main.module.css
new file mode 100644
index 00000000..de9c575b
--- /dev/null
+++ b/src/components/pages/gameData/story/main.module.css
@@ -0,0 +1,62 @@
+div.sysMessage {
+ border: #505050 1px solid;
+ border-radius: 0.25rem;
+ text-align: center;
+ padding: 0.5rem;
+ margin: 0.5rem 0;
+}
+div.speakerIcon {
+ height: 3rem;
+ width: 3rem;
+ margin-right: 0.5rem;
+}
+div.mainImage {
+ text-align: center;
+}
+div.relatedLinks {
+ text-align: center;
+}
+div.audioControl {
+ text-align: center;
+ margin: 0.5rem 0;
+}
+@keyframes conversationBackground {
+ 0% {
+ background-color: #312901;
+ }
+ 50% {
+ background-color: #473a06;
+ }
+ 100% {
+ background-color: #312901;
+ }
+}
+div.conversation, div.conversationActive {
+ margin: 0.3rem 0;
+}
+div.conversationActive {
+ animation: conversationBackground 3s linear infinite;
+ border: #806805 solid 3px;
+ border-radius: 0.5rem;
+ padding: 0 0.3rem 0.3rem;
+}
+
+img.speakerIcon {
+ border-radius: 0.25rem;
+}
+img.mainImage {
+ max-height: 60vh;
+}
+
+button.audioControl {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ font-size: 2rem;
+ width: 3rem;
+ height: 3rem;
+ border: 0;
+ margin: 0 auto;
+}
+
+/*# sourceMappingURL=main.module.css.map */
diff --git a/src/components/pages/gameData/story/main.module.css.map b/src/components/pages/gameData/story/main.module.css.map
new file mode 100644
index 00000000..c6617459
--- /dev/null
+++ b/src/components/pages/gameData/story/main.module.css.map
@@ -0,0 +1 @@
+{"version":3,"sourceRoot":"","sources":["main.module.scss"],"names":[],"mappings":"AAGE;EACE;EACA;EACA;EACA;EACA;;AAGF;EACE;EACA;EACA;;AAGF;EACE;;AAGF;EACE;;AAGF;EACE;EACA;;AAGF;EACE;IACE;;EAGF;IACE;;EAGF;IACE;;;AAIJ;EACE;;AAGF;EAGE;EACA;EACA;EACA;;;AAKF;EACE;;AAGF;EACE;;;AAKF;EAEE;EACA;EACA;EAEA;EACA;EACA;EAEA;EACA","file":"main.module.css"}
\ No newline at end of file
diff --git a/src/components/pages/gameData/story/main.module.scss b/src/components/pages/gameData/story/main.module.scss
new file mode 100644
index 00000000..da7f0bc2
--- /dev/null
+++ b/src/components/pages/gameData/story/main.module.scss
@@ -0,0 +1,83 @@
+@use "../../../../../styles/colors";
+
+div {
+ &.sysMessage {
+ border: colors.$color-black-80 1px solid;
+ border-radius: 0.25rem;
+ text-align: center;
+ padding: 0.5rem;
+ margin: 0.5rem 0;
+ }
+
+ &.speakerIcon {
+ height: 3rem;
+ width: 3rem;
+ margin-right: 0.5rem;
+ }
+
+ &.mainImage {
+ text-align: center;
+ }
+
+ &.relatedLinks {
+ text-align: center;
+ }
+
+ &.audioControl {
+ text-align: center;
+ margin: 0.5rem 0;
+ }
+
+ @keyframes conversationBackground {
+ 0% {
+ background-color: #312901;
+ }
+
+ 50% {
+ background-color: #473a06;
+ }
+
+ 100% {
+ background-color: #312901;
+ }
+ }
+
+ &.conversation {
+ margin: 0.3rem 0;
+ }
+
+ &.conversationActive {
+ @extend .conversation;
+
+ animation: conversationBackground 3s linear infinite;
+ border: #806805 solid 3px;
+ border-radius: 0.5rem;
+ padding: 0 0.3rem 0.3rem;
+ }
+}
+
+img {
+ &.speakerIcon {
+ border-radius: 0.25rem;
+ }
+
+ &.mainImage {
+ max-height: 60vh;
+ }
+}
+
+button {
+ &.audioControl {
+ // For centering icon
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ // Sizing
+ font-size: 2rem;
+ width: 3rem;
+ height: 3rem;
+ // Styling
+ border: 0;
+ margin: 0 auto;
+ }
+}
diff --git a/src/components/pages/gameData/story/main.test.tsx b/src/components/pages/gameData/story/main.test.tsx
new file mode 100644
index 00000000..fdc8a208
--- /dev/null
+++ b/src/components/pages/gameData/story/main.test.tsx
@@ -0,0 +1,232 @@
+import React from 'react';
+
+import {screen} from '@testing-library/react';
+
+import {renderReact} from '../../../../../test/render/main';
+import {DepotPaths, StoryBook} from '../../../../api-def/resources';
+import {ResourceLoader} from '../../../../utils/services/resources/loader';
+import {UnitStory} from './main';
+
+
+describe('Unit story page', () => {
+ let fnGetStoryBook: jest.SpyInstance>;
+
+ beforeEach(() => {
+ fnGetStoryBook = jest.spyOn(ResourceLoader, 'getStoryBook');
+ });
+
+ it('shows
for thematic break', async () => {
+ fnGetStoryBook.mockResolvedValueOnce([
+ {
+ id: 1,
+ title: 'story',
+ conversations: [{type: 'break'}],
+ },
+ ]);
+
+ renderReact(
+ () => ,
+ {contextParams: {unitId: 10650503}},
+ );
+
+ expect(await screen.findByText('', {selector: 'hr'}));
+ });
+
+ it('shows story content with speaker icon if available', async () => {
+ fnGetStoryBook.mockResolvedValueOnce([
+ {
+ id: 1,
+ title: 'story',
+ conversations: [{
+ type: 'conversation',
+ speakerName: 'speaker',
+ speakerIcon: 'icon.png',
+ content: 'content',
+ isSys: false,
+ audioPaths: [],
+ }],
+ },
+ ]);
+
+ renderReact(
+ () => ,
+ {contextParams: {unitId: 10650503}},
+ );
+
+ const icon = await screen.findByText('', {selector: 'img'});
+ expect(icon).toHaveAttribute('src', DepotPaths.getStorySpeakerIconURL('icon.png'));
+ });
+
+ it('shows story content without speaker icon if not available', async () => {
+ fnGetStoryBook.mockResolvedValueOnce([
+ {
+ id: 1,
+ title: 'story',
+ conversations: [{
+ type: 'conversation',
+ speakerName: 'speaker',
+ speakerIcon: null,
+ content: 'content',
+ isSys: false,
+ audioPaths: [],
+ }],
+ },
+ ]);
+
+ renderReact(
+ () => ,
+ {contextParams: {unitId: 10650503}},
+ );
+
+ expect(await screen.findByText('speaker')).toBeInTheDocument();
+ expect(screen.queryByText('', {selector: 'img'})).not.toBeInTheDocument();
+ });
+
+ it('shows speaker name explicitly', async () => {
+ fnGetStoryBook.mockResolvedValueOnce([
+ {
+ id: 1,
+ title: 'story',
+ conversations: [{
+ type: 'conversation',
+ speakerName: 'speaker',
+ speakerIcon: null,
+ content: 'content',
+ isSys: false,
+ audioPaths: [],
+ }],
+ },
+ ]);
+
+ renderReact(
+ () => ,
+ {contextParams: {unitId: 10650503}},
+ );
+
+ expect(await screen.findByText('speaker')).toBeInTheDocument();
+ });
+
+ it('shows story content', async () => {
+ fnGetStoryBook.mockResolvedValueOnce([
+ {
+ id: 1,
+ title: 'story',
+ conversations: [{
+ type: 'conversation',
+ speakerName: 'SYS',
+ speakerIcon: null,
+ content: 'content',
+ isSys: true,
+ audioPaths: [],
+ }],
+ },
+ ]);
+
+ renderReact(
+ () => ,
+ {contextParams: {unitId: 10650503}},
+ );
+
+ expect(await screen.findByText('content')).toBeInTheDocument();
+ });
+
+ it('shows system message without speaker name', async () => {
+ fnGetStoryBook.mockResolvedValueOnce([
+ {
+ id: 1,
+ title: 'story',
+ conversations: [{
+ type: 'conversation',
+ speakerName: 'SYS',
+ speakerIcon: null,
+ content: 'content',
+ isSys: true,
+ audioPaths: [],
+ }],
+ },
+ ]);
+
+ renderReact(
+ () => ,
+ {contextParams: {unitId: 10650503}},
+ );
+
+ expect(await screen.findByText('content')).toBeInTheDocument();
+ expect(screen.queryByText('SYS')).not.toBeInTheDocument();
+ });
+
+ it('shows ads for normal users', async () => {
+ fnGetStoryBook.mockResolvedValueOnce([
+ {
+ id: 1,
+ title: 'story',
+ conversations: [{
+ type: 'conversation',
+ speakerName: 'SYS',
+ speakerIcon: null,
+ content: 'content',
+ isSys: true,
+ audioPaths: [],
+ }],
+ },
+ ]);
+
+ renderReact(
+ () => ,
+ {contextParams: {unitId: 10650503}},
+ );
+
+ expect(await screen.findByText('content')).toBeInTheDocument();
+ expect(screen.getByTestId('ads-story')).toBeInTheDocument();
+ });
+
+ it('hides ads for ads-free users', async () => {
+ fnGetStoryBook.mockResolvedValueOnce([
+ {
+ id: 1,
+ title: 'story',
+ conversations: [{
+ type: 'conversation',
+ speakerName: 'SYS',
+ speakerIcon: null,
+ content: 'content',
+ isSys: true,
+ audioPaths: [],
+ }],
+ },
+ ]);
+
+ renderReact(
+ () => ,
+ {contextParams: {unitId: 10650503}, user: {adsFreeExpiry: new Date()}},
+ );
+
+ expect(await screen.findByText('content')).toBeInTheDocument();
+ expect(screen.queryByTestId('ads-story')).not.toBeInTheDocument();
+ });
+
+ it('shows related links', async () => {
+ fnGetStoryBook.mockResolvedValueOnce([
+ {
+ id: 107504041,
+ title: 'story',
+ conversations: [{
+ type: 'conversation',
+ speakerName: 'SYS',
+ speakerIcon: null,
+ content: 'content',
+ isSys: true,
+ audioPaths: [],
+ }],
+ },
+ ]);
+
+ renderReact(
+ () => ,
+ {contextParams: {unitId: 10750404}},
+ );
+
+ expect(await screen.findByText('Analysis')).toBeInTheDocument();
+ expect(screen.getByText('Info')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/pages/gameData/story/main.tsx b/src/components/pages/gameData/story/main.tsx
new file mode 100644
index 00000000..c3ae69ec
--- /dev/null
+++ b/src/components/pages/gameData/story/main.tsx
@@ -0,0 +1,64 @@
+import React from 'react';
+
+import Col from 'react-bootstrap/Col';
+import Row from 'react-bootstrap/Row';
+
+import {StoryBook} from '../../../../api-def/resources';
+import {useI18n} from '../../../../i18n/hook';
+import {ResourceLoader} from '../../../../utils/services/resources/loader';
+import {useUnitInfo} from '../../../../utils/services/resources/unitInfo/hooks';
+import {sortAscending} from '../../../../utils/sort';
+import {AdsStory} from '../../../elements/common/ads/main';
+import {isNotFetched, useFetchState} from '../../../elements/common/fetch';
+import {Loading} from '../../../elements/common/loading';
+import {useUnitId} from '../../../elements/gameData/hook';
+import {CollapsibleSectionedContent} from '../../../elements/posts/output/section';
+import {StoryChapter} from './chapter';
+import {StoryOtherInfo} from './other';
+
+
+export const UnitStory = () => {
+ const {lang} = useI18n();
+ const unitId = useUnitId();
+
+ if (!unitId) {
+ return <>>;
+ }
+
+ const {
+ fetchStatus: storyBook,
+ fetchFunction: fetchStoryBook,
+ } = useFetchState(
+ undefined,
+ () => ResourceLoader.getStoryBook(lang, unitId),
+ `Failed to fetch the unit story of ${unitId}`,
+ );
+ const {unitInfoMap} = useUnitInfo();
+ const unitInfo = unitInfoMap.get(unitId);
+
+ fetchStoryBook();
+
+ if (isNotFetched(storyBook) || !storyBook.data) {
+ return ;
+ }
+
+ return (
+ <>
+ {unitInfo && }
+
+
+ chapter.id}))}
+ getTitle={(chapter) => chapter.title}
+ renderSection={(chapter) => (
+ <>
+
+
+ >
+ )}
+ />
+
+
+ >
+ );
+};
diff --git a/src/components/pages/gameData/story/other.tsx b/src/components/pages/gameData/story/other.tsx
new file mode 100644
index 00000000..fefa1739
--- /dev/null
+++ b/src/components/pages/gameData/story/other.tsx
@@ -0,0 +1,52 @@
+import React from 'react';
+
+import Col from 'react-bootstrap/Col';
+import Row from 'react-bootstrap/Row';
+
+import {DepotPaths, UnitInfoData} from '../../../../api-def/resources';
+import {PostPath, UnitPath} from '../../../../const/path/definitions';
+import {useI18n} from '../../../../i18n/hook';
+import {makePostUrl, makeUnitUrl} from '../../../../utils/path/make';
+import {Image} from '../../../elements/common/image';
+import {InternalLink} from '../../../elements/common/link/internal';
+import styles from './main.module.css';
+
+
+type Props = {
+ unitInfo: UnitInfoData,
+}
+
+export const StoryOtherInfo = ({unitInfo}: Props) => {
+ const {t, lang} = useI18n();
+
+ return (
+ <>
+
+
+
+
+
+
+
+ t.game.unitInfo.links.analysis)}
+ />
+
+
+ t.game.unitInfo.links.info)}
+ />
+
+
+
+ >
+ );
+};
diff --git a/src/components/pages/gameData/story/types.ts b/src/components/pages/gameData/story/types.ts
new file mode 100644
index 00000000..c795edc6
--- /dev/null
+++ b/src/components/pages/gameData/story/types.ts
@@ -0,0 +1,19 @@
+export type AudioState = {
+ mainIdx: number,
+ subIdx: number,
+ isPlaying: boolean,
+}
+
+export type AudioPlayingState = 'playing' | 'pausing' | 'stopping';
+
+export type UseAudioControlReturn = {
+ playingState: AudioPlayingState,
+ isPlayingForIdx: (idx: number) => boolean,
+ startAudio: () => void,
+ stopAudio: () => void,
+ pauseAudio: () => void,
+ resumeAudio: () => void,
+ advanceToNextAudio: (srcIdx: number) => () => void,
+ advanceToNextSub: (newSubIdx: number) => void,
+ currentState: AudioState,
+}
diff --git a/src/components/pages/gameData/unitInfo/lookup/main.test.tsx b/src/components/pages/gameData/unitInfo/lookup/main.test.tsx
index b88efb54..78db9feb 100644
--- a/src/components/pages/gameData/unitInfo/lookup/main.test.tsx
+++ b/src/components/pages/gameData/unitInfo/lookup/main.test.tsx
@@ -115,31 +115,26 @@ describe('Analysis lookup page', () => {
});
it('scrolls on not found, also scrolls on found', async () => {
- fnGetLookup.mockResolvedValue(lookupResponseNoAnalyses);
+ fnGetLookup.mockResolvedValue(lookupResponseHasAnalyses);
const {rerender} = renderReact(() => );
- expect(fnGetLookupLanding).toHaveBeenCalledTimes(1);
-
const keywordInput = screen.getByPlaceholderText(translationEN.misc.searchKeyword);
const searchButton = await screen.findByText(translationEN.misc.search, {selector: 'button:enabled'});
userEvent.type(keywordInput, 'AAA');
userEvent.click(searchButton);
- expect(fnGetLookup).toHaveBeenCalledTimes(1);
await waitFor(() => expect(fnScroll).toHaveBeenCalledTimes(2));
expect(screen.queryByText('Gala Leonidas')).not.toBeInTheDocument();
- fnGetLookup.mockResolvedValue(lookupResponseHasAnalyses);
rerender();
expect(fnGetLookupLanding).toHaveBeenCalledTimes(1);
userEvent.clear(keywordInput);
userEvent.click(searchButton);
- expect(fnGetLookup).toHaveBeenCalledTimes(1);
- await waitFor(() => expect(screen.getByAltText('Gala Leonidas')).toBeInTheDocument());
- expect(fnScroll).toHaveBeenCalledTimes(3);
- }, 15000); // Finding `Gala Leonidas` is time-consuming, causing false negative
+ await waitFor(() => expect(fnScroll).toHaveBeenCalledTimes(3));
+ expect(await screen.findByAltText('Gala Leonidas')).toBeInTheDocument();
+ });
it('searches by element and type', async () => {
fnGetLookup.mockResolvedValue(lookupResponseNoAnalyses);
@@ -154,7 +149,7 @@ describe('Analysis lookup page', () => {
userEvent.click(searchButton);
expect(fnGetLookup).toHaveBeenCalledTimes(1);
- await waitFor(() => expect(screen.getByAltText('Gala Leonidas')).toBeInTheDocument());
+ await waitFor(() => expect(screen.getByAltText('Panther')).toBeInTheDocument());
expect(screen.queryByAltText('Karina')).not.toBeInTheDocument();
});
diff --git a/src/components/pages/gameData/unitInfo/lookup/main.tsx b/src/components/pages/gameData/unitInfo/lookup/main.tsx
index dc475797..8a6b8bfb 100644
--- a/src/components/pages/gameData/unitInfo/lookup/main.tsx
+++ b/src/components/pages/gameData/unitInfo/lookup/main.tsx
@@ -10,7 +10,7 @@ import {useFetchState} from '../../../../elements/common/fetch';
import {UnitSearcher} from '../../../../elements/gameData/unit/searcher/main';
import {PostManageBar} from '../../../../elements/posts/manageBar';
import {UnitInfoLookupLanding} from './in/landing';
-import {orderName} from './in/sort/lookup';
+import {orderName, sortFunc} from './in/sort/lookup';
import {generateInputData} from './in/utils';
import {MaxEntriesToDisplay} from './out/const';
import {UnitInfoLookupOutput} from './out/main';
@@ -77,6 +77,12 @@ export const UnitInfoLookup = () => {
renderCount={MaxEntriesToDisplay}
onSearchRequested={(inputData) => GoogleAnalytics.analysisLookup(inputData)}
isUnitPrioritized={(info) => info.id in analysisMeta.data.analyses}
+ getSortedUnitInfo={(unitInfo, inputData) => (
+ unitInfo
+ .map((info) => ({unitInfo: info, lookupInfo: analysisMeta.data.analyses[info.id]}))
+ .sort(sortFunc[inputData.sortBy])
+ .map((item) => item.unitInfo)
+ )}
isLoading={analysisMeta.fetching}
/>
>
diff --git a/src/components/pages/gameData/unitInfo/lookup/out/main.test.tsx b/src/components/pages/gameData/unitInfo/lookup/out/main.test.tsx
index b932ae6b..bc8c44a5 100644
--- a/src/components/pages/gameData/unitInfo/lookup/out/main.test.tsx
+++ b/src/components/pages/gameData/unitInfo/lookup/out/main.test.tsx
@@ -1,24 +1,15 @@
import React from 'react';
-import {waitFor} from '@testing-library/react';
-
import unitInfo from '../../../../../../../test/data/resources/info/chara.json';
import {renderReact} from '../../../../../../../test/render/main';
import {overrideObject} from '../../../../../../utils/override';
-import {sortFunc} from '../in/sort/lookup';
import {InputData} from '../in/types';
import {generateInputData} from '../in/utils';
import {UnitInfoLookupOutput} from './main';
describe('Unit info lookup output', () => {
- let fnSortByUnitId: jest.SpyInstance;
-
- beforeEach(() => {
- fnSortByUnitId = jest.spyOn(sortFunc, 'unitId');
- });
-
- it('sorts the output', async () => {
+ it('renders the output', async () => {
const inputData: InputData = overrideObject(generateInputData(), {sortBy: 'unitId'});
renderReact(() => (
@@ -29,7 +20,5 @@ describe('Unit info lookup output', () => {
analyses={{}}
/>
));
-
- await waitFor(() => expect(fnSortByUnitId).toHaveBeenCalled());
});
});
diff --git a/src/components/pages/gameData/unitInfo/lookup/out/main.tsx b/src/components/pages/gameData/unitInfo/lookup/out/main.tsx
index a2fe94d9..a02c9066 100644
--- a/src/components/pages/gameData/unitInfo/lookup/out/main.tsx
+++ b/src/components/pages/gameData/unitInfo/lookup/out/main.tsx
@@ -6,7 +6,6 @@ import Form from 'react-bootstrap/Form';
import {UnitInfoLookupAnalyses} from '../../../../../../api-def/api';
import {useI18n} from '../../../../../../i18n/hook';
import {UnitSearchOutputProps} from '../../../../../elements/gameData/unit/searcher/types';
-import {sortFunc} from '../in/sort/lookup';
import {InputData, SortOrder} from '../in/types';
import {UnitInfoEntry} from './entry';
@@ -16,7 +15,6 @@ type AnalysisLookupOutputProps = UnitSearchOutputProps & {
}
export const UnitInfoLookupOutput = ({
- inputData,
prioritizedUnitInfo,
otherUnitInfo,
analyses,
@@ -25,13 +23,12 @@ export const UnitInfoLookupOutput = ({
// Split to prioritize the units that have analysis
const unitInfoHasAnalysis = prioritizedUnitInfo
- .map((info) => ({unitInfo: info, lookupInfo: analyses[info.id]}))
- .sort(sortFunc[inputData.sortBy]);
+ .map((info) => ({unitInfo: info, lookupInfo: analyses[info.id]}));
const unitInfoNoAnalysis = otherUnitInfo
.map((info) => ({unitInfo: info, lookupInfo: undefined}));
const unitInfoSorted = [...unitInfoHasAnalysis, ...unitInfoNoAnalysis];
- if (!prioritizedUnitInfo.length && !otherUnitInfo.length) {
+ if (!unitInfoSorted.length) {
return (
{t((t) => t.posts.analysis.error.noResult)}
diff --git a/src/components/pages/gameData/unitInfo/output/elements/normalAttack/branchedTab.tsx b/src/components/pages/gameData/unitInfo/output/elements/normalAttack/branchedTab.tsx
index 76369b4b..589033e5 100644
--- a/src/components/pages/gameData/unitInfo/output/elements/normalAttack/branchedTab.tsx
+++ b/src/components/pages/gameData/unitInfo/output/elements/normalAttack/branchedTab.tsx
@@ -3,7 +3,7 @@ import React from 'react';
import {NormalAttackBranchedChain} from '../../../../../../../api-def/resources';
import {ConditionCodes} from '../../../../../../../const/gameData';
import {useI18n} from '../../../../../../../i18n/hook';
-import {sum} from '../../../../../../../utils/calc';
+import {roundArray, sum} from '../../../../../../../utils/calc';
import {ConditionBadges} from '../../../../../../elements/gameData/badges/conditions';
import {Markdown} from '../../../../../../elements/markdown/main';
import styles from '../main.module.css';
@@ -46,7 +46,7 @@ export const NormalAttackBranchedTab = ({branchedChain}: Props) => {
{idx + 1} |
- {`==(${combo.mods.join(' + ')}) x 100%[2f]==`}
+ {`==(${roundArray(combo.mods, 3).join(' + ')}) x 100%[2f]==`}
|
{combo.mods.length} |
@@ -54,14 +54,14 @@ export const NormalAttackBranchedTab = ({branchedChain}: Props) => {
{branchedChain.hasUtp && {combo.utp} | }
- {`==(${combo.odRate.join(' + ')}) / ${combo.odRate.length}[2f]==`}
+ {`==(${roundArray(combo.odRate, 3).join(' + ')}) / ${combo.odRate.length}[2f]==`}
|
{
branchedChain.hasCrisis &&
- {`==(${combo.crisisMod.join(' + ')}) / ${combo.crisisMod.length}[2f]==`}
+ {`==(${roundArray(combo.crisisMod, 3).join(' + ')}) / ${combo.crisisMod.length}[2f]==`}
|
}
diff --git a/src/components/pages/tier/main.tsx b/src/components/pages/tier/main.tsx
index 7b732969..29c24234 100644
--- a/src/components/pages/tier/main.tsx
+++ b/src/components/pages/tier/main.tsx
@@ -9,7 +9,7 @@ import {ApiRequestSender} from '../../../utils/services/api/requestSender';
import {ButtonBar} from '../../elements/common/buttonBar';
import {useFetchStateProcessed} from '../../elements/common/fetch';
import {UnitSearcher} from '../../elements/gameData/unit/searcher/main';
-import {MaxEntriesToDisplay, orderName} from './const';
+import {MaxEntriesToDisplay, orderName, sortFunc} from './const';
import {useKeyPointData} from './hooks';
import {TierListOutput} from './out/main';
import {Display, DisplayOption, InputData} from './types';
@@ -71,6 +71,12 @@ export const TierList = () => {
)}
renderCount={MaxEntriesToDisplay}
isUnitPrioritized={(info) => info.id in tierData.data}
+ getSortedUnitInfo={(unitInfo, inputData) => (
+ unitInfo
+ .map((info) => ({unitInfo: info, tierNote: tierData.data[info.id]}))
+ .sort(sortFunc[inputData.sortBy])
+ .map((obj) => obj.unitInfo)
+ )}
isLoading={tierData.fetching}
/>
);
diff --git a/src/components/pages/tier/out/main.tsx b/src/components/pages/tier/out/main.tsx
index 3f8fb4b4..f6c63f96 100644
--- a/src/components/pages/tier/out/main.tsx
+++ b/src/components/pages/tier/out/main.tsx
@@ -8,7 +8,6 @@ import {UnitTierData} from '../../../../api-def/api';
import {useI18n} from '../../../../i18n/hook';
import {AdsTierResultsEnd} from '../../../elements/common/ads/main';
import {UnitSearchOutputProps} from '../../../elements/gameData/unit/searcher/types';
-import {sortFunc} from '../const';
import {IconCompDependent} from '../icons';
import {InputData, PropsUseKeyPointData, SortOrder} from '../types';
import {TierListOutputShowAll} from './all/main';
@@ -23,8 +22,7 @@ export const TierListOutput = ({inputData, tierData, prioritizedUnitInfo, otherU
const {t} = useI18n();
const entryPackHasTierNote = prioritizedUnitInfo
- .map((info) => ({unitInfo: info, tierNote: tierData[info.id]}))
- .sort(sortFunc[inputData.sortBy]);
+ .map((info) => ({unitInfo: info, tierNote: tierData[info.id]}));
const entryPackNoTierNote = otherUnitInfo
.map((info) => ({unitInfo: info, tierNote: undefined}));
diff --git a/src/components/pages/tier/unit/main.tsx b/src/components/pages/tier/unit/main.tsx
new file mode 100644
index 00000000..b11c4afd
--- /dev/null
+++ b/src/components/pages/tier/unit/main.tsx
@@ -0,0 +1,6 @@
+import React from 'react';
+
+
+export const TierNoteUnit = () => {
+ return <>WIP>;
+};
diff --git a/src/const/path/definitions.ts b/src/const/path/definitions.ts
index 185daebb..fc3dba98 100644
--- a/src/const/path/definitions.ts
+++ b/src/const/path/definitions.ts
@@ -5,9 +5,17 @@ export enum DataPath {
TIER_KEY_POINT = '/tier/points/[id]',
}
+// Must and only have `id` as the key for story ID
+export enum StoryPath {
+ UNIT = '/story/unit/[id]',
+ MAIN = '/story/main/[id]',
+ EVENT = '/story/event/[id]',
+}
+
// Must and only have `id` as the key for unit ID
export enum UnitPath {
UNIT_INFO = '/info/[id]',
+ UNIT_TIER = '/tier/[id]',
UNIT_TIER_EDIT = '/tier/edit/[id]'
}
@@ -55,9 +63,9 @@ export enum AuthPath {
}
export const allPaths = ([] as Array).concat(
- ...[DataPath, UnitPath, PostPath, GeneralPath, AuthPath].map(
+ ...[DataPath, UnitPath, PostPath, StoryPath, GeneralPath, AuthPath].map(
(paths) => Object.values(paths),
),
);
-export type PagePath = DataPath | UnitPath | PostPath | GeneralPath | AuthPath;
+export type PagePath = DataPath | UnitPath | PostPath | StoryPath | GeneralPath | AuthPath;
diff --git a/src/i18n/translations/cht/translation.ts b/src/i18n/translations/cht/translation.ts
index ee7698a8..99662cf0 100644
--- a/src/i18n/translations/cht/translation.ts
+++ b/src/i18n/translations/cht/translation.ts
@@ -395,7 +395,9 @@ export const translation: TranslationStruct = {
},
links: {
analysis: '評測',
+ tier: '評級',
info: '資訊',
+ story: '故事',
},
text: {
total: '(總計)',
@@ -651,10 +653,14 @@ export const translation: TranslationStruct = {
description: '編輯要點內容的頁面。',
},
},
+ unit: {
+ title: '【評級 / 要點】{{unitName}}',
+ description: '{{unitName}} 的評級和要點頁面。',
+ },
},
gameData: {
info: {
- title: '角色/龍族資訊目錄',
+ title: '角色/龍族索引',
description: '各角色、龍族的評測、資訊的索引頁面。',
},
ex: {
@@ -682,6 +688,12 @@ export const translation: TranslationStruct = {
description: '設定物件名稱的頁面。',
},
},
+ story: {
+ unit: {
+ title: '【角色故事】{{unitName}}',
+ description: '{{unitName}} 的角色故事全集。',
+ },
+ },
},
error: {
401: {
@@ -702,7 +714,7 @@ export const translation: TranslationStruct = {
suffix: ' | 龍絆攻略站 by OM',
},
nav: {
- unitInfo: '角色/龍族資訊',
+ unitInfo: '角色/龍族索引',
unitTier: '評級',
},
posts: {
diff --git a/src/i18n/translations/definition.ts b/src/i18n/translations/definition.ts
index 6efcfa39..88a03c51 100644
--- a/src/i18n/translations/definition.ts
+++ b/src/i18n/translations/definition.ts
@@ -390,7 +390,9 @@ export type TranslationStruct = {
},
links: {
analysis: string,
+ tier: string,
info: string,
+ story: string,
},
text: {
total: string,
@@ -529,6 +531,7 @@ export type TranslationStruct = {
usage: PageMetaTranslations,
edit: PageMetaTranslations,
},
+ unit: PageMetaTranslations,
},
gameData: {
info: PageMetaTranslations,
@@ -542,6 +545,9 @@ export type TranslationStruct = {
info: PageMetaTranslations,
name: PageMetaTranslations,
},
+ story: {
+ unit: PageMetaTranslations,
+ }
},
error: {
401: PageMetaTranslations,
diff --git a/src/i18n/translations/en/translation.ts b/src/i18n/translations/en/translation.ts
index 39538cdc..47767011 100644
--- a/src/i18n/translations/en/translation.ts
+++ b/src/i18n/translations/en/translation.ts
@@ -427,7 +427,9 @@ export const translation: TranslationStruct = {
},
links: {
analysis: 'Analysis',
+ tier: 'Ranking / Tier',
info: 'Info',
+ story: 'Story',
},
text: {
total: '(Total)',
@@ -698,6 +700,10 @@ export const translation: TranslationStruct = {
description: 'Page to edit the content of the key points.',
},
},
+ unit: {
+ title: '【Ranking / Key Point】{{unitName}}',
+ description: 'Ranking and the key points of {{unitName}} .',
+ },
},
gameData: {
info: {
@@ -729,6 +735,12 @@ export const translation: TranslationStruct = {
description: 'Page to configure the custom unit names.',
},
},
+ story: {
+ unit: {
+ title: '【Unit Story】{{unitName}}',
+ description: 'All unit stories of {{unitName}}.',
+ },
+ },
},
error: {
401: {
diff --git a/src/i18n/translations/jp/translation.ts b/src/i18n/translations/jp/translation.ts
index f68cb084..2b6d15cb 100644
--- a/src/i18n/translations/jp/translation.ts
+++ b/src/i18n/translations/jp/translation.ts
@@ -402,7 +402,9 @@ export const translation: TranslationStruct = {
},
links: {
analysis: '評価',
+ tier: 'TBA',
info: 'キャラ情報',
+ story: 'ストーリー',
},
text: {
total: '(総計)',
@@ -656,6 +658,10 @@ export const translation: TranslationStruct = {
description: 'ポイントの編集ページ。',
},
},
+ unit: {
+ title: 'TBA',
+ description: 'TBA',
+ },
},
gameData: {
info: {
@@ -680,13 +686,19 @@ export const translation: TranslationStruct = {
unit: {
info: {
title: '{{unitName}}',
- description: ' {{unitName}}に関する情報',
+ description: ' {{unitName}}に関する情報。',
},
name: {
title: 'ユニット名前設定ページ',
description: 'ユニット名前を設定する。',
},
},
+ story: {
+ unit: {
+ title: '【ユニットストーリー】{{unitName}}',
+ description: '全部の{{unitName}}のストーリー。',
+ },
+ },
},
error: {
401: {
diff --git a/src/utils/calc.ts b/src/utils/calc.ts
index 66e7493d..a89e9af5 100644
--- a/src/utils/calc.ts
+++ b/src/utils/calc.ts
@@ -1,3 +1,5 @@
+import Decimal from 'decimal.js';
+
export const normalize = (numbers: Array, padding: number = 0) => {
const max = Math.max(...numbers);
@@ -22,3 +24,7 @@ export const varTally = (arr: Array) => {
return tally;
};
+
+export const roundArray = (nums: Array, places: number): Array => {
+ return nums.map((mod) => new Decimal(mod).toDecimalPlaces(places, Decimal.ROUND_HALF_CEIL));
+};
diff --git a/src/utils/meta/preprocess.ts b/src/utils/meta/preprocess.ts
index 3d1db744..e4ae6131 100644
--- a/src/utils/meta/preprocess.ts
+++ b/src/utils/meta/preprocess.ts
@@ -2,6 +2,7 @@ import {getSession} from 'next-auth/client';
import {AppContext} from 'next/app';
import {FailedResponse, PageMetaResponse, SupportedLanguages} from '../../api-def/api';
+import {StoryPath} from '../../const/path/definitions';
import {isDataPath, isPostPath, isUnitPath} from '../../const/path/utils';
import {ApiRequestSender} from '../services/api/requestSender';
import {pathPostType} from './lookup';
@@ -18,10 +19,8 @@ export const getPageMetaPromise = async ({
}: PageMetaPromiseArgs): Promise => {
const uid = (await getSession(context.ctx))?.user?.id.toString() || '';
- let responsePromise = ApiRequestSender.getPageMeta(uid, lang);
-
if (isDataPath(pathnameNoLang)) {
- responsePromise = ApiRequestSender.getDataMeta(
+ return ApiRequestSender.getDataMeta(
uid,
lang,
'tierKeyPoint',
@@ -30,7 +29,7 @@ export const getPageMetaPromise = async ({
}
if (isPostPath(pathnameNoLang)) {
- responsePromise = ApiRequestSender.getPostMeta(
+ return ApiRequestSender.getPostMeta(
uid,
lang,
pathPostType[pathnameNoLang],
@@ -38,13 +37,13 @@ export const getPageMetaPromise = async ({
);
}
- if (isUnitPath(pathnameNoLang)) {
- responsePromise = ApiRequestSender.getUnitMeta(
+ if (isUnitPath(pathnameNoLang) || pathnameNoLang === StoryPath.UNIT) {
+ return ApiRequestSender.getUnitMeta(
uid,
lang,
context.router.query.id as string,
);
}
- return responsePromise;
+ return ApiRequestSender.getPageMeta(uid, lang);
};
diff --git a/src/utils/meta/translations.ts b/src/utils/meta/translations.ts
index 626d6dd1..df0ac2a3 100644
--- a/src/utils/meta/translations.ts
+++ b/src/utils/meta/translations.ts
@@ -1,4 +1,4 @@
-import {AuthPath, DataPath, GeneralPath, PagePath, PostPath, UnitPath} from '../../const/path/definitions';
+import {AuthPath, DataPath, GeneralPath, PagePath, PostPath, StoryPath, UnitPath} from '../../const/path/definitions';
import {PageMetaTranslations} from '../../i18n/translations/definition';
import {GetTranslationFunction} from '../../i18n/types';
@@ -7,6 +7,7 @@ export const metaTransFunctions: { [path in PagePath]: GetTranslationFunction t.meta.inUse.tier.points.usage,
[UnitPath.UNIT_INFO]: (t) => t.meta.inUse.unit.info,
+ [UnitPath.UNIT_TIER]: (t) => t.meta.inUse.tier.unit,
[UnitPath.UNIT_TIER_EDIT]: (t) => t.meta.inUse.tier.edit,
[PostPath.QUEST]: (t) => t.meta.inUse.post.quest.post,
[PostPath.QUEST_EDIT]: (t) => t.meta.inUse.post.quest.edit,
@@ -14,6 +15,7 @@ export const metaTransFunctions: { [path in PagePath]: GetTranslationFunction t.meta.inUse.post.analysis.edit,
[PostPath.MISC]: (t) => t.meta.inUse.post.misc.post,
[PostPath.MISC_EDIT]: (t) => t.meta.inUse.post.misc.edit,
+ [StoryPath.UNIT]: (t) => t.meta.inUse.story.unit,
[GeneralPath.HOME]: (t) => t.meta.inUse.home,
[GeneralPath.QUEST_LIST]: (t) => t.meta.inUse.post.quest.list,
[GeneralPath.QUEST_NEW]: (t) => t.meta.inUse.post.quest.new,
@@ -35,6 +37,8 @@ export const metaTransFunctions: { [path in PagePath]: GetTranslationFunction t.meta.temp.constructing,
[GeneralPath.STORY]: (t) => t.meta.temp.constructing,
[GeneralPath.ROTATION_CALC]: (t) => t.meta.temp.constructing,
+ [StoryPath.MAIN]: (t) => t.meta.inUse.post.misc.edit,
+ [StoryPath.EVENT]: (t) => t.meta.inUse.post.misc.edit,
// Legacy
[GeneralPath.ANALYSIS_LIST]: (t) => t.meta.inUse.gameData.info,
};
diff --git a/src/utils/path/make.ts b/src/utils/path/make.ts
index f6dfb2d7..168b6ea5 100644
--- a/src/utils/path/make.ts
+++ b/src/utils/path/make.ts
@@ -1,5 +1,5 @@
import {SupportedLanguages} from '../../api-def/api';
-import {DataPath, GeneralPath, PostPath, UnitPath} from '../../const/path/definitions';
+import {DataPath, GeneralPath, PostPath, StoryPath, UnitPath} from '../../const/path/definitions';
const generateUrl = (path: string, args: { [key in string]: string | number }) => {
@@ -46,3 +46,12 @@ type UnitPathArgs = PathArgs & {
export const makeUnitUrl = (path: UnitPath, args: UnitPathArgs) => {
return generateUrl(`/${args.lang}${path}`, args);
};
+
+// Needs to match the key names used in `StoryPath`
+type StoryPathArgs = PathArgs & {
+ id: number,
+}
+
+export const makeStoryUrl = (path: StoryPath, args: StoryPathArgs) => {
+ return generateUrl(`/${args.lang}${path}`, args);
+};
diff --git a/src/utils/services/resources/loader.ts b/src/utils/services/resources/loader.ts
index 829ce68e..262659dd 100644
--- a/src/utils/services/resources/loader.ts
+++ b/src/utils/services/resources/loader.ts
@@ -1,3 +1,4 @@
+import {SupportedLanguages} from '../../../api-def/api/other/lang';
import {
AttackingSkillData,
BuffParamEnums,
@@ -11,10 +12,12 @@ import {
ExBuffParams,
InfoDataAdvanced,
NormalAttackChain,
- ResourcePaths, SimpleUnitInfo,
+ ResourcePaths,
+ SimpleUnitInfo,
SkillEnums,
SkillIdentifierInfo,
StatusEnums,
+ StoryBook,
WeaponTypeEnums,
} from '../../../api-def/resources';
@@ -229,6 +232,23 @@ export class ResourceLoader {
// endregion
+ // region Story
+ /**
+ * Get the story book of a unit.
+ *
+ * @function
+ * @param {SupportedLanguages} lang language of the stories
+ * @param {number} unitId unit ID to get the stories
+ * @param {function?} callback function to be called after fetching the resource
+ * @return {Promise} promise after the callback
+ */
+ static getStoryBook(
+ lang: SupportedLanguages, unitId: number, callback?: (advancedInfo: StoryBook) => void,
+ ): Promise {
+ return ResourceLoader.fetchResources(ResourcePaths.getStoryDataURL(lang, unitId), callback);
+ }
+ // endregion
+
// region Misc
/**
* Get the element bonus data.
diff --git a/test/data/resources b/test/data/resources
index 69c60f0f..e3740e0b 160000
--- a/test/data/resources
+++ b/test/data/resources
@@ -1 +1 @@
-Subproject commit 69c60f0fd74213676477ccb50248100e55f750a9
+Subproject commit e3740e0bf4db4cdfb48e702aa8c52d48f7a20db4