Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions web/src/setup/preference.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
export const SETUP_PREFERENCE_KEY = "notes.setup";
type HintStorage = Pick<Storage, "getItem" | "setItem">;
export interface SetupPreference {
isDismissed(): boolean;
dismiss(): Readonly<{ persisted: boolean }>;
subscribe(listener: () => void): () => void;
}

/** Optional profile introduction only: never account or library state. */
export function createSetupPreference(
storage: () => HintStorage | null,
events?: EventTarget,
): SetupPreference {
let dismissedHere = false;
const listeners = new Set<() => void>();
function notify() {
for (const listener of listeners) {
try {
listener();
} catch {
/* One observer cannot block completion. */
}
}
}
const changed = (event: Event) => {
const key = (event as StorageEvent).key;
if (key === SETUP_PREFERENCE_KEY || key === null) notify();
};
return {
isDismissed() {
if (dismissedHere) return true;
try {
const raw = storage()?.getItem(SETUP_PREFERENCE_KEY);
if (!raw || raw.length > 256) return false;
const value: unknown = JSON.parse(raw);
if (!value || typeof value !== "object" || Array.isArray(value))
return false;
const hint = value as Record<string, unknown>;
dismissedHere =
Object.keys(hint).length === 2 &&
Object.hasOwn(hint, "version") &&
Object.hasOwn(hint, "dismissed") &&
hint.version === 1 &&
hint.dismissed === true;
} catch {
/* Missing/denied preferences never prevent browsing. */
}
return dismissedHere;
},
dismiss() {
dismissedHere = true;
let persisted = false;
try {
const current = storage();
if (current) {
current.setItem(
SETUP_PREFERENCE_KEY,
'{"version":1,"dismissed":true}',
);
persisted = true;
}
} catch {
/* The context latch prevents repeated optional takeover. */
}
notify();
return Object.freeze({ persisted });
},
subscribe(listener) {
if (listeners.size === 0) events?.addEventListener("storage", changed);
listeners.add(listener);
return () => {
listeners.delete(listener);
if (listeners.size === 0)
events?.removeEventListener("storage", changed);
};
},
};
}

