From d4765e6576099c90a0cfd9429217f3f9334822b6 Mon Sep 17 00:00:00 2001 From: natreex Date: Sun, 5 Apr 2026 12:52:54 -0400 Subject: [PATCH] Add foundational components and logic for migration, UI design, and input handling - Introduced foundational UI components (`Badge`, `LockCard`, `SectionHeader`, `AvatarIcon`, etc.) for flexible layouts and consistent design. - Added migration support with the `MigrationModal` component and backend integration for exporting/importing data between Electron and Tauri. - Extended form components with `TextAreaInput`, `OrderInput`, `ToggleField`, and `ToolbarSelect` for improved input handling. - Updated `ScribeShell` with migration popup logic to prompt users for data migration. - Integrated `AlertStack` for better alert handling and notification management. - Enhanced Rust/Tauri services with migration command implementations. - Added translations and styles for new components. --- .../settings/guideline/GuideLineSetting.tsx | 207 ++++++++++++++++++ components/form/ImageDropZone.tsx | 72 ++++++ components/form/OrderInput.tsx | 72 ++++++ components/form/TextAreaInput.tsx | 84 +++++++ components/form/ToggleField.tsx | 49 +++++ components/form/ToolbarSelect.tsx | 43 ++++ components/layout/ScribeControllerBar.tsx | 160 ++++++++++++++ components/layout/ScribeFooterBar.tsx | 96 ++++++++ components/layout/ScribeShell.tsx | 17 ++ components/layout/ScribeTopBar.tsx | 39 ++++ components/layout/UserMenu.tsx | 84 +++++++ components/migration/MigrationModal.tsx | 176 +++++++++++++++ components/rightbar/ComposerRightBar.tsx | 23 +- components/ui/AlertBox.tsx | 123 +++++++++++ components/ui/AlertStack.tsx | 44 ++++ components/ui/AvatarIcon.tsx | 65 ++++++ components/ui/Badge.tsx | 61 ++++++ components/ui/Button.tsx | 84 +++++++ components/ui/Collapse.tsx | 65 ++++++ components/ui/CreditMeters.tsx | 29 +++ components/ui/DetailField.tsx | 51 +++++ components/ui/DetailHeroSection.tsx | 26 +++ components/ui/Dropdown.tsx | 89 ++++++++ components/ui/EmptyState.tsx | 33 +++ components/ui/EntityListItem.tsx | 86 ++++++++ components/ui/IconButton.tsx | 69 ++++++ components/ui/IconContainer.tsx | 47 ++++ components/ui/IconLabel.tsx | 16 ++ components/ui/InsetPanel.tsx | 14 ++ components/ui/ListItem.tsx | 121 ++++++++++ components/ui/LockCard.tsx | 30 +++ components/ui/Modal.tsx | 102 +++++++++ components/ui/PulseLoader.tsx | 30 +++ components/ui/QSTextGeneratedPreview.tsx | 98 +++++++++ components/ui/SectionHeader.tsx | 43 ++++ components/ui/SettingsPanel.tsx | 53 +++++ components/ui/StaticAlert.tsx | 73 ++++++ components/ui/ToggleGroup.tsx | 59 +++++ context/ThemeContext.ts | 14 ++ electron/main.ts | 73 ++++++ hooks/useOnboarding.tsx | 175 +++++++++++++++ hooks/useTheme.ts | 30 +++ lib/crashReporter.ts | 101 +++++++++ lib/locales/en.json | 29 +++ lib/locales/fr.json | 29 +++ lib/tauri.ts | 23 ++ lib/utils/webkitDetect.ts | 4 + src-tauri/src/domains/mod.rs | 1 + src-tauri/src/lib.rs | 5 +- 49 files changed, 3115 insertions(+), 2 deletions(-) create mode 100644 components/book/settings/guideline/GuideLineSetting.tsx create mode 100644 components/form/ImageDropZone.tsx create mode 100644 components/form/OrderInput.tsx create mode 100644 components/form/TextAreaInput.tsx create mode 100644 components/form/ToggleField.tsx create mode 100644 components/form/ToolbarSelect.tsx create mode 100644 components/layout/ScribeControllerBar.tsx create mode 100644 components/layout/ScribeFooterBar.tsx create mode 100644 components/layout/ScribeTopBar.tsx create mode 100644 components/layout/UserMenu.tsx create mode 100644 components/migration/MigrationModal.tsx create mode 100644 components/ui/AlertBox.tsx create mode 100644 components/ui/AlertStack.tsx create mode 100644 components/ui/AvatarIcon.tsx create mode 100644 components/ui/Badge.tsx create mode 100644 components/ui/Button.tsx create mode 100644 components/ui/Collapse.tsx create mode 100644 components/ui/CreditMeters.tsx create mode 100644 components/ui/DetailField.tsx create mode 100644 components/ui/DetailHeroSection.tsx create mode 100644 components/ui/Dropdown.tsx create mode 100644 components/ui/EmptyState.tsx create mode 100644 components/ui/EntityListItem.tsx create mode 100644 components/ui/IconButton.tsx create mode 100644 components/ui/IconContainer.tsx create mode 100644 components/ui/IconLabel.tsx create mode 100644 components/ui/InsetPanel.tsx create mode 100644 components/ui/ListItem.tsx create mode 100644 components/ui/LockCard.tsx create mode 100644 components/ui/Modal.tsx create mode 100644 components/ui/PulseLoader.tsx create mode 100644 components/ui/QSTextGeneratedPreview.tsx create mode 100644 components/ui/SectionHeader.tsx create mode 100644 components/ui/SettingsPanel.tsx create mode 100644 components/ui/StaticAlert.tsx create mode 100644 components/ui/ToggleGroup.tsx create mode 100644 context/ThemeContext.ts create mode 100644 hooks/useOnboarding.tsx create mode 100644 hooks/useTheme.ts create mode 100644 lib/crashReporter.ts create mode 100644 lib/utils/webkitDetect.ts 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 ( +
+