mirror of
https://github.com/computernewb/collab-vm-1.2-webapp.git
synced 2025-01-22 10:52:05 -05:00
More i18n related updates
- More string keys - Reworked string keys entirely - Moved formatting into seperate typescript module - Write unit tests using `jest` for the format module - README improvements Pirate language needs to be reworked and it should be a bit less painful now to actually add more string keys later on (eventually making the whole webapp strings lie inside the stringkeys)
This commit is contained in:
parent
3a7e590797
commit
0cef7194ce
13 changed files with 3899 additions and 177 deletions
33
README.md
33
README.md
|
@ -2,17 +2,38 @@
|
|||
|
||||
![CollabVM Web App](/webapp.png)
|
||||
|
||||
The CollabVM Web App is the viewer for the CollabVM Server, currently in beta
|
||||
The CollabVM Web App is the viewer for the CollabVM Server.
|
||||
|
||||
## Building
|
||||
Edit Config.ts to your needs, then:
|
||||
1. `npm i`
|
||||
2. `npm run build`
|
||||
|
||||
The build output directory is `dist/`
|
||||
Edit Config.ts to your needs, then:
|
||||
|
||||
## yarn
|
||||
- `yarn`
|
||||
- `yarn build`
|
||||
|
||||
## npm
|
||||
- `npm i`
|
||||
- `npm run build`
|
||||
|
||||
The build output directory is `dist/`.
|
||||
|
||||
## Unit testing
|
||||
|
||||
This is very minimal and only tests a single standalone part at the moment:
|
||||
|
||||
- `yarn test`
|
||||
|
||||
## Serving
|
||||
|
||||
Just drop the contents of `dist/` somewhere into our webroot. For testing purposes, you can throw up a quick test webserver with the following command
|
||||
Just drop the contents of `dist/` somewhere into your webroot.
|
||||
|
||||
For **testing or development purposes only**, you can throw up a quick test webserver with the following command:
|
||||
|
||||
## yarn
|
||||
|
||||
`yarn serve`
|
||||
|
||||
## npm
|
||||
|
||||
`npm run serve`
|
||||
|
|
5
jest.config.js
Normal file
5
jest.config.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
};
|
|
@ -6,6 +6,7 @@
|
|||
"scripts": {
|
||||
"build": "parcel build --no-source-maps --dist-dir dist --public-url '.' src/html/index.html",
|
||||
"serve": "parcel src/html/index.html",
|
||||
"test": "jest",
|
||||
"clean": "run-script-os",
|
||||
"clean:darwin:linux": "rm -rf dist .parcel-cache",
|
||||
"clean:win32": "rd /s /q dist .parcel-cache"
|
||||
|
@ -24,10 +25,13 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@types/bootstrap": "^5.2.10",
|
||||
"@types/jest": "^29.5.12",
|
||||
"jest": "^29.7.0",
|
||||
"parcel": "^2.11.0",
|
||||
"parcel-reporter-static-files-copy": "^1.5.3",
|
||||
"prettier": "^3.2.5",
|
||||
"run-script-os": "^1.1.6",
|
||||
"ts-jest": "^29.1.2",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -166,7 +166,7 @@
|
|||
<button class="btn btn-secondary" id="rebootBtn"><i class="fa-solid fa-power-off"></i> Reboot</button>
|
||||
<button class="btn btn-secondary" id="clearQueueBtn"><i class="fa-solid fa-eraser"></i> Clear Turn Queue</button>
|
||||
<button class="btn btn-secondary" id="bypassTurnBtn"><i class="fa-solid fa-forward"></i> Bypass Turn</button>
|
||||
<button class="btn btn-secondary" id="endTurnBtn"><i class="fa-solid fa-ban"></i> End Turn</button>
|
||||
<button class="btn btn-secondary" id="endTurnBtn"><i class="fa-solid fa-ban"></i> <span id="endTurnBtnText"></span></button>
|
||||
<button class="btn btn-secondary" id="indefTurnBtn"><i class="fa-solid fa-infinity"></i> Indefinite Turn</button>
|
||||
<button class="btn btn-secondary" id="qemuMonitorBtn" data-bs-toggle="modal" data-bs-target="#qemuMonitorModal"><i class="fa-solid fa-terminal"></i> QEMU Monitor</button>
|
||||
</div>
|
||||
|
|
72
src/ts/format.ts
Normal file
72
src/ts/format.ts
Normal file
|
@ -0,0 +1,72 @@
|
|||
import { StringLike } from './StringLike';
|
||||
|
||||
/// A simple function for formatting strings in a more expressive manner.
|
||||
/// While JavaScript *does* have string interpolation, it's not a total replacement
|
||||
/// for just formatting strings, and a method like this is better for data independent formatting.
|
||||
///
|
||||
/// ## Example usage
|
||||
///
|
||||
/// ```typescript
|
||||
/// let hello = Format("Hello, {0}!", "World");
|
||||
/// ```
|
||||
export function Format(pattern: string, ...args: Array<StringLike>) {
|
||||
let argumentsAsStrings: Array<string> = [...args].map((el) => {
|
||||
// This catches cases where the thing already is a string
|
||||
if (typeof el == 'string') return el as string;
|
||||
return el.toString();
|
||||
});
|
||||
|
||||
let pat = pattern;
|
||||
|
||||
// Handle pattern ("{0} {1} {2} {3} {4} {5}") syntax if found
|
||||
for (let i = 0; i < pat.length; ++i) {
|
||||
if (pat[i] == '{') {
|
||||
let replacementStart = i;
|
||||
let foundSpecifierEnd = false;
|
||||
|
||||
// Make sure the specifier is not cut off (the last character of the string)
|
||||
if (i + 3 >= pat.length) {
|
||||
throw new Error(`Error in format pattern "${pat}": Cutoff/invalid format specifier`);
|
||||
}
|
||||
|
||||
// Try and find the specifier end ('}').
|
||||
// Whitespace and a '{' are considered errors.
|
||||
for (let j = i + 1; j < pat.length; ++j) {
|
||||
switch (pat[j]) {
|
||||
case '}':
|
||||
foundSpecifierEnd = true;
|
||||
i = j;
|
||||
break;
|
||||
|
||||
case '{':
|
||||
throw new Error(`Error in format pattern "${pat}": Cannot start a format specifier in an existing replacement`);
|
||||
break;
|
||||
|
||||
case ' ':
|
||||
throw new Error(`Error in format pattern "${pat}": Whitespace inside format specifier`);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (foundSpecifierEnd) break;
|
||||
}
|
||||
|
||||
if (!foundSpecifierEnd) throw new Error(`Error in format pattern "${pat}": No terminating "}" character found`);
|
||||
|
||||
// Get the beginning and trailer
|
||||
let beginning = pat.substring(0, replacementStart);
|
||||
let trailer = pat.substring(replacementStart + 3);
|
||||
|
||||
let argumentIndex = parseInt(pat.substring(replacementStart + 1, i));
|
||||
if (Number.isNaN(argumentIndex) || argumentIndex > argumentsAsStrings.length) throw new Error(`Error in format pattern "${pat}": Argument index out of bounds`);
|
||||
|
||||
// This is seriously the only decent way to do this in javascript
|
||||
// thanks brendan eich (replace this thanking with more choice words in your head)
|
||||
pat = beginning + argumentsAsStrings[argumentIndex] + trailer;
|
||||
}
|
||||
}
|
||||
|
||||
return pat;
|
||||
}
|
239
src/ts/i18n.ts
239
src/ts/i18n.ts
|
@ -1,33 +1,55 @@
|
|||
import { StringLike } from './StringLike';
|
||||
import { Format } from './format';
|
||||
|
||||
/// All string keys.
|
||||
export enum I18nStringKey {
|
||||
kSiteName = 'kSiteName',
|
||||
kHomeButton = 'kHomeButton',
|
||||
kFAQButton = 'kFAQButton',
|
||||
kRulesButton = 'kRulesButton',
|
||||
kVMResetTitle = 'kVMResetTitle',
|
||||
kGenericYes = 'kGenericYes',
|
||||
kGenericNo = 'kGenericNo',
|
||||
kVMVoteTime = 'kVMVoteTime',
|
||||
kPassVoteButton = 'kPassVoteButton',
|
||||
kCancelVoteButton = 'kCancelVoteButton',
|
||||
kTakeTurnButton = 'kTakeTurnButton',
|
||||
kEndTurnButton = 'kEndTurnButton',
|
||||
kChangeUsernameButton = 'kChangeUsernameButton',
|
||||
kVoteButton = 'kVoteButton',
|
||||
kScreenshotButton = 'kScreenshotButton',
|
||||
kUsersOnlineHeading = 'kUsersOnlineHeading',
|
||||
kTurnTime = 'kTurnTime',
|
||||
kWaitingTurnTime = 'kWaitingTurnTime',
|
||||
kVoteCooldown = 'kVoteCooldown',
|
||||
kEnterNewUsername = 'kEnterNewUsername'
|
||||
// Generic things
|
||||
kGeneric_CollabVM = 'kGeneric_CollabVM',
|
||||
kGeneric_Yes = 'kGeneric_Yes',
|
||||
kGeneric_No = 'kGeneric_No',
|
||||
kGeneric_Ok = 'kGeneric_Ok',
|
||||
kGeneric_Cancel = 'kGeneric_Cancel',
|
||||
|
||||
kSiteButtons_Home = 'kSiteButtons_Home',
|
||||
kSiteButtons_FAQ = 'kSiteButtons_FAQ',
|
||||
kSiteButtons_Rules = 'kSiteButtons_Rules',
|
||||
|
||||
kVM_UsersOnlineText = 'kVM_UsersOnlineText',
|
||||
|
||||
kVM_TurnTimeTimer = 'kVM_TurnTimeTimer',
|
||||
kVM_WaitingTurnTimer = 'kVM_WaitingTurnTimer',
|
||||
kVM_VoteCooldownTimer = 'kVM_VoteCooldownTimer',
|
||||
|
||||
kVM_VoteForResetTitle = 'kVM_VoteForResetTitle',
|
||||
kVM_VoteForResetTimer = 'kVM_VoteForResetTimer',
|
||||
|
||||
kVMButtons_TakeTurn = 'kVMButtons_TakeTurn',
|
||||
kVMButtons_EndTurn = 'kVMButtons_EndTurn',
|
||||
kVMButtons_ChangeUsername = 'kVMButtons_ChangeUsername',
|
||||
|
||||
kVMButtons_VoteForReset = 'kVMButtons_VoteForReset',
|
||||
kVMButtons_Screenshot = 'kVMButtons_Screenshot',
|
||||
|
||||
// Admin VM buttons
|
||||
kAdminVMButtons_PassVote = 'kAdminVMButtons_PassVote',
|
||||
kAdminVMButtons_CancelVote = 'kAdminVMButtons_CancelVote',
|
||||
|
||||
// prompts
|
||||
kVMPrompts_EnterNewUsernamePrompt = 'kVMPrompts_EnterNewUsernamePrompt',
|
||||
|
||||
// error messages
|
||||
kError_UnexpectedDisconnection = 'kError_UnexpectedDisconnection',
|
||||
|
||||
kError_UsernameTaken = 'kError_UsernameTaken',
|
||||
kError_UsernameInvalid = 'kError_UsernameInvalid',
|
||||
kError_UsernameBlacklisted = 'kError_UsernameBlacklisted',
|
||||
}
|
||||
|
||||
// This models the JSON structure.
|
||||
export type Language = {
|
||||
type Language = {
|
||||
languageName: string;
|
||||
translatedLanguageName: string;
|
||||
flag: string; // country flag, can be blank if not applicable. will be displayed in language dropdown
|
||||
author: string;
|
||||
|
||||
stringKeys: {
|
||||
|
@ -39,36 +61,61 @@ export type Language = {
|
|||
};
|
||||
};
|
||||
|
||||
// `languages.json`
|
||||
type LanguagesJson = {
|
||||
// Array of language IDs to allow loading
|
||||
languages: Array<string>;
|
||||
|
||||
// The default language (set if a invalid language not in the languages array is set, or no language is set)
|
||||
defaultLanguage: string;
|
||||
}
|
||||
|
||||
// ID for fallback language
|
||||
const fallbackId = 'fallback';
|
||||
const fallbackId = '!!fallback';
|
||||
|
||||
// This language is provided in the webapp itself just in case language stuff fails
|
||||
const fallbackLanguage: Language = {
|
||||
languageName: 'Fallback',
|
||||
translatedLanguageName: 'Fallback',
|
||||
flag: "no",
|
||||
author: 'Computernewb',
|
||||
|
||||
stringKeys: {
|
||||
kSiteName: 'CollabVM',
|
||||
kHomeButton: 'Home',
|
||||
kFAQButton: 'FAQ',
|
||||
kRulesButton: 'Rules',
|
||||
kVMResetTitle: 'Do you want to reset the VM?',
|
||||
kGenericYes: 'Yes',
|
||||
kGenericNo: 'No',
|
||||
kVMVoteTime: 'Vote ends in {0} seconds',
|
||||
kPassVoteButton: 'Pass Vote',
|
||||
kCancelVoteButton: 'Cancel Vote',
|
||||
kTakeTurnButton: 'Take Turn',
|
||||
kEndTurnButton: 'End Turn',
|
||||
kChangeUsernameButton: 'Change Username',
|
||||
kVoteButton: 'Vote For Reset',
|
||||
kScreenshotButton: 'Screenshot',
|
||||
kUsersOnlineHeading: 'Users Online:',
|
||||
kTurnTime: 'Turn expires in {0} seconds.',
|
||||
kWaitingTurnTime: 'Waiting for turn in {0} seconds.',
|
||||
kVoteCooldown: 'Please wait {0} seconds before starting another vote.',
|
||||
kEnterNewUsername: 'Enter a new username, or leave the field blank to be assigned a guest username'
|
||||
kGeneric_CollabVM: 'CollabVM',
|
||||
kGeneric_Yes: 'Yes',
|
||||
kGeneric_No: 'No',
|
||||
kGeneric_Ok: 'OK',
|
||||
kGeneric_Cancel: 'Cancel',
|
||||
|
||||
kSiteButtons_Home: 'Home',
|
||||
kSiteButtons_FAQ: 'FAQ',
|
||||
kSiteButtons_Rules: 'Rules',
|
||||
|
||||
kVM_UsersOnlineText: 'Users Online:',
|
||||
|
||||
kVM_TurnTimeTimer: 'Turn expires in {0} seconds.',
|
||||
kVM_WaitingTurnTimer: 'Waiting for turn in {0} seconds.',
|
||||
kVM_VoteCooldownTimer: 'Please wait {0} seconds before starting another vote.',
|
||||
|
||||
kVM_VoteForResetTitle: 'Do you want to reset the VM?',
|
||||
kVM_VoteForResetTimer: 'Vote ends in {0} seconds',
|
||||
|
||||
kVMButtons_TakeTurn: 'Take Turn',
|
||||
kVMButtons_EndTurn: 'End Turn',
|
||||
kVMButtons_ChangeUsername: 'Change Username',
|
||||
|
||||
kVMButtons_VoteForReset: 'Vote For Reset',
|
||||
kVMButtons_Screenshot: 'Screenshot',
|
||||
|
||||
kAdminVMButtons_PassVoteButton: 'Pass Vote',
|
||||
kAdminVMButtons_CancelVoteButton: 'Cancel Vote',
|
||||
|
||||
kVMPrompts_EnterNewUsernamePrompt: 'Enter a new username, or leave the field blank to be assigned a guest username',
|
||||
|
||||
kError_UnexpectedDisconnection: 'You have been disconnected from the server.',
|
||||
kError_UsernameTaken: 'That username is already taken',
|
||||
kError_UsernameInvalid: 'Usernames can contain only numbers, letters, spaces, dashes, underscores, and dots, and it must be between 3 and 20 characters.',
|
||||
kError_UsernameBlacklisted: 'That username has been blacklisted.'
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -106,6 +153,8 @@ export class I18n {
|
|||
}
|
||||
|
||||
async Init() {
|
||||
// TODO: load languages.json, add selections, and if an invalid language (not in the languages array) is specified,
|
||||
// set it to the defaultLanguage in there.
|
||||
let lang = window.localStorage.getItem('i18n-lang');
|
||||
|
||||
// Set a default language if not specified
|
||||
|
@ -146,24 +195,25 @@ export class I18n {
|
|||
// Replaces static strings that we don't recompute
|
||||
private ReplaceStaticStrings() {
|
||||
const kDomIdtoStringMap: StringKeyMap = {
|
||||
siteNameText: I18nStringKey.kSiteName,
|
||||
homeBtnText: I18nStringKey.kHomeButton,
|
||||
faqBtnText: I18nStringKey.kFAQButton,
|
||||
rulesBtnText: I18nStringKey.kRulesButton,
|
||||
siteNameText: I18nStringKey.kGeneric_CollabVM,
|
||||
homeBtnText: I18nStringKey.kSiteButtons_Home,
|
||||
faqBtnText: I18nStringKey.kSiteButtons_FAQ,
|
||||
rulesBtnText: I18nStringKey.kSiteButtons_Rules,
|
||||
|
||||
usersOnlineText: I18nStringKey.kUsersOnlineHeading,
|
||||
usersOnlineText: I18nStringKey.kVM_UsersOnlineText,
|
||||
|
||||
voteResetHeaderText: I18nStringKey.kVMResetTitle,
|
||||
voteYesBtnText: I18nStringKey.kGenericYes,
|
||||
voteNoBtnText: I18nStringKey.kGenericNo,
|
||||
voteResetHeaderText: I18nStringKey.kVM_VoteForResetTitle,
|
||||
voteYesBtnText: I18nStringKey.kGeneric_Yes,
|
||||
voteNoBtnText: I18nStringKey.kGeneric_No,
|
||||
|
||||
changeUsernameBtnText: I18nStringKey.kChangeUsernameButton,
|
||||
voteForResetBtnText: I18nStringKey.kVoteButton,
|
||||
screenshotBtnText: I18nStringKey.kScreenshotButton,
|
||||
changeUsernameBtnText: I18nStringKey.kVMButtons_ChangeUsername,
|
||||
voteForResetBtnText: I18nStringKey.kVMButtons_VoteForReset,
|
||||
screenshotBtnText: I18nStringKey.kVMButtons_Screenshot,
|
||||
|
||||
// admin stuff
|
||||
passVoteBtnText: I18nStringKey.kPassVoteButton,
|
||||
cancelVoteBtnText: I18nStringKey.kCancelVoteButton
|
||||
passVoteBtnText: I18nStringKey.kAdminVMButtons_PassVote,
|
||||
cancelVoteBtnText: I18nStringKey.kAdminVMButtons_CancelVote,
|
||||
endTurnBtnText: I18nStringKey.kVMButtons_EndTurn
|
||||
};
|
||||
|
||||
for (let domId of Object.keys(kDomIdtoStringMap)) {
|
||||
|
@ -174,88 +224,33 @@ export class I18n {
|
|||
}
|
||||
|
||||
// Do the magic.
|
||||
element.innerText = this.GetString(kDomIdtoStringMap[domId]);
|
||||
// N.B: For now, we assume all strings in this map are not formatted.
|
||||
// If this assumption changes, then we should just use GetString() again
|
||||
// and maybe include arguments, but for now this is okay
|
||||
element.innerText = this.GetStringRaw(kDomIdtoStringMap[domId]);
|
||||
}
|
||||
}
|
||||
|
||||
// Gets a string, which also allows replacing by index with the given replacements.
|
||||
GetString(key: I18nStringKey, ...replacements: StringLike[]): string {
|
||||
let replacementStringArray: Array<string> = [...replacements].map((el) => {
|
||||
// This catches cases where the thing already is a string
|
||||
if (typeof el == 'string') return el as string;
|
||||
return el.toString();
|
||||
});
|
||||
|
||||
// Returns a (raw, unformatted) string. Currently only used if we don't need formatting.
|
||||
GetStringRaw(key: I18nStringKey): string {
|
||||
let val = this.lang.stringKeys[key];
|
||||
|
||||
// Helper to throw a more descriptive error (including the looked-up string in question)
|
||||
let throwError = (desc: string) => {
|
||||
throw new Error(`Invalid replacement "${val}": ${desc}`);
|
||||
};
|
||||
|
||||
// Look up the fallback language by default if the language doesn't
|
||||
// have that string key yet; if the fallback doesn't have it either,
|
||||
// then just return the string key and a bit of a notice things have gone wrong
|
||||
if (val == undefined) {
|
||||
let fallback = fallbackLanguage.stringKeys[key];
|
||||
if (fallback !== undefined) val = fallback;
|
||||
else return `${key} (ERROR)`;
|
||||
}
|
||||
|
||||
// Handle replacement ("{0} {1} {2} {3} {4} {5}" syntax) in string keys
|
||||
// which allows us to just specify arguments we want to format into the final string,
|
||||
// instead of hacky replacements hardcoded at the source. It's more flexible that way.
|
||||
for (let i = 0; i < val.length; ++i) {
|
||||
if (val[i] == '{') {
|
||||
let replacementStart = i;
|
||||
let foundReplacementEnd = false;
|
||||
|
||||
// Make sure the replacement is not cut off (the last character of the string)
|
||||
if (i + 1 > val.length) {
|
||||
throwError('Cutoff/invalid replacement');
|
||||
}
|
||||
|
||||
// Try and find the replacement end ('}').
|
||||
// Whitespace and a '{' are considered errors.
|
||||
for (let j = i + 1; j < val.length; ++j) {
|
||||
switch (val[j]) {
|
||||
case '}':
|
||||
foundReplacementEnd = true;
|
||||
i = j;
|
||||
break;
|
||||
|
||||
case '{':
|
||||
throwError('Cannot start a replacement in an existing replacement');
|
||||
break;
|
||||
|
||||
case ' ':
|
||||
throwError('Whitespace inside replacement');
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (foundReplacementEnd) break;
|
||||
}
|
||||
|
||||
if (!foundReplacementEnd) throwError('No terminating "}" character found');
|
||||
|
||||
// Get the beginning and trailer
|
||||
let beginning = val.substring(0, replacementStart);
|
||||
let trailer = val.substring(replacementStart + 3);
|
||||
|
||||
let replacementIndex = parseInt(val.substring(replacementStart + 1, i));
|
||||
if (Number.isNaN(replacementIndex) || replacementIndex > replacementStringArray.length) throwError('Replacement index out of bounds');
|
||||
|
||||
// This is seriously the only decent way to do this in javascript
|
||||
// thanks brendan eich (replace this thanking with more choice words in your head)
|
||||
val = beginning + replacementStringArray[replacementIndex] + trailer;
|
||||
}
|
||||
else return `${key} (ERROR LOOKING UP TRANSLATION!!!)`;
|
||||
}
|
||||
|
||||
return val;
|
||||
}
|
||||
|
||||
// Returns a formatted localized string.
|
||||
GetString(key: I18nStringKey, ...replacements: StringLike[]): string {
|
||||
return Format(this.GetStringRaw(key), ...replacements);
|
||||
}
|
||||
}
|
||||
|
||||
export let TheI18n = new I18n();
|
||||
|
|
|
@ -264,16 +264,21 @@ let VM: CollabVMClient | null = null;
|
|||
async function multicollab(url: string) {
|
||||
// Create the client
|
||||
let client = new CollabVMClient(url);
|
||||
// Wait for the client to open
|
||||
await new Promise<void>((res) => client.on('open', () => res()));
|
||||
|
||||
await client.WaitForOpen();
|
||||
|
||||
// Get the list of VMs
|
||||
let list = await client.list();
|
||||
|
||||
// Get the number of online users
|
||||
let online = client.getUsers().length;
|
||||
|
||||
// Close the client
|
||||
client.close();
|
||||
|
||||
// Add to the list
|
||||
vms.push(...list);
|
||||
|
||||
// Add to the DOM
|
||||
for (let vm of list) {
|
||||
let div = document.createElement('div');
|
||||
|
@ -327,13 +332,13 @@ async function openVM(vm: VM): Promise<void> {
|
|||
// TODO: i18n these
|
||||
switch (status) {
|
||||
case 'taken':
|
||||
alert('That username is already taken');
|
||||
alert(TheI18n.GetString(I18nStringKey.kError_UsernameTaken));
|
||||
break;
|
||||
case 'invalid':
|
||||
alert('Usernames can contain only numbers, letters, spaces, dashes, underscores, and dots, and it must be between 3 and 20 characters.');
|
||||
alert(TheI18n.GetString(I18nStringKey.kError_UsernameInvalid));
|
||||
break;
|
||||
case 'blacklisted':
|
||||
alert('That username has been blacklisted.');
|
||||
alert(TheI18n.GetString(I18nStringKey.kError_UsernameBlacklisted));
|
||||
break;
|
||||
}
|
||||
})
|
||||
|
@ -341,11 +346,11 @@ async function openVM(vm: VM): Promise<void> {
|
|||
unsubscribeCallbacks.push(VM!.on('turn', (status) => turnUpdate(status)));
|
||||
unsubscribeCallbacks.push(VM!.on('vote', (status: VoteStatus) => voteUpdate(status)));
|
||||
unsubscribeCallbacks.push(VM!.on('voteend', () => voteEnd()));
|
||||
unsubscribeCallbacks.push(VM!.on('votecd', (voteCooldown) => window.alert(TheI18n.GetString(I18nStringKey.kVoteCooldown, voteCooldown))));
|
||||
unsubscribeCallbacks.push(VM!.on('votecd', (voteCooldown) => window.alert(TheI18n.GetString(I18nStringKey.kVM_VoteCooldownTimer, voteCooldown))));
|
||||
unsubscribeCallbacks.push(VM!.on('login', (rank: Rank, perms: Permissions) => onLogin(rank, perms)));
|
||||
unsubscribeCallbacks.push(
|
||||
VM!.on('close', () => {
|
||||
if (!expectedClose) alert('You have been disconnected from the server');
|
||||
if (!expectedClose) alert(TheI18n.GetString(I18nStringKey.kError_UnexpectedDisconnection));
|
||||
|
||||
// Call all the unsubscribe callbacks.
|
||||
for (let l of unsubscribeCallbacks) l();
|
||||
|
@ -355,9 +360,7 @@ async function openVM(vm: VM): Promise<void> {
|
|||
);
|
||||
|
||||
// Wait for the client to open
|
||||
await new Promise<void>((res) => {
|
||||
unsubscribeCallbacks.push(VM!.on('open', () => res()));
|
||||
});
|
||||
await VM!.WaitForOpen();
|
||||
|
||||
// Connect to node
|
||||
chatMessage('', `<b>${vm.id}</b><hr>`);
|
||||
|
@ -419,11 +422,11 @@ function closeVM() {
|
|||
}
|
||||
|
||||
async function loadList() {
|
||||
let p = [];
|
||||
for (let url of Config.ServerAddresses) {
|
||||
p.push(multicollab(url));
|
||||
}
|
||||
await Promise.all(p);
|
||||
await Promise.all(
|
||||
Config.ServerAddresses.map((url) => {
|
||||
return multicollab(url);
|
||||
})
|
||||
);
|
||||
|
||||
// automatically join the vm that's in the url if it exists in the node list
|
||||
let v = vms.find((v) => v.id === window.location.hash.substring(1));
|
||||
|
@ -556,7 +559,7 @@ function turnUpdate(status: TurnStatus) {
|
|||
user.element.classList.remove('user-turn', 'user-waiting');
|
||||
user.element.setAttribute('data-cvm-turn', '-1');
|
||||
}
|
||||
elements.turnBtnText.innerHTML = TheI18n.GetString(I18nStringKey.kTakeTurnButton);
|
||||
elements.turnBtnText.innerHTML = TheI18n.GetString(I18nStringKey.kVMButtons_TakeTurn);
|
||||
enableOSK(false);
|
||||
|
||||
if (status.user !== null) {
|
||||
|
@ -572,14 +575,14 @@ function turnUpdate(status: TurnStatus) {
|
|||
if (status.user?.username === w.username) {
|
||||
turn = 0;
|
||||
turnTimer = status.turnTime! / 1000;
|
||||
elements.turnBtnText.innerHTML = TheI18n.GetString(I18nStringKey.kEndTurnButton);
|
||||
elements.turnBtnText.innerHTML = TheI18n.GetString(I18nStringKey.kVMButtons_EndTurn);
|
||||
VM!.canvas.classList.add('focused');
|
||||
enableOSK(true);
|
||||
}
|
||||
if (status.queue.some((u) => u.username === w.username)) {
|
||||
turn = status.queue.findIndex((u) => u.username === w.username) + 1;
|
||||
turnTimer = status.queueTime! / 1000;
|
||||
elements.turnBtnText.innerHTML = TheI18n.GetString(I18nStringKey.kEndTurnButton);
|
||||
elements.turnBtnText.innerHTML = TheI18n.GetString(I18nStringKey.kVMButtons_EndTurn);
|
||||
VM!.canvas.classList.add('waiting');
|
||||
}
|
||||
if (turn === -1) elements.turnstatus.innerText = '';
|
||||
|
@ -602,7 +605,7 @@ function voteUpdate(status: VoteStatus) {
|
|||
|
||||
function updateVoteEndTime() {
|
||||
voteTimer--;
|
||||
elements.voteTimeText.innerText = TheI18n.GetString(I18nStringKey.kVMVoteTime, voteTimer);
|
||||
elements.voteTimeText.innerText = TheI18n.GetString(I18nStringKey.kVM_VoteForResetTimer, voteTimer);
|
||||
if (voteTimer === 0) clearInterval(voteInterval);
|
||||
}
|
||||
|
||||
|
@ -617,8 +620,8 @@ function turnIntervalCb() {
|
|||
}
|
||||
|
||||
function setTurnStatus() {
|
||||
if (turn === 0) elements.turnstatus.innerText = TheI18n.GetString(I18nStringKey.kTurnTime, turnTimer);
|
||||
else elements.turnstatus.innerText = TheI18n.GetString(I18nStringKey.kWaitingTurnTime, turnTimer);
|
||||
if (turn === 0) elements.turnstatus.innerText = TheI18n.GetString(I18nStringKey.kVM_TurnTimeTimer, turnTimer);
|
||||
else elements.turnstatus.innerText = TheI18n.GetString(I18nStringKey.kVM_WaitingTurnTimer, turnTimer);
|
||||
}
|
||||
|
||||
function sendChat() {
|
||||
|
@ -637,7 +640,7 @@ elements.chatinput.addEventListener('keypress', (e) => {
|
|||
if (e.key === 'Enter') sendChat();
|
||||
});
|
||||
elements.changeUsernameBtn.addEventListener('click', () => {
|
||||
let newname = prompt(TheI18n.GetString(I18nStringKey.kEnterNewUsername), w.username);
|
||||
let newname = prompt(TheI18n.GetString(I18nStringKey.kVMPrompts_EnterNewUsernamePrompt), w.username);
|
||||
if (newname === w.username) return;
|
||||
VM?.rename(newname);
|
||||
});
|
||||
|
@ -732,7 +735,7 @@ function userModOptions(user: { user: User; element: HTMLTableRowElement }) {
|
|||
td.setAttribute('aria-expanded', 'false');
|
||||
let ul = document.createElement('ul');
|
||||
ul.classList.add('dropdown-menu', 'dropdown-menu-dark', 'table-dark', 'text-light');
|
||||
if (perms.bypassturn) addUserDropdownItem(ul, TheI18n.GetString(I18nStringKey.kEndTurnButton), () => VM!.endTurn(user.user.username));
|
||||
if (perms.bypassturn) addUserDropdownItem(ul, TheI18n.GetString(I18nStringKey.kVMButtons_EndTurn), () => VM!.endTurn(user.user.username));
|
||||
if (perms.ban) addUserDropdownItem(ul, 'Ban', () => VM!.ban(user.user.username));
|
||||
if (perms.kick) addUserDropdownItem(ul, 'Kick', () => VM!.kick(user.user.username));
|
||||
if (perms.rename)
|
||||
|
|
|
@ -11,7 +11,7 @@ import MuteState from './MuteState.js';
|
|||
import { StringLike } from '../StringLike.js';
|
||||
|
||||
export interface CollabVMClientEvents {
|
||||
open: () => void;
|
||||
//open: () => void;
|
||||
close: () => void;
|
||||
|
||||
message: (...args: string[]) => void;
|
||||
|
@ -37,6 +37,7 @@ export interface CollabVMClientEvents {
|
|||
|
||||
// types for private emitter
|
||||
interface CollabVMClientPrivateEvents {
|
||||
open: () => void;
|
||||
list: (listEntries: string[]) => void;
|
||||
connect: (connectedToVM: boolean) => void;
|
||||
ip: (username: string, ip: string) => void;
|
||||
|
@ -175,7 +176,7 @@ export default class CollabVMClient {
|
|||
|
||||
// Fires when the WebSocket connection is opened
|
||||
private onOpen() {
|
||||
this.publicEmitter.emit('open');
|
||||
this.internalEmitter.emit('open');
|
||||
}
|
||||
|
||||
// Fires on WebSocket message
|
||||
|
@ -373,6 +374,16 @@ export default class CollabVMClient {
|
|||
}
|
||||
}
|
||||
|
||||
async WaitForOpen() {
|
||||
return new Promise<void>((res) => {
|
||||
// TODO: should probably reject on close
|
||||
let unsub = this.onInternal('open', () => {
|
||||
unsub();
|
||||
res();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Sends a message to the server
|
||||
send(...args: StringLike[]) {
|
||||
let guacElements = [...args].map((el) => {
|
||||
|
|
23
src/ts/tests/format.test.ts
Normal file
23
src/ts/tests/format.test.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { Format } from '../format';
|
||||
|
||||
test('a string without any format specifiers in it is unaltered', () => {
|
||||
expect(Format("Hello World")).toBe("Hello World");
|
||||
});
|
||||
|
||||
test('formatting a string works', () => {
|
||||
expect(Format('Hello, {0}!', 'World')).toBe('Hello, World!');
|
||||
});
|
||||
|
||||
test('a cut off format specifier throws', () => {
|
||||
expect(() => Format('a{0', 1)).toThrow('Cutoff/invalid format specifier');
|
||||
});
|
||||
|
||||
test('a malformed format specifier throws', () => {
|
||||
expect(() => Format('a{0 }', 1)).toThrow('Whitespace inside format specifier');
|
||||
expect(() => Format('a{ 0}', 1)).toThrow('Whitespace inside format specifier');
|
||||
expect(() => Format('a{ 0 }', 1)).toThrow('Whitespace inside format specifier');
|
||||
});
|
||||
|
||||
test("a OOB format specifier doesn't work", () => {
|
||||
expect(() => Format('a {37}', 1)).toThrow('Argument index out of bounds');
|
||||
});
|
|
@ -1,28 +1,44 @@
|
|||
{
|
||||
"languageName": "English (US)",
|
||||
"translatedLanguageName": "English (US)",
|
||||
"flag": "🇺🇸",
|
||||
"author": "Computernewb",
|
||||
|
||||
"stringKeys": {
|
||||
"kSiteName": "CollabVM",
|
||||
"kHomeButton": "Home",
|
||||
"kFAQButton": "FAQ",
|
||||
"kRulesButton": "Rules",
|
||||
"kVMResetTitle": "Do you want to reset the VM?",
|
||||
"kGenericYes": "Yes",
|
||||
"kGenericNo": "No",
|
||||
"kVMVoteTime": "Vote ends in {0} seconds",
|
||||
"kPassVoteButton": "Pass Vote",
|
||||
"kCancelVoteButton": "Cancel Vote",
|
||||
"kTakeTurnButton": "Take Turn",
|
||||
"kEndTurnButton": "End Turn",
|
||||
"kChangeUsernameButton": "Change Username",
|
||||
"kVoteButton": "Vote For Reset",
|
||||
"kScreenshotButton": "Screenshot",
|
||||
"kUsersOnlineHeading": "Users Online:",
|
||||
"kTurnTime": "Turn expires in {0} seconds.",
|
||||
"kWaitingTurnTime": "Waiting for turn in {0} seconds.",
|
||||
"kVoteCooldown": "Please wait {0} seconds before starting another vote.",
|
||||
"kEnterNewUsername": "Enter a new username, or leave the field blank to be assigned a guest username"
|
||||
"kGeneric_CollabVM": "CollabVM",
|
||||
"kGeneric_Yes": "Yes",
|
||||
"kGeneric_No": "No",
|
||||
"kGeneric_Ok": "OK",
|
||||
"kGeneric_Cancel": "Cancel",
|
||||
|
||||
"kSiteButtons_Home": "Home",
|
||||
"kSiteButtons_FAQ": "FAQ",
|
||||
"kSiteButtons_Rules": "Rules",
|
||||
|
||||
"kVM_UsersOnlineText": "Users Online:",
|
||||
|
||||
"kVM_TurnTimeTimer": "Turn expires in {0} seconds.",
|
||||
"kVM_WaitingTurnTimer": "Waiting for turn in {0} seconds.",
|
||||
"kVM_VoteCooldownTimer": "Please wait {0} seconds before starting another vote.",
|
||||
|
||||
"kVM_VoteForResetTitle": "Do you want to reset the VM?",
|
||||
"kVM_VoteForResetTimer": "Vote ends in {0} seconds",
|
||||
|
||||
"kVMButtons_TakeTurn": "Take Turn",
|
||||
"kVMButtons_EndTurn": "End Turn",
|
||||
"kVMButtons_ChangeUsername": "Change Username",
|
||||
|
||||
"kVMButtons_VoteForReset": "Vote For Reset",
|
||||
"kVMButtons_Screenshot": "Screenshot",
|
||||
|
||||
"kAdminVMButtons_PassVoteButton": "Pass Vote",
|
||||
"kAdminVMButtons_CancelVoteButton": "Cancel Vote",
|
||||
|
||||
"kVMPrompts_EnterNewUsernamePrompt": "Enter a new username, or leave the field blank to be assigned a guest username",
|
||||
|
||||
"kError_UnexpectedDisconnection": "You have been disconnected from the server.",
|
||||
"kError_UsernameTaken": "That username is already taken",
|
||||
"kError_UsernameInvalid": "Usernames can contain only numbers, letters, spaces, dashes, underscores, and dots, and it must be between 3 and 20 characters.",
|
||||
"kError_UsernameBlacklisted": "That username has been blacklisted."
|
||||
}
|
||||
}
|
||||
|
|
4
static/lang/languages.json
Normal file
4
static/lang/languages.json
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"languages": ["en-us", "pirate"],
|
||||
"defaultLanguage": "en-us"
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"languageName": "Pirate",
|
||||
"translatedLanguageName": "Arrrghh, matey!",
|
||||
"flag": "🏴☠️",
|
||||
"author": "Computernewb",
|
||||
|
||||
"stringKeys": {
|
||||
|
@ -9,8 +10,8 @@
|
|||
"kFAQButton": "Arrrgh, get your Map",
|
||||
"kRulesButton": "Arrrrghh, get the Etiquette Book",
|
||||
"kVMResetTitle": "Argh, matey, this VM is broken. Would you like to particpate in the VM War?",
|
||||
"kGenericYes": "Arrrghh, yay!",
|
||||
"kGenericNo": "Arrrrgh, nay!",
|
||||
"kGeneric_Yes": "Arrrghh, yay!",
|
||||
"kGeneric_No": "Arrrrgh, nay!",
|
||||
"kVMVoteTime": "Argghh, the cannon fight ends in {0} seconds",
|
||||
"kPassVoteButton": "Rig the vote",
|
||||
"kCancelVoteButton": "Ignore the vote",
|
||||
|
@ -23,6 +24,9 @@
|
|||
"kTurnTime": "Arrrrgh, your wheel rights expire in {0} seconds.",
|
||||
"kWaitingTurnTime": "Waiting for the wheel in {0} seconds.",
|
||||
"kVoteCooldown": "Arrgh matey, you need to wait {0} seconds to vote again.",
|
||||
"kEnterNewUsername": "Arggghh, matey, what would you like to be known as?"
|
||||
"kEnterNewUsername": "Arggghh, matey, what would you like to be known as?",
|
||||
"kError_UsernameTaken": "Sorry, matey, but another pirate is using that name.",
|
||||
"kError_UsernameInvalid": "Sorry matey, but that name is invalid. Usernames can contain only numbers, letters, spaces, dashes, underscores, and dots, and it must be between 3 and 20 characters.",
|
||||
"kError_UsernameBlacklisted": "Sorry matey, but you cannot use that name."
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue