Shop, thumbnail, transactions, and admin changes.

- Fix shop view all button.
- Fix internal server error when accessing an asset page while logged out.
- User render busy icon.
- Virtubrick server-side rendering paginator.
- Pagination in user search.
- Details of punishments on user admin page.
- Updated some APIs to not return virtubrick.local thumbnail urls.
- Compacted admin user search and turned it into a GET request.
- Removed testing thumbnail from user circle component.
- Removed testing thumbnail from user admin page.
- Updated dashboard and profile to use new function for user thumbnails.
- Grammar edit on user admin page for "user is a (power type)" label.
- Made MarkdownHelper play nicely with site alerts.
- Created a transactions page and APIs to go along with it.
- Website can now render user thumbnails.
- Reworked avatar/closeup render scripts.
- Added a "userToJson" function to the user model for use in APIs that return user data.
This commit is contained in:
Graphictoria 2023-01-08 21:26:52 -05:00
parent c00e8ccc75
commit ad71c712b9
35 changed files with 884 additions and 83 deletions

View File

@ -10,19 +10,31 @@ namespace App\Helpers;
use Illuminate\Support\HtmlString;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\CommonMark\Node\Inline\Link;
use League\CommonMark\Extension\CommonMark\Node\Inline\Image;
use League\CommonMark\Extension\DefaultAttributes\DefaultAttributesExtension;
use League\CommonMark\Extension\Table\TableExtension;
use League\CommonMark\MarkdownConverter;
class MarkdownHelper
{
// XlXi: This bit was taken from https://github.com/laravel/framework/blob/b9203fca96960ef9cd8860cb4ec99d1279353a8d/src/Illuminate/Mail/Markdown.php line 106
// TODO: XlXi: Add a non-nav alert mode for links.
// XlXi: This bit was partially taken from https://github.com/laravel/framework/blob/b9203fca96960ef9cd8860cb4ec99d1279353a8d/src/Illuminate/Mail/Markdown.php line 106
public static function parse($text) {
$environment = new Environment([
//
'default_attributes' => [
Link::class => [
'class' => ['text-decoration-none']
],
Image::class => [
'classes' => ['img-fluid']
]
]
]);
$environment->addExtension(new CommonMarkCoreExtension);
$environment->addExtension(new TableExtension);
$environment->addExtension(new DefaultAttributesExtension);
$converter = new MarkdownConverter($environment);

View File

@ -48,7 +48,7 @@ class CommentsController extends Controller
foreach($comments as $comment) {
$poster = [
'name' => $comment->user->username,
'thumbnail' => 'https://www.virtubrick.local/images/testing/headshot.png',
'thumbnail' => $comment->user->getHeadshotImageUrl(),
'url' => $comment->user->getProfileUrl()
];

View File

@ -2,13 +2,14 @@
namespace App\Http\Controllers\Api;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use App\Http\Controllers\Controller;
use App\Models\Friend;
use App\Models\Shout;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class FeedController extends Controller
{
@ -31,19 +32,12 @@ class FeedController extends Controller
];
foreach($postsQuery as $post) {
// TODO: XlXi: icons/colors
// TODO: XlXi: groups
$poster = [];
if($post['poster_type'] == 'user') {
$user = User::where('id', $post['poster_id'])->first();
$poster = [
'type' => 'User',
'name' => $user->username,
'thumbnail' => 'https://www.virtubrick.local/images/testing/headshot.png',
'url' => $user->getProfileUrl()
];
$poster = $user->userToJson();
}
/* */

View File

@ -0,0 +1,148 @@
<?php
namespace App\Http\Controllers\Api;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use App\Helpers\ValidationHelper;
use App\Http\Controllers\Controller;
use App\Models\Transaction;
use App\Models\TransactionType;
use App\Models\User;
class MoneyController extends Controller
{
public function userSummary(Request $request)
{
$result = [
'columns' => [],
'total' => 0
];
$dataPoints = [
[
'Name' => 'Item Purchases',
'Points' => ['Purchases']
],
[
'Name' => 'Sale of Goods',
'Points' => ['Sales', 'Commissions']
],
[
'Name' => 'Group Payouts',
'Points' => ['Group Payouts']
]
];
foreach($dataPoints as $dataPoint)
{
$newColumn = ['name' => $dataPoint['Name'], 'total' => 0];
foreach($dataPoint['Points'] as $transactionType)
{
$newColumn['total'] += Transaction::where('user_id', Auth::user()->id)
->where('transaction_type_id', TransactionType::IDFromType($transactionType))
->where(function($query) use($request) {
if(!$request->has('filter'))
return $query;
$now = Carbon::now();
switch($request->get('filter'))
{
case 'pastday':
return $query->where('created_at', '>', $now->subDay());
case 'pastweek':
return $query->where('created_at', '>', $now->subWeek());
case 'pastmonth':
return $query->where('created_at', '>', $now->subMonth());
case 'pastyear':
return $query->where('created_at', '>', $now->subYear());
default:
return $query;
}
})
->sum('delta');
}
array_push($result['columns'], $newColumn);
$result['total'] += $newColumn['total'];
}
return response($result);
}
public function userTransactions(Request $request)
{
$validator = Validator::make($request->all(), [
'filter' => ['required', 'in:purchases,sales,commissions,grouppayouts']
]);
if($validator->fails())
return ValidationHelper::generateValidatorError($validator);
$valid = $validator->valid();
$resultData = [];
$transactionType = 0;
switch($valid['filter'])
{
case 'purchases':
$transactionType = TransactionType::where('name', 'Purchases')->first();
break;
case 'sales':
$transactionType = TransactionType::where('name', 'Sales')->first();
break;
case 'commissions':
$transactionType = TransactionType::where('name', 'Commissions')->first();
break;
case 'grouppayouts':
$transactionType = TransactionType::where('name', 'Group Payouts')->first();
break;
}
$transactions = Transaction::where(function($query) use($valid) {
if($valid['filter'] == 'sales')
return $query->where('seller_id', Auth::user()->id);
return $query->where('user_id', Auth::user()->id);
})
->where('transaction_type_id', $transactionType->id)
->orderByDesc('id')
->cursorPaginate(30);
$prevCursor = $transactions->previousCursor();
$nextCursor = $transactions->nextCursor();
foreach($transactions as $transaction)
{
$user = null;
if($valid['filter'] != 'sales')
$user = $transaction->seller;
else
$user = $transaction->user;
$asset = null;
if($transactionType->format != '')
$asset = [
'url' => route('shop.asset', ['asset' => $transaction->asset, 'assetName' => Str::slug($transaction->asset->name, '-')]),
'name' => $transaction->asset->name
];
array_push($resultData, [
'date' => $transaction->created_at->isoFormat('lll'),
'member' => $user->userToJson(),
'description' => $transactionType->format,
'amount' => $transaction->delta,
'item' => $asset
]);
}
return response([
'data' => $resultData,
'prev_cursor' => ($prevCursor ? $prevCursor->encode() : null),
'next_cursor' => ($nextCursor ? $nextCursor->encode() : null)
]);
}
}

View File

@ -41,7 +41,7 @@ class ThumbnailController extends Controller
];
}
private function handleRender(Request $request, string $renderType)
private function handleRender(Request $request, string $renderType, bool $assetId = null)
{
$validator = Validator::make($request->all(), $this->{strtolower($renderType) . 'ValidationRules'}());
@ -64,6 +64,24 @@ class ThumbnailController extends Controller
$valid['position'] = 'Full';
$valid['position'] = strtolower($valid['position']);
if($valid['position'] != 'full' && $valid['type'] == '3d')
{
$validator->errors()->add('type', 'Cannot render non-full avatar as 3D.');
return ValidationHelper::generateValidatorError($validator);
}
switch($valid['position'])
{
case 'full':
if($model->thumbnail2DHash && $valid['type'] == '2d')
return response(['status' => 'success', 'data' => route('content', $model->thumbnail2DHash)]);
break;
case 'bust':
if($model->thumbnailBustHash && $valid['type'] == '2d')
return response(['status' => 'success', 'data' => route('content', $model->thumbnailBustHash)]);
break;
}
} elseif($renderType == 'Asset') {
if($model->moderated)
return response(['status' => 'success', 'data' => '/thumbs/DeletedThumbnail.png']);
@ -78,16 +96,17 @@ class ThumbnailController extends Controller
$validator->errors()->add('id', 'This asset cannot be rendered.');
return ValidationHelper::generateValidatorError($validator);
}
if($model->thumbnail2DHash && $valid['type'] == '2d')
return response(['status' => 'success', 'data' => route('content', $model->thumbnail2DHash)]);
}
if($model->thumbnail2DHash && $valid['type'] == '2d')
return response(['status' => 'success', 'data' => route('content', $model->thumbnail2DHash)]);
if($model->thumbnail3DHash && $valid['type'] == '3d')
return response(['status' => 'success', 'data' => route('content', $model->thumbnail3DHash)]);
$trackerType = sprintf('%s%s', strtolower($renderType), $valid['type']);
if($renderType == 'User' && $valid['position'] == 'bust')
$trackerType .= 'bust';
$tracker = RenderTracker::where('type', $trackerType)
->where('target', $valid['id'])
->where('created_at', '>', Carbon::now()->subMinute());
@ -101,7 +120,7 @@ class ThumbnailController extends Controller
ArbiterRender::dispatch(
$tracker,
$valid['type'] == '3d',
($renderType == 'User' ? $valid['position'] : $model->typeString()),
($renderType == 'User' ? $valid['position'] == 'full' ? 'Avatar' : 'Bust' : $model->typeString()),
$model->id
);
}
@ -119,8 +138,22 @@ class ThumbnailController extends Controller
return $this->handleRender($request, 'User');
}
public function tryAsset()
public function tryAsset(Request $request)
{
//
$validator = Validator::make($request->all(), [
'id' => [
'required',
Rule::exists('App\Models\Asset', 'id')->where(function($query) {
return $query->where('moderated', false);
})
]
]);
if($validator->fails())
return ValidationHelper::generateValidatorError($validator);
$valid = $validator->valid();
return $this->handleRender($request, 'User', $valid['id']);
}
}

View File

@ -41,44 +41,63 @@ class AdminController extends Controller
}
// GET admin.usersearch
function userSearch()
function userSearch(Request $request)
{
$types = [
'userid' => 'UserId',
'username' => 'UserName',
'ipaddress' => 'IpAddress'
];
foreach($types as $type => &$func)
{
if($type == $request->has($type))
return $this->{'userSearchQuery' . $func}($request);
}
return view('web.admin.usersearch');
}
// POST admin.usersearchquery
function userSearchQuery(Request $request)
function userSearchQueryUserId(Request $request)
{
if($request->has('userid-button'))
{
$request->validate([
'userid-search' => ['required', 'int']
]);
return view('web.admin.usersearch')->with('users', User::where('id', $request->get('userid-search'))->get());
}
$request->validate([
'userid' => ['required', 'int']
]);
if($request->has('username-button'))
{
$request->validate([
'username-search' => ['required', 'string']
]);
return view('web.admin.usersearch')->with('users', User::where('username', 'like', '%' . $request->get('username-search') . '%')->get());
}
$users = User::where('id', $request->get('userid'))
->paginate(25)
->appends($request->all());
if($request->has('ipaddress-button'))
{
$request->validate([
'ipaddress-search' => ['required', 'ip']
]);
$result = UserIp::where('ipAddress', $request->get('ipaddress-search'))
->join('users', 'users.id', '=', 'user_ips.userId')
->orderBy('users.id', 'desc');
return view('web.admin.usersearch')->with('users', $result->get())->with('isIpSearch', true);
}
return view('web.admin.usersearch')->with('users', $users);
}
function userSearchQueryUserName(Request $request)
{
$request->validate([
'username' => ['required', 'string']
]);
return view('web.admin.usersearch')->with('error', 'Input validation failed.');
$users = User::where('username', 'like', '%' . $request->get('username') . '%')
->paginate(25)
->appends($request->all());
return view('web.admin.usersearch')->with('users', $users);
}
function userSearchQueryIpAddress(Request $request)
{
$request->validate([
'ipaddress' => ['required', 'ip']
]);
$users = UserIp::where('ipAddress', $request->get('ipaddress'))
->join('users', 'users.id', '=', 'user_ips.userId')
->orderBy('users.id', 'desc')
->paginate(25)
->appends($request->all());
return view('web.admin.usersearch')->with('users', $users)->with('isIpSearch', true);
}
// GET admin.userlookup

View File

@ -0,0 +1,15 @@
<?php
namespace App\Http\Controllers\Web;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
class AvatarController extends Controller
{
public function index()
{
return view('web.avatar.editor');
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class MoneyController extends Controller
{
public function transactions()
{
return view('web.money.transactions');
}
}

View File

@ -92,6 +92,16 @@ class ArbiterRender implements ShouldQueue
];
switch($this->type) {
case 'Bust':
$this->type = 'Closeup';
array_push($arguments, false); // Quadratic
array_push($arguments, 30); // Base Hat Zoom
array_push($arguments, 100); // Max Hat Zoom
array_push($arguments, 0); // Camera Offset X
array_push($arguments, 0); // Camera Offset Y
case 'Avatar':
$arguments[0] = url('test', ['id' => $this->assetId]); // TODO: this
break;
case 'Head':
case 'Shirt':
case 'Pants':
@ -185,7 +195,10 @@ class ArbiterRender implements ShouldQueue
->resize($arguments[2]/4, $arguments[3]/4)
->toString();
$this->tracker->targetObj->set2DHash(CdnHelper::SaveContentB64(base64_encode($image), 'image/png'));
if($this->type == 'Closeup')
$this->tracker->targetObj->setBustHash(CdnHelper::SaveContentB64(base64_encode($image), 'image/png'));
else
$this->tracker->targetObj->set2DHash(CdnHelper::SaveContentB64(base64_encode($image), 'image/png'));
}
$this->tracker->delete();

View File

@ -21,7 +21,7 @@ class RenderTracker extends Model
public function targetObj()
{
if($this->type == 'user2d' || $this->type == 'user3d')
if($this->type == 'user2d' || $this->type == 'user2dbust' || $this->type == 'user3d')
return $this->belongsTo(User::class, 'target');
elseif($this->type == 'asset2d' || $this->type == 'asset3d')
return $this->belongsTo(Asset::class, 'target');

View File

@ -9,6 +9,16 @@ class Transaction 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.
*
@ -23,9 +33,25 @@ class Transaction extends Model
'seller_id'
];
public function user()
{
return $this->belongsTo(User::class, 'user_id');
}
public function seller()
{
return $this->belongsTo(User::class, 'seller_id');
}
public function asset()
{
return $this->belongsTo(Asset::class, 'asset_id');
}
protected static function createPurchase($transaction)
{
return self::create(array_merge($transaction, [
'delta' => -$transaction->delta,
'transaction_type_id' => TransactionType::where('name', 'Purchases')->first()->id
]));
}

View File

@ -8,4 +8,9 @@ use Illuminate\Database\Eloquent\Model;
class TransactionType extends Model
{
use HasFactory;
public static function IDFromType($type)
{
return self::where('name', $type)->first()->id;
}
}

View File

@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Laravel\Sanctum\HasApiTokens;
use App\Notifications\ResetPasswordNotification;
@ -166,6 +167,59 @@ class User extends Authenticatable implements MustVerifyEmail
$this->save();
}
public function getImageUrl()
{
$renderId = $this->id;
$thumbnail = Http::get(route('thumbnails.v1.user', ['id' => $renderId, 'position' => 'full', 'type' => '2d']));
if($thumbnail->json('status') == 'loading')
return '/images/busy/user.png';
return $thumbnail->json('data');
}
public function getHeadshotImageUrl()
{
$renderId = $this->id;
$thumbnail = Http::get(route('thumbnails.v1.user', ['id' => $renderId, 'position' => 'bust', 'type' => '2d']));
if($thumbnail->json('status') == 'loading')
return '/images/busy/user.png';
return $thumbnail->json('data');
}
public function set2DHash($hash)
{
$this->thumbnail2DHash = $hash;
$this->timestamps = false;
$this->save();
}
public function setBustHash($hash)
{
$this->thumbnailBustHash = $hash;
$this->timestamps = false;
$this->save();
}
public function set3DHash($hash)
{
$this->thumbnail3DHash = $hash;
$this->timestamps = false;
$this->save();
}
public function userToJson()
{
return [
'type' => 'User',
'name' => $this->username,
'thumbnail' => $this->getHeadshotImageUrl(),
'url' => $this->getProfileUrl()
];
}
public function _hasRolesetInternal($roleName)
{
$roleset = Roleset::where('Name', $roleName)->first();

View File

@ -143,7 +143,7 @@ class Asset extends Model
$thumbnail = Http::get(route('thumbnails.v1.asset', ['id' => $renderId, 'type' => '2d']));
if($thumbnail->json('status') == 'loading')
return ($this->assetTypeId == 9 ? 'https://virtubrick.local/images/busy/game.png' : 'https://virtubrick.local/images/busy/asset.png');
return ($this->assetTypeId == 9 ? '/images/busy/game.png' : '/images/busy/asset.png');
return $thumbnail->json('data');
}

View File

@ -26,6 +26,7 @@ return new class extends Migration
$table->unsignedBigInteger('tokens')->default(0);
$table->dateTime('next_reward')->useCurrent();
$table->string('thumbnailBustHash')->nullable();
$table->string('thumbnail2DHash')->nullable();
$table->string('thumbnail3DHash')->nullable();

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -127,6 +127,7 @@ class ShopCategoryButton extends Component {
let categoryAssetTypeIds = this.props.getCategoryAssetTypeIds(categoryName);
switch(typeof(categoryAssetTypeIds.assetTypeId)) {
case 'string':
case 'number':
assetTypes[categoryAssetTypeIds.assetTypeId] = true;
break;

View File

@ -0,0 +1,328 @@
/*
Copyright © XlXi 2022
*/
import { Component, createRef } from 'react';
import axios from 'axios';
import { buildGenericApiUrl } from '../util/HTTP.js';
import Loader from './Loader';
axios.defaults.withCredentials = true;
class SummaryTab extends Component {
constructor(props) {
super(props);
this.state = {
loaded: false,
filters: [
{label: 'Past Day', tag: 'pastday', ref: createRef()},
{label: 'Past Week', tag: 'pastweek', ref: createRef()},
{label: 'Past Month', tag: 'pastmonth', ref: createRef()},
{label: 'Past Year', tag: 'pastyear', ref: createRef()},
{label: 'All Time', tag: 'all', ref: createRef()}
],
items: [],
total: null
};
this.categoryDropdown = createRef();
this.setTag = this.setTag.bind(this);
}
componentDidMount() {
this.setTag('pastday');
}
setTag(tag) {
this.setState({ loaded: false });
let selectedTag = this.state.filters.find(obj => {
return obj.tag === tag
});
this.categoryDropdown.current.innerText = selectedTag.label;
this.state.filters.map(({ label, tag, ref }) => {
if(ref.current.innerText == selectedTag.label)
ref.current.classList.add('active');
else
ref.current.classList.remove('active');
});
axios.get(buildGenericApiUrl('api', `shop/v1/user-summary?filter=${tag}`))
.then(res => {
const response = res.data;
this.setState({ items: response.columns, total: response.total, loaded: true });
});
}
render() {
return (
<div>
<p className="d-inline-block me-2">Time Period:</p>
<button className="btn btn-secondary dropdown-toggle" type="button" disabled={ !this.state.loaded } ref={ this.categoryDropdown } data-bs-toggle="dropdown" aria-expanded="false">
...
</button>
<ul className="dropdown-menu">
{
this.state.filters.map(({ label, tag, ref }) =>
<li><button className="dropdown-item" ref={ ref } onClick={ () => this.setTag(tag) }>{ label }</button></li>
)
}
</ul>
{
this.state.loaded
?
<>
<p className="virtubrick-tokens">Tokens</p>
<table className="table virtubrick-table mt-2">
<thead>
<tr>
<th scope="col">Categories</th>
<th scope="col">Credit</th>
</tr>
</thead>
<tbody>
{
this.state.items.map(({ name, total }, index) =>
<tr className="align-middle">
<th scope="col">{ name }</th>
<th scope="col">{ total != 0 ? <span className="virtubrick-tokens">{ total }</span> : null }</th>
</tr>
)
}
</tbody>
</table>
<p className="text-right">Total <span className="virtubrick-tokens">{ this.state.total }</span></p>
</>
:
<div className="d-flex">
<Loader />
</div>
}
</div>
)
}
}
class TransactionsTab extends Component {
constructor(props) {
super(props);
this.state = {
loaded: false,
loadingMore: false,
filters: [
{label: 'Purchases', tag: 'purchases', ref: createRef()},
{label: 'Sales', tag: 'sales', ref: createRef()},
{label: 'Commissions', tag: 'commissions', ref: createRef()},
{label: 'Group Payouts', tag: 'grouppayouts', ref: createRef()}
],
items: []
};
this.categoryDropdown = createRef();
this.setTag = this.setTag.bind(this);
this.loadMore = this.loadMore.bind(this);
}
componentWillMount() {
window.addEventListener('scroll', this.loadMore);
}
componentWillUnmount() {
window.removeEventListener('scroll', this.loadMore);
}
componentDidMount() {
this.setTag('purchases');
}
setTag(tag) {
this.setState({ loaded: false });
let selectedTag = this.state.filters.find(obj => {
return obj.tag === tag
});
this.categoryDropdown.current.innerText = selectedTag.label;
this.state.filters.map(({ label, tag, ref }) => {
if(ref.current.innerText == selectedTag.label)
ref.current.classList.add('active');
else
ref.current.classList.remove('active');
});
this.nextCursor = null;
axios.get(buildGenericApiUrl('api', `shop/v1/user-transactions?filter=${tag}`))
.then(res => {
const response = res.data;
this.nextCursor = response.next_cursor;
this.setState({ items: response.data, filter: tag, loaded: true });
});
}
loadMore() {
// XlXi: Taking the height of the footer into account.
if (window.innerHeight + document.documentElement.scrollTop >= document.scrollingElement.scrollHeight-200) {
if (!!(this.nextCursor) && !this.state.loading && !this.state.loadingMore) {
this.setState({ loadingMore: true });
axios.get(buildGenericApiUrl('api', `shop/v1/user-transactions?filter=${this.state.filter}&cursor=${this.nextCursor}`))
.then(res => {
const response = res.data;
this.nextCursor = response.next_cursor;
this.setState({ items: this.state.items.concat(response.data), loadingMore: false });
});
}
}
}
render() {
return (
<div>
<p className="d-inline-block me-2">Currently Selected:</p>
<button className="btn btn-secondary dropdown-toggle" type="button" disabled={ !this.state.loaded } ref={ this.categoryDropdown } data-bs-toggle="dropdown" aria-expanded="false">
...
</button>
<ul className="dropdown-menu">
{
this.state.filters.map(({ label, tag, ref }) =>
<li><button className="dropdown-item" ref={ ref } onClick={ () => this.setTag(tag) }>{ label }</button></li>
)
}
</ul>
{
this.state.loaded
?
<>
<p className="virtubrick-tokens">Tokens</p>
<table className="table virtubrick-table mt-2">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Member</th>
<th scope="col">Description</th>
<th scope="col">Amount</th>
</tr>
</thead>
<tbody>
{
this.state.items.map(({ date, member, description, amount, item }, index) =>
<tr className="align-middle">
<th scope="col">{ date }</th>
<th scope="col"><a href={ member.url } className="text-decoration-none">{ member.name }</a></th>
<th scope="col">{ description }{ item != null ? <a href={ item.url } className="text-decoration-none">{ item.name }</a> : null }</th>
<th scope="col"><p className="virtubrick-tokens">{ amount }</p></th>
</tr>
)
}
</tbody>
</table>
{
this.state.loadingMore
?
<div className="d-flex">
<Loader />
</div>
:
null
}
</>
:
<div className="d-flex">
<Loader />
</div>
}
</div>
)
}
}
class Transactions extends Component {
constructor(props) {
super(props);
this.state = {
loaded: false,
tabs: [
{label: 'Summary', name: 'summary', ref: createRef()},
{label: 'My Transactions', name: 'transactions', ref: createRef()}
]
};
this.tabIndex = 0;
this.setCurrentTab = this.setCurrentTab.bind(this);
this.setTab = this.setTab.bind(this);
}
componentDidMount() {
const params = new Proxy(new URLSearchParams(window.location.search), {
get: (searchParams, prop) => searchParams.get(prop),
});
let queryTab = this.state.tabs.find(obj => {
if(!params.tab)
return false;
return obj.name === params.tab.toLowerCase();
});
if(queryTab)
this.setTab(queryTab.name);
else
this.setTab('summary');
}
setCurrentTab(instance)
{
this.currentTab = instance;
this.setState({loaded: true});
}
setTab(tabType) {
this.setState({loaded: false});
this.tabIndex += 1;
switch(tabType)
{
case 'summary':
this.setCurrentTab(<SummaryTab key={this.tabIndex} />);
break;
case 'transactions':
this.setCurrentTab(<TransactionsTab key={this.tabIndex} />);
break;
}
this.state.tabs.map(({ name, ref }) => {
if(name == tabType)
ref.current.classList.add('active');
else
ref.current.classList.remove('active');
});
}
render() {
return (
<>
<ul className="nav nav-tabs">
{
this.state.tabs.map(({ label, name, ref }) =>
<li className="nav-item">
<button className="nav-link" onClick={ () => this.setTab(name) } ref={ ref }>{ label }</button>
</li>
)
}
</ul>
<div className="card p-2">
{ this.state.loaded ? this.currentTab : <Loader /> }
</div>
</>
);
}
}
export default Transactions;

View File

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

View File

@ -9,21 +9,20 @@
@if(!isset($nolabel) || !$nolabel)
<label for="vb-{{ $id }}-search" class="form-label">{{ $definition }}</label>
@endif
<form method="POST" action="{{ route('admin.usersearch') }}" enctype="multipart/form-data">
@csrf
<form method="GET" action="{{ route('admin.usersearch') }}">
<div class="input-group mb-3">
<input
type="text"
class="form-control"
placeholder="{{ $definition }} Here"
aria-label="{{ $definition }} Here"
name="{{ $id }}-search" id="vb-{{ $id }}-search"
name="{{ $id }}" id="vb-{{ $id }}-search"
aria-describedby="vb-{{ $id }}-search-btn"
@if(isset($value))
value="{{ $value }}"
@endif
>
<button type="submit" class="btn btn-primary" type="button" name="{{ $id }}-button" id="vb-{{ $id }}-search-btn">Search</button>
<button type="submit" class="btn btn-primary" type="button" id="vb-{{ $id }}-search-btn">Search</button>
</div>
</form>
</div>

View File

@ -24,9 +24,8 @@
@endphp
<span class="d-flex align-items-center">
{{-- TODO: XlXi: User headshots --}}
<img
src="{{ asset('images/testing/headshot.png') }}"
src="{{ $user->getHeadshotImageUrl() }}"
@class($classes)
width="{{ $size }}"
height="{{ $size }}"

View File

@ -166,19 +166,23 @@
</li>
</ul>
<div class="d-md-flex">
<p class="my-auto me-2 virtubrick-tokens" data-bs-toggle="tooltip" data-bs-placement="bottom" title="You have {{ number_format(Auth::user()->tokens) }} tokens. Your next reward is in {{ Auth::user()->next_reward->diffForHumans(['syntax' => Carbon\CarbonInterface::DIFF_ABSOLUTE]) }}.">
{{ \App\Helpers\NumberHelper::Abbreviate(Auth::user()->tokens) }}
</p>
<a href="{{ route('user.transactions') }}" class="my-auto me-2 text-decoration-none">
<span>
<p class="virtubrick-tokens" href="" data-bs-toggle="tooltip" data-bs-placement="bottom" title="You have {{ number_format(Auth::user()->tokens) }} tokens. Your next reward is in {{ Auth::user()->next_reward->diffForHumans(['syntax' => Carbon\CarbonInterface::DIFF_ABSOLUTE]) }}.">
{{ \App\Helpers\NumberHelper::Abbreviate(Auth::user()->tokens) }}
</p>
</span>
</a>
<div class="dropdown">
<a class="nav-link dropdown-toggle virtubrick-user-dropdown px-0 px-md-3" href="#" id="virtubrick-user-dropdown" role="button" data-bs-toggle="dropdown" area-expanded="false">
<x-user-circle :user="Auth::user()" :statusIndicator=false />
</a>
<ul class="dropdown-menu virtubrick-user-dropdown" area-labelledby="virtubrick-user-dropdown">
<li><a class="dropdown-item" href="{{ Auth::user()->getProfileUrl() }}">Profile</a></li>
<li><a class="dropdown-item" href="{{ url('/todo123') }}">Character</a></li>
<li><a class="dropdown-item" href="{{ url('/my/settings') }}">Settings</a></li>
<li><a class="dropdown-item" href="{{ route('user.avatarEditor') }}">Character</a></li>
<li><a class="dropdown-item" href="{{ route('user.settings') }}">Settings</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url('/logout') }}">Sign out</a></li>
<li><a class="dropdown-item" href="{{ route('auth.logout') }}">Sign out</a></li>
</ul>
</div>
</div>

View File

@ -0,0 +1,13 @@
@if ($paginator->hasPages())
<ul class="list-inline mx-auto mt-3">
<li class="list-inline-item">
<a @class(['btn', 'btn-secondary', 'disabled' => $paginator->onFirstPage()]) {!! !$paginator->onFirstPage() ? 'href="' . $paginator->previousPageUrl() . '"' : '' !!}><i class="fa-solid fa-angle-left"></i></a>
</li>
<li class="list-inline-item virtubrick-paginator">
<span>Page {{ $paginator->currentPage() }} of {{ $paginator->lastPage() }}</span>
</li>
<li class="list-inline-item">
<a @class(['btn', 'btn-secondary', 'disabled' => !$paginator->hasMorePages()]) {!! $paginator->hasMorePages() ? 'href="' . $paginator->nextPageUrl() . '"' : '' !!}><i class="fa-solid fa-angle-right"></i></a>
</li>
</ul>
@endif

View File

@ -56,7 +56,7 @@
'virtubrick-error-popup',
'mt-3' => !$isProtected
])>
This user is a {{ $powerType }}.
This user is a(n) {{ $powerType }}.
</div>
@endif
<x-admin.user-admin-label label="Username">{{ $user->username }}</x-admin.user-admin-label>
@ -71,7 +71,7 @@
<x-admin.user-admin-label label="Current Location">Website <b>TODO</b></x-admin.user-admin-label>
<div class="row py-2 border-top">
<div class="col-6">
<img src="{{ asset('/images/testing/avatar.png') }}" width="200" height="200" class="img-fluid vb-charimg" />
<img src="{{ $user->getImageUrl() }}" width="200" height="200" class="img-fluid vb-charimg" />
</div>
<div class="col-6">
<a href="{{ $user->getProfileUrl() }}" class="text-decoration-none">User Homepage</a><br/>
@ -85,6 +85,7 @@
<table class="table virtubrick-table">
<thead>
<tr>
<th scope="col"></th>
<th scope="col">ID</th>
<th scope="col">Action</th>
<th scope="col">Moderator</th>
@ -96,7 +97,12 @@
<tbody>
@foreach($user->punishments as $punishment)
<tr>
<th scope="col">{{ $punishment->id }}</th>
<th scope="col">
<button class="btn btn-sm p-0 px-1 text-decoration-none" type="button" data-bs-toggle="collapse" data-bs-target="#punishment-collapse-{{ $punishment->id }}" aria-expanded="false" aria-controls="punishment-collapse-{{ $punishment->id }}">
<i class="fa-solid fa-bars"></i>
</button>
</th>
<th scope="col">&nbsp;{{ $punishment->id }}</th>
<th scope="col">{{ $punishment->punishment_type->label }}</th>
<th scope="col">
<a href="{{ route('admin.useradmin', ['ID' => $punishment->moderator->id]) }}" class="text-decoration-none">
@ -107,6 +113,27 @@
<th scope="col">{{ $punishment->expirationStr() }}</th>
<th scope="col">{{ $punishment->active ? 'No' : 'Yes' }}</th>
</tr>
<tr class="collapse" id="punishment-collapse-{{ $punishment->id }}">
<td colspan="7" class="bg-secondary">
<div class="mx-2">
<p><b>Note to User:</b> {{ $punishment->user_note }}</p>
@if($punishment->context->count() > 0)
<p><b>Abuses:</b></p>
@endif
@foreach($punishment->context as $context)
<div class="card bg-secondary p-2 mb-2 border-1">
<p><b>Reason:</b> {{ $context->user_note }} (<a href="#" class="text-decoration-none">TODO: audit</a>)</p>
@if($context->description)
<p><b>Offensive Item:</b> {{ $context->description }}</p>
@endif
@if($context->content_hash)
<img src="{{ route('content', $context->content_hash) }}" class="img-fluid" width="210" height="210"/>
@endif
</div>
@endforeach
</div>
</td>
</tr>
@endforeach
</tbody>
</table>

View File

@ -83,6 +83,7 @@
@endforeach
</tbody>
</table>
{{ $users->links('pagination.virtubrick') }}
</div>
@endif
</div>

View File

@ -0,0 +1,48 @@
@extends('layouts.app')
@section('title', 'Avatar Editor')
@section('content')
<div class="container my-2">
<h4>Avatar Editor</h4>
<div class="row mt-2">
<div class="col-3">
<div class="card text-center" id="vb-character-thumbnail">
<img src="{{ Auth::user()->getImageUrl() }}" class="img-fluid vb-charimg" />
</div>
<h4 class="mt-3">Colors</h4>
<div class="card p-2" id="vb-character-colors">
<x-loader />
</div>
</div>
<div class="col-9">
<ul class="nav nav-tabs" id="vb-character-tabs">
<li class="nav-item">
<button class="nav-link active disabled" disabled>Wardrobe</button>
</li>
<li class="nav-item">
<button class="nav-link disabled" disabled>Outfits</button>
</li>
</ul>
<div class="card p-2" id="vb-character-editor">
<x-loader />
</div>
<h4 class="mt-3">Currently Wearing</h4>
<div class="card p-2" id="vb-character-wearing">
<x-loader />
</div>
</div>
</div>
<p>Todo:</p>
<ul>
<li>Character Preview</li>
<li>3d Character Preview</li>
<li>Redraw button</li>
<li>Section for changing body colors</li>
<li>Section for wearing items</li>
<li>Section for taking off worn items</li>
</ul>
</div>
@endsection

