Asset changes and bugfixes.

- Squashed a bug that prevented sales from being displayed on the transaction overview page.
- Split up auto uploader and xml uploader on admin panel.
- Manual asset uploader on admin panel.
- General package AssetType support.
- Manual BodyPart upload support.
- Manual Face upload support.
- Trusted, user creatable, and admin creatable options added to asset types.
- Vertical tabbed navs.
- Moved all routes for "shop.asset" to a method in the asset model itself.
- Added a way to view a list of the most recent assets uploaded by admins.
- Added Face render support.
- Indev outfits page.
This commit is contained in:
Graphictoria 2023-01-29 02:43:37 -05:00
parent 8b3f940fc6
commit 426778c340
33 changed files with 1103 additions and 167 deletions

View File

@ -15,6 +15,7 @@ use App\Helpers\CdnHelper;
use App\Models\Asset;
use App\Models\AssetVersion;
use App\Models\RobloxAsset;
use App\Models\UserAsset;
class AssetHelper
{
@ -24,23 +25,33 @@ class AssetHelper
'.ROBLOXSECURITY' => env('app.robloxcookie')
], '.roblox.com');
return Http::withOptions(['cookies' => $cookieJar, 'headers' => ['User-Agent' => 'Roblox/WinInet']]);
return Http::withOptions(['cookies' => $cookieJar, 'headers' => ['User-Agent' => 'Roblox/WinInet', 'Requester' => 'Server']]);
}
public static function uploadRobloxAsset($id, $uploadToHolder = false)
public static function newAsset($props, $hash)
{
{
$uploadedAsset = RobloxAsset::where('robloxAssetId', $id)->first();
if($uploadedAsset)
return $uploadedAsset->asset;
}
$asset = Asset::create($props);
$assetVersion = AssetVersion::create([
'parentAsset' => $asset->id,
'localVersion' => 1,
'contentURL' => $hash
]);
$asset->assetVersionId = $assetVersion->id;
$asset->save();
$marketplaceResult = self::cookiedRequest()->get('https://api.roblox.com/marketplace/productinfo?assetId=' . $id);
$assetResult = self::cookiedRequest()->get('https://assetdelivery.roblox.com/v2/asset?id=' . $id);
UserAsset::createSerialed($asset->creatorId, $asset->id);
if(!$marketplaceResult->ok() || !$assetResult->ok())
return $asset;
}
private static function getRBXMarketplaceInfo($assetId)
{
$marketplaceResult = self::cookiedRequest()->get('https://api.roblox.com/marketplace/productinfo?assetId=' . $assetId);
if(!$marketplaceResult->ok())
return false;
$marketplaceResult = $marketplaceResult->json();
$assetTypeId = $marketplaceResult['AssetTypeId'];
if(
$assetTypeId == 41 || // Hair Accessory
@ -54,25 +65,37 @@ class AssetHelper
$assetTypeId = 8;
}
$marketplaceResult['AssetTypeId'] = $assetTypeId;
return $marketplaceResult;
}
public static function uploadRobloxAsset($id, $uploadToHolder = false)
{
{
$uploadedAsset = RobloxAsset::where('robloxAssetId', $id)->first();
if($uploadedAsset)
return $uploadedAsset->asset;
}
$marketplaceResult = self::getRBXMarketplaceInfo($id);
$assetResult = self::cookiedRequest()->get('https://assetdelivery.roblox.com/v2/asset?id=' . $id);
if(!$marketplaceResult || !$assetResult->ok())
return false;
$assetContent = Http::get($assetResult['locations'][0]['location']);
$hash = CdnHelper::SaveContent($assetContent->body(), $assetContent->header('Content-Type'));
$asset = Asset::create([
$asset = self::newAsset([
'creatorId' => ($uploadToHolder ? 1 : Auth::user()->id),
'name' => $marketplaceResult['Name'],
'description' => $marketplaceResult['Description'],
'approved' => true,
'priceInTokens' => $marketplaceResult['PriceInRobux'] ?: 0,
'onSale' => $marketplaceResult['IsForSale'],
'assetTypeId' => $assetTypeId,
'assetTypeId' => $marketplaceResult['AssetTypeId'],
'assetVersionId' => 0
]);
$assetVersion = AssetVersion::create([
'parentAsset' => $asset->id,
'localVersion' => 1,
'contentURL' => $hash
]);
$asset->assetVersionId = $assetVersion->id;
$asset->save();
], $hash);
RobloxAsset::create([
'robloxAssetId' => $id,
@ -90,42 +113,22 @@ class AssetHelper
return $uploadedAsset->asset;
}
$marketplaceResult = self::cookiedRequest()->get('https://api.roblox.com/marketplace/productinfo?assetId=' . $id);
$marketplaceResult = self::getRBXMarketplaceInfo($id);
if(!$marketplaceResult->ok())
if(!$marketplaceResult)
return false;
$assetTypeId = $marketplaceResult['AssetTypeId'];
if(
$assetTypeId == 41 || // Hair Accessory
$assetTypeId == 42 || // Face Accessory
$assetTypeId == 43 || // Neck Accessory
$assetTypeId == 44 || // Shoulder Accessory
$assetTypeId == 45 || // Front Accessory
$assetTypeId == 46 || // Back Accessory
$assetTypeId == 47 // Waist Accessory
) {
$assetTypeId = 8;
}
$hash = CdnHelper::SaveContentB64($b64Content, 'application/octet-stream');
$asset = Asset::create([
$asset = self::newAsset([
'creatorId' => ($uploadToHolder ? 1 : Auth::user()->id),
'name' => $marketplaceResult['Name'],
'description' => $marketplaceResult['Description'],
'approved' => true,
'priceInTokens' => $marketplaceResult['PriceInRobux'] ?: 0,
'onSale' => $marketplaceResult['IsForSale'],
'assetTypeId' => $assetTypeId,
'assetTypeId' => $marketplaceResult['AssetTypeId'],
'assetVersionId' => 0
]);
$assetVersion = AssetVersion::create([
'parentAsset' => $asset->id,
'localVersion' => 1,
'contentURL' => $hash
]);
$asset->assetVersionId = $assetVersion->id;
$asset->save();
], $hash);
RobloxAsset::create([
'robloxAssetId' => $id,

View File

@ -144,10 +144,9 @@ class GridHelper
]);
}
public static function getBodyColorsXML()
private static function getXMLFromGameDisk($fileName)
{
$disk = self::getGameDisk();
$fileName = 'BodyColors.xml';
if(!$disk->exists($fileName))
throw new Exception('Unable to locate template file.');
@ -155,6 +154,21 @@ class GridHelper
return $disk->get($fileName);
}
public static function getBodyColorsXML()
{
return self::getXMLFromGameDisk('BodyColors.xml');
}
public static function getBodyPartXML()
{
return self::getXMLFromGameDisk('BodyPart.xml');
}
public static function getFaceXML()
{
return self::getXMLFromGameDisk('Face.xml');
}
public static function getArbiter($name)
{
$query = DynamicWebConfiguration::where('name', sprintf('%sArbiterIP', $name))->first();

View File

@ -3,14 +3,18 @@
namespace App\Http\Controllers\Api;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
use App\Helpers\AssetHelper;
use App\Helpers\CdnHelper;
use App\Helpers\GridHelper;
use App\Helpers\ValidationHelper;
use App\Http\Controllers\Controller;
use App\Jobs\AppDeployment;
use App\Models\AssetType;
use App\Models\Deployment;
use App\Models\RobloxAsset;
use App\Rules\AppDeploymentFilenameRule;
class AdminController extends Controller
@ -19,7 +23,173 @@ class AdminController extends Controller
// Admin+
function manualAssetUpload(Request $request)
{
$validator = Validator::make($request->all(), [
'asset-type-id' => ['required', 'int'],
'name' => ['required', 'string'],
'description' => ['string', 'nullable'],
'roblox-id' => ['int', 'min:0', 'nullable'],
'on-sale' => ['required', 'boolean'],
'price' => ['required_if:on-sale,true', 'int', 'min:0'],
'content' => ['nullable'],
'mesh-id' => ['int', 'nullable'],
'base-id' => ['int', 'nullable'],
'overlay-id' => ['int', 'nullable'],
],[
'asset-type-id.required' => 'An asset type ID must be provided.',
'roblox-id.integer' => 'Roblox ID must be an integer.'
]);
if($validator->fails())
return ValidationHelper::generateValidatorError($validator);
$valid = $validator->valid();
$isRobloxAsset = ($request->has('roblox-id') && $valid['roblox-id'] > 0);
if($isRobloxAsset)
{
$uploadedAsset = RobloxAsset::where('robloxAssetId', $valid['roblox-id'])->first();
if($uploadedAsset)
{
$validator->errors()->add('roblox-id', 'This asset has already been uploaded!');
return ValidationHelper::generateValidatorError($validator);
}
}
$assetType = AssetType::where('id', $valid['asset-type-id'])
->where('adminCreatable', 1)
->first();
if(!$assetType)
{
$validator->errors()->add('asset-type-id', 'Invalid asset type for admin upload.');
return ValidationHelper::generateValidatorError($validator);
}
$assetFunction = 'Unknown';
$assetFunctionArgs = [];
switch($assetType->id)
{
case 27: // Torso
case 28: // Right Arm
case 29: // Left Arm
case 30: // Left Leg
case 31: // Right Leg
$assetFunctionArgs = [$assetType->id, $valid];
$assetFunction = 'BodyPart';
break;
case 18: // Face
$assetFunctionArgs = [$validator, $valid];
$assetFunction = 'Face';
break;
default:
$assetFunctionArgs = [$validator];
$assetFunction = 'Generic';
break;
}
$assetContent = $this->{ 'manualAssetUpload' . $assetFunction }($request, ...$assetFunctionArgs);
$hash = CdnHelper::SaveContent($assetContent, 'application/octet-stream');
$asset = AssetHelper::newAsset([
'creatorId' => 1,
'name' => $valid['name'],
'description' => $valid['description'],
'approved' => true,
'priceInTokens' => $valid['price'],
'onSale' => $valid['on-sale'] == 1 ? true : false,
'assetTypeId' => $assetType->id,
'assetVersionId' => 0
], $hash);
$asset->logAdminUpload(Auth::user()->id);
if($isRobloxAsset)
{
RobloxAsset::create([
'robloxAssetId' => $valid['roblox-id'],
'localAssetId' => $asset->id
]);
}
return response([
'success' => true,
'message' => 'Your asset has been successfully uploaded!',
'assetId' => $asset->id
]);
}
function manualAssetUploadBodyPart(Request $request, $assetTypeId, $valid)
{
$bodyParts = [
27 => 1, // Torso
28 => 3, // Right Arm
29 => 2, // Left Arm
30 => 4, // Left Leg
31 => 5 // Right Leg
];
$document = simplexml_load_string(GridHelper::getBodyPartXML());
$document->xpath('//int[@name="BaseTextureId"]')[0][0] = $valid['base-id'] ?: 0;
$document->xpath('//token[@name="BodyPart"]')[0][0] = $bodyParts[$assetTypeId];
$document->xpath('//int[@name="MeshId"]')[0][0] = $valid['mesh-id'];
$document->xpath('//string[@name="Name"]')[0][0] = $valid['name'];
$document->xpath('//int[@name="OverlayTextureId"]')[0][0] = $valid['overlay-id'] ?: 0;
$domXML = dom_import_simplexml($document);
$assetContent = $domXML->ownerDocument->saveXML($domXML->ownerDocument->documentElement);
return $assetContent;
}
function manualAssetUploadFace(Request $request, $validator, $valid)
{
if(!$request->has('content'))
{
$validator->errors()->add('content', 'Asset content cannot be blank!');
return ValidationHelper::generateValidatorError($validator);
}
$hash = CdnHelper::SaveContent(
file_get_contents($request->file('content')->path()),
'application/octet-stream'
);
$imageAsset = AssetHelper::newAsset([
'creatorId' => 1,
'name' => $valid['name'],
'approved' => true,
'onSale' => false,
'assetTypeId' => 1, // Image
'assetVersionId' => 0
], $hash);
$imageAsset->logAdminUpload(Auth::user()->id);
$imageAssetUrl = route('client.asset', ['id' => $imageAsset->id]);
$document = simplexml_load_string(GridHelper::getFaceXML());
$document->xpath('//Content[@name="Texture"]')[0][0]->addChild('url', $imageAssetUrl);
$domXML = dom_import_simplexml($document);
$assetContent = $domXML->ownerDocument->saveXML($domXML->ownerDocument->documentElement);
return $assetContent;
}
function manualAssetUploadGeneric(Request $request, $validator)
{
if(!$request->has('content'))
{
$validator->errors()->add('content', 'Asset content cannot be blank!');
return ValidationHelper::generateValidatorError($validator);
}
$assetContent = file_get_contents($request->file('content')->path());
return $assetContent;
}
function manualAssetUploadUnknown(Request $request)
{
throw new \BadMethodCallException('Not implemented');
}
// Owner+
function deploy(Request $request)

View File

@ -76,7 +76,7 @@ class AvatarController extends Controller
array_push($data, [
'id' => $asset->id,
'Url' => route('shop.asset', ['asset' => $asset, 'assetName' => Str::slug($asset->name, '-')]),
'Url' => $asset->getShopUrl(),
'Thumbnail' => $asset->getThumbnail(),
'Name' => $asset->name,
'Wearing' => Auth::user()->isWearing($asset->id)
@ -100,7 +100,7 @@ class AvatarController extends Controller
array_push($data, [
'id' => $asset->id,
'Url' => route('shop.asset', ['asset' => $asset, 'assetName' => Str::slug($asset->name, '-')]),
'Url' => $asset->getShopUrl(),
'Thumbnail' => $asset->getThumbnail(),
'Name' => $asset->name,
'Wearing' => true
@ -160,11 +160,7 @@ class AvatarController extends Controller
return ValidationHelper::generateValidatorError($validator);
}
AvatarAsset::Create([
'owner_id' => Auth::user()->id,
'asset_id' => $valid['id']
]);
Auth::user()->wearAsset($valid['id']);
Auth::user()->redraw();
return response(['success' => true]);

View File

@ -44,7 +44,9 @@ class MoneyController extends Controller
foreach($dataPoint['Points'] as $transactionType)
{
$newColumn['total'] += Transaction::where('user_id', Auth::user()->id)
$column = $transactionType == 'Sales' ? 'seller_id' : 'user_id';
$newColumn['total'] += Transaction::where($column, Auth::user()->id)
->where('transaction_type_id', TransactionType::IDFromType($transactionType))
->where(function($query) use($request) {
if(!$request->has('filter'))
@ -110,6 +112,7 @@ class MoneyController extends Controller
return $query->where('user_id', Auth::user()->id);
})
->where('transaction_type_id', $transactionType->id)
->with('asset')
->orderByDesc('id')
->cursorPaginate(30);
$prevCursor = $transactions->previousCursor();
@ -126,7 +129,7 @@ class MoneyController extends Controller
$asset = null;
if($transactionType->format != '')
$asset = [
'url' => route('shop.asset', ['asset' => $transaction->asset, 'assetName' => Str::slug($transaction->asset->name, '-')]),
'url' => $transaction->asset->getShopUrl(),
'name' => $transaction->asset->name
];

View File

@ -85,7 +85,7 @@ class ShopController extends Controller
'Thumbnail' => $asset->getThumbnail(),
'OnSale' => $asset->onSale,
'Price' => $asset->priceInTokens,
'Url' => route('shop.asset', ['asset' => $asset->id, 'assetName' => Str::slug($asset->name, '-')])
'Url' => $asset->getShopUrl()
]);
}

View File

@ -83,6 +83,9 @@ class ThumbnailController extends Controller
break;
}
} elseif($renderType == 'Asset') {
if($model->renderId)
$model = Asset::where('id', $model->renderId)->first();
if($model->moderated)
return response(['status' => 'success', 'data' => '/thumbs/DeletedThumbnail.png']);
@ -108,13 +111,13 @@ class ThumbnailController extends Controller
if($renderType == 'User' && $valid['position'] == 'bust')
$trackerType .= 'bust';
$tracker = RenderTracker::where('type', $trackerType)
->where('target', $valid['id'])
->where('target', $model->id)
->where('created_at', '>', Carbon::now()->subMinute());
if(!$tracker->exists()) {
$tracker = RenderTracker::create([
'type' => $trackerType,
'target' => $valid['id']
'target' => $model->id
]);
ArbiterRender::dispatch(

View File

@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use App\Http\Controllers\Controller;
use App\Models\AdminUpload;
use App\Models\DynamicWebConfiguration;
use App\Models\PunishmentType;
use App\Models\Username;
@ -264,13 +265,30 @@ class AdminController extends Controller
return view('web.admin.userlookup')->with('users', $users)->with('input', $request->get('lookup'));
}
// Admin+
// GET admin.autoupload
function autoUpload()
{
return view('web.admin.autoupload');
return view('web.admin.catalog.autoupload');
}
// GET admin.assetupload
function assetUpload()
{
return view('web.admin.catalog.assetupload');
}
// GET admin.adminuploads
function getAdminUploads(Request $request)
{
$uploads = AdminUpload::query()
->orderByDesc('id')
->paginate(25);
return view('web.admin.catalog.adminuploads')->with('uploads', $uploads);
}
// Admin+
function metricsVisualization()
{
return view('web.admin.metricsvisualization');

View File

@ -102,6 +102,9 @@ class ArbiterRender implements ShouldQueue
case 'Avatar':
$arguments[0] = route('client.characterFetch', ['userId' => $this->assetId]);
break;
case 'Face':
$this->type = 'Decal';
break;
case 'Torso':
case 'Right Arm':
case 'Left Arm':
@ -116,8 +119,9 @@ class ArbiterRender implements ShouldQueue
break;
case 'Package':
// TODO: XlXi: Move these to config, as it could be different from prod in a testing environment. Also move these to their own assets (not loading from roblox).
$arguments[0] = $this->tracker->targetObj->getPackageAssetUrls();
array_push($arguments, 'https://www.roblox.com/asset/?id=1785197'); // Rig
array_push($arguments, '27113661;25251154'); // Custom Texture URLs (shirt and pands)
array_push($arguments, 'https://www.roblox.com/asset/?id=27113661;https://www.roblox.com/asset/?id=25251154'); // Custom Texture URLs (shirt and pands)
break;
case 'Place':
$arguments[2] = 768*4; // XlXi: These get scaled down by 4.

View File

@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class AdminUpload 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 = [
'asset_id',
'uploader_id'
];
public function asset()
{
return $this->belongsTo(Asset::class, 'asset_id');
}
}

View File

@ -229,7 +229,9 @@ class User extends Authenticatable
foreach($oldHashes as $hash)
{
if(!User::where('thumbnailBustHash', $hash)->orWhere('thumbnail2DHash', $hash)->orWhere('thumbnail3DHash', $hash)->exists())
$userThumbExists = User::where('thumbnailBustHash', $hash)->orWhere('thumbnail2DHash', $hash)->orWhere('thumbnail3DHash', $hash)->exists();
$assetThumbExists = Asset::where('thumbnail2DHash', $hash)->orWhere('thumbnail3DHash', $hash)->exists();
if(!$userThumbExists && !$assetThumbExists)
CdnHelper::Delete($hash);
}
}
@ -247,6 +249,27 @@ class User extends Authenticatable
->whereRelation('asset', 'moderated', 0);
}
public function wearAsset($assetId)
{
$asset = Asset::where('id', $assetId)->first();
if($asset->assetType->id == 32)
{
foreach(explode(';', $asset->getPackageAssetIds()) as $asset)
{
if($this->isWearing($asset)) continue;
$this->wearAsset($asset);
}
}
else
{
AvatarAsset::Create([
'owner_id' => $this->id,
'asset_id' => $assetId
]);
}
}
public function getBodyColors()
{
$colors = AvatarColor::user($this->id);

View File

@ -25,12 +25,34 @@ class UserAsset extends Model
return $this->belongsTo(Asset::class, 'asset_id');
}
public function owner()
{
return $this->belongsTo(User::class, 'owner_id');
}
public static function createSerialed($ownerId, $assetId)
{
return self::create([
$userAsset = self::create([
'owner_id' => $ownerId,
'asset_id' => $assetId,
'serial' => self::where('asset_id', $assetId)->count()+1
]);
$userAsset->postSerialize();
return $userAsset;
}
public function postSerialize()
{
// Grant package assets.
if($this->asset->assetType->id == 32)
{
foreach(explode(';', $this->asset->getPackageAssetIds()) as $asset)
{
if($this->owner->hasAsset($asset)) continue;
self::createSerialed($this->owner_id, $asset);
}
}
}
}

View File

@ -6,7 +6,9 @@ use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use App\Helpers\CdnHelper;
use App\Models\AssetType;
/*
@ -41,7 +43,7 @@ class Asset extends Model
'creatorId',
'name',
'description',
'parentAssetId',
'renderId',
'approved',
'priceInTokens',
'onSale',
@ -139,6 +141,12 @@ class Asset extends Model
public function getThumbnail()
{
if($this->renderId)
{
$asset = Asset::where('id', $this->renderId)->first();
return $asset->getThumbnail();
}
if($this->moderated)
return '/thumbs/DeletedThumbnail.png';
@ -160,6 +168,29 @@ class Asset extends Model
return route('content', $this->thumbnail2DHash);
}
// XlXi: For packages. https://abc.com/asset?id=1;https://abc.com/asset?id=2
public function getPackageAssetUrls()
{
$result = '';
$assets = $this->getPackageAssetIds();
if(!$assets)
return $result;
foreach(explode(';', $assets) as $key=>$asset)
{
$result .= ($key > 0 ? ';' : '') . route('client.asset', ['id' => $asset]);
}
return $result;
}
public function getPackageAssetIds()
{
$disk = CdnHelper::GetDisk();
return $disk->get($this->getContent());
}
public function set2DHash($hash)
{
$this->thumbnail2DHash = $hash;
@ -174,6 +205,19 @@ class Asset extends Model
$this->save();
}
public function getShopUrl()
{
return route('shop.asset', ['asset' => $this, 'assetName' => Str::slug($this->name, '-')]);
}
public function logAdminUpload($uploaderId)
{
return AdminUpload::create([
'asset_id' => $this->id,
'uploader_id' => $uploaderId
]);
}
public function getCreated()
{
$date = $this['created_at'];

View File

@ -0,0 +1,28 @@
<?php
namespace App\View\Components\Admin\Navigation;
use Illuminate\View\Component;
class AssetUploader 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.navigation.asset-uploader');
}
}

View File

@ -19,7 +19,7 @@ return new class extends Migration
$table->unsignedBigInteger('creatorId');
$table->string('name');
$table->longText('description')->nullable();
$table->unsignedBigInteger('parentAssetId')->nullable()->comment('Used by things like images that were created because of something else.');
$table->unsignedBigInteger('renderId')->nullable();
$table->boolean('approved')->default(false);
$table->boolean('moderated')->default(false);

View File

@ -22,6 +22,9 @@ return new class extends Migration
$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->boolean('trusted')->default(false); // Skips text filtering for name and description.
$table->boolean('userCreatable')->default(false); // Can be uploaded by a user.
$table->boolean('adminCreatable')->default(false); // Can be uploaded via the admin panel.
$table->timestamps();
});
}

View File

@ -0,0 +1,35 @@
<?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('admin_uploads', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('asset_id');
$table->unsignedBigInteger('uploader_id');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('admin_uploads');
}
};

View File

@ -19,14 +19,17 @@ class AssetTypeSeeder extends Seeder
'name' => 'Image',
'renderable' => true,
'copyable' => true,
'locked' => true
'locked' => true,
'trusted' => true,
'adminCreatable' => true
],
[
'name' => 'T-Shirt',
'renderable' => true,
'copyable' => true,
'sellable' => true,
'wearable' => true
'wearable' => true,
'userCreatable' => true
],
[
'name' => 'Audio',
@ -37,22 +40,26 @@ class AssetTypeSeeder extends Seeder
'renderable' => true,
'renderable3d' => true,
'copyable' => true,
'locked' => true
'locked' => true,
'adminCreatable' => true
],
[
'name' => 'Lua',
'copyable' => true,
'locked' => true
'locked' => true,
'adminCreatable' => true
],
[
'name' => 'HTML',
'copyable' => true,
'locked' => true
'locked' => true,
'adminCreatable' => true
],
[
'name' => 'Text',
'copyable' => true,
'locked' => true
'locked' => true,
'adminCreatable' => true
],
[
'name' => 'Hat',
@ -60,15 +67,18 @@ class AssetTypeSeeder extends Seeder
'renderable3d' => true,
'copyable' => true,
'sellable' => true,
'wearable' => true
'wearable' => true,
'adminCreatable' => true
],
[
'name' => 'Place',
'renderable' => true
'renderable' => true,
'userCreatable' => true
],
[
'name' => 'Model',
'renderable' => true
'renderable' => true,
'userCreatable' => true
],
[
'name' => 'Shirt',
@ -76,7 +86,8 @@ class AssetTypeSeeder extends Seeder
'renderable3d' => true,
'copyable' => true,
'sellable' => true,
'wearable' => true
'wearable' => true,
'userCreatable' => true
],
[
'name' => 'Pants',
@ -84,7 +95,8 @@ class AssetTypeSeeder extends Seeder
'renderable3d' => true,
'copyable' => true,
'sellable' => true,
'wearable' => true
'wearable' => true,
'userCreatable' => true
],
[
'name' => 'Decal',
@ -103,13 +115,16 @@ class AssetTypeSeeder extends Seeder
'renderable3d' => true,
'copyable' => true,
'sellable' => true,
'wearable' => true
'wearable' => true,
'adminCreatable' => true
],
[
'name' => 'Face',
'renderable' => true,
'copyable' => true,
'sellable' => true,
'wearable' => true
'wearable' => true,
'adminCreatable' => true
],
[
'name' => 'Gear',
@ -117,7 +132,8 @@ class AssetTypeSeeder extends Seeder
'renderable3d' => true,
'copyable' => true,
'sellable' => true,
'wearable' => true
'wearable' => true,
'adminCreatable' => true
],
null,
[
@ -129,28 +145,22 @@ class AssetTypeSeeder extends Seeder
'name' => 'Group Emblem',
'renderable' => true,
'copyable' => true,
'locked' => true
'locked' => true,
'trusted' => true
],
null,
[
'name' => 'Animation',
'copyable' => true
'copyable' => true,
'userCreatable' => true
],
[
'name' => 'Arms',
'renderable' => true,
'renderable3d' => true,
'copyable' => true,
'locked' => true,
'wearable' => true
'locked' => true
],
[
'name' => 'Legs',
'renderable' => true,
'renderable3d' => true,
'copyable' => true,
'locked' => true,
'wearable' => true
'locked' => true
],
[
'name' => 'Torso',
@ -158,7 +168,8 @@ class AssetTypeSeeder extends Seeder
'renderable3d' => true,
'copyable' => true,
'locked' => true,
'wearable' => true
'wearable' => true,
'adminCreatable' => true
],
[
'name' => 'Right Arm',
@ -166,7 +177,8 @@ class AssetTypeSeeder extends Seeder
'renderable3d' => true,
'copyable' => true,
'locked' => true,
'wearable' => true
'wearable' => true,
'adminCreatable' => true
],
[
'name' => 'Left Arm',
@ -174,7 +186,8 @@ class AssetTypeSeeder extends Seeder
'renderable3d' => true,
'copyable' => true,
'locked' => true,
'wearable' => true
'wearable' => true,
'adminCreatable' => true
],
[
'name' => 'Left Leg',
@ -182,7 +195,8 @@ class AssetTypeSeeder extends Seeder
'renderable3d' => true,
'copyable' => true,
'locked' => true,
'wearable' => true
'wearable' => true,
'adminCreatable' => true
],
[
'name' => 'Right Leg',
@ -190,7 +204,8 @@ class AssetTypeSeeder extends Seeder
'renderable3d' => true,
'copyable' => true,
'locked' => true,
'wearable' => true
'wearable' => true,
'adminCreatable' => true
],
[
'name' => 'Package',
@ -198,7 +213,8 @@ class AssetTypeSeeder extends Seeder
'renderable3d' => true,
'copyable' => true,
'sellable' => true,
'wearable' => true
'wearable' => true,
'adminCreatable' => true
],
[
'name' => 'YouTubeVideo',
@ -206,7 +222,8 @@ class AssetTypeSeeder extends Seeder
],
[
'name' => 'Game Pass',
'sellable' => true
'sellable' => true,
'userCreatable' => true
],
[
'name' => 'App',
@ -216,11 +233,13 @@ class AssetTypeSeeder extends Seeder
[
'name' => 'Code',
'copyable' => true,
'locked' => true
'locked' => true,
'adminCreatable' => true
],
[
'name' => 'Plugin',
'sellable' => true
'sellable' => true,
'userCreatable' => true
],
[
'name' => 'SolidModel',
@ -236,59 +255,31 @@ class AssetTypeSeeder extends Seeder
],
[
'name' => 'Hair Accessory',
'renderable' => true,
'renderable3d' => true,
'copyable' => true,
'sellable' => true,
'wearable' => true
'locked' => true
],
[
'name' => 'Face Accessory',
'renderable' => true,
'renderable3d' => true,
'copyable' => true,
'sellable' => true,
'wearable' => true
'locked' => true
],
[
'name' => 'Neck Accessory',
'renderable' => true,
'renderable3d' => true,
'copyable' => true,
'sellable' => true,
'wearable' => true
'locked' => true
],
[
'name' => 'Shoulder Accessory',
'renderable' => true,
'renderable3d' => true,
'copyable' => true,
'sellable' => true,
'wearable' => true
'locked' => true
],
[
'name' => 'Front Accessory',
'renderable' => true,
'renderable3d' => true,
'copyable' => true,
'sellable' => true,
'wearable' => true
'locked' => true
],
[
'name' => 'Back Accessory',
'renderable' => true,
'renderable3d' => true,
'copyable' => true,
'sellable' => true,
'wearable' => true
'locked' => true
],
[
'name' => 'Waist Accessory',
'renderable' => true,
'renderable3d' => true,
'copyable' => true,
'sellable' => true,
'wearable' => true
'locked' => true
],
[
'name' => 'Climb Animation',

View File

@ -408,7 +408,12 @@ class OutfitsTab extends Component {
}
render() {
return (<><p>outfits</p></>);
return (<>
<div className="mb-1 d-flex">
<button className="btn btn-sm btn-primary ms-auto">Create New</button>
</div>
<p>outfits</p>
</>);
}
}

View File

@ -0,0 +1,401 @@
/*
Copyright © XlXi 2022
*/
import { Component, createElement, createRef } from 'react';
import axios from 'axios';
import classNames from 'classnames/bind';
import { buildGenericApiUrl } from '../util/HTTP.js';
import Loader from './Loader';
axios.defaults.withCredentials = true;
const assetTypes = [
{
assetTypeId: 1,
name: 'Image',
type: 'fileupload',
extra: <p>PNG, JPG, JPEG, and other common image formats are supported.</p>,
sellable: false
},
{
assetTypeId: 4,
name: 'Mesh',
type: 'fileupload',
extra: <p>Your mesh can be <b>obj</b> or Roblox's <b>mesh</b> format.</p>,
sellable: false
},
{
assetTypeId: 5,
name: 'Lua',
type: 'text',
sellable: false
},
{
assetTypeId: 6,
name: 'HTML',
type: 'text',
sellable: false
},
{
assetTypeId: 7,
name: 'Text',
type: 'text',
sellable: false
},
{
assetTypeId: 8,
name: 'Hat',
type: 'fileupload',
sellable: true
},
{
assetTypeId: 17,
name: 'Head',
type: 'fileupload',
extra: <p>Heads are SpecialMeshes. Export it from studio, do not upload the mesh file here.</p>,
sellable: true
},
{
assetTypeId: 18,
name: 'Face',
type: 'fileupload',
extra: <p>Faces are image files. The XML will be automatically generated.</p>,
sellable: true
},
{
assetTypeId: 19,
name: 'Gear',
type: 'fileupload',
sellable: true
},
{
assetTypeId: 27,
name: 'Torso',
type: 'packagepart',
extra: <p>Overlay ID displays atop clothing, base ID displays under clothing.</p>,
sellable: false
},
{
assetTypeId: 28,
name: 'Right Arm',
type: 'packagepart',
extra: <p>Overlay ID displays atop clothing, base ID displays under clothing.</p>,
sellable: false
},
{
assetTypeId: 29,
name: 'Left Arm',
type: 'packagepart',
extra: <p>Overlay ID displays atop clothing, base ID displays under clothing.</p>,
sellable: false
},
{
assetTypeId: 30,
name: 'Left Leg',
type: 'packagepart',
extra: <p>Overlay ID displays atop clothing, base ID displays under clothing.</p>,
sellable: false
},
{
assetTypeId: 31,
name: 'Right Leg',
type: 'packagepart',
extra: <p>Overlay ID displays atop clothing, base ID displays under clothing.</p>,
sellable: false
},
{
assetTypeId: 32,
name: 'Package',
type: 'text',
extra: <p>Asset IDs for package. Example: 1;2;3;4;5</p>,
sellable: true
},
{
assetTypeId: 37,
name: 'Code',
type: 'text',
sellable: false
}
];
class ManualAssetUploadModel extends Component {
constructor(props) {
super(props);
this.state = {
forSale: false,
requestInputs: {
name: 'New Asset',
description: this.props.TypeName + ' Asset',
'roblox-id': 0,
'on-sale': false,
price: 0
}
};
this.forSaleRef = createRef();
this.pushInput = this.pushInput.bind(this);
this.updateContentInputFile = this.updateContentInputFile.bind(this);
this.updateContentInputText = this.updateContentInputText.bind(this);
this.updateInput = this.updateInput.bind(this);
this.uploadAsset = this.uploadAsset.bind(this);
}
componentDidMount() {
this.pushInput('asset-type-id', this.props.TypeId);
if(this.props.UploadType == 'packagepart')
{
this.pushInput('mesh-id', 0);
this.pushInput('overlay-id', 0);
this.pushInput('base-id', 0);
}
}
pushInput(name, value) {
this.setState((state, props) => ({
requestInputs: { ...state.requestInputs, [name]: value }
}));
}
updateContentInputFile(value) {
this.pushInput('content', value);
}
updateContentInputText(value) {
const file = (value != '' && new Blob([ value ], { type: 'text/plain' }));
this.pushInput('content', file);
}
updateInput(e, prop='value') {
this.pushInput(e.target.id, e.target[prop]);
}
uploadAsset() {
let { requestInputs } = this.state;
this.props.setLoad(true);
if(!requestInputs.content && this.props.UploadType != 'packagepart')
{
this.props.flashError('Asset content cannot be blank!');
this.props.setLoad(false);
return;
}
let bodyFormData = new FormData();
Object.keys(requestInputs).map(key => {
let val = requestInputs[key];
if(typeof(val) == 'boolean')
val = val ? 1 : 0;
bodyFormData.append(key, val);
});
axios.post(buildGenericApiUrl('api', 'admin/v1/manual-asset-upload'), bodyFormData)
.then(res => {
const data = res.data;
this.props.flashSuccess(data.message, data.assetId);
this.props.setLoad(false);
})
.catch(err => {
const data = err.response.data;
this.props.flashError(data.errors ? data.errors[0].message : 'An unknown error occurred.');
this.props.setLoad(false);
});
}
render() {
let { TypeName, UploadType, Extra, Sellable } = this.props;
return (
<>
<h4>Create a { TypeName }</h4>
<div>
<div className="mb-3">
{ Extra }
{
UploadType == 'fileupload'
?
<>
<label for="content" className="form-label">Find your { TypeName }:</label>
<input className="form-control mb-2" type="file" id="content" onChange={ (e) => this.updateContentInputFile(e.target.files[0]) } />
</>
:
UploadType == 'packagepart'
?
<>
<label for="mesh-id" className="form-label">Mesh ID:</label>
<input className="form-control mb-2" type="number" id="mesh-id" value={ this.state.requestInputs['mesh-id'] } onChange={ this.updateInput } />
<label for="overlay-id" className="form-label">Overlay Texture ID (if applicable):</label>
<input className="form-control mb-2" type="number" id="overlay-id" value={ this.state.requestInputs['overlay-id'] } onChange={ this.updateInput } />
<label for="base-id" className="form-label">Base Texture ID (if applicable):</label>
<input className="form-control mb-2" type="number" id="base-id" value={ this.state.requestInputs['base-id'] } onChange={ this.updateInput } />
</>
:
UploadType == 'text'
&&
<>
<label for="content" className="form-label">{ TypeName } Contents:</label>
<textarea className="form-control mb-2" type="text" id="content" onChange={ (e) => this.updateContentInputText(e.target.value) }></textarea>
</>
}
<label for="name" className="form-label">{ TypeName } Name:</label>
<input className="form-control mb-2" type="text" id="name" value={ this.state.requestInputs.name } onChange={ this.updateInput } />
<label for="description" className="form-label">{ TypeName } Description:</label>
<textarea className="form-control mb-2" type="text" id="description" value={ this.state.requestInputs.description } onChange={ this.updateInput }></textarea>
<label for="roblox-id" className="form-label">Roblox Asset ID (if applicable):</label>
<input className="form-control" type="number" id="roblox-id" value={ this.state.requestInputs['roblox-id'] } onChange={ this.updateInput } />
{
Sellable
&&
<>
<hr />
<h4>Sell this Item</h4>
<div className="form-check">
<input className="form-check-input" type="checkbox" value={ this.state.requestInputs['on-sale'] } id="on-sale" onChange={ (e) => this.updateInput(e, 'checked') } />
<label className="form-check-label" for="on-sale">Sell this Item</label>
</div>
{
this.state.requestInputs['on-sale']
&&
<div className="input-group mb-2">
<span className="input-group-text px-1 pe-0">
<p className="virtubrick-tokens">&nbsp;</p>
</span>
<input className="form-control" type="number" id="price" value={ this.state.requestInputs.price } min="0" max="999999999" placeholder="Price" onChange={ this.updateInput } />
</div>
}
</>
}
</div>
<button className="btn btn-success px-5" onClick={ this.uploadAsset }>Upload</button>
</div>
</>
)
}
}
class ManualAssetUpload extends Component {
constructor(props) {
super(props);
this.state = {
createModelLoaded: false,
currentTabTypeId: 0,
tabKey: 0,
loading: false
};
this.findAssetType = this.findAssetType.bind(this);
this.setLoad = this.setLoad.bind(this);
this.flashError = this.flashError.bind(this);
this.flashSuccess = this.flashSuccess.bind(this);
this.navigateAssetType = this.navigateAssetType.bind(this);
}
componentDidMount()
{
this.navigateAssetType(1);
}
findAssetType(typeId) {
return assetTypes.find(obj => {
return obj.assetTypeId === typeId
});
}
setLoad(loading) {
this.setState({ loading: loading });
}
flashError(message) {
this.setState({ errorMessage: message });
setTimeout(function(){
this.setState({ errorMessage: null });
}.bind(this), 3000);
}
flashSuccess(message, assetId) {
this.setState({ successMessage: message, successId: assetId });
setTimeout(function(){
this.setState({ successMessage: null, successId: null });
}.bind(this), 10000);
}
navigateAssetType(typeId)
{
if(this.state.loading) return;
let data = this.findAssetType(typeId);
this.activeModel = createElement(
ManualAssetUploadModel,
{
TypeId: data.assetTypeId,
TypeName: data.name,
UploadType: data.type,
Extra: data.extra,
Sellable: data.sellable,
setLoad: this.setLoad,
flashError: this.flashError,
flashSuccess: this.flashSuccess,
key: this.state.tabKey
}
);
this.setState({ createModelLoaded: true, currentTabTypeId: data.assetTypeId, tabKey: this.state.tabKey+1 });
}
render() {
return (
<>
<div className="col-2 pe-0">
<ul className="nav nav-tabs flex-column">
{
assetTypes.map(({ assetTypeId, name }) =>
<li className="nav-item">
<button className={classNames({ 'nav-link': true, 'active': (assetTypeId == this.state.currentTabTypeId) })} disabled={ this.state.loading } onClick={ () => this.navigateAssetType(assetTypeId) }>{ name }</button>
</li>
)
}
</ul>
</div>
<div className="col-10 ps-0">
{
this.state.successMessage
&&
<div className="alert alert-success virtubrick-alert virtubrick-error-popup">{ this.state.successMessage } <a className="text-decoration-none" href={ buildGenericApiUrl('www', `shop/${ this.state.successId }`) }>Click Here</a></div>
}
{
this.state.errorMessage
&&
<div className="alert alert-danger virtubrick-alert virtubrick-error-popup">{ this.state.errorMessage }</div>
}
<div className="card p-3 vb-card-navconnector">
{
this.state.loading
&&
<div className="virtubrick-shop-overlay">
<Loader />
</div>
}
{
!this.state.createModelLoaded
?
<Loader />
:
this.activeModel
}
</div>
</div>
</>
);
}
}
export default ManualAssetUpload;

View File

@ -0,0 +1,18 @@
/*
Copyright © XlXi 2023
*/
import $ from 'jquery';
import React from 'react';
import { render } from 'react-dom';
import ManualAssetUpload from '../components/ManualAssetUpload';
const assetUploadId = 'vb-manual-assetupload';
$(document).ready(function() {
if (document.getElementById(assetUploadId)) {
render(<ManualAssetUpload />, document.getElementById(assetUploadId));
}
});

View File

@ -1098,6 +1098,29 @@ input {
&.nav-justified > li {
vertical-align: bottom;
}
&.flex-column {
.nav-link {
width: 100%;
text-align: left;
margin-top: 0;
border-top-right-radius: 0;
border-bottom-left-radius: 0.25rem;
&:not(.disabled):hover,
&:not(.disabled):focus,
&.active {
padding: 0.5rem 1rem;
}
}
border-bottom: 0;
}
}
.card.vb-card-navconnector {
border-left: 0;
border-top-left-radius: 0;
}
@keyframes dropdownEase {

View File

@ -0,0 +1,5 @@
<x-admin.navigation-tabs>
<x-admin.navigation-tab-link label="Upload Asset" :route="route('admin.assetupload')" />
<x-admin.navigation-tab-link label="Auto Uploader" :route="route('admin.autoupload')" />
<x-admin.navigation-tab-link label="Uploaded Assets" :route="route('admin.adminuploads')" />
</x-admin.navigation-tabs>

View File

@ -1,23 +0,0 @@
@extends('layouts.admin')
@section('title', 'Auto Uploader')
@push('content')
<div class="container-md">
<h4>Auto Uploader</h4>
<div class="card p-3">
<label for="vb-rbx-asset" class="form-label">Roblox Asset ID</label>
<div class="input-group mb-3">
<input
type="text"
class="form-control"
placeholder="Roblox Asset ID Here"
aria-label="Roblox Asset ID Here"
name="rbx-asset" id="vb-rbx-asset"
aria-describedby="vb-rbx-asset"
>
<button type="submit" class="btn btn-primary" type="button" name="rbx-asset-button" id="vb-rbx-asset-btn">Search</button>
</div>
</div>
</div>
@endpush

View File

@ -0,0 +1,44 @@
@extends('layouts.admin')
@section('title', 'Uploaded Assets')
@push('content')
<div class="container-md">
<x-admin.navigation.asset-uploader />
<h4>Uploaded Assets</h4>
<div class="card">
@if(isset($uploads) && count($uploads) > 0)
<table class="table virtubrick-table">
<thead>
<tr>
<th scope="col">Asset</th>
<th scope="col">Type</th>
<th scope="col">Uploader</th>
<th scope="col">Created</th>
</tr>
</thead>
<tbody>
@foreach($uploads as $upload)
@php
$asset = $upload->asset;
@endphp
<tr class="align-middle">
<th scope="col"><a href="{{ $asset->getShopUrl() }}" class="text-decoration-none">{{ $asset->name }}</th>
<th scope="col">{{ $asset->typeString() }}</th>
<th scope="col">
<a href="{{ route('admin.useradmin', ['ID' => $asset->user->id]) }}" class="text-decoration-none">
<x-user-circle :user="$asset->user" :size=40 />
</a>
</th>
<th scope="col">{{ $upload->created_at->isoFormat('l LT') }}</th>
</tr>
@endforeach
</tbody>
</table>
{{ $uploads->links('pagination.virtubrick') }}
@else
<p class="text-muted p-3">No assets found.</p>
@endif
</div>
</div>
@endpush

View File

@ -0,0 +1,18 @@
@extends('layouts.admin')
@section('title', 'Upload Asset')
@section('page-specific')
<!-- Secure Page JS -->
<script src="{{ mix('js/adm/ManualAssetUpload.js') }}"></script>
@endsection
@push('content')
<div class="container-md">
<x-admin.navigation.asset-uploader />
<h4>Upload Asset</h4>
<div class="row" id="vb-manual-assetupload">
<x-loader />
</div>
</div>
@endpush

View File

@ -0,0 +1,13 @@
@extends('layouts.admin')
@section('title', 'Auto Uploader')
@push('content')
<div class="container-md">
<x-admin.navigation.asset-uploader />
<h4>Auto Uploader</h4>
<div class="card p-3">
</div>
</div>
@endpush

View File

@ -44,7 +44,11 @@ Route::middleware('auth')->group(function () {
Route::group(['as' => 'v1.', 'prefix' => 'v1'], function() {
Route::middleware('roleset:owner')->group(function () {
Route::get('/deploy', 'AdminController@deploy')->name('deploy');
Route::post('/deploy/{version}', 'AdminController@deployVersion')->name('deploy');
Route::post('/deploy/{version}', 'AdminController@deployVersion')->name('deployVersion');
});
Route::middleware('roleset:administrator')->group(function () {
Route::post('/manual-asset-upload', 'AdminController@manualAssetUpload')->name('manualAssetUpload')->middleware('throttle:6,2,adminassetupload');
});
// RCC Only

View File

@ -62,11 +62,15 @@ Route::group(['as' => 'admin.', 'prefix' => 'admin'], function() {
Route::get('/userlookuptool', 'AdminController@userLookup')->name('userlookup');
Route::post('/userlookuptool', 'AdminController@userLookupQuery')->name('userlookupquery');
});
Route::get('/auto-uploader', 'AdminController@autoUpload')->name('autoupload');
});
Route::middleware('roleset:administrator')->group(function () {
Route::group(['prefix' => 'catalog'], function() {
Route::get('/autoassetupload', 'AdminController@autoUpload')->name('autoupload');
Route::get('/manualassetupload', 'AdminController@assetUpload')->name('assetupload');
Route::get('/uploadedassets', 'AdminController@getAdminUploads')->name('adminuploads');
});
Route::get('/metrics', 'AdminController@metricsVisualization')->name('metricsvisualization');
Route::get('/arbiter-diag/{arbiterType?}', 'AdminController@arbiterDiag')->name('diag');

View File

@ -0,0 +1,14 @@
<roblox xmlns:xmime="http://www.w3.org/2005/05/xmlmime" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://www.roblox.com/roblox.xsd" version="4">
<External>null</External>
<External>nil</External>
<Item class="CharacterMesh" referent="RBX0">
<Properties>
<int name="BaseTextureId"></int>
<token name="BodyPart"></token>
<int name="MeshId"></int>
<string name="Name"></string>
<int name="OverlayTextureId"></int>
<bool name="archivable">true</bool>
</Properties>
</Item>
</roblox>

View File

@ -0,0 +1,14 @@
<roblox xmlns:xmime="http://www.w3.org/2005/05/xmlmime" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://www.roblox.com/roblox.xsd" version="4">
<External>null</External>
<External>nil</External>
<Item class="Decal" referent="RBX0">
<Properties>
<token name="Face">5</token>
<string name="Name">face</string>
<float name="Shiny">20</float>
<float name="Specular">0</float>
<Content name="Texture"></Content>
<bool name="archivable">true</bool>
</Properties>
</Item>
</roblox>

View File

@ -23,10 +23,6 @@
<category name="Private Servers">
<link name="Private Server Transactions" route="admin.dashboard" />
</category>
<category name="Catalog">
<link name="Auto Uploader" route="admin.autoupload" />
</category>
</list>
<list name="Administration" color="primary" roleset="Administrator">
@ -73,6 +69,12 @@
<category name="Universe Status">
<link name="Universe Status" route="admin.dashboard" />
</category>
<category name="Catalog">
<link name="Upload Asset" route="admin.assetupload" />
<link name="Auto Uploader" route="admin.autoupload" />
<link name="Uploaded Assets" route="admin.adminuploads" />
</category>
</list>
<list name="Owner" color="danger" roleset="Owner">

View File

@ -15,6 +15,7 @@ mix.js('resources/js/app.js', 'public/js')
.js('resources/js/pages/AvatarEditor.js', 'public/js')
.js('resources/js/pages/Item.js', 'public/js')
.js('resources/js/pages/Place.js', 'public/js')
.js('resources/js/pages/ManualAssetUpload.js', 'public/js/adm')
.js('resources/js/pages/ManualUserModeration.js', 'public/js/adm')
.js('resources/js/pages/AppDeployer.js', 'public/js/adm')
.js('resources/js/pages/SiteConfiguration.js', 'public/js/adm')