Chapters as rows, in two views, in their own colours

Three things Marc asked for, and one of them turned out to be the hinge
the other two hang from.

**A chapter is a row like any other.** Same grip, same drag, same place
in a hand-made arrangement as a note or a separator. That needed notes
and chapters numbered from *one* counter: numbered separately they are
two sequences that cannot be interleaved again, and a chapter dragged
between two notes springs back to the top. Positions therefore have to be
named rather than read off an array index, so POST /notes/reorder now
takes `positions: [{id, order}]` beside the older `ids`, and chapters
have a reorder route of their own.

**Two ways for a chapter to open**, chosen per account beside the
typeface, for the same reason that one lives there: it is a choice about
how your own notes are laid out and it should follow you to the phone.
Beside the list, its notes in a further column the way OneNote lays out
pages — the notebook's own list carrying on to the left, chapter rows and
all. Or unfolded under the chapter's own row, indented against a rule in
its colour. A phone has room for neither and fills the list instead, with
a way back, which is what it did before there was a choice.

**Its own colour**, from eight named ones rather than the full palette:
the point of chapters is that they are different places, and twenty
near-identical blues do not help anyone tell them apart.

The colour is validated on the way in — it is written by a browser, so
only a plain #rrggbb gets through and anything else becomes no colour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-12 14:20:14 +02:00
1 parent 6cad632a0c
commit d74fea4905
12 files changed
+640 -88

No files matched your search

