Files
Trilium/apps/client/src/setup.tsx

506 lines
20 KiB
TypeScript

import "./setup.css";
import { LOCALES, SetupSyncFromServerResponse } from "@triliumnext/commons";
import clsx from "clsx";
import { ComponentChildren, render } from "preact";
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import { useTranslation } from "react-i18next";
import logo from "./assets/icon-color.svg?url";
import { initLocale, t } from "./services/i18n";
import server from "./services/server";
import { isElectron, replaceHtmlEscapedSlashes } from "./services/utils";
import ActionButton from "./widgets/react/ActionButton";
import Admonition from "./widgets/react/Admonition";
import Button from "./widgets/react/Button";
import { Card, CardFrame, CardSection } from "./widgets/react/Card";
import FormGroup from "./widgets/react/FormGroup";
import FormList, { FormListItem } from "./widgets/react/FormList";
import FormTextBox from "./widgets/react/FormTextBox";
import Icon from "./widgets/react/Icon";
async function main() {
await initLocale();
const bodyWrapper = document.createElement("div");
bodyWrapper.classList.add("setup-outer-wrapper");
document.body.classList.add("setup");
if (isElectron()) {
document.body.classList.add("electron");
}
render(<App />, bodyWrapper);
document.body.replaceChildren(bodyWrapper);
}
type State = "selectLanguage" | "firstOptions" | "createNewDocumentOptions" | "createNewDocumentWithDemo" | "createNewDocumentEmpty" | "syncFromDesktop" | "syncFromServer" | "syncInProgress" | "syncFailed";
const STATE_ORDER: State[] = ["selectLanguage", "firstOptions", "createNewDocumentOptions", "createNewDocumentWithDemo", "createNewDocumentEmpty", "syncFromDesktop", "syncFromServer", "syncInProgress", "syncFailed"];
function renderState(state: State, setState: (state: State) => void) {
switch (state) {
case "selectLanguage": return <SelectLanguage setState={setState} />;
case "firstOptions": return <SetupOptions setState={setState} />;
case "createNewDocumentOptions": return <CreateNewDocumentOptions setState={setState} />;
case "createNewDocumentWithDemo": return <CreateNewDocumentInProgress withDemo />;
case "createNewDocumentEmpty": return <CreateNewDocumentInProgress />;
case "syncFromServer": return <SyncFromServer setState={setState} />;
case "syncFromDesktop": return <SyncFromDesktop setState={setState} />;
case "syncInProgress": return <SyncInProgress device="server" />;
default: return null;
}
}
function App() {
const [state, setState] = useState<State>("syncFromDesktop");
const [prevState, setPrevState] = useState<State | null>(null);
const [transitioning, setTransitioning] = useState(false);
const prevStateRef = useRef<State>(state);
function handleSetState(newState: State) {
setPrevState(prevStateRef.current);
prevStateRef.current = newState;
setTransitioning(true);
setState(newState);
}
const direction = prevState !== null
? STATE_ORDER.indexOf(state) > STATE_ORDER.indexOf(prevState) ? "forward" : "backward"
: "forward";
return (
<div class="setup-container">
{transitioning && prevState !== null && (
<div
class={`slide-page slide-out-${direction}`}
onAnimationEnd={() => {
setTransitioning(false);
setPrevState(null);
}}
>
{renderState(prevState, handleSetState)}
</div>
)}
<div class={`slide-page ${transitioning ? `slide-in-${direction}` : "slide-current"}`} key={state}>
{renderState(state, handleSetState)}
</div>
</div>
);
}
function SelectLanguage({ setState }: { setState: (state: State) => void }) {
const { t, i18n } = useTranslation();
const [ currentLocale, setCurrentLocale ] = useState(i18n.language);
const filteredLocales = useMemo(() => LOCALES.filter(l => !l.contentOnly), []);
return (
<SetupPage
title={t("setup.language")}
className="select-language"
illustration={<Icon icon="bx bx-globe" className="illustration-icon" />}
footer={<Button text={t("setup.continue")} kind="primary" onClick={() => setState("firstOptions")} />}
>
<FormList onSelect={async (id) => {
await i18n.changeLanguage(id);
setCurrentLocale(id);
}}>
{filteredLocales.map(locale => (
<FormListItem key={locale.id} value={locale.id} active={locale.id === currentLocale}>{locale.name}</FormListItem>
))}
</FormList>
</SetupPage>
);
}
function SetupOptions({ setState }: { setState: (state: State) => void }) {
return (
<SetupPage
title={t("setup.heading")}
className="setup-options-container"
illustration={<img src={logo} alt="Setup illustration" className="illustration-logo" />}
onBack={() => setState("selectLanguage")}
>
<div class="setup-options">
<SetupOptionCard
icon="bx bx-file-blank"
title={t("setup.new-document")}
description={t("setup.new-document-description")}
onClick={() => setState("createNewDocumentOptions")}
/>
<SetupOptionCard
icon="bx bx-server"
title={t("setup.sync-from-server")}
description={t("setup.sync-from-server-description")}
onClick={() => setState("syncFromServer")}
/>
<SetupOptionCard
icon="bx bx-desktop"
title={t("setup.sync-from-desktop")}
description={t("setup.sync-from-desktop-description")}
disabled={glob.isStandalone}
onClick={() => setState("syncFromDesktop")}
/>
</div>
</SetupPage>
);
}
type SyncStep = "connecting" | "syncing" | "finalizing";
function getSyncStep(stats: { outstandingPullCount: number; totalPullCount: number | null; initialized: boolean }): SyncStep {
if (stats.initialized) {
return "finalizing"; // will reload momentarily
}
if (stats.totalPullCount !== null && stats.outstandingPullCount > 0) {
return "syncing";
}
if (stats.totalPullCount !== null && stats.outstandingPullCount === 0) {
return "finalizing";
}
return "connecting";
}
function SyncInProgress({ device }: { device: "server" | "desktop" }) {
const stats = useOutstandingSyncInfo();
const step = getSyncStep(stats);
useEffect(() => {
if (stats.initialized) {
location.reload();
}
}, [stats.initialized]);
const steps: { key: SyncStep; label: string }[] = [
{ key: "connecting", label: t("setup.sync-step-connecting") },
{ key: "syncing", label: t("setup.sync-step-syncing") },
{ key: "finalizing", label: t("setup.sync-step-finalizing") }
];
const currentIndex = steps.findIndex((s) => s.key === step);
const syncingDone = currentIndex > steps.findIndex((s) => s.key === "syncing");
let progress = 0;
if (syncingDone) {
progress = 100;
} else if (stats.totalPullCount) {
progress = Math.round(((stats.totalPullCount - stats.outstandingPullCount) / stats.totalPullCount) * 100);
}
return (
<SetupPage
className="sync-in-progress"
illustration={<SyncIllustration targetDevice={device} />}
title={t("setup.sync-in-progress-title")}
>
<Card className="sync-steps">
{steps.map((s, i) => (
<CardSection className={i < currentIndex ? "completed" : i === currentIndex ? "active" : ""} key={s.key}>
<Icon icon={i < currentIndex ? "bx bx-check-circle" : i === currentIndex ? "bx bx-loader-circle bx-spin" : "bx bx-circle"} />{" "}
{s.label}
{s.key === "syncing" && (
<div class="sync-progress">
<progress value={syncingDone ? 1 : stats.totalPullCount! - stats.outstandingPullCount} max={syncingDone ? 1 : stats.totalPullCount!} />
<span>{progress}%</span>
</div>
)}
</CardSection>
))}
</Card>
</SetupPage>
);
}
function useOutstandingSyncInfo() {
const [ outstandingPullCount, setOutstandingPullCount ] = useState(0);
const [ totalPullCount, setTotalPullCount ] = useState<number | null>(null);
const [ initialized, setInitialized ] = useState(false);
async function refresh() {
const resp = await server.get<{ outstandingPullCount: number; totalPullCount: number | null; initialized: boolean }>("sync/stats");
setOutstandingPullCount(resp.outstandingPullCount);
setTotalPullCount(resp.totalPullCount);
setInitialized(resp.initialized);
}
useEffect(() => {
const interval = setInterval(refresh, 1000);
refresh();
return () => clearInterval(interval);
}, []);
return { outstandingPullCount, totalPullCount, initialized };
}
function CreateNewDocumentOptions({ setState }: { setState: (state: State) => void }) {
return (
<SetupPage
className="create-new-document-options"
title={t("setup.create-new-document-options-title")}
illustration={<Icon icon="bx bx-star" className="illustration-icon" />}
onBack={() => setState("firstOptions")}
>
<div class="setup-options">
<SetupOptionCard icon="bx bx-book-open" title={t("setup.create-new-document-options-with-demo")} description={t("setup.create-new-document-options-with-demo-description")} onClick={() => setState("createNewDocumentWithDemo")} />
<SetupOptionCard icon="bx bx-file-blank" title={t("setup.create-new-document-options-empty")} description={t("setup.create-new-document-options-empty-description")} onClick={() => setState("createNewDocumentEmpty")} />
</div>
</SetupPage>
);
}
function CreateNewDocumentInProgress({ withDemo = false }: { withDemo?: boolean }) {
useEffect(() => {
server.post(`setup/new-document${withDemo ? "" : "?skipDemoDb"}`).then(() => {
location.reload();
});
}, [ withDemo ]);
return (
<SetupPage
className="create-new-document"
title={t("setup.create-new-document-title")}
description={t("setup.create-new-document-description")}
illustration={<Icon icon="bx bx-loader-circle bx-spin" className="illustration-icon" />}
/>
);
}
function SyncFromServer({ setState }: { setState: (state: State) => void }) {
const [ syncServerHost, setSyncServerHost ] = useState("");
const [ password, setPassword ] = useState("");
const [ syncProxy, setSyncProxy ] = useState("");
const [ error, setError ] = useState<string | null>(null);
const [ errorId, setErrorId ] = useState(0);
const [ isWrongPassword, setIsWrongPassword ] = useState(false);
const isValid = syncServerHost.trim() !== "" && password !== "";
function raiseError(message: string) {
setError(message);
setErrorId(id => id + 1);
}
async function handleFinishSetup() {
try {
const resp = await server.post<SetupSyncFromServerResponse>("setup/sync-from-server", {
syncServerHost: syncServerHost.trim(),
syncProxy: syncProxy.trim(),
password
});
if (resp.result === "success") {
setState("syncInProgress");
} else if (resp.error.includes("Incorrect password")) {
setIsWrongPassword(true);
} else {
raiseError(t("setup.sync-failed", { message: resp.error }));
}
} catch (e) {
raiseError(e instanceof Error ? e.message : String(e));
}
}
return (
<SetupPage
className="sync-from-server top-aligned"
title={t("setup.sync-from-server")}
description={t("setup.sync-from-server-page-description")}
illustration={<SyncIllustration targetDevice="server" />}
error={error}
errorId={errorId}
onBack={() => setState("firstOptions")}
footer={<Button text={t("setup.button-finish-setup")} kind="primary" onClick={handleFinishSetup} disabled={!isValid} />}
>
<form>
<Card>
<CardSection>
<FormGroup label={t("setup.server-host")} name="serverHost">
<FormTextBox
placeholder={t("setup.server-host-placeholder")}
currentValue={syncServerHost} onChange={setSyncServerHost}
autocomplete="trilium-sync-server-host"
required
/>
</FormGroup>
</CardSection>
<CardSection>
<FormGroup
label={t("setup.server-password")} name="serverPassword"
error={isWrongPassword ? t("setup.wrong-password") : undefined}
>
<FormTextBox
type="password"
currentValue={password} onChange={setPassword}
autocomplete="trilium-sync-server-password"
required
/>
</FormGroup>
</CardSection>
</Card>
<Card heading={t("setup.advanced-options")}>
<CardSection>
<FormGroup
name="proxyServer"
label={t("setup.proxy-server")}
description={isElectron() ? t("setup.proxy-instruction") : undefined}
>
<FormTextBox placeholder={t("setup.proxy-server-placeholder")} currentValue={syncProxy} onChange={setSyncProxy} />
</FormGroup>
</CardSection>
</Card>
</form>
</SetupPage>
);
}
function SyncFromDesktop({ setState }: { setState: (state: State) => void }) {
const networkAddresses = getNetworkAddresses();
return (
<SetupPage
className="sync-from-desktop"
title={t("setup.sync-from-desktop")}
illustration={<SyncIllustration targetDevice="desktop" />}
onBack={() => setState("firstOptions")}
>
<div class="card-columns">
<Card heading="On the other device">
<CardSection>1. {t("setup.sync-from-desktop-step1")}</CardSection>
<CardSection>2. {t("setup.sync-from-desktop-step2")}</CardSection>
<CardSection>3. {t("setup.sync-from-desktop-step3")}</CardSection>
<CardSection>4. {t("setup.sync-from-desktop-step4")}</CardSection>
<CardSection>5. {t("setup.sync-from-desktop-step5")}</CardSection>
</Card>
{networkAddresses.length > 0 && (
<Card heading={t("setup.your-ip-addresses")} className="ip-addresses">
{networkAddresses.map((addr) => (
<CardSection key={addr}>{addr}</CardSection>
))}
</Card>
)}
</div>
<div class="sync-from-desktop-waiting">
<div class="main"><Icon icon="bx bx-loader-circle bx-spin" />{" "} {t("setup.sync-from-desktop-waiting")}</div>
<div class="subtle">{t("setup.sync-from-desktop-warning")}</div>
</div>
</SetupPage>
);
}
function SyncIllustration({ targetDevice }: { targetDevice: "desktop" | "server" }) {
return (
<div class="sync-illustration">
<div>
<Icon icon={isElectron() ? "bx bx-desktop" : "bx bx-globe"} />
{t("setup.sync-illustration-this-device")}
</div>
<div class="sync-illustration-arrows" />
<div>
<Icon icon={targetDevice === "desktop" ? "bx bx-desktop" : "bx bx-server"} />
{targetDevice === "desktop" ? t("setup.sync-illustration-desktop-app") : t("setup.sync-illustration-server")}
</div>
</div>
);
}
function SetupOptionCard({ title, description, icon, onClick, disabled }: { title: string; description: string, icon: string, onClick?: () => void, disabled?: boolean }) {
return (
<CardFrame
className={clsx("setup-option-card", { disabled })}
onClick={disabled ? undefined : onClick}
>
<Icon icon={icon} />
<div>
<h3>{title}</h3>
<p>{description}</p>
</div>
</CardFrame>
);
}
function SetupPage({ title, description, className, illustration, children, footer, error, errorId, onBack }: {
title: string;
description?: string;
error?: string | null;
errorId?: number;
className?: string;
illustration?: ComponentChildren;
children?: ComponentChildren;
footer?: ComponentChildren;
onBack?: () => void;
}) {
const [ showError, setShowError ] = useState(!!error);
useEffect(() => {
if (error) {
setShowError(true);
}
}, [ error, errorId ]);
return (
<div className={clsx("page", className, { "contentless": !children })}>
{onBack && (
<Button
className="back-button"
icon="bx bx-arrow-back"
text={t("setup.button-back")}
onClick={onBack}
kind="lowProfile"
/>
)}
{error && showError && (
<Admonition className="page-error" type="caution">
<ActionButton icon="bx bx-x" text={t("setup.dismiss-error")} onClick={() => setShowError(false)} />
{replaceHtmlEscapedSlashes(error)}
</Admonition>
)}
{illustration}
<h1>{title}</h1>
{description && <p class="page-description">{description}</p>}
{children && <main>
{children}
</main>}
{footer && <footer>{footer}</footer>}
</div>
);
}
function getNetworkAddresses(): string[] {
if (!isElectron()) {
return [`${location.protocol}//${location.host}`];
}
// eslint-disable-next-line @typescript-eslint/no-require-imports
const os = require("os") as typeof import("os");
const interfaces = os.networkInterfaces();
const addresses: string[] = [];
for (const nets of Object.values(interfaces)) {
if (!nets) continue;
for (const net of nets) {
if (net.internal) continue;
if (net.family === "IPv6" && net.scopeid !== 0) continue;
addresses.push(net.address);
}
}
// Sort by likelihood of being the local network address.
addresses.sort((a, b) => networkScore(a) - networkScore(b));
return addresses.map((addr) => `${location.protocol}//${addr}:${location.port}`);
}
function networkScore(addr: string): number {
if (addr.startsWith("192.168.")) return 0;
if (addr.startsWith("10.")) return 1;
if (/^172\.(1[6-9]|2\d|3[01])\./.test(addr)) return 2;
if (addr.includes(":")) return 4; // IPv6
return 3;
}
main();