Add components for Act management and integrate Electron setup

This commit is contained in:
natreex
2025-11-16 11:00:04 -05:00
parent e192b6dcc2
commit 8167948881
97 changed files with 25378 additions and 3 deletions

View File

@@ -0,0 +1,608 @@
import React, {Dispatch, SetStateAction, useContext, useState} from 'react';
import {
faFire,
faFlag,
faPuzzlePiece,
faScaleBalanced,
faTrophy,
IconDefinition,
} from '@fortawesome/free-solid-svg-icons';
import {Act as ActType, Incident, PlotPoint} from '@/lib/models/Book';
import {ActChapter, ChapterListProps} from '@/lib/models/Chapter';
import System from '@/lib/models/System';
import {BookContext} from '@/context/BookContext';
import {SessionContext} from '@/context/SessionContext';
import {AlertContext} from '@/context/AlertContext';
import CollapsableArea from '@/components/CollapsableArea';
import ActDescription from '@/components/book/settings/story/act/ActDescription';
import ActChaptersSection from '@/components/book/settings/story/act/ActChaptersSection';
import ActIncidents from '@/components/book/settings/story/act/ActIncidents';
import ActPlotPoints from '@/components/book/settings/story/act/ActPlotPoints';
import {useTranslations} from 'next-intl';
import {LangContext, LangContextProps} from "@/context/LangContext";
interface ActProps {
acts: ActType[];
setActs: Dispatch<SetStateAction<ActType[]>>;
mainChapters: ChapterListProps[];
}
export default function Act({acts, setActs, mainChapters}: ActProps) {
const t = useTranslations('actComponent');
const {lang} = useContext<LangContextProps>(LangContext);
const {book} = useContext(BookContext);
const {session} = useContext(SessionContext);
const {errorMessage, successMessage} = useContext(AlertContext);
const bookId: string | undefined = book?.bookId;
const token: string = session.accessToken;
const [expandedSections, setExpandedSections] = useState<{
[key: string]: boolean;
}>({});
const [newIncidentTitle, setNewIncidentTitle] = useState<string>('');
const [newPlotPointTitle, setNewPlotPointTitle] = useState<string>('');
const [selectedIncidentId, setSelectedIncidentId] = useState<string>('');
function toggleSection(sectionKey: string): void {
setExpandedSections(prev => ({
...prev,
[sectionKey]: !prev[sectionKey],
}));
}
function updateActSummary(actId: number, summary: string): void {
const updatedActs: ActType[] = acts.map((act: ActType): ActType => {
if (act.id === actId) {
return {...act, summary};
}
return act;
});
setActs(updatedActs);
}
function getIncidents(): Incident[] {
const act2: ActType | undefined = acts.find((act: ActType): boolean => act.id === 2);
return act2?.incidents || [];
}
async function addIncident(actId: number): Promise<void> {
if (newIncidentTitle.trim() === '') return;
try {
const incidentId: string =
await System.authPostToServer<string>('book/incident/new', {
bookId,
name: newIncidentTitle,
}, token, lang);
if (!incidentId) {
errorMessage(t('errorAddIncident'));
return;
}
const updatedActs: ActType[] = acts.map((act: ActType): ActType => {
if (act.id === actId) {
const newIncident: Incident = {
incidentId: incidentId,
title: newIncidentTitle,
summary: '',
chapters: [],
};
return {
...act,
incidents: [...(act.incidents || []), newIncident],
};
}
return act;
});
setActs(updatedActs);
setNewIncidentTitle('');
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(t('errorAddIncident'));
} else {
errorMessage(t('errorUnknownAddIncident'));
}
}
}
async function deleteIncident(actId: number, incidentId: string): Promise<void> {
try {
const response: boolean = await System.authDeleteToServer<boolean>('book/incident/remove', {
bookId,
incidentId,
}, token, lang);
if (!response) {
errorMessage(t('errorDeleteIncident'));
return;
}
const updatedActs: ActType[] = acts.map((act: ActType): ActType => {
if (act.id === actId) {
return {
...act,
incidents: (act.incidents || []).filter(
(inc: Incident): boolean => inc.incidentId !== incidentId,
),
};
}
return act;
});
setActs(updatedActs);
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t('errorUnknownDeleteIncident'));
}
}
}
async function addPlotPoint(actId: number): Promise<void> {
if (newPlotPointTitle.trim() === '') return;
try {
const plotId: string = await System.authPostToServer<string>('book/plot/new', {
bookId,
name: newPlotPointTitle,
incidentId: selectedIncidentId,
}, token, lang);
if (!plotId) {
errorMessage(t('errorAddPlotPoint'));
return;
}
const updatedActs: ActType[] = acts.map((act: ActType): ActType => {
if (act.id === actId) {
const newPlotPoint: PlotPoint = {
plotPointId: plotId,
title: newPlotPointTitle,
summary: '',
linkedIncidentId: selectedIncidentId,
chapters: [],
};
return {
...act,
plotPoints: [...(act.plotPoints || []), newPlotPoint],
};
}
return act;
});
setActs(updatedActs);
setNewPlotPointTitle('');
setSelectedIncidentId('');
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(t('errorAddPlotPoint'));
} else {
errorMessage(t('errorUnknownAddPlotPoint'));
}
}
}
async function deletePlotPoint(actId: number, plotPointId: string): Promise<void> {
try {
const response: boolean = await System.authDeleteToServer<boolean>('book/plot/remove', {
plotId: plotPointId,
}, token, lang);
if (!response) {
errorMessage(t('errorDeletePlotPoint'));
return;
}
const updatedActs: ActType[] = acts.map((act: ActType): ActType => {
if (act.id === actId) {
return {
...act,
plotPoints: (act.plotPoints || []).filter(
(pp: PlotPoint): boolean => pp.plotPointId !== plotPointId,
),
};
}
return act;
});
setActs(updatedActs);
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t('errorUnknownDeletePlotPoint'));
}
}
}
async function linkChapter(
actId: number,
chapterId: string,
destination: 'act' | 'incident' | 'plotPoint',
itemId?: string,
): Promise<void> {
const chapterToLink: ChapterListProps | undefined = mainChapters.find((chapter: ChapterListProps): boolean => chapter.chapterId === chapterId);
if (!chapterToLink) {
errorMessage(t('errorChapterNotFound'));
return;
}
try {
const linkId: string =
await System.authPostToServer<string>('chapter/resume/add', {
bookId,
chapterId: chapterId,
actId: actId,
plotId: destination === 'plotPoint' ? itemId : null,
incidentId: destination === 'incident' ? itemId : null,
}, token, lang);
if (!linkId) {
errorMessage(t('errorLinkChapter'));
return;
}
const newChapter: ActChapter = {
chapterInfoId: linkId,
chapterId: chapterId,
title: chapterToLink.title,
chapterOrder: chapterToLink.chapterOrder || 0,
actId: actId,
incidentId: destination === 'incident' ? itemId : '0',
plotPointId: destination === 'plotPoint' ? itemId : '0',
summary: '',
goal: '',
};
const updatedActs: ActType[] = acts.map((act: ActType): ActType => {
if (act.id === actId) {
switch (destination) {
case 'act':
return {
...act,
chapters: [...(act.chapters || []), newChapter],
};
case 'incident':
return {
...act,
incidents:
act.incidents?.map((incident: Incident): Incident =>
incident.incidentId === itemId
? {
...incident,
chapters: [...(incident.chapters || []), newChapter],
}
: incident,
) || [],
};
case 'plotPoint':
return {
...act,
plotPoints:
act.plotPoints?.map(
(plotPoint: PlotPoint): PlotPoint =>
plotPoint.plotPointId === itemId
? {
...plotPoint,
chapters: [...(plotPoint.chapters || []), newChapter],
}
: plotPoint,
) || [],
};
}
}
return act;
});
setActs(updatedActs);
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t('errorUnknownLinkChapter'));
}
}
}
async function unlinkChapter(
chapterInfoId: string,
actId: number,
chapterId: string,
destination: 'act' | 'incident' | 'plotPoint',
itemId?: string,
): Promise<void> {
try {
const response: boolean = await System.authDeleteToServer<boolean>('chapter/resume/remove', {
chapterInfoId,
}, token, lang);
if (!response) {
errorMessage(t('errorUnlinkChapter'));
return;
}
const updatedActs: ActType[] = acts.map((act: ActType): ActType => {
if (act.id === actId) {
switch (destination) {
case 'act':
return {
...act,
chapters: (act.chapters || []).filter(
(ch: ActChapter): boolean => ch.chapterId !== chapterId,
),
};
case 'incident':
if (!itemId) return act;
return {
...act,
incidents:
act.incidents?.map((incident: Incident): Incident => {
if (incident.incidentId === itemId) {
return {
...incident,
chapters: (incident.chapters || []).filter(
(ch: ActChapter): boolean =>
ch.chapterId !== chapterId,
),
};
}
return incident;
}) || [],
};
case 'plotPoint':
if (!itemId) return act;
return {
...act,
plotPoints:
act.plotPoints?.map((plotPoint: PlotPoint): PlotPoint => {
if (plotPoint.plotPointId === itemId) {
return {
...plotPoint,
chapters: (plotPoint.chapters || []).filter((chapter: ActChapter): boolean => chapter.chapterId !== chapterId),
};
}
return plotPoint;
}) || [],
};
}
}
return act;
});
setActs(updatedActs);
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t('errorUnknownUnlinkChapter'));
}
}
}
function updateLinkedChapterSummary(
actId: number,
chapterId: string,
summary: string,
destination: 'act' | 'incident' | 'plotPoint',
itemId?: string,
): void {
const updatedActs: ActType[] = acts.map((act: ActType): ActType => {
if (act.id === actId) {
switch (destination) {
case 'act':
return {
...act,
chapters: (act.chapters || []).map((chapter: ActChapter): ActChapter => {
if (chapter.chapterId === chapterId) {
return {...chapter, summary};
}
return chapter;
}),
};
case 'incident':
if (!itemId) return act;
return {
...act,
incidents:
act.incidents?.map((incident: Incident): Incident => {
if (incident.incidentId === itemId) {
return {
...incident,
chapters: (incident.chapters || []).map((chapter: ActChapter) => {
if (chapter.chapterId === chapterId) {
return {...chapter, summary};
}
return chapter;
}),
};
}
return incident;
}) || [],
};
case 'plotPoint':
if (!itemId) return act;
return {
...act,
plotPoints:
act.plotPoints?.map((plotPoint: PlotPoint): PlotPoint => {
if (plotPoint.plotPointId === itemId) {
return {
...plotPoint,
chapters: (plotPoint.chapters || []).map((chapter: ActChapter): ActChapter => {
if (chapter.chapterId === chapterId) {
return {...chapter, summary};
}
return chapter;
}),
};
}
return plotPoint;
}) || [],
};
}
}
return act;
});
setActs(updatedActs);
}
function getSectionKey(actId: number, section: string): string {
return `section_${actId}_${section}`;
}
function renderActChapters(act: ActType) {
if (act.id === 2 || act.id === 3) {
return null;
}
const sectionKey: string = getSectionKey(act.id, 'chapters');
const isExpanded: boolean = expandedSections[sectionKey];
return (
<ActChaptersSection
actId={act.id}
chapters={act.chapters || []}
mainChapters={mainChapters}
onLinkChapter={(actId, chapterId) => linkChapter(actId, chapterId, 'act')}
onUpdateChapterSummary={(chapterId, summary) =>
updateLinkedChapterSummary(act.id, chapterId, summary, 'act')
}
onUnlinkChapter={(chapterInfoId, chapterId) =>
unlinkChapter(chapterInfoId, act.id, chapterId, 'act')
}
sectionKey={sectionKey}
isExpanded={isExpanded}
onToggleSection={toggleSection}
/>
);
}
function renderActDescription(act: ActType) {
if (act.id === 2 || act.id === 3) {
return null;
}
return (
<ActDescription
actId={act.id}
summary={act.summary || ''}
onUpdateSummary={updateActSummary}
/>
);
}
function renderIncidents(act: ActType) {
if (act.id !== 2) return null;
const sectionKey: string = getSectionKey(act.id, 'incidents');
const isExpanded: boolean = expandedSections[sectionKey];
return (
<ActIncidents
incidents={act.incidents || []}
actId={act.id}
mainChapters={mainChapters}
newIncidentTitle={newIncidentTitle}
setNewIncidentTitle={setNewIncidentTitle}
onAddIncident={addIncident}
onDeleteIncident={deleteIncident}
onLinkChapter={(actId, chapterId, incidentId) =>
linkChapter(actId, chapterId, 'incident', incidentId)
}
onUpdateChapterSummary={(chapterId, summary, incidentId) =>
updateLinkedChapterSummary(act.id, chapterId, summary, 'incident', incidentId)
}
onUnlinkChapter={(chapterInfoId, chapterId, incidentId) =>
unlinkChapter(chapterInfoId, act.id, chapterId, 'incident', incidentId)
}
sectionKey={sectionKey}
isExpanded={isExpanded}
onToggleSection={toggleSection}
/>
);
}
function renderPlotPoints(act: ActType) {
if (act.id !== 3) return null;
const sectionKey: string = getSectionKey(act.id, 'plotPoints');
const isExpanded: boolean = expandedSections[sectionKey];
return (
<ActPlotPoints
plotPoints={act.plotPoints || []}
incidents={getIncidents()}
actId={act.id}
mainChapters={mainChapters}
newPlotPointTitle={newPlotPointTitle}
setNewPlotPointTitle={setNewPlotPointTitle}
selectedIncidentId={selectedIncidentId}
setSelectedIncidentId={setSelectedIncidentId}
onAddPlotPoint={addPlotPoint}
onDeletePlotPoint={deletePlotPoint}
onLinkChapter={(actId, chapterId, plotPointId) =>
linkChapter(actId, chapterId, 'plotPoint', plotPointId)
}
onUpdateChapterSummary={(chapterId, summary, plotPointId) =>
updateLinkedChapterSummary(act.id, chapterId, summary, 'plotPoint', plotPointId)
}
onUnlinkChapter={(chapterInfoId, chapterId, plotPointId) =>
unlinkChapter(chapterInfoId, act.id, chapterId, 'plotPoint', plotPointId)
}
sectionKey={sectionKey}
isExpanded={isExpanded}
onToggleSection={toggleSection}
/>
);
}
function renderActIcon(actId: number): IconDefinition {
switch (actId) {
case 1:
return faFlag;
case 2:
return faFire;
case 3:
return faPuzzlePiece;
case 4:
return faScaleBalanced;
case 5:
return faTrophy;
default:
return faFlag;
}
}
function renderActTitle(actId: number): string {
switch (actId) {
case 1:
return t('act1Title');
case 2:
return t('act2Title');
case 3:
return t('act3Title');
case 4:
return t('act4Title');
case 5:
return t('act5Title');
default:
return '';
}
}
return (
<div className="space-y-6">
{acts.map((act: ActType) => (
<CollapsableArea key={`act-${act.id}`}
title={renderActTitle(act.id)}
icon={renderActIcon(act.id)}
children={
<>
{renderActDescription(act)}
{renderActChapters(act)}
{renderIncidents(act)}
{renderPlotPoints(act)}
</>
}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,149 @@
import React, {ChangeEvent, useContext, useState} from 'react';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faPlus, faTrash, faWarning,} from '@fortawesome/free-solid-svg-icons';
import {Issue} from '@/lib/models/Book';
import System from '@/lib/models/System';
import {BookContext} from '@/context/BookContext';
import {SessionContext} from '@/context/SessionContext';
import {AlertContext} from '@/context/AlertContext';
import CollapsableArea from "@/components/CollapsableArea";
import {useTranslations} from "next-intl";
import {LangContext, LangContextProps} from "@/context/LangContext";
interface IssuesProps {
issues: Issue[];
setIssues: React.Dispatch<React.SetStateAction<Issue[]>>;
}
export default function Issues({issues, setIssues}: IssuesProps) {
const t = useTranslations();
const {lang} = useContext<LangContextProps>(LangContext);
const {book} = useContext(BookContext);
const {session} = useContext(SessionContext);
const {errorMessage} = useContext(AlertContext);
const bookId: string | undefined = book?.bookId;
const token: string = session.accessToken;
const [newIssueName, setNewIssueName] = useState<string>('');
async function addNewIssue(): Promise<void> {
if (newIssueName.trim() === '') {
errorMessage(t("issues.errorEmptyName"));
return;
}
try {
const issueId: string = await System.authPostToServer<string>('book/issue/add', {
bookId,
name: newIssueName,
}, token, lang);
if (!issueId) {
errorMessage(t("issues.errorAdd"));
return;
}
const newIssue: Issue = {
name: newIssueName,
id: issueId,
};
setIssues([...issues, newIssue]);
setNewIssueName('');
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t("issues.errorUnknownAdd"));
}
}
}
async function deleteIssue(issueId: string): Promise<void> {
if (issueId === undefined) {
errorMessage(t("issues.errorInvalidId"));
}
try {
const response: boolean = await System.authDeleteToServer<boolean>(
'book/issue/remove',
{
bookId,
issueId,
},
token,
lang
);
if (response) {
const updatedIssues: Issue[] = issues.filter((issue: Issue): boolean => issue.id !== issueId,);
setIssues(updatedIssues);
} else {
errorMessage(t("issues.errorDelete"));
}
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t("issues.errorUnknownDelete"));
}
}
}
function updateIssueName(issueId: string, name: string): void {
const updatedIssues: Issue[] = issues.map((issue: Issue): Issue => {
if (issue.id === issueId) {
return {...issue, name};
}
return issue;
});
setIssues(updatedIssues);
}
return (
<CollapsableArea title={t("issues.title")} children={
<div className="p-1">
{issues && issues.length > 0 ? (
issues.map((item: Issue) => (
<div
className="mb-2 bg-secondary/30 rounded-xl p-3 shadow-sm hover:shadow-md transition-all duration-200"
key={`issue-${item.id}`}
>
<div className="flex justify-between items-center">
<input
className="flex-1 text-text-primary text-base px-2 py-1 bg-transparent border-b border-secondary/50 focus:outline-none focus:border-primary transition-colors duration-200 placeholder:text-muted/60"
value={item.name}
onChange={(e) => updateIssueName(item.id, e.target.value)}
placeholder={t("issues.issueNamePlaceholder")}
/>
<button
className="p-1.5 ml-2 rounded-lg text-error hover:bg-error/20 hover:scale-110 transition-all duration-200"
onClick={() => deleteIssue(item.id)}
>
<FontAwesomeIcon icon={faTrash} size="sm"/>
</button>
</div>
</div>
))
) : (
<p className="text-text-secondary text-center py-2 text-sm">
{t("issues.noIssue")}
</p>
)}
<div className="flex items-center mt-3 bg-secondary/30 p-3 rounded-xl shadow-sm">
<input
className="flex-1 text-text-primary text-base px-2 py-1 bg-transparent border-b border-secondary/50 focus:outline-none focus:border-primary transition-colors duration-200 placeholder:text-muted/60"
value={newIssueName}
onChange={(e: ChangeEvent<HTMLInputElement>) => setNewIssueName(e.target.value)}
placeholder={t("issues.newIssuePlaceholder")}
/>
<button
className="bg-primary w-9 h-9 rounded-full flex justify-center items-center ml-2 text-text-primary shadow-md hover:shadow-lg hover:scale-110 hover:bg-primary-dark transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
onClick={addNewIssue}
disabled={newIssueName.trim() === ''}
>
<FontAwesomeIcon icon={faPlus}/>
</button>
</div>
</div>
} icon={faWarning}/>
);
}

