diff --git a/components/book/settings/guideline/GuideLineSetting.tsx b/components/book/settings/guideline/GuideLineSetting.tsx new file mode 100644 index 0000000..e8cfd76 --- /dev/null +++ b/components/book/settings/guideline/GuideLineSetting.tsx @@ -0,0 +1,207 @@ +'use client' +import React, {ChangeEvent, forwardRef, useContext, useEffect, useImperativeHandle, useState} from 'react'; +import {apiGet, apiPost} from '@/lib/api/client'; +import {isDesktop} from '@/lib/configs'; +import * as tauri from '@/lib/tauri'; +import OfflineContext, {OfflineContextType} from '@/context/OfflineContext'; +import {AlertContext, AlertContextProps} from "@/context/AlertContext"; +import {BookContext, BookContextProps} from '@/context/BookContext'; +import {SessionContext, SessionContextProps} from "@/context/SessionContext"; +import {GuideLine} from "@/lib/types/book"; +import TextAreaInput from "@/components/form/TextAreaInput"; +import InputField from "@/components/form/InputField"; +import {useTranslations} from '@/lib/i18n'; +import {LangContext, LangContextProps} from "@/context/LangContext"; +import {SettingRef} from "@/lib/types/settings"; + +function GuideLineSetting(_props: object, ref: React.ForwardedRef): React.JSX.Element { + const t = useTranslations(); + const {lang}: LangContextProps = useContext(LangContext); + const {book}: BookContextProps = useContext(BookContext); + const {session}: SessionContextProps = useContext(SessionContext); + const userToken: string = session?.accessToken ? session?.accessToken : ''; + const {errorMessage, successMessage}: AlertContextProps = useContext(AlertContext); + const {isCurrentlyOffline}: OfflineContextType = useContext(OfflineContext); + const bookId: string = book?.bookId ?? ''; + + const [tone, setTone] = useState(''); + const [atmosphere, setAtmosphere] = useState(''); + const [writingStyle, setWritingStyle] = useState(''); + const [themes, setThemes] = useState(''); + const [symbolism, setSymbolism] = useState(''); + const [motifs, setMotifs] = useState(''); + const [narrativeVoice, setNarrativeVoice] = useState(''); + const [pacing, setPacing] = useState(''); + const [intendedAudience, setIntendedAudience] = useState(''); + const [keyMessages, setKeyMessages] = useState(''); + + useEffect((): void => { + getGuideLine().then(); + }, []); + + useImperativeHandle(ref, (): SettingRef => ({ + handleSave: savePersonal + })); + + async function getGuideLine(): Promise { + try { + let response: GuideLine; + if (isDesktop && (isCurrentlyOffline() || book?.localBook)) { + response = await tauri.getGuideLine(bookId); + } else { + response = await apiGet( + `book/guide-line`, + userToken, + lang, + {id: bookId}, + ); + } + if (response) { + setTone(response.tone); + setAtmosphere(response.atmosphere); + setWritingStyle(response.writingStyle); + setThemes(response.themes); + setSymbolism(response.symbolism); + setMotifs(response.motifs); + setNarrativeVoice(response.narrativeVoice); + setPacing(response.pacing); + setIntendedAudience(response.intendedAudience); + setKeyMessages(response.keyMessages); + } + } catch (error: unknown) { + if (error instanceof Error) { + errorMessage(error.message); + } else { + errorMessage(t("guideLineSetting.errorUnknown")); + } + } + } + + async function savePersonal(): Promise { + try { + let response: boolean; + if (isDesktop && (isCurrentlyOffline() || book?.localBook)) { + response = await tauri.updateGuideLine({ + bookId: bookId, + tone: tone, + atmosphere: atmosphere, + writingStyle: writingStyle, + themes: themes, + symbolism: symbolism, + motifs: motifs, + narrativeVoice: narrativeVoice, + pacing: pacing, + intendedAudience: intendedAudience, + keyMessages: keyMessages, + }); + } else { + response = await apiPost( + 'book/guide-line', + { + bookId: bookId, + tone: tone, + atmosphere: atmosphere, + writingStyle: writingStyle, + themes: themes, + symbolism: symbolism, + motifs: motifs, + narrativeVoice: narrativeVoice, + pacing: pacing, + intendedAudience: intendedAudience, + keyMessages: keyMessages, + }, + userToken, + lang, + ); + } + if (!response) { + errorMessage(t("guideLineSetting.saveError")); + return; + } + successMessage(t("guideLineSetting.saveSuccess")); + } catch (error: unknown) { + if (error instanceof Error) { + errorMessage(error.message); + } else { + errorMessage(t("guideLineSetting.errorUnknown")); + } + } + } + + return ( +
+ ): void => setTone(e.target.value)} + placeholder={t("guideLineSetting.tonePlaceholder")} + /> + }/> + ): void => setAtmosphere(e.target.value)} + placeholder={t("guideLineSetting.atmospherePlaceholder")} + /> + }/> + ): void => setWritingStyle(e.target.value)} + placeholder={t("guideLineSetting.writingStylePlaceholder")} + /> + }/> + ): void => setThemes(e.target.value)} + placeholder={t("guideLineSetting.themesPlaceholder")} + /> + }/> + ): void => setSymbolism(e.target.value)} + placeholder={t("guideLineSetting.symbolismPlaceholder")} + /> + }/> + ): void => setMotifs(e.target.value)} + placeholder={t("guideLineSetting.motifsPlaceholder")} + /> + }/> + ): void => setNarrativeVoice(e.target.value)} + placeholder={t("guideLineSetting.narrativeVoicePlaceholder")} + /> + }/> + ): void => setPacing(e.target.value)} + placeholder={t("guideLineSetting.pacingPlaceholder")} + /> + }/> + ): void => setIntendedAudience(e.target.value)} + placeholder={t("guideLineSetting.intendedAudiencePlaceholder")} + /> + }/> + ): void => setKeyMessages(e.target.value)} + placeholder={t("guideLineSetting.keyMessagesPlaceholder")} + /> + }/> +
+ ); +} + +export default forwardRef(GuideLineSetting); diff --git a/components/form/ImageDropZone.tsx b/components/form/ImageDropZone.tsx new file mode 100644 index 0000000..7225ee3 --- /dev/null +++ b/components/form/ImageDropZone.tsx @@ -0,0 +1,72 @@ +'use client' +import {DragEvent, ReactNode, useRef, useState} from "react"; +import {ImagePlus} from "lucide-react"; + +interface ImageDropZoneProps { + onFileSelect: (file: File) => void; + accept?: string; + label?: ReactNode; +} + +const acceptedTypes: string[] = ['image/png', 'image/jpeg', 'image/webp']; + +function ImageDropZone({onFileSelect, accept = "image/png, image/jpeg, image/webp", label}: ImageDropZoneProps) { + const inputRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + + function handleDragOver(e: DragEvent): void { + e.preventDefault(); + setIsDragging(true); + } + + function handleDragLeave(e: DragEvent): void { + e.preventDefault(); + setIsDragging(false); + } + + function handleDrop(e: DragEvent): void { + e.preventDefault(); + setIsDragging(false); + const file: File | undefined = e.dataTransfer.files?.[0]; + if (file && acceptedTypes.includes(file.type)) { + onFileSelect(file); + } + } + + function handleClick(): void { + inputRef.current?.click(); + } + + function handleInputChange(e: React.ChangeEvent): void { + const file: File | undefined = e.target.files?.[0]; + if (file) { + onFileSelect(file); + } + } + + return ( +
+ + + {label && {label}} +
+ ); +} + +export default ImageDropZone; diff --git a/components/form/OrderInput.tsx b/components/form/OrderInput.tsx new file mode 100644 index 0000000..eafa87e --- /dev/null +++ b/components/form/OrderInput.tsx @@ -0,0 +1,72 @@ +import React, {ChangeEvent} from "react"; +import {Minus, Plus} from "lucide-react"; +import IconButton from "@/components/ui/IconButton"; +import TextInput from "@/components/form/TextInput"; + +interface OrderInputProps { + value: string; + setValue: (e: ChangeEvent) => void; + placeholder: string; + order: number; + setOrder: (order: number) => void; + onAdd: () => Promise; + isAddDisabled?: boolean; +} + +export default function OrderInput( + { + value, + setValue, + placeholder, + order, + setOrder, + onAdd, + isAddDisabled = false + }: OrderInputProps) { + + function decrementOrder(): void { + if (order > 0) { + setOrder(order - 1); + } + } + + function incrementOrder(): void { + setOrder(order + 1); + } + + return ( +
+
+ + + {order} + + +
+
+ +
+ +
+ ); +} diff --git a/components/form/TextAreaInput.tsx b/components/form/TextAreaInput.tsx new file mode 100644 index 0000000..8807342 --- /dev/null +++ b/components/form/TextAreaInput.tsx @@ -0,0 +1,84 @@ +import React, {ChangeEvent, useEffect, useRef} from "react"; + +interface TextAreaInputProps { + value: string; + setValue: (e: ChangeEvent) => void; + placeholder: string; + maxLength?: number; +} + +export default function TextAreaInput( + { + value, + setValue, + placeholder, + maxLength + }: TextAreaInputProps) { + const progressRef = useRef(null); + + useEffect(() => { + if (progressRef.current && maxLength) { + progressRef.current.style.width = `${getProgressPercentage()}%`; + } + }); + + function getProgressPercentage(): number { + if (!maxLength) return 0; + return Math.min((value.length / maxLength) * 100, 100); + } + + function getStatusStyles(): { textColor: string; progressColor: string } | Record { + if (!maxLength) return {}; + const percentage = getProgressPercentage(); + + if (percentage >= 100) return { + textColor: 'text-error', + progressColor: 'bg-error' + }; + + if (percentage >= 75) return { + textColor: 'text-warning', + progressColor: 'bg-warning' + }; + + return { + textColor: 'text-muted', + progressColor: 'bg-primary' + }; + } + + const styles = getStatusStyles(); + + return ( +
+