Files
feuerwerk/src/editor/FilePicker.ts
T
adminandClaude Opus 5.5 925a39a276
feuerwerk / gate (push) Successful in 4m51s
feuerwerk / suite (push) Successful in 19m23s
Let guests try a demo project without an account (#428)
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>
2026-10-16 06:58:30 +02:00

491 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
}
}