View File

@@ -0,0 +1,278 @@
'use client'
import React, {ChangeEvent, useContext, useEffect, useState} from 'react';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faArrowDown, faArrowUp, faBookmark, faMinus, faPlus, faTrash,} from '@fortawesome/free-solid-svg-icons';
import {ChapterListProps} from '@/lib/models/Chapter';
import System from '@/lib/models/System';
import {BookContext} from '@/context/BookContext';
import {SessionContext} from '@/context/SessionContext';
import {AlertContext} from '@/context/AlertContext';
import AlertBox from "@/components/AlertBox";
import CollapsableArea from "@/components/CollapsableArea";
import {useTranslations} from "next-intl";
import {LangContext} from "@/context/LangContext";
interface MainChapterProps {
chapters: ChapterListProps[];
setChapters: React.Dispatch<React.SetStateAction<ChapterListProps[]>>;
}
export default function MainChapter({chapters, setChapters}: MainChapterProps) {
const t = useTranslations();
const {lang} = useContext(LangContext)
const {book} = useContext(BookContext);
const {session} = useContext(SessionContext);
const {errorMessage, successMessage} = useContext(AlertContext);
const bookId: string | undefined = book?.bookId;
const token: string = session.accessToken;
const [newChapterTitle, setNewChapterTitle] = useState<string>('');
const [newChapterOrder, setNewChapterOrder] = useState<number>(0);
const [deleteConfirmMessage, setDeleteConfirmMessage] = useState<boolean>(false);
const [chapterIdToRemove, setChapterIdToRemove] = useState<string>('');
function handleChapterTitleChange(chapterId: string, newTitle: string) {
const updatedChapters: ChapterListProps[] = chapters.map((chapter: ChapterListProps): ChapterListProps => {
if (chapter.chapterId === chapterId) {
return {...chapter, title: newTitle};
}
return chapter;
});
setChapters(updatedChapters);
}
function moveChapter(index: number, direction: number): void {
const visibleChapters: ChapterListProps[] = chapters
.filter((chapter: ChapterListProps): boolean => chapter.chapterOrder !== -1)
.sort((a: ChapterListProps, b: ChapterListProps): number => (a.chapterOrder || 0) - (b.chapterOrder || 0));
const currentChapter: ChapterListProps = visibleChapters[index];
const allChaptersIndex: number = chapters.findIndex(
(chapter: ChapterListProps): boolean => chapter.chapterId === currentChapter.chapterId,
);
const updatedChapters: ChapterListProps[] = [...chapters];
const currentOrder: number = updatedChapters[allChaptersIndex].chapterOrder || 0;
const newOrder: number = Math.max(0, currentOrder + direction);
updatedChapters[allChaptersIndex] = {
...updatedChapters[allChaptersIndex],
chapterOrder: newOrder,
};
setChapters(updatedChapters);
}
function moveChapterUp(index: number): void {
moveChapter(index, -1);
}
function moveChapterDown(index: number): void {
moveChapter(index, 1);
}
async function deleteChapter(): Promise<void> {
try {
setDeleteConfirmMessage(false);
const response: boolean = await System.authDeleteToServer<boolean>(
'chapter/remove',
{
bookId,
chapterId: chapterIdToRemove,
},
token,
lang,
);
if (!response) {
errorMessage(t("mainChapter.errorDelete"));
}
const updatedChapters: ChapterListProps[] = chapters.filter((chapter: ChapterListProps): boolean => chapter.chapterId !== chapterIdToRemove,);
setChapters(updatedChapters);
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message)
} else {
errorMessage(t("mainChapter.errorUnknownDelete"));
}
}
}
async function addNewChapter(): Promise<void> {
if (newChapterTitle.trim() === '') {
return;
}
try {
const responseId: string = await System.authPostToServer<string>(
'chapter/add',
{
bookId: bookId,
wordsCount: 0,
chapterOrder: newChapterOrder ? newChapterOrder : 0,
title: newChapterTitle,
},
token,
);
if (!responseId) {
errorMessage(t("mainChapter.errorAdd"));
return;
}
const newChapter: ChapterListProps = {
chapterId: responseId as string,
title: newChapterTitle,
chapterOrder: newChapterOrder,
summary: '',
goal: '',
};
setChapters([...chapters, newChapter]);
setNewChapterTitle('');
setNewChapterOrder(newChapterOrder + 1);
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message)
} else {
errorMessage(t("mainChapter.errorUnknownAdd"));
}
}
}
function decrementNewChapterOrder(): void {
if (newChapterOrder > 0) {
setNewChapterOrder(newChapterOrder - 1);
}
}
function incrementNewChapterOrder(): void {
setNewChapterOrder(newChapterOrder + 1);
}
useEffect((): void => {
const visibleChapters: ChapterListProps[] = chapters
.filter((chapter: ChapterListProps): boolean => chapter.chapterOrder !== -1)
.sort((a: ChapterListProps, b: ChapterListProps): number =>
(a.chapterOrder || 0) - (b.chapterOrder || 0),
);
const nextOrder: number =
visibleChapters.length > 0
? (visibleChapters[visibleChapters.length - 1].chapterOrder || 0) + 1
: 0;
setNewChapterOrder(nextOrder);
}, [chapters]);
const visibleChapters: ChapterListProps[] = chapters
.filter((chapter: ChapterListProps): boolean => chapter.chapterOrder !== -1)
.sort((a: ChapterListProps, b: ChapterListProps): number => (a.chapterOrder || 0) - (b.chapterOrder || 0));
return (
<div>
<CollapsableArea icon={faBookmark} title={t("mainChapter.chapters")} children={
<div className="space-y-4">
{visibleChapters.length > 0 ? (
<div>
{visibleChapters.map((item: ChapterListProps, index: number) => (
<div key={item.chapterId}
className="mb-2 bg-secondary/30 rounded-xl p-3 shadow-sm hover:shadow-md transition-all duration-200">
<div className="flex items-center">
<span
className="bg-primary-dark text-white text-sm w-6 h-6 rounded-full text-center leading-6 mr-2">
{item.chapterOrder !== undefined ? item.chapterOrder : index}
</span>
<input
className="flex-1 text-text-primary text-base px-2 py-1 bg-transparent border-b border-secondary/50 focus:outline-none focus:border-primary transition-colors duration-200"
value={item.title}
onChange={(e: ChangeEvent<HTMLInputElement>) => handleChapterTitleChange(item.chapterId, e.target.value)}
placeholder={t("mainChapter.chapterTitlePlaceholder")}
/>
<div className="flex ml-1">
<button
className={`p-1.5 mx-0.5 rounded-lg hover:bg-secondary/50 transition-all duration-200 ${
item.chapterOrder === 0 ? 'text-muted cursor-not-allowed' : 'text-primary hover:scale-110'
}`}
onClick={(): void => moveChapterUp(index)}
disabled={item.chapterOrder === 0}
>
<FontAwesomeIcon icon={faArrowUp} size="sm"/>
</button>
<button
className="p-1.5 mx-0.5 rounded-lg text-primary hover:bg-secondary/50 hover:scale-110 transition-all duration-200"
onClick={(): void => moveChapterDown(index)}
>
<FontAwesomeIcon icon={faArrowDown} size="sm"/>
</button>
<button
className="p-1.5 mx-0.5 rounded-lg text-error hover:bg-error/20 hover:scale-110 transition-all duration-200"
onClick={(): void => {
setChapterIdToRemove(item.chapterId);
setDeleteConfirmMessage(true);
}}
>
<FontAwesomeIcon icon={faTrash} size="sm"/>
</button>
</div>
</div>
</div>
))}
</div>
) : (
<p className="text-text-secondary text-center py-2">
{t("mainChapter.noChapter")}
</p>
)}
<div className="bg-secondary/30 rounded-xl p-3 mt-3 shadow-sm">
<div className="flex items-center">
<div
className="flex items-center gap-1 bg-secondary/50 rounded-lg mr-2 px-1 py-0.5 shadow-inner">
<button
className={`w-6 h-6 rounded-full bg-secondary flex justify-center items-center hover:scale-110 transition-all duration-200 ${
newChapterOrder <= 0 ? 'text-muted cursor-not-allowed opacity-50' : 'text-primary hover:bg-secondary-dark'
}`}
onClick={decrementNewChapterOrder}
disabled={newChapterOrder <= 0}
>
<FontAwesomeIcon icon={faMinus} size="xs"/>
</button>
<span
className="bg-primary-dark text-white text-sm w-6 h-6 rounded-full text-center leading-6 mx-0.5">
{newChapterOrder}
</span>
<button
className="w-6 h-6 rounded-full bg-secondary flex justify-center items-center text-primary hover:bg-secondary-dark hover:scale-110 transition-all duration-200"
onClick={incrementNewChapterOrder}
>
<FontAwesomeIcon icon={faPlus} size="xs"/>
</button>
</div>
<input
className="flex-1 text-text-primary text-base px-2 py-1 bg-transparent border-b border-secondary/50 focus:outline-none focus:border-primary transition-colors duration-200 placeholder:text-muted/60"
value={newChapterTitle}
onChange={e => setNewChapterTitle(e.target.value)}
placeholder={t("mainChapter.newChapterPlaceholder")}
/>
<button
className="bg-primary w-9 h-9 rounded-full flex justify-center items-center ml-2 text-text-primary shadow-md hover:shadow-lg hover:scale-110 hover:bg-primary-dark transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
onClick={addNewChapter}
disabled={newChapterTitle.trim() === ''}
>
<FontAwesomeIcon icon={faPlus} className={'w-5 h-5'}/>
</button>
</div>
</div>
</div>
}/>
{
deleteConfirmMessage &&
<AlertBox title={t("mainChapter.deleteTitle")} message={t("mainChapter.deleteMessage")}
type={"danger"} onConfirm={deleteChapter}
onCancel={(): void => setDeleteConfirmMessage(false)}/>
}
</div>
);
}