View File

@ -12,7 +12,7 @@
<div class="col-md-3">
<h4>Hello, {{ Auth::user()->username }}!</h4>
<div class="card text-center">
<img src="{{ asset('/images/testing/avatar.png') }}" class="img-fluid vb-charimg" />
<img src="{{ Auth::user()->getImageUrl() }}" class="img-fluid vb-charimg" />
</div>
<h4 class="mt-3">Blog</h4>

View File

@ -0,0 +1,26 @@
@extends('layouts.app')
@section('title', 'Transactions')
@section('page-specific')
<script src="{{ mix('js/Transactions.js') }}"></script>
@endsection
@section('content')
<div class="container my-2">
<h4 class="mb-2">Transactions</h4>
<div id="vb-transactions">
<ul class="nav nav-tabs">
<li class="nav-item">
<button class="nav-link active disabled" disabled>Summary</button>
</li>
<li class="nav-item">
<button class="nav-link disabled" disabled>My Transactions</button>
</li>
</ul>
<div class="card p-2">
<x-loader />
</div>
</div>
</div>
@endsection

View File

@ -81,7 +81,7 @@
<h3 class="mb-0">{{ $asset->name }}</h3>
<p>
By <a class="text-decoration-none fw-normal" href="{{ $asset->user->getProfileUrl() }}">{{ $asset->user->username }}</a>
@if(Auth::user()->hasAsset($asset->id))
@if(Auth::user() && Auth::user()->hasAsset($asset->id))
<span>
&nbsp;
&nbsp;

