diff --git a/app/Http/Controllers/TeamsController.php b/app/Http/Controllers/TeamsController.php new file mode 100644 index 00000000000..81200002ff9 --- /dev/null +++ b/app/Http/Controllers/TeamsController.php @@ -0,0 +1,26 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace App\Http\Controllers; + +use App\Models\Team; +use App\Transformers\UserCompactTransformer; +use Symfony\Component\HttpFoundation\Response; + +class TeamsController extends Controller +{ + public function show(string $id): Response + { + $team = Team + ::with(array_map( + fn (string $preload): string => "members.user.{$preload}", + UserCompactTransformer::CARD_INCLUDES_PRELOAD, + ))->findOrFail($id); + + return ext_view('teams.show', compact('team')); + } +} diff --git a/app/Models/Team.php b/app/Models/Team.php new file mode 100644 index 00000000000..00b2be5d963 --- /dev/null +++ b/app/Models/Team.php @@ -0,0 +1,82 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace App\Models; + +use App\Libraries\BBCodeForDB; +use App\Libraries\Uploader; +use Illuminate\Database\Eloquent\Relations\HasMany; + +class Team extends Model +{ + protected $casts = ['is_open' => 'bool']; + + private Uploader $header; + private Uploader $logo; + + public function applications(): HasMany + { + return $this->hasMany(TeamApplication::class); + } + + public function leader(): BelongsTo + { + return $this->belongsTo(User::class, 'leader_id'); + } + + public function members(): HasMany + { + return $this->hasMany(TeamMember::class); + } + + public function setHeaderAttribute(?string $value): void + { + if ($value === null) { + $this->header()->delete(); + } else { + $this->header()->store($value); + } + } + + public function setLogoAttribute(?string $value): void + { + if ($value === null) { + $this->logo()->delete(); + } else { + $this->logo()->store($value); + } + } + + public function descriptionHtml(): string + { + $description = presence($this->description); + + return $description === null + ? '' + : bbcode((new BBCodeForDB($description))->generate()); + } + + public function header(): Uploader + { + return $this->header ??= new Uploader( + 'teams/header', + $this, + 'header_file', + ['image' => ['maxDimensions' => [1000, 250]]], + ); + } + + public function logo(): Uploader + { + return $this->logo ??= new Uploader( + 'teams/logo', + $this, + 'logo_file', + ['image' => ['maxDimensions' => [256, 128]]], + ); + } +} diff --git a/app/Models/TeamMember.php b/app/Models/TeamMember.php new file mode 100644 index 00000000000..d2510426fa1 --- /dev/null +++ b/app/Models/TeamMember.php @@ -0,0 +1,27 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace App\Models; + +use Illuminate\Database\Eloquent\Relations\BelongsTo; + +class TeamMember extends Model +{ + public $incrementing = false; + + protected $primaryKey = 'user_id'; + + public function team(): BelongsTo + { + return $this->belongsTo(Team::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 845541d35e1..4b951acefbf 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -127,6 +127,7 @@ * @property-read Collection $storeAddresses * @property-read Collection $supporterTagPurchases * @property-read Collection $supporterTags + * @property-read TeamMember|null $teamMembership * @property-read Collection $tokens * @property-read Collection $topicWatches * @property-read Collection $userAchievements @@ -296,6 +297,11 @@ public function userCountryHistory(): HasMany return $this->hasMany(UserCountryHistory::class); } + public function teamMembership(): HasOne + { + return $this->hasOne(TeamMember::class, 'user_id'); + } + public function getAuthPassword() { return $this->user_password; @@ -951,6 +957,7 @@ public function getAttribute($key) 'storeAddresses', 'supporterTagPurchases', 'supporterTags', + 'teamMembership', 'tokens', 'topicWatches', 'userAchievements', diff --git a/app/helpers.php b/app/helpers.php index bd987785574..cedee6ca65b 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -922,6 +922,7 @@ function page_title() $namespaceKey = "{$currentRoute['namespace']}._"; $namespaceKey = match ($namespaceKey) { 'admin_forum._' => 'admin._', + 'teams._' => 'main.teams_controller._', default => $namespaceKey, }; $keys = [ @@ -1092,7 +1093,7 @@ function wiki_url($path = null, $locale = null, $api = null, $fullUrl = true) return rtrim(str_replace($params['path'], $path, route($route, $params, $fullUrl)), '/'); } -function bbcode($text, $uid, $options = []) +function bbcode($text, $uid = null, $options = []) { return (new App\Libraries\BBCodeFromDB($text, $uid, $options))->toHTML(); } diff --git a/database/factories/TeamFactory.php b/database/factories/TeamFactory.php new file mode 100644 index 00000000000..95b367ec93b --- /dev/null +++ b/database/factories/TeamFactory.php @@ -0,0 +1,32 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace Database\Factories; + +use App\Models\Team; +use App\Models\User; + +class TeamFactory extends Factory +{ + protected $model = Team::class; + + public function configure(): static + { + return $this->afterCreating(function (Team $team): void { + $team->members()->create(['user_id' => $team->leader_id]); + }); + } + + public function definition(): array + { + return [ + 'name' => fn () => $this->faker->name(), + 'short_name' => fn () => $this->faker->domainWord(), + 'leader_id' => User::factory(), + ]; + } +} diff --git a/database/migrations/2024_11_30_000000_create_teams.php b/database/migrations/2024_11_30_000000_create_teams.php new file mode 100644 index 00000000000..69014c6e94f --- /dev/null +++ b/database/migrations/2024_11_30_000000_create_teams.php @@ -0,0 +1,39 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::create('teams', function (Blueprint $table) { + $table->id(); + $table->string('name')->nullable(false); + $table->string('short_name')->nullable(false); + $table->string('logo_file')->nullable(true); + $table->string('header_file')->nullable(true); + $table->string('url')->nullable(true); + $table->text('description')->nullable(true); + $table->boolean('is_open')->nullable(false)->default(true); + $table->smallInteger('default_ruleset_id')->nullable(false)->default(0); + $table->bigInteger('leader_id')->nullable(false); + $table->timestampTz('created_at')->useCurrent(); + $table->timestampTz('updated_at')->useCurrent(); + + $table->unique('name'); + $table->unique('short_name'); + }); + } + + public function down(): void + { + Schema::dropIfExists('teams'); + } +}; diff --git a/database/migrations/2024_11_30_000001_create_team_members.php b/database/migrations/2024_11_30_000001_create_team_members.php new file mode 100644 index 00000000000..2fd17f70d14 --- /dev/null +++ b/database/migrations/2024_11_30_000001_create_team_members.php @@ -0,0 +1,30 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::create('team_members', function (Blueprint $table) { + $table->unsignedBigInteger('user_id')->primary(); + $table->unsignedBigInteger('team_id')->nullable(false); + $table->timestampTz('created_at')->useCurrent(); + $table->timestampTz('updated_at')->useCurrent(); + + $table->index('team_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('team_members'); + } +}; diff --git a/resources/css/bem-index.less b/resources/css/bem-index.less index 726d15a5efc..9ab4c02c49f 100644 --- a/resources/css/bem-index.less +++ b/resources/css/bem-index.less @@ -380,6 +380,10 @@ @import "bem/supporter-promo"; @import "bem/supporter-quote"; @import "bem/supporter-status"; +@import "bem/team-info-entries"; +@import "bem/team-info-entry"; +@import "bem/team-members"; +@import "bem/team-summary"; @import "bem/textual-button"; @import "bem/title"; @import "bem/tooltip-achievement"; diff --git a/resources/css/bem/profile-info.less b/resources/css/bem/profile-info.less index 347e30a0183..7cd9658deeb 100644 --- a/resources/css/bem/profile-info.less +++ b/resources/css/bem/profile-info.less @@ -2,17 +2,26 @@ // See the LICENCE file in the repository root for full licence text. .profile-info { + --avatar: none; --avatar-radius-extended-desktop: 40px; --avatar-radius-extended: 20px; --avatar-radius: 20px; --avatar-size-extended-desktop: @profile-avatar-size; --avatar-size-extended: var(--content-height); --avatar-size: var(--content-height); + + --avatar-height: var(--avatar-size); + --avatar-width: var(--avatar-size); + + --bg: none; + --content-height: 65px; + --vertical-padding: 10px; + --info-margin-extended-desktop: 20px; --info-margin-extended: 10px; --info-margin: 10px; - --vertical-padding: 10px; + display: flex; flex-direction: column; flex: none; @@ -29,6 +38,13 @@ background-color: hsl(var(--hsl-b4)); } + &--team { + --avatar-width: calc(var(--avatar-height) * 2); + --avatar: url("~@images/headers/generic.jpg"); + --bg: url("~@images/headers/generic.jpg"); + background-color: hsl(var(--hsl-b4)); + } + @media @desktop { --avatar-radius-extended: var(--avatar-radius-extended-desktop); --avatar-size-extended: var(--avatar-size-extended-desktop); @@ -38,17 +54,19 @@ &__avatar { .own-layer(); // required by safari for border-radius to be applied correctly flex: none; - width: var(--avatar-size); - height: var(--avatar-size); + width: var(--avatar-width); + height: var(--avatar-height); border-radius: var(--avatar-radius); overflow: hidden; align-self: flex-end; margin-bottom: var(--vertical-padding); + background-image: var(--avatar); .default-box-shadow(); } &__bg { position: relative; + background-image: var(--bg); background-size: cover; background-position: center; height: 100px; diff --git a/resources/css/bem/team-info-entries.less b/resources/css/bem/team-info-entries.less new file mode 100644 index 00000000000..60b7648d1cd --- /dev/null +++ b/resources/css/bem/team-info-entries.less @@ -0,0 +1,8 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +.team-info-entries { + display: grid; + gap: 10px; + margin-bottom: 20px; +} diff --git a/resources/css/bem/team-info-entry.less b/resources/css/bem/team-info-entry.less new file mode 100644 index 00000000000..76ae4f5dd7c --- /dev/null +++ b/resources/css/bem/team-info-entry.less @@ -0,0 +1,12 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +.team-info-entry { + display: grid; + + &__value { + color: hsl(var(--hsl-c2)); + display: grid; + min-width: 0; + } +} diff --git a/resources/css/bem/team-members.less b/resources/css/bem/team-members.less new file mode 100644 index 00000000000..4a04ab54806 --- /dev/null +++ b/resources/css/bem/team-members.less @@ -0,0 +1,22 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +.team-members { + border-radius: @border-radius--large; + display: grid; + gap: 5px; + padding: 5px; + background-color: hsl(var(--hsl-b2)); + + &--owner { + background-color: hsl(var(--hsl-orange-1)); + color: hsl(var(--hsl-orange-4)); + } + + &__meta { + padding: 0 10px; + font-weight: 600; + display: grid; + grid-template-columns: 1fr auto; + } +} diff --git a/resources/css/bem/team-summary.less b/resources/css/bem/team-summary.less new file mode 100644 index 00000000000..2433f08e294 --- /dev/null +++ b/resources/css/bem/team-summary.less @@ -0,0 +1,41 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +.team-summary { + margin: 0 calc(var(--inner-gutter) * -1); + padding: 0 var(--inner-gutter); + + display: grid; + gap: 20px; + grid-template-columns: 1fr; + align-items: start; + max-height: calc(70 * var(--vh)); + overflow-y: scroll; + + @media @desktop { + grid-template-columns: 2fr auto 3fr; + } + + &__members { + display: grid; + gap: 10px; + } + + &__sidebar { + &--separator { + display: none; + + @media @desktop { + display: block; + height: 100%; + width: 2px; + background-color: hsl(var(--hsl-b6)); + } + } + + @media @desktop { + position: sticky; + top: 0; + } + } +} diff --git a/resources/css/bem/title.less b/resources/css/bem/title.less index ef216e3b6c4..c1c5b154040 100644 --- a/resources/css/bem/title.less +++ b/resources/css/bem/title.less @@ -59,6 +59,10 @@ margin: 0; } + &--page-extra-small-top { + padding-top: 0; + } + &__count { color: @osu-colour-f1; background-color: @osu-colour-b6; diff --git a/resources/lang/en/page_title.php b/resources/lang/en/page_title.php index cc6440af8e8..7533545d6c5 100644 --- a/resources/lang/en/page_title.php +++ b/resources/lang/en/page_title.php @@ -107,6 +107,10 @@ 'seasons_controller' => [ '_' => 'rankings', ], + 'teams_controller' => [ + '_' => 'teams', + 'show' => 'team info', + ], 'tournaments_controller' => [ '_' => 'tournaments', ], diff --git a/resources/lang/en/teams.php b/resources/lang/en/teams.php new file mode 100644 index 00000000000..82726a24f1a --- /dev/null +++ b/resources/lang/en/teams.php @@ -0,0 +1,27 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +return [ + 'show' => [ + 'bar' => [ + 'settings' => 'Settings', + ], + + 'info' => [ + 'created' => 'Formed', + 'website' => 'Website', + ], + + 'members' => [ + 'members' => 'Team Members', + 'owner' => 'Team Leader', + ], + + 'sections' => [ + 'members' => 'Members', + 'info' => 'Info', + ], + ], +]; diff --git a/resources/views/teams/show.blade.php b/resources/views/teams/show.blade.php new file mode 100644 index 00000000000..2422b0548f2 --- /dev/null +++ b/resources/views/teams/show.blade.php @@ -0,0 +1,121 @@ +{{-- + Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. + See the LICENCE file in the repository root for full licence text. +--}} +@php + use App\Transformers\UserCompactTransformer; + + $userTransformer = new UserCompactTransformer(); + $teamMembers = array_map( + fn ($users) => json_collection($users, $userTransformer, UserCompactTransformer::CARD_INCLUDES), + $team->members->mapToGroups(fn ($member) => [ + $member->user_id === $team->leader_id ? 'leader' : 'member' => $member->user, + ])->all(), + ); + $teamMembers['member'] ??= []; + $headerUrl = $team->header()->url(); +@endphp + +@extends('master', [ + 'titlePrepend' => $team->name, +]) + +@section('content') + @include('layout._page_header_v4', ['params' => [ + 'theme' => 'team', + 'backgroundImage' => $headerUrl, + ]]) + +
+
+
+
+
+
logo()->url()) !!} + >
+
+

+ {{ $team->name }} +

+
+

+ [{{ $team->short_name }}] +

+
+
+
+
+ +
+
+@endsection diff --git a/routes/web.php b/routes/web.php index 25a72e153af..5862adadb75 100644 --- a/routes/web.php +++ b/routes/web.php @@ -296,6 +296,8 @@ Route::post('user-cover-presets/batch-activate', 'UserCoverPresetsController@batchActivate')->name('user-cover-presets.batch-activate'); Route::resource('user-cover-presets', 'UserCoverPresetsController', ['only' => ['index', 'store', 'update']]); + Route::resource('teams', 'TeamsController', ['only' => ['show']]); + Route::post('users/check-username-availability', 'UsersController@checkUsernameAvailability')->name('users.check-username-availability'); Route::get('users/lookup', 'Users\LookupController@index')->name('users.lookup'); Route::get('users/disabled', 'UsersController@disabled')->name('users.disabled'); diff --git a/tests/Browser/SanityTest.php b/tests/Browser/SanityTest.php index b6dde5b2e2e..d97bb0e5c8c 100644 --- a/tests/Browser/SanityTest.php +++ b/tests/Browser/SanityTest.php @@ -44,6 +44,7 @@ use App\Models\Score; use App\Models\Season; use App\Models\Store; +use App\Models\Team; use App\Models\Tournament; use App\Models\UpdateStream; use App\Models\User; @@ -274,6 +275,8 @@ private static function createScaffolding() self::$scaffolding['daily_challenge_room'] = Room::factory()->create(['category' => 'daily_challenge']); PlaylistItem::factory()->create(['room_id' => self::$scaffolding['daily_challenge_room']]); + + self::$scaffolding['team'] = Team::factory()->create(); } private static function filterLog(array $log)