I've been busy back here.

- Added setup endpoint.

- Added cdn endpoint.

- Indev games page and place page.

- Universes for places. These are pretty much just a collection of places with shared things like datastores, player points, etc.

- Moved asset types to the database.

- Indev negotiation tickets. Only the migration has been added.

- Indev client/studio deployments. Mostly complete.

- Upgraded to fontawesome pro.

- Moved the admin usage bars to their own component for cleanliness.

- Created default place thumbnails. Not used at the moment.

- Updated /asset to behave with the migration of asset types to the database.

- "Busy" icon for a rendering game icon.
This commit is contained in:
Graphictoria 2022-11-27 02:31:56 -05:00
parent 6c482f5a22
commit 730eb2ccb4
113 changed files with 64228 additions and 741 deletions

33
deployments.txt Normal file
View File

@ -0,0 +1,33 @@
Deployer
Deploy or Revert switch
If deploy:
- Generate a new version hash for studio and/or client.
- Able to deploy both client/studio at the same time.
- If no launcher provided, use launcher from previous version.
- Set client/studio version in dynamic web config.
If revert:
- Provide a dropdown list of recent versions with their version numbers
i.e.: (Studio 1.0.0.0) version-abcdefghijk
(Client 1.0.0.0) version-bbcdefghijk
- Reset client/studio version in dynamic web config.
Studio/Client deployment buttons.
- Create a new card for deploying the new studio/client.
- Disable button if one exists on the view already.
- Allow Xing out of a deployment box.
- Standardized deployment interface.
- Allow one or both.
- On upload both should be pushed at the same time, not as they upload.
Deployment Api
On deploy:
- Create and return version hash.
- /admin/v1/deploy should return a status, such as the ones thumbnails return. These should have a percentage and a message.
- Message/percentage to be displayed on modal in deployment JS.
- Call /admin/v1/deploy/version-hash
- Validate if the version is supposed to be deployed.

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@ -62,17 +62,21 @@
# Api endpoints.
<VirtualHost *:80 *:443>
ServerName gtoria.net
ServerAlias api.gtoria.net
ServerAlias apis.gtoria.net
ServerAlias assetgame.gtoria.net
ServerAlias data.gtoria.net
ServerAlias gamepersistence.gtoria.net
ServerAlias cdn.gtoria.net
ServerAlias clientsettings.api.gtoria.net
ServerAlias ephemeralcounters.api.gtoria.net
ServerAlias versioncompatibility.api.gtoria.net
ServerAlias logging.service.gtoria.net
ServerAlias data.gtoria.net
ServerAlias ecsv2.gtoria.net
ServerAlias ephemeralcounters.api.gtoria.net
ServerAlias gamepersistence.gtoria.net
ServerAlias logging.service.gtoria.net
ServerAlias setup.gtoria.net
ServerAlias test.public.ecs.gtoria.net
ServerAlias versioncompatibility.api.gtoria.net
SSLEngine on
SSLCertificateFile "C:/graphictoria/COMMIT_HASH/etc/cert/cloudflare.crt"

View File