View File

@@ -0,0 +1,167 @@
'use client'
import React, {createContext, forwardRef, useContext, useEffect, useImperativeHandle, useState} from 'react';
import {BookContext} from '@/context/BookContext';
import {SessionContext} from '@/context/SessionContext';
import {AlertContext} from '@/context/AlertContext';
import System from '@/lib/models/System';
import {Act as ActType, Issue} from '@/lib/models/Book';
import {ActChapter, ChapterListProps} from '@/lib/models/Chapter';
import MainChapter from "@/components/book/settings/story/MainChapter";
import Issues from "@/components/book/settings/story/Issue";
import Act from "@/components/book/settings/story/Act";
import {useTranslations} from "next-intl";
import {LangContext, LangContextProps} from "@/context/LangContext";
export const StoryContext = createContext<{
acts: ActType[];
setActs: React.Dispatch<React.SetStateAction<ActType[]>>;
mainChapters: ChapterListProps[];
setMainChapters: React.Dispatch<React.SetStateAction<ChapterListProps[]>>;
issues: Issue[];
setIssues: React.Dispatch<React.SetStateAction<Issue[]>>;
}>({
acts: [],
setActs: (): void => {
},
mainChapters: [],
setMainChapters: (): void => {
},
issues: [],
setIssues: (): void => {
},
});
interface StoryFetchData {
mainChapter: ChapterListProps[];
acts: ActType[];
issues: Issue[];
}
export function Story(props: any, ref: any) {
const t = useTranslations();
const {lang} = useContext<LangContextProps>(LangContext);
const {book} = useContext(BookContext);
const bookId: string = book?.bookId ? book.bookId.toString() : '';
const {session} = useContext(SessionContext);
const userToken: string = session.accessToken;
const {errorMessage, successMessage} = useContext(AlertContext);
const [acts, setActs] = useState<ActType[]>([]);
const [issues, setIssues] = useState<Issue[]>([]);
const [mainChapters, setMainChapters] = useState<ChapterListProps[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
useImperativeHandle(ref, function () {
return {
handleSave: handleSave
};
});
useEffect((): void => {
getStoryData().then();
}, [userToken]);
useEffect((): void => {
cleanupDeletedChapters();
}, [mainChapters]);
async function getStoryData(): Promise<void> {
try {
const response: StoryFetchData = await System.authGetQueryToServer<StoryFetchData>(`book/story`, userToken, lang, {
bookid: bookId,
});
if (response) {
setActs(response.acts);
setMainChapters(response.mainChapter);
setIssues(response.issues);
setIsLoading(false);
}
} catch (e: unknown) {
setIsLoading(false);
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t("story.errorUnknownFetch"));
}
}
}
function cleanupDeletedChapters(): void {
const existingChapterIds: string[] = mainChapters.map(ch => ch.chapterId);
const updatedActs = acts.map((act: ActType) => {
const filteredChapters: ActChapter[] = act.chapters?.filter((chapter: ActChapter): boolean =>
existingChapterIds.includes(chapter.chapterId)) || [];
const updatedIncidents = act.incidents?.map(incident => {
return {
...incident,
chapters: incident.chapters?.filter(chapter =>
existingChapterIds.includes(chapter.chapterId)) || []
};
}) || [];
const updatedPlotPoints = act.plotPoints?.map(plotPoint => {
return {
...plotPoint,
chapters: plotPoint.chapters?.filter(chapter =>
existingChapterIds.includes(chapter.chapterId)) || []
};
}) || [];
return {
...act,
chapters: filteredChapters,
incidents: updatedIncidents,
plotPoints: updatedPlotPoints,
};
});
setActs(updatedActs);
}
async function handleSave(): Promise<void> {
try {
const response: boolean =
await System.authPostToServer<boolean>('book/story', {
bookId,
acts,
mainChapters,
issues,
}, userToken, lang);
if (!response) {
errorMessage(t("story.errorSave"))
}
successMessage(t("story.successSave"));
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t("story.errorUnknownSave"));
}
}
}
return (
<StoryContext.Provider
value={{
acts,
setActs,
mainChapters,
setMainChapters,
issues,
setIssues,
}}>
<div className="flex flex-col h-full">
<div className="flex-grow overflow-auto py-4">
<div className="space-y-6 px-4">
<MainChapter chapters={mainChapters} setChapters={setMainChapters}/>
<div className="space-y-4">
<Act acts={acts} setActs={setActs} mainChapters={mainChapters}/>
</div>
<Issues issues={issues} setIssues={setIssues}/>
</div>
</div>
</div>
</StoryContext.Provider>
);
}
export default forwardRef(Story);