export const profileSetupPreference = createSetupPreference(
() => (typeof localStorage === "undefined" ? null : localStorage),
typeof window === "undefined" ? undefined : window,
);
149 changes: 149 additions & 0 deletions web/src/styles/setup.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
.first-run-setup {
width: min(620px, calc(100vw - 32px));
max-height: calc(100dvh - 32px);
padding: 36px 40px;
display: flex;
flex-direction: column;
gap: 18px;
background: var(--main);
color: var(--t1);
}
.first-run-setup:not([open]) {
display: none;
}
.first-run-setup > h2 {
order: 0;
margin: 0;
font-size: 24px;
font-weight: 500;
letter-spacing: -0.02em;
}
.setup-step {
order: -1;
font-size: 12px;
color: var(--t3);
}
.setup-intro {
margin: 0;
color: var(--t3);
}
.setup-progress {
height: 6px;
border-radius: 999px;
background: var(--line2);
overflow: hidden;
}
.setup-progress i {
display: block;
height: 100%;
background: var(--t1);
border-radius: inherit;
}
.setup-choice {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid var(--line2);
background: var(--main);
min-width: 0;
}
.setup-choice.current {
border-color: var(--t1);
box-shadow: inset 0 0 0 1px var(--t1);
}
.setup-choice > svg,
.setup-number {
flex: none;
margin-top: 2px;
width: 18px;
}
.setup-choice > div:first-of-type {
flex: 1;
min-width: 0;
}
.setup-choice b {
display: block;
font-size: 13px;
font-weight: 500;
overflow-wrap: anywhere;
}
.setup-choice small {
display: block;
color: var(--t3);
line-height: 1.5;
margin-top: 3px;
}
.setup-status,
.setup-number {
font-size: 11px;
color: var(--t3);
white-space: nowrap;
}
.setup-actions {
display: flex;
gap: 6px;
flex: none;
}
.setup-footer {
display: flex;
justify-content: flex-end;
}
.empty.library-empty {
margin: 0;
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 10px;
padding: 0 28px 40px;
text-align: left;
}
.library-empty h2 {
margin: 0;
font-size: 18px;
font-weight: 500;
}
.library-empty p {
margin: 0;
max-width: 540px;
}
.library-empty-icon {
width: 40px;
height: 40px;
border-radius: 12px;
display: grid;
place-items: center;
background: var(--raise);
color: var(--t1);
}
.library-empty-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.library-empty-actions button {
margin: 0;
}
.library-local-copy {
font-size: 12px;
color: var(--t3);
}
@media (max-width: 600px) {
.first-run-setup {
padding: 24px 20px;
gap: 14px;
}
.setup-choice {
flex-wrap: wrap;
}
.setup-actions {
width: 100%;
justify-content: flex-end;
}
.empty.library-empty {
padding: 24px 20px 40px;
}
}
145 changes: 145 additions & 0 deletions web/src/ui/FirstRunSetup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { useEffect, useState } from "react";
import { Check } from "lucide-react";
import { Dialog } from "./primitives";
import "../styles/setup.css";
export type SetupImportState =
| "idle"
| "picking"
| "reading"
| "review"
| "running"
| "failed"
| "completed";
export function FirstRunSetup({
email,
environment,
readOnly = false,
importState,
importedCount = 0,
remainingCount = 0,
importMessage = "",
onChooseMarkdown,
onComplete,
onClose,
}: {
email: string;
environment: "desktop" | "browser";
readOnly?: boolean;
importState: SetupImportState;
importedCount?: number;
remainingCount?: number;
importMessage?: string;
onChooseMarkdown: () => void;
onComplete: () => void;
onClose: () => void;
}) {
const [step, setStep] = useState<"import" | "sync">("import");
const busy =
["picking", "reading", "review", "running"].includes(importState) ||
remainingCount > 0;
useEffect(() => {
if (importState === "completed") setStep("sync");
}, [importState]);
return (
<Dialog
title="Set up Orbyn Notes"
className="first-run-setup"
onClose={() => {
if (!busy) onClose();
}}
>
<span className="setup-step">Step {step === "import" ? 2 : 3} of 3</span>
<p className="setup-intro">
Every step is optional. You can come back to each one in Settings.
</p>
<div className="setup-progress" aria-hidden="true">
<i style={{ width: step === "import" ? "55%" : "100%" }} />
</div>
<div className="setup-choice">
<Check size={18} aria-hidden="true" />
<div>
<b>Signed in as {email}</b>
<small>Your Orbyn account</small>
</div>
<span className="setup-status">Done</span>
</div>
<div className={`setup-choice ${step === "import" ? "current" : ""}`}>
<span className="setup-number">2</span>
<div>
<b>Bring in your notes</b>
<small>
Import Markdown files. Your originals stay where they are.
</small>
<small>Apple Notes import isn’t available in this beta.</small>
{readOnly && (
<small>Import is unavailable while Notes is read-only.</small>
)}
{importedCount > 0 && (
<small>
{importedCount}{" "}
{importedCount === 1 ? "note imported" : "notes imported"}.
</small>
)}
{importState === "picking" && (
<small role="status">Choose Markdown files.</small>
)}
{importState === "reading" && (
<small role="status">Reading Markdown files…</small>
)}
{importState === "running" && (
<small role="status">Importing notes…</small>
)}
{importState === "failed" && (
<small role="alert">
{importMessage ||
"Import didn’t finish. Review the remaining files."}
</small>
)}
</div>
{step === "import" ? (
<div className="setup-actions">
<button disabled={busy} onClick={() => setStep("sync")}>
Skip
</button>
<button
className="primary"
autoFocus
disabled={busy || readOnly}
onClick={onChooseMarkdown}
>
Import notes
</button>
</div>
) : (
<span className="setup-status">
{importState === "completed" ? "Done" : "Skipped"}
</span>
)}
</div>
<div className={`setup-choice ${step === "sync" ? "current" : ""}`}>
<span className="setup-number">3</span>
<div>
<b>Sync your devices</b>
<small>
Sync isn’t available in this beta. Your notes stay{" "}
{environment === "desktop"
? "in this app on this device"
: "in this browser profile"}
.
</small>
</div>
<span className="setup-status">Planned</span>
{step === "sync" && (
<button disabled={busy} onClick={onComplete}>
Skip
</button>
)}
</div>
<div className="setup-footer">
<button disabled={busy} onClick={onClose}>
Close setup
</button>
</div>
</Dialog>
);
}
Loading
Loading