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.
This commit is contained in:
207
components/book/settings/guideline/GuideLineSetting.tsx
Normal file
207
components/book/settings/guideline/GuideLineSetting.tsx
Normal file
@@ -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<SettingRef>): React.JSX.Element {
|
||||||
|
const t = useTranslations();
|
||||||
|
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
|
||||||
|
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
|
||||||
|
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
|
||||||
|
const userToken: string = session?.accessToken ? session?.accessToken : '';
|
||||||
|
const {errorMessage, successMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
|
||||||
|
const {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
|
||||||
|
const bookId: string = book?.bookId ?? '';
|
||||||
|
|
||||||
|
const [tone, setTone] = useState<string>('');
|
||||||
|
const [atmosphere, setAtmosphere] = useState<string>('');
|
||||||
|
const [writingStyle, setWritingStyle] = useState<string>('');
|
||||||
|
const [themes, setThemes] = useState<string>('');
|
||||||
|
const [symbolism, setSymbolism] = useState<string>('');
|
||||||
|
const [motifs, setMotifs] = useState<string>('');
|
||||||
|
const [narrativeVoice, setNarrativeVoice] = useState<string>('');
|
||||||
|
const [pacing, setPacing] = useState<string>('');
|
||||||
|
const [intendedAudience, setIntendedAudience] = useState<string>('');
|
||||||
|
const [keyMessages, setKeyMessages] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect((): void => {
|
||||||
|
getGuideLine().then();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, (): SettingRef => ({
|
||||||
|
handleSave: savePersonal
|
||||||
|
}));
|
||||||
|
|
||||||
|
async function getGuideLine(): Promise<void> {
|
||||||
|
try {
|
||||||
|
let response: GuideLine;
|
||||||
|
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
|
||||||
|
response = await tauri.getGuideLine(bookId);
|
||||||
|
} else {
|
||||||
|
response = await apiGet<GuideLine>(
|
||||||
|
`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<void> {
|
||||||
|
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<boolean>(
|
||||||
|
'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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<InputField fieldName={t("guideLineSetting.tone")} input={
|
||||||
|
<TextAreaInput
|
||||||
|
value={tone}
|
||||||
|
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setTone(e.target.value)}
|
||||||
|
placeholder={t("guideLineSetting.tonePlaceholder")}
|
||||||
|
/>
|
||||||
|
}/>
|
||||||
|
<InputField fieldName={t("guideLineSetting.atmosphere")} input={
|
||||||
|
<TextAreaInput
|
||||||
|
value={atmosphere}
|
||||||
|
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setAtmosphere(e.target.value)}
|
||||||
|
placeholder={t("guideLineSetting.atmospherePlaceholder")}
|
||||||
|
/>
|
||||||
|
}/>
|
||||||
|
<InputField fieldName={t("guideLineSetting.writingStyle")} input={
|
||||||
|
<TextAreaInput
|
||||||
|
value={writingStyle}
|
||||||
|
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setWritingStyle(e.target.value)}
|
||||||
|
placeholder={t("guideLineSetting.writingStylePlaceholder")}
|
||||||
|
/>
|
||||||
|
}/>
|
||||||
|
<InputField fieldName={t("guideLineSetting.themes")} input={
|
||||||
|
<TextAreaInput
|
||||||
|
value={themes}
|
||||||
|
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setThemes(e.target.value)}
|
||||||
|
placeholder={t("guideLineSetting.themesPlaceholder")}
|
||||||
|
/>
|
||||||
|
}/>
|
||||||
|
<InputField fieldName={t("guideLineSetting.symbolism")} input={
|
||||||
|
<TextAreaInput
|
||||||
|
value={symbolism}
|
||||||
|
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setSymbolism(e.target.value)}
|
||||||
|
placeholder={t("guideLineSetting.symbolismPlaceholder")}
|
||||||
|
/>
|
||||||
|
}/>
|
||||||
|
<InputField fieldName={t("guideLineSetting.motifs")} input={
|
||||||
|
<TextAreaInput
|
||||||
|
value={motifs}
|
||||||
|
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setMotifs(e.target.value)}
|
||||||
|
placeholder={t("guideLineSetting.motifsPlaceholder")}
|
||||||
|
/>
|
||||||
|
}/>
|
||||||
|
<InputField fieldName={t("guideLineSetting.narrativeVoice")} input={
|
||||||
|
<TextAreaInput
|
||||||
|
value={narrativeVoice}
|
||||||
|
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setNarrativeVoice(e.target.value)}
|
||||||
|
placeholder={t("guideLineSetting.narrativeVoicePlaceholder")}
|
||||||
|
/>
|
||||||
|
}/>
|
||||||
|
<InputField fieldName={t("guideLineSetting.pacing")} input={
|
||||||
|
<TextAreaInput
|
||||||
|
value={pacing}
|
||||||
|
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setPacing(e.target.value)}
|
||||||
|
placeholder={t("guideLineSetting.pacingPlaceholder")}
|
||||||
|
/>
|
||||||
|
}/>
|
||||||
|
<InputField fieldName={t("guideLineSetting.intendedAudience")} input={
|
||||||
|
<TextAreaInput
|
||||||
|
value={intendedAudience}
|
||||||
|
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setIntendedAudience(e.target.value)}
|
||||||
|
placeholder={t("guideLineSetting.intendedAudiencePlaceholder")}
|
||||||
|
/>
|
||||||
|
}/>
|
||||||
|
<InputField fieldName={t("guideLineSetting.keyMessages")} input={
|
||||||
|
<TextAreaInput
|
||||||
|
value={keyMessages}
|
||||||
|
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setKeyMessages(e.target.value)}
|
||||||
|
placeholder={t("guideLineSetting.keyMessagesPlaceholder")}
|
||||||
|
/>
|
||||||
|
}/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default forwardRef<SettingRef, object>(GuideLineSetting);
|
||||||
72
components/form/ImageDropZone.tsx
Normal file
72
components/form/ImageDropZone.tsx
Normal file
@@ -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<HTMLInputElement | null>(null);
|
||||||
|
const [isDragging, setIsDragging] = useState<boolean>(false);
|
||||||
|
|
||||||
|
function handleDragOver(e: DragEvent<HTMLDivElement>): void {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave(e: DragEvent<HTMLDivElement>): void {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(e: DragEvent<HTMLDivElement>): 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<HTMLInputElement>): void {
|
||||||
|
const file: File | undefined = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
onFileSelect(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={handleClick}
|
||||||
|
className={`flex flex-col items-center justify-center gap-2 border-2 border-dashed rounded-lg p-6 cursor-pointer transition-colors
|
||||||
|
${isDragging
|
||||||
|
? 'border-primary bg-primary/10'
|
||||||
|
: 'border-secondary bg-dark-background hover:bg-tertiary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<ImagePlus className="w-8 h-8 text-muted"/>
|
||||||
|
{label && <span className="text-text-primary text-sm">{label}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ImageDropZone;
|
||||||
72
components/form/OrderInput.tsx
Normal file
72
components/form/OrderInput.tsx
Normal file
@@ -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<HTMLInputElement>) => void;
|
||||||
|
placeholder: string;
|
||||||
|
order: number;
|
||||||
|
setOrder: (order: number) => void;
|
||||||
|
onAdd: () => Promise<void>;
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<IconButton
|
||||||
|
icon={Minus}
|
||||||
|
variant="muted"
|
||||||
|
size="sm"
|
||||||
|
onClick={decrementOrder}
|
||||||
|
disabled={order <= 0}
|
||||||
|
/>
|
||||||
|
<span className="text-muted text-xs w-5 h-5 rounded-md bg-tertiary text-center leading-5">
|
||||||
|
{order}
|
||||||
|
</span>
|
||||||
|
<IconButton
|
||||||
|
icon={Plus}
|
||||||
|
variant="muted"
|
||||||
|
size="sm"
|
||||||
|
onClick={incrementOrder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<TextInput
|
||||||
|
value={value}
|
||||||
|
setValue={setValue}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<IconButton
|
||||||
|
icon={Plus}
|
||||||
|
variant="primary"
|
||||||
|
onClick={onAdd}
|
||||||
|
disabled={isAddDisabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
components/form/TextAreaInput.tsx
Normal file
84
components/form/TextAreaInput.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import React, {ChangeEvent, useEffect, useRef} from "react";
|
||||||
|
|
||||||
|
interface TextAreaInputProps {
|
||||||
|
value: string;
|
||||||
|
setValue: (e: ChangeEvent<HTMLTextAreaElement>) => void;
|
||||||
|
placeholder: string;
|
||||||
|
maxLength?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TextAreaInput(
|
||||||
|
{
|
||||||
|
value,
|
||||||
|
setValue,
|
||||||
|
placeholder,
|
||||||
|
maxLength
|
||||||
|
}: TextAreaInputProps) {
|
||||||
|
const progressRef = useRef<HTMLDivElement>(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<string, never> {
|
||||||
|
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 (
|
||||||
|
<div className="flex-grow flex-col flex h-full">
|
||||||
|
<textarea
|
||||||
|
value={value}
|
||||||
|
onChange={setValue}
|
||||||
|
placeholder={placeholder}
|
||||||
|
rows={3}
|
||||||
|
className={`input-base w-full flex-grow p-3 lg:p-4 resize-none ${
|
||||||
|
maxLength && value.length >= maxLength
|
||||||
|
? 'border-error'
|
||||||
|
: ''
|
||||||
|
} h-full min-h-[200px]`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{maxLength && (
|
||||||
|
<div className="flex items-center justify-end gap-3 mt-3">
|
||||||
|
{/* Compteur avec effet de croissance */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-24 h-1.5 bg-secondary rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
ref={progressRef}
|
||||||
|
className={`h-full rounded-full transition-all duration-500 ease-out ${styles.progressColor}`}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs font-medium ${styles.textColor}`}>
|
||||||
|
{value.length}/{maxLength}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
components/form/ToggleField.tsx
Normal file
49
components/form/ToggleField.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {LucideIcon} from "lucide-react";
|
||||||
|
import InputField from "@/components/form/InputField";
|
||||||
|
import ToggleWithConfirmation from "@/components/form/ToggleWithConfirmation";
|
||||||
|
import {AlertType} from "@/components/ui/AlertBox";
|
||||||
|
|
||||||
|
interface ToggleFieldProps {
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (value: boolean) => void;
|
||||||
|
alertTitle: string;
|
||||||
|
alertMessage: string;
|
||||||
|
alertType: AlertType;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ToggleField(
|
||||||
|
{
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
alertTitle,
|
||||||
|
alertMessage,
|
||||||
|
alertType,
|
||||||
|
confirmText,
|
||||||
|
cancelText,
|
||||||
|
}: ToggleFieldProps) {
|
||||||
|
return (
|
||||||
|
<InputField
|
||||||
|
icon={icon}
|
||||||
|
fieldName={label}
|
||||||
|
centered
|
||||||
|
input={
|
||||||
|
<ToggleWithConfirmation
|
||||||
|
checked={checked}
|
||||||
|
onChange={onChange}
|
||||||
|
alertTitle={alertTitle}
|
||||||
|
alertMessage={alertMessage}
|
||||||
|
alertType={alertType}
|
||||||
|
confirmText={confirmText}
|
||||||
|
cancelText={cancelText}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
components/form/ToolbarSelect.tsx
Normal file
43
components/form/ToolbarSelect.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import React, {ChangeEvent} from "react";
|
||||||
|
import {SelectBoxProps} from "@/components/form/SelectBox";
|
||||||
|
|
||||||
|
interface ToolbarSelectProps {
|
||||||
|
onChangeCallBack: (event: ChangeEvent<HTMLSelectElement>) => void;
|
||||||
|
data: SelectBoxProps[];
|
||||||
|
defaultValue: string | null | undefined;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ToolbarSelect(
|
||||||
|
{
|
||||||
|
onChangeCallBack,
|
||||||
|
data,
|
||||||
|
defaultValue,
|
||||||
|
placeholder,
|
||||||
|
disabled
|
||||||
|
}: ToolbarSelectProps): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
onChange={onChangeCallBack}
|
||||||
|
disabled={disabled}
|
||||||
|
key={defaultValue || 'placeholder'}
|
||||||
|
defaultValue={defaultValue || '0'}
|
||||||
|
className="bg-transparent text-text-primary text-xs px-2 py-1.5 rounded-lg
|
||||||
|
border border-transparent
|
||||||
|
hover:bg-secondary hover:border-secondary
|
||||||
|
focus:bg-secondary focus:border-primary focus:outline-none
|
||||||
|
transition-all duration-200 cursor-pointer
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{placeholder && <option value={'0'}>{placeholder}</option>}
|
||||||
|
{data.map(function (item: SelectBoxProps): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<option key={item.value} value={item.value} className="bg-tertiary text-text-primary">
|
||||||
|
{item.label}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
components/layout/ScribeControllerBar.tsx
Normal file
160
components/layout/ScribeControllerBar.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import React, {useContext, useState} from "react";
|
||||||
|
const logo = "/eritors-favicon-white.png";
|
||||||
|
import {ChapterProps} from "@/lib/types/chapter";
|
||||||
|
import {chapterVersions} from "@/lib/constants/chapter";
|
||||||
|
import {ChapterContext, ChapterContextProps} from "@/context/ChapterContext";
|
||||||
|
import {BookContext, BookContextProps} from "@/context/BookContext";
|
||||||
|
import {apiGet} from '@/lib/api/client';
|
||||||
|
import {isDesktop} from '@/lib/configs';
|
||||||
|
import * as tauri from '@/lib/tauri';
|
||||||
|
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
|
||||||
|
import {getCookie, setCookie} from '@/lib/utils/cookies';
|
||||||
|
import {useRouter} from "@/lib/navigation";
|
||||||
|
import UserMenu from "@/components/layout/UserMenu";
|
||||||
|
import {Globe, Home, Settings} from "lucide-react";
|
||||||
|
import IconButton from "@/components/ui/IconButton";
|
||||||
|
import ToggleGroup from "@/components/ui/ToggleGroup";
|
||||||
|
import {SelectBoxProps} from "@/components/form/SelectBox";
|
||||||
|
import ToolbarSelect from "@/components/form/ToolbarSelect";
|
||||||
|
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
|
||||||
|
import {SessionContext, SessionContextProps} from "@/context/SessionContext";
|
||||||
|
import {booksToSelectBox} from "@/lib/utils/book";
|
||||||
|
import BookSetting from "@/components/book/settings/BookSetting";
|
||||||
|
import {useTranslations} from '@/lib/i18n';
|
||||||
|
import {isSupportedLocale, LangContext, LangContextProps, SupportedLocale} from "@/context/LangContext";
|
||||||
|
import CreditCounter from "@/components/ui/CreditMeters";
|
||||||
|
import {getSubLevel, isAnthropicEnabled} from "@/lib/utils/quillsense";
|
||||||
|
import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext";
|
||||||
|
import OfflineToggle from "@/components/offline/OfflineToggle";
|
||||||
|
|
||||||
|
export default function ScribeControllerBar() {
|
||||||
|
const {chapter, setChapter}: ChapterContextProps = useContext<ChapterContextProps>(ChapterContext);
|
||||||
|
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
|
||||||
|
const router = useRouter();
|
||||||
|
const {errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
|
||||||
|
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
|
||||||
|
const t = useTranslations();
|
||||||
|
const {lang, setLang}: LangContextProps = useContext<LangContextProps>(LangContext)
|
||||||
|
const {serverSyncedBooks, serverOnlyBooks, localOnlyBooks}: BooksSyncContextProps = useContext<BooksSyncContextProps>(BooksSyncContext);
|
||||||
|
const {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
|
||||||
|
|
||||||
|
const anthropicEnabled: boolean = !isCurrentlyOffline() && isAnthropicEnabled(session);
|
||||||
|
const isSubTierTwo: boolean = !isCurrentlyOffline() && getSubLevel(session) >= 2;
|
||||||
|
const hasAccess: boolean = anthropicEnabled || isSubTierTwo;
|
||||||
|
|
||||||
|
const [showSettingPanel, setShowSettingPanel] = useState<boolean>(false);
|
||||||
|
|
||||||
|
async function handleChapterVersionChanged(version: number) {
|
||||||
|
try {
|
||||||
|
let response: ChapterProps;
|
||||||
|
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
|
||||||
|
response = await tauri.getWholeChapter(chapter?.chapterId ?? '', version, book?.bookId ?? '');
|
||||||
|
} else {
|
||||||
|
response = await apiGet<ChapterProps>(`chapter/whole`, session.accessToken, lang, {
|
||||||
|
bookid: book?.bookId,
|
||||||
|
id: chapter?.chapterId,
|
||||||
|
version: version,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!response) {
|
||||||
|
errorMessage(t("controllerBar.chapterNotFound"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setChapter(response);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
errorMessage(e.message);
|
||||||
|
} else {
|
||||||
|
errorMessage(t("controllerBar.unknownChapterError"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBookNavigation(bookId: string): void {
|
||||||
|
router.push(`/book/${bookId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLanguageChange(language: SupportedLocale): void {
|
||||||
|
setCookie('lang', language, 365);
|
||||||
|
const newLang: string | null = getCookie('lang');
|
||||||
|
if (newLang && isSupportedLocale(newLang)) {
|
||||||
|
setLang(language);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative flex items-center justify-between px-4 py-2 bg-tertiary">
|
||||||
|
{/* Gauche : Logo + contrôles */}
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="flex items-center space-x-2 pr-3 border-r border-secondary">
|
||||||
|
<img src={logo} alt={t("scribeTopBar.logoAlt")} width={24} height={24}/>
|
||||||
|
<span className="font-['ADLaM_Display'] text-sm tracking-wide text-text-primary">Scribe</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{book && (
|
||||||
|
<IconButton icon={Settings} variant="ghost" shape="square"
|
||||||
|
onClick={(): void => setShowSettingPanel(true)}/>
|
||||||
|
)}
|
||||||
|
{book && (
|
||||||
|
<IconButton icon={Home} variant="ghost" shape="square"
|
||||||
|
onClick={(): void => router.push('/')}/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ToolbarSelect onChangeCallBack={(e) => handleBookNavigation(e.target.value)}
|
||||||
|
data={booksToSelectBox([...(serverSyncedBooks ?? []), ...(serverOnlyBooks ?? []), ...(localOnlyBooks ?? [])])} defaultValue={book?.bookId}
|
||||||
|
placeholder={t("controllerBar.selectBook")}/>
|
||||||
|
{chapter && (
|
||||||
|
<ToolbarSelect onChangeCallBack={(e) => handleChapterVersionChanged(parseInt(e.target.value))}
|
||||||
|
data={chapterVersions.filter((version: SelectBoxProps): boolean => {
|
||||||
|
return !(version.value === '1' && (!hasAccess || book?.quillsenseEnabled === false));
|
||||||
|
}).map((version: SelectBoxProps) => {
|
||||||
|
return {
|
||||||
|
value: version.value.toString(),
|
||||||
|
label: t(version.label)
|
||||||
|
}
|
||||||
|
})} defaultValue={chapter?.chapterContent.version.toString()}/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Centre : Titre du livre */}
|
||||||
|
{book && (
|
||||||
|
<div
|
||||||
|
className="absolute left-1/2 -translate-x-1/2 flex items-center bg-secondary px-4 py-1.5 rounded-lg border border-secondary">
|
||||||
|
<div className="h-4 w-0.5 bg-primary rounded-full mr-3"></div>
|
||||||
|
<div className="text-center">
|
||||||
|
<span className="text-text-primary font-semibold text-sm tracking-wide">
|
||||||
|
{book.title}
|
||||||
|
</span>
|
||||||
|
{book.subTitle && (
|
||||||
|
<span className="text-text-secondary text-xs italic ml-2">
|
||||||
|
{book.subTitle}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="h-4 w-0.5 bg-primary rounded-full ml-3"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Droite : Crédits, offline, langue, user */}
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
{
|
||||||
|
hasAccess && book?.quillsenseEnabled !== false &&
|
||||||
|
<CreditCounter isCredit={isSubTierTwo}/>
|
||||||
|
}
|
||||||
|
{isDesktop && <OfflineToggle/>}
|
||||||
|
<ToggleGroup
|
||||||
|
options={[{value: 'fr', label: 'FR'}, {value: 'en', label: 'EN'}]}
|
||||||
|
value={lang}
|
||||||
|
onChange={function (value: string): void {
|
||||||
|
if (isSupportedLocale(value)) {
|
||||||
|
handleLanguageChange(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
icon={Globe}
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
<UserMenu/>
|
||||||
|
</div>
|
||||||
|
{showSettingPanel && <BookSetting onClose={() => setShowSettingPanel(false)}/>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
96
components/layout/ScribeFooterBar.tsx
Normal file
96
components/layout/ScribeFooterBar.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import {ChapterContext, ChapterContextProps} from "@/context/ChapterContext";
|
||||||
|
import {EditorContext, EditorContextProps} from "@/context/EditorContext";
|
||||||
|
import React, {useContext, useEffect, useState} from "react";
|
||||||
|
import {BookOpen, ChevronRight, FileText, Pilcrow, Type} from "lucide-react";
|
||||||
|
import IconLabel from "@/components/ui/IconLabel";
|
||||||
|
import {useTranslations} from '@/lib/i18n';
|
||||||
|
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
|
||||||
|
import {BookContext, BookContextProps} from "@/context/BookContext";
|
||||||
|
import {chapterVersions} from "@/lib/constants/chapter";
|
||||||
|
|
||||||
|
export default function ScribeFooterBar() {
|
||||||
|
const t = useTranslations();
|
||||||
|
const {chapter}: ChapterContextProps = useContext<ChapterContextProps>(ChapterContext);
|
||||||
|
const {editor}: EditorContextProps = useContext<EditorContextProps>(EditorContext);
|
||||||
|
const {errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
|
||||||
|
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
|
||||||
|
|
||||||
|
const [wordsCount, setWordsCount] = useState<number>(0);
|
||||||
|
const [paragraphCount, setParagraphCount] = useState<number>(0);
|
||||||
|
|
||||||
|
useEffect((): void => {
|
||||||
|
getWordCount();
|
||||||
|
}, [editor?.state.doc.textContent]);
|
||||||
|
|
||||||
|
function getWordCount(): void {
|
||||||
|
if (editor) {
|
||||||
|
try {
|
||||||
|
const content: string = editor.state.doc.textContent;
|
||||||
|
const texteNormalise: string = content
|
||||||
|
.replace(/'/g, ' ')
|
||||||
|
.replace(/-/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
const mots: string[] = texteNormalise.split(' ');
|
||||||
|
const wordCount: number = mots.filter(
|
||||||
|
(mot: string): boolean => mot.length > 0,
|
||||||
|
).length;
|
||||||
|
setWordsCount(wordCount);
|
||||||
|
|
||||||
|
let paragraphs: number = 0;
|
||||||
|
editor.state.doc.descendants(function (node): void {
|
||||||
|
if (node.type.name === 'paragraph' && node.textContent.trim().length > 0) {
|
||||||
|
paragraphs++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setParagraphCount(paragraphs);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
errorMessage(t('errors.wordCountError') + ` (${e.message})`);
|
||||||
|
} else {
|
||||||
|
errorMessage(t('errors.wordCountError'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVersionLabel(): string {
|
||||||
|
if (!chapter) return '';
|
||||||
|
const version = chapterVersions.find(function (v) {
|
||||||
|
return v.value === chapter.chapterContent.version.toString();
|
||||||
|
});
|
||||||
|
return version ? t(version.label) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-5 py-2 bg-tertiary text-text-secondary flex justify-between items-center text-xs">
|
||||||
|
{/* Gauche : Breadcrumb */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{book ? (
|
||||||
|
<>
|
||||||
|
<BookOpen className="w-3 h-3 text-muted" strokeWidth={1.75}/>
|
||||||
|
<span className="text-text-primary font-medium">{book.title}</span>
|
||||||
|
{chapter && (
|
||||||
|
<>
|
||||||
|
<ChevronRight className="w-3 h-3 text-muted" strokeWidth={1.75}/>
|
||||||
|
<span>{chapter.title}</span>
|
||||||
|
<ChevronRight className="w-3 h-3 text-muted" strokeWidth={1.75}/>
|
||||||
|
<span className="text-primary">{getVersionLabel()}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-text-dimmed">{t('scribeFooterBar.madeWith')} ERitors</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Droite : Stats */}
|
||||||
|
{(chapter || book) && (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<IconLabel icon={Type}>{wordsCount} {t('scribeFooterBar.words')}</IconLabel>
|
||||||
|
<IconLabel icon={FileText}>{Math.ceil(wordsCount / 300)} {t('scribeFooterBar.pages')}</IconLabel>
|
||||||
|
<IconLabel icon={Pilcrow}>{paragraphCount} {t('scribeFooterBar.paragraphs')}</IconLabel>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ import OfflineProvider from '@/context/OfflineProvider';
|
|||||||
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
|
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
|
||||||
import OfflinePinSetup from '@/components/offline/OfflinePinSetup';
|
import OfflinePinSetup from '@/components/offline/OfflinePinSetup';
|
||||||
import OfflinePinVerify from '@/components/offline/OfflinePinVerify';
|
import OfflinePinVerify from '@/components/offline/OfflinePinVerify';
|
||||||
|
import MigrationModal from '@/components/migration/MigrationModal';
|
||||||
import {isDesktop} from '@/lib/configs';
|
import {isDesktop} from '@/lib/configs';
|
||||||
import * as tauri from '@/lib/tauri';
|
import * as tauri from '@/lib/tauri';
|
||||||
import useSyncBooks from '@/hooks/useSyncBooks';
|
import useSyncBooks from '@/hooks/useSyncBooks';
|
||||||
@@ -136,6 +137,16 @@ function ScribeContent({children}: { children: ReactNode }) {
|
|||||||
const [currentChapter, setCurrentChapter] = useState<ChapterProps | undefined>(undefined);
|
const [currentChapter, setCurrentChapter] = useState<ChapterProps | undefined>(undefined);
|
||||||
const [currentBook, setCurrentBook] = useState<BookProps | null>(null);
|
const [currentBook, setCurrentBook] = useState<BookProps | null>(null);
|
||||||
const [bookSettingId, setBookSettingId] = useState<string>('');
|
const [bookSettingId, setBookSettingId] = useState<string>('');
|
||||||
|
const [showMigrationPopup, setShowMigrationPopup] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(function (): void {
|
||||||
|
if (!isDesktop) return;
|
||||||
|
const done: boolean = localStorage.getItem('electron_migration_done') === 'true';
|
||||||
|
const dismissed: boolean = localStorage.getItem('electron_migration_dismissed') === 'true';
|
||||||
|
if (!done && !dismissed) {
|
||||||
|
setShowMigrationPopup(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [serverSyncedBooks, setServerSyncedBooks] = useState<SyncedBook[]>([]);
|
const [serverSyncedBooks, setServerSyncedBooks] = useState<SyncedBook[]>([]);
|
||||||
const [localSyncedBooks, setLocalSyncedBooks] = useState<SyncedBook[]>([]);
|
const [localSyncedBooks, setLocalSyncedBooks] = useState<SyncedBook[]>([]);
|
||||||
@@ -426,6 +437,12 @@ function ScribeContent({children}: { children: ReactNode }) {
|
|||||||
onCancel={(): void => {}}
|
onCancel={(): void => {}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{showMigrationPopup && (
|
||||||
|
<MigrationModal
|
||||||
|
onClose={function (): void { setShowMigrationPopup(false); }}
|
||||||
|
onSuccess={function (): void { setShowMigrationPopup(false); window.location.reload(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</SettingBookContext.Provider>
|
</SettingBookContext.Provider>
|
||||||
</AIUsageContext.Provider>
|
</AIUsageContext.Provider>
|
||||||
</ChapterContext.Provider>
|
</ChapterContext.Provider>
|
||||||
|
|||||||
39
components/layout/ScribeTopBar.tsx
Normal file
39
components/layout/ScribeTopBar.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
const logo = "/eritors-favicon-white.png";
|
||||||
|
import React, {useContext} from "react";
|
||||||
|
import {BookContext, BookContextProps} from "@/context/BookContext";
|
||||||
|
import {useTranslations} from '@/lib/i18n';
|
||||||
|
|
||||||
|
export default function ScribeTopBar() {
|
||||||
|
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
|
||||||
|
const t = useTranslations();
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between px-6 py-3 bg-tertiary border-b border-secondary">
|
||||||
|
<div className="flex items-center space-x-4 group">
|
||||||
|
<div className="transition-transform duration-300">
|
||||||
|
<img src={logo} alt={t("scribeTopBar.logoAlt")} width={24} height={24}/>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="font-['ADLaM_Display'] text-xl tracking-wide text-text-primary">{t("scribeTopBar.scribe")}</span>
|
||||||
|
</div>
|
||||||
|
{book && (
|
||||||
|
<div
|
||||||
|
className="flex items-center space-x-3 bg-text-primary/10 backdrop-blur-sm px-4 py-2 rounded-lg border border-text-primary/20">
|
||||||
|
<div className="h-8 w-1 bg-text-primary/40 rounded-full"></div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-text-primary font-semibold text-base tracking-wide">
|
||||||
|
{book.title}
|
||||||
|
</p>
|
||||||
|
{book.subTitle && (
|
||||||
|
<p className="text-text-secondary text-xs italic mt-0.5">
|
||||||
|
{book.subTitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="h-8 w-1 bg-text-primary/40 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center space-x-2 min-w-[120px] justify-end">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
84
components/layout/UserMenu.tsx
Normal file
84
components/layout/UserMenu.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import React, {useContext, useEffect, useRef, useState} from "react";
|
||||||
|
import {SessionContext, SessionContextProps} from "@/context/SessionContext";
|
||||||
|
import AvatarIcon from "@/components/ui/AvatarIcon";
|
||||||
|
import {removeCookie} from '@/lib/utils/cookies';
|
||||||
|
import {isDesktop} from '@/lib/configs';
|
||||||
|
import * as tauri from '@/lib/tauri';
|
||||||
|
import {useTranslations} from '@/lib/i18n';
|
||||||
|
|
||||||
|
export default function UserMenu(): React.JSX.Element {
|
||||||
|
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
const profileMenuRef: React.RefObject<HTMLDivElement | null> = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState<boolean>(false);
|
||||||
|
|
||||||
|
function handleProfileClick(): void {
|
||||||
|
setIsProfileMenuOpen(!isProfileMenuOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect((): () => void => {
|
||||||
|
function handleClickOutside(event: MouseEvent): void {
|
||||||
|
if (profileMenuRef.current && event.target instanceof Node && !profileMenuRef.current.contains(event.target)) {
|
||||||
|
setIsProfileMenuOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isProfileMenuOpen) {
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (): void => {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [isProfileMenuOpen]);
|
||||||
|
|
||||||
|
function handleLogout(): void {
|
||||||
|
removeCookie("token");
|
||||||
|
if (isDesktop) {
|
||||||
|
tauri.logout();
|
||||||
|
} else {
|
||||||
|
document.location.href = "https://eritors.com/login";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" data-guide="user-dropdown" ref={profileMenuRef}>
|
||||||
|
<button
|
||||||
|
className="bg-secondary hover:bg-gray-dark p-1.5 rounded-full transition-colors duration-150 flex items-center border border-secondary hover:border-primary"
|
||||||
|
onClick={session.user ? handleProfileClick : (): void => {
|
||||||
|
document.location.href = "/login";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
session.user && (
|
||||||
|
<AvatarIcon
|
||||||
|
size="xs"
|
||||||
|
initial={session.user.username?.charAt(0).toUpperCase() ?? ''}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
{isProfileMenuOpen && (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 mt-3 w-56 bg-tertiary rounded-xl py-2 z-[100] border border-secondary backdrop-blur-sm animate-fadeIn">
|
||||||
|
<div
|
||||||
|
className="px-4 py-3 border-b border-secondary bg-gradient-to-r from-primary/10 to-transparent">
|
||||||
|
<p className="text-text-primary font-bold text-sm tracking-wide">{session.user?.username}</p>
|
||||||
|
<p className="text-text-secondary text-xs mt-0.5">{session.user?.email}</p>
|
||||||
|
</div>
|
||||||
|
<a href="https://eritors.com/settings"
|
||||||
|
className="group flex items-center gap-3 px-4 py-2.5 text-text-primary hover:bg-secondary transition-all hover:pl-5">
|
||||||
|
<span
|
||||||
|
className="text-sm font-medium group-hover:text-primary transition-colors">{t("userMenu.settings")}</span>
|
||||||
|
</a>
|
||||||
|
<a onClick={handleLogout} href="#"
|
||||||
|
className="group flex items-center gap-3 px-4 py-2.5 text-error hover:bg-error/10 transition-all hover:pl-5 rounded-b-xl">
|
||||||
|
<span className="text-sm font-medium">{t("userMenu.logout")}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
176
components/migration/MigrationModal.tsx
Normal file
176
components/migration/MigrationModal.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import React, {useState} from 'react';
|
||||||
|
import {ArrowRightLeft, CheckCircle, AlertTriangle, FolderOpen, Loader2} from 'lucide-react';
|
||||||
|
import Modal from '@/components/ui/Modal';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import {useTranslations} from '@/lib/i18n';
|
||||||
|
import * as tauri from '@/lib/tauri';
|
||||||
|
|
||||||
|
interface MigrationModalProps {
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MigrationStep = 'intro' | 'select' | 'importing' | 'success' | 'error';
|
||||||
|
|
||||||
|
export default function MigrationModal({onClose, onSuccess}: MigrationModalProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const [step, setStep] = useState<MigrationStep>('intro');
|
||||||
|
const [filePath, setFilePath] = useState<string>('');
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string>('');
|
||||||
|
const [migratedUserId, setMigratedUserId] = useState<string>('');
|
||||||
|
|
||||||
|
async function handleCheck(): Promise<void> {
|
||||||
|
if (!filePath.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result: tauri.MigrationCheckResult = await tauri.checkElectronMigration(filePath.trim());
|
||||||
|
if (!result.found) {
|
||||||
|
setErrorMsg(t('migration.fileNotFound'));
|
||||||
|
setStep('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!result.hasDb) {
|
||||||
|
setErrorMsg(t('migration.dbNotFound'));
|
||||||
|
setStep('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await handleImport();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setErrorMsg(e instanceof Error ? e.message : String(e));
|
||||||
|
setStep('error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImport(): Promise<void> {
|
||||||
|
setStep('importing');
|
||||||
|
try {
|
||||||
|
const result: tauri.MigrationResult = await tauri.importFromElectron(filePath.trim());
|
||||||
|
if (result.success) {
|
||||||
|
setMigratedUserId(result.userId || '');
|
||||||
|
setStep('success');
|
||||||
|
} else {
|
||||||
|
setErrorMsg(result.error || t('migration.importFailed'));
|
||||||
|
setStep('error');
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setErrorMsg(e instanceof Error ? e.message : String(e));
|
||||||
|
setStep('error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSuccessClose(): void {
|
||||||
|
localStorage.setItem('electron_migration_done', 'true');
|
||||||
|
onSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDismiss(): void {
|
||||||
|
localStorage.setItem('electron_migration_dismissed', 'true');
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={t('migration.title')}
|
||||||
|
icon={ArrowRightLeft}
|
||||||
|
size="md"
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
{step === 'intro' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-muted leading-relaxed">{t('migration.introText')}</p>
|
||||||
|
<div className="bg-tertiary rounded-xl p-4 space-y-2">
|
||||||
|
<p className="text-sm font-semibold text-text-primary">{t('migration.steps')}</p>
|
||||||
|
<ol className="list-decimal list-inside text-sm text-muted space-y-1">
|
||||||
|
<li>{t('migration.step1')}</li>
|
||||||
|
<li>{t('migration.step2')}</li>
|
||||||
|
<li>{t('migration.step3')}</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<Button variant="secondary" onClick={handleDismiss}>
|
||||||
|
{t('migration.later')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" icon={FolderOpen} onClick={function (): void {
|
||||||
|
setStep('select');
|
||||||
|
}}>
|
||||||
|
{t('migration.haveFile')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'select' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-muted text-sm">{t('migration.selectText')}</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-text-primary">
|
||||||
|
{t('migration.filePath')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={filePath}
|
||||||
|
onChange={function (e: React.ChangeEvent<HTMLInputElement>): void {
|
||||||
|
setFilePath(e.target.value);
|
||||||
|
}}
|
||||||
|
placeholder="/Users/.../Desktop/eritors-migration.json"
|
||||||
|
className="w-full px-4 py-2.5 rounded-xl bg-tertiary border border-secondary text-text-primary text-sm placeholder:text-muted/50 focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<Button variant="secondary" onClick={function (): void {
|
||||||
|
setStep('intro');
|
||||||
|
}}>
|
||||||
|
{t('migration.back')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={handleCheck} disabled={!filePath.trim()}>
|
||||||
|
{t('migration.import')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'importing' && (
|
||||||
|
<div className="flex flex-col items-center py-8 space-y-4">
|
||||||
|
<Loader2 className="w-10 h-10 text-primary animate-spin" strokeWidth={1.75}/>
|
||||||
|
<p className="text-muted">{t('migration.importing')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'success' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-col items-center py-6 space-y-3">
|
||||||
|
<CheckCircle className="w-12 h-12 text-success" strokeWidth={1.75}/>
|
||||||
|
<p className="text-text-primary font-semibold text-lg">{t('migration.successTitle')}</p>
|
||||||
|
<p className="text-muted text-sm text-center">{t('migration.successText')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end pt-2">
|
||||||
|
<Button variant="primary" onClick={handleSuccessClose}>
|
||||||
|
{t('migration.done')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'error' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-col items-center py-6 space-y-3">
|
||||||
|
<AlertTriangle className="w-12 h-12 text-error" strokeWidth={1.75}/>
|
||||||
|
<p className="text-text-primary font-semibold">{t('migration.errorTitle')}</p>
|
||||||
|
<p className="text-muted text-sm text-center">{errorMsg}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
{t('migration.close')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={function (): void {
|
||||||
|
setStep('select');
|
||||||
|
setErrorMsg('');
|
||||||
|
}}>
|
||||||
|
{t('migration.retry')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import {ExternalLink, Feather, Globe, Info, MapPin, MessageCircle, Users, Wand2, X} from 'lucide-react';
|
import {ArrowRightLeft, ExternalLink, Feather, Globe, Info, MapPin, MessageCircle, Users, Wand2, X} from 'lucide-react';
|
||||||
import React, {lazy, Suspense, useContext, useEffect, useState} from "react";
|
import React, {lazy, Suspense, useContext, useEffect, useState} from "react";
|
||||||
import {BookContext, BookContextProps} from "@/context/BookContext";
|
import {BookContext, BookContextProps} from "@/context/BookContext";
|
||||||
import {PanelComponent} from "@/lib/types/editor";
|
import {PanelComponent} from "@/lib/types/editor";
|
||||||
@@ -11,6 +11,8 @@ import PulseLoader from '@/components/ui/PulseLoader';
|
|||||||
import InsetPanel from "@/components/ui/InsetPanel";
|
import InsetPanel from "@/components/ui/InsetPanel";
|
||||||
import IconButton from "@/components/ui/IconButton";
|
import IconButton from "@/components/ui/IconButton";
|
||||||
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
|
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
|
||||||
|
import {isDesktop} from '@/lib/configs';
|
||||||
|
import MigrationModal from '@/components/migration/MigrationModal';
|
||||||
|
|
||||||
// Lazy loaded Editor components
|
// Lazy loaded Editor components
|
||||||
const WorldEditor = lazy(function () {
|
const WorldEditor = lazy(function () {
|
||||||
@@ -52,6 +54,11 @@ export default function ComposerRightBar(): React.JSX.Element {
|
|||||||
const [panelHidden, setPanelHidden] = useState<boolean>(false);
|
const [panelHidden, setPanelHidden] = useState<boolean>(false);
|
||||||
const [currentPanel, setCurrentPanel] = useState<PanelComponent | undefined>();
|
const [currentPanel, setCurrentPanel] = useState<PanelComponent | undefined>();
|
||||||
const [showAbout, setShowAbout] = useState<boolean>(false);
|
const [showAbout, setShowAbout] = useState<boolean>(false);
|
||||||
|
const [showMigration, setShowMigration] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const migrationDone: boolean = localStorage.getItem('electron_migration_done') === 'true';
|
||||||
|
const migrationDismissed: boolean = localStorage.getItem('electron_migration_dismissed') === 'true';
|
||||||
|
const showMigrationButton: boolean = isDesktop && !migrationDone;
|
||||||
|
|
||||||
function togglePanel(component: PanelComponent): void {
|
function togglePanel(component: PanelComponent): void {
|
||||||
if (panelHidden) {
|
if (panelHidden) {
|
||||||
@@ -103,6 +110,16 @@ export default function ComposerRightBar(): React.JSX.Element {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const homeComponents: PanelComponent[] = [
|
const homeComponents: PanelComponent[] = [
|
||||||
|
...(showMigrationButton ? [{
|
||||||
|
id: 0,
|
||||||
|
title: t("composerRightBar.homeComponents.migration.title"),
|
||||||
|
description: t("composerRightBar.homeComponents.migration.description"),
|
||||||
|
badge: 'IMPORT',
|
||||||
|
icon: ArrowRightLeft,
|
||||||
|
action: function (): void {
|
||||||
|
setShowMigration(true);
|
||||||
|
}
|
||||||
|
}] : []),
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
title: t("composerRightBar.homeComponents.about.title"),
|
title: t("composerRightBar.homeComponents.about.title"),
|
||||||
@@ -224,6 +241,10 @@ export default function ComposerRightBar(): React.JSX.Element {
|
|||||||
{showAbout && <AboutEditors onClose={function (): void {
|
{showAbout && <AboutEditors onClose={function (): void {
|
||||||
setShowAbout(false);
|
setShowAbout(false);
|
||||||
}}/>}
|
}}/>}
|
||||||
|
{showMigration && <MigrationModal
|
||||||
|
onClose={function (): void { setShowMigration(false); }}
|
||||||
|
onSuccess={function (): void { setShowMigration(false); window.location.reload(); }}
|
||||||
|
/>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
123
components/ui/AlertBox.tsx
Normal file
123
components/ui/AlertBox.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import React, {useEffect, useState} from 'react';
|
||||||
|
import {createPortal} from 'react-dom';
|
||||||
|
import {AlertTriangle, Check, Info, LucideIcon, X} from 'lucide-react';
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
|
||||||
|
export type AlertType = 'alert' | 'danger' | 'informatif' | 'success';
|
||||||
|
|
||||||
|
interface AlertBoxProps {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
type: AlertType;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
onConfirm: () => Promise<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlertConfig {
|
||||||
|
background: string;
|
||||||
|
borderColor: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
iconBg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AlertBox(
|
||||||
|
{
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
type,
|
||||||
|
confirmText = 'Confirmer',
|
||||||
|
cancelText = 'Annuler',
|
||||||
|
onConfirm,
|
||||||
|
onCancel
|
||||||
|
}: AlertBoxProps) {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
return () => setMounted(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function getButtonVariant(alertType: AlertType): 'warning' | 'danger' | 'info' | 'success' {
|
||||||
|
switch (alertType) {
|
||||||
|
case 'alert':
|
||||||
|
return 'warning';
|
||||||
|
case 'danger':
|
||||||
|
return 'danger';
|
||||||
|
case 'informatif':
|
||||||
|
return 'info';
|
||||||
|
case 'success':
|
||||||
|
default:
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAlertConfig(alertType: AlertType): AlertConfig {
|
||||||
|
switch (alertType) {
|
||||||
|
case 'alert':
|
||||||
|
return {
|
||||||
|
background: 'bg-warning',
|
||||||
|
borderColor: 'border-warning/30',
|
||||||
|
icon: AlertTriangle,
|
||||||
|
iconBg: 'bg-warning/10'
|
||||||
|
};
|
||||||
|
case 'danger':
|
||||||
|
return {
|
||||||
|
background: 'bg-error',
|
||||||
|
borderColor: 'border-error/30',
|
||||||
|
icon: X,
|
||||||
|
iconBg: 'bg-error/10'
|
||||||
|
};
|
||||||
|
case 'informatif':
|
||||||
|
return {
|
||||||
|
background: 'bg-info',
|
||||||
|
borderColor: 'border-info/30',
|
||||||
|
icon: Info,
|
||||||
|
iconBg: 'bg-info/10'
|
||||||
|
};
|
||||||
|
case 'success':
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
background: 'bg-success',
|
||||||
|
borderColor: 'border-success/30',
|
||||||
|
icon: Check,
|
||||||
|
iconBg: 'bg-success/10'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const alertSettings = getAlertConfig(type);
|
||||||
|
const AlertIcon = alertSettings.icon;
|
||||||
|
|
||||||
|
const alertContent = (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-darkest-background/60 backdrop-blur-md animate-fadeIn">
|
||||||
|
<div
|
||||||
|
className="relative w-full max-w-md rounded-2xl bg-tertiary overflow-hidden">
|
||||||
|
<div className={`${alertSettings.background} px-6 py-4`}>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div
|
||||||
|
className={`w-12 h-12 rounded-xl ${alertSettings.iconBg} flex items-center justify-center`}>
|
||||||
|
<AlertIcon className="w-6 h-6 text-text-primary" strokeWidth={1.75}/>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold text-text-primary tracking-wide">{title}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 bg-darkest-background">
|
||||||
|
<p className="mb-6 text-text-primary whitespace-pre-line leading-relaxed">{message}</p>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button variant="secondary" onClick={onCancel}>{cancelText}</Button>
|
||||||
|
<Button variant={getButtonVariant(type)} onClick={onConfirm}>{confirmText}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
return createPortal(alertContent, document.body);
|
||||||
|
}
|
||||||
44
components/ui/AlertStack.tsx
Normal file
44
components/ui/AlertStack.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {createPortal} from 'react-dom';
|
||||||
|
import StaticAlert from '@/components/ui/StaticAlert';
|
||||||
|
import {Alert} from '@/context/AlertProvider';
|
||||||
|
|
||||||
|
interface AlertStackProps {
|
||||||
|
alerts: Alert[];
|
||||||
|
onClose: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AlertStack({alerts, onClose}: AlertStackProps) {
|
||||||
|
const [mounted, setMounted] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
return () => setMounted(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
const alertContent = (
|
||||||
|
<div className="fixed top-4 right-4 z-50 flex flex-col gap-3 pointer-events-none">
|
||||||
|
{alerts.map((alert, index) => (
|
||||||
|
<div
|
||||||
|
key={alert.id}
|
||||||
|
className="pointer-events-auto alert-slide-in"
|
||||||
|
ref={(el: HTMLDivElement | null): void => {
|
||||||
|
if (el) el.style.animationDelay = `${index * 50}ms`;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StaticAlert
|
||||||
|
type={alert.type}
|
||||||
|
message={alert.message}
|
||||||
|
onClose={() => onClose(alert.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return createPortal(alertContent, document.body);
|
||||||
|
}
|
||||||
65
components/ui/AvatarIcon.tsx
Normal file
65
components/ui/AvatarIcon.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {LucideIcon} from 'lucide-react';
|
||||||
|
|
||||||
|
type AvatarSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
|
||||||
|
interface AvatarIconProps {
|
||||||
|
size?: AvatarSize;
|
||||||
|
image?: string | null;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
initial?: string;
|
||||||
|
alt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClasses: Record<AvatarSize, string> = {
|
||||||
|
xs: 'w-7 h-7',
|
||||||
|
sm: 'w-10 h-10',
|
||||||
|
md: 'w-12 h-12',
|
||||||
|
lg: 'w-14 h-14',
|
||||||
|
xl: 'w-16 h-16',
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconSizeClasses: Record<AvatarSize, string> = {
|
||||||
|
xs: 'w-3.5 h-3.5',
|
||||||
|
sm: 'w-5 h-5',
|
||||||
|
md: 'w-6 h-6',
|
||||||
|
lg: 'w-7 h-7',
|
||||||
|
xl: 'w-8 h-8',
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialSizeClasses: Record<AvatarSize, string> = {
|
||||||
|
xs: 'text-xs',
|
||||||
|
sm: 'text-sm',
|
||||||
|
md: 'text-base',
|
||||||
|
lg: 'text-lg',
|
||||||
|
xl: 'text-xl',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AvatarIcon({
|
||||||
|
size = 'md',
|
||||||
|
image,
|
||||||
|
icon: Icon,
|
||||||
|
initial,
|
||||||
|
alt = '',
|
||||||
|
}: AvatarIconProps): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${sizeClasses[size]} rounded-full border-2 border-primary overflow-hidden bg-secondary transition-colors flex items-center justify-center`}
|
||||||
|
>
|
||||||
|
{image ? (
|
||||||
|
<img
|
||||||
|
src={image}
|
||||||
|
alt={alt}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : Icon ? (
|
||||||
|
<Icon className={`text-primary ${iconSizeClasses[size]}`} strokeWidth={1.75}/>
|
||||||
|
) : initial ? (
|
||||||
|
<div
|
||||||
|
className={`w-full h-full flex items-center justify-center bg-primary/10 text-primary font-bold ${initialSizeClasses[size]}`}>
|
||||||
|
{initial}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
components/ui/Badge.tsx
Normal file
61
components/ui/Badge.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import React, {ReactNode} from "react";
|
||||||
|
import {LucideIcon} from 'lucide-react';
|
||||||
|
|
||||||
|
type BadgeVariant = 'primary' | 'success' | 'warning' | 'error' | 'muted';
|
||||||
|
type BadgeSize = 'sm' | 'md';
|
||||||
|
type BadgeShape = 'pill' | 'rounded';
|
||||||
|
|
||||||
|
interface BadgeProps {
|
||||||
|
variant?: BadgeVariant;
|
||||||
|
size?: BadgeSize;
|
||||||
|
shape?: BadgeShape;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
children?: ReactNode;
|
||||||
|
interactive?: boolean;
|
||||||
|
floating?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantClasses: Record<BadgeVariant, string> = {
|
||||||
|
primary: 'bg-primary/20 text-primary border border-primary/30',
|
||||||
|
success: 'bg-success/20 text-success border border-success/30',
|
||||||
|
warning: 'bg-warning/20 text-warning border border-warning/30',
|
||||||
|
error: 'bg-error/20 text-error border border-error/30',
|
||||||
|
muted: 'bg-secondary text-muted border border-secondary',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeClasses: Record<BadgeSize, string> = {
|
||||||
|
sm: 'text-[10px] px-2 py-0.5',
|
||||||
|
md: 'text-xs px-3 py-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
const shapeClasses: Record<BadgeShape, string> = {
|
||||||
|
pill: 'rounded-full',
|
||||||
|
rounded: 'rounded-lg',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Badge(
|
||||||
|
{
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
shape = 'pill',
|
||||||
|
icon: Icon,
|
||||||
|
children,
|
||||||
|
interactive = false,
|
||||||
|
floating = false,
|
||||||
|
}: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`
|
||||||
|
font-medium inline-flex items-center gap-1.5
|
||||||
|
${shapeClasses[shape]}
|
||||||
|
${variantClasses[variant]}
|
||||||
|
${sizeClasses[size]}
|
||||||
|
${interactive ? 'cursor-pointer group transition-colors' : ''}
|
||||||
|
${floating ? 'absolute top-3 right-3' : ''}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{Icon && <Icon className="w-3 h-3" strokeWidth={1.75}/>}
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
components/ui/Button.tsx
Normal file
84
components/ui/Button.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import React, {ReactNode} from "react";
|
||||||
|
import {Loader2, LucideIcon} from "lucide-react";
|
||||||
|
|
||||||
|
type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost' | 'warning' | 'info' | 'success' | 'dashed';
|
||||||
|
type ButtonSize = 'sm' | 'md' | 'lg';
|
||||||
|
|
||||||
|
interface ButtonProps {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: ButtonSize;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
isLoading?: boolean;
|
||||||
|
loadingText?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
onClick?: () => void | Promise<void>;
|
||||||
|
type?: 'button' | 'submit';
|
||||||
|
fullWidth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantClasses: Record<ButtonVariant, string> = {
|
||||||
|
primary: 'bg-primary-dark text-text-primary hover:bg-primary',
|
||||||
|
secondary: 'bg-secondary text-text-primary border border-secondary hover:bg-gray-dark hover:border-gray-dark',
|
||||||
|
danger: 'bg-error text-text-primary hover:bg-error/80',
|
||||||
|
warning: 'bg-warning text-text-primary hover:bg-warning/80',
|
||||||
|
info: 'bg-info text-text-primary hover:bg-info/80',
|
||||||
|
success: 'bg-success text-text-primary hover:bg-success/80',
|
||||||
|
ghost: 'text-muted hover:text-primary hover:bg-primary/10',
|
||||||
|
dashed: 'border-2 border-dashed border-secondary bg-dark-background hover:bg-tertiary text-text-primary',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeClasses: Record<ButtonSize, string> = {
|
||||||
|
sm: 'px-3 py-1.5 text-xs rounded-lg gap-1.5',
|
||||||
|
md: 'px-5 py-2.5 text-sm rounded-xl gap-2',
|
||||||
|
lg: 'px-6 py-3 text-base rounded-xl gap-2',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Button(
|
||||||
|
{
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
icon: Icon,
|
||||||
|
isLoading = false,
|
||||||
|
loadingText,
|
||||||
|
disabled = false,
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
type = 'button',
|
||||||
|
fullWidth = false,
|
||||||
|
}: ButtonProps) {
|
||||||
|
const isDisabled: boolean = disabled || isLoading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type={type}
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={`
|
||||||
|
font-semibold transition-all duration-200
|
||||||
|
flex items-center justify-center relative overflow-hidden
|
||||||
|
${variantClasses[variant]}
|
||||||
|
${sizeClasses[size]}
|
||||||
|
${isDisabled ? 'opacity-50 cursor-not-allowed' : ''}
|
||||||
|
${fullWidth ? 'w-full' : ''}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`flex items-center gap-2 transition-opacity duration-200 ${isLoading ? 'opacity-0' : 'opacity-100'}`}>
|
||||||
|
{Icon && (
|
||||||
|
<Icon className="w-4 h-4" strokeWidth={1.75}/>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" strokeWidth={1.75}/>
|
||||||
|
{loadingText && (
|
||||||
|
<span className="ml-2 text-sm font-medium">{loadingText}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
components/ui/Collapse.tsx
Normal file
65
components/ui/Collapse.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import React, {ReactNode, useState} from "react";
|
||||||
|
import {ChevronDown, ChevronUp, LucideIcon} from 'lucide-react';
|
||||||
|
import IconContainer from "@/components/ui/IconContainer";
|
||||||
|
|
||||||
|
type CollapseVariant = 'default' | 'card';
|
||||||
|
|
||||||
|
export interface CollapseProps {
|
||||||
|
title: string;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
variant?: CollapseVariant;
|
||||||
|
content?: ReactNode;
|
||||||
|
children?: ReactNode;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Collapse(
|
||||||
|
{
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
variant = 'default',
|
||||||
|
content,
|
||||||
|
children,
|
||||||
|
defaultOpen = false,
|
||||||
|
}: CollapseProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState<boolean>(defaultOpen);
|
||||||
|
const renderContent: ReactNode = children || content;
|
||||||
|
const isCard: boolean = variant === 'card';
|
||||||
|
const ChevronIcon = isOpen ? ChevronUp : ChevronDown;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`mb-4 transition-colors duration-150 ${isCard ? 'bg-tertiary rounded-xl' : ''}`}>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className={`
|
||||||
|
w-full text-left transition-all duration-200 p-4 flex items-center justify-between group
|
||||||
|
${isCard
|
||||||
|
? ''
|
||||||
|
: `bg-secondary hover:bg-gray-dark ${isOpen ? 'rounded-t-xl' : 'rounded-xl'}`
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{icon && (
|
||||||
|
<div className="transition-colors duration-150">
|
||||||
|
<IconContainer icon={icon} size="sm" shape="circle" filled/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="text-text-primary font-bold">{title}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronIcon className="text-primary w-4 h-4 transition-transform"
|
||||||
|
strokeWidth={1.75}/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className={`animate-fadeIn ${isCard
|
||||||
|
? 'p-3 m-4 mt-0 bg-darkest-background rounded-lg'
|
||||||
|
: 'bg-darkest-background border-l-4 border-primary p-4 rounded-b-xl'
|
||||||
|
}`}>
|
||||||
|
{renderContent}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
components/ui/CreditMeters.tsx
Normal file
29
components/ui/CreditMeters.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import React, {useContext} from "react";
|
||||||
|
import {Coins, DollarSign} from "lucide-react";
|
||||||
|
import {AIUsageContext, AIUsageContextProps} from "@/context/AIUsageContext";
|
||||||
|
|
||||||
|
export default function CreditCounter({isCredit}: { isCredit: boolean }) {
|
||||||
|
const {totalCredits, totalPrice}: AIUsageContextProps = useContext<AIUsageContextProps>(AIUsageContext)
|
||||||
|
|
||||||
|
if (isCredit) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center space-x-2 bg-darkest-background rounded-lg px-3 py-1.5 border border-secondary">
|
||||||
|
<Coins className="w-4 h-4 text-warning" strokeWidth={1.75}/>
|
||||||
|
<span className="text-sm text-text-primary font-medium">
|
||||||
|
{Math.round(totalCredits)} crédits
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center space-x-2 bg-darkest-background rounded-lg px-3 py-1.5 border border-secondary">
|
||||||
|
<DollarSign className="w-4 h-4 text-primary" strokeWidth={1.75}/>
|
||||||
|
<span className="text-sm text-text-primary font-medium">
|
||||||
|
{totalPrice ? totalPrice.toFixed(2) : '0.00'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
components/ui/DetailField.tsx
Normal file
51
components/ui/DetailField.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {LucideIcon} from 'lucide-react';
|
||||||
|
|
||||||
|
type DetailFieldVariant = 'default' | 'compact';
|
||||||
|
|
||||||
|
interface DetailFieldProps {
|
||||||
|
label: string;
|
||||||
|
value: string | number | null | undefined;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
variant?: DetailFieldVariant;
|
||||||
|
preserveWhitespace?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DetailField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
icon,
|
||||||
|
variant = 'default',
|
||||||
|
preserveWhitespace = true,
|
||||||
|
}: DetailFieldProps): React.JSX.Element | null {
|
||||||
|
if (variant === 'compact' && !value) return null;
|
||||||
|
|
||||||
|
if (variant === 'compact') {
|
||||||
|
return (
|
||||||
|
<div className="mb-3">
|
||||||
|
<span className="text-text-secondary text-xs block mb-1">{label}</span>
|
||||||
|
<p className={`text-text-primary text-sm ${preserveWhitespace ? 'whitespace-pre-wrap' : ''}`}>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderIcon(): React.JSX.Element | null {
|
||||||
|
if (!icon) return null;
|
||||||
|
const Icon: LucideIcon = icon;
|
||||||
|
return <Icon className="w-4 h-4 text-primary" strokeWidth={1.75}/>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-5 bg-secondary rounded-xl">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
{renderIcon()}
|
||||||
|
<h3 className="text-text-primary font-semibold">{label}</h3>
|
||||||
|
</div>
|
||||||
|
<p className={`${preserveWhitespace ? 'whitespace-pre-wrap' : ''} ${value ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
|
||||||
|
{value || '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
components/ui/DetailHeroSection.tsx
Normal file
26
components/ui/DetailHeroSection.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import React, {ReactNode} from 'react';
|
||||||
|
import {LucideIcon} from 'lucide-react';
|
||||||
|
import IconContainer from "@/components/ui/IconContainer";
|
||||||
|
|
||||||
|
interface DetailHeroSectionProps {
|
||||||
|
icon: LucideIcon;
|
||||||
|
title: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DetailHeroSection({icon, title, children}: DetailHeroSectionProps): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="p-6 bg-gradient-to-r from-primary/10 via-darkest-background to-transparent rounded-2xl border border-secondary">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="shrink-0">
|
||||||
|
<IconContainer icon={icon} size="lg" shape="square"/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h2 className="text-2xl font-bold text-text-primary">{title}</h2>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
components/ui/Dropdown.tsx
Normal file
89
components/ui/Dropdown.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import React, {ReactNode, useEffect, useRef, useState} from "react";
|
||||||
|
import {LucideIcon} from "lucide-react";
|
||||||
|
|
||||||
|
interface DropdownItem {
|
||||||
|
label: string;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
onClick: () => void;
|
||||||
|
variant?: 'default' | 'danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DropdownProps {
|
||||||
|
trigger: ReactNode;
|
||||||
|
items: DropdownItem[];
|
||||||
|
align?: 'left' | 'right';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Dropdown(
|
||||||
|
{
|
||||||
|
trigger,
|
||||||
|
items,
|
||||||
|
align = 'left',
|
||||||
|
}: DropdownProps): React.JSX.Element {
|
||||||
|
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||||
|
const dropdownRef: React.RefObject<HTMLDivElement | null> = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(function handleClickOutside() {
|
||||||
|
function onClickOutside(event: MouseEvent): void {
|
||||||
|
if (dropdownRef.current && event.target instanceof Node && !dropdownRef.current.contains(event.target)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', onClickOutside);
|
||||||
|
return (): void => document.removeEventListener('mousedown', onClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function handleItemClick(item: DropdownItem): void {
|
||||||
|
item.onClick();
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={dropdownRef} className="relative">
|
||||||
|
<div onClick={(): void => setIsOpen(!isOpen)}>
|
||||||
|
{trigger}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
absolute top-full mt-2 z-50 min-w-48
|
||||||
|
bg-tertiary rounded-xl py-2
|
||||||
|
border border-secondary backdrop-blur-sm animate-fadeIn
|
||||||
|
${align === 'right' ? 'right-0' : 'left-0'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{items.map(function renderDropdownItem(item: DropdownItem, index: number) {
|
||||||
|
const isDanger: boolean = item.variant === 'danger';
|
||||||
|
const ItemIcon: LucideIcon | undefined = item.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={(): void => handleItemClick(item)}
|
||||||
|
className={`
|
||||||
|
group w-full flex items-center gap-3 px-4 py-2.5
|
||||||
|
transition-all hover:pl-5
|
||||||
|
${isDanger
|
||||||
|
? 'text-error hover:bg-error/10'
|
||||||
|
: 'text-text-primary hover:bg-secondary'
|
||||||
|
}
|
||||||
|
${index === 0 ? 'rounded-t-xl' : ''}
|
||||||
|
${index === items.length - 1 ? 'rounded-b-xl' : ''}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{ItemIcon && (
|
||||||
|
<ItemIcon
|
||||||
|
className={`w-4 h-4 ${isDanger ? 'text-error' : 'text-muted group-hover:text-primary'}`}
|
||||||
|
strokeWidth={1.75}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="font-medium text-sm">{item.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
components/ui/EmptyState.tsx
Normal file
33
components/ui/EmptyState.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import React, {ReactNode} from "react";
|
||||||
|
import {LucideIcon} from "lucide-react";
|
||||||
|
import IconContainer from "@/components/ui/IconContainer";
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
icon: LucideIcon;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
action?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EmptyState(
|
||||||
|
{
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
action,
|
||||||
|
}: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center py-12 text-center p-8">
|
||||||
|
<div className="mb-6">
|
||||||
|
<IconContainer icon={icon} size="xl" shape="rounded"/>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-['ADLaM_Display'] text-text-primary mb-3">{title}</h3>
|
||||||
|
{description && (
|
||||||
|
<p className="text-muted max-w-md text-lg leading-relaxed">{description}</p>
|
||||||
|
)}
|
||||||
|
{action && (
|
||||||
|
<div className="mt-6">{action}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
components/ui/EntityListItem.tsx
Normal file
86
components/ui/EntityListItem.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import React, {ReactNode} from 'react';
|
||||||
|
import {ChevronRight} from 'lucide-react';
|
||||||
|
|
||||||
|
type EntityListItemSize = 'sm' | 'md';
|
||||||
|
type EntityListItemVariant = 'default' | 'transparent';
|
||||||
|
|
||||||
|
interface EntityListItemProps {
|
||||||
|
onClick: () => void;
|
||||||
|
avatar: ReactNode;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string | null;
|
||||||
|
extra?: ReactNode;
|
||||||
|
size?: EntityListItemSize;
|
||||||
|
variant?: EntityListItemVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowClasses: Record<EntityListItemSize, Record<EntityListItemVariant, string>> = {
|
||||||
|
sm: {
|
||||||
|
default: 'group flex items-center p-3 bg-tertiary rounded-lg cursor-pointer hover:bg-secondary transition-colors duration-150',
|
||||||
|
transparent: 'group flex items-center p-3 rounded-lg cursor-pointer hover:bg-dark-background transition-colors duration-150',
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
default: 'group flex items-center p-4 bg-tertiary rounded-xl cursor-pointer hover:bg-secondary transition-colors duration-150',
|
||||||
|
transparent: 'group flex items-center p-4 rounded-xl cursor-pointer hover:bg-dark-background transition-colors duration-150',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const contentClasses: Record<EntityListItemSize, { margin: string; title: string; subtitle: string }> = {
|
||||||
|
sm: {
|
||||||
|
margin: 'ml-3',
|
||||||
|
title: 'text-text-primary font-semibold text-sm group-hover:text-primary transition-colors truncate',
|
||||||
|
subtitle: 'text-muted text-xs truncate',
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
margin: 'ml-4',
|
||||||
|
title: 'text-text-primary font-bold text-base group-hover:text-primary transition-colors',
|
||||||
|
subtitle: 'text-text-secondary text-sm mt-0.5 truncate',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const chevronClasses: Record<EntityListItemSize, { container: string; icon: string }> = {
|
||||||
|
sm: {
|
||||||
|
container: 'w-6 flex justify-center',
|
||||||
|
icon: 'text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-3 h-3',
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
container: 'w-8 flex justify-center',
|
||||||
|
icon: 'text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-4 h-4',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function EntityListItem({
|
||||||
|
onClick,
|
||||||
|
avatar,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
extra,
|
||||||
|
size = 'md',
|
||||||
|
variant = 'default',
|
||||||
|
}: EntityListItemProps): React.JSX.Element {
|
||||||
|
const content: { margin: string; title: string; subtitle: string } = contentClasses[size];
|
||||||
|
const chevron: { container: string; icon: string } = chevronClasses[size];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div onClick={onClick} className={rowClasses[size][variant]}>
|
||||||
|
{avatar}
|
||||||
|
|
||||||
|
<div className={`${content.margin} flex-1 min-w-0`}>
|
||||||
|
<div className={content.title}>{title}</div>
|
||||||
|
{subtitle && (
|
||||||
|
<div className={content.subtitle}>{subtitle}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{extra && (
|
||||||
|
<div className="px-3">
|
||||||
|
{extra}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={chevron.container}>
|
||||||
|
<ChevronRight className={chevron.icon} strokeWidth={1.75}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
components/ui/IconButton.tsx
Normal file
69
components/ui/IconButton.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {LucideIcon} from "lucide-react";
|
||||||
|
|
||||||
|
type IconButtonVariant = 'primary' | 'danger' | 'ghost' | 'muted' | 'light';
|
||||||
|
type IconButtonSize = 'sm' | 'md' | 'lg';
|
||||||
|
type IconButtonShape = 'circle' | 'square';
|
||||||
|
|
||||||
|
interface IconButtonProps {
|
||||||
|
icon: LucideIcon;
|
||||||
|
variant?: IconButtonVariant;
|
||||||
|
size?: IconButtonSize;
|
||||||
|
shape?: IconButtonShape;
|
||||||
|
onClick?: () => void | Promise<void>;
|
||||||
|
disabled?: boolean;
|
||||||
|
selected?: boolean;
|
||||||
|
tooltip?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantClasses: Record<IconButtonVariant, string> = {
|
||||||
|
primary: 'text-muted hover:text-primary hover:bg-primary/10',
|
||||||
|
danger: 'text-muted hover:text-error hover:bg-error/10',
|
||||||
|
ghost: 'text-text-secondary hover:text-text-primary hover:bg-secondary',
|
||||||
|
muted: 'bg-secondary hover:bg-gray-dark text-text-secondary hover:text-text-primary',
|
||||||
|
light: 'text-text-primary/80 hover:text-text-primary hover:bg-text-primary/10',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeClasses: Record<IconButtonSize, { button: string; icon: string }> = {
|
||||||
|
sm: {button: 'p-1.5', icon: 'w-4 h-4'},
|
||||||
|
md: {button: 'p-2', icon: 'w-5 h-5'},
|
||||||
|
lg: {button: 'p-2', icon: 'w-6 h-6'},
|
||||||
|
};
|
||||||
|
|
||||||
|
const shapeClasses: Record<IconButtonShape, string> = {
|
||||||
|
circle: 'rounded-full',
|
||||||
|
square: 'rounded-lg',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function IconButton(
|
||||||
|
{
|
||||||
|
icon: Icon,
|
||||||
|
variant = 'ghost',
|
||||||
|
size = 'md',
|
||||||
|
shape = 'square',
|
||||||
|
onClick,
|
||||||
|
disabled = false,
|
||||||
|
selected = false,
|
||||||
|
tooltip,
|
||||||
|
}: IconButtonProps) {
|
||||||
|
const sizeConfig = sizeClasses[size];
|
||||||
|
const selectedClass: string = selected ? 'bg-secondary text-primary' : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
title={tooltip}
|
||||||
|
className={`
|
||||||
|
flex items-center justify-center
|
||||||
|
transition-all duration-200
|
||||||
|
${shapeClasses[shape]}
|
||||||
|
${selected ? selectedClass : variantClasses[variant]}
|
||||||
|
${sizeConfig.button}
|
||||||
|
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<Icon className={sizeConfig.icon} strokeWidth={2.25}/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
components/ui/IconContainer.tsx
Normal file
47
components/ui/IconContainer.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {LucideIcon} from "lucide-react";
|
||||||
|
|
||||||
|
type IconContainerSize = 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
type IconContainerShape = 'square' | 'rounded' | 'circle';
|
||||||
|
|
||||||
|
interface IconContainerProps {
|
||||||
|
icon: LucideIcon;
|
||||||
|
size?: IconContainerSize;
|
||||||
|
shape?: IconContainerShape;
|
||||||
|
filled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClasses: Record<IconContainerSize, { container: string; icon: string }> = {
|
||||||
|
sm: {container: 'w-10 h-10', icon: 'w-4 h-4'},
|
||||||
|
md: {container: 'w-12 h-12', icon: 'w-5 h-5'},
|
||||||
|
lg: {container: 'w-16 h-16', icon: 'w-8 h-8'},
|
||||||
|
xl: {container: 'w-20 h-20', icon: 'w-10 h-10'},
|
||||||
|
};
|
||||||
|
|
||||||
|
const shapeClasses: Record<IconContainerShape, string> = {
|
||||||
|
square: 'rounded-lg',
|
||||||
|
rounded: 'rounded-2xl',
|
||||||
|
circle: 'rounded-full',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function IconContainer(
|
||||||
|
{
|
||||||
|
icon: Icon,
|
||||||
|
size = 'sm',
|
||||||
|
shape = 'square',
|
||||||
|
filled = false,
|
||||||
|
}: IconContainerProps) {
|
||||||
|
const sizeConfig = sizeClasses[size];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
${filled ? 'bg-primary' : 'bg-primary/10'} flex items-center justify-center
|
||||||
|
${sizeConfig.container}
|
||||||
|
${shapeClasses[shape]}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<Icon className={`${filled ? 'text-text-primary' : 'text-primary'} ${sizeConfig.icon}`} strokeWidth={1.75}/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
components/ui/IconLabel.tsx
Normal file
16
components/ui/IconLabel.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {LucideIcon} from "lucide-react";
|
||||||
|
|
||||||
|
interface IconLabelProps {
|
||||||
|
icon: LucideIcon;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IconLabel({icon: Icon, children}: IconLabelProps): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Icon className="w-3 h-3 text-muted" strokeWidth={1.75}/>
|
||||||
|
<span>{children}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
components/ui/InsetPanel.tsx
Normal file
14
components/ui/InsetPanel.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import React, {ReactNode} from "react";
|
||||||
|
|
||||||
|
interface InsetPanelProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InsetPanel({children, className = ''}: InsetPanelProps): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className={`flex-1 bg-darkest-background rounded-xl m-1 overflow-auto flex flex-col ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
components/ui/ListItem.tsx
Normal file
121
components/ui/ListItem.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import {ArrowDown, ArrowUp, Check, LucideIcon, Pen, Trash2, X} from "lucide-react";
|
||||||
|
import React, {ChangeEvent, useState} from "react";
|
||||||
|
import IconContainer from "@/components/ui/IconContainer";
|
||||||
|
|
||||||
|
interface ListItemProps {
|
||||||
|
onClick: () => void;
|
||||||
|
selectedId: number | string;
|
||||||
|
id: number | string;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
numericalIdentifier?: number;
|
||||||
|
isEditable?: boolean;
|
||||||
|
text: string;
|
||||||
|
handleDelete?: (itemId: string) => void;
|
||||||
|
handleUpdate?: (itemId: string, newValue: string, subNewValue: number) => void;
|
||||||
|
onReorder?: (itemId: string, newOrder: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ListItem(
|
||||||
|
{
|
||||||
|
text,
|
||||||
|
selectedId,
|
||||||
|
id,
|
||||||
|
icon,
|
||||||
|
onClick,
|
||||||
|
isEditable = false,
|
||||||
|
handleDelete,
|
||||||
|
numericalIdentifier,
|
||||||
|
handleUpdate,
|
||||||
|
onReorder
|
||||||
|
}: ListItemProps): React.JSX.Element {
|
||||||
|
|
||||||
|
const [editMode, setEditMode] = useState<boolean>(false);
|
||||||
|
const [newName, setNewName] = useState<string>('');
|
||||||
|
const [newChapterOrder, setNewChapterOrder] = useState<number>(numericalIdentifier ?? 0);
|
||||||
|
|
||||||
|
function handleEdit(itemName: string): void {
|
||||||
|
setNewName(itemName)
|
||||||
|
setEditMode(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave(): void {
|
||||||
|
if (!handleUpdate) return;
|
||||||
|
handleUpdate(String(id), newName, newChapterOrder)
|
||||||
|
setEditMode(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveItem(direction: "up" | "down"): void {
|
||||||
|
const nextOrder: number = direction === "up" ? newChapterOrder - 1 : newChapterOrder + 1;
|
||||||
|
if (nextOrder < 0) return;
|
||||||
|
setNewChapterOrder(nextOrder);
|
||||||
|
if (onReorder) {
|
||||||
|
onReorder(String(id), nextOrder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className={`group relative flex items-center p-3 rounded-xl transition-colors duration-200 ${
|
||||||
|
selectedId === id
|
||||||
|
? 'bg-tertiary text-text-primary'
|
||||||
|
: 'hover:bg-tertiary'
|
||||||
|
}`}>
|
||||||
|
{(numericalIdentifier != null && newChapterOrder >= 0) && (
|
||||||
|
<span className="text-muted text-xs w-5 h-5 rounded-md bg-tertiary text-center leading-5 shrink-0 mr-2">
|
||||||
|
{newChapterOrder}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{icon && (
|
||||||
|
<IconContainer icon={icon} size="sm" shape="square"/>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center w-full gap-2">
|
||||||
|
{editMode ? (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newName || text}
|
||||||
|
onChange={(e: ChangeEvent<HTMLInputElement>): void => setNewName(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
className="flex-1 bg-transparent text-sm font-medium text-text-primary outline-none min-w-0"
|
||||||
|
/>
|
||||||
|
<div className="flex shrink-0">
|
||||||
|
<button onClick={(): void => moveItem('up')} disabled={newChapterOrder <= 0}
|
||||||
|
className="text-muted hover:text-text-primary disabled:opacity-30 p-0.5">
|
||||||
|
<ArrowUp className="w-3.5 h-3.5" strokeWidth={1.75}/>
|
||||||
|
</button>
|
||||||
|
<button onClick={(): void => moveItem('down')}
|
||||||
|
className="text-muted hover:text-text-primary p-0.5">
|
||||||
|
<ArrowDown className="w-3.5 h-3.5" strokeWidth={1.75}/>
|
||||||
|
</button>
|
||||||
|
<button onClick={handleSave}
|
||||||
|
className="text-primary hover:text-primary/80 p-0.5">
|
||||||
|
<Check className="w-3.5 h-3.5" strokeWidth={1.75}/>
|
||||||
|
</button>
|
||||||
|
<button onClick={(): void => setEditMode(false)}
|
||||||
|
className="text-muted hover:text-text-primary p-0.5">
|
||||||
|
<X className="w-3.5 h-3.5" strokeWidth={1.75}/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="cursor-pointer text-sm font-medium flex-1 group-hover:text-text-primary transition-colors"
|
||||||
|
onClick={onClick}>{text}</span>
|
||||||
|
)}
|
||||||
|
{!editMode && isEditable && (
|
||||||
|
<div className="absolute right-1 flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button onClick={(): void => handleEdit(text)}
|
||||||
|
className="text-muted hover:text-text-primary p-1.5 rounded-lg hover:bg-secondary">
|
||||||
|
<Pen className="w-4 h-4" strokeWidth={1.75}/>
|
||||||
|
</button>
|
||||||
|
<button onClick={(): void => {
|
||||||
|
if (handleDelete) handleDelete(id.toString());
|
||||||
|
}}
|
||||||
|
className="text-muted hover:text-error p-1.5 rounded-lg hover:bg-error/10">
|
||||||
|
<Trash2 className="w-4 h-4" strokeWidth={1.75}/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
30
components/ui/LockCard.tsx
Normal file
30
components/ui/LockCard.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {Lock} from 'lucide-react';
|
||||||
|
import IconContainer from "@/components/ui/IconContainer";
|
||||||
|
|
||||||
|
interface LockCardProps {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LockCard({title, description}: LockCardProps): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-tertiary">
|
||||||
|
<div className="flex-1 p-6 overflow-y-auto flex items-center justify-center">
|
||||||
|
<div className="max-w-md mx-auto">
|
||||||
|
<div className="bg-tertiary rounded-2xl p-8 text-center">
|
||||||
|
<div className="mx-auto mb-6">
|
||||||
|
<IconContainer icon={Lock} size="xl" shape="rounded" filled/>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-['ADLaM_Display'] text-text-primary mb-4">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted leading-relaxed text-lg">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
components/ui/Modal.tsx
Normal file
102
components/ui/Modal.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
'use client';
|
||||||
|
import React, {ReactNode, useEffect, useState} from 'react';
|
||||||
|
import {createPortal} from 'react-dom';
|
||||||
|
import {LucideIcon, X} from "lucide-react";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import IconButton from "@/components/ui/IconButton";
|
||||||
|
|
||||||
|
type ModalSize = 'sm' | 'md' | 'lg';
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
title: string;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
children: ReactNode;
|
||||||
|
size?: ModalSize;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
footer?: ReactNode;
|
||||||
|
actions?: ReactNode;
|
||||||
|
enableOverflow?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClasses: Record<ModalSize, string> = {
|
||||||
|
sm: 'md:w-3/4 xl:w-1/4 lg:w-2/4 sm:w-11/12',
|
||||||
|
md: 'md:w-3/4 xl:w-2/5 lg:w-2/4 sm:w-11/12',
|
||||||
|
lg: 'md:w-3/4 xl:w-3/5 lg:w-3/4 sm:w-11/12',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Modal(
|
||||||
|
{
|
||||||
|
title,
|
||||||
|
icon: Icon,
|
||||||
|
children,
|
||||||
|
size = 'md',
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
confirmText = 'Confirmer',
|
||||||
|
cancelText = 'Annuler',
|
||||||
|
footer,
|
||||||
|
actions,
|
||||||
|
enableOverflow = true,
|
||||||
|
}: ModalProps) {
|
||||||
|
const [mounted, setMounted] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect((): (() => void) => {
|
||||||
|
setMounted(true);
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return (): void => {
|
||||||
|
setMounted(false);
|
||||||
|
document.body.style.overflow = 'auto';
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hasFooter: boolean = !!footer || !!onConfirm;
|
||||||
|
|
||||||
|
const modalContent: React.JSX.Element = (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-darkest-background/60 backdrop-blur-md animate-fadeIn">
|
||||||
|
<div
|
||||||
|
className={`relative bg-tertiary text-text-primary rounded-xl max-h-[90vh] overflow-hidden flex flex-col ${sizeClasses[size]}`}>
|
||||||
|
<div className="flex justify-between items-center px-6 py-4">
|
||||||
|
<h2 className="flex items-center gap-3 font-['ADLaM_Display'] text-xl tracking-wide">
|
||||||
|
{Icon && <Icon className="w-6 h-6" strokeWidth={1.75}/>}
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{actions}
|
||||||
|
<IconButton icon={X} variant="light" onClick={onClose}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`flex-1 min-h-0 bg-darkest-background rounded-xl mx-2 flex flex-col overflow-hidden ${!hasFooter ? 'mb-2' : ''}`}>
|
||||||
|
<div
|
||||||
|
className={`flex-1 min-h-0 ${enableOverflow ? 'overflow-auto custom-scrollbar' : 'overflow-hidden'}`}>
|
||||||
|
<div className="p-5 space-y-6">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{hasFooter && (
|
||||||
|
<div className="flex justify-end gap-3 px-6 py-4">
|
||||||
|
{footer ? footer : (
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
{cancelText}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={onConfirm}>
|
||||||
|
{confirmText}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
return createPortal(modalContent, document.body);
|
||||||
|
}
|
||||||
30
components/ui/PulseLoader.tsx
Normal file
30
components/ui/PulseLoader.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {Loader2} from 'lucide-react';
|
||||||
|
|
||||||
|
type PulseLoaderSize = 'sm' | 'md' | 'lg';
|
||||||
|
|
||||||
|
interface PulseLoaderProps {
|
||||||
|
text?: string;
|
||||||
|
size?: PulseLoaderSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClasses: Record<PulseLoaderSize, { container: string; icon: string }> = {
|
||||||
|
sm: {container: 'py-8', icon: 'w-6 h-6'},
|
||||||
|
md: {container: 'h-32', icon: 'w-8 h-8'},
|
||||||
|
lg: {container: 'h-64', icon: 'w-10 h-10'},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PulseLoader({text, size = 'md'}: PulseLoaderProps): React.JSX.Element {
|
||||||
|
const sizeConfig: { container: string; icon: string } = sizeClasses[size];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center justify-center ${sizeConfig.container}`}>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<Loader2 className={`${sizeConfig.icon} text-primary animate-spin mb-3`} strokeWidth={1.75}/>
|
||||||
|
{text && (
|
||||||
|
<p className="text-text-secondary">{text}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
components/ui/QSTextGeneratedPreview.tsx
Normal file
98
components/ui/QSTextGeneratedPreview.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {RefreshCw, Send, Square} from 'lucide-react';
|
||||||
|
import {useTranslations} from '@/lib/i18n';
|
||||||
|
import IconButton from '@/components/ui/IconButton';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import Modal from '@/components/ui/Modal';
|
||||||
|
|
||||||
|
interface QSTextGeneratedPreviewProps {
|
||||||
|
onClose: () => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
value: string;
|
||||||
|
onInsert: () => void;
|
||||||
|
isGenerating?: boolean;
|
||||||
|
onStop?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QSTextGeneratedPreview(
|
||||||
|
{
|
||||||
|
onClose,
|
||||||
|
onRefresh,
|
||||||
|
value,
|
||||||
|
onInsert,
|
||||||
|
isGenerating = false,
|
||||||
|
onStop,
|
||||||
|
}: QSTextGeneratedPreviewProps) {
|
||||||
|
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
const filteredValue: string = value.replace(/^starting\.{0,3}\s*/i, '').trim();
|
||||||
|
const hasRealContent: boolean = filteredValue.length > 0;
|
||||||
|
|
||||||
|
const headerActions: React.ReactNode = isGenerating && onStop ? (
|
||||||
|
<IconButton icon={Square} variant="danger" onClick={onStop}/>
|
||||||
|
) : (
|
||||||
|
<IconButton icon={RefreshCw} variant="ghost" onClick={onRefresh}/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const footerContent: React.ReactNode = (
|
||||||
|
<Button variant="primary" onClick={onInsert} icon={Send}>
|
||||||
|
{t("qsTextPreview.insert")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={t("qsTextPreview.title")}
|
||||||
|
onClose={onClose}
|
||||||
|
size="md"
|
||||||
|
actions={headerActions}
|
||||||
|
footer={footerContent}
|
||||||
|
enableOverflow={true}
|
||||||
|
>
|
||||||
|
<div className="font-['Lora']">
|
||||||
|
{isGenerating && !hasRealContent ? (
|
||||||
|
<div className="space-y-3 animate-pulse">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<span className="h-5 bg-primary/20 rounded px-4"></span>
|
||||||
|
<span className="h-5 bg-primary/15 rounded px-6"></span>
|
||||||
|
<span className="h-5 bg-primary/20 rounded px-3"></span>
|
||||||
|
<span className="h-5 bg-primary/10 rounded px-8"></span>
|
||||||
|
<span className="h-5 bg-primary/20 rounded px-5"></span>
|
||||||
|
<span className="h-5 bg-primary/15 rounded px-4"></span>
|
||||||
|
<span className="h-5 bg-primary/20 rounded px-7"></span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<span className="h-5 bg-primary/15 rounded px-5"></span>
|
||||||
|
<span className="h-5 bg-primary/20 rounded px-3"></span>
|
||||||
|
<span className="h-5 bg-primary/10 rounded px-6"></span>
|
||||||
|
<span className="h-5 bg-primary/20 rounded px-4"></span>
|
||||||
|
<span className="h-5 bg-primary/15 rounded px-8"></span>
|
||||||
|
<span className="h-5 bg-primary/20 rounded px-3"></span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<span className="h-5 bg-primary/20 rounded px-6"></span>
|
||||||
|
<span className="h-5 bg-primary/10 rounded px-4"></span>
|
||||||
|
<span className="h-5 bg-primary/20 rounded px-5"></span>
|
||||||
|
<span className="h-5 bg-primary/15 rounded px-7"></span>
|
||||||
|
<span className="h-5 bg-primary/20 rounded px-3"></span>
|
||||||
|
<span className="h-5 bg-primary/10 rounded px-5"></span>
|
||||||
|
<span className="h-5 bg-primary/20 rounded px-4"></span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<span className="h-5 bg-primary/15 rounded px-4"></span>
|
||||||
|
<span className="h-5 bg-primary/20 rounded px-6"></span>
|
||||||
|
<span className="h-5 bg-primary/10 rounded px-3"></span>
|
||||||
|
<span className="h-5 bg-primary/20 rounded px-5"></span>
|
||||||
|
<span className="h-5 bg-primary/15 rounded px-7"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-text-primary text-justify leading-relaxed whitespace-pre-wrap fade-in-text">
|
||||||
|
{filteredValue}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
components/ui/SectionHeader.tsx
Normal file
43
components/ui/SectionHeader.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import React, {ReactNode} from "react";
|
||||||
|
import {LucideIcon} from "lucide-react";
|
||||||
|
import Badge from "@/components/ui/Badge";
|
||||||
|
import IconContainer from "@/components/ui/IconContainer";
|
||||||
|
|
||||||
|
interface SectionHeaderProps {
|
||||||
|
icon?: LucideIcon;
|
||||||
|
title: string;
|
||||||
|
badge?: string;
|
||||||
|
description?: string;
|
||||||
|
actions?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SectionHeader(
|
||||||
|
{
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
badge,
|
||||||
|
description,
|
||||||
|
actions,
|
||||||
|
}: SectionHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="shrink-0">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="text-xl text-text-primary font-bold flex items-center gap-3 flex-wrap">
|
||||||
|
{icon && <IconContainer icon={icon} size="sm"/>}
|
||||||
|
<span className="tracking-wide">{title}</span>
|
||||||
|
{badge && <Badge>{badge}</Badge>}
|
||||||
|
</h2>
|
||||||
|
{description && (
|
||||||
|
<p className="text-text-secondary text-xs mt-2 ml-13">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{actions && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
components/ui/SettingsPanel.tsx
Normal file
53
components/ui/SettingsPanel.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
'use client'
|
||||||
|
import {ReactNode, useEffect, useState} from "react";
|
||||||
|
import {X} from "lucide-react";
|
||||||
|
import {createPortal} from "react-dom";
|
||||||
|
|
||||||
|
interface SettingsPanelProps {
|
||||||
|
title: string;
|
||||||
|
sidebar: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsPanel({title, sidebar, children, onClose}: SettingsPanelProps) {
|
||||||
|
const [mounted, setMounted] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect((): void => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="fixed inset-0 z-40 bg-darkest-background/60 backdrop-blur-md flex items-center justify-center">
|
||||||
|
<div className="w-3/4 h-[85vh] bg-tertiary rounded-2xl border border-secondary flex flex-col">
|
||||||
|
|
||||||
|
<div className="px-6 py-4 rounded-t-2xl flex justify-between items-center">
|
||||||
|
<h2 className="font-['ADLaM_Display'] text-xl text-text-primary">{title}</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-text-primary/80 hover:text-text-primary p-2 rounded-lg hover:bg-text-primary/10 transition-all"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" strokeWidth={1.75}/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 min-h-0">
|
||||||
|
<div className="w-56 flex-shrink-0 overflow-y-auto">
|
||||||
|
{sidebar}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 p-2 pl-0">
|
||||||
|
<div className="h-full bg-darkest-background rounded-xl overflow-hidden">
|
||||||
|
<div className="h-full overflow-y-auto">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
73
components/ui/StaticAlert.tsx
Normal file
73
components/ui/StaticAlert.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
'use client'
|
||||||
|
import React, {useEffect, useState} from 'react';
|
||||||
|
import {CheckCircle, AlertCircle, Info, AlertTriangle, X, LucideIcon} from 'lucide-react';
|
||||||
|
|
||||||
|
interface StaticAlertProps {
|
||||||
|
type: 'success' | 'error' | 'info' | 'warning';
|
||||||
|
message: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconMap: Record<StaticAlertProps['type'], LucideIcon> = {
|
||||||
|
success: CheckCircle,
|
||||||
|
error: AlertCircle,
|
||||||
|
info: Info,
|
||||||
|
warning: AlertTriangle,
|
||||||
|
};
|
||||||
|
|
||||||
|
const accentColorMap: Record<StaticAlertProps['type'], string> = {
|
||||||
|
success: 'text-success border-l-success',
|
||||||
|
error: 'text-error border-l-error',
|
||||||
|
info: 'text-info border-l-info',
|
||||||
|
warning: 'text-warning border-l-warning',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StaticAlert({type, message, onClose}: StaticAlertProps) {
|
||||||
|
const [visible, setVisible] = useState<boolean>(false);
|
||||||
|
const onCloseRef = React.useRef(onClose);
|
||||||
|
|
||||||
|
useEffect((): void => {
|
||||||
|
onCloseRef.current = onClose;
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
useEffect((): (() => void) => {
|
||||||
|
setVisible(true);
|
||||||
|
const timer: ReturnType<typeof setTimeout> = setTimeout((): void => {
|
||||||
|
setVisible(false);
|
||||||
|
setTimeout((): void => onCloseRef.current(), 300);
|
||||||
|
}, 4800);
|
||||||
|
|
||||||
|
return (): void => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function handleClose(): void {
|
||||||
|
setVisible(false);
|
||||||
|
setTimeout((): void => onCloseRef.current(), 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
const AlertIcon: LucideIcon = iconMap[type];
|
||||||
|
const accentClasses: string = accentColorMap[type];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`max-w-sm rounded-xl transition-all duration-300 ease-in-out transform ${
|
||||||
|
visible ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'
|
||||||
|
} bg-darkest-background border-l-4 ${accentClasses}`}
|
||||||
|
>
|
||||||
|
<div className="px-4 py-3 flex items-center gap-3">
|
||||||
|
<AlertIcon className="w-5 h-5 flex-shrink-0" strokeWidth={2}/>
|
||||||
|
<span className="flex-grow text-text-primary text-sm font-medium">
|
||||||
|
{typeof message === 'string' ? message : String(message ?? 'Une erreur est survenue')}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="text-muted hover:text-text-primary p-1 rounded-lg hover:bg-secondary transition-all duration-200 flex-shrink-0"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" strokeWidth={2}/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
components/ui/ToggleGroup.tsx
Normal file
59
components/ui/ToggleGroup.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {LucideIcon} from "lucide-react";
|
||||||
|
|
||||||
|
interface ToggleOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToggleGroupSize = 'sm' | 'md';
|
||||||
|
|
||||||
|
interface ToggleGroupProps {
|
||||||
|
options: ToggleOption[];
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
size?: ToggleGroupSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClasses: Record<ToggleGroupSize, { wrapper: string; icon: string; button: string }> = {
|
||||||
|
sm: {wrapper: 'rounded-lg', icon: 'w-3.5 h-3.5 mx-2', button: 'px-2.5 py-1 text-xs'},
|
||||||
|
md: {wrapper: 'rounded-xl', icon: 'w-4 h-4 mx-2.5', button: 'px-3 py-1.5 text-sm'},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ToggleGroup(
|
||||||
|
{
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
icon: Icon,
|
||||||
|
size = 'sm'
|
||||||
|
}: ToggleGroupProps): React.JSX.Element {
|
||||||
|
const config = sizeClasses[size];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center bg-darkest-background ${config.wrapper} border border-secondary`}>
|
||||||
|
{Icon && (
|
||||||
|
<Icon className={`${config.icon} text-primary flex-shrink-0`} strokeWidth={1.75}/>
|
||||||
|
)}
|
||||||
|
{options.map(function (option: ToggleOption): React.JSX.Element {
|
||||||
|
const isActive: boolean = value === option.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
onClick={function (): void {
|
||||||
|
onChange(option.value);
|
||||||
|
}}
|
||||||
|
className={`${config.button} font-semibold transition-all duration-200 ${
|
||||||
|
isActive
|
||||||
|
? 'bg-primary text-text-primary rounded-lg'
|
||||||
|
: 'text-text-secondary hover:text-text-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
context/ThemeContext.ts
Normal file
14
context/ThemeContext.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import {Context, createContext} from "react";
|
||||||
|
|
||||||
|
export type SupportedTheme = 'dark' | 'light';
|
||||||
|
|
||||||
|
export interface ThemeContextProps {
|
||||||
|
theme: SupportedTheme;
|
||||||
|
setTheme: (theme: SupportedTheme) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ThemeContext: Context<ThemeContextProps> = createContext<ThemeContextProps>({
|
||||||
|
theme: 'dark',
|
||||||
|
setTheme: (): void => {
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -450,6 +450,79 @@ ipcMain.on('logout', ():void => {
|
|||||||
createLoginWindow();
|
createLoginWindow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ========== MIGRATION EXPORT (Electron → Tauri) ==========
|
||||||
|
|
||||||
|
ipcMain.handle('export-migration', async ():Promise<{success: boolean; path?: string; error?: string}> => {
|
||||||
|
const storage:SecureStorage = getSecureStorage();
|
||||||
|
const userId:string | null = storage.get<string>('userId', null);
|
||||||
|
const lastUserId:string | null = storage.get<string>('lastUserId', null);
|
||||||
|
|
||||||
|
const targetUserId:string | null = userId || lastUserId;
|
||||||
|
if (!targetUserId) {
|
||||||
|
return { success: false, error: 'No user found in storage' };
|
||||||
|
}
|
||||||
|
|
||||||
|
let encryptionKey:string | null = null;
|
||||||
|
try {
|
||||||
|
encryptionKey = getUserEncryptionKey(targetUserId);
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: 'Encryption key not found for this user' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pinHash:string | null = storage.get<string>(`pin-${targetUserId}`, null);
|
||||||
|
|
||||||
|
const userDataPath:string = app.getPath('userData');
|
||||||
|
const dbPath:string = path.join(userDataPath, 'eritors-local.db');
|
||||||
|
|
||||||
|
if (!fs.existsSync(dbPath)) {
|
||||||
|
return { success: false, error: 'No local database found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { filePath } = await dialog.showSaveDialog({
|
||||||
|
title: 'Export migration data',
|
||||||
|
defaultPath: path.join(app.getPath('desktop'), 'eritors-migration.json'),
|
||||||
|
filters: [{ name: 'Migration', extensions: ['json'] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
return { success: false, error: 'Export cancelled' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const migrationData = {
|
||||||
|
version: 1,
|
||||||
|
exported_at: Date.now(),
|
||||||
|
user_id: targetUserId,
|
||||||
|
encryption_key: encryptionKey,
|
||||||
|
pin_hash: pinHash,
|
||||||
|
db_source: dbPath,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(migrationData, null, 2), 'utf-8');
|
||||||
|
|
||||||
|
// Copier aussi la DB à côté du fichier de migration
|
||||||
|
const dbDestination:string = path.join(path.dirname(filePath), `eritors-local-${targetUserId}.db`);
|
||||||
|
fs.copyFileSync(dbPath, dbDestination);
|
||||||
|
|
||||||
|
// Copier WAL et SHM si existants
|
||||||
|
const walPath:string = dbPath + '-wal';
|
||||||
|
const shmPath:string = dbPath + '-shm';
|
||||||
|
if (fs.existsSync(walPath)) {
|
||||||
|
fs.copyFileSync(walPath, dbDestination + '-wal');
|
||||||
|
}
|
||||||
|
if (fs.existsSync(shmPath)) {
|
||||||
|
fs.copyFileSync(shmPath, dbDestination + '-shm');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, path: filePath };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Export failed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ========== USER SYNC (PRE-AUTHENTICATION) ==========
|
// ========== USER SYNC (PRE-AUTHENTICATION) ==========
|
||||||
|
|
||||||
interface SyncUserData {
|
interface SyncUserData {
|
||||||
|
|||||||
175
hooks/useOnboarding.tsx
Normal file
175
hooks/useOnboarding.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
'use client';
|
||||||
|
import {Dispatch, SetStateAction, useContext, useEffect, useState} from 'react';
|
||||||
|
import {useTranslations} from '@/lib/i18n';
|
||||||
|
import {AlertContext, AlertContextProps} from '@/context/AlertContext';
|
||||||
|
import {LangContext, LangContextProps} from '@/context/LangContext';
|
||||||
|
import {SessionProps} from '@/lib/types/session';
|
||||||
|
import {guideTourDone, setNewGuideTour} from '@/lib/utils/user';
|
||||||
|
import {apiPost} from '@/lib/api/client';
|
||||||
|
import {GuideStep} from '@/components/GuideTour';
|
||||||
|
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
|
||||||
|
|
||||||
|
interface UseOnboardingReturn {
|
||||||
|
isTermsAccepted: boolean;
|
||||||
|
homeStepsGuide: boolean;
|
||||||
|
setHomeStepsGuide: Dispatch<SetStateAction<boolean>>;
|
||||||
|
handleTermsAcceptance: () => Promise<void>;
|
||||||
|
handleHomeTour: () => Promise<void>;
|
||||||
|
homeSteps: GuideStep[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseOnboardingParams {
|
||||||
|
session: SessionProps;
|
||||||
|
setSession: Dispatch<SetStateAction<SessionProps>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useOnboarding({session, setSession}: UseOnboardingParams): UseOnboardingReturn {
|
||||||
|
const t = useTranslations();
|
||||||
|
const {lang: locale}: LangContextProps = useContext<LangContextProps>(LangContext);
|
||||||
|
const {errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
|
||||||
|
const {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
|
||||||
|
|
||||||
|
const [isTermsAccepted, setIsTermsAccepted] = useState<boolean>(false);
|
||||||
|
const [homeStepsGuide, setHomeStepsGuide] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect((): void => {
|
||||||
|
if (isCurrentlyOffline()) {
|
||||||
|
setIsTermsAccepted(true);
|
||||||
|
const localGuideDone: boolean = localStorage.getItem('guide-tour-home-basic') === 'true';
|
||||||
|
setHomeStepsGuide(!localGuideDone);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (session.isConnected) {
|
||||||
|
setIsTermsAccepted(session.user?.termsAccepted ?? false);
|
||||||
|
const guides: string[] = ['home-basic', 'new-first-book'];
|
||||||
|
for (const guide of guides) {
|
||||||
|
const done: boolean = !guideTourDone(session.user?.guideTour ?? [], guide);
|
||||||
|
localStorage.setItem(`guide-tour-${guide}`, done ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
setHomeStepsGuide(guideTourDone(session.user?.guideTour ?? [], 'home-basic'));
|
||||||
|
}
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
|
const homeSteps: GuideStep[] = [
|
||||||
|
{
|
||||||
|
id: 0,
|
||||||
|
x: 50,
|
||||||
|
y: 50,
|
||||||
|
title: t('homePage.guide.welcome', {name: session.user?.name || ''}),
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<p>{t('homePage.guide.step0.description1')}</p>
|
||||||
|
<br/>
|
||||||
|
<p>{t('homePage.guide.step0.description2')}</p>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1, position: 'right',
|
||||||
|
targetSelector: `[data-guide="left-panel-container"]`,
|
||||||
|
title: t('homePage.guide.step1.title'),
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<p className={'flex items-center space-x-2'}>
|
||||||
|
<strong>{t('homePage.guide.step1.addBook')}</strong>
|
||||||
|
</p>
|
||||||
|
<br/>
|
||||||
|
<p><strong>{t('homePage.guide.step1.generateStory')}</strong></p>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: t('homePage.guide.step2.title'), position: 'bottom',
|
||||||
|
targetSelector: `[data-guide="search-bar"]`,
|
||||||
|
content: (
|
||||||
|
<p>{t('homePage.guide.step2.description')}</p>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: t('homePage.guide.step3.title'),
|
||||||
|
targetSelector: `[data-guide="user-dropdown"]`,
|
||||||
|
position: 'auto',
|
||||||
|
content: (
|
||||||
|
<p>{t('homePage.guide.step3.description')}</p>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
title: t('homePage.guide.step4.title'),
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<p>{t('homePage.guide.step4.description1')}</p>
|
||||||
|
<br/>
|
||||||
|
<p>{t('homePage.guide.step4.description2')}</p>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function handleTermsAcceptance(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response: boolean = await apiPost<boolean>(`user/terms/accept`, {
|
||||||
|
version: '2025-07-1'
|
||||||
|
}, session.accessToken, locale);
|
||||||
|
if (response && session.user) {
|
||||||
|
setIsTermsAccepted(true);
|
||||||
|
setHomeStepsGuide(true);
|
||||||
|
const newSession: SessionProps = {
|
||||||
|
...session,
|
||||||
|
user: {
|
||||||
|
...session.user,
|
||||||
|
termsAccepted: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setSession(newSession);
|
||||||
|
} else {
|
||||||
|
errorMessage(t('homePage.errors.termsAcceptError'));
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
errorMessage(e.message);
|
||||||
|
} else {
|
||||||
|
errorMessage(t('homePage.errors.termsAcceptError'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleHomeTour(): Promise<void> {
|
||||||
|
if (isCurrentlyOffline()) {
|
||||||
|
localStorage.setItem('guide-tour-home-basic', 'true');
|
||||||
|
setHomeStepsGuide(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response: boolean = await apiPost<boolean>('logs/tour', {
|
||||||
|
plateforme: 'desktop',
|
||||||
|
tour: 'home-basic'
|
||||||
|
},
|
||||||
|
session.accessToken,
|
||||||
|
locale
|
||||||
|
);
|
||||||
|
if (response) {
|
||||||
|
localStorage.setItem('guide-tour-home-basic', 'true');
|
||||||
|
setSession(setNewGuideTour(session, 'home-basic'));
|
||||||
|
setHomeStepsGuide(false);
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
errorMessage(e.message);
|
||||||
|
} else {
|
||||||
|
errorMessage(t('homePage.errors.termsError'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isTermsAccepted,
|
||||||
|
homeStepsGuide,
|
||||||
|
setHomeStepsGuide,
|
||||||
|
handleTermsAcceptance,
|
||||||
|
handleHomeTour,
|
||||||
|
homeSteps,
|
||||||
|
};
|
||||||
|
}
|
||||||
30
hooks/useTheme.ts
Normal file
30
hooks/useTheme.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import {useCallback, useEffect, useState} from "react";
|
||||||
|
import {SupportedTheme} from "@/context/ThemeContext";
|
||||||
|
import {getCookie, setCookie} from "@/lib/utils/cookies";
|
||||||
|
|
||||||
|
interface UseThemeReturn {
|
||||||
|
theme: SupportedTheme;
|
||||||
|
setTheme: (theme: SupportedTheme) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useTheme(): UseThemeReturn {
|
||||||
|
const [theme, setThemeState] = useState<SupportedTheme>('dark');
|
||||||
|
|
||||||
|
useEffect(function loadSavedTheme(): void {
|
||||||
|
const savedTheme: string | null = getCookie('theme');
|
||||||
|
if (savedTheme === 'light' || savedTheme === 'dark') {
|
||||||
|
setThemeState(savedTheme);
|
||||||
|
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||||
|
} else {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'dark');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setTheme = useCallback(function applyTheme(newTheme: SupportedTheme): void {
|
||||||
|
setThemeState(newTheme);
|
||||||
|
setCookie('theme', newTheme, 365);
|
||||||
|
document.documentElement.setAttribute('data-theme', newTheme);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {theme, setTheme};
|
||||||
|
}
|
||||||
101
lib/crashReporter.ts
Normal file
101
lib/crashReporter.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import {configs, isDesktop} from '@/lib/configs';
|
||||||
|
|
||||||
|
interface CrashReportPayload {
|
||||||
|
appName: string;
|
||||||
|
appVersion: string;
|
||||||
|
platform: string;
|
||||||
|
osVersion?: string;
|
||||||
|
errorType: string;
|
||||||
|
errorMessage: string;
|
||||||
|
stackTrace?: string;
|
||||||
|
screenName?: string;
|
||||||
|
userId?: string;
|
||||||
|
breadcrumbs?: Breadcrumb[];
|
||||||
|
extraData?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Breadcrumb {
|
||||||
|
timestamp: number;
|
||||||
|
action: string;
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_BREADCRUMBS = 30;
|
||||||
|
const breadcrumbs: Breadcrumb[] = [];
|
||||||
|
|
||||||
|
export function addBreadcrumb(action: string, detail?: string): void {
|
||||||
|
breadcrumbs.push({timestamp: Date.now(), action, detail});
|
||||||
|
if (breadcrumbs.length > MAX_BREADCRUMBS) breadcrumbs.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlatform(): string {
|
||||||
|
if (!isDesktop) return 'web';
|
||||||
|
const ua: string = navigator.userAgent.toLowerCase();
|
||||||
|
if (ua.includes('mac')) return 'desktop-macos';
|
||||||
|
if (ua.includes('win')) return 'desktop-windows';
|
||||||
|
if (ua.includes('linux')) return 'desktop-linux';
|
||||||
|
return 'desktop';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserId(): string | undefined {
|
||||||
|
try {
|
||||||
|
const raw: string | null = localStorage.getItem('userId');
|
||||||
|
return raw ?? undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendCrashReport(payload: CrashReportPayload): Promise<void> {
|
||||||
|
try {
|
||||||
|
await axios.post(configs.apiUrl + 'crash-report', payload, {
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Silently fail — we can't crash while reporting a crash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReport(errorType: string, errorMessage: string, stackTrace?: string): CrashReportPayload {
|
||||||
|
return {
|
||||||
|
appName: configs.appName,
|
||||||
|
appVersion: configs.appVersion,
|
||||||
|
platform: getPlatform(),
|
||||||
|
osVersion: navigator.userAgent,
|
||||||
|
errorType,
|
||||||
|
errorMessage,
|
||||||
|
stackTrace,
|
||||||
|
screenName: window.location.pathname,
|
||||||
|
userId: getUserId(),
|
||||||
|
breadcrumbs: [...breadcrumbs],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reportError(error: Error, extraData?: Record<string, unknown>): void {
|
||||||
|
const report: CrashReportPayload = buildReport(
|
||||||
|
error.name || 'Error',
|
||||||
|
error.message,
|
||||||
|
error.stack,
|
||||||
|
);
|
||||||
|
if (extraData) report.extraData = extraData;
|
||||||
|
sendCrashReport(report);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initCrashReporter(): void {
|
||||||
|
window.onerror = (_message, _source, _lineno, _colno, error: Error | undefined): void => {
|
||||||
|
if (error) {
|
||||||
|
sendCrashReport(buildReport('UncaughtError', error.message, error.stack));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.onunhandledrejection = (event: PromiseRejectionEvent): void => {
|
||||||
|
const reason: unknown = event.reason;
|
||||||
|
if (reason instanceof Error) {
|
||||||
|
sendCrashReport(buildReport('UnhandledRejection', reason.message, reason.stack));
|
||||||
|
} else {
|
||||||
|
sendCrashReport(buildReport('UnhandledRejection', String(reason)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -187,9 +187,38 @@
|
|||||||
"title": "Discord",
|
"title": "Discord",
|
||||||
"description": "Join our community on Discord.",
|
"description": "Join our community on Discord.",
|
||||||
"badge": "DISCORD"
|
"badge": "DISCORD"
|
||||||
|
},
|
||||||
|
"migration": {
|
||||||
|
"title": "Import from Electron",
|
||||||
|
"description": "Migrate your data from the old version."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"migration": {
|
||||||
|
"title": "Migration from Electron",
|
||||||
|
"introText": "We detected this is your first launch. If you were using the old version of ERitors Scribe (Electron), you can import your local data (books, characters, chapters, etc.).",
|
||||||
|
"steps": "How to proceed:",
|
||||||
|
"step1": "In the old Electron app, go to the menu and click \"Export for migration\".",
|
||||||
|
"step2": "A .json file and a copy of your database will be created on your Desktop.",
|
||||||
|
"step3": "Come back here and enter the path to the exported .json file.",
|
||||||
|
"later": "Later",
|
||||||
|
"haveFile": "I have the file",
|
||||||
|
"selectText": "Paste the full path to the migration file exported from Electron.",
|
||||||
|
"filePath": "Migration file path",
|
||||||
|
"back": "Back",
|
||||||
|
"import": "Import",
|
||||||
|
"importing": "Migrating...",
|
||||||
|
"successTitle": "Migration successful!",
|
||||||
|
"successText": "Your data has been imported successfully. Log in again to access it.",
|
||||||
|
"deleteReminder": "Remember to delete the migration file and the database copy from your Desktop.",
|
||||||
|
"done": "Done",
|
||||||
|
"errorTitle": "Migration error",
|
||||||
|
"close": "Close",
|
||||||
|
"retry": "Retry",
|
||||||
|
"fileNotFound": "The migration file was not found at this path.",
|
||||||
|
"dbNotFound": "The database was not found next to the migration file.",
|
||||||
|
"importFailed": "Import failed. Please check that the files are valid."
|
||||||
|
},
|
||||||
"quillSense": {
|
"quillSense": {
|
||||||
"needSubscription": "Please subscribe to QuillSense or bring your keys to access this feature.",
|
"needSubscription": "Please subscribe to QuillSense or bring your keys to access this feature.",
|
||||||
"subscriptionDescription": "Unlock powerful writing tools to enrich your prose.",
|
"subscriptionDescription": "Unlock powerful writing tools to enrich your prose.",
|
||||||
|
|||||||
@@ -187,9 +187,38 @@
|
|||||||
"title": "Discord",
|
"title": "Discord",
|
||||||
"description": "Rejoignez notre communauté sur Discord.",
|
"description": "Rejoignez notre communauté sur Discord.",
|
||||||
"badge": "DISCORD"
|
"badge": "DISCORD"
|
||||||
|
},
|
||||||
|
"migration": {
|
||||||
|
"title": "Importer depuis Electron",
|
||||||
|
"description": "Migrer vos données depuis l'ancienne version."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"migration": {
|
||||||
|
"title": "Migration depuis Electron",
|
||||||
|
"introText": "Nous avons détecté que c'est votre premier lancement. Si vous utilisiez l'ancienne version d'ERitors Scribe (Electron), vous pouvez importer vos données locales (livres, personnages, chapitres, etc.).",
|
||||||
|
"steps": "Comment procéder :",
|
||||||
|
"step1": "Dans l'ancienne app Electron, allez dans le menu et cliquez sur « Exporter pour migration ».",
|
||||||
|
"step2": "Un fichier .json et une copie de votre base de données seront créés sur votre Bureau.",
|
||||||
|
"step3": "Revenez ici et indiquez le chemin du fichier .json exporté.",
|
||||||
|
"later": "Plus tard",
|
||||||
|
"haveFile": "J'ai le fichier",
|
||||||
|
"selectText": "Collez le chemin complet du fichier de migration exporté depuis Electron.",
|
||||||
|
"filePath": "Chemin du fichier de migration",
|
||||||
|
"back": "Retour",
|
||||||
|
"import": "Importer",
|
||||||
|
"importing": "Migration en cours...",
|
||||||
|
"successTitle": "Migration réussie !",
|
||||||
|
"successText": "Vos données ont été importées avec succès. Reconnectez-vous pour y accéder.",
|
||||||
|
"deleteReminder": "Pensez à supprimer le fichier de migration et la copie de la base de données de votre Bureau.",
|
||||||
|
"done": "Terminé",
|
||||||
|
"errorTitle": "Erreur de migration",
|
||||||
|
"close": "Fermer",
|
||||||
|
"retry": "Réessayer",
|
||||||
|
"fileNotFound": "Le fichier de migration est introuvable à ce chemin.",
|
||||||
|
"dbNotFound": "La base de données n'a pas été trouvée à côté du fichier de migration.",
|
||||||
|
"importFailed": "L'importation a échoué. Vérifiez que les fichiers sont valides."
|
||||||
|
},
|
||||||
"quillSense": {
|
"quillSense": {
|
||||||
"needSubscription": "Veuillez vous abonner à QuillSense ou Amenez vos clés pour accéder à cette fonctionnalité.",
|
"needSubscription": "Veuillez vous abonner à QuillSense ou Amenez vos clés pour accéder à cette fonctionnalité.",
|
||||||
"subscriptionDescription": "Débloquez des outils d'aide à l'écriture puissants pour enrichir votre prose.",
|
"subscriptionDescription": "Débloquez des outils d'aide à l'écriture puissants pour enrichir votre prose.",
|
||||||
|
|||||||
23
lib/tauri.ts
23
lib/tauri.ts
@@ -677,6 +677,29 @@ export async function applySeriesTombstones(tombstones: TombstoneRecord[]): Prom
|
|||||||
return invoke<void>('apply_series_tombstones', {tombstones});
|
return invoke<void>('apply_series_tombstones', {tombstones});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Migration ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface MigrationCheckResult {
|
||||||
|
found: boolean;
|
||||||
|
userId: string | null;
|
||||||
|
hasDb: boolean;
|
||||||
|
migrationPath: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MigrationResult {
|
||||||
|
success: boolean;
|
||||||
|
userId: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkElectronMigration(migrationFilePath: string): Promise<MigrationCheckResult> {
|
||||||
|
return invoke<MigrationCheckResult>('check_electron_migration', {migrationFilePath});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importFromElectron(migrationFilePath: string): Promise<MigrationResult> {
|
||||||
|
return invoke<MigrationResult>('import_from_electron', {data: {migrationFilePath}});
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Window Management ──────────────────────────────────────
|
// ─── Window Management ──────────────────────────────────────
|
||||||
|
|
||||||
let loginWindowOpening = false;
|
let loginWindowOpening = false;
|
||||||
|
|||||||
4
lib/utils/webkitDetect.ts
Normal file
4
lib/utils/webkitDetect.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export function isWebKitWithoutIndentFix(): boolean {
|
||||||
|
const ua: string = navigator.userAgent;
|
||||||
|
return /AppleWebKit/.test(ua) && !/Chrome|Chromium|Edg/.test(ua);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ pub mod incident;
|
|||||||
pub mod offline;
|
pub mod offline;
|
||||||
pub mod issue;
|
pub mod issue;
|
||||||
pub mod location;
|
pub mod location;
|
||||||
|
pub mod migration;
|
||||||
pub mod plotpoint;
|
pub mod plotpoint;
|
||||||
pub mod series;
|
pub mod series;
|
||||||
pub mod series_character;
|
pub mod series_character;
|
||||||
|
|||||||
@@ -167,10 +167,13 @@ pub fn run() {
|
|||||||
domains::series_sync::commands::series_sync_upload,
|
domains::series_sync::commands::series_sync_upload,
|
||||||
// ─── Sync ──────────────────────────────────────
|
// ─── Sync ──────────────────────────────────────
|
||||||
domains::sync::commands::get_synced_series,
|
domains::sync::commands::get_synced_series,
|
||||||
// ─── Tombstone ─────────────────────────────────
|
// ─── Tombstone ─────────────<EFBFBD><EFBFBD><EFBFBD>───────────────────
|
||||||
domains::tombstone::commands::get_tombstones_since,
|
domains::tombstone::commands::get_tombstones_since,
|
||||||
domains::tombstone::commands::apply_book_tombstones,
|
domains::tombstone::commands::apply_book_tombstones,
|
||||||
domains::tombstone::commands::apply_series_tombstones,
|
domains::tombstone::commands::apply_series_tombstones,
|
||||||
|
// ─── Migration ────────────<E29480><E29480>───────────────────
|
||||||
|
domains::migration::commands::check_electron_migration,
|
||||||
|
domains::migration::commands::import_from_electron,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
Reference in New Issue
Block a user