The editor was only open to somebody with an account. A guest now picks
a demo project on the sign-in screen and gets the whole editor on a copy
held in the tab. The server writes nothing for a guest, so there is no
guest session, no guest folder and no quota to keep.
- Demo copies are frozen copies like a release, in data/demos/. An
administrator makes, updates and removes one with "Demo copy..." in
the project's main menu. GET demos and GET demos/{token} need no
session. An empty project is just a demo that is empty.
- Demo material: an administrator offers an uploaded file with "Offer
as demo" in the pickers (PATCH {demo}, data/demo-files.json). Every
list carries it with demo: true, for accounts and guests alike.
Signed out, a list holds the demo material alone, without the owner.
- An offered file is only an administrator's to delete. A file a demo
copy names counts as used, like one a release names.
- The guest's project lives in MemoryProjectStore. Every store that
would write refuses in the tab before a request is made, and the
autosave is not attached at all.
- The main menu drops projects, snapshots and release for a guest. The
pickers drop Upload and Delete, a dropped or pasted file is refused,
and a recording is saved as a download.
- The toolbar says the work is not saved. The Account menu holds
"Sign in..." instead of Logout and the password.
- A guest's demo never goes into the address: a reload brings the
sign-in screen back, not the copy. Found by e2e 70.
- GET plugins needs no session any more, since a demo may switch one
on. It only reads the server's own modules.
Tests:
- e2e 70 (atx): ALL PASSED, 13 checks. Counter-test with the guest
autosave sent to the server: 70f and 70g red on the PUT.
- tests/core/guest.test.ts: 9 tests. PHP: demos 20, files 46,
rights 80 checks (the matrix asks every new route).
- Gate (atx): CHECK EXIT 0, 314 files, 4852 tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
491 lines
17 KiB
TypeScript
491 lines
17 KiB
TypeScript
/**
|
||
* The dialog behind "+ → Texture / Font / Sound": everything the server holds of one kind, with
|
||
* what the project already calls it. Offering the server's files rather than the machine's is the
|
||
* point – uploading the same file twice is wasteful, and nothing collects the files a project
|
||
* stopped referencing. Uploading is still one button away.
|
||
*
|
||
* The list differs per kind (tiles for pictures, rows with a sample line for typefaces, rows with a
|
||
* ▶ for sounds); the dialog around it does not.
|
||
*/
|
||
import type { Container, FederatedPointerEvent, Graphics, Text } from 'pixi.js';
|
||
import type { AssetType } from '../core/model/AssetTypes';
|
||
import type { Project } from '../core/model/Project';
|
||
import type { StoredFile } from '../core/store/FileStore';
|
||
import {
|
||
Button,
|
||
drawControlBox,
|
||
type HStack,
|
||
formatBytes,
|
||
Label,
|
||
Popup,
|
||
pressCount,
|
||
ScrollContainer,
|
||
Theme,
|
||
textStyle,
|
||
Widget,
|
||
crispText,
|
||
} from '../ui';
|
||
import { dialogButtons } from './FormDialog';
|
||
import { allows } from './session';
|
||
|
||
/** The dialog keeps this size whatever the list turns out to be, so it does not jump on opening. */
|
||
export const PICKER_WIDTH = 560;
|
||
const PICKER_HEIGHT = 340;
|
||
|
||
/** One offered file, with the asset that already wears it (if any). */
|
||
export type PickerItem<S extends StoredFile = StoredFile> = S & { assetName: string | null };
|
||
|
||
/** Pairs what the server lists with what the project calls it – the asset name is the caption worth showing. */
|
||
export function withAssetNames<S extends StoredFile>(
|
||
stored: S[],
|
||
project: Project,
|
||
type: AssetType,
|
||
): PickerItem<S>[] {
|
||
const named = new Map<string, string>();
|
||
for (const asset of project.assets) {
|
||
if (asset.type === type && 'file' in asset) named.set(asset.file, asset.name);
|
||
}
|
||
return stored.map((item) => ({ ...item, assetName: named.get(item.file) ?? null }));
|
||
}
|
||
|
||
/** Whose a file is (#344), as the line under the list says it. */
|
||
function ownerText(owner: string | null): string {
|
||
return owner === null ? 'no owner' : owner === '' ? 'owner deleted' : `owner ${owner}`;
|
||
}
|
||
|
||
/** The line under the list for a picked file: its upload name and size, whose it is, and who wears it. */
|
||
function describeStored(item: PickerItem): string {
|
||
const size = item.name ? `${item.name} – ${formatBytes(item.bytes)}` : formatBytes(item.bytes);
|
||
const owned = `${size} – ${ownerText(item.owner)}${demoText(item)}`;
|
||
return item.assetName ? `${owned} – already in the project as "${item.assetName}"` : owned;
|
||
}
|
||
|
||
/** Said after what a file is when an administrator offers it to everybody (#428). */
|
||
export function demoText(item: StoredFile): string {
|
||
return item.demo ? ' – demo material' : '';
|
||
}
|
||
|
||
export interface PickerRow {
|
||
/** A Container holding the rest, not a Graphics with children: Pixi v8 only lets containers have them. */
|
||
box: Container;
|
||
background: Graphics;
|
||
caption: Text;
|
||
}
|
||
|
||
/**
|
||
* The selectable part of the dialog: one entry per file, one of them picked, a double press
|
||
* confirms. How entries are built, placed and drawn is the subclass's.
|
||
*/
|
||
export abstract class PickerList<Item extends PickerItem, Row extends PickerRow> extends Widget {
|
||
protected items: Item[] = [];
|
||
protected rows: Row[] = [];
|
||
private selectedFile: string | null = null;
|
||
private readonly empty = crispText({ text: '', style: textStyle('muted') });
|
||
private readonly unwatch: (() => void)[] = [];
|
||
onPick?: (file: string) => void;
|
||
onActivate?: (file: string) => void;
|
||
emptyText = '';
|
||
|
||
constructor() {
|
||
super();
|
||
this.addChild(this.empty);
|
||
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPress(e));
|
||
}
|
||
|
||
/** Redraws whenever a library reports an arrival, until the list is destroyed. */
|
||
protected watch(library: { onChange(fn: () => void): () => void }, onArrive: () => void): void {
|
||
this.unwatch.push(library.onChange(onArrive));
|
||
}
|
||
|
||
setItems(items: Item[]): void {
|
||
this.items = items;
|
||
for (const row of this.rows) row.box.destroy({ children: true });
|
||
this.rows = items.map(() => {
|
||
const row = this.buildRow();
|
||
this.addChild(row.box);
|
||
return row;
|
||
});
|
||
if (!items.some((item) => item.file === this.selectedFile)) this.selectedFile = null;
|
||
this.layout();
|
||
this.redraw();
|
||
}
|
||
|
||
get selected(): Item | null {
|
||
return this.items.find((item) => item.file === this.selectedFile) ?? null;
|
||
}
|
||
|
||
get fileList(): string[] {
|
||
return this.items.map((item) => item.file);
|
||
}
|
||
|
||
get usedFiles(): string[] {
|
||
return this.items.filter((item) => item.assetName !== null).map((item) => item.file);
|
||
}
|
||
|
||
get demoFiles(): string[] {
|
||
return this.items.filter((item) => item.demo === true).map((item) => item.file);
|
||
}
|
||
|
||
/** The captions as drawn, read off the rows themselves rather than recomputed (test hook). */
|
||
get captions(): string[] {
|
||
return this.rows.map((row) => row.caption.text);
|
||
}
|
||
|
||
select(file: string | null): void {
|
||
if (file === this.selectedFile) return;
|
||
this.selectedFile = file;
|
||
this.redraw();
|
||
if (file) this.onPick?.(file);
|
||
}
|
||
|
||
/** The row of a file, with its index; null when the file is not offered. */
|
||
protected rowOf(file: string): { row: Row; index: number } | null {
|
||
const index = this.items.findIndex((item) => item.file === file);
|
||
const row = this.rows[index];
|
||
return row ? { row, index } : null;
|
||
}
|
||
|
||
override redraw(): void {
|
||
this.empty.text = this.items.length === 0 ? this.emptyText : '';
|
||
this.empty.position.set(0, 0);
|
||
this.rows.forEach((row, index) => {
|
||
const item = this.items[index];
|
||
if (item) this.redrawRow(row, item, item.file === this.selectedFile);
|
||
});
|
||
}
|
||
|
||
override destroy(): void {
|
||
for (const stop of this.unwatch) stop();
|
||
super.destroy({ children: true });
|
||
}
|
||
|
||
protected drawBox(row: Row, w: number, h: number, selected: boolean): void {
|
||
row.background.clear();
|
||
drawControlBox(row.background, w, h, {
|
||
fill: selected ? Theme.colors.selection : Theme.colors.control,
|
||
border: selected ? Theme.colors.accent : Theme.colors.border,
|
||
});
|
||
}
|
||
|
||
protected onPress(e: FederatedPointerEvent): void {
|
||
const local = this.toLocal(e.global);
|
||
const item = this.items[this.indexAt(local.x, local.y)];
|
||
if (!item) return;
|
||
this.select(item.file);
|
||
if (pressCount(e, item.file) === 2) this.onActivate?.(item.file);
|
||
}
|
||
|
||
protected abstract buildRow(): Row;
|
||
/** Which entry a local point is on; any index without an item means none. */
|
||
protected abstract indexAt(x: number, y: number): number;
|
||
protected abstract redrawRow(row: Row, item: Item, selected: boolean): void;
|
||
}
|
||
|
||
/** A list of equally tall rows, one under the other. */
|
||
export abstract class RowList<Item extends PickerItem, Row extends PickerRow> extends PickerList<Item, Row> {
|
||
protected abstract readonly rowHeight: number;
|
||
protected readonly rowGap = 4;
|
||
|
||
/** Global centre of a row (test hook); `fromX` keeps clear of a column that does something else. */
|
||
rowGlobal(file: string, fromX = 0): { x: number; y: number } | null {
|
||
const found = this.rowOf(file);
|
||
if (!found) return null;
|
||
const p = found.row.box.getGlobalPosition();
|
||
return { x: p.x + fromX + (this.w - fromX) / 2, y: p.y + this.rowHeight / 2 };
|
||
}
|
||
|
||
heightOfRows(count: number): number {
|
||
return count === 0 ? 0 : count * this.rowHeight + (count - 1) * this.rowGap;
|
||
}
|
||
|
||
override measure(): { w: number; h: number } {
|
||
return { w: this.minWidth, h: Math.max(this.minHeight, this.heightOfRows(this.items.length)) };
|
||
}
|
||
|
||
override layout(): void {
|
||
this.rows.forEach((row, index) => row.box.position.set(0, index * (this.rowHeight + this.rowGap)));
|
||
}
|
||
|
||
protected override indexAt(x: number, y: number): number {
|
||
return x >= 0 && x <= this.w ? Math.floor(y / (this.rowHeight + this.rowGap)) : -1;
|
||
}
|
||
}
|
||
|
||
/** What the Delete button says while it waits for the second press that really deletes. */
|
||
const ARMED_LABEL = 'Delete for good';
|
||
const OFFER_LABEL = 'Offer as demo';
|
||
const WITHDRAW_LABEL = 'Stop offering';
|
||
|
||
interface ButtonInfo {
|
||
enabled: boolean;
|
||
label: string;
|
||
shown: string;
|
||
x: number;
|
||
y: number;
|
||
}
|
||
|
||
export interface PickerWords {
|
||
title: string;
|
||
loading: string;
|
||
/** Under the list while nothing is picked; a hint for what the rows can do. */
|
||
idle?: string;
|
||
empty: string;
|
||
use: string;
|
||
/** "image", "font", "sound" – for "3 fonts on the server". */
|
||
noun: string;
|
||
}
|
||
|
||
let current: FilePicker<PickerItem> | null = null;
|
||
|
||
/**
|
||
* Opens the dialog centred, closing whichever picker was open before it, and starts the listing.
|
||
* The listing starts here and not in the constructor: it asks the subclass, and a subclass's own
|
||
* fields are not assigned until its constructor has run past `super()`.
|
||
*/
|
||
export function openPicker<P extends FilePicker<PickerItem>>(picker: P): P {
|
||
current?.close();
|
||
current = picker;
|
||
picker.openCentered();
|
||
void picker.load();
|
||
return picker;
|
||
}
|
||
|
||
/** The open dialog if it is one of `kind`, for the test hooks. */
|
||
export function currentPicker<P extends FilePicker<PickerItem>>(
|
||
kind: abstract new (...args: never[]) => P,
|
||
): P | null {
|
||
return current instanceof kind ? current : null;
|
||
}
|
||
|
||
export abstract class FilePicker<Item extends PickerItem> extends Popup {
|
||
protected readonly scroll = new ScrollContainer();
|
||
private readonly status: Label;
|
||
private readonly detail: Label;
|
||
private readonly use: Button;
|
||
private readonly remove: Button;
|
||
/** An administrator's switch for offering the picked file as demo material (#428). */
|
||
private readonly offer: Button | null;
|
||
private readonly buttons: HStack;
|
||
/** The file the Delete button was pressed for once; the second press deletes it. */
|
||
private armed: string | null = null;
|
||
|
||
constructor(
|
||
protected readonly list: PickerList<Item, PickerRow>,
|
||
private readonly words: PickerWords,
|
||
upload: () => void,
|
||
private readonly deleteFile: (file: string) => Promise<void>,
|
||
/** Buttons of a kind of picker of its own, after Delete; `picked` says what is picked. */
|
||
protected readonly extras: readonly Button[] = [],
|
||
/** There for an administrator only, see `FileStore.setDemo`. */
|
||
private readonly setDemo?: (file: string, demo: boolean) => Promise<void>,
|
||
) {
|
||
super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap });
|
||
this.content.add(new Label(words.title, { style: 'bodyBold' }));
|
||
this.status = new Label(words.loading, { style: 'muted' });
|
||
this.content.add(this.status);
|
||
|
||
list.minWidth = PICKER_WIDTH;
|
||
list.emptyText = words.empty;
|
||
list.onPick = () => this.refreshSelection();
|
||
list.onActivate = () => this.confirm();
|
||
this.scroll.minWidth = PICKER_WIDTH;
|
||
this.scroll.minHeight = PICKER_HEIGHT;
|
||
this.scroll.setContent(list);
|
||
this.content.add(this.scroll);
|
||
|
||
this.detail = new Label(words.idle ?? '', { style: 'muted' });
|
||
this.content.add(this.detail);
|
||
|
||
const uploadButton = new Button('Upload…');
|
||
uploadButton.onClick = this.closeThen(upload);
|
||
this.use = new Button(words.use, { variant: 'primary' });
|
||
this.use.onClick = () => this.confirm();
|
||
this.use.setEnabled(false);
|
||
this.remove = new Button('Delete', { variant: 'danger' });
|
||
this.remove.onClick = () => void this.pressDelete();
|
||
this.remove.setEnabled(false);
|
||
this.offer = setDemo ? new Button(OFFER_LABEL) : null;
|
||
if (this.offer) {
|
||
this.offer.onClick = () => void this.toggleDemo();
|
||
this.offer.setEnabled(false);
|
||
}
|
||
// A guest (#428) uploads and deletes nothing: the buttons are not there rather than refused.
|
||
const writes = allows('upload') ? [uploadButton, this.remove] : [];
|
||
this.buttons = dialogButtons(this.closeThen(), this.use, [
|
||
...writes,
|
||
...extras,
|
||
...(this.offer ? [this.offer] : []),
|
||
]);
|
||
this.content.add(this.buttons);
|
||
}
|
||
|
||
/**
|
||
* What the dialog offers, which of it the project already wears, what is picked, the line under
|
||
* the list, the Delete button with its centre and, for an administrator, the demo switch; a
|
||
* button that is not in the dialog is null (test hook).
|
||
*/
|
||
debugInfo(): {
|
||
files: string[];
|
||
used: string[];
|
||
demo: string[];
|
||
captions: string[];
|
||
selected: string | null;
|
||
detail: string;
|
||
deleteButton: ButtonInfo | null;
|
||
uploadShown: boolean;
|
||
offerButton: ButtonInfo | null;
|
||
} {
|
||
const info = (button: Button): ButtonInfo => {
|
||
const at = button.getGlobalPosition();
|
||
const centre = { x: at.x + button.w / 2, y: at.y + button.h / 2 };
|
||
return { enabled: button.enabled, label: button.text, shown: button.shownText, ...centre };
|
||
};
|
||
return {
|
||
files: this.list.fileList,
|
||
used: this.list.usedFiles,
|
||
demo: this.list.demoFiles,
|
||
captions: this.list.captions,
|
||
selected: this.list.selected?.file ?? null,
|
||
detail: this.detail.text,
|
||
deleteButton: this.remove.parent ? info(this.remove) : null,
|
||
uploadShown: this.remove.parent !== null,
|
||
offerButton: this.offer ? info(this.offer) : null,
|
||
};
|
||
}
|
||
|
||
/** The scrolled part of the dialog – what tells an entry in view from one past its edge (test hook). */
|
||
get listView(): ScrollContainer {
|
||
return this.scroll;
|
||
}
|
||
|
||
override close(): void {
|
||
super.close();
|
||
if (current === this) current = null;
|
||
// Escape, Cancel and a confirmed pick all land here, and `Popup.close` tolerates a second
|
||
// call; the list's listeners must be dropped exactly once.
|
||
if (!this.list.destroyed) this.list.destroy();
|
||
}
|
||
|
||
async load(): Promise<void> {
|
||
try {
|
||
const items = await this.fetchItems();
|
||
this.list.setItems(items);
|
||
// Only as tall as the entries need, up to the cap: a handful of files should not leave half
|
||
// a dialog of empty box under them.
|
||
const { unit, total } = this.contentHeight(items.length);
|
||
this.scroll.minHeight = Math.min(this.roomFor(this.scroll, unit), Math.max(unit, total));
|
||
const { noun } = this.words;
|
||
this.status.setText(
|
||
items.length === 1 ? `1 ${noun} on the server` : `${items.length} ${noun}s on the server`,
|
||
);
|
||
} catch (error) {
|
||
this.status.setText(
|
||
`Could not list the ${this.words.noun}s: ${error instanceof Error ? error.message : String(error)}`,
|
||
);
|
||
}
|
||
this.refreshSelection();
|
||
}
|
||
|
||
private refreshSelection(): void {
|
||
const item = this.list.selected;
|
||
this.armed = null;
|
||
this.setDeleteLabel('Delete');
|
||
this.use.setEnabled(item !== null && this.canUse(item));
|
||
this.remove.setEnabled(item?.deletable ?? false);
|
||
if (this.offer) {
|
||
this.offer.setEnabled(item !== null);
|
||
this.offer.setText(item?.demo ? WITHDRAW_LABEL : OFFER_LABEL);
|
||
this.buttons.layout();
|
||
}
|
||
this.picked(item);
|
||
this.detail.setText(item ? this.describe(item) : (this.words.idle ?? ''));
|
||
this.refreshLayout();
|
||
}
|
||
|
||
/** Says the `extras` what is picked now, null for nothing. */
|
||
protected picked(_item: Item | null): void {}
|
||
|
||
/** Shows a line under the list instead of what is picked, until the pick changes. */
|
||
protected say(text: string): void {
|
||
this.detail.setText(text);
|
||
this.refreshLayout();
|
||
}
|
||
|
||
/** A button does not measure itself again on a new text; the row around it has to (see `Button.setText`). */
|
||
private setDeleteLabel(label: string): void {
|
||
this.remove.setText(label);
|
||
this.buttons.layout();
|
||
}
|
||
|
||
/**
|
||
* Deleting a file cannot be taken back, so the first press only asks; the second deletes. The
|
||
* server refuses a file a project of any account still uses, and its reason is shown as it is.
|
||
*/
|
||
private async pressDelete(): Promise<void> {
|
||
const item = this.list.selected;
|
||
if (!item?.deletable) return;
|
||
if (this.armed !== item.file) {
|
||
this.armed = item.file;
|
||
this.setDeleteLabel(ARMED_LABEL);
|
||
this.detail.setText(`Press again to delete ${item.name || `this ${this.words.noun}`} from the server.`);
|
||
this.refreshLayout();
|
||
return;
|
||
}
|
||
this.remove.setEnabled(false);
|
||
try {
|
||
await this.deleteFile(item.file);
|
||
} catch (error) {
|
||
this.refreshSelection();
|
||
this.detail.setText(`Not deleted: ${error instanceof Error ? error.message : String(error)}`);
|
||
this.refreshLayout();
|
||
return;
|
||
}
|
||
if (this.list.destroyed) return;
|
||
await this.load();
|
||
this.detail.setText(`Deleted ${item.name || `the ${this.words.noun}`}.`);
|
||
this.refreshLayout();
|
||
}
|
||
|
||
/** Offers the picked file to everybody as demo material, or takes that back (#428). */
|
||
private async toggleDemo(): Promise<void> {
|
||
const item = this.list.selected;
|
||
if (!item || !this.setDemo) return;
|
||
const offered = !item.demo;
|
||
try {
|
||
await this.setDemo(item.file, offered);
|
||
} catch (error) {
|
||
this.say(`Not changed: ${error instanceof Error ? error.message : String(error)}`);
|
||
return;
|
||
}
|
||
if (this.list.destroyed) return;
|
||
await this.load();
|
||
this.list.select(item.file);
|
||
this.say(
|
||
offered
|
||
? `Every account and every guest can use ${item.name || `this ${this.words.noun}`} now.`
|
||
: `${item.name || `This ${this.words.noun}`} is no longer offered as demo material.`,
|
||
);
|
||
}
|
||
|
||
private confirm(): void {
|
||
const item = this.list.selected;
|
||
if (!item || !this.canUse(item)) return;
|
||
this.close();
|
||
this.adopt(item);
|
||
}
|
||
|
||
/** The server's list, paired with the project's names (`withAssetNames`). */
|
||
protected abstract fetchItems(): Promise<Item[]>;
|
||
/** How tall the list is for `count` entries, and the height of one – the least the scroll shows. */
|
||
protected abstract contentHeight(count: number): { unit: number; total: number };
|
||
protected abstract adopt(item: Item): void;
|
||
|
||
protected canUse(_item: Item): boolean {
|
||
return true;
|
||
}
|
||
|
||
protected describe(item: Item): string {
|
||
return describeStored(item);
|
||
}
|
||
}
|