Files
natreex 8eab6fd771 Enhance synchronization logic and offline handling
- Refactor components to support conditional offline and online CRUD operations.
- Introduce `addToQueue` mechanism for syncing offline changes to the server.
- Add `isChapterContentExist` method and related existence checks in repositories.
- Consolidate data structures and streamline book, chapter, character, and guideline synchronization workflows.
- Encrypt additional character fields and adjust repository inserts for offline data.
2026-01-07 20:43:34 -05:00

678 lines
28 KiB
TypeScript

import {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";
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQueueContext";
import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext";
import {SyncedBook} from "@/lib/models/SyncedBook";
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 {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
const {addToQueue} = useContext<LocalSyncQueueContextProps>(LocalSyncQueueContext);
const {localSyncedBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
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 {
let incidentId: string;
if (isCurrentlyOffline() || book?.localBook) {
incidentId = await window.electron.invoke<string>('db:book:incident:add', {
bookId,
name: newIncidentTitle,
});
} else {
incidentId = await System.authPostToServer<string>('book/incident/new', {
bookId,
name: newIncidentTitle,
}, token, lang);
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) {
addToQueue('db:book:incident:add', {
bookId,
incidentId,
name: newIncidentTitle,
});
}
}
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 {
let response: boolean;
const deleteData = { bookId, incidentId };
if (isCurrentlyOffline() || book?.localBook) {
response = await window.electron.invoke<boolean>('db:book:incident:remove', deleteData);
} else {
response = await System.authDeleteToServer<boolean>('book/incident/remove', deleteData, token, lang);
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) {
addToQueue('db:book:incident:remove', deleteData);
}
}
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 {
let plotId: string;
const plotData = {
bookId,
name: newPlotPointTitle,
incidentId: selectedIncidentId,
};
if (isCurrentlyOffline() || book?.localBook) {
plotId = await window.electron.invoke<string>('db:book:plot:add', plotData);
} else {
plotId = await System.authPostToServer<string>('book/plot/new', plotData, token, lang);
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) {
addToQueue('db:book:plot:add', {
...plotData,
plotId,
});
}
}
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 {
let response: boolean;
const deleteData = { plotId: plotPointId };
if (isCurrentlyOffline() || book?.localBook) {
response = await window.electron.invoke<boolean>('db:book:plot:remove', deleteData);
} else {
response = await System.authDeleteToServer<boolean>('book/plot/remove', deleteData, token, lang);
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) {
addToQueue('db:book:plot:remove', deleteData);
}
}
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 {
let linkId: string;
const linkData = {
bookId,
chapterId: chapterId,
actId: actId,
plotId: destination === 'plotPoint' ? itemId : null,
incidentId: destination === 'incident' ? itemId : null,
};
if (isCurrentlyOffline() || book?.localBook) {
linkId = await window.electron.invoke<string>('db:chapter:information:add', linkData);
} else {
linkId = await System.authPostToServer<string>('chapter/resume/add', linkData, token, lang);
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) {
addToQueue('db:chapter:information:add', {
...linkData,
chapterInfoId: linkId,
});
}
}
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 {
let response: boolean;
const unlinkData = { chapterInfoId };
if (isCurrentlyOffline() || book?.localBook) {
response = await window.electron.invoke<boolean>('db:chapter:information:remove', unlinkData);
} else {
response = await System.authDeleteToServer<boolean>('chapter/resume/remove', unlinkData, token, lang);
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) {
addToQueue('db:chapter:information:remove', unlinkData);
}
}
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>
);
}