View File

@ -18,7 +18,7 @@
<div class="card p-2">
<div class="d-flex">
<div class="pe-3">
<img class="img-fluid border virtubrick-user-circle m-1" src="{{ asset('/images/testing/headshot.png') }}" alt="User avatar of {{ $user->username }}" width="120px" />
<img class="img-fluid border virtubrick-user-circle m-1" src="{{ $user->getHeadshotImageUrl() }}" alt="User avatar of {{ $user->username }}" width="120px" />
</div>
<div class="flex-fill d-flex flex-column p-2">
{{-- TODO: XlXi: Advanced presence --}}

View File

@ -51,6 +51,9 @@ Route::group(['as' => 'shop.', 'prefix' => 'shop'], function() {
Route::group(['as' => 'v1.', 'prefix' => 'v1'], function() {
Route::get('/list-json', 'ShopController@listJson')->name('list');
Route::post('/purchase/{asset}', 'ShopController@purchase')->name('purchase');
Route::get('/user-summary', 'MoneyController@userSummary')->name('summary');
Route::get('/user-transactions', 'MoneyController@userTransactions')->name('transactions');
});
});

View File

@ -35,7 +35,14 @@ Route::group(['as' => 'games.', 'prefix' => 'games'], function() {
Route::middleware('auth')->group(function () {
Route::group(['as' => 'user.', 'prefix' => 'my'], function() {
Route::get('/settings', 'SettingsController@index')->name('index');
Route::get('/settings', 'SettingsController@index')->name('settings');
Route::get('/avatar', 'AvatarController@index')->name('avatarEditor');
Route::get('/transactions', 'MoneyController@transactions')->name('transactions');
});
Route::group(['as' => 'punishment.', 'prefix' => 'membership'], function() {
Route::get('/not-approved', 'ModerationController@notice')->name('notice');
Route::post('/not-approved', 'ModerationController@reactivate')->name('reactivate');
});
});
@ -50,7 +57,6 @@ Route::group(['as' => 'admin.', 'prefix' => 'admin'], function() {
Route::group(['prefix' => 'users'], function() {
Route::get('/useradmin', 'AdminController@userAdmin')->name('useradmin');
Route::get('/find', 'AdminController@userSearch')->name('usersearch');
Route::post('/find', 'AdminController@userSearchQuery')->name('usersearchquery');
Route::get('/userlookuptool', 'AdminController@userLookup')->name('userlookup');
Route::post('/userlookuptool', 'AdminController@userLookupQuery')->name('userlookupquery');
});
@ -118,13 +124,6 @@ Route::group(['as' => 'auth.', 'namespace' => 'Auth'], function() {
});
});
Route::group(['as' => 'punishment.', 'prefix' => 'membership'], function() {
Route::middleware('auth')->group(function () {
Route::get('/not-approved', 'ModerationController@notice')->name('notice');
Route::post('/not-approved', 'ModerationController@reactivate')->name('reactivate');
});
});
Route::withoutMiddleware(['csrf'])->group(function () {
Route::group(['as' => 'client.'], function() {
Route::get('/asset', 'ClientController@asset')->name('asset');

View File

@ -1,4 +1,4 @@
local characterAppearanceUrl, baseUrl, fileExtension, x, y = ...
local characterAppearanceUrl, fileExtension, x, y, baseUrl = ...
pcall(function() game:GetService("ContentProvider"):SetBaseUrl(baseUrl) end)
game:GetService("ContentProvider"):SetThreadPool(16)

View File

@ -1,4 +1,4 @@
local baseUrl, characterAppearanceUrl, fileExtension, x, y, quadratic, baseHatZoom, maxHatZoom, cameraOffsetX, cameraOffsetY = ...
local characterAppearanceUrl, fileExtension, x, y, baseUrl, quadratic, baseHatZoom, maxHatZoom, cameraOffsetX, cameraOffsetY = ...
pcall(function() game:GetService("ContentProvider"):SetBaseUrl(baseUrl) end)
game:GetService("ContentProvider"):SetThreadPool(16)

View File

@ -11,6 +11,7 @@ mix.js('resources/js/app.js', 'public/js')
.js('resources/js/pages/Dashboard.js', 'public/js')
.js('resources/js/pages/Shop.js', 'public/js')
.js('resources/js/pages/Games.js', 'public/js')
.js('resources/js/pages/Transactions.js', 'public/js')
.js('resources/js/pages/Item.js', 'public/js')
.js('resources/js/pages/Place.js', 'public/js')
.js('resources/js/pages/AppDeployer.js', 'public/js/adm')