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>
This commit is contained in:
1 parent
ce1f25f2eb
commit
925a39a276
30 files changed
+1397
-128
No files matched your search
+78
-12
@@ -26,6 +26,7 @@ import {
|
||||
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;
|
||||
@@ -55,10 +56,15 @@ function ownerText(owner: string | null): string {
|
||||
/** 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)}`;
|
||||
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;
|
||||
@@ -116,6 +122,10 @@ export abstract class PickerList<Item extends PickerItem, Row extends PickerRow>
|
||||
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);
|
||||
@@ -203,6 +213,16 @@ export abstract class RowList<Item extends PickerItem, Row extends PickerRow> ex
|
||||
|
||||
/** 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;
|
||||
@@ -243,6 +263,8 @@ export abstract class FilePicker<Item extends PickerItem> extends Popup {
|
||||
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;
|
||||
@@ -254,6 +276,8 @@ export abstract class FilePicker<Item extends PickerItem> extends Popup {
|
||||
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' }));
|
||||
@@ -280,36 +304,52 @@ export abstract class FilePicker<Item extends PickerItem> extends Popup {
|
||||
this.remove = new Button('Delete', { variant: 'danger' });
|
||||
this.remove.onClick = () => void this.pressDelete();
|
||||
this.remove.setEnabled(false);
|
||||
this.buttons = dialogButtons(this.closeThen(), this.use, [uploadButton, this.remove, ...extras]);
|
||||
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 and the Delete button with its centre (test hook).
|
||||
* 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: { enabled: boolean; label: string; shown: string; x: number; y: number };
|
||||
deleteButton: ButtonInfo | null;
|
||||
uploadShown: boolean;
|
||||
offerButton: ButtonInfo | null;
|
||||
} {
|
||||
const at = this.remove.getGlobalPosition();
|
||||
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: {
|
||||
enabled: this.remove.enabled,
|
||||
label: this.remove.text,
|
||||
shown: this.remove.shownText,
|
||||
x: at.x + this.remove.w / 2,
|
||||
y: at.y + this.remove.h / 2,
|
||||
},
|
||||
deleteButton: this.remove.parent ? info(this.remove) : null,
|
||||
uploadShown: this.remove.parent !== null,
|
||||
offerButton: this.offer ? info(this.offer) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -352,6 +392,11 @@ export abstract class FilePicker<Item extends PickerItem> extends Popup {
|
||||
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();
|
||||
@@ -401,6 +446,27 @@ export abstract class FilePicker<Item extends PickerItem> extends Popup {
|
||||
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;
|
||||
|
||||
Reference in new issue
Block a user