Restores the commit reverted off the routing branch, which is where it was committed by mistake. The change is unmodified; only its branch is. The map had no memory. Every visit started at the same overview, and a system worth returning to had to be found again by name each time. A mark on the readout and on a body's panel now keeps it, a Bookmarks tab lists what has been kept, and choosing one goes there — a star by flying into its system, a body by opening its page. Local storage, not an account. This map asks nobody to sign in, and a list of stars somebody liked is not worth a server. Every read of that store is defensive, because it is a string a person can edit, another tab can write, and a browser can refuse to hand over at all: a bad entry is skipped rather than losing the rest, duplicates are collapsed since two entries for one place would each toggle the other's control, the list is bounded so a hand-edited store cannot decide how much this renders, and where storage is denied outright the bookmarks still work for the visit — they just do not outlive it. The name is stored alongside the id rather than looked up, so the list reads before the catalogues have loaded, and a bookmark to something a later catalogue no longer holds still says what it was instead of decaying into a bare number. The tab is offered even when it is empty, and says what the mark does: a tab that appears only once you have already found the feature is a tab that never taught anyone anything. Choosing a kept place hands the panel back to the readout, the same move as choosing a search result and for the same reason. That behaviour is what the end-to-end spec caught missing — the readout it asserted on did not exist, because the panel just used was still covering it. Verified: build clean, 587/587 unit, 11/11 end-to-end, design detector clean, screenshots at 1440x900 and 390x844. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
112 lines
4.0 KiB
TypeScript
112 lines
4.0 KiB
TypeScript
import { Injectable, signal } from '@angular/core';
|
|
|
|
/**
|
|
* A place someone chose to keep. Either a star, which is a system to fly into, or a body, which
|
|
* is a page to open — the two things the map lets you arrive at.
|
|
*/
|
|
export interface Bookmark {
|
|
readonly kind: 'star' | 'body';
|
|
/** A HYG star id, or a `bodies.json`/`exoplanets.json` id. */
|
|
readonly id: number | string;
|
|
/**
|
|
* The name as it read when it was kept. Stored rather than looked up, so the list can be
|
|
* shown before the catalogues have loaded — and so a bookmark to something a later catalogue
|
|
* no longer holds still says what it was rather than becoming a bare id.
|
|
*/
|
|
readonly name: string;
|
|
}
|
|
|
|
const STORAGE_KEY = 'star-map.bookmarks';
|
|
|
|
/**
|
|
* How many are kept. Not a limit anyone will reach by hand — it is a bound on what a corrupted
|
|
* or hand-edited store can make the app render, and on what is written back.
|
|
*/
|
|
const MAX_BOOKMARKS = 200;
|
|
|
|
/** `${kind}:${id}`, since a star id and a body id are different kinds of thing. */
|
|
function keyOf(kind: Bookmark['kind'], id: number | string): string {
|
|
return `${kind}:${id}`;
|
|
}
|
|
|
|
function isBookmark(value: unknown): value is Bookmark {
|
|
if (typeof value !== 'object' || value === null) {
|
|
return false;
|
|
}
|
|
const candidate = value as Partial<Bookmark>;
|
|
return (
|
|
(candidate.kind === 'star' || candidate.kind === 'body') &&
|
|
(typeof candidate.id === 'number' || typeof candidate.id === 'string') &&
|
|
typeof candidate.name === 'string'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The places kept between visits, in this browser and nowhere else.
|
|
*
|
|
* Local storage rather than an account: the map asks nobody to sign in, and a list of stars
|
|
* somebody liked is not worth a server. Every read of it is defensive — the store is a string
|
|
* a user can edit, another tab can write, and a browser can refuse to give at all — and a
|
|
* failure to read or write one is never allowed to take the map down with it.
|
|
*/
|
|
@Injectable({ providedIn: 'root' })
|
|
export class BookmarksStore {
|
|
private readonly kept = signal<readonly Bookmark[]>(this.read());
|
|
|
|
/** Most recently kept first, which is the order they are useful in. */
|
|
readonly bookmarks = this.kept.asReadonly();
|
|
|
|
has(kind: Bookmark['kind'], id: number | string): boolean {
|
|
return this.kept().some((bookmark) => bookmark.kind === kind && bookmark.id === id);
|
|
}
|
|
|
|
/** Keeps a place, or drops it if it was already kept. Returns whether it is kept now. */
|
|
toggle(bookmark: Bookmark): boolean {
|
|
const kept = this.has(bookmark.kind, bookmark.id);
|
|
this.write(kept ? this.kept().filter((other) => !(other.kind === bookmark.kind && other.id === bookmark.id)) : [bookmark, ...this.kept()].slice(0, MAX_BOOKMARKS));
|
|
return !kept;
|
|
}
|
|
|
|
remove(kind: Bookmark['kind'], id: number | string): void {
|
|
this.write(this.kept().filter((bookmark) => !(bookmark.kind === kind && bookmark.id === id)));
|
|
}
|
|
|
|
private write(bookmarks: readonly Bookmark[]): void {
|
|
this.kept.set(bookmarks);
|
|
try {
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(bookmarks));
|
|
} catch {
|
|
// Full, disabled, or private-mode storage. The list still works for this visit; it just
|
|
// will not outlive it, which is a smaller loss than the alternative of failing here.
|
|
}
|
|
}
|
|
|
|
private read(): Bookmark[] {
|
|
let raw: string | null = null;
|
|
try {
|
|
raw = localStorage.getItem(STORAGE_KEY);
|
|
} catch {
|
|
return [];
|
|
}
|
|
if (!raw) {
|
|
return [];
|
|
}
|
|
try {
|
|
const parsed: unknown = JSON.parse(raw);
|
|
if (!Array.isArray(parsed)) {
|
|
return [];
|
|
}
|
|
// Filtered rather than rejected wholesale: one bad entry should not lose the others, and
|
|
// deduplicated because two entries for one place would each toggle the other's control.
|
|
const seen = new Set<string>();
|
|
return parsed
|
|
.filter(isBookmark)
|
|
.filter((bookmark) => !seen.has(keyOf(bookmark.kind, bookmark.id)) && seen.add(keyOf(bookmark.kind, bookmark.id)))
|
|
.slice(0, MAX_BOOKMARKS)
|
|
.map(({ kind, id, name }) => ({ kind, id, name }));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
}
|