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
@@ -0,0 +1,49 @@
|
||||
import { apiRequest } from './http';
|
||||
import type { ReleasedGame, ReleaseStore, ReleaseSummary } from './ReleaseStore';
|
||||
|
||||
/** A demo copy as a guest picks it (#428). */
|
||||
export interface DemoSummary {
|
||||
token: string;
|
||||
name: string;
|
||||
/** ISO time, UTC: when the copy was made or last updated. */
|
||||
publishedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Demo copies (#428): frozen copies of projects a guest opens in the editor without an account,
|
||||
* stored like a release. Listing and loading one needs no sign-in; making, updating and removing the
|
||||
* copy of a project (the `ReleaseStore` half) is for administrators.
|
||||
*/
|
||||
export interface DemoStore extends ReleaseStore {
|
||||
list(): Promise<DemoSummary[]>;
|
||||
/** The copy, under its token as the project id. */
|
||||
load(token: string): Promise<ReleasedGame>;
|
||||
}
|
||||
|
||||
export class HttpDemoStore implements DemoStore {
|
||||
constructor(private readonly endpoint: string) {}
|
||||
|
||||
list(): Promise<DemoSummary[]> {
|
||||
return apiRequest(this.endpoint, 'GET', 'demos');
|
||||
}
|
||||
|
||||
load(token: string): Promise<ReleasedGame> {
|
||||
return apiRequest(this.endpoint, 'GET', `demos/${token}`);
|
||||
}
|
||||
|
||||
of(projectId: string): Promise<ReleaseSummary | null> {
|
||||
return apiRequest(this.endpoint, 'GET', route(projectId));
|
||||
}
|
||||
|
||||
publish(projectId: string): Promise<ReleaseSummary> {
|
||||
return apiRequest(this.endpoint, 'POST', route(projectId));
|
||||
}
|
||||
|
||||
takeDown(projectId: string): Promise<void> {
|
||||
return apiRequest(this.endpoint, 'DELETE', route(projectId));
|
||||
}
|
||||
}
|
||||
|
||||
function route(projectId: string): string {
|
||||
return `projects/${encodeURIComponent(projectId)}/demo`;
|
||||
}
|
||||
@@ -16,6 +16,8 @@ export interface StoredFile extends UploadedFile {
|
||||
owner: string | null;
|
||||
/** Whether the one asking may delete it – whether a project still uses it, only the delete finds out. */
|
||||
deletable: boolean;
|
||||
/** Offered to everybody as demo material by an administrator (#428); absent from an older server. */
|
||||
demo?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,4 +32,9 @@ export interface FileStore<U extends UploadedFile = UploadedFile, S extends Stor
|
||||
upload(bytes: Blob | ArrayBuffer, type: string, name?: string): Promise<U>;
|
||||
list(): Promise<S[]>;
|
||||
delete(file: string): Promise<void>;
|
||||
/**
|
||||
* Offers a file to everybody as demo material, or takes that back (#428). Only there for an
|
||||
* administrator; the pickers show the switch when it is.
|
||||
*/
|
||||
setDemo?: (file: string, demo: boolean) => Promise<void>;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FileStore, StoredFile, UploadedFile } from './FileStore';
|
||||
import { apiRequest } from './http';
|
||||
import { apiRequest, jsonBody } from './http';
|
||||
import type { StoredVideo, UploadedVideo, VideoStore, VideoUploadOptions } from './VideoStore';
|
||||
|
||||
/**
|
||||
@@ -13,10 +13,20 @@ export class HttpFileStore<
|
||||
U extends UploadedFile = UploadedFile,
|
||||
S extends StoredFile = StoredFile,
|
||||
> implements FileStore<U, S> {
|
||||
readonly setDemo?: (file: string, demo: boolean) => Promise<void>;
|
||||
|
||||
/** `offersDemos` for an administrator's session: the server refuses everybody else anyway (#428). */
|
||||
constructor(
|
||||
protected readonly endpoint: string,
|
||||
private readonly route: FileRoute,
|
||||
) {}
|
||||
offersDemos = false,
|
||||
) {
|
||||
if (offersDemos) {
|
||||
this.setDemo = async (file, demo) => {
|
||||
await apiRequest<void>(this.endpoint, 'PATCH', `${this.route}/${file}`, jsonBody({ demo }), refusal);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async upload(bytes: Blob | ArrayBuffer, type: string, name = ''): Promise<U> {
|
||||
return this.send(bytes, type, { name });
|
||||
@@ -42,8 +52,8 @@ export class HttpFileStore<
|
||||
|
||||
/** Videos: an upload may be kept without converting it, and converted later. */
|
||||
export class HttpVideoStore extends HttpFileStore<UploadedVideo, StoredVideo> implements VideoStore {
|
||||
constructor(endpoint: string) {
|
||||
super(endpoint, 'videos');
|
||||
constructor(endpoint: string, offersDemos = false) {
|
||||
super(endpoint, 'videos', offersDemos);
|
||||
}
|
||||
|
||||
override async upload(
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Project } from '../model/Project';
|
||||
import {
|
||||
type LoadedProject,
|
||||
type ProjectStore,
|
||||
type ProjectSummary,
|
||||
StaleRevisionError,
|
||||
} from './ProjectStore';
|
||||
import { deserializeProject, serializeProject } from './serialize';
|
||||
|
||||
/**
|
||||
* A guest's project (#428): one demo copy, held in this tab and nowhere else. Saving keeps it here,
|
||||
* so nothing a guest changes reaches the server, and a reload starts from the copy again. There is
|
||||
* no second project to create, open or delete.
|
||||
*/
|
||||
export class MemoryProjectStore implements ProjectStore {
|
||||
private revision = 1;
|
||||
private readonly updatedAt = new Date().toISOString();
|
||||
|
||||
constructor(
|
||||
private readonly id: string,
|
||||
private readonly name: string,
|
||||
private raw: unknown,
|
||||
private readonly refusal: string,
|
||||
) {}
|
||||
|
||||
list(): Promise<ProjectSummary[]> {
|
||||
return Promise.resolve([
|
||||
{ id: this.id, name: this.name, revision: this.revision, updatedAt: this.updatedAt },
|
||||
]);
|
||||
}
|
||||
|
||||
async load(id: string, prepare?: (raw: unknown) => Promise<void>): Promise<LoadedProject> {
|
||||
if (id !== this.id) throw new Error(`Project not found: ${id}`);
|
||||
await prepare?.(this.raw);
|
||||
// Saves are matched by id, so the copy answers to the token whatever its file said.
|
||||
const raw = typeof this.raw === 'object' && this.raw !== null ? { ...this.raw, id: this.id } : this.raw;
|
||||
return { project: deserializeProject(raw, this.id), revision: this.revision };
|
||||
}
|
||||
|
||||
save(project: Project, revision: number): Promise<number> {
|
||||
if (revision !== this.revision) return Promise.reject(new StaleRevisionError(this.revision));
|
||||
this.raw = serializeProject(project);
|
||||
return Promise.resolve(++this.revision);
|
||||
}
|
||||
|
||||
create(_name: string): Promise<LoadedProject> {
|
||||
return Promise.reject(new Error(this.refusal));
|
||||
}
|
||||
|
||||
delete(_id: string): Promise<void> {
|
||||
return Promise.reject(new Error(this.refusal));
|
||||
}
|
||||
}
|
||||
+110
-41
@@ -1,11 +1,13 @@
|
||||
import type { Application } from 'pixi.js';
|
||||
import type { AuthStore, SignedIn } from '../core/store/AuthStore';
|
||||
import { type DemoStore, type DemoSummary, HttpDemoStore } from '../core/store/DemoStore';
|
||||
import { HttpAuthStore } from '../core/store/HttpAuthStore';
|
||||
import { HttpFileStore, HttpVideoStore } from '../core/store/HttpFileStore';
|
||||
import { HttpProjectStore } from '../core/store/HttpProjectStore';
|
||||
import { HttpReleaseStore } from '../core/store/HttpReleaseStore';
|
||||
import { HttpSnapshotStore } from '../core/store/HttpSnapshotStore';
|
||||
import { HttpUsersStore } from '../core/store/HttpUsersStore';
|
||||
import { MemoryProjectStore } from '../core/store/MemoryProjectStore';
|
||||
import type { StoredTexture, UploadedTexture } from '../core/store/TextureStore';
|
||||
import { loadPhysics } from '../runtime/physics/engine';
|
||||
import { projectPictureFactory } from '../viewport/generatedPictures';
|
||||
@@ -18,25 +20,40 @@ import { installLoginHook } from './auth/loginHook';
|
||||
import { consoleLog } from './console/log';
|
||||
import { installDebugHook } from './debugHook';
|
||||
import { Editor } from './Editor';
|
||||
import { guestFiles, guestReleases, guestSnapshots, guestUsers, guestVideos } from './guestStores';
|
||||
import { isDragFrame } from './framePreview';
|
||||
import { startPlugins } from './plugins/startPlugins';
|
||||
import { CelUploadStore } from './pixelart/celUploadStore';
|
||||
import { API_ENDPOINT } from '../core/site';
|
||||
import { EditorState } from './state';
|
||||
import { GUEST_REFUSAL, setSession } from './session';
|
||||
|
||||
/** Who the sign-in screen let in: an account, or a guest with the demo copy they picked (#428). */
|
||||
type Entry = { account: SignedIn } | { demo: DemoSummary };
|
||||
|
||||
/**
|
||||
* Shows the sign-in screen until somebody is signed in, and answers with their name. Returns
|
||||
* Shows the sign-in screen until somebody is signed in or picks a demo, and answers with who. Returns
|
||||
* right away when the session from an earlier visit is still good – which is what lets a
|
||||
* deployment replace the editor under an open tab without asking for the password again.
|
||||
*/
|
||||
async function signedInUser(app: Application, root: VStack, auth: AuthStore): Promise<SignedIn> {
|
||||
async function signedInUser(
|
||||
app: Application,
|
||||
root: VStack,
|
||||
auth: AuthStore,
|
||||
demos: DemoStore,
|
||||
): Promise<Entry> {
|
||||
const existing = await auth.session();
|
||||
if (existing !== null) return existing;
|
||||
if (existing !== null) return { account: existing };
|
||||
|
||||
let done: (account: SignedIn) => void = () => {};
|
||||
const screen = new LoginScreen(auth, (account) => done(account));
|
||||
let done: (entry: Entry) => void = () => {};
|
||||
const screen = new LoginScreen(
|
||||
auth,
|
||||
demos,
|
||||
(account) => done({ account }),
|
||||
(demo) => done({ demo }),
|
||||
);
|
||||
const removeHook = installLoginHook(app, screen);
|
||||
const account = await new Promise<SignedIn>((resolve) => {
|
||||
const entry = await new Promise<Entry>((resolve) => {
|
||||
done = resolve;
|
||||
root.add(screen, 1);
|
||||
root.resize(app.screen.width, app.screen.height);
|
||||
@@ -50,7 +67,7 @@ async function signedInUser(app: Application, root: VStack, auth: AuthStore): Pr
|
||||
// the corner.
|
||||
root.resize(0, 0);
|
||||
screen.destroy({ children: true });
|
||||
return account;
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,8 +82,13 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
|
||||
const auth = new HttpAuthStore(API_ENDPOINT);
|
||||
setUpProjectFiles(app.renderer);
|
||||
|
||||
const demos = new HttpDemoStore(API_ENDPOINT);
|
||||
|
||||
const openSession = async (): Promise<void> => {
|
||||
const { user, role } = await signedInUser(app, root, auth);
|
||||
const entry = await signedInUser(app, root, auth, demos);
|
||||
const guest = 'demo' in entry;
|
||||
const { user, role } = guest ? { user: 'Guest', role: 'user' as const } : entry.account;
|
||||
setSession({ guest, role });
|
||||
// Who is signed in goes into the window title: the toolbar is crowded enough, and the title is
|
||||
// also what tells two tabs on two accounts apart.
|
||||
document.title = `Feuerwerk – ${user}`;
|
||||
@@ -79,38 +101,70 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
|
||||
state.on('project', () => {
|
||||
if (!isDragFrame()) preloadProjectFiles(state.project);
|
||||
});
|
||||
const editor = new Editor(
|
||||
app,
|
||||
root,
|
||||
state,
|
||||
new CelUploadStore(
|
||||
new HttpProjectStore(API_ENDPOINT),
|
||||
pixelCels,
|
||||
new HttpFileStore(API_ENDPOINT, 'pixels'),
|
||||
),
|
||||
new HttpFileStore<UploadedTexture, StoredTexture>(API_ENDPOINT, 'textures'),
|
||||
new HttpFileStore(API_ENDPOINT, 'fonts'),
|
||||
new HttpFileStore(API_ENDPOINT, 'sounds'),
|
||||
new HttpVideoStore(API_ENDPOINT),
|
||||
new HttpSnapshotStore(API_ENDPOINT),
|
||||
new HttpReleaseStore(API_ENDPOINT),
|
||||
{
|
||||
user,
|
||||
role,
|
||||
users: new HttpUsersStore(API_ENDPOINT),
|
||||
logOut: () =>
|
||||
void endSession(editor).catch((error: unknown) => {
|
||||
console.error('[auth] Signing out failed:', error);
|
||||
}),
|
||||
// Straight through to the store: the server checks the old password, and the session this
|
||||
// tab holds is the one it keeps – so there is nothing to do here afterwards.
|
||||
changePassword: (current, next) => auth.changePassword(current, next),
|
||||
},
|
||||
);
|
||||
const admin = role === 'admin';
|
||||
const textures = new HttpFileStore<UploadedTexture, StoredTexture>(API_ENDPOINT, 'textures', admin);
|
||||
const fonts = new HttpFileStore(API_ENDPOINT, 'fonts', admin);
|
||||
const sounds = new HttpFileStore(API_ENDPOINT, 'sounds', admin);
|
||||
const videos = new HttpVideoStore(API_ENDPOINT, admin);
|
||||
const logOut = (): void =>
|
||||
void endSession(editor, guest).catch((error: unknown) => {
|
||||
console.error('[auth] Signing out failed:', error);
|
||||
});
|
||||
// A guest (#428) gets the demo copy in a store of the tab's own, and every store that would
|
||||
// write refuses before it asks: nothing a guest does reaches the server's writing routes.
|
||||
const editor = guest
|
||||
? new Editor(
|
||||
app,
|
||||
root,
|
||||
state,
|
||||
await guestProject(demos, entry.demo),
|
||||
guestFiles(textures),
|
||||
guestFiles(fonts),
|
||||
guestFiles(sounds),
|
||||
guestVideos(videos),
|
||||
guestSnapshots,
|
||||
guestReleases,
|
||||
{
|
||||
user,
|
||||
role,
|
||||
guest: true,
|
||||
users: guestUsers,
|
||||
logOut,
|
||||
changePassword: () => Promise.reject(new Error(GUEST_REFUSAL)),
|
||||
},
|
||||
)
|
||||
: new Editor(
|
||||
app,
|
||||
root,
|
||||
state,
|
||||
new CelUploadStore(
|
||||
new HttpProjectStore(API_ENDPOINT),
|
||||
pixelCels,
|
||||
new HttpFileStore(API_ENDPOINT, 'pixels'),
|
||||
),
|
||||
textures,
|
||||
fonts,
|
||||
sounds,
|
||||
videos,
|
||||
new HttpSnapshotStore(API_ENDPOINT),
|
||||
new HttpReleaseStore(API_ENDPOINT),
|
||||
{
|
||||
user,
|
||||
role,
|
||||
users: new HttpUsersStore(API_ENDPOINT),
|
||||
demos: admin ? demos : undefined,
|
||||
logOut,
|
||||
// Straight through to the store: the server checks the old password, and the session
|
||||
// this tab holds is the one it keeps – so there is nothing to do here afterwards.
|
||||
changePassword: (current, next) => auth.changePassword(current, next),
|
||||
},
|
||||
);
|
||||
installDebugHook(app, state, editor);
|
||||
// Before the project, not after it (#119): which plugins run is written in the project file,
|
||||
// and `boot()` hands that list over before it reads the file into the model.
|
||||
await startPlugins(API_ENDPOINT, state, editor);
|
||||
// A guest has one project; a `?project=` of an account's would only fail to open.
|
||||
if (guest) forgetProjectInAddress();
|
||||
await editor.boot();
|
||||
loadPhysics().catch((error: unknown) => console.error('[physics] Rapier could not be loaded:', error));
|
||||
};
|
||||
@@ -120,19 +174,34 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
|
||||
* of everything, then the server drops the session, and the sign-in screen comes back. The
|
||||
* editor goes first, so its last save still travels with a session the server accepts.
|
||||
*/
|
||||
const endSession = async (editor: Editor): Promise<void> => {
|
||||
const endSession = async (editor: Editor, guest: boolean): Promise<void> => {
|
||||
await editor.dispose();
|
||||
document.title = 'Feuerwerk';
|
||||
// The console panel is page-wide, the lines in it are not: what one account's project printed
|
||||
// is none of the next one's business.
|
||||
consoleLog.clear();
|
||||
try {
|
||||
await auth.logOut();
|
||||
} catch (error) {
|
||||
console.error('[auth] Signing out did not reach the server:', error);
|
||||
// A guest has no session on the server to end.
|
||||
if (!guest) {
|
||||
try {
|
||||
await auth.logOut();
|
||||
} catch (error) {
|
||||
console.error('[auth] Signing out did not reach the server:', error);
|
||||
}
|
||||
}
|
||||
await openSession();
|
||||
};
|
||||
|
||||
await openSession();
|
||||
}
|
||||
|
||||
/** The demo copy a guest picked, in a store of the tab's own (#428). */
|
||||
async function guestProject(demos: DemoStore, demo: DemoSummary): Promise<MemoryProjectStore> {
|
||||
const copy = await demos.load(demo.token);
|
||||
return new MemoryProjectStore(demo.token, copy.name, copy.project, GUEST_REFUSAL);
|
||||
}
|
||||
|
||||
function forgetProjectInAddress(): void {
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.delete('project');
|
||||
history.replaceState(null, '', url);
|
||||
}
|
||||
+42
-14
@@ -4,6 +4,7 @@ import { findAsset, findObjectHolder, ROUTING_ID } from '../core/model/Project';
|
||||
import { assetTitle } from '../core/music/embedded';
|
||||
import { mixOf, videoBusesOf } from '../core/music/mixer';
|
||||
import type { Role } from '../core/store/AuthStore';
|
||||
import type { DemoStore } from '../core/store/DemoStore';
|
||||
import type { ProjectStore } from '../core/store/ProjectStore';
|
||||
import { pluginsOf } from '../core/store/projectPlugins';
|
||||
import type { FontStore } from '../core/store/FontStore';
|
||||
@@ -158,7 +159,11 @@ export interface EditorAccount {
|
||||
user: string;
|
||||
/** What the editor offers: an administrator gets the user management (#180, #179). */
|
||||
role: Role;
|
||||
/** Somebody trying a demo copy without an account (#428): nothing they do is kept. */
|
||||
guest?: boolean;
|
||||
users: UsersStore;
|
||||
/** An administrator's: making the demo copy of the open project (#428). */
|
||||
demos?: DemoStore;
|
||||
logOut: () => void;
|
||||
/** Rejects with `WrongCredentialsError` when the current password does not match (#182). */
|
||||
changePassword: (current: string, next: string) => Promise<void>;
|
||||
@@ -365,6 +370,7 @@ export class Editor implements PlayHost {
|
||||
private readonly cleanups: (() => void)[] = [];
|
||||
private readonly autosave: { flush: () => Promise<void>; stop: () => void };
|
||||
private disposed = false;
|
||||
private readonly guest: boolean;
|
||||
// Each starts where its divider was left in this browser (`panelSizes.ts`), else at the number here.
|
||||
private leftWidth = panelSize('left', 250, MIN_LEFT_WIDTH, MAX_LEFT_WIDTH);
|
||||
private rightWidth = panelSize('right', 350, MIN_RIGHT_WIDTH, MAX_RIGHT_WIDTH);
|
||||
@@ -448,11 +454,13 @@ export class Editor implements PlayHost {
|
||||
() => {
|
||||
if (this.play.isPlaying) this.play.stop();
|
||||
},
|
||||
{ snapshots, releases, flush: () => this.autosave.flush() },
|
||||
{ snapshots, releases, demos: account.demos, flush: () => this.autosave.flush() },
|
||||
{ guest: account.guest === true, role: account.role },
|
||||
// Every project brings its own plugins (#119); until `startPlugins` has run there is no
|
||||
// manager, and a project with plugins simply has none of them.
|
||||
async (raw) => void (await this.plugins?.syncTo(pluginsOf(raw))),
|
||||
);
|
||||
this.guest = account.guest === true;
|
||||
|
||||
this.graphEditor.canvas.traceProvider = (assetId) => this.play.traceFor(assetId);
|
||||
this.graphEditor.canvas.timeProvider = () => this.play.time;
|
||||
@@ -476,16 +484,20 @@ export class Editor implements PlayHost {
|
||||
: null;
|
||||
this.managementPane = new ManagementPane(this.usersPane, this.pluginsPane);
|
||||
|
||||
this.toolbar = new Toolbar(state, {
|
||||
openMainMenu: (anchor) =>
|
||||
this.projects.openMainMenu(anchor, this.managementEntries(), [
|
||||
{ label: 'Master routing', onSelect: () => this.openDocument('routing') },
|
||||
]),
|
||||
togglePlay: () => (state.mode === 'edit' ? this.play.play() : this.play.stop()),
|
||||
togglePause: () => this.play.togglePause(),
|
||||
step: () => this.play.step(),
|
||||
openAccountMenu: (anchor) => this.openAccountMenu(anchor, account),
|
||||
});
|
||||
this.toolbar = new Toolbar(
|
||||
state,
|
||||
{
|
||||
openMainMenu: (anchor) =>
|
||||
this.projects.openMainMenu(anchor, this.managementEntries(), [
|
||||
{ label: 'Master routing', onSelect: () => this.openDocument('routing') },
|
||||
]),
|
||||
togglePlay: () => (state.mode === 'edit' ? this.play.play() : this.play.stop()),
|
||||
togglePause: () => this.play.togglePause(),
|
||||
step: () => this.play.step(),
|
||||
openAccountMenu: (anchor) => this.openAccountMenu(anchor, account),
|
||||
},
|
||||
account.guest === true,
|
||||
);
|
||||
root.add(this.toolbar);
|
||||
|
||||
this.workspace = new HStack();
|
||||
@@ -614,7 +626,10 @@ export class Editor implements PlayHost {
|
||||
// A press in the editor takes the keys back from a window (#371).
|
||||
this.workspace.on('pointerdowncapture', () => (this.pressedWindow = null));
|
||||
|
||||
this.autosave = attachAutosave(state, store);
|
||||
// A guest's project lives in the tab alone (#428): there is nowhere to save it to.
|
||||
this.autosave = this.guest
|
||||
? { flush: () => Promise.resolve(), stop: () => {} }
|
||||
: attachAutosave(state, store);
|
||||
this.cleanups.push(
|
||||
installShortcuts(state, {
|
||||
play: () => this.play.play(),
|
||||
@@ -863,6 +878,14 @@ export class Editor implements PlayHost {
|
||||
* picked: it says who is signed in without costing the crowded toolbar another label.
|
||||
*/
|
||||
private openAccountMenu(anchor: Widget, account: EditorAccount): void {
|
||||
if (this.guest) {
|
||||
new Menu([
|
||||
{ label: 'Guest – nothing is saved', disabled: true, onSelect: () => {} },
|
||||
{ separator: true },
|
||||
{ label: 'Sign in…', onSelect: () => account.logOut() },
|
||||
]).openBelow(anchor);
|
||||
return;
|
||||
}
|
||||
new Menu([
|
||||
{ label: `Signed in as ${account.user}`, disabled: true, onSelect: () => {} },
|
||||
{ separator: true },
|
||||
@@ -1348,8 +1371,13 @@ export class Editor implements PlayHost {
|
||||
};
|
||||
}
|
||||
|
||||
boot(): Promise<void> {
|
||||
return this.projects.boot();
|
||||
async boot(): Promise<void> {
|
||||
await this.projects.boot();
|
||||
if (this.guest) {
|
||||
this.state.setNotice(
|
||||
'You are trying a demo as a guest: change anything, play it, record it – nothing is saved. Sign in from the menu at the far right to keep your work.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+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;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
SetWhilePlayingCommand,
|
||||
} from '../core/commands/projectCommands';
|
||||
import { assetRefChoices } from '../core/properties/assetRef';
|
||||
import type { DemoStore } from '../core/store/DemoStore';
|
||||
import { type ProjectStore, StaleRevisionError } from '../core/store/ProjectStore';
|
||||
import { releaseUrl, type ReleaseStore } from '../core/store/ReleaseStore';
|
||||
import type { SnapshotStore, SnapshotSummary } from '../core/store/SnapshotStore';
|
||||
@@ -24,6 +25,7 @@ import { ProjectSettingsDialog, settingsChanges } from './ProjectSettingsDialog'
|
||||
import { clearSaved, hasSaved } from './saves';
|
||||
import { snapshotBlocker, snapshotTime } from './snapshots';
|
||||
import { SnapshotsDialog } from './SnapshotsDialog';
|
||||
import { type Session, sessionAllows } from './session';
|
||||
import type { EditorState } from './state';
|
||||
|
||||
export class ProjectSession {
|
||||
@@ -41,10 +43,18 @@ export class ProjectSession {
|
||||
private readonly store: ProjectStore,
|
||||
private readonly beforeSwitch: () => void,
|
||||
/**
|
||||
* What the server copies of the project: snapshots (#231) and the release (#266). `flush` writes
|
||||
* what the autosave still holds, so a copy is of the stand on screen.
|
||||
* What the server copies of the project: snapshots (#231), the release (#266) and, for an
|
||||
* administrator, the demo copy (#428). `flush` writes what the autosave still holds, so a copy is
|
||||
* of the stand on screen.
|
||||
*/
|
||||
private readonly copies: { snapshots: SnapshotStore; releases: ReleaseStore; flush: () => Promise<void> },
|
||||
private readonly copies: {
|
||||
snapshots: SnapshotStore;
|
||||
releases: ReleaseStore;
|
||||
demos?: DemoStore;
|
||||
flush: () => Promise<void>;
|
||||
},
|
||||
/** What the menu offers: a guest (#428) neither switches projects nor has copies made. */
|
||||
private readonly session: Session,
|
||||
/**
|
||||
* Runs on the project as it came off the server, before it is read into the model: the plugins
|
||||
* this project switches on are loaded there (#119), so that what they register is in place
|
||||
@@ -79,6 +89,8 @@ export class ProjectSession {
|
||||
this.beforeSwitch();
|
||||
const loaded = await this.store.load(id, this.prepare);
|
||||
this.state.loadProject(loaded.project, loaded.revision);
|
||||
// A guest's demo is nobody's project: a reload brings the sign-in screen, not the copy (#428).
|
||||
if (this.session.guest) return;
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.set('project', id);
|
||||
history.replaceState(null, '', url);
|
||||
@@ -109,12 +121,20 @@ export class ProjectSession {
|
||||
* the editor adds that is not about the project (#179).
|
||||
*/
|
||||
openMainMenu(anchor: Widget, more: MenuEntry[] = [], project: MenuEntry[] = []): void {
|
||||
const may = (action: Parameters<typeof sessionAllows>[1]) => sessionAllows(this.session, action);
|
||||
const items: MenuEntry[] = [
|
||||
{ label: 'New project', onSelect: () => void this.run(this.createNext()) },
|
||||
{ label: 'Open project…', onSelect: () => this.openProjectPicker() },
|
||||
{ separator: true },
|
||||
{ label: 'Snapshots…', onSelect: () => this.openSnapshots() },
|
||||
{ label: 'Release…', onSelect: () => this.openRelease() },
|
||||
...(may('projects')
|
||||
? [
|
||||
{ label: 'New project', onSelect: () => void this.run(this.createNext()) },
|
||||
{ label: 'Open project…', onSelect: () => this.openProjectPicker() },
|
||||
{ separator: true } as const,
|
||||
]
|
||||
: []),
|
||||
...(may('snapshots') ? [{ label: 'Snapshots…', onSelect: () => this.openSnapshots() }] : []),
|
||||
...(may('release') ? [{ label: 'Release…', onSelect: () => this.openRelease() }] : []),
|
||||
...(may('demoCopy') && this.copies.demos
|
||||
? [{ label: 'Demo copy…', onSelect: () => this.openDemoCopy() }]
|
||||
: []),
|
||||
{ label: 'Project settings…', onSelect: () => this.openProjectSettings() },
|
||||
...project,
|
||||
{ label: 'Clear saved data', onSelect: () => this.clearSavedData() },
|
||||
@@ -123,6 +143,67 @@ export class ProjectSession {
|
||||
new Menu(items).openBelow(anchor);
|
||||
}
|
||||
|
||||
/**
|
||||
* The demo copy of the open project (#428): what a guest gets when they pick it on the sign-in
|
||||
* screen. It is the project as stored, like a release, so the dialog says how old it is and
|
||||
* whether the project has moved on since.
|
||||
*/
|
||||
openDemoCopy(): void {
|
||||
const demos = this.copies.demos;
|
||||
if (!demos) return;
|
||||
const projectId = this.state.project.id;
|
||||
void this.run(
|
||||
(async () => {
|
||||
const copy = await demos.of(projectId);
|
||||
const cancel = { label: 'Close', onClick: () => {} };
|
||||
const update = {
|
||||
label: copy ? 'Update demo copy' : 'Make demo copy',
|
||||
primary: true,
|
||||
onClick: () => void this.run(this.publishDemo(projectId, copy !== null)),
|
||||
};
|
||||
if (!copy) {
|
||||
new Dialog(
|
||||
'Demo copy',
|
||||
'Guests can try a copy of this project without an account. It is the project as it is saved now; later changes reach them when you update the copy.',
|
||||
[cancel, update],
|
||||
).openCentered();
|
||||
return;
|
||||
}
|
||||
const behind = copy.revision !== this.state.revision ? ' The project has changed since.' : '';
|
||||
new Dialog(
|
||||
'Demo copy',
|
||||
`Guests can pick this project. Their copy is from ${new Date(copy.publishedAt).toLocaleString()}.${behind}`,
|
||||
[
|
||||
{
|
||||
label: 'Remove',
|
||||
danger: true,
|
||||
onClick: () =>
|
||||
void this.run(
|
||||
(async () => {
|
||||
await demos.takeDown(projectId);
|
||||
this.state.setNotice('Demo copy removed – guests no longer see this project');
|
||||
})(),
|
||||
),
|
||||
},
|
||||
cancel,
|
||||
update,
|
||||
],
|
||||
).openCentered();
|
||||
})(),
|
||||
);
|
||||
}
|
||||
|
||||
private async publishDemo(projectId: string, again: boolean): Promise<void> {
|
||||
await this.copies.flush();
|
||||
const blocker = snapshotBlocker(this.state.saveStatus, 'demo');
|
||||
if (blocker) {
|
||||
this.state.setNotice(blocker, 'error');
|
||||
return;
|
||||
}
|
||||
await this.copies.demos?.publish(projectId);
|
||||
this.state.setNotice(again ? 'Demo copy updated' : 'Demo copy made – guests can pick this project now');
|
||||
}
|
||||
|
||||
/** Forgets what persistent variables saved in this browser (#166), so the next run is a first start. */
|
||||
private clearSavedData(): void {
|
||||
const id = this.state.project.id;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Graphics } from 'pixi.js';
|
||||
import logoSvg from '../../../public/brand/feuerwerk-logo-stacked-dark.svg?raw';
|
||||
import { type AuthStore, type SignedIn, WrongCredentialsError } from '../../core/store/AuthStore';
|
||||
import type { DemoStore, DemoSummary } from '../../core/store/DemoStore';
|
||||
import {
|
||||
Button,
|
||||
drawFloatingBox,
|
||||
drawSvg,
|
||||
Label,
|
||||
Menu,
|
||||
Spacer,
|
||||
TextField,
|
||||
Theme,
|
||||
@@ -77,20 +79,26 @@ class Card extends VStack {
|
||||
*
|
||||
* It is shown instead of the editor, not over it – nothing of a project is loaded while nobody is
|
||||
* signed in, so there is nothing behind it to hide.
|
||||
*
|
||||
* Below the form a guest picks a demo copy to try the editor without an account (#428).
|
||||
*/
|
||||
export class LoginScreen extends VStack {
|
||||
private readonly logo = new Logo();
|
||||
private readonly user: TextField;
|
||||
private readonly password: TextField;
|
||||
private readonly submit: Button;
|
||||
private readonly tryButton: Button;
|
||||
private readonly status = new FormStatus((busy) => {
|
||||
this.submit.setText(busy ? 'Signing in…' : 'Sign in');
|
||||
this.submit.setEnabled(!busy);
|
||||
this.tryButton.setEnabled(!busy);
|
||||
});
|
||||
|
||||
constructor(
|
||||
private readonly auth: AuthStore,
|
||||
private readonly demos: DemoStore,
|
||||
private readonly onSignedIn: (account: SignedIn) => void,
|
||||
private readonly onGuest: (demo: DemoSummary) => void,
|
||||
) {
|
||||
super({ align: 'center', gap: Theme.gap });
|
||||
this.add(new Spacer(), 1);
|
||||
@@ -126,6 +134,13 @@ export class LoginScreen extends VStack {
|
||||
onClick: () => void this.signIn(),
|
||||
});
|
||||
card.add(this.submit);
|
||||
card.add(new Spacer(Theme.padding));
|
||||
card.add(new Label('No account? Try a demo project – nothing is saved.', { style: 'small' }));
|
||||
this.tryButton = new Button('Try without an account', {
|
||||
width: FIELD_WIDTH,
|
||||
onClick: () => void this.pickDemo(),
|
||||
});
|
||||
card.add(this.tryButton);
|
||||
this.add(card);
|
||||
|
||||
// Twice the space below as above, so logo and card sit a little above the middle.
|
||||
@@ -142,6 +157,25 @@ export class LoginScreen extends VStack {
|
||||
return this.status.info();
|
||||
}
|
||||
|
||||
/** The demo copies under the button, one entry each; picking one starts the guest session. */
|
||||
private async pickDemo(): Promise<void> {
|
||||
if (this.status.busy) return;
|
||||
let demos: DemoSummary[];
|
||||
try {
|
||||
demos = await this.demos.list();
|
||||
} catch (error) {
|
||||
this.status.say(`Could not list the demos: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return;
|
||||
}
|
||||
if (demos.length === 0) {
|
||||
this.status.say('There are no demo projects yet');
|
||||
return;
|
||||
}
|
||||
new Menu(demos.map((demo) => ({ label: demo.name, onSelect: () => this.onGuest(demo) }))).openBelow(
|
||||
this.tryButton,
|
||||
);
|
||||
}
|
||||
|
||||
private async signIn(): Promise<void> {
|
||||
if (this.status.busy) return;
|
||||
const username = this.user.value.trim();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { importSounds, isSoundFileName, type SoundImportDeps } from './soundImpo
|
||||
import { importImages, type TextureImportDeps } from './textureImport';
|
||||
import { openVideoPicker } from './video/VideoPicker';
|
||||
import { isVideoFileName, uploadVideos, type VideoImportDeps } from './videoImport';
|
||||
import { allows, GUEST_REFUSAL } from './session';
|
||||
|
||||
export type FileImportDeps = TextureImportDeps & FontImportDeps & SoundImportDeps & VideoImportDeps;
|
||||
|
||||
@@ -28,12 +29,16 @@ export function installFileImport(target: HTMLElement, deps: FileImportDeps): ()
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (!allows('upload')) {
|
||||
deps.state.setNotice(GUEST_REFUSAL);
|
||||
return;
|
||||
}
|
||||
run(files, deps);
|
||||
};
|
||||
const onPaste = (event: ClipboardEvent) => {
|
||||
if (isTextFieldFocused()) return;
|
||||
const files = sortFiles(event.clipboardData);
|
||||
if (files.none) return;
|
||||
if (files.none || !allows('upload')) return;
|
||||
event.preventDefault();
|
||||
run(files, deps);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { FileStore, StoredFile, UploadedFile } from '../core/store/FileStore';
|
||||
import type { ReleaseStore } from '../core/store/ReleaseStore';
|
||||
import type { SnapshotStore } from '../core/store/SnapshotStore';
|
||||
import type { UsersStore } from '../core/store/UsersStore';
|
||||
import type { VideoStore } from '../core/store/VideoStore';
|
||||
import { GUEST_REFUSAL } from './session';
|
||||
|
||||
/**
|
||||
* What a guest's editor gets instead of the server's stores (#428). Files are listed as the server
|
||||
* offers them to somebody signed out, which is the demo material; everything that would write is
|
||||
* refused here in the tab, before a request is made. The rest of the editor works on these as on
|
||||
* any store, so no path through it can reach the server's writing routes.
|
||||
*/
|
||||
function refuse(): Promise<never> {
|
||||
return Promise.reject(new Error(GUEST_REFUSAL));
|
||||
}
|
||||
|
||||
export function guestFiles<U extends UploadedFile, S extends StoredFile>(
|
||||
server: FileStore<U, S>,
|
||||
): FileStore<U, S> {
|
||||
return { list: () => server.list(), upload: refuse, delete: refuse };
|
||||
}
|
||||
|
||||
export function guestVideos(server: VideoStore): VideoStore {
|
||||
return { list: () => server.list(), upload: refuse, delete: refuse, convert: refuse };
|
||||
}
|
||||
|
||||
export const guestSnapshots: SnapshotStore = {
|
||||
list: refuse,
|
||||
create: refuse,
|
||||
restore: refuse,
|
||||
delete: refuse,
|
||||
};
|
||||
|
||||
export const guestReleases: ReleaseStore = { of: refuse, publish: refuse, takeDown: refuse };
|
||||
|
||||
export const guestUsers: UsersStore = { list: refuse, create: refuse, update: refuse, remove: refuse };
|
||||
@@ -26,6 +26,8 @@ const SAVE_LABELS: Record<SaveStatus, string> = {
|
||||
conflict: 'Conflict – reload',
|
||||
};
|
||||
|
||||
const GUEST_LABEL = 'Guest – not saved';
|
||||
|
||||
/** Square, because an icon has no reading direction that would ask for a wider box. */
|
||||
const ICON_BUTTON = Theme.controlHeight;
|
||||
|
||||
@@ -68,6 +70,8 @@ export class Toolbar extends HStack {
|
||||
constructor(
|
||||
private readonly state: EditorState,
|
||||
actions: ToolbarActions,
|
||||
/** A guest's project is never saved (#428), and the place of the save status says so. */
|
||||
private readonly guest = false,
|
||||
) {
|
||||
super({ gap: Theme.gap, padding: Theme.headerPadding, align: 'center' });
|
||||
this.minHeight = Theme.controlHeight + Theme.headerPadding * 2;
|
||||
@@ -195,8 +199,10 @@ export class Toolbar extends HStack {
|
||||
this.pauseButton.invalidate();
|
||||
this.stepButton.setEnabled(!editing && state.paused);
|
||||
this.stepButton.invalidate();
|
||||
this.status.setText(SAVE_LABELS[state.saveStatus]);
|
||||
this.status.setStyle(state.saveStatus === 'error' || state.saveStatus === 'conflict' ? 'body' : 'small');
|
||||
this.status.setText(this.guest ? GUEST_LABEL : SAVE_LABELS[state.saveStatus]);
|
||||
this.status.setStyle(
|
||||
this.guest || state.saveStatus === 'error' || state.saveStatus === 'conflict' ? 'body' : 'small',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import type { UploadedVideo } from '../core/store/VideoStore';
|
||||
import type { MadeRecording } from '../viewport/RecordingTake';
|
||||
import type { RecordingUploadOptions } from '../core/recording/recording';
|
||||
import { downloadBlob } from '../ui';
|
||||
import { allows } from './session';
|
||||
import { adoptVideo, type VideoImportDeps } from './videoImport';
|
||||
|
||||
/** How often the server is asked whether a recording is converted, as the video picker does. */
|
||||
const POLL_MS = 1000;
|
||||
|
||||
/** `kept`: uploaded without converting it; `converted`: converted, but not to be imported. */
|
||||
type UploadState = 'uploading' | 'converting' | 'added' | 'kept' | 'converted' | 'elsewhere' | 'failed';
|
||||
/**
|
||||
* `kept`: uploaded without converting it; `converted`: converted, but not to be imported;
|
||||
* `downloaded`: a guest's, handed to the browser instead (#428).
|
||||
*/
|
||||
type UploadState =
|
||||
'uploading' | 'converting' | 'added' | 'kept' | 'converted' | 'elsewhere' | 'failed' | 'downloaded';
|
||||
|
||||
/** What became of a recording on its way into the project (test hook). */
|
||||
export interface RecordingUpload {
|
||||
@@ -26,6 +32,8 @@ export interface RecordingUpload {
|
||||
* while the editor is not playing: a run plays a copy of the project that is thrown away, and so
|
||||
* would the asset be. A recording may stop short of either step, as "Start recording…" or the node
|
||||
* asked: it is always uploaded, and the video picker has it.
|
||||
*
|
||||
* A guest keeps nothing on the server (#428): their recording is saved as a download instead.
|
||||
*/
|
||||
export class RecordingUploads {
|
||||
private readonly uploads: RecordingUpload[] = [];
|
||||
@@ -42,6 +50,14 @@ export class RecordingUploads {
|
||||
error: '',
|
||||
};
|
||||
this.uploads.push(upload);
|
||||
if (!allows('upload')) {
|
||||
downloadBlob(recording.blob, `${recording.name}.mkv`);
|
||||
upload.state = 'downloaded';
|
||||
this.deps.state.setNotice(
|
||||
`Saved the recording "${recording.name}" as a download – guests keep nothing on the server.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
void this.bring(recording, upload, options).catch((error: unknown) => {
|
||||
upload.state = 'failed';
|
||||
upload.error = error instanceof Error ? error.message : String(error);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Role } from '../core/store/AuthStore';
|
||||
|
||||
/**
|
||||
* Who sits at the editor (#428): somebody signed in, with their role, or a guest trying a demo
|
||||
* copy without an account.
|
||||
*/
|
||||
export interface Session {
|
||||
guest: boolean;
|
||||
role: Role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the editor does that writes to the server or manages it. A guest's project lives in
|
||||
* the tab alone, so a guest does none of these; an account does all of them but the
|
||||
* administrators' own.
|
||||
*/
|
||||
export type SessionAction =
|
||||
| 'save'
|
||||
| 'upload'
|
||||
| 'deleteFile'
|
||||
| 'projects'
|
||||
| 'snapshots'
|
||||
| 'release'
|
||||
| 'password'
|
||||
| 'users'
|
||||
| 'demoCopy'
|
||||
| 'offerDemo';
|
||||
|
||||
const ADMINISTRATORS_ONLY: ReadonlySet<SessionAction> = new Set(['users', 'demoCopy', 'offerDemo']);
|
||||
|
||||
/** Whether `session` may do `action`. The server decides again; this is what the editor offers. */
|
||||
export function sessionAllows(session: Session, action: SessionAction): boolean {
|
||||
if (session.guest) return false;
|
||||
return !ADMINISTRATORS_ONLY.has(action) || session.role === 'admin';
|
||||
}
|
||||
|
||||
/** What a guest is told when something would need the server. */
|
||||
export const GUEST_REFUSAL = 'Guests keep nothing on the server – sign in to keep your work';
|
||||
|
||||
let current: Session = { guest: false, role: 'user' };
|
||||
|
||||
/**
|
||||
* The session of this page, for the places that are not handed the account: the pickers, a file
|
||||
* dropped on the editor, a finished recording. One page holds one session at a time.
|
||||
*/
|
||||
export function setSession(session: Session): void {
|
||||
current = session;
|
||||
}
|
||||
|
||||
export function allows(action: SessionAction): boolean {
|
||||
return sessionAllows(current, action);
|
||||
}
|
||||
@@ -6,14 +6,17 @@ export function snapshotTime(at: Date): string {
|
||||
return `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`;
|
||||
}
|
||||
|
||||
const COPY_WORDS = { snapshot: 'take a snapshot of', release: 'release', demo: 'make a demo copy of' };
|
||||
|
||||
/**
|
||||
* Why a snapshot cannot be made or restored right now, or null when it can (#231). The server copies
|
||||
* what it has stored, so the editor has to have saved everything first: a snapshot without the
|
||||
* last edits would be a stand nobody ever saw. A release is a copy as well (#266) and asks the same.
|
||||
* last edits would be a stand nobody ever saw. A release (#266) and a demo copy (#428) are copies as
|
||||
* well and ask the same.
|
||||
*/
|
||||
export function snapshotBlocker(
|
||||
status: SaveStatus,
|
||||
copy: 'snapshot' | 'release' = 'snapshot',
|
||||
copy: 'snapshot' | 'release' | 'demo' = 'snapshot',
|
||||
): string | null {
|
||||
switch (status) {
|
||||
case 'saved':
|
||||
@@ -21,7 +24,7 @@ export function snapshotBlocker(
|
||||
case 'conflict':
|
||||
return 'This project was changed elsewhere. Reload to continue.';
|
||||
case 'error':
|
||||
return `The project could not be saved, so there is nothing current to ${copy === 'release' ? 'release' : 'take a snapshot of'}`;
|
||||
return `The project could not be saved, so there is nothing current to ${COPY_WORDS[copy]}`;
|
||||
default:
|
||||
return 'The latest changes are not saved yet – stop the game or wait a moment';
|
||||
}
|
||||
|
||||
@@ -99,6 +99,8 @@ class SoundPicker extends FilePicker<SoundItem> {
|
||||
},
|
||||
() => pickAndImportSounds(deps),
|
||||
(file) => deps.sounds.delete(file),
|
||||
[],
|
||||
deps.sounds.setDemo,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { formatBytes, type Size, textStyle, truncate, crispText } from '../../ui
|
||||
import { textureLibrary } from '../../viewport/TextureLibrary';
|
||||
import {
|
||||
currentPicker,
|
||||
demoText,
|
||||
FilePicker,
|
||||
openPicker,
|
||||
PICKER_WIDTH,
|
||||
@@ -112,6 +113,8 @@ class ImagePicker extends FilePicker<ImageItem> {
|
||||
},
|
||||
() => pickAndImportImages(deps),
|
||||
(file) => deps.textures.delete(file),
|
||||
[],
|
||||
deps.textures.setDemo,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -136,7 +139,7 @@ class ImagePicker extends FilePicker<ImageItem> {
|
||||
protected override describe(item: ImageItem): string {
|
||||
if (item.width <= 0) return 'The server cannot read this file as an image.';
|
||||
const measurements = `${item.width} × ${item.height} px, ${formatBytes(item.bytes)}`;
|
||||
const size = item.name ? `${item.name} – ${measurements}` : measurements;
|
||||
const size = `${item.name ? `${item.name} – ${measurements}` : measurements}${demoText(item)}`;
|
||||
return item.assetName ? `${size} – already in the project as "${item.assetName}"` : size;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { posterLibrary, videoFileUrl } from '../../viewport/VideoLibrary';
|
||||
import { pickFiles } from '../assetImport';
|
||||
import {
|
||||
currentPicker,
|
||||
demoText,
|
||||
FilePicker,
|
||||
openPicker,
|
||||
type PickerItem,
|
||||
@@ -108,6 +109,7 @@ class VideoPicker extends FilePicker<VideoItem> {
|
||||
new Button('Download', { tooltip: 'Save the video as it was uploaded' }),
|
||||
new Button('Convert', { tooltip: 'Convert it on the server, so a project can use it' }),
|
||||
],
|
||||
deps.videos.setDemo,
|
||||
);
|
||||
const [download, convert] = this.extras;
|
||||
download!.onClick = () => this.download();
|
||||
@@ -214,7 +216,7 @@ class VideoPicker extends FilePicker<VideoItem> {
|
||||
protected override describe(item: VideoItem): string {
|
||||
const size =
|
||||
item.status === 'ready' || item.status === 'unconverted' ? ` – ${formatBytes(item.bytes)}` : '';
|
||||
return `${item.name || 'Video'}${size} – ${videoStatusText(item)}`;
|
||||
return `${item.name || 'Video'}${size} – ${videoStatusText(item)}${demoText(item)}`;
|
||||
}
|
||||
|
||||
private itemOf(file: string): VideoItem | null {
|
||||
|
||||
Reference in new issue
Block a user