View File

@@ -0,0 +1,37 @@
import React, {ChangeEvent} from 'react';
import {faTrash} from '@fortawesome/free-solid-svg-icons';
import {ActChapter} from '@/lib/models/Chapter';
import InputField from '@/components/form/InputField';
import TexteAreaInput from '@/components/form/TexteAreaInput';
import {useTranslations} from 'next-intl';
interface ActChapterItemProps {
chapter: ActChapter;
onUpdateSummary: (chapterId: string, summary: string) => void;
onUnlink: (chapterInfoId: string, chapterId: string) => Promise<void>;
}
export default function ActChapterItem({chapter, onUpdateSummary, onUnlink}: ActChapterItemProps) {
const t = useTranslations('actComponent');
return (
<div
className="bg-secondary/20 p-4 rounded-xl mb-3 border border-secondary/30 shadow-sm hover:shadow-md transition-all duration-200">
<InputField
input={
<TexteAreaInput
value={chapter.summary || ''}
setValue={(e: ChangeEvent<HTMLTextAreaElement>) =>
onUpdateSummary(chapter.chapterId, e.target.value)
}
placeholder={t('chapterSummaryPlaceholder')}
/>
}
actionIcon={faTrash}
fieldName={chapter.title}
action={(): Promise<void> => onUnlink(chapter.chapterInfoId, chapter.chapterId)}
actionLabel={t('remove')}
/>
</div>
);
}