+203 -4
View File
@@ -8,7 +8,7 @@ import { usePwaInstall } from "./hooks/usePwaInstall";
import { useNotebookDnd } from "./hooks/useNotebookDnd";
import { useNavigationHistory } from "./hooks/useNavigationHistory";
import { DEFAULT_FOLDERS, coverBackground } from "./ui/constants.jsx";
import { CHAPTER_COLORS, DEFAULT_FOLDERS, coverBackground } from "./ui/constants.jsx";
import { I } from "./ui/icons.jsx";
import { makeTheme, FONT_UI } from "./ui/theme";
import { ConfirmDialog } from "./ui/ConfirmDialog.jsx";
@@ -103,6 +103,12 @@ export default function App() {
// Presentation only, and it lives on the account so it follows to the phone.
const noteFont = user?.preferences?.noteFont || "serif";
/* How a chapter opens: a further column beside the list, or unfolded under
its own row. A phone has room for neither, so there it fills the screen —
which is what "column" already did before there was a choice. */
const chapterView = user?.preferences?.chapterView || "column";
const inlineChapters = chapterView === "inline" && !isMobile;
const columnChapters = chapterView === "column" && !isMobile;
const data = useNoteData(user, activeNote);
const { notes, notebooks, noteContents, backlinks } = data;
@@ -287,7 +293,8 @@ export default function App() {
if (activeFolder === "trash") list = trashedNotes;
else if (activeFolder === "starred") list = activeNotes.filter((n) => n.starred);
else if (activeFolder === "all" || activeFolder === "dashboard") list = activeNotes;
else if (activeChapter) list = activeNotes.filter((n) => n.chapterId === activeChapter);
else if (activeChapter && !inlineChapters && !columnChapters)
list = activeNotes.filter((n) => n.chapterId === activeChapter);
/* In the notebook itself, a note filed into a chapter is *in* that chapter
and not loose here — otherwise it would appear twice and moving it would
look like it had done nothing. */
@@ -305,7 +312,24 @@ export default function App() {
return list.filter((n) => n.title.toLowerCase().includes(q));
}
return sortNotes(list, sortMode);
}, [activeFolder, activeChapter, searchQuery, search.results, activeNotes, trashedNotes, sortMode]);
}, [
activeFolder,
activeChapter,
inlineChapters,
columnChapters,
searchQuery,
search.results,
activeNotes,
trashedNotes,
sortMode,
]);
/* The notes of the chapter being looked into, in the same order they would
have in a list of their own. */
const chapterNotes = useMemo(
() => (activeChapter ? sortNotes(activeNotes.filter((n) => n.chapterId === activeChapter), sortMode) : []),
[activeChapter, activeNotes, sortMode]
);
const noteMeta = activeNote ? notes.find((n) => n.id === activeNote) : null;
// The editor opens as soon as the metadata is known; the body fills in when
@@ -603,6 +627,18 @@ export default function App() {
onSave: (name) => data.renameChapter(chapter.id, name),
}),
},
{
label: "Colour",
icon: I.edit,
submenu: [
{ label: "None", colorDot: T.faint, action: () => data.recolourChapter(chapter.id, null) },
...CHAPTER_COLORS.map((c) => ({
label: c.label,
colorDot: c.value,
action: () => data.recolourChapter(chapter.id, c.value),
})),
],
},
{ divider: true },
{
label: "Remove chapter",
@@ -680,6 +716,58 @@ export default function App() {
if (!user) return <AuthScreen onLogin={onLogin} dark={dark} />;
// On a phone the list and the editor take turns; on a desktop both are shown.
/* A chapter's notes, unfolded under its own row. Indented and against a rule,
so it reads as "inside this" rather than as more of the same list. */
const renderChapterNotes = (chapter) => (
<div
data-chapter-notes={chapter.id}
style={{ margin: "2px 0 8px 22px", borderLeft: `2px solid ${chapter.color || T.accent}55`, paddingLeft: 8 }}
>
{chapterNotes.length === 0 && (
<div style={{ padding: "8px 10px", fontSize: 12, color: T.muted }}>Nothing in here yet.</div>
)}
{chapterNotes.map((n) => (
<button
key={n.id}
data-chapter-note={n.id}
onClick={() => openNote(n.id)}
onContextMenu={(e) => noteContextMenu(e, n)}
style={{
display: "flex",
alignItems: "center",
gap: 7,
width: "100%",
padding: "8px 10px",
borderRadius: 8,
border: `1px solid ${activeNote === n.id ? T.accent + "55" : "transparent"}`,
background: activeNote === n.id ? T.accentSoft : "transparent",
cursor: "pointer",
textAlign: "left",
marginBottom: 2,
}}
>
<span style={{ display: "flex", color: T.faint, flexShrink: 0 }}>
{n.type === "pdf" ? I.pdf : I.note}
</span>
<span
style={{
flex: 1,
minWidth: 0,
fontSize: 12.5,
fontWeight: 600,
color: T.text,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{n.title}
</span>
</button>
))}
</div>
);
const showList = !(isMobile && mobileView === "editor" && activeNote);
const showEditor = !isMobile || mobileView === "editor";
@@ -775,10 +863,15 @@ export default function App() {
onRenameSeparator={renameSeparator}
onSeparatorContextMenu={separatorContextMenu}
chapters={folderChapters}
openChapter={openChapter}
/* Only in the fallback: there the list becomes the chapter, and
its header is the way back. Beside or below, the notebook's own
list carries on and keeps its chapter rows. */
openChapter={inlineChapters || columnChapters ? null : openChapter}
onOpenChapter={setActiveChapter}
onAddChapter={activeNotebook ? addChapter : null}
onChapterContextMenu={chapterContextMenu}
expandedChapter={inlineChapters ? activeChapter : null}
renderChapterNotes={inlineChapters ? renderChapterNotes : null}
onMoveNote={setMovingNote}
onReorder={rearrangeNotes}
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
@@ -790,6 +883,112 @@ export default function App() {
onEmptyTrash={emptyTrash}
/>
)}
{/* The chapter's own column, beside the list — the way OneNote lays
out its pages. Only where there is room: on a phone the chapter
fills the screen instead, with a way back. */}
{columnChapters && openChapter && (
<div
data-chapter-column
style={{
width: 268,
flexShrink: 0,
display: "flex",
flexDirection: "column",
borderRight: `1px solid ${T.borderLight}`,
background: T.panel || "transparent",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "14px 14px 10px",
borderBottom: `1px solid ${T.borderLight}`,
}}
>
<span style={{ display: "flex", color: openChapter.color || T.accent, flexShrink: 0 }}>
{I.books}
</span>
<span
style={{
flex: 1,
minWidth: 0,
fontSize: 14,
fontWeight: 600,
color: T.text,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{openChapter.name}
</span>
<button
onClick={() => setActiveChapter(null)}
title="Close chapter"
aria-label="Close chapter"
data-chapter-close
style={{
border: "none",
background: "transparent",
color: T.muted,
cursor: "pointer",
display: "flex",
padding: 0,
}}
>
{I.x}
</button>
</div>
<div style={{ flex: 1, overflowY: "auto", padding: 6 }}>
{chapterNotes.length === 0 && (
<div style={{ padding: "12px 10px", fontSize: 12, color: T.muted }}>
Nothing in here yet.
</div>
)}
{chapterNotes.map((n) => (
<button
key={n.id}
data-chapter-note={n.id}
onClick={() => openNote(n.id)}
onContextMenu={(e) => noteContextMenu(e, n)}
style={{
display: "flex",
alignItems: "center",
gap: 7,
width: "100%",
padding: "9px 10px",
borderRadius: 9,
border: `1px solid ${activeNote === n.id ? T.accent + "55" : "transparent"}`,
background: activeNote === n.id ? T.accentSoft : "transparent",
cursor: "pointer",
textAlign: "left",
marginBottom: 2,
}}
>
<span style={{ display: "flex", color: T.faint, flexShrink: 0 }}>
{n.type === "pdf" ? I.pdf : I.note}
</span>
<span
style={{
flex: 1,
minWidth: 0,
fontSize: 12.5,
fontWeight: 600,
color: T.text,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{n.title}
</span>
</button>
))}
</div>
</div>
)}
{showEditor && (
<EditorPane
T={T}
+39 -20
View File
@@ -19,8 +19,9 @@ const iconButton = (T) => ({
color: T.muted,
});
/** A note rendered outside the manual order, wrapped to look like an entry. */
/** A row rendered outside the manual order, wrapped to look like an entry. */
const asNoteEntry = (note) => ({ kind: "note", id: note.id, note });
const asChapterEntry = (chapter) => ({ kind: "chapter", id: chapter.id, chapter });
/**
* Where the dragged row will land.
@@ -211,7 +212,7 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
* place, and the notes are what is in this one. The count is what makes it worth
* opening — a row saying only "Invoices" tells you nothing about whether to.
*/
function ChapterRow({ T, chapter, onOpen, onContextMenu }) {
function ChapterRow({ T, chapter, open, onOpen, onContextMenu }) {
return (
<button
data-chapter-row={chapter.id}
@@ -221,17 +222,20 @@ function ChapterRow({ T, chapter, onOpen, onContextMenu }) {
display: "flex",
alignItems: "center",
gap: 9,
width: "100%",
flex: 1,
minWidth: 0,
padding: "10px 12px",
borderRadius: 10,
border: `1px solid ${T.borderLight}`,
background: "transparent",
border: `1px solid ${open ? T.accent + "55" : T.borderLight}`,
background: open ? T.accentSoft : "transparent",
cursor: "pointer",
marginBottom: 4,
textAlign: "left",
}}
>
<span style={{ display: "flex", color: T.accent, flexShrink: 0 }}>{I.books}</span>
{/* Its own colour, so a shelf of chapters can be told apart at a glance
— the point of having them is that they are different places. */}
<span style={{ display: "flex", color: chapter.color || T.accent, flexShrink: 0 }}>{I.books}</span>
<span
style={{
flex: 1,
@@ -247,7 +251,7 @@ function ChapterRow({ T, chapter, onOpen, onContextMenu }) {
{chapter.name}
</span>
<span style={{ fontSize: 11, color: T.faint, flexShrink: 0 }}>{chapter.noteCount}</span>
<span style={{ fontSize: 11, color: T.faint, flexShrink: 0 }}>›</span>
<span style={{ fontSize: 11, color: T.faint, flexShrink: 0 }}>{open ? "▾" : "›"}</span>
</button>
);
}
@@ -453,6 +457,8 @@ export function NoteList({
onOpenChapter,
onAddChapter,
onChapterContextMenu,
expandedChapter,
renderChapterNotes,
onMoveNote,
onReorder,
onToggleSidebar,
@@ -471,6 +477,9 @@ export function NoteList({
or the trash, which are views over notebooks rather than a place to file
anything, and not inside a chapter, because a chapter holds notes. */
const canAddChapter = !!onAddChapter && !openChapter;
/* `openChapter` means this list *is* the chapter — the fallback where there is
no room beside or below, so the chapter fills the list and the header offers
a way back. In the other two views the chapter rows stay where they are. */
const shownChapters = openChapter ? [] : chapters || [];
return (
@@ -705,19 +714,10 @@ export function NoteList({
something there: in "newest first" the notes would slide past a
line on every edit. They are not shown in the other orders, and
are still in place on the way back. */}
{shownChapters.map((chapter) => (
<ChapterRow
key={chapter.id}
T={T}
chapter={chapter}
onOpen={onOpenChapter}
onContextMenu={onChapterContextMenu}
/>
))}
{shownChapters.length > 0 && notes.length > 0 && (
<div style={{ height: 1, background: T.borderLight, margin: "8px 4px 10px" }} />
)}
{(reorderable ? mergeEntries(notes, separators) : notes.map(asNoteEntry)).map((entry) => {
{(reorderable
? mergeEntries(notes, separators, shownChapters)
: [...shownChapters.map(asChapterEntry), ...notes.map(asNoteEntry)]
).map((entry) => {
const showDropLine = reorderable && dnd.overId === entry.id;
/* The whole row, indicator included, is the drop target.
*
@@ -728,6 +728,25 @@ export function NoteList({
const rowProps = reorderable ? dnd.rowProps(entry.id) : {};
const lifted = dnd.draggingId === entry.id;
if (entry.kind === "chapter") {
return (
<div key={entry.id} {...rowProps} style={{ opacity: lifted ? 0.45 : 1 }}>
{showDropLine && <DropLine T={T} />}
<div style={{ display: "flex", alignItems: "stretch", gap: 2 }}>
{reorderable && <Grip T={T} {...dnd.gripProps} />}
<ChapterRow
T={T}
chapter={entry.chapter}
open={expandedChapter === entry.id}
onOpen={onOpenChapter}
onContextMenu={onChapterContextMenu}
/>
</div>
{expandedChapter === entry.id && renderChapterNotes?.(entry.chapter)}
</div>
);
}
if (entry.kind === "separator") {
return (
<div key={entry.id} {...rowProps} style={{ opacity: lifted ? 0.45 : 1 }}>
+67 -26
View File
@@ -12,7 +12,7 @@
* scramble everything that was not being looked at.
*/
import type { NoteMeta, Separator, SortMode } from "../../types";
import type { Chapter, NoteMeta, Separator, SortMode } from "../../types";
export const DEFAULT_SORT: SortMode = "updated";
@@ -86,6 +86,7 @@ export function sortNotes(notes: NoteMeta[], mode: SortMode = DEFAULT_SORT): Not
*/
export type Entry =
| { kind: "note"; id: string; order: number; note: NoteMeta }
| { kind: "chapter"; id: string; order: number; chapter: Chapter }
| { kind: "separator"; id: string; order: number; separator: Separator };
/**
@@ -94,51 +95,90 @@ export type Entry =
* A separator sitting at the same position as a note comes first, so "insert a
* line above this note" lands where it was asked for rather than one row late.
*/
export function mergeEntries(notes: NoteMeta[], separators: Separator[] = []): Entry[] {
/* Notes are placed by their rank, not by their stored number. Notes that have
* never been dragged all carry 0, and sorting has already broken that tie —
* using the stored numbers here would pile them all at position 0 and push
* every separator behind the lot. */
const noteEntries: Entry[] = sortNotes(notes, "manual").map((note, rank) => ({
kind: "note" as const,
id: note.id,
order: rank,
note,
}));
export function mergeEntries(
notes: NoteMeta[],
separators: Separator[] = [],
chapters: Chapter[] = []
): Entry[] {
/* Notes and chapters share one sequence.
*
* A chapter is a row in the list like a note is, and can be dragged among
* them, so they cannot each be numbered from zero — they are ranked together
* by the position each carries.
*
* Ranked, not placed by the stored number: rows that have never been dragged
* all carry 0, and using the raw numbers would pile them at position 0 and
* push every separator behind the lot. */
const ranked = sortNotes(notes, "manual");
const rows = [
...chapters.map((chapter, i) => ({
kind: "chapter" as const,
id: chapter.id,
chapter,
sort: chapter.sortOrder ?? 0,
// Keeps the order within one kind settled when several share a position,
// which every row does until it has been dragged once.
tie: i,
})),
...ranked.map((note, i) => ({ kind: "note" as const, id: note.id, note, sort: note.sortOrder ?? 0, tie: i })),
].sort(
(a, b) =>
a.sort - b.sort ||
// A chapter shares a position with a note only before either has been
// moved; putting chapters first there matches where they are created.
(a.kind === b.kind ? a.tie - b.tie : a.kind === "chapter" ? -1 : 1)
);
const rowEntries: Entry[] = rows.map((row, rank) =>
row.kind === "chapter"
? { kind: "chapter", id: row.id, order: rank, chapter: row.chapter }
: { kind: "note", id: row.id, order: rank, note: row.note }
);
const separatorEntries: Entry[] = separators.map((separator) => ({
kind: "separator" as const,
id: separator.id,
// Clamped, so a separator left over from a notebook that has since shrunk
// still appears — at the end — rather than vanishing with its label.
order: Math.max(0, Math.min(separator.order, noteEntries.length)),
order: Math.max(0, Math.min(separator.order, rowEntries.length)),
separator,
}));
return [...noteEntries, ...separatorEntries].sort(
// A separator sitting at the same position as a note comes first, so
// "a line above this note" lands where it was asked for.
return [...rowEntries, ...separatorEntries].sort(
// A separator sitting at the same position as a row comes first, so
// "a line above this one" lands where it was asked for.
(a, b) => a.order - b.order || (a.kind === "separator" ? -1 : 1)
);
}
/** The result of a drag: what to write back for notes and for separators. */
/** The result of a drag: what to write back for each kind of row. */
export interface Arrangement {
noteIds: string[];
/** Notes in their new order, with the position each should carry. */
notes: Array<{ id: string; order: number }>;
/** Chapters likewise — numbered from the *same* counter as the notes. */
chapters: Array<{ id: string; order: number }>;
separators: Separator[];
/** The note ids alone, for callers that only reorder notes. */
noteIds: string[];
}
/** Turns an ordered entry list back into storable positions. */
export function toArrangement(entries: Entry[]): Arrangement {
const noteIds: string[] = [];
const notes: Array<{ id: string; order: number }> = [];
const chapters: Array<{ id: string; order: number }> = [];
const separators: Separator[] = [];
/* One counter for notes and chapters together. Numbering them separately
* would leave two sequences that cannot be interleaved again, and a chapter
* dragged between two notes would spring back. */
let position = 0;
for (const entry of entries) {
if (entry.kind === "note") noteIds.push(entry.id);
// A separator's position is "how many notes come before me". Expressed that
// way it stays put as the notes around it are renamed, edited or opened.
else separators.push({ ...entry.separator, order: noteIds.length });
if (entry.kind === "note") notes.push({ id: entry.id, order: position++ });
else if (entry.kind === "chapter") chapters.push({ id: entry.id, order: position++ });
// A separator's position is "how many rows come before me". Expressed that
// way it stays put as the rows around it are renamed, edited or opened.
else separators.push({ ...entry.separator, order: position });
}
return { noteIds, separators };
return { notes, chapters, separators, noteIds: notes.map((n) => n.id) };
}
/**
@@ -151,10 +191,11 @@ export function reindexEntries(
notes: NoteMeta[],
separators: Separator[],
dragId: string,
targetId: string
targetId: string,
chapters: Chapter[] = []
): Arrangement | null {
if (dragId === targetId) return null;
const entries = mergeEntries(notes, separators);
const entries = mergeEntries(notes, separators, chapters);
const from = entries.findIndex((e) => e.id === dragId);
if (from < 0) return null;
+23 -3
View File
@@ -54,6 +54,7 @@ export interface NoteData {
chapters: Chapter[];
createChapter: (notebookId: string, name: string) => Promise<Chapter>;
renameChapter: (id: string, name: string) => Promise<void>;
recolourChapter: (id: string, color: string | null) => Promise<void>;
deleteChapter: (id: string) => Promise<void>;
deleteNotebook: (id: string) => Promise<void>;
reorderNotebooks: (dragId: string, targetId: string) => void;
@@ -79,6 +80,8 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
notesRef.current = notes;
const notebooksRef = useRef(notebooks);
notebooksRef.current = notebooks;
const chaptersRef = useRef(chapters);
chaptersRef.current = chapters;
const contentsRef = useRef(noteContents);
contentsRef.current = noteContents;
@@ -300,14 +303,25 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
const all = notebook.separators || [];
const mine = all.filter((sep) => (sep.chapterId ?? null) === chapterId);
const others = all.filter((sep) => (sep.chapterId ?? null) !== chapterId);
/* Chapters are rows in this list too, and only in the notebook itself — a
chapter has no chapters of its own. */
const inList = chapterId
? []
: chaptersRef.current.filter((c) => c.notebookId === notebookId);
const next = reindexEntries(inNotebook, mine, dragId, targetId);
const next = reindexEntries(inNotebook, mine, dragId, targetId, inList);
if (!next) return;
const merged = [...others, ...next.separators.map((sep) => ({ ...sep, chapterId }))];
const position = new Map(next.noteIds.map((id, i) => [id, i]));
const position = new Map(next.notes.map((n) => [n.id, n.order]));
setNotes((p) => p.map((n) => (position.has(n.id) ? { ...n, sortOrder: position.get(n.id)! } : n)));
void sync.reorderNotes(next.noteIds);
void sync.reorderNotes(next.notes);
if (next.chapters.length) {
const chapterAt = new Map(next.chapters.map((c) => [c.id, c.order]));
setChapters((p) => p.map((c) => (chapterAt.has(c.id) ? { ...c, sortOrder: chapterAt.get(c.id)! } : c)));
void sync.reorderChapters(next.chapters);
}
/* Only written when it actually changed.
*
@@ -353,6 +367,11 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
await sync.updateChapter(id, { name });
}, []);
const recolourChapter = useCallback(async (id: string, color: string | null) => {
setChapters((p) => p.map((c) => (c.id === id ? { ...c, color } : c)));
await sync.updateChapter(id, { color });
}, []);
const deleteChapter = useCallback(
async (id: string) => {
await sync.deleteChapter(id);
@@ -416,6 +435,7 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
chapters,
createChapter,
renameChapter,
recolourChapter,
deleteChapter,
deleteNotebook,
reorderNotebooks,
+42 -8
View File
@@ -83,6 +83,7 @@ interface ServerChapter {
id: string;
notebook_id: string;
name: string;
color?: string | null;
sort_order?: number;
}
@@ -530,7 +531,7 @@ type QueuedAction =
}
| { action: "deleteNotebook"; data: { id: string } }
| { action: "createChapter"; data: { localId: string; notebookId: string; name: string } }
| { action: "updateChapter"; data: { id: string; name?: string; sortOrder?: number } }
| { action: "updateChapter"; data: { id: string; name?: string; sortOrder?: number; color?: string | null } }
| { action: "deleteChapter"; data: { id: string } }
| {
action: "createNote";
@@ -660,7 +661,11 @@ async function processEntry(entry: QueueEntry): Promise<void> {
break;
}
case "updateChapter":
await api.put(`/chapters/${a.data.id}`, { name: a.data.name, sortOrder: a.data.sortOrder });
await api.put(`/chapters/${a.data.id}`, {
name: a.data.name,
sortOrder: a.data.sortOrder,
color: a.data.color,
});
break;
case "deleteChapter":
await api.del(`/chapters/${a.data.id}`);
@@ -792,6 +797,7 @@ export async function fetchChapters(): Promise<Chapter[]> {
id: c.id,
notebookId: c.notebook_id,
name: c.name,
color: c.color ?? null,
sortOrder: c.sort_order || 0,
}));
const local = await db.getAllChapters();
@@ -825,7 +831,10 @@ export async function createChapter(notebookId: string, name: string): Promise<C
return chapter;
}
export async function updateChapter(id: string, updates: { name?: string; sortOrder?: number }): Promise<void> {
export async function updateChapter(
id: string,
updates: { name?: string; sortOrder?: number; color?: string | null }
): Promise<void> {
const chapter = (await db.getAllChapters()).find((c) => c.id === id);
if (!chapter) return;
await db.putChapter({ ...chapter, ...updates });
@@ -998,15 +1007,40 @@ export async function getNotes(): Promise<NoteMeta[]> {
* note update would be wrong for the same reason — it is sent when there is a
* connection and simply re-derived from the local order otherwise.
*/
export async function reorderNotes(orderedIds: string[]): Promise<void> {
/**
* Writes new positions for notes.
*
* Positions are named rather than taken from the array index: chapters share
* the sequence, so a note's place among the notes is no longer its place in the
* list. A bare list of ids still works and is numbered in order.
*/
export async function reorderNotes(
order: string[] | Array<{ id: string; order: number }>
): Promise<void> {
const positions = order.map((entry, i) =>
typeof entry === "string" ? { id: entry, order: i } : entry
);
const notes = await db.getAllNotes();
for (let i = 0; i < orderedIds.length; i++) {
const note = notes.find((n) => n.id === orderedIds[i]);
if (note && note.sortOrder !== i) await db.putNote({ ...note, sortOrder: i });
for (const p of positions) {
const note = notes.find((n) => n.id === p.id);
if (note && note.sortOrder !== p.order) await db.putNote({ ...note, sortOrder: p.order });
}
notifyData("notes", null);
if (_online && api.token) {
api.post("/notes/reorder", { ids: orderedIds }).catch(() => {});
api.post("/notes/reorder", { positions }).catch(() => {});
}
}
/** The same, for chapters — they are rows in that one sequence too. */
export async function reorderChapters(positions: Array<{ id: string; order: number }>): Promise<void> {
const chapters = await db.getAllChapters();
for (const p of positions) {
const chapter = chapters.find((c) => c.id === p.id);
if (chapter && chapter.sortOrder !== p.order) await db.putChapter({ ...chapter, sortOrder: p.order });
}
notifyData("chapters", null);
if (_online && api.token) {
api.post("/chapters/reorder", { positions }).catch(() => {});
}
}
+12
View File
@@ -105,6 +105,8 @@ export interface Chapter {
id: string;
notebookId: string;
name: string;
/** `#rrggbb` for its icon, so a shelf of chapters can be told apart. */
color?: string | null;
sortOrder: number;
/** Set while a chapter created offline has not reached the server yet. */
_pendingCreate?: boolean;
@@ -129,8 +131,18 @@ export interface Notebook {
/** How the note body is set. Presentation only — the export is unaffected. */
export type NoteFont = "serif" | "sans" | "mono";
/**
* How a chapter opens.
*
* "column" puts its notes in a further column beside the list, the way OneNote
* lays out its pages; "inline" unfolds them under the chapter's own row. A
* phone has room for neither and fills the screen either way, with a way back.
*/
export type ChapterView = "column" | "inline";
export interface UserPreferences {
noteFont: NoteFont;
chapterView?: ChapterView;
}
export interface User {
+45 -2
View File
@@ -4,13 +4,15 @@ import { I } from "./icons.jsx";
/* ═══════ ACCOUNT MENU ═══════ */
export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin, onExportAll, dark }) {
const chooseFont = async (noteFont) => {
const choosePreference = async (change) => {
try {
onUserUpdate(await sync.updatePreferences({ noteFont }));
onUserUpdate(await sync.updatePreferences(change));
} catch {
/* the choice is presentation; failing to store it must not block the panel */
}
};
const chooseFont = (noteFont) => choosePreference({ noteFont });
const chooseChapterView = (chapterView) => choosePreference({ chapterView });
const [tab, setTab] = useState("profile"); // profile | username | password
const [displayName, setDisplayName] = useState(user?.displayName || "");
const [newUsername, setNewUsername] = useState(user?.username || "");
@@ -305,6 +307,47 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin
})}
</div>
</div>
{/* How a chapter opens. Kept beside the typeface because both are
the same kind of thing: how your own notes are laid out, stored
on the account so the answer follows you to the phone. */}
<div style={{ marginTop: 18 }}>
<label style={{ fontSize: 12, color: T.muted, marginBottom: 6, display: "block" }}>
Chapters open
</label>
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 8 }}>
{[
["column", "Beside the list", "Its notes in a further column, the way OneNote lays out pages"],
["inline", "Under the chapter", "Its notes unfold in place, below the chapter's own row"],
].map(([value, label, hint]) => {
const active = (user.preferences?.chapterView || "column") === value;
return (
<button
key={value}
data-chapter-view={value}
onClick={() => chooseChapterView(value)}
title={hint}
style={{
padding: "10px 8px",
borderRadius: 10,
border: `1.5px solid ${active ? T.accent : T.border}`,
background: active ? T.accent + "15" : "transparent",
color: active ? T.accent : T.text,
cursor: "pointer",
fontSize: 12.5,
textAlign: "left",
lineHeight: 1.35,
}}
>
{label}
</button>
);
})}
</div>
<div style={{ fontSize: 11, color: T.muted, marginTop: 6, lineHeight: 1.5 }}>
A phone has room for neither, and fills the screen either way — with a way back.
</div>
</div>
</>
)}
+18
View File
@@ -41,6 +41,24 @@ export const COVERS = [
{ bg: "linear-gradient(135deg,#654ea3,#eaafc8)", spine: "#4e3c7e", accent: "#f8bbd0" },
];
/**
* Colours a chapter's icon can be given.
*
* A short, named list rather than the full palette: the point is telling one
* chapter from another at a glance, and twenty near-identical blues do not help
* with that. Each is legible on both themes.
*/
export const CHAPTER_COLORS = [
{ label: "Red", value: "#e5534b" },
{ label: "Orange", value: "#e07c24" },
{ label: "Yellow", value: "#d4a017" },
{ label: "Green", value: "#2da44e" },
{ label: "Teal", value: "#1f9c96" },
{ label: "Blue", value: "#4a8ff5" },
{ label: "Purple", value: "#a371f7" },
{ label: "Pink", value: "#db61a2" },
];
/* The icon is a key rather than an emoji: the sidebar draws it from the app's
* own set, so it matches the stroke weight of everything around it and looks
* the same on every machine. */