@ -0,0 +1,138 @@
<?php
namespace App\Http\Controllers\Api;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use App\Helpers\ValidationHelper;
use App\Http\Controllers\Controller;
use App\Jobs\AppDeployment;
use App\Models\Deployment;
use App\Rules\AppDeploymentFilenameRule;
class AdminController extends Controller
{
// Moderator+
// Admin+
// Owner+
function deploy(Request $request)
{
$validator = Validator::make($request->all(), [
'version' => ['regex:/version\\-[a-fA-F0-9]{16}/'],
'type' => ['required_without:version', 'regex:/(Deploy|Revert)/i'],
'app' => ['required_without:version', 'regex:/(Client|Studio)/i']
]);
if($validator->fails())
return ValidationHelper::generateValidatorError($validator);
$valid = $validator->valid();
$response = [
'status' => 'Loading',
'version' => null,
'message' => 'Please wait...',
'progress' => 0
];
if(!$request->has('version'))
{
$deployment = Deployment::newVersionHash($valid);
$response['version'] = $deployment->version;
$response['message'] = 'Created deployment.';
$response['progress'] = 0;
return response($response);
}
$deployment = Deployment::where('version', $valid['version'])->first();
if($deployment === null || !$deployment->isValid()) {
$validator->errors()->add('version', 'Unknown version deployment hash.');
return ValidationHelper::generateValidatorError($validator);
}
$response['version'] = $deployment->version;
if($deployment->error != null)
{
$response['status'] = 'Error';
$response['message'] = sprintf('Failed to deploy %s. Error: %s', $deployment->version, $deployment->error);
$response['progress'] = 1;
return response($response);
}
$steps = 5;
$response['progress'] = $deployment->step/$steps;
switch($deployment->step)
{
case 0:
$response['message'] = 'Files uploading.';
break;
case 1:
$response['message'] = 'Batching deployment.';
break;
case 2:
$response['message'] = 'Unpacking files.';
break;
case 3:
$response['message'] = 'Updating version security.';
break;
case 4:
$response['message'] = 'Pushing deployment to setup.';
break;
case 5:
$response['status'] = 'Success';
$response['message'] = sprintf('Deploy completed. Successfully deployed %s %s', $deployment->app, $deployment->version);
break;
}
return response($response);
}
function deployVersion(Request $request, string $version)
{
$validator = Validator::make($request->all(), [
'file.*' => ['required']
]);
if($validator->fails())
return ValidationHelper::generateValidatorError($validator);
$valid = $validator->valid();
$deployment = Deployment::where('version', $version)->first();
if($deployment === null || !$deployment->isValid() || $deployment->step != 0) {
$validator->errors()->add('version', 'Unknown version deployment hash.');
return ValidationHelper::generateValidatorError($validator);
}
$deploymentRule = new AppDeploymentFilenameRule($deployment->app);
if(!$deploymentRule->passes('file', $request->file('file')))
{
$deployment->error = 'Missing files.';
$deployment->save();
$validator->errors()->add('file', $deployment->error);
return ValidationHelper::generateValidatorError($validator);
}
foreach($request->file('file') as $file)
{
$file->storeAs(
'setuptmp',
sprintf('%s-%s', $version, $file->getClientOriginalName())
);
}
$deployment->step = 1; // Batching deployment.
$deployment->save();
AppDeployment::dispatch($deployment);
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers\Api;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use App\Http\Controllers\Controller;
use App\Models\NegotiationTicket;
class ClientController extends Controller
{
function generateAuthTicket()
{
$ticket = Str::random(100);
NegotiationTicket::create([
'ticket' => $ticket,
'userId' => Auth::user()->id
]);
return response($ticket)
->header('Content-Type', 'text/plain');
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Api;
use App\Helpers\ValidationHelper;
use App\Http\Controllers\Controller;
use App\Models\Asset;
use App\Models\Universe;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
class GamesController extends Controller
{
protected static function getAssets()
{
// TODO: XlXi: sort also based on how many people are in open servers
return Universe::where('public', true)
->whereRelation('starterPlace', 'moderated', false)
->join('assets', 'assets.id', '=', 'universes.startPlaceId')
->orderBy('assets.created_at', 'desc')
->orderBy('assets.visits', 'desc');
}
protected function listJson(Request $request)
{
$assets = self::getAssets()->paginate(30);
$data = [];
foreach($assets as $asset) {
$asset = $asset->starterPlace;
$creator = $asset->user;
array_push($data, [
'Name' => $asset->universe->name,
'Creator' => [
'Name' => $creator->username,
'Url' => $creator->getProfileUrl()
],
'Playing' => 0,
'Ratio' => 0,
'Url' => route('games.asset', ['asset' => $asset->id, 'assetName' => Str::slug($asset->name, '-')])
]);
}
return response([
'pages' => ($assets->hasPages() ? $assets->lastPage() : 1),
'data' => $data
]);
}
}

View File

@ -5,7 +5,6 @@ namespace App\Http\Controllers\Api;
use App\Helpers\ValidationHelper;
use App\Http\Controllers\Controller;
use App\Models\Asset;
use App\Models\Shout;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;

View File

@ -67,8 +67,8 @@ class ThumbnailController extends Controller
// TODO: XlXi: Turn this into a switch case and fill in the rest of the unrenderables.
// Things like HTML assets should just have a generic "default" image.
if($model->assetTypeId == 1)
$model = Asset::where('id', $model->parentAsset)->first();
//if($model->assetTypeId == 1)
// $model = Asset::where('id', $model->parentAsset)->first();
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers\Setup;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use App\Http\Controllers\Controller;
use App\Models\DynamicWebConfiguration;
class SetupController extends Controller
{
public function getFile(Request $request, $file)
{
$file = basename($file);
$filePath = Storage::path('setup/' . $file);
if(!file_exists($filePath) || strtolower($file) == '.gitignore' || str_ends_with(strtolower($file), 'pdb.zip'))
return response('404 not found.', 404)
->header('Content-Type', 'text/plain');
return response()->file($filePath);
}
public function getClientVersion()
{
return response(DynamicWebConfiguration::where('name', 'ClientUploadVersion')->first()->value)
->header('Content-Type', 'text/plain');
}
public function getStudioVersion()
{
return response(DynamicWebConfiguration::where('name', 'StudioUploadVersion')->first()->value)
->header('Content-Type', 'text/plain');
}
}

View File

@ -9,6 +9,16 @@ use App\Models\DynamicWebConfiguration;
class AdminController extends Controller
{
function getJs(Request $request, string $jsFile)
{
$filePath = public_path('js/adm/' . basename($jsFile));
if(!file_exists($filePath))
abort(404);
return response()->file($filePath);
}
// Moderator+
function dashboard()
{
@ -16,6 +26,11 @@ class AdminController extends Controller
}
// Admin+
function metricsVisualization()
{
return view('web.admin.metricsvisualization');
}
function arbiterDiag(Request $request, string $arbiterType = null)
{
return view('web.admin.arbiter.diag')->with([
@ -39,4 +54,9 @@ class AdminController extends Controller
'values' => DynamicWebConfiguration::get()
]);
}
function deployer()
{
return view('web.admin.deployer');
}
}

View File

@ -81,6 +81,8 @@ class ClientController extends Controller
if(
!($asset->onSale || (Auth::check() && Auth::user()->id == $asset->creatorId)) // not on sale and not the creator
&&
!($asset->copyable()) // asset isn't defaulted to open source
&&
!GridHelper::hasAllAccess() // not grid
) {
$validator->errors()->add('id', 'You do not have access to this asset.');

View File

@ -2,8 +2,10 @@
namespace App\Http\Controllers\Web;
use App\Models\Asset;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class GamesController extends Controller
{
@ -11,4 +13,23 @@ class GamesController extends Controller
{
return view('web.games.index');
}
public function showGame(Request $request, Asset $asset, string $assetName = null)
{
$assetSlug = Str::slug($asset->name, '-');
if($asset->moderated)
abort(404);
if($asset->assetTypeId != 9) // Place
return redirect()->route('shop.asset', ['asset' => $asset->id, 'assetName' => $assetSlug]);
if ($assetName != $assetSlug)
return redirect()->route('games.asset', ['asset' => $asset->id, 'assetName' => $assetSlug]);
return view('web.games.asset')->with([
'title' => sprintf('%s by %s', $asset->universe->name, $asset->user->username),
'asset' => $asset
]);
}
}

View File

@ -16,10 +16,14 @@ class ShopController extends Controller
public function showAsset(Request $request, Asset $asset, string $assetName = null)
{
if ($asset->moderated)
$assetSlug = Str::slug($asset->name, '-');
if($asset->moderated)
abort(404);
$assetSlug = Str::slug($asset->name, '-');
if($asset->assetTypeId == 9) // Place
return redirect()->route('games.asset', ['asset' => $asset->id, 'assetName' => $assetSlug]);
if ($assetName != $assetSlug)
return redirect()->route('shop.asset', ['asset' => $asset->id, 'assetName' => $assetSlug]);

View File

@ -0,0 +1,125 @@
<?php
namespace App\Jobs;
use COM;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use ZipArchive;
use App\Models\Deployment;
use App\Models\DynamicWebConfiguration;
class AppDeployment implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $deployment;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(Deployment $deployment)
{
$this->deployment = $deployment;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$this->deployment->step = 2; // Unpacking files.
$this->deployment->save();
$workingDirectory = storage_path(sprintf('app/setuptmp/%s', $this->deployment->version));
Storage::makeDirectory($workingDirectory);
$appArchive = '';
$appName = '';
$bootstrapperName = '';
$bootstrapperVersionName = '';
switch($this->deployment->app)
{
case 'client':
$appArchive = 'GraphictoriaApp.zip';
$appName = 'GraphictoriaPlayer.exe';
$bootstrapperName = 'GraphictoriaPlayerLauncher.exe';
$bootstrapperVersionName = 'BootstrapperVersion.txt';
break;
case 'studio':
$appArchive = 'GraphictoriaStudio.zip';
$appName = 'GraphictoriaStudio.exe';
$bootstrapperName = 'GraphictoriaStudioLauncherBeta.exe';
$bootstrapperVersionName = 'BootstrapperQTStudioVersion.txt';
break;
}
$bootstrapperLocation = sprintf('%s/../%s-%s', $workingDirectory, $this->deployment->version, $bootstrapperName);
$zip = new ZipArchive();
$zip->open(sprintf(
'%s/../%s-%s',
$workingDirectory,
$this->deployment->version,
$appArchive
));
$zip->extractTo($workingDirectory);
$zip->close();
$this->deployment->step = 3; // Updating version security.
$this->deployment->save();
// XlXi: this will not work on linux.
$fso = new COM("Scripting.FileSystemObject");
$appVersion = $fso->GetFileVersion(sprintf('%s/%s', $workingDirectory, $appName));
$bootstrapperVersion = $fso->GetFileVersion($bootstrapperLocation);
$hashConfig = DynamicWebConfiguration::where('name', sprintf('%sUploadVersion', $this->deployment->app))->first();
$versionConfig = DynamicWebConfiguration::where('name', sprintf('%sDeployVersion', $this->deployment->app))->first();
$launcherConfig = DynamicWebConfiguration::where('name', sprintf('%sLauncherDeployVersion', $this->deployment->app))->first();
$hashConfig->value = $this->deployment->version;
$versionConfig->value = $appVersion;
$launcherConfig->value = $bootstrapperVersion;
$hashConfig->save();
$versionConfig->save();
$launcherConfig->save();
$this->deployment->step = 4; // Pushing to setup.
$this->deployment->save();
Storage::copy(sprintf('setuptmp/%s-%s', $this->deployment->version, $bootstrapperName), sprintf('setup/%s', $bootstrapperName));
Storage::put(sprintf('setup/%s-%s', $this->deployment->version, $bootstrapperVersionName), str_replace('.', ', ', $bootstrapperVersion));
Storage::put(sprintf('setup/%s-gtManifest.txt', $this->deployment->version), '');
$files = Storage::files('setuptmp');
foreach($files as $file)
{
$fileName = str_replace('setuptmp/', '', $file);
if(str_starts_with($fileName, $this->deployment->version))
Storage::move($file, sprintf('setup/%s', $fileName));
}
Storage::deleteDirectory(sprintf('setuptmp/%s', $this->deployment->version));
$this->deployment->step = 5; // Success.
$this->deployment->save();
}
public function failed($exception)
{
$this->deployment->error = $exception->getMessage();
$this->deployment->save();
}
}

View File

@ -83,7 +83,7 @@ class ArbiterRender implements ShouldQueue
{
// TODO: XlXi: User avatar/closeup render support.
$arguments = [
url(sprintf('/asset?id=%d', $this->assetId)), // TODO: XlXi: Move url() to route once the route actually exists.
url(sprintf('/asset?id=%d', $this->assetId)), // TODO: XlXi: Move url() to route() once the route actually exists.
($this->is3D ? 'OBJ' : 'PNG'),
840, // Width
840, // Height
@ -103,6 +103,8 @@ class ArbiterRender implements ShouldQueue
array_push($arguments, '27113661;25251154'); // Custom Texture URLs (shirt and pands)
break;
case 'Place':
$arguments[2] = 768*4; // XlXi: These get scaled down by 4.
$arguments[3] = 432*4; // XlXi: These get scaled down by 4.
array_push($arguments, '0'); // TODO: XlXi: Universe IDs
break;
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class AssetType extends Model
{
use HasFactory;
}

View File

@ -0,0 +1,58 @@
<?php
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Deployment extends Model
{
use HasFactory;
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'created_at' => 'datetime',
'updated_at' => 'datetime'
];
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'version',
'app',
'type'
];
static function newVersionHash($config)
{
// XlXi: We want a GUID here, not a UUID. This is why we're not using Str::uuid().
$hash = preg_replace('/[^a-z0-9]+/i', '', com_create_guid());
$hash = substr($hash, 0, 16);
$hash = strtolower($hash);
return self::create([
'version' => sprintf('version-%s', $hash),
'app' => strtolower($config['app']),
'type' => strtolower($config['type'])
]);
}
function isValid()
{
$isValid = $this->created_at > Carbon::now()->subMinute(3);
if(!$isValid)
{
$this->delete();
}
return $isValid;
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class NegotiationTicket extends Model
{
use HasFactory;
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'ticket',
'userId'
];
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Universe extends Model
{
use HasFactory;
public function starterPlace()
{
return $this->belongsTo(Asset::class, 'startPlaceId');
}
}

View File

@ -7,6 +7,17 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Http;
use App\Models\AssetType;
/*
TODO: XlXi: game performance priority system
where games can be chosen to have a higher
thread count on RCC.
TODO: XlXi: game reccomendations, split words of title
SELECT articleID, COUNT(keyword) FROM keyword WHERE keyword IN (A, B, C) GROUP BY articleID ORDER BY COUNT(keyword) DESC
*/
class Asset extends Model
{
use HasFactory;
@ -21,85 +32,7 @@ class Asset extends Model
'updated_at' => 'datetime',
];
protected $assetTypes = [
/* 0 */ 'Product',
/* 1 */ 'Image',
/* 2 */ 'T-Shirt',
/* 3 */ 'Audio',
/* 4 */ 'Mesh',
/* 5 */ 'Lua',
/* 6 */ 'HTML',
/* 7 */ 'Text',
/* 8 */ 'Hat',
/* 9 */ 'Place',
/* 10 */ 'Model',
/* 11 */ 'Shirt',
/* 12 */ 'Pants',
/* 13 */ 'Decal',
/* 14 */ null, // Doesn't exist on Roblox.
/* 15 */ null, // Doesn't exist on Roblox.
/* 16 */ 'Avatar',
/* 17 */ 'Head',
/* 18 */ 'Face',
/* 19 */ 'Gear',
/* 20 */ null, // Doesn't exist on Roblox.
/* 21 */ 'Badge',
/* 22 */ 'Group Emblem',
/* 23 */ null, // Doesn't exist on Roblox.
/* 24 */ 'Animation',
/* 25 */ 'Arms',
/* 26 */ 'Legs',
/* 27 */ 'Torso',
/* 28 */ 'Right Arm',
/* 29 */ 'Left Arm',
/* 30 */ 'Left Leg',
/* 31 */ 'Right Leg',
/* 32 */ 'Package',
/* 33 */ 'YouTubeVideo',
/* 34 */ 'Game Pass',
/* 35 */ 'App',
/* 36 */ null, // Doesn't exist on Roblox.
/* 37 */ 'Code',
/* 38 */ 'Plugin',
/* 39 */ 'SolidModel',
/* 40 */ 'MeshPart',
/* 41 */ 'Hair Accessory',
/* 42 */ 'Face Accessory',
/* 43 */ 'Neck Accessory',
/* 44 */ 'Shoulder Accessory',
/* 45 */ 'Front Accessory',
/* 46 */ 'Back Accessory',
/* 47 */ 'Waist Accessory',
/* 48 */ 'Climb Animation',
/* 49 */ 'Death Animation',
/* 50 */ 'Fall Animation',
/* 51 */ 'Idle Animation',
/* 52 */ 'Jump Animation',
/* 53 */ 'Run Animation',
/* 54 */ 'Swim Animation',
/* 55 */ 'Walk Animation',
/* 56 */ 'Pose Animation',
/* 57 */ 'Ear Accessory',
/* 58 */ 'Eye Accessory',
/* 59 */ 'LocalizationTableManifest',
/* 60 */ 'LocalizationTableTranslation',
/* 61 */ 'Emote Animation',
/* 62 */ 'Video',
/* 63 */ 'TexturePack',
/* 64 */ 'T-Shirt Accessory',
/* 65 */ 'Shirt Accessory',
/* 66 */ 'Pants Accessory',
/* 67 */ 'Jacket Accessory',
/* 68 */ 'Sweater Accessory',
/* 69 */ 'Shorts Accessory',
/* 70 */ 'Left Shoe Accessory',
/* 71 */ 'Right Shoe Accessory',
/* 72 */ 'Dress Skirt Accessory',
/* 73 */ 'Font Family',
/* 74 */ 'Font Face',
/* 75 */ 'MeshHiddenSurfaceRemoval'
];
/* TODO: XlXi: move to db */
protected $assetGenres = [
/* 0 */ 'All',
/* 1 */ 'Town And City',
@ -119,6 +52,7 @@ class Asset extends Model
/* 15 */ 'RPG'
];
/* TODO: XlXi: move to db */
protected $gearAssetGenres = [
/* 0 */ 'Melee Weapon',
/* 1 */ 'Ranged Weapon',
@ -131,6 +65,11 @@ class Asset extends Model
/* 8 */ 'Personal Transport'
];
public function assetType()
{
return $this->belongsTo(AssetType::class, 'assetTypeId');
}
public function user()
{
return $this->belongsTo(User::class, 'creatorId');
@ -138,85 +77,44 @@ class Asset extends Model
public function parentAsset()
{
return $this->belongsTo(User::class, 'parentAssetId');
return $this->belongsTo(Asset::class, 'parentAssetId');
}
public function typeString()
{
return $this->assetTypes[$this->assetTypeId];
}
public function universe()
{
return $this->belongsTo(Universe::class, 'universeId');
}
public function latestVersion()
{
return $this->belongsTo(AssetVersion::class, 'assetVersionId');
}
public function typeString()
{
return $this->assetType->name;
}
public function isWearable()
{
switch($this->assetTypeId)
{
case 2: // T-Shirt
case 8: // Hat
case 11: // Shirt
case 12: // Pants
case 17: // Head
case 18: // Face
case 19: // Gear
case 25: // Arms
case 26: // Legs
case 27: // Torso
case 28: // Right Arm
case 29: // Left Arm
case 30: // Left Leg
case 31: // Right Leg
case 32: // Package
return true;
}
return false;
return $this->assetType->wearable;
}
public function isRenderable()
{
switch($this->assetTypeId)
{
case 2: // T-Shirt
case 4: // Mesh
case 8: // Hat
case 9: // Place
case 10: // Model
case 11: // Shirt
case 12: // Pants
case 13: // Decal
case 17: // Head
case 18: // Face
case 19: // Gear
case 25: // Arms
case 26: // Legs
case 27: // Torso
case 28: // Right Arm
case 29: // Left Arm
case 30: // Left Leg
case 31: // Right Leg
case 32: // Package
return true;
}
return false;
return $this->assetType->renderable;
}
public function canRender3D()
{
switch($this->assetTypeId)
{
case 9: // Place
case 10: // Model
case 13: // Decal
case 18: // Face
return false;
}
return $this->isRenderable();
return $this->assetType->renderable3d;
}
// XlXi: copyable() is when an asset is freely available for download
// on /asset regardless of whether or not its on sale or owned.
public function copyable()
{
return $this->assetType->copyable;
}
public function getThumbnail()
@ -225,12 +123,12 @@ class Asset extends Model
// TODO: XlXi: Turn this into a switch case and fill in the rest of the unrenderables.
// Things like HTML assets should just have a generic "default" image.
if($this->assetTypeId == 1) // Image
$renderId = $this->parentAsset->id;
//if($this->assetTypeId == 1) // Image
// $renderId = $this->parentAsset->id;
$thumbnail = Http::get(route('thumbnails.v1.asset', ['id' => $renderId, 'type' => '2d']));
if($thumbnail->json('status') == 'loading')
return 'https://gtoria.local/images/busy/asset.png';
return ($this->assetTypeId == 9 ? 'https://gtoria.local/images/busy/game.png' : 'https://gtoria.local/images/busy/asset.png');
return $thumbnail->json('data');
}

View File

@ -71,6 +71,14 @@ class RouteServiceProvider extends ServiceProvider
->middleware('api')
->namespace('App\Http\Controllers\Cdn')
->group(base_path('routes/cdn.php'));
//
// Domain: setup.gtoria.net
//
Route::domain('setup.' . DomainHelper::TopLevelDomain())
->middleware('api')
->namespace('App\Http\Controllers\Setup')
->group(base_path('routes/setup.php'));
});
}

View File

@ -0,0 +1,91 @@
<?php
namespace App\Rules;
use Illuminate\Contracts\Validation\Rule;
class AppDeploymentFilenameRule implements Rule
{
protected $appType;
/**
* Create a new rule instance.
*
* @param string $appType
* @return void
*/
public function __construct(string $appType)
{
$this->appType = $appType;
}
/**
* Determine if the validation rule passes.
*
* @param string $attribute
* @param mixed $value
* @return bool
*/
public function passes($attribute, $value)
{
if(!$value)
return false;
$files = [
'content-fonts.zip',
'content-music.zip',
'content-particles.zip',
'content-sky.zip',
'content-sounds.zip',
'content-terrain.zip',
'content-textures.zip',
'content-textures2.zip',
'content-textures3.zip',
'shaders.zip',
'redist.zip',
'libraries.zip'
];
if($this->appType == 'client')
{
array_push($files, ...[
'playerpdb.zip',
'graphictoria.zip',
'graphictoriaplayerlauncher.exe'
]);
}
elseif($this->appType == 'studio')
{
array_push($files, ...[
'builtinplugins.zip',
'imageformats.zip',
'content-scripts.zip',
'studiopdb.zip',
'graphictoriastudio.zip',
'graphictoriastudiolauncherbeta.exe'
]);
}
$neededFiles = count($files);
if(count($value) != $neededFiles)
return false;
foreach($value as $file)
{
if(!in_array(strtolower($file->getClientOriginalName()), $files))
return false;
}
return true;
}
/**
* Get the validation error message.
*
* @return string
*/
public function message()
{
return 'Missing files.';
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\View\Components\Admin;
use Illuminate\View\Component;
class UsageBar extends Component
{
/**
* Create a new component instance.
*
* @return void
*/
public function __construct()
{
//
}
/**
* Get the view / contents that represent the component.
*
* @return \Illuminate\Contracts\View\View|\Closure|string
*/
public function render()
{
return view('components.admin.usage-bar');
}
}

View File

@ -20,6 +20,7 @@ return new class extends Migration
$table->text('user_agent')->nullable();
$table->text('payload');
$table->integer('last_activity')->index();
$table->boolean('clientAuthenticated')->default(false);
$table->timestamps();
});
}

View File

@ -24,6 +24,10 @@ return new class extends Migration
$table->boolean('approved')->default(false);
$table->boolean('moderated')->default(false);
$table->unsignedBigInteger('favorites')->default(0);
$table->unsignedBigInteger('upVotes')->default(0);
$table->unsignedBigInteger('downVotes')->default(0);
$table->unsignedBigInteger('priceInTokens')->default(15);
$table->unsignedBigInteger('sales')->default(0);
$table->boolean('onSale')->default(false);
@ -32,6 +36,14 @@ return new class extends Migration
$table->unsignedSmallInteger('assetAttributeId')->nullable();
$table->unsignedBigInteger('assetVersionId')->comment('The most recent version id for the asset. This is used internally as asset version 0 when using the /asset api.');
$table->unsignedBigInteger('universeId')->nullable();
$table->unsignedBigInteger('onlinePlayers')->default(0);
$table->unsignedBigInteger('visits')->default(0);
$table->unsignedBigInteger('maxPlayers')->default(10);
$table->unsignedTinyInteger('chatStyleEnum')->default(2);
$table->boolean('uncopylocked')->default(false);
$table->string('iconHash')->nullable();
$table->string('thumbnail2DHash')->nullable();
$table->string('thumbnail3DHash')->nullable();

View File

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('universes', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('creatorId');
$table->string('name');
$table->unsignedBigInteger('startPlaceId');
$table->boolean('public')->default(false);
$table->boolean('studioApiServices')->default(false);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('universes');
}
};

View File

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('asset_types', function (Blueprint $table) {
$table->unsignedBigInteger('id')->unique()->nullable();
$table->string('name')->nullable();
$table->boolean('wearable')->default(false);
$table->boolean('renderable')->default(false);
$table->boolean('renderable3d')->default(false);
$table->boolean('copyable')->default(false); // Can be downloaded through /asset
$table->boolean('sellable')->default(false); // If false, can be made on sale for free only.
$table->boolean('locked')->default(false); // Cannot be put on sale
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('asset_types');
}
};

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('negotiation_tickets', function (Blueprint $table) {
$table->id();
$table->string('ticket');
$table->unsignedBigInteger('userId');
$table->boolean('used')->default(false);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('negotiation_tickets');
}
};

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('deployments', function (Blueprint $table) {
$table->id();
$table->string('version');
$table->string('app');
$table->string('type');
$table->int('step')->default(0);
$table->string('error')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('deployments');
}
};

View File

@ -0,0 +1,422 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use App\Models\AssetType;
class AssetTypeSeeder extends Seeder
{
/* Default asset types */
protected $assetTypes = [
[
'name' => 'Product',
'locked' => true
],
[
'name' => 'Image',
'renderable' => true,
'copyable' => true,
'locked' => true
],
[
'name' => 'T-Shirt',
'renderable' => true,
'renderable3d' => true,
'copyable' => true,
'sellable' => true,
'wearable' => true
],
[
'name' => 'Audio',
'copyable' => true
],
[
'name' => 'Mesh',
'renderable' => true,
'renderable3d' => true,
'copyable' => true,
'locked' => true
],
[
'name' => 'Lua',
'copyable' => true,
'locked' => true
],
[
'name' => 'HTML',
'copyable' => true,
'locked' => true
],
[
'name' => 'Text',
'copyable' => true,
'locked' => true
],
[
'name' => 'Hat',
'renderable' => true,
'renderable3d' => true,
'copyable' => true,
'sellable' => true,
'wearable' => true
],
[
'name' => 'Place',
'renderable' => true
],
[
'name' => 'Model',
'renderable' => true
],
[
'name' => 'Shirt',
'renderable' => true,
'renderable3d' => true,
'copyable' => true,
'sellable' => true,
'wearable' => true
],
[
'name' => 'Pants',
'renderable' => true,
'renderable3d' => true,
'copyable' => true,
'sellable' => true,
'wearable' => true
],
[
'name' => 'Decal',
'renderable' => true,
'copyable' => true
],
null,
null,
[
'name' => 'Avatar',
'locked' => true
],
[
'name' => 'Head',
'renderable' => true,
'renderable3d' => true,
'copyable' => true,
'sellable' => true,
'wearable' => true
],
[
'name' => 'Face',
'renderable' => true,
'renderable3d' => true,
'copyable' => true,
'sellable' => true,
'wearable' => true
],
[
'name' => 'Gear',
'renderable' => true,
'renderable3d' => true,
'copyable' => true,
'sellable' => true,
'wearable' => true
],
null,
[
'name' => 'Badge',
'copyable' => true,
'locked' => true
],
[
'name' => 'Group Emblem',
'renderable' => true,
'copyable' => true,
'locked' => true
],
null,
[
'name' => 'Animation',
'copyable' => true
],
[
'name' => 'Arms',
'renderable' => true,
'renderable3d' => true,
'copyable' => true,
'locked' => true,
'wearable' => true
],
[
'name' => 'Legs',
'renderable' => true,
'renderable3d' => true,
'copyable' => true,
'locked' => true,
'wearable' => true
],
[
'name' => 'Torso',
'renderable' => true,
'renderable3d' => true,
'copyable' => true,
'locked' => true,
'wearable' => true
],
[
'name' => 'Right Arm',
'renderable' => true,
'renderable3d' => true,
'copyable' => true,
'locked' => true,
'wearable' => true
],
[
'name' => 'Left Arm',
'renderable' => true,
'renderable3d' => true,
'copyable' => true,
'locked' => true,
'wearable' => true
],
[
'name' => 'Left Leg',
'renderable' => true,
'renderable3d' => true,
'copyable' => true,
'locked' => true,
'wearable' => true
],
[
'name' => 'Right Leg',
'renderable' => true,
'renderable3d' => true,
'copyable' => true,
'locked' => true,
'wearable' => true
],
[
'name' => 'Package',
'renderable' => true,
'renderable3d' => true,
'copyable' => true,
'sellable' => true,
'wearable' => true
],
[
'name' => 'YouTubeVideo',
'locked' => true
],
[
'name' => 'Game Pass',
'sellable' => true
],
[
'name' => 'App',
'locked' => true
],
null,
[
'name' => 'Code',
'copyable' => true,
'locked' => true
],
[
'name' => 'Plugin',
'sellable' => true
],
[
'name' => 'SolidModel',
'renderable' => true,
'renderable3d' => true,
'copyable' => true,
'locked' => true
],
[
'name' => 'MeshPart',
'renderable' => true,
'renderable3d' => true,
'locked' => true
],
[
'name' => 'Hair Accessory',
'renderable' => true,
'renderable3d' => true,
'copyable' => true,
'sellable' => true,
'wearable' => true
],
[
'name' => 'Face Accessory',
'renderable' => true,
'renderable3d' => true,
'copyable' => true,
'sellable' => true,
'wearable' => true
],
[
'name' => 'Neck Accessory',
'renderable' => true,
'renderable3d' => true,
'copyable' => true,
'sellable' => true,
'wearable' => true
],
[
'name' => 'Shoulder Accessory',
'renderable' => true,
'renderable3d' => true,
'copyable' => true,
'sellable' => true,
'wearable' => true
],
[
'name' => 'Front Accessory',
'renderable' => true,
'renderable3d' => true,
'copyable' => true,
'sellable' => true,
'wearable' => true
],
[
'name' => 'Back Accessory',
'renderable' => true,
'renderable3d' => true,
'copyable' => true,
'sellable' => true,
'wearable' => true
],
[
'name' => 'Waist Accessory',
'renderable' => true,
'renderable3d' => true,
'copyable' => true,
'sellable' => true,
'wearable' => true
],
[
'name' => 'Climb Animation',
'locked' => true
],
[
'name' => 'Death Animation',
'locked' => true
],
[
'name' => 'Fall Animation',
'locked' => true
],
[
'name' => 'Idle Animation',
'locked' => true
],
[
'name' => 'Jump Animation',
'locked' => true
],
[
'name' => 'Run Animation',
'locked' => true
],
[
'name' => 'Swim Animation',
'locked' => true
],
[
'name' => 'Walk Animation',
'locked' => true
],
[
'name' => 'Pose Animation',
'locked' => true
],
[
'name' => 'Ear Accessory',
'locked' => true
],
[
'name' => 'Eye Accessory',
'locked' => true
],
[
'name' => 'LocalizationTableManifest',
'locked' => true
],
[
'name' => 'LocalizationTableTranslation',
'locked' => true
],
[
'name' => 'Emote Animation',
'locked' => true
],
[
'name' => 'Video',
'locked' => true
],
[
'name' => 'TexturePack',
'locked' => true
],
[
'name' => 'T-Shirt Accessory',
'locked' => true
],
[
'name' => 'Shirt Accessory',
'locked' => true
],
[
'name' => 'Pants Accessory',
'locked' => true
],
[
'name' => 'Jacket Accessory',
'locked' => true
],
[
'name' => 'Sweater Accessory',
'locked' => true
],
[
'name' => 'Shorts Accessory',
'locked' => true
],
[
'name' => 'Left Shoe Accessory',
'locked' => true
],
[
'name' => 'Right Shoe Accessory',
'locked' => true
],
[
'name' => 'Dress Skirt Accessory',
'locked' => true
],
[
'name' => 'Font Family',
'locked' => true
],
[
'name' => 'Font Face',
'locked' => true
],
[
'name' => 'MeshHiddenSurfaceRemoval',
'locked' => true
]
];
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
foreach($this->assetTypes as $typeId => $assetType) {
AssetType::create($assetType != null ? array_merge(['id' => $typeId], $assetType) : ['name'=>null]);
}
}
}

View File

@ -16,6 +16,7 @@ class DatabaseSeeder extends Seeder
{
$this->call([
WebConfigurationSeeder::class,
AssetTypeSeeder::class,
UsageCounterSeeder::class,
RolesetSeeder::class
//FFlagSeeder::class

View File

@ -53,5 +53,35 @@ class WebConfigurationSeeder extends Seeder
'name' => 'ThumbnailArbiterIP',
'value' => '127.0.0.1'
]);
DynamicWebConfiguration::create([
'name' => 'ClientUploadVersion',
'value' => 'version-unknown'
]);
DynamicWebConfiguration::create([
'name' => 'ClientDeployVersion',
'value' => '0.0.0.0'
]);
DynamicWebConfiguration::create([
'name' => 'ClientLauncherDeployVersion',
'value' => '0, 0, 0, 0'
]);
DynamicWebConfiguration::create([
'name' => 'StudioUploadVersion',
'value' => 'version-unknown'
]);
DynamicWebConfiguration::create([
'name' => 'StudioDeployVersion',
'value' => '0.0.0.0'
]);
DynamicWebConfiguration::create([
'name' => 'StudioLauncherDeployVersion',
'value' => '0, 0, 0, 0'
]);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -10,8 +10,6 @@ import Twemoji from 'react-twemoji';
import { buildGenericApiUrl } from '../util/HTTP.js';
import Loader from './Loader';
const commentsId = 'gt-comments'; // XlXi: Keep this in sync with the Item page.
axios.defaults.withCredentials = true;
class Comments extends Component {

View File

@ -0,0 +1,635 @@
/*
Graphictoria 5 (https://gtoria.net)
Copyright © XlXi 2022
*/
import { Component, createRef } from 'react';
import classNames from 'classnames/bind';
import axios from 'axios';
import { buildGenericApiUrl } from '../util/HTTP.js';
import Loader from './Loader';
axios.defaults.withCredentials = true;
class DeploymentUploadModal extends Component {
constructor(props) {
super(props);
this.state = {
deployments: []
};
this.updateInterval = null;
this.ModalRef = createRef();
this.Modal = null;
}
componentDidMount() {
this.Modal = new Bootstrap.Modal(
this.ModalRef.current,
{
backdrop: 'static',
keyboard: false
}
);
this.Modal.show();
this.ModalRef.current.addEventListener('hidden.bs.modal', (event) => {
this.props.setModal(null);
})
this.setState({ deployments: this.props.deployments });
this.updateInterval = setInterval(function() {
let deployments = this.state.deployments;
deployments.map((component, index) => {
axios.get(buildGenericApiUrl('api', `admin/v1/deploy?version=${ component.version }`))
.then(res => {
deployments[index] = { ...component, ...res.data };
// XlXi: -_-
//deployment['status'] = res.data['status'];
//deployment['message'] = res.data['message'];
//deployment['progress'] = res.data['progress'];
});
});
this.setState({ deployments: deployments });
}.bind(this), 2000);
}
componentWillUnmount() {
this.Modal.dispose();
clearInterval(this.updateInterval);
}
render() {
return (
<div ref={this.ModalRef} className="modal fade">
<div className="modal-dialog modal-dialog-centered">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Deployment Status</h5>
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div className="modal-body">
{
this.state.deployments.map((deployment, index) => (
<>
<h5 className="mb-0">Deploying { capitalizeFirstLetter(deployment.key) } { deployment.version }</h5>
<p className="text-muted mb-2">{ deployment.message }</p>
<div className="progress">
<div
className={classNames({
'progress-bar': true,
'progress-bar-striped': true,
'progress-bar-animated': true,
'bg-primary': ( deployment.status == 'Loading' ),
'bg-success': ( deployment.status == 'Success' ),
'bg-danger': ( deployment.status == 'Error' )
})}
style={ {width: `${deployment.progress * 100}%`} }
></div>
</div>
{ index != this.state.deployments.length-1 ? <hr /> : null }
</>
))
}
</div>
</div>
</div>
</div>
);
}
}
class DeploymentCard extends Component {
constructor(props) {
super(props);
}
render() {
return (
<div className="card mb-2">
<div className="card-header d-flex">
<span>{ this.props.name }</span>
<button className="ms-auto btn-close" onClick={ ()=>this.props.removeDeployment(this.props.index) }></button>
</div>
<div className="card-body">
{ this.props.children }
</div>
</div>
);
}
}
class RevertDeploymentCard extends Component {
constructor(props) {
super(props);
this.state = {
loading: true,
deployments: []
};
}
componentDidMount() {
let deployType = 'Unknown';
switch(this.props.index)
{
case 'client':
deployType = 'WindowsPlayer'
break;
case 'studio':
deployType = 'Studio'
break;
}
let deployHistoryRegex = new RegExp(`New ${deployType} version-[a-zA-Z0-9]{16} at \\d{1,2}\\/\\d{1,2}\\/\\d{4} \\d{1,2}:\\d{1,2}:\\d{1,2} (AM|PM), file version: (\\d+(, )?){4}\\.{3}Done!`, 'g');
axios.get(buildGenericApiUrl('setup', 'DeployHistory.txt'))
.then(res => {
let deployments = res.data.split(/\r?\n/).reverse();
deployments = deployments.filter((deployment) => deployHistoryRegex.test(deployment)).slice(0, 30);
let realDeployments = [];
deployments.map((deployment) => {
let newDeployment = {
type: deployType,
hash: deployment.match(/version-[a-zA-Z0-9]{16}/)[0],
version: deployment.match(/file version: (\d+(, )?){4}/)[0].replace('file version: ', ''),
date: deployment.match(/at \d{1,2}\/\d{1,2}\/\d{4} \d{1,2}:\d{1,2}:\d{1,2} (AM|PM)/)[0].replace('at ', '')
};
realDeployments.push(newDeployment);
});
this.setState({ loading: false, deployments: realDeployments });
});
}
render() {
return (
<DeploymentCard name={ this.props.name } index={ this.props.index } removeDeployment={ this.props.removeDeployment }>
<h5 className="mb-0">Revert Deployment</h5>
<p className="text-muted">Select a previous deployment below to roll back the { this.props.index } version.</p>
<select className="form-select mt-2" id="gt-revert-deployment" disabled={ this.state.loading }>
<option selected>{ this.state.loading ? 'Loading...' : 'None Selected' }</option>
{
this.state.deployments.map((deployment, index) => (
<option value={ deployment.hash } key={ index }>[{ deployment.type } { deployment.version }] [{ deployment.date }] { deployment.hash }</option>
))
}
</select>
</DeploymentCard>
);
}
}
class PushDeploymentCard extends Component {
constructor(props) {
super(props);
this.state = {
drag: false,
showRequiredFiles: false,
files: [],
options: []
};
this.dragDropBox = createRef();
this.dragCounter = 0;
this.neededFiles = [
'content-fonts.zip',
'content-music.zip',
'content-particles.zip',
'content-sky.zip',
'content-sounds.zip',
'content-terrain.zip',
'content-textures.zip',
'content-textures2.zip',
'content-textures3.zip',
'shaders.zip',
'redist.zip',
'Libraries.zip'
];
this.handleDrag = this.handleDrag.bind(this);
this.handleDragIn = this.handleDragIn.bind(this);
this.handleDragOut = this.handleDragOut.bind(this);
this.handleDrop = this.handleDrop.bind(this);
this.handleDropUi = this.handleDropUi.bind(this);
this.fileExists = this.fileExists.bind(this);
this.removeFile = this.removeFile.bind(this);
this.setOptions = this.setOptions.bind(this);
}
componentDidMount() {
switch(this.props.index)
{
case 'client':
this.neededFiles = this.neededFiles.concat([
'PlayerPdb.zip',
'Graphictoria.zip',
'GraphictoriaPlayerLauncher.exe'
]);
break;
case 'studio':
this.neededFiles = this.neededFiles.concat([
'BuiltInPlugins.zip',
'imageformats.zip',
'content-scripts.zip',
'StudioPdb.zip',
'GraphictoriaStudio.zip',
'GraphictoriaStudioLauncherBeta.exe'
]);
break;
}
this.setState({ showRequiredFiles: true });
let ddb = this.dragDropBox.current
ddb.addEventListener('dragenter', this.handleDragIn)
ddb.addEventListener('dragleave', this.handleDragOut)
ddb.addEventListener('dragover', this.handleDrag)
ddb.addEventListener('drop', this.handleDrop)
}
componentWillUnmount() {
let ddb = this.dragDropBox.current
ddb.removeEventListener('dragenter', this.handleDragIn)
ddb.removeEventListener('dragleave', this.handleDragOut)
ddb.removeEventListener('dragover', this.handleDrag)
ddb.removeEventListener('drop', this.handleDrop)
}
handleDrag(evt) {
evt.preventDefault();
evt.stopPropagation();
}
handleDragIn(evt) {
evt.preventDefault();
evt.stopPropagation();
this.dragCounter++;
if (evt.dataTransfer.items && evt.dataTransfer.items.length > 0) {
this.setState({drag: true});
}
}
handleDragOut(evt) {
evt.preventDefault();
evt.stopPropagation();
this.dragCounter--;
if (this.dragCounter === 0) {
this.setState({drag: false});
}
}
handleDrop(evt) {
evt.preventDefault();
evt.stopPropagation();
this.setState({drag: false});
if (evt.dataTransfer.files && evt.dataTransfer.files.length > 0) {
this.handleDropUi(evt.dataTransfer.files);
evt.dataTransfer.clearData();
this.dragCounter = 0;
}
}
handleDropUi(files) {
let fileList = this.state.files;
for (var i = 0; i < files.length; i++) {
let file = files[i];
if (!file)
continue;
if(this.fileExists(file.name))
continue;
fileList.push(file);
}
this.setState({files: fileList});
this.props.updateDeploymentFiles(fileList);
}
fileExists(fileName) {
return this.state.files.some(file => file.name.toLowerCase() === fileName.toLowerCase());
}
removeFile(fileName) {
this.setState(prevState => ({
files: prevState.files.filter((file) => file.name.toLowerCase() !== fileName.toLowerCase())
}));
this.props.updateDeploymentFiles(this.state.files);
}
setOptions(key, value) {
let options = this.state.options;
if(value != '')
options[key] = value;
else
delete options[key];
this.setState({ options: options });
this.props.updateDeploymentOptions(options)
}
render() {
return (
<DeploymentCard name={ this.props.name } index={ this.props.index } removeDeployment={ this.props.removeDeployment }>
<h5 className="mb-0">Deployment Files</h5>
<p className="text-muted">Drag-and-Drop the necessary files into the box below. Any unneeded files will be discarded when uploading.</p>
<div className="card bg-secondary mt-3 p-3" ref={ this.dragDropBox }>
<div>
{/* XlXi: Reusing game cards here because they were already exactly what I wanted. */}
{
this.state.files.length == 0
?
<p className="text-muted">Drop files here.</p>
:
this.state.files.map((file, index) => {
let fileType = /(?:\.([^.]+))?$/.exec(file.name)[1].toLowerCase();
let fileIconClasses = {
'm-auto': true,
'fs-1': true,
'fa-regular': true
};
switch(fileType)
{
case 'exe':
fileIconClasses['fa-browser'] = true;
break;
case 'zip':
fileIconClasses['fa-file-zipper'] = true;
break;
default:
fileIconClasses['fa-file'] = true;
break;
}
return (
<div className="graphictoria-item-card graphictoria-game-card">
<div className="card m-2" data-bs-toggle="tooltip" data-bs-placement="top" title={ file.name }>
<div className="bg-light d-flex p-3">
<i className={classNames(fileIconClasses)}></i>
</div>
<div className="p-2">
<p className="text-truncate">{ file.name }</p>
<button className="btn btn-sm btn-danger mt-1 w-100" onClick={ ()=>this.removeFile(file.name) }>Remove</button>
</div>
</div>
</div>
);
})
}
</div>
{
this.state.showRequiredFiles
?
<>
<hr />
<h5>Needed Files</h5>
<div className="small">
{
this.neededFiles.map((fileName) => {
let fileExists = this.fileExists(fileName);
return (
<p className={classNames({
'text-success': fileExists,
'text-danger': !fileExists
})}>{ fileName }</p>
);
})
}
</div>
</>
:
null
}
</div>
{
this.props.index == 'client'
?
<>
<h5 className="mb-0 mt-3">Optional Configuration</h5>
<p className="text-muted mb-3">Only change if you've updated the security settings on the client/rcc. Shutting down game servers will delay deployment by 10 minutes.</p>
<div className="form-check form-switch">
<input className="form-check-input" type="checkbox" role="switch" id="gt-shut-down-servers" />
<label className="form-check-label" htmlFor="gt-shut-down-servers">Shut down game servers.</label>
</div>
<label htmlFor="gt-rcc-security-key" className="form-label mt-2">Update RCC Security Key</label>
<input type="text" id="gt-rcc-security-key" className="form-control" placeholder="New RCC Security Key" onChange={ changeEvent => this.setOptions('rccAccessKey', changeEvent.target.value) } />
<label htmlFor="gt-rcc-security-key" className="form-label mt-2">Update Version Compatibility Salt</label>
<input type="text" id="gt-rcc-security-key" className="form-control" placeholder="New Version Compatibility Salt" onChange={ changeEvent => this.setOptions('versionCompatiblityFuzzyKey', changeEvent.target.value) } />
</>
:
null
}
</DeploymentCard>
);
}
}
// https://stackoverflow.com/questions/1026069/how-do-i-make-the-first-letter-of-a-string-uppercase-in-javascript
function capitalizeFirstLetter(string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
class Deployer extends Component {
constructor(props) {
super(props);
this.state = {
showModal: false,
deployType: 'deploy',
showTypeSwitchError: false,
deploying: false,
deployments: []
};
this.visibleModal = null;
this.updateDeploymentFiles = this.updateDeploymentFiles.bind(this);
this.deploymentExists = this.deploymentExists.bind(this);
this.getDeployment = this.getDeployment.bind(this);
this.createDeployment = this.createDeployment.bind(this);
this.removeDeployment = this.removeDeployment.bind(this);
this.trySetDeployType = this.trySetDeployType.bind(this);
this.uploadDeploymentFiles = this.uploadDeploymentFiles.bind(this);
this.uploadDeployments = this.uploadDeployments.bind(this);
this.setModal = this.setModal.bind(this);
}
updateDeploymentFiles(key, files) {
if(this.state.deploying) // XlXi: Prevent messing with the component when we're deploying.
return;
let deployment = this.getDeployment(key);
deployment['files'] = files;
}
updateDeploymentOptions(key, options) {
if(this.state.deploying) // XlXi: Prevent messing with the component when we're deploying.
return;
let deployment = this.getDeployment(key);
deployment['extraOptions'] = options;
}
deploymentExists(key) {
return this.state.deployments.some(deployment => deployment.key === key);
}
getDeployment(key) {
return this.state.deployments.find((deployment) => deployment.key === key);
}
createDeployment(key) {
if(this.state.deploying) // XlXi: Prevent messing with the component when we're deploying.
return;
let newElement = {
key: key,
name: capitalizeFirstLetter(this.state.deployType) + ' ' + capitalizeFirstLetter(key),
files: [],
extraOptions: []
};
this.setState(prevState => ({
deployments: [...prevState.deployments, newElement]
}));
}
removeDeployment(key) {
if(this.state.deploying) // XlXi: Prevent messing with the component when we're deploying.
return;
this.setState(prevState => ({
deployments: prevState.deployments.filter((deployment) => deployment.key !== key)
}));
}
trySetDeployType(evt, type) {
if(this.state.deploying) // XlXi: Prevent messing with the component when we're deploying.
return;
if(!evt.target.checked)
return;
if(this.state.deployType != type && this.state.deployments.length != 0)
{
this.setState({ showTypeSwitchError: true });
return;
}
this.setState({ deployType: type, showTypeSwitchError: false });
}
uploadDeploymentFiles(key, version) {
let deployment = this.getDeployment(key);
let bodyFormData = new FormData();
deployment.files.map((file) => {
bodyFormData.append('file[]', file);
});
Object.keys(deployment.extraOptions).forEach(function(key, index) {
bodyFormData.append(key, deployment.extraOptions[key]);
});
axios.post(
buildGenericApiUrl('api', `admin/v1/deploy/${ deployment.version }`),
bodyFormData
);
}
uploadDeployments() {
if(this.state.deploying) // XlXi: Prevent multiple deployments of the same files.
return;
this.setState({ deploying: true });
let requests = 0;
this.state.deployments.map((component, index) => {
axios.get(buildGenericApiUrl('api', `admin/v1/deploy?type=deploy&app=${ component.key }`))
.then(res => {
requests++;
this.state.deployments[index] = {...component, ...res.data};
if(requests == this.state.deployments.length)
this.setModal(<DeploymentUploadModal setModal={ this.setModal } deployments={ this.state.deployments } />);
this.uploadDeploymentFiles(component.key, component.version);
});
});
//this.setState({ deployments: [] });
}
setModal(modal = null) {
this.visibleModal = modal;
if(modal) {
this.setState({'showModal': true});
} else {
this.setState({'showModal': false});
}
}
render() {
return (
<>
<h5>Deployment Options</h5>
<div className="d-block">
<div className="btn-group mb-1">
<input type="radio" className="btn-check" name="gt-deployment-type" id="gt-deployment-deploy" autoComplete="off" onChange={ (e)=>this.trySetDeployType(e, 'deploy') } checked={ this.state.deployType == 'deploy' } />
<label className="btn btn-sm btn-outline-primary" htmlFor="gt-deployment-deploy">Deploy</label>
<input type="radio" className="btn-check" name="gt-deployment-type" id="gt-deployment-revert" autoComplete="off" onChange={ (e)=>this.trySetDeployType(e, 'revert') } checked={ this.state.deployType == 'revert' } />
<label className="btn btn-sm btn-outline-primary" htmlFor="gt-deployment-revert">Revert</label>
</div>
<br />
<button className="btn btn-sm btn-success" onClick={ ()=>this.createDeployment('client') } disabled={ this.deploymentExists('client') }>Deploy Client</button>
<button className="btn btn-sm btn-primary" onClick={ ()=>this.createDeployment('studio') } disabled={ this.deploymentExists('studio') }>Deploy Studio</button>
</div>
<hr />
{
this.state.showTypeSwitchError ?
<div className="alert alert-danger graphictoria-alert graphictoria-error-popup">Remove your { this.state.deployType == 'deploy' ? 'deployments' : 'reversions' } to change the uploader type.</div>
:
null
}
<h5>{ this.state.deployType == 'deploy' ? 'Staged Deployments' : 'Revert Deployments' }</h5>
{
this.state.deployments.length == 0 ?
<p className="text-muted">No deployments are selected.</p>
:
this.state.deployments.map((component) => {
// XlXi: surely theres a better way to do this.....
switch(this.state.deployType)
{
case 'deploy':
return <PushDeploymentCard name={ component.name } index={ component.key } key={ component.key } removeDeployment={ this.removeDeployment } updateDeploymentFiles={ (files)=>this.updateDeploymentFiles(component.key, files) } updateDeploymentOptions={ (options)=>this.updateDeploymentOptions(component.key, options) } />
case 'revert':
return <RevertDeploymentCard name={ component.name } index={ component.key } key={ component.key } removeDeployment={ this.removeDeployment } />
}
})
}
<hr />
<button className="btn btn-primary" onClick={ this.uploadDeployments } disabled={ this.state.deploying || this.state.deployments.length == 0 }>{ this.state.deployType == 'deploy' ? 'Deploy' : 'Revert Deployment' }</button>
{ this.state.showModal ? this.visibleModal : null }
</>
);
}
}
export default Deployer;

View File

@ -34,21 +34,21 @@ class GameItemCard extends Component {
render() {
return (
<a
className="graphictoria-item-card"
href="#"
className="graphictoria-item-card graphictoria-game-card"
href={ this.props.item.Url }
onMouseEnter={() => this.setState({hovered: true})}
onMouseLeave={() => this.setState({hovered: false})}
>
<span className="card m-2">
<ProgressiveImage
src={ buildGenericApiUrl('www', 'images/busy/game.png') }
placeholderImg={ buildGenericApiUrl('www', 'images/busy/game.png') }
alt="Game todo"
src={ buildGenericApiUrl('www', 'images/busy/game-icon.png') }
placeholderImg={ buildGenericApiUrl('www', 'images/busy/game-icon.png') }
alt={ this.props.item.Name }
className='img-fluid'
/>
<div className="p-2">
<p>Todo</p>
<p className="text-muted small">{commaSeparate(1337)} Playing</p>
<p>{ this.props.item.Name }</p>
<p className="text-muted small">{commaSeparate(this.props.item.Playing)} Playing</p>
<div className="d-flex mt-1">
<i className={classNames({
'fa-solid': true,
@ -73,7 +73,7 @@ class GameItemCard extends Component {
'bg-dark': !this.state.hovered,
'bg-success': this.state.hovered,
})}
style={{width: '80%', height: '8px'}}></div>
style={{width: (this.props.item.Ratio * 100) + '%', height: '8px'}}></div>
</div>
<i className={classNames({
'fa-solid': true,
@ -89,7 +89,7 @@ class GameItemCard extends Component {
<div className="card px-2">
<hr className="m-0" />
<p className="text-truncate my-1">
<span className="text-muted">By </span><a href="#" className="text-decoration-none fw-normal">Todo</a>
<span className="text-muted">By </span><a href={ this.props.item.Creator.Url } className="text-decoration-none fw-normal">{ this.props.item.Creator.Name }</a>
</p>
</div>
</span>
@ -104,14 +104,96 @@ class GameItemCard extends Component {
class Games extends Component {
constructor(props) {
super(props);
this.state = {
pageItems: [],
pageLoaded: true,
pageNumber: null,
pageCount: null,
error: false
};
this.navigate = this.navigate.bind(this);
}
componentDidMount() {
this.navigate();
}
navigate(data = {}) {
this.setState({pageLoaded: false});
let url = buildGenericApiUrl('api', 'games/v1/list-json');
let paramIterator = 0;
Object.keys(data).map(key => {
url += ((paramIterator++ == 0 ? '?' : '&') + `${key}=${data[key]}`);
});
axios.get(url)
.then(res => {
const items = res.data;
this.setState({ pageItems: items.data, pageCount: items.pages, pageNumber: 1, pageLoaded: true, error: false });
}).catch(err => {
const data = err.response.data;
let errorMessage = 'An error occurred while processing your request.';
if(data.errors)
errorMessage = data.errors[0].message;
this.setState({ pageItems: [], pageCount: 1, pageNumber: 1, pageLoaded: true, error: errorMessage });
});
}
render() {
return (
<div className="container-lg my-2 d-flex flex-column">
<div className="container-sm my-2 d-flex flex-column">
<h4 className="my-auto">Games</h4>
<Loader />
<GameItemCard />
<div className="card p-3 mt-2">
{
this.state.error ?
<div className="alert alert-danger p-2 mb-0 text-center">{this.state.error}</div>
:
null
}
{
!this.state.pageLoaded ?
<div className="graphictoria-shop-overlay">
<Loader />
</div>
:
null
}
{
(this.state.pageItems.length == 0 && !this.state.error) ?
<p className="text-muted text-center">Nothing found.</p>
:
<div>
{
this.state.pageItems.map((item, index) =>
<GameItemCard item={ item } key={ index } />
)
}
</div>
}
{
this.state.pageCount > 1 ?
<ul className="list-inline mx-auto mt-3">
<li className="list-inline-item">
<button className="btn btn-secondary" disabled={(this.state.pageNumber <= 1) ? true : null}><i className="fa-solid fa-angle-left"></i></button>
</li>
<li className="list-inline-item graphictoria-paginator">
<span>Page&nbsp;</span>
<input type="text" value={ this.state.pageNumber || '' } className="form-control" disabled={this.state.pageLoaded ? null : true} />
<span>&nbsp;of { this.state.pageCount || '???' }</span>
</li>
<li className="list-inline-item">
<button className="btn btn-secondary" disabled={(this.state.pageNumber >= this.state.pageCount) ? true : null}><i className="fa-solid fa-angle-right"></i></button>
</li>
</ul>
:
null
}
</div>
</div>
);
}

View File

@ -0,0 +1,199 @@
/*
Graphictoria 5 (https://gtoria.net)
Copyright © XlXi 2022
*/
import { Component, createRef } from 'react';
import axios from 'axios';
import Twemoji from 'react-twemoji';
import classNames from 'classnames/bind';
import { buildGenericApiUrl, getCurrentDomain } from '../util/HTTP.js';
import Loader from './Loader';
axios.defaults.withCredentials = true;
const playerProtocol = 'graphictoria-player';
class PlaceLoadingModal extends Component {
constructor(props) {
super(props);
this.state = {
showDownloadScreen: false
};
this.ModalRef = createRef();
this.Modal = null;
}
componentDidMount() {
this.Modal = new Bootstrap.Modal(this.ModalRef.current);
this.Modal.show();
this.ModalRef.current.addEventListener('hidden.bs.modal', (event) => {
this.props.setModal(null);
});
setTimeout(function(){
this.setState({showDownloadScreen: true});
}.bind(this), 10000)
}
componentWillUnmount() {
this.Modal.dispose();
}
render() {
return (
<div ref={this.ModalRef} className="modal fade">
<div className="modal-dialog modal-dialog-centered">
<div className={classNames({
'modal-content': true,
'text-center': true,
'mx-5': (!this.state.showDownloadScreen)
})}>
<div className={classNames({
'modal-body': true,
'd-flex': true,
'flex-column': true,
'pb-5': (!this.state.showDownloadScreen)
})}>
<button type="button" className="ms-auto btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
{
this.state.showDownloadScreen ?
<>
<h5>Download Graphictoria</h5>
<p>Download Graphictoria to get access to thousands of community-driven games.</p>
<a href={ buildGenericApiUrl('setup', 'GraphictoriaPlayerLauncher.exe') } target="_blank" className="btn btn-success mt-3">Download</a>
</>
:
<>
<Loader />
<p>Starting Graphictoria</p>
</>
}
</div>
</div>
</div>
</div>
);
}
}
class PlaceLoadingErrorModal extends Component {
constructor(props) {
super(props);
this.state = {
};
this.ModalRef = createRef();
this.Modal = null;
}
componentDidMount() {
this.Modal = new Bootstrap.Modal(this.ModalRef.current);
this.Modal.show();
this.ModalRef.current.addEventListener('hidden.bs.modal', (event) => {
this.props.setModal(null);
});
}
componentWillUnmount() {
this.Modal.dispose();
}
render() {
return (
<div ref={this.ModalRef} className="modal fade">
<div className="modal-dialog modal-dialog-centered">
<div className="modal-content text-center">
<div className="modal-body d-flex flex-column pb-4">
<button type="button" className="ms-auto btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
<h5>An error occurred while starting Graphictoria.</h5>
<p>Error Detail: { this.props.message }</p>
</div>
</div>
</div>
</div>
);
}
}
class PlaceButtons extends Component {
constructor(props) {
super(props);
this.state = {
playDebounce: false,
showModal: false
};
this.playGame = this.playGame.bind(this);
this.setModal = this.setModal.bind(this);
}
componentDidMount() {
let placeElement = this.props.element;
if (placeElement) {
this.placeId = parseInt(placeElement.getAttribute('data-place-id'))
}
}
setModal(modal = null) {
this.visibleModal = modal;
if(modal) {
this.setState({'showModal': true});
} else {
this.setState({'showModal': false});
}
}
playGame() {
this.setState({ playDebounce: true });
setTimeout(function(){
this.setState({ playDebounce: false });
}.bind(this), 1000);
this.setModal(<PlaceLoadingModal setModal={ this.setModal } />);
let protocol = playerProtocol;
let domainSplit = getCurrentDomain().split('.');
if(getCurrentDomain() == 'gtoria.local')
{
protocol += '-dev';
}
else if(domainSplit.length > 2)
{
protocol += '-' + domainSplit.at(-3); // XlXi: Third to last
}
axios.get(buildGenericApiUrl('api', `auth/v1/generate-token`))
.then(res => {
window.location = protocol
+ ':1'
+ '+launchmode:play'
+ '+gameinfo:' + res.data
+ '+placelauncherurl:' + encodeURIComponent(buildGenericApiUrl('www', `Game/PlaceLauncher?request=RequestGame&placeId=&${this.placeId}&isPlayTogetherGame=false`));
})
.catch(function(error) {
this.setModal(<PlaceLoadingErrorModal setModal={ this.setModal } message={ error.message } />);
//alert('Error while starting Graphictoria: ' + error.message);
}.bind(this));
}
render() {
return (
<>
<button className="btn btn-lg btn-success fs-3" onClick={ this.playGame } disabled={ this.state.playDebounce }>
<i className="fa-solid fa-play"></i>
</button>
{ this.state.showModal ? this.visibleModal : null }
</>
);
}
}
export default PlaceButtons;

View File

@ -3,7 +3,7 @@
Copyright © XlXi 2022
*/
import { Component, createRef } from 'react';
import { Component } from 'react';
import classNames from 'classnames/bind';

View File

@ -119,7 +119,7 @@ class ThumbnailTool extends Component {
this.setState({ initialLoading: false });
if(localStorage.getItem('gt-use-3d-thumbnails') === 'true')
if(this.renderable3d && localStorage.getItem('gt-use-3d-thumbnails') === 'true')
this.toggle3D();
}
}
@ -207,9 +207,11 @@ class ThumbnailTool extends Component {
:
<ProgressiveImage
src={ this.thumbnail2d }
placeholderImg={ buildGenericApiUrl('www', 'images/busy/asset.png') }
placeholderImg={ buildGenericApiUrl('www', (this.props.placeholder == null ? 'images/busy/asset.png' : this.props.placeholder)) }
alt={ this.assetName }
className='img-fluid'
width={ this.props.width }
height={ this.props.height }
/>
)
}

View File

@ -0,0 +1,19 @@
/*
Graphictoria 5 (https://gtoria.net)
Copyright © XlXi 2022
*/
import $ from 'jquery';
import React from 'react';
import { render } from 'react-dom';
import Deployer from '../components/Deployer';
const deployerId = 'gt-deployer';
$(document).ready(function() {
if (document.getElementById(deployerId)) {
render(<Deployer />, document.getElementById(deployerId));
}
});

View File

@ -0,0 +1,32 @@
/*
Graphictoria 5 (https://gtoria.net)
Copyright © XlXi 2022
*/
import $ from 'jquery';
import React from 'react';
import { render } from 'react-dom';
import Comments from '../components/Comments';
import ThumbnailTool from '../components/ThumbnailTool';
import PlaceButtons from '../components/PlaceButtons';
const thumbnailId = 'gt-thumbnail';
const buttonsId = 'gt-place-buttons';
const commentsId = 'gt-comments';
$(document).ready(function() {
if (document.getElementById(thumbnailId)) {
let tElem = document.getElementById(thumbnailId);
render(<ThumbnailTool element={ tElem } placeholder="/images/busy/game.png" width="640" height="360" />, tElem);
}
if (document.getElementById(buttonsId)) {
let bElem = document.getElementById(buttonsId);
render(<PlaceButtons element={ bElem } />, bElem);
}
if (document.getElementById(commentsId)) {
let cElem = document.getElementById(commentsId);
render(<Comments element={ cElem } />, cElem);
}
});

View File

@ -9,8 +9,12 @@
@import "Variables";
@import "~bootstrap/scss/bootstrap";
@import "./scss/fontawesome.scss";
@import "./scss/solid.scss";
@import "./scss/brands.scss";
@import "./scss/duotone.scss";
@import "./scss/light.scss";
@import "./scss/regular.scss";
@import "./scss/solid.scss";
@import "./scss/thin.scss";
// Variables
@ -73,6 +77,11 @@ img.twemoji {
height: 420px;
}
.graphictoria-game-thumbnail {
width: 640px;
height: 360px;
}
.graphictoria-smaller-page {
max-width: 1096px;
margin: 0 auto 0 auto;
@ -140,6 +149,28 @@ img.twemoji {
background-size: cover;
}
.graphictoria-game-card {
@media (max-width: 576px) {
width: math.div(100%, 2)
}
@media (min-width: 576px) {
width: math.div(100%, 3)
}
@media (min-width: 768px) {
width: math.div(100%, 4)
}
@media (min-width: 992px) {
width: math.div(100%, 5)
}
@media (min-width: 1200px) {
width: 176px;
}
}
.graphictoria-shop-overlay {
position: absolute;
width: 100%;
@ -750,6 +781,75 @@ html {
}
}
.btn-favorite {
--bs-text-opacity: 1;
transition: .1s;
background: transparent;
border: 0;
html.gtoria-light & {
color: rgba(var(--bs-black-rgb),var(--bs-text-opacity));
}
html.gtoria-dark & {
color: rgba(var(--bs-light-rgb),var(--bs-text-opacity));
}
&:hover {
color: #e59800!important;
}
&.selected {
color: #e59800!important;
}
}
.btn-upvote {
--bs-text-opacity: 1;
transition: .1s;
background: transparent;
border: 0;
html.gtoria-light & {
color: rgba(var(--bs-black-rgb),var(--bs-text-opacity));
}
html.gtoria-dark & {
color: rgba(var(--bs-light-rgb),var(--bs-text-opacity));
}
&:hover {
color: rgba(var(--bs-success-rgb),var(--bs-text-opacity))!important;
}
&.selected {
color: rgba(var(--bs-success-rgb),var(--bs-text-opacity))!important;
}
}
.btn-downvote {
--bs-text-opacity: 1;
transition: .1s;
background: transparent;
border: 0;
html.gtoria-light & {
color: rgba(var(--bs-black-rgb),var(--bs-text-opacity));
}
html.gtoria-dark & {
color: rgba(var(--bs-light-rgb),var(--bs-text-opacity));
}
&:hover {
color: rgba(var(--bs-danger-rgb),var(--bs-text-opacity))!important;
}
&.selected {
color: rgba(var(--bs-danger-rgb),var(--bs-text-opacity))!important;
}
}
// Typography
.text-secondary {

View File

@ -8,17 +8,17 @@
.#{$fa-css-prefix},
.fas,
.fa-solid,
.#{$fa-css-prefix}-solid,
.far,
.fa-regular,
.#{$fa-css-prefix}-regular,
.fal,
.fa-light,
.#{$fa-css-prefix}-light,
.fat,
.fa-thin,
.#{$fa-css-prefix}-thin,
.fad,
.fa-duotone,
.#{$fa-css-prefix}-duotone,
.fab,
.fa-brands {
.#{$fa-css-prefix}-brands {
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
display: var(--#{$fa-css-prefix}-display, #{$fa-display});

View File

@ -0,0 +1,11 @@
// specific duotone icon class definition
// -------------------------
/* Font Awesome uses the Unicode Private Use Area (PUA) to ensure screen
readers do not read off random characters that represent icons */
@each $name, $icon in $fa-icons {
.fad.#{$fa-css-prefix}-#{$name}::after, .#{$fa-css-prefix}-duotone.#{$fa-css-prefix}-#{$name}::after {
content: unquote("\"#{ $icon }#{ $icon }\"");
}
}

View File

@ -1,7 +1,12 @@
// functions
// --------------------------
// Originally obtained from the Bootstrap https://github.com/twbs/bootstrap
// fa-content: convenience function used to set content property
@function fa-content($fa-var) {
@return unquote("\"#{ $fa-var }\"");
}
// fa-divide: Originally obtained from the Bootstrap https://github.com/twbs/bootstrap
//
// Licensed under: The MIT License (MIT)
//
@ -25,6 +30,7 @@
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
@function fa-divide($dividend, $divisor, $precision: 10) {
$sign: if($dividend > 0 and $divisor > 0, 1, -1);
$dividend: abs($dividend);

View File

@ -5,5 +5,5 @@
readers do not read off random characters that represent icons */
@each $name, $icon in $fa-icons {
.#{$fa-css-prefix}-#{$name}::before { content: fa-content($icon); }
.#{$fa-css-prefix}-#{$name}::before { content: unquote("\"#{ $icon }\""); }
}

View File

@ -0,0 +1,23 @@
// Icon Sizes
// -------------------------
// makes the font 33% larger relative to the icon container
.#{$fa-css-prefix}-lg {
font-size: (4em / 3);
line-height: (3em / 4);
vertical-align: -.0667em;
}
.#{$fa-css-prefix}-xs {
font-size: .75em;
}
.#{$fa-css-prefix}-sm {
font-size: .875em;
}
@for $i from 1 through 10 {
.#{$fa-css-prefix}-#{$i}x {
font-size: $i * 1em;
}
}

View File

@ -62,6 +62,36 @@
}
}
@mixin fa-icon-light($fa-var) {
@extend %fa-icon;
@extend .fa-light;
&::before {
content: unquote("\"#{ $fa-var }\"");
}
}
@mixin fa-icon-thin($fa-var) {
@extend %fa-icon;
@extend .fa-thin;
&::before {
content: unquote("\"#{ $fa-var }\"");
}
}
@mixin fa-icon-duotone($fa-var) {
@extend %fa-icon;
@extend .fa-duotone;
&::before {
content: unquote("\"#{ $fa-var }\"");
}
&::after {
content: unquote("\"#{ $fa-var }#{ $fa-var }\"");
}
}
@mixin fa-icon-brands($fa-var) {
@extend %fa-icon;
@extend .fa-brands;

View File

@ -3,12 +3,12 @@
// only display content to screen readers
.sr-only,
.fa-sr-only {
@include fa-sr-only;
.#{$fa-css-prefix}-sr-only {
@include fa-sr-only;
}
// use in conjunction with .sr-only to only display content when it's focused
.sr-only-focusable,
.fa-sr-only-focusable {
@include fa-sr-only-focusable;
.#{$fa-css-prefix}-sr-only-focusable {
@include fa-sr-only-focusable;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +1,13 @@
/*!
* Font Awesome Free 6.0.0 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
* Font Awesome Pro 6.1.2 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license (Commercial License)
* Copyright 2022 Fonticons, Inc.
*/
@import 'functions';
@import 'variables';
:root, :host {
--#{ $fa-css-prefix }-font-brands: normal 400 1em/1 "Font Awesome 6 Brands";
--#{$fa-css-prefix}-font-brands: normal 400 1em/1 "Font Awesome 6 Brands";
}
@font-face {
@ -20,11 +20,11 @@
}
.fab,
.fa-brands {
.#{$fa-css-prefix}-brands {
font-family: 'Font Awesome 6 Brands';
font-weight: 400;
}
@each $name, $icon in $fa-brand-icons {
.#{$fa-css-prefix}-#{$name}:before { content: fa-content($icon); }
.#{$fa-css-prefix}-#{$name}:before { content: unquote("\"#{ $icon }\""); }
}

View File

@ -0,0 +1,65 @@
/*!
* Font Awesome Pro 6.1.2 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license (Commercial License)
* Copyright 2022 Fonticons, Inc.
*/
@import 'functions';
@import 'variables';
:root, :host {
--#{$fa-css-prefix}-font-duotone: normal 900 1em/1 "Font Awesome 6 Duotone";
}
@font-face {
font-family: 'Font Awesome 6 Duotone';
font-style: normal;
font-weight: 900;
font-display: $fa-font-display;
src: url('#{$fa-font-path}/fa-duotone-900.woff2') format('woff2'),
url('#{$fa-font-path}/fa-duotone-900.ttf') format('truetype');
}
.fad,
.#{$fa-css-prefix}-duotone {
position: relative;
font-family: 'Font Awesome 6 Duotone';
font-weight: 900;
letter-spacing: normal;
}
.fad::before,
.#{$fa-css-prefix}-duotone::before {
position: absolute;
color: var(--#{$fa-css-prefix}-primary-color, inherit);
opacity: var(--#{$fa-css-prefix}-primary-opacity, #{$fa-primary-opacity});
}
.fad::after,
.#{$fa-css-prefix}-duotone::after {
color: var(--#{$fa-css-prefix}-secondary-color, inherit);
opacity: var(--#{$fa-css-prefix}-secondary-opacity, #{$fa-secondary-opacity});
}
.#{$fa-css-prefix}-swap-opacity .fad::before,
.#{$fa-css-prefix}-swap-opacity .fa-duotone::before,
.fad.#{$fa-css-prefix}-swap-opacity::before,
.fa-duotone.#{$fa-css-prefix}-swap-opacity::before {
opacity: var(--#{$fa-css-prefix}-secondary-opacity, #{$fa-secondary-opacity});
}
.#{$fa-css-prefix}-swap-opacity .fad::after,
.#{$fa-css-prefix}-swap-opacity .fa-duotone::after,
.fad.#{$fa-css-prefix}-swap-opacity::after,
.fa-duotone.#{$fa-css-prefix}-swap-opacity::after {
opacity: var(--#{$fa-css-prefix}-primary-opacity, #{$fa-primary-opacity});
}
.fad.#{$fa-css-prefix}-inverse,
.#{$fa-css-prefix}-duotone.#{$fa-css-prefix}-inverse {
color: var(--#{$fa-css-prefix}-inverse, $fa-inverse);
}
.fad.#{$fa-css-prefix}-stack-1x, .fad.#{$fa-css-prefix}-stack-2x,
.#{$fa-css-prefix}-duotone.#{$fa-css-prefix}-stack-1x, .#{$fa-css-prefix}-duotone.#{$fa-css-prefix}-stack-2x {
position: absolute;
}

View File

@ -1,6 +1,6 @@
/*!
* Font Awesome Free 6.0.0 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
* Font Awesome Pro 6.1.2 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license (Commercial License)
* Copyright 2022 Fonticons, Inc.
*/
// Font Awesome core compile (Web Fonts-based)

View File

@ -0,0 +1,26 @@
/*!
* Font Awesome Pro 6.1.2 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license (Commercial License)
* Copyright 2022 Fonticons, Inc.
*/
@import 'functions';
@import 'variables';
:root, :host {
--#{$fa-css-prefix}-font-light: normal 300 1em/1 "#{ $fa-style-family }";
}
@font-face {
font-family: 'Font Awesome 6 Pro';
font-style: normal;
font-weight: 300;
font-display: $fa-font-display;
src: url('#{$fa-font-path}/fa-light-300.woff2') format('woff2'),
url('#{$fa-font-path}/fa-light-300.ttf') format('truetype');
}
.fal,
.#{$fa-css-prefix}-light {
font-family: 'Font Awesome 6 Pro';
font-weight: 300;
}

View File

@ -1,17 +1,17 @@
/*!
* Font Awesome Free 6.0.0 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
* Font Awesome Pro 6.1.2 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license (Commercial License)
* Copyright 2022 Fonticons, Inc.
*/
@import 'functions';
@import 'variables';
:root, :host {
--#{ $fa-css-prefix }-font-regular: normal 400 1em/1 "#{ $fa-style-family }";
--#{$fa-css-prefix}-font-regular: normal 400 1em/1 "#{ $fa-style-family }";
}
@font-face {
font-family: 'Font Awesome 6 Free';
font-family: 'Font Awesome 6 Pro';
font-style: normal;
font-weight: 400;
font-display: $fa-font-display;
@ -20,7 +20,7 @@
}
.far,
.fa-regular {
font-family: 'Font Awesome 6 Free';
.#{$fa-css-prefix}-regular {
font-family: 'Font Awesome 6 Pro';
font-weight: 400;
}

View File

@ -1,17 +1,17 @@
/*!
* Font Awesome Free 6.0.0 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
* Font Awesome Pro 6.1.2 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license (Commercial License)
* Copyright 2022 Fonticons, Inc.
*/
@import 'functions';
@import 'variables';
:root, :host {
--#{ $fa-css-prefix }-font-solid: normal 900 1em/1 "#{ $fa-style-family }";
--#{$fa-css-prefix}-font-solid: normal 900 1em/1 "#{ $fa-style-family }";
}
@font-face {
font-family: 'Font Awesome 6 Free';
font-family: 'Font Awesome 6 Pro';
font-style: normal;
font-weight: 900;
font-display: $fa-font-display;
@ -20,7 +20,7 @@
}
.fas,
.fa-solid {
font-family: 'Font Awesome 6 Free';
.#{$fa-css-prefix}-solid {
font-family: 'Font Awesome 6 Pro';
font-weight: 900;
}

View File

@ -0,0 +1,26 @@
/*!
* Font Awesome Pro 6.1.2 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license (Commercial License)
* Copyright 2022 Fonticons, Inc.
*/
@import 'functions';
@import 'variables';
:root, :host {
--#{$fa-css-prefix}-font-thin: normal 100 1em/1 "#{ $fa-style-family }";
}
@font-face {
font-family: 'Font Awesome 6 Pro';
font-style: normal;
font-weight: 100;
font-display: $fa-font-display;
src: url('#{$fa-font-path}/fa-thin-100.woff2') format('woff2'),
url('#{$fa-font-path}/fa-thin-100.ttf') format('truetype');
}
.fat,
.#{$fa-css-prefix}-thin {
font-family: 'Font Awesome 6 Pro';
font-weight: 100;
}

View File

@ -1,6 +1,6 @@
/*!
* Font Awesome Free 6.0.0 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
* Font Awesome Pro 6.1.2 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license (Commercial License)
* Copyright 2022 Fonticons, Inc.
*/
// V4 shims compile (Web Fonts-based)

Binary file not shown.

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 730 KiB

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,21 @@
@props([
'stat'
])
<div class="my-auto rounded-1 bg-secondary border border-light right-0 me-1 position-relative graphictoria-admin-usagebar">
@php
$usage_bar_color = 'bg-primary';
$usage_bar_usage = $stat * 100;
if($usage_bar_usage <= 25)
$usage_bar_color = 'bg-success'; // Green
elseif($usage_bar_usage > 25 && $usage_bar_usage <= 75)
$usage_bar_color = 'bg-warning'; // Orange
elseif($usage_bar_usage > 75)
$usage_bar_color = 'bg-danger'; // Red
@endphp
<div
class="{{ $usage_bar_color }} rounded-1 position-absolute graphictoria-admin-usagebar"
style="width:{{ $usage_bar_usage }}%!important;height:8px!important;"
></div>
</div>

View File

@ -0,0 +1,19 @@
@extends('layouts.app')
@section('title', 'Not Implemented')
@section('content')
<div class="container graphictoria-center-vh">
<x-card title="NOT IMPLEMENTED">
<x-slot name="body">
todo
</x-slot>
<x-slot name="footer">
<div class="mt-2">
<a class="btn btn-primary px-4 me-2" href="{{ url('/') }}">Home</a>
<a class="btn btn-secondary px-4" onclick="history.back();return false;">Back</a>
</div>
</x-slot>
</x-card>
</div>
@endsection

View File

@ -48,7 +48,7 @@
color: #eee;
}
.graphictoria-admin-memorybar {
.graphictoria-admin-usagebar {
width: 100px;
height: 10px;
}
@ -62,61 +62,19 @@
<div class="collapse navbar-collapse" id="graphictoria-admin-nav">
<ul class="navbar-nav graphictoria-admin-nav ms-auto">
@yield('quick-admin')
@admin
<li class="nav-item">
<a href="{{ route('admin.diag') }}" class="nav-link py-0">Arbiter Diag</a>
</li>
@endadmin
@owner
<li class="nav-item">
<a href="{{ route('admin.arbitermanagement') }}" class="nav-link py-0">Arbiter Management</a>
</li>
@endowner
<li class="nav-item">
<a href="{{ route('admin.dashboard') }}" class="nav-link py-0"><i class="fa-solid fa-gavel"></i></a>
</li>
@admin
<li class="nav-item" data-bs-toggle="tooltip" data-bs-placement="bottom" data-bs-html="true" title="<strong>Updates every minute</strong><br/>{{ \App\Helpers\QAaMBHelper::getMemoryUsage() }}">
<span class="px-md-2 d-flex" style="height:24px;">
<div class="my-auto rounded-1 bg-secondary border border-light right-0 me-1 position-relative graphictoria-admin-memorybar">
@php
$admin_memorybar_color = 'bg-primary';
$admin_memorybar_usage = \App\Helpers\QAaMBHelper::getMemoryPercentage() * 100;
if($admin_memorybar_usage <= 25)
$admin_memorybar_color = 'bg-success'; // Green
elseif($admin_memorybar_usage > 25 && $admin_memorybar_usage <= 75)
$admin_memorybar_color = 'bg-warning'; // Orange
elseif($admin_memorybar_usage > 75)
$admin_memorybar_color = 'bg-danger'; // Red
@endphp
<div
class="{{ $admin_memorybar_color }} rounded-1 position-absolute graphictoria-admin-memorybar"
style="width:{{ $admin_memorybar_usage }}%!important;height:8px!important;"
></div>
</div>
<x-admin.usage-bar :stat="\App\Helpers\QAaMBHelper::getMemoryPercentage()" />
<i class="my-auto fa-solid fa-gear"></i>
</span>
</li>
<li class="nav-item" data-bs-toggle="tooltip" data-bs-placement="bottom" data-bs-html="true" title="<strong>Updates every minute</strong><br/>{{ \App\Helpers\QAaMBHelper::getCpuUsage() }}">
<span class="px-md-2 d-flex" style="height:24px;">
<div class="my-auto rounded-1 bg-secondary border border-light right-0 me-1 position-relative graphictoria-admin-memorybar">
@php
$admin_cpubar_color = 'bg-primary';
$admin_cpubar_usage = \App\Helpers\QAaMBHelper::getCpuPercentage() * 100;
if($admin_cpubar_usage <= 25)
$admin_cpubar_color = 'bg-success'; // Green
elseif($admin_cpubar_usage > 25 && $admin_cpubar_usage <= 75)
$admin_cpubar_color = 'bg-warning'; // Orange
elseif($admin_cpubar_usage > 75)
$admin_cpubar_color = 'bg-danger'; // Red
@endphp
<div
class="{{ $admin_cpubar_color }} rounded-1 position-absolute graphictoria-admin-memorybar"
style="width:{{ $admin_cpubar_usage }}%!important;height:8px!important;"
></div>
</div>
<x-admin.usage-bar :stat="\App\Helpers\QAaMBHelper::getCpuPercentage()" />
<i class="my-auto fa-solid fa-microchip"></i>
</span>
</li>

View File

@ -4,6 +4,20 @@
@section('page-specific')
<style>
.gt-conf-row > .row {
padding: 0.5rem!important;
margin: 0;
}
.gt-conf-row:nth-of-type(even)>* {
background-color: #0000000d;
}
.gt-conf-row > .row > div:not(:last-child) {
border-right: 1px solid #00000020;
}
/* old stuff */
.gt-small-row {
width: 0;
text-align: center;
@ -19,13 +33,29 @@
</style>
<!-- Secure Page JS -->
<script src="data:text/javascript;base64,{{ base64_encode(File::get(public_path('js/adm/SiteConfiguration.js'))) }}"></script>
<script src="{{ mix('js/adm/SiteConfiguration.js') }}"></script>
@endsection
@push('content')
<div class="container-md">
<h4>Site Configuration</h4>
<div class="card p-2">
<div class="card">
<div class="gt-conf-row">
<div class="row">
<div class="col-3">
<p>Name</p>
</div>
<div class="col-6">
<p>Value</p>
</div>
<div class="col-2">
<p>Last Modified</p>
</div>
<div class="col-1">
<button class="btn btn-sm btn-primary mx-auto" disabled>Edit</button>
</div>
</div>
</div>
<table class="table table-sm table-bordered table-striped mb-2">
<thead>
<tr>

View File

@ -0,0 +1,118 @@
@extends('layouts.admin')
@section('title', 'App Deployer')
@section('page-specific')
<!-- Secure Page JS -->
<script src="{{ mix('js/adm/AppDeployer.js') }}"></script>
@endsection
@push('content')
<div class="container">
<div class="alert alert-warning graphictoria-alert graphictoria-error-popup">Ensure the RCC version compatibility security settings and api keys are synced before deploying, else you may completely brick games or api calls.</div>
<h4 class="mt-1">App Deployer</h4>
<div class="card p-2" id="gt-deployer">
<x-loader />
@if(false)
<h5>Deployment Options</h5>
<div class="d-block">
<div class="btn-group mb-1">
<input type="radio" class="btn-check" name="gt-deployment-type" id="gt-deployment-deploy" autocomplete="off" checked>
<label class="btn btn-sm btn-outline-primary" for="gt-deployment-deploy">Deploy</label>
<input type="radio" class="btn-check" name="gt-deployment-type" id="gt-deployment-revert" autocomplete="off">
<label class="btn btn-sm btn-outline-primary" for="gt-deployment-revert">Revert</label>
</div>
<br />
<button class="btn btn-sm btn-success" disabled>Deploy Client</button>
<button class="btn btn-sm btn-primary">Deploy Studio</button>
</div>
<hr />
<div class="alert alert-danger graphictoria-alert graphictoria-error-popup">Remove your [deployments/reversions] to change the uploader type.</div>
<h5>Revert Deployments</h5>
<div class="card">
<div class="card-header d-flex">
<span>Revert Client</span>
<button class="ms-auto btn-close"></button>
</div>
<div class="card-body">
<h5 class="mb-0">Revert Deployment</h5>
<p class="text-muted">Select a previous deployment below to roll back the [client/studio] version.</p>
<select class="form-select mt-2" id="gt-revert-deployment">
<option selected>None Selected</option>
<option value="version-abcdefghijk">[Client 1.0.0.2] [11/23/2022] version-abcdefghijk</option>
<option value="version-bbcdefghijk">[Client 1.0.0.1] [11/22/2022] version-bbcdefghijk</option>
<option value="version-cbcdefghijk">[Client 1.0.0.0] [11/21/2022] version-cbcdefghijk</option>
</select>
</div>
</div>
<p class="text-muted">No deployments are selected.</p>
<h5>Staged Deployments</h5>
<div class="card">
<div class="card-header d-flex">
<span>Deploy Client</span>
<button class="ms-auto btn-close"></button>
</div>
<div class="card-body">
<h5 class="mb-0">Deployment Files</h5>
<p class="text-muted">Drag-and-Drop the necessary files into the box below.</p>
<div class="card bg-secondary mt-3 p-3">
<div>
{{-- XlXi: Reusing game cards here because they were already exactly what I wanted. --}}
<div class="graphictoria-item-card graphictoria-game-card">
<div class="card m-2" data-bs-toggle="tooltip" data-bs-placement="top" title="GraphictoriaLauncherBeta.exe">
<div class="bg-light d-flex p-3">
<i class="m-auto fs-1 fa-regular fa-browser"></i>
</div>
<div class="p-2">
<p class="text-truncate">GraphictoriaLauncherBeta.exe</p>
<button class="btn btn-sm btn-danger mt-1 w-100">Remove</button>
</div>
</div>
</div>
<div class="graphictoria-item-card graphictoria-game-card">
<div class="card m-2" data-bs-toggle="tooltip" data-bs-placement="top" title="Libraries.zip">
<div class="bg-light d-flex p-3">
<i class="m-auto fs-1 fa-regular fa-file-zipper"></i>
</div>
<div class="p-2">
<p class="text-truncate">Libraries.zip</p>
<button class="btn btn-sm btn-danger mt-1 w-100">Remove</button>
</div>
</div>
</div>
</div>
<hr/>
<h5>Needed Files</h5>
<div class="small">
<p class="text-success">Libraries.zip</p>
<p class="text-danger">GraphictoriaApp.zip</p>
</div>
<hr/>
<h5>Optional Files</h5>
<div class="small">
<p class="text-warning">GraphictoriaLauncherBeta 2.exe</p>
<p class="text-success">GraphictoriaLauncherBeta.exe</p>
</div>
</div>
<h5 class="mb-0 mt-3">Optional Configuration</h5>
<p class="text-muted mb-3">Only change if you've updated the security settings on the client/rcc. Shutting down game servers will delay deployment by 10 minutes.</p>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="gt-shut-down-servers">
<label class="form-check-label" for="gt-shut-down-servers">Shut down game servers.</label>
</div>
<label for="gt-rcc-security-key" class="form-label mt-2">Update RCC Security Key</label>
<input type="text" id="gt-rcc-security-key" class="form-control" placeholder="New RCC Security Key"/>
<label for="gt-rcc-security-key" class="form-label mt-2">Update Version Compatibility Salt</label>
<input type="text" id="gt-rcc-security-key" class="form-control" placeholder="New Version Compatibility Salt"/>
</div>
</div>
<p class="text-muted">No deployments are selected.</p>
<hr />
<button class="btn btn-primary">Deploy / Revert Deployment</button>
@endif
</div>
</div>
@endpush

Some files were not shown because too many files have changed in this diff Show More