diff --git a/.env.example b/.env.example index 2e1db83a1bc..7c5b8beb274 100644 --- a/.env.example +++ b/.env.example @@ -321,6 +321,7 @@ CLIENT_CHECK_VERSION=false # OSU_URL_LAZER_OTHER='https://github.com/ppy/osu/#running-osu' # OSU_URL_LAZER_WINDOWS_X64='https://github.com/ppy/osu/releases/latest/download/install.exe' # OSU_URL_LAZER_INFO= +# OSU_URL_MENU_CONTENT_JSON=https://assets.ppy.sh/menu-content.json # OSU_URL_USER_RESTRICTION=/wiki/Help_centre/Account_restrictions # USER_COUNTRY_CHANGE_MAX_MIXED_MONTHS=2 diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index 69e7d5f8b90..0cd6ff043d9 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -7,6 +7,7 @@ use App; use App\Libraries\CurrentStats; +use App\Libraries\MenuContent; use App\Libraries\Search\AllSearch; use App\Libraries\Search\QuickSearch; use App\Models\BeatmapDownload; @@ -14,6 +15,7 @@ use App\Models\Forum\Post; use App\Models\NewsPost; use App\Models\UserDonation; +use App\Transformers\MenuImageTransformer; use Auth; use Jenssegers\Agent\Agent; use Request; @@ -98,10 +100,12 @@ public function index() $news = NewsPost::default()->limit($newsLimit)->get(); if (Auth::check()) { + $menuImages = json_collection(MenuContent::activeImages(), new MenuImageTransformer()); $newBeatmapsets = Beatmapset::latestRanked(); $popularBeatmapsets = Beatmapset::popular()->get(); return ext_view('home.user', compact( + 'menuImages', 'newBeatmapsets', 'news', 'popularBeatmapsets' diff --git a/app/Libraries/MenuContent.php b/app/Libraries/MenuContent.php new file mode 100644 index 00000000000..587727120ef --- /dev/null +++ b/app/Libraries/MenuContent.php @@ -0,0 +1,67 @@ +. 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\Libraries; + +use Carbon\Carbon; +use Exception; +use GuzzleHttp\Client; + +class MenuContent +{ + /** + * Get all active menu content images. + * + * @return array[] + */ + public static function activeImages(): array + { + return cache_remember_mutexed('menu-content-active-images', 60, [], function () { + $images = self::parse(self::fetch()); + $now = Carbon::now(); + + return array_values(array_filter($images, fn ($image) => ( + ($image['started_at']?->lessThanOrEqualTo($now) ?? true) + && ($image['ended_at']?->greaterThan($now) ?? true) + ))); + }); + } + + private static function fetch(): array + { + $response = (new Client()) + ->get(osu_url('menu_content')) + ->getBody() + ->getContents(); + + return json_decode($response, true); + } + + private static function parse(array $data): array + { + if (!is_array($data['images'] ?? null)) { + throw new Exception('Invalid "images" key in menu-content response'); + } + + $parsedImages = []; + + foreach ($data['images'] as $image) { + if (!is_string($image['image']) || !is_string($image['url'])) { + throw new Exception('Invalid "image" or "url" key in menu-content image'); + } + + $parsedImages[] = [ + 'ended_at' => parse_time_to_carbon($image['expires']), + 'image_url' => $image['image'], + 'started_at' => parse_time_to_carbon($image['begins']), + 'url' => $image['url'], + ]; + } + + return $parsedImages; + } +} diff --git a/app/Transformers/MenuImageTransformer.php b/app/Transformers/MenuImageTransformer.php new file mode 100644 index 00000000000..3fafd846f81 --- /dev/null +++ b/app/Transformers/MenuImageTransformer.php @@ -0,0 +1,21 @@ +. 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\Transformers; + +class MenuImageTransformer extends TransformerAbstract +{ + public function transform(array $menuImage): array + { + return [ + 'ended_at' => json_time($menuImage['ended_at']), + 'image_url' => $menuImage['image_url'], + 'started_at' => json_time($menuImage['started_at']), + 'url' => $menuImage['url'], + ]; + } +} diff --git a/config/osu.php b/config/osu.php index 0ed4a744783..3106ff038af 100644 --- a/config/osu.php +++ b/config/osu.php @@ -226,6 +226,7 @@ 'lazer_dl.windows_x64' => presence(env('OSU_URL_LAZER_WINDOWS_X64')) ?? 'https://github.com/ppy/osu/releases/latest/download/install.exe', 'lazer_dl_other' => presence(env('OSU_URL_LAZER_OTHER')) ?? 'https://github.com/ppy/osu/#running-osu', 'lazer_info' => presence(env('OSU_URL_LAZER_INFO')), + 'menu_content' => presence(env('OSU_URL_MENU_CONTENT_JSON')) ?? 'https://assets.ppy.sh/menu-content.json', 'osx' => 'https://osx.ppy.sh', 'server_status' => 'https://status.ppy.sh', 'smilies' => '/forum/images/smilies', diff --git a/resources/css/bem-index.less b/resources/css/bem-index.less index e3af35b7437..d6437fa8b58 100644 --- a/resources/css/bem-index.less +++ b/resources/css/bem-index.less @@ -226,6 +226,8 @@ @import "bem/logo"; @import "bem/love-beatmap-dialog"; @import "bem/medals-group"; +@import "bem/menu-image"; +@import "bem/menu-images"; @import "bem/message-length-counter"; @import "bem/mobile-menu"; @import "bem/mobile-menu-tab"; diff --git a/resources/css/bem/menu-image.less b/resources/css/bem/menu-image.less new file mode 100644 index 00000000000..c5ce7dd2671 --- /dev/null +++ b/resources/css/bem/menu-image.less @@ -0,0 +1,16 @@ +// 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. + +.menu-image { + --index: 0; + width: 100%; + height: 100%; + left: calc(100% * var(--index)); + position: absolute; + + &__image { + object-fit: contain; + width: 100%; + height: 100%; + } +} diff --git a/resources/css/bem/menu-images.less b/resources/css/bem/menu-images.less new file mode 100644 index 00000000000..7de562ca11b --- /dev/null +++ b/resources/css/bem/menu-images.less @@ -0,0 +1,106 @@ +// 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. + +.menu-images { + --arrow-opacity: 0; + --container-height: 90px; + --indicators-opacity: 1; + + margin-top: 20px; + overflow-x: hidden; + position: relative; + + &:hover { + --arrow-opacity: 1; + } + + &--placeholder { + --indicators-opacity: 0; + } + + &__arrow { + .reset-input(); + .fade-element(@hover-transition-duration); + height: var(--container-height); + padding: 10px; + position: absolute; + top: 0; + opacity: var(--arrow-opacity); + + &:hover { + color: @osu-colour-l1; + } + + &--left { + --icon: @fa-var-chevron-left; + left: 0; + } + + &--right { + --icon: @fa-var-chevron-right; + right: 0; + } + + &::before { + .fas(); + .default-text-shadow(); + content: var(--icon); + } + } + + &__container { + .center-content(); + display: flex; + height: var(--container-height); + transform: translateX(calc(-100% * var(--index, 0))); + + &--transition { + transition: transform 300ms ease-in-out; + } + } + + &__indicator { + .reset-input(); + align-items: center; + display: flex; + + width: 30px; + height: 6px; + + --content-height: 2px; + --content-opacity: 0.5; + + &::before { + content: ""; + + background: @osu-colour-h1; + border-radius: 10000px; + opacity: var(--content-opacity); + + transition: 100ms ease-out; + transition-property: height, opacity; + + width: 100%; + height: var(--content-height); + } + + &:hover { + --content-height: 100%; + } + + &--active { + --content-height: 100%; + --content-opacity: 1; + } + } + + &__indicators { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: center; + margin-top: 10px; + opacity: var(--indicators-opacity); + } +} diff --git a/resources/js/components/menu-image.tsx b/resources/js/components/menu-image.tsx new file mode 100644 index 00000000000..46c738e7e7f --- /dev/null +++ b/resources/js/components/menu-image.tsx @@ -0,0 +1,24 @@ +// 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. + +import MenuImageJson from 'interfaces/menu-image-json'; +import * as React from 'react'; + +const bn = 'menu-image'; + +interface Props { + image: MenuImageJson; + index?: number; +} + +export default function MenuImage({ image, index }: Props) { + return ( + + + + ); +} diff --git a/resources/js/components/menu-images.tsx b/resources/js/components/menu-images.tsx new file mode 100644 index 00000000000..cbcfae47993 --- /dev/null +++ b/resources/js/components/menu-images.tsx @@ -0,0 +1,192 @@ +// 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. + +import MenuImageJson from 'interfaces/menu-image-json'; +import { range } from 'lodash'; +import { action, makeObservable, observable } from 'mobx'; +import { observer } from 'mobx-react'; +import * as React from 'react'; +import { classWithModifiers } from 'utils/css'; +import MenuImage from './menu-image'; + +function modulo(dividend: number, divisor: number): number { + return ((dividend % divisor) + divisor) % divisor; +} + +const autoRotateIntervalMs = 6000; +const bn = 'menu-images'; + +interface Props { + images: MenuImageJson[]; +} + +@observer +export default class MenuImages extends React.Component { + private autoRotateTimerId?: number; + @observable private index = 0; + @observable private transition = true; + + private get length() { + return this.props.images.length; + } + + private get maxIndex() { + return Math.max(this.length - 1, this.index); + } + + private get minIndex() { + return Math.min(0, this.index); + } + + constructor(props: Props) { + super(props); + + makeObservable(this); + } + + componentDidMount() { + this.setAutoRotateTimer(); + document.addEventListener('visibilitychange', this.handlePageVisibilityChange); + } + + componentWillUnmount() { + this.clearAutoRotateTimer(); + document.removeEventListener('visibilitychange', this.handlePageVisibilityChange); + } + + render() { + if (this.length === 0) { + return null; + } + + if (this.length === 1) { + return ( +
+
+ +
+
+ ); + } + + return ( +
+
+ {/* + Render the images. If minIndex or maxIndex have been adjusted, this + will render duplicate images in a cycling pattern to help create + the illusion of an infinitely scrolling container + */} + {range(this.minIndex, this.maxIndex + 1).map((index) => ( + + ))} +
+ {this.renderArrows()} + {this.renderIndicators()} +
+ ); + } + + private readonly clearAutoRotateTimer = () => { + window.clearInterval(this.autoRotateTimerId); + }; + + private readonly handleArrowClick = (event: React.MouseEvent) => { + this.setIndex(this.index + parseInt(event.currentTarget.dataset.increment ?? '', 10)); + }; + + private readonly handleIndicatorClick = (event: React.MouseEvent) => { + // Increment the index by the visible difference between the selected and + // active indicator + this.setIndex( + this.index + + parseInt(event.currentTarget.dataset.index ?? '', 10) + - modulo(this.index, this.length), + ); + }; + + private readonly handlePageVisibilityChange = () => { + if (document.hidden) { + this.clearAutoRotateTimer(); + } else { + this.setAutoRotateTimer(); + } + }; + + @action + private readonly handleTransitionEnd = (event: React.TransitionEvent) => { + if (event.propertyName !== 'transform' || event.currentTarget !== event.target) { + return; + } + + // Reset the index to be within normal bounds, if it went outside. Don't + // show the transition so that nothing changes visually + this.setIndex(modulo(this.index, this.length), false); + }; + + private renderArrows() { + return ( + <> +