View File

@@ -0,0 +1,93 @@
import React, {useState} from 'react';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faChevronDown, faChevronUp} from '@fortawesome/free-solid-svg-icons';
import {ActChapter, ChapterListProps} from '@/lib/models/Chapter';
import {SelectBoxProps} from '@/shared/interface';
import ActChapterItem from './ActChapter';
import InputField from '@/components/form/InputField';
import SelectBox from '@/components/form/SelectBox';
import {useTranslations} from 'next-intl';
interface ActChaptersSectionProps {
actId: number;
chapters: ActChapter[];
mainChapters: ChapterListProps[];
onLinkChapter: (actId: number, chapterId: string) => Promise<void>;
onUpdateChapterSummary: (chapterId: string, summary: string) => void;
onUnlinkChapter: (chapterInfoId: string, chapterId: string) => Promise<void>;
sectionKey: string;
isExpanded: boolean;
onToggleSection: (sectionKey: string) => void;
}
export default function ActChaptersSection({
actId,
chapters,
mainChapters,
onLinkChapter,
onUpdateChapterSummary,
onUnlinkChapter,
sectionKey,
isExpanded,
onToggleSection,
}: ActChaptersSectionProps) {
const t = useTranslations('actComponent');
const [selectedChapterId, setSelectedChapterId] = useState<string>('');
function mainChaptersData(): SelectBoxProps[] {
return mainChapters.map((chapter: ChapterListProps): SelectBoxProps => ({
value: chapter.chapterId,
label: `${chapter.chapterOrder}. ${chapter.title}`,
}));
}
return (
<div className="mb-4">
<button
className="flex justify-between items-center w-full bg-secondary/50 p-3 rounded-xl text-left hover:bg-secondary transition-all duration-200 shadow-sm"
onClick={(): void => onToggleSection(sectionKey)}
>
<span className="font-bold text-text-primary">{t('chapters')}</span>
<FontAwesomeIcon
icon={isExpanded ? faChevronUp : faChevronDown}
className="text-text-primary w-3.5 h-3.5"
/>
</button>
{isExpanded && (
<div className="p-2">
{chapters && chapters.length > 0 ? (
chapters.map((chapter: ActChapter) => (
<ActChapterItem
key={`chapter-${chapter.chapterInfoId}`}
chapter={chapter}
onUpdateSummary={(chapterId, summary) =>
onUpdateChapterSummary(chapterId, summary)
}
onUnlink={(chapterInfoId, chapterId) =>
onUnlinkChapter(chapterInfoId, chapterId)
}
/>
))
) : (
<p className="text-text-secondary text-center text-sm p-2">
{t('noLinkedChapter')}
</p>
)}
<InputField
addButtonCallBack={(): Promise<void> => onLinkChapter(actId, selectedChapterId)}
input={
<SelectBox
defaultValue={null}
onChangeCallBack={(e) => setSelectedChapterId(e.target.value)}
data={mainChaptersData()}
placeholder={t('selectChapterPlaceholder')}
/>
}
isAddButtonDisabled={!selectedChapterId}
/>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,60 @@
import React, {ChangeEvent} from 'react';
import {faTrash} from '@fortawesome/free-solid-svg-icons';
import InputField from '@/components/form/InputField';
import TexteAreaInput from '@/components/form/TexteAreaInput';
import {useTranslations} from 'next-intl';
interface ActDescriptionProps {
actId: number;
summary: string;
onUpdateSummary: (actId: number, summary: string) => void;
}
export default function ActDescription({actId, summary, onUpdateSummary}: ActDescriptionProps) {
const t = useTranslations('actComponent');
function getActSummaryTitle(actId: number): string {
switch (actId) {
case 1:
return t('act1Summary');
case 4:
return t('act4Summary');
case 5:
return t('act5Summary');
default:
return t('actSummary');
}
}
function getActSummaryPlaceholder(actId: number): string {
switch (actId) {
case 1:
return t('act1SummaryPlaceholder');
case 4:
return t('act4SummaryPlaceholder');
case 5:
return t('act5SummaryPlaceholder');
default:
return t('actSummaryPlaceholder');
}
}
return (
<div className="mb-4">
<InputField
fieldName={getActSummaryTitle(actId)}
input={
<TexteAreaInput
value={summary || ''}
setValue={(e: ChangeEvent<HTMLTextAreaElement>) =>
onUpdateSummary(actId, e.target.value)
}
placeholder={getActSummaryPlaceholder(actId)}
/>
}
actionIcon={faTrash}
actionLabel={t('delete')}
/>
</div>
);
}

View File

@@ -0,0 +1,176 @@
import React, {useState} from 'react';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faChevronDown, faChevronUp, faPlus, faTrash} from '@fortawesome/free-solid-svg-icons';
import {Incident} from '@/lib/models/Book';
import {ActChapter, ChapterListProps} from '@/lib/models/Chapter';
import ActChapterItem from './ActChapter';
import {useTranslations} from 'next-intl';
interface ActIncidentsProps {
incidents: Incident[];
actId: number;
mainChapters: ChapterListProps[];
newIncidentTitle: string;
setNewIncidentTitle: (title: string) => void;
onAddIncident: (actId: number) => Promise<void>;
onDeleteIncident: (actId: number, incidentId: string) => Promise<void>;
onLinkChapter: (actId: number, chapterId: string, incidentId: string) => Promise<void>;
onUpdateChapterSummary: (chapterId: string, summary: string, incidentId: string) => void;
onUnlinkChapter: (chapterInfoId: string, chapterId: string, incidentId: string) => Promise<void>;
sectionKey: string;
isExpanded: boolean;
onToggleSection: (sectionKey: string) => void;
}
export default function ActIncidents({
incidents,
actId,
mainChapters,
newIncidentTitle,
setNewIncidentTitle,
onAddIncident,
onDeleteIncident,
onLinkChapter,
onUpdateChapterSummary,
onUnlinkChapter,
sectionKey,
isExpanded,
onToggleSection,
}: ActIncidentsProps) {
const t = useTranslations('actComponent');
const [expandedItems, setExpandedItems] = useState<{ [key: string]: boolean }>({});
const [selectedChapterId, setSelectedChapterId] = useState<string>('');
function toggleItem(itemKey: string): void {
setExpandedItems(prev => ({
...prev,
[itemKey]: !prev[itemKey],
}));
}
return (
<div className="mb-4">
<button
className="flex justify-between items-center w-full bg-secondary/50 p-3 rounded-xl text-left hover:bg-secondary transition-all duration-200 shadow-sm"
onClick={(): void => onToggleSection(sectionKey)}
>
<span className="font-bold text-text-primary">{t('incidentsTitle')}</span>
<FontAwesomeIcon
icon={isExpanded ? faChevronUp : faChevronDown}
className="text-text-primary w-3.5 h-3.5"
/>
</button>
{isExpanded && (
<div className="p-2">
{incidents && incidents.length > 0 ? (
<>
{incidents.map((item: Incident) => {
const itemKey = `incident_${item.incidentId}`;
const isItemExpanded: boolean = expandedItems[itemKey];
return (
<div
key={`incident-${item.incidentId}`}
className="bg-secondary/30 rounded-xl mb-3 overflow-hidden border border-secondary/40 shadow-sm hover:shadow-md transition-all duration-200"
>
<button
className="flex justify-between items-center w-full p-2 text-left"
onClick={(): void => toggleItem(itemKey)}
>
<span className="font-bold text-text-primary">{item.title}</span>
<div className="flex items-center">
<FontAwesomeIcon
icon={isItemExpanded ? faChevronUp : faChevronDown}
className="text-text-primary w-3.5 h-3.5 mr-2"
/>
<button
onClick={async (e) => {
e.stopPropagation();
await onDeleteIncident(actId, item.incidentId);
}}
className="text-error hover:bg-error/20 p-1.5 rounded-lg transition-all duration-200 hover:scale-110"
>
<FontAwesomeIcon icon={faTrash} className="w-3.5 h-3.5"/>
</button>
</div>
</button>
{isItemExpanded && (
<div className="p-3 bg-secondary/20">
{item.chapters && item.chapters.length > 0 ? (
<>
{item.chapters.map((chapter: ActChapter) => (
<ActChapterItem
key={`inc-chapter-${chapter.chapterId}-${chapter.chapterInfoId}`}
chapter={chapter}
onUpdateSummary={(chapterId, summary) =>
onUpdateChapterSummary(chapterId, summary, item.incidentId)
}
onUnlink={(chapterInfoId, chapterId) =>
onUnlinkChapter(chapterInfoId, chapterId, item.incidentId)
}
/>
))}
</>
) : (
<p className="text-text-secondary text-center text-sm p-2">
{t('noLinkedChapter')}
</p>
)}
<div className="flex items-center mt-2">
<select
onChange={(e) => setSelectedChapterId(e.target.value)}
className="flex-1 bg-secondary/50 text-text-primary rounded-xl px-4 py-2.5 mr-2 border border-secondary/50 focus:outline-none focus:ring-4 focus:ring-primary/20 focus:border-primary hover:bg-secondary hover:border-secondary transition-all duration-200"
>
<option value="">{t('selectChapterPlaceholder')}</option>
{mainChapters.map((chapter: ChapterListProps) => (
<option key={chapter.chapterId} value={chapter.chapterId}>
{`${chapter.chapterOrder}. ${chapter.title}`}
</option>
))}
</select>
<button
className="bg-primary text-text-primary w-9 h-9 rounded-full flex items-center justify-center disabled:opacity-50 disabled:cursor-not-allowed shadow-md hover:shadow-lg hover:scale-110 hover:bg-primary-dark transition-all duration-200"
onClick={(): Promise<void> =>
onLinkChapter(actId, selectedChapterId, item.incidentId)
}
disabled={selectedChapterId.length === 0}
>
<FontAwesomeIcon icon={faPlus} className="w-3.5 h-3.5"/>
</button>
</div>
</div>
)}
</div>
);
})}
</>
) : (
<p className="text-text-secondary text-center text-sm p-2">
{t('noIncidentAdded')}
</p>
)}
<div className="flex items-center mt-2">
<input
type="text"
className="flex-1 bg-secondary/50 text-text-primary rounded-xl px-4 py-2.5 mr-2 border border-secondary/50 focus:outline-none focus:ring-4 focus:ring-primary/20 focus:border-primary hover:bg-secondary hover:border-secondary transition-all duration-200 placeholder:text-muted/60"
value={newIncidentTitle}
onChange={(e) => setNewIncidentTitle(e.target.value)}
placeholder={t('newIncidentPlaceholder')}
/>
<button
className="bg-primary text-text-primary w-9 h-9 rounded-full flex items-center justify-center disabled:opacity-50 disabled:cursor-not-allowed shadow-md hover:shadow-lg hover:scale-110 hover:bg-primary-dark transition-all duration-200"
onClick={(): Promise<void> => onAddIncident(actId)}
disabled={newIncidentTitle.trim() === ''}
>
<FontAwesomeIcon icon={faPlus} className="w-3.5 h-3.5"/>
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,202 @@
import React, {useState} from 'react';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faChevronDown, faChevronUp, faPlus, faTrash} from '@fortawesome/free-solid-svg-icons';
import {Incident, PlotPoint} from '@/lib/models/Book';
import {ActChapter, ChapterListProps} from '@/lib/models/Chapter';
import {SelectBoxProps} from '@/shared/interface';
import ActChapterItem from './ActChapter';
import InputField from '@/components/form/InputField';
import SelectBox from '@/components/form/SelectBox';
import {useTranslations} from 'next-intl';
interface ActPlotPointsProps {
plotPoints: PlotPoint[];
incidents: Incident[];
actId: number;
mainChapters: ChapterListProps[];
newPlotPointTitle: string;
setNewPlotPointTitle: (title: string) => void;
selectedIncidentId: string;
setSelectedIncidentId: (id: string) => void;
onAddPlotPoint: (actId: number) => Promise<void>;
onDeletePlotPoint: (actId: number, plotPointId: string) => Promise<void>;
onLinkChapter: (actId: number, chapterId: string, plotPointId: string) => Promise<void>;
onUpdateChapterSummary: (chapterId: string, summary: string, plotPointId: string) => void;
onUnlinkChapter: (chapterInfoId: string, chapterId: string, plotPointId: string) => Promise<void>;
sectionKey: string;
isExpanded: boolean;
onToggleSection: (sectionKey: string) => void;
}
export default function ActPlotPoints({
plotPoints,
incidents,
actId,
mainChapters,
newPlotPointTitle,
setNewPlotPointTitle,
selectedIncidentId,
setSelectedIncidentId,
onAddPlotPoint,
onDeletePlotPoint,
onLinkChapter,
onUpdateChapterSummary,
onUnlinkChapter,
sectionKey,
isExpanded,
onToggleSection,
}: ActPlotPointsProps) {
const t = useTranslations('actComponent');
const [expandedItems, setExpandedItems] = useState<{ [key: string]: boolean }>({});
const [selectedChapterId, setSelectedChapterId] = useState<string>('');
function toggleItem(itemKey: string): void {
setExpandedItems(prev => ({
...prev,
[itemKey]: !prev[itemKey],
}));
}
function getIncidentData(): SelectBoxProps[] {
return incidents.map((incident: Incident): SelectBoxProps => ({
value: incident.incidentId,
label: incident.title,
}));
}
return (
<div className="mb-4">
<button
className="flex justify-between items-center w-full bg-secondary/50 p-3 rounded-xl text-left hover:bg-secondary transition-all duration-200 shadow-sm"
onClick={(): void => onToggleSection(sectionKey)}
>
<span className="font-bold text-text-primary">{t('plotPointsTitle')}</span>
<FontAwesomeIcon
icon={isExpanded ? faChevronUp : faChevronDown}
className="text-text-primary w-3.5 h-3.5"
/>
</button>
{isExpanded && (
<div className="p-2">
{plotPoints && plotPoints.length > 0 ? (
plotPoints.map((item: PlotPoint) => {
const itemKey = `plotpoint_${item.plotPointId}`;
const isItemExpanded: boolean = expandedItems[itemKey];
const linkedIncident: Incident | undefined = incidents.find(
(inc: Incident): boolean => inc.incidentId === item.linkedIncidentId
);
return (
<div
key={`plot-point-${item.plotPointId}`}
className="bg-secondary/30 rounded-xl mb-3 overflow-hidden border border-secondary/40 shadow-sm hover:shadow-md transition-all duration-200"
>
<button
className="flex justify-between items-center w-full p-2 text-left"
onClick={(): void => toggleItem(itemKey)}
>
<div>
<p className="font-bold text-text-primary">{item.title}</p>
{linkedIncident && (
<p className="text-text-secondary text-sm italic">
{t('linkedTo')}: {linkedIncident.title}
</p>
)}
</div>
<div className="flex items-center">
<FontAwesomeIcon
icon={isItemExpanded ? faChevronUp : faChevronDown}
className="text-text-primary w-3.5 h-3.5 mr-2"
/>
<button
onClick={async (e): Promise<void> => {
e.stopPropagation();
await onDeletePlotPoint(actId, item.plotPointId);
}}
className="text-error hover:bg-error/20 p-1.5 rounded-lg transition-all duration-200 hover:scale-110"
>
<FontAwesomeIcon icon={faTrash} className="w-3.5 h-3.5"/>
</button>
</div>
</button>
{isItemExpanded && (
<div className="p-3 bg-secondary/20">
{item.chapters && item.chapters.length > 0 ? (
item.chapters.map((chapter: ActChapter) => (
<ActChapterItem
key={`plot-chapter-${chapter.chapterId}-${chapter.chapterInfoId}`}
chapter={chapter}
onUpdateSummary={(chapterId, summary) =>
onUpdateChapterSummary(chapterId, summary, item.plotPointId)
}
onUnlink={(chapterInfoId, chapterId) =>
onUnlinkChapter(chapterInfoId, chapterId, item.plotPointId)
}
/>
))
) : (
<p className="text-text-secondary text-center text-sm p-2">
{t('noLinkedChapter')}
</p>
)}
<div className="flex items-center mt-2">
<select
onChange={(e) => setSelectedChapterId(e.target.value)}
className="flex-1 bg-secondary/50 text-text-primary rounded-xl px-4 py-2.5 mr-2 border border-secondary/50 focus:outline-none focus:ring-4 focus:ring-primary/20 focus:border-primary hover:bg-secondary hover:border-secondary transition-all duration-200"
>
<option value="">{t('selectChapterPlaceholder')}</option>
{mainChapters.map((chapter: ChapterListProps) => (
<option key={chapter.chapterId} value={chapter.chapterId}>
{`${chapter.chapterOrder}. ${chapter.title}`}
</option>
))}
</select>
<button
className="bg-primary text-text-primary w-9 h-9 rounded-full flex items-center justify-center disabled:opacity-50 disabled:cursor-not-allowed shadow-md hover:shadow-lg hover:scale-110 hover:bg-primary-dark transition-all duration-200"
onClick={() => onLinkChapter(actId, selectedChapterId, item.plotPointId)}
disabled={!selectedChapterId}
>
<FontAwesomeIcon icon={faPlus} className="w-3.5 h-3.5"/>
</button>
</div>
</div>
)}
</div>
);
})
) : (
<p className="text-text-secondary text-center text-sm p-2">
{t('noPlotPointAdded')}
</p>
)}
<div className="mt-2 space-y-2">
<div className="flex items-center">
<input
type="text"
className="flex-1 bg-secondary/50 text-text-primary rounded-xl px-4 py-2.5 border border-secondary/50 focus:outline-none focus:ring-4 focus:ring-primary/20 focus:border-primary hover:bg-secondary hover:border-secondary transition-all duration-200 placeholder:text-muted/60"
value={newPlotPointTitle}
onChange={(e) => setNewPlotPointTitle(e.target.value)}
placeholder={t('newPlotPointPlaceholder')}
/>
</div>
<InputField
input={
<SelectBox
defaultValue={``}
onChangeCallBack={(e) => setSelectedIncidentId(e.target.value)}
data={getIncidentData()}
/>
}
addButtonCallBack={(): Promise<void> => onAddPlotPoint(actId)}
isAddButtonDisabled={newPlotPointTitle.trim() === ''}
/>
</div>
</div>
)}
</div>
);
}