Mod detail page work

This commit is contained in:
Refringe 2024-06-01 23:04:06 -04:00
parent 62185179cd
commit c026870bed
Signed by: Refringe
GPG Key ID: 7715B85B4A6306ED
10 changed files with 469 additions and 128 deletions

View File

@ -20,7 +20,6 @@ LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
# Only 'mysql' and 'pgsql' are supported due to a 'naturalsort' database function.
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
@ -29,7 +28,8 @@ DB_USERNAME=root
DB_PASSWORD=
# This is only needed if you are running the app:import-woltlab-data command.
# For normal development you should just seed the database with fake data.
# For normal development you should just seed the database with fake data:
# `php artisan migrate:fresh --seed`
DB_WOLTLAB_CONNECTION=mysql
DB_WOLTLAB_HOST=127.0.0.1
DB_WOLTLAB_PORT=3306

View File

@ -14,6 +14,8 @@ use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use League\HTMLToMarkdown\HtmlConverter;
use Stevebauman\Purify\Facades\Purify;
class ImportWoltlabData extends Command
{
@ -212,19 +214,19 @@ class ImportWoltlabData extends Command
}
$insertData[] = [
'hub_id' => $mod->fileID,
'hub_id' => (int) $mod->fileID,
'user_id' => User::whereHubId($mod->userID)->value('id'),
'name' => $modContent ? $modContent->subject : '',
'slug' => $modContent ? Str::slug($modContent->subject) : '',
'teaser' => $modContent ? (strlen($modContent->teaser) > 100 ? Str::take($modContent->teaser, 97).'...' : $modContent->teaser) : '',
'description' => $modContent ? $modContent->message : '',
'description' => $this->convertModDescription($modContent?->message ?? ''),
'thumbnail' => $this->fetchModThumbnail($command, $curl, $mod->fileID, $mod->iconHash, $mod->iconExtension),
'license_id' => License::whereHubId($mod->licenseID)->value('id'),
'source_code_link' => $this->fetchSourceLinkValue($modOptions),
'featured' => $mod->isFeatured,
'featured' => (bool) $mod->isFeatured,
'contains_ai_content' => $this->fetchContainsAiContentValue($modOptions),
'contains_ads' => $this->fetchContainsAdsValue($modOptions),
'disabled' => $mod->isDisabled,
'disabled' => (bool) $mod->isDisabled,
'created_at' => Carbon::parse($mod->time, 'UTC'),
'updated_at' => Carbon::parse($mod->lastChangeTime, 'UTC'),
];
@ -408,11 +410,11 @@ class ImportWoltlabData extends Command
'hub_id' => $version->versionID,
'mod_id' => $modId,
'version' => $version->versionNumber,
'description' => $versionContent['description'] ?? '',
'description' => $this->convertModDescription($versionContent['description'] ?? ''),
'link' => $version->downloadURL,
'spt_version_id' => SptVersion::whereHubId($versionLabel)->value('id'),
'virus_total_link' => $this->fetchVirusTotalLink($modOptions),
'downloads' => (int) $version->downloads,
'downloads' => max((int) $version->downloads, 0), // Ensure the value is at least 0
'disabled' => (bool) $version->isDisabled,
'created_at' => Carbon::parse($version->uploadTime, 'UTC'),
'updated_at' => Carbon::parse($version->uploadTime, 'UTC'),
@ -449,8 +451,10 @@ class ImportWoltlabData extends Command
return '';
}
protected function updateDisabledPropertty(): void
protected function convertModDescription(string $description): string
{
$this->output->newLine();
// Alright, hear me out... Shut up.
$converter = new HtmlConverter();
return $converter->convert(Purify::clean($description));
}
}

View File

@ -76,10 +76,10 @@ class Mod extends Model
->orderByDesc(
SptVersion::select('version')
->whereColumn('mod_versions.spt_version_id', 'spt_versions.id')
->orderByDesc(DB::raw('naturalSort(version)'))
->orderByDesc('version')
->take(1),
)
->orderByDesc(DB::raw('naturalSort(version)'))
->orderByDesc('version')
->take(1),
])
->havingNotNull('latest_spt_version_id')

View File

@ -17,7 +17,9 @@
"laravel/sanctum": "^4.0",
"laravel/scout": "^10.9",
"laravel/tinker": "^2.9",
"livewire/livewire": "^3.0"
"league/html-to-markdown": "^5.1",
"livewire/livewire": "^3.0",
"stevebauman/purify": "^6.2"
},
"require-dev": {
"barryvdh/laravel-debugbar": "^3.13",

218
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "bea00e78566494d8d997f51c6871ca02",
"content-hash": "b42a2bd2686065b7db27628579fde13c",
"packages": [
{
"name": "bacon/bacon-qr-code",
@ -878,6 +878,67 @@
],
"time": "2023-10-06T06:47:41+00:00"
},
{
"name": "ezyang/htmlpurifier",
"version": "v4.17.0",
"source": {
"type": "git",
"url": "https://github.com/ezyang/htmlpurifier.git",
"reference": "bbc513d79acf6691fa9cf10f192c90dd2957f18c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/bbc513d79acf6691fa9cf10f192c90dd2957f18c",
"reference": "bbc513d79acf6691fa9cf10f192c90dd2957f18c",
"shasum": ""
},
"require": {
"php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0"
},
"require-dev": {
"cerdic/css-tidy": "^1.7 || ^2.0",
"simpletest/simpletest": "dev-master"
},
"suggest": {
"cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.",
"ext-bcmath": "Used for unit conversion and imagecrash protection",
"ext-iconv": "Converts text to and from non-UTF-8 encodings",
"ext-tidy": "Used for pretty-printing HTML"
},
"type": "library",
"autoload": {
"files": [
"library/HTMLPurifier.composer.php"
],
"psr-0": {
"HTMLPurifier": "library/"
},
"exclude-from-classmap": [
"/library/HTMLPurifier/Language/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1-or-later"
],
"authors": [
{
"name": "Edward Z. Yang",
"email": "admin@htmlpurifier.org",
"homepage": "http://ezyang.com"
}
],
"description": "Standards compliant HTML filter written in PHP",
"homepage": "http://htmlpurifier.org/",
"keywords": [
"html"
],
"support": {
"issues": "https://github.com/ezyang/htmlpurifier/issues",
"source": "https://github.com/ezyang/htmlpurifier/tree/v4.17.0"
},
"time": "2023-11-17T15:01:25+00:00"
},
{
"name": "fruitcake/php-cors",
"version": "v1.3.0",
@ -2757,6 +2818,95 @@
},
"time": "2024-05-06T20:05:52+00:00"
},
{
"name": "league/html-to-markdown",
"version": "5.1.1",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/html-to-markdown.git",
"reference": "0b4066eede55c48f38bcee4fb8f0aa85654390fd"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/html-to-markdown/zipball/0b4066eede55c48f38bcee4fb8f0aa85654390fd",
"reference": "0b4066eede55c48f38bcee4fb8f0aa85654390fd",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-xml": "*",
"php": "^7.2.5 || ^8.0"
},
"require-dev": {
"mikehaertl/php-shellcommand": "^1.1.0",
"phpstan/phpstan": "^1.8.8",
"phpunit/phpunit": "^8.5 || ^9.2",
"scrutinizer/ocular": "^1.6",
"unleashedtech/php-coding-standard": "^2.7 || ^3.0",
"vimeo/psalm": "^4.22 || ^5.0"
},
"bin": [
"bin/html-to-markdown"
],
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "5.2-dev"
}
},
"autoload": {
"psr-4": {
"League\\HTMLToMarkdown\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Colin O'Dell",
"email": "colinodell@gmail.com",
"homepage": "https://www.colinodell.com",
"role": "Lead Developer"
},
{
"name": "Nick Cernis",
"email": "nick@cern.is",
"homepage": "http://modernnerd.net",
"role": "Original Author"
}
],
"description": "An HTML-to-markdown conversion helper for PHP",
"homepage": "https://github.com/thephpleague/html-to-markdown",
"keywords": [
"html",
"markdown"
],
"support": {
"issues": "https://github.com/thephpleague/html-to-markdown/issues",
"source": "https://github.com/thephpleague/html-to-markdown/tree/5.1.1"
},
"funding": [
{
"url": "https://www.colinodell.com/sponsor",
"type": "custom"
},
{
"url": "https://www.paypal.me/colinpodell/10.00",
"type": "custom"
},
{
"url": "https://github.com/colinodell",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/league/html-to-markdown",
"type": "tidelift"
}
],
"time": "2023-07-12T21:21:09+00:00"
},
{
"name": "league/mime-type-detection",
"version": "1.15.0",
@ -4576,6 +4726,72 @@
],
"time": "2024-03-06T16:03:49+00:00"
},
{
"name": "stevebauman/purify",
"version": "v6.2.0",
"source": {
"type": "git",
"url": "https://github.com/stevebauman/purify.git",
"reference": "303d23e5756a1fd0e9b34f14459ec6aa327a8412"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/stevebauman/purify/zipball/303d23e5756a1fd0e9b34f14459ec6aa327a8412",
"reference": "303d23e5756a1fd0e9b34f14459ec6aa327a8412",
"shasum": ""
},
"require": {
"ezyang/htmlpurifier": "^4.17",
"illuminate/contracts": "^7.0|^8.0|^9.0|^10.0|^11.0",
"illuminate/support": "^7.0|^8.0|^9.0|^10.0|^11.0",
"php": ">=7.4"
},
"require-dev": {
"orchestra/testbench": "^5.0|^6.0|^7.0|^8.0|^9.0",
"phpunit/phpunit": "^8.0|^9.0|^10.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Stevebauman\\Purify\\PurifyServiceProvider"
],
"aliases": {
"Purify": "Stevebauman\\Purify\\Facades\\Purify"
}
}
},
"autoload": {
"psr-4": {
"Stevebauman\\Purify\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Steve Bauman",
"email": "steven_bauman@outlook.com"
}
],
"description": "An HTML Purifier / Sanitizer for Laravel",
"keywords": [
"Purifier",
"clean",
"cleaner",
"html",
"laravel",
"purification",
"purify"
],
"support": {
"issues": "https://github.com/stevebauman/purify/issues",
"source": "https://github.com/stevebauman/purify/tree/v6.2.0"
},
"time": "2024-03-12T15:22:59+00:00"
},
{
"name": "symfony/clock",
"version": "v7.1.0",

112
config/purify.php Normal file
View File

@ -0,0 +1,112 @@
<?php
use Stevebauman\Purify\Definitions\Html5Definition;
return [
/*
|--------------------------------------------------------------------------
| Default Config
|--------------------------------------------------------------------------
|
| This option defines the default config that is provided to HTMLPurifier.
|
*/
'default' => 'default',
/*
|--------------------------------------------------------------------------
| Config sets
|--------------------------------------------------------------------------
|
| Here you may configure various sets of configuration for differentiated use of HTMLPurifier.
| A specific set of configuration can be applied by calling the "config($name)" method on
| a Purify instance. Feel free to add/remove/customize these attributes as you wish.
|
| Documentation: http://htmlpurifier.org/live/configdoc/plain.html
|
| Core.Encoding The encoding to convert input to.
| HTML.Doctype Doctype to use during filtering.
| HTML.Allowed The allowed HTML Elements with their allowed attributes.
| HTML.ForbiddenElements The forbidden HTML elements. Elements that are listed in this
| string will be removed, however their content will remain.
| CSS.AllowedProperties The Allowed CSS properties.
| AutoFormat.AutoParagraph Newlines are converted in to paragraphs whenever possible.
| AutoFormat.RemoveEmpty Remove empty elements that contribute no semantic information to the document.
|
*/
'configs' => [
'default' => [
'Core.Encoding' => 'utf-8',
'HTML.Doctype' => 'HTML 4.01 Strict',
'HTML.Allowed' => 'h1,h2,h3,h4,h5,h6,b,strong,i,em,s,del,a[href|title],ul,ol,li,p,br,span,img[width|height|alt|src],blockquote',
'HTML.ForbiddenElements' => '',
'HTML.TargetBlank' => true,
'CSS.AllowedProperties' => 'font-weight,font-style,text-decoration,color,background-color,text-align',
'AutoFormat.RemoveEmpty.RemoveNbsp' => true,
'AutoFormat.AutoParagraph' => false,
'AutoFormat.RemoveEmpty' => true,
'AutoFormat.RemoveSpansWithoutAttributes' => true,
'URI.AllowedSchemes' => ['http' => true, 'https' => true, 'ftp' => true],
],
],
/*
|--------------------------------------------------------------------------
| HTMLPurifier definitions
|--------------------------------------------------------------------------
|
| Here you may specify a class that augments the HTML definitions used by
| HTMLPurifier. Additional HTML5 definitions are provided out of the box.
| When specifying a custom class, make sure it implements the interface:
|
| \Stevebauman\Purify\Definitions\Definition
|
| Note that these definitions are applied to every Purifier instance.
|
| Documentation: http://htmlpurifier.org/docs/enduser-customize.html
|
*/
'definitions' => Html5Definition::class,
/*
|--------------------------------------------------------------------------
| HTMLPurifier CSS definitions
|--------------------------------------------------------------------------
|
| Here you may specify a class that augments the CSS definitions used by
| HTMLPurifier. When specifying a custom class, make sure it implements
| the interface:
|
| \Stevebauman\Purify\Definitions\CssDefinition
|
| Note that these definitions are applied to every Purifier instance.
|
| CSS should be extending $definition->info['css-attribute'] = values
| See HTMLPurifier_CSSDefinition for further explanation
|
*/
'css-definitions' => null,
/*
|--------------------------------------------------------------------------
| Serializer
|--------------------------------------------------------------------------
|
| The storage implementation where HTMLPurifier can store its serializer files.
| If the filesystem cache is in use, the path must be writable through the
| storage disk by the web server, otherwise an exception will be thrown.
|
*/
'serializer' => [
'driver' => env('CACHE_DRIVER', 'file'),
'cache' => \Stevebauman\Purify\Cache\CacheDefinitionCache::class,
],
];

View File

@ -1,79 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
if (config('database.default') === 'sqlite') {
throw new \Exception('This project does not support SQLite. Update to MySQL or PostgreSQL.');
}
if (config('database.default') === 'mysql') {
// https://www.drupal.org/project/natsort
DB::unprepared("
DROP FUNCTION IF EXISTS naturalSort;
CREATE FUNCTION naturalSort (s VARCHAR (255)) RETURNS VARCHAR (255) NO SQL DETERMINISTIC BEGIN
DECLARE orig VARCHAR (255) DEFAULT s;
DECLARE ret VARCHAR (255) DEFAULT '';
IF s IS NULL THEN
RETURN NULL;
ELSEIF NOT s REGEXP '[0-9]' THEN
SET ret = s;
ELSE
SET s = REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(s, '0', '#'), '1', '#'), '2', '#'), '3', '#'), '4', '#');
SET s = REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(s, '5', '#'), '6', '#'), '7', '#'), '8', '#'), '9', '#');
SET s = REPLACE(s, '.#', '##');
SET s = REPLACE(s, '#,#', '###');
BEGIN
DECLARE numpos INT;
DECLARE numlen INT;
DECLARE numstr VARCHAR (255);
lp1: LOOP
SET numpos = locate('#', s);
IF numpos = 0 THEN
SET ret = concat(ret, s);
LEAVE lp1;
END IF;
SET ret = concat(ret, substring(s, 1, numpos - 1));
SET s = substring(s, numpos);
SET orig = substring(orig, numpos);
SET numlen = char_length(s) - char_length(trim(LEADING '#' FROM s));
SET numstr = cast(REPLACE(substring(orig, 1, numlen), ',', '') AS DECIMAL (13, 3));
SET numstr = lpad(numstr, 15, '0');
SET ret = concat(ret, '[', numstr, ']');
SET s = substring(s, numlen + 1);
SET orig = substring(orig, numlen + 1);
END LOOP;
END;
END IF;
SET ret = REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(ret, ' ', ''), ',', ''), ':', ''), '.', ''), ';', '' ), '(', ''), ')', '');
RETURN ret;
END;
");
}
if (config('database.default') === 'pgsql') {
// http://www.rhodiumtoad.org.uk/junk/naturalsort.sql
DB::unprepared('
create or replace function naturalSort(text)
returns bytea language sql immutable strict as
$f$ select string_agg(convert_to(coalesce(r[2],length(length(r[1])::text) || length(r[1])::text || r[1]),\'SQL_ASCII\'),\'\x00\')
from regexp_matches($1, \'0*([0-9]+)|([^0-9]+)\', \'g\') r; $f$;
');
}
}
public function down(): void
{
if (config('database.default') === 'sqlite') {
throw new \Exception('This project does not support SQLite. Update to MySQL or PostgreSQL.');
}
if (config('database.default') === 'mysql' || config('database.default') === 'pgsql') {
DB::unprepared('DROP FUNCTION IF EXISTS naturalSort');
}
}
};

View File

@ -15,6 +15,10 @@ input[type="checkbox"] {
@apply text-gray-800 dark:text-gray-300 focus:ring-gray-600 dark:focus:ring-gray-500 border-gray-300 dark:border-gray-700 rounded;
}
main a:not(.mod-list-component):not(.tab) {
@apply underline text-gray-800 hover:text-black dark:text-gray-200 dark:hover:text-white;
}
.badge-version {
@apply bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-100;
@ -38,3 +42,45 @@ input[type="checkbox"] {
@apply bg-yellow-100 dark:bg-yellow-700 text-yellow-700 dark:text-yellow-100;
}
}
.user-markdown {
b, strong {
@apply font-bold;
}
i, em {
@apply italic
}
h1, h2, h3, h4, h5, h6 {
@apply font-bold mt-4 mb-2 text-black dark:text-white;
}
h1 {
@apply text-2xl;
}
h2 {
@apply text-xl;
}
h3 {
@apply text-lg;
}
h4 {
@apply text-base;
}
h5 {
@apply text-sm;
}
h6 {
@apply text-xs;
}
p {
@apply my-2 text-gray-800 dark:text-gray-300;
}
ul {
@apply list-disc mb-2;
}
ol {
@apply list-decimal mb-2;
}
li {
@apply my-2 ml-7 text-gray-800 dark:text-gray-300;
}
}

View File

@ -2,7 +2,7 @@
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
@foreach ($mods as $mod)
<a href="/mod/{{ $mod->id }}/{{ $mod->slug }}">
<a href="/mod/{{ $mod->id }}/{{ $mod->slug }}" class="mod-list-component">
<div class="flex flex-col group h-full w-full max-w-md mx-auto bg-white dark:bg-gray-950 rounded-xl shadow-md dark:shadow-gray-950 drop-shadow-2xl overflow-hidden md:max-w-2xl hover:shadow-lg hover:bg-gray-50 dark:hover:bg-black hover:shadow-gray-400 dark:hover:shadow-black transition-all duration-200">
<div class="h-auto md:h-full md:flex">
<div class="h-auto md:h-full md:shrink-0 overflow-hidden">

View File

@ -7,34 +7,68 @@
</x-slot>
<div class="grid grid-cols-1 lg:grid-cols-3 max-w-7xl mx-auto pb-6 px-4 gap-6 sm:px-6 lg:px-8">
<div class="lg:col-span-2 flex flex-col gap-6">
<div class="p-4 sm:p-6 text-center sm:text-left bg-white dark:bg-gray-950 rounded-xl shadow-md dark:shadow-gray-950 drop-shadow-2xl">
<div class="flex flex-col sm:flex-row gap-4 sm:gap-6">
<div class="grow-0 shrink-0 flex justify-center items-center">
@if(empty($mod->thumbnail))
<img src="https://placehold.co/144x144/EEE/31343C?font=source-sans-pro&text={{ $mod->name }}" alt="{{ $mod->name }}" class="block dark:hidden w-36 rounded-lg">
<img src="https://placehold.co/144x144/31343C/EEE?font=source-sans-pro&text={{ $mod->name }}" alt="{{ $mod->name }}" class="hidden dark:block w-36 rounded-lg">
@else
<img src="{{ $mod->thumbnail }}" alt="{{ $mod->name }}" class="w-36 rounded-lg">
@endif
</div>
<div class="grow flex flex-col justify-center items-center sm:items-start text-gray-800 dark:text-gray-200">
<h2 class="pb-1 sm:p-0 text-3xl font-bold text-gray-900 dark:text-white">
{{ $mod->name }}
<span class="font-light text-nowrap text-gray-700 dark:text-gray-400">
{{ $mod->latestSptVersion->version }}
</span>
</h2>
<p>{{ __('Created by') }} {{ $mod->user->name }}</p>
<p>{{ $mod->latestSptVersion->sptVersion->version }} {{ __('Compatible') }}</p>
<p>{{ $mod->total_downloads }} {{ __('Downloads') }}</p>
</div>
</div>
</div>
<div class="lg:col-span-2 p-4 sm:p-6 text-center sm:text-left bg-white dark:bg-gray-950 rounded-xl shadow-md dark:shadow-gray-950 drop-shadow-2xl">
<div class="flex flex-col sm:flex-row gap-4 sm:gap-6">
<div class="grow-0 flex justify-center items-center">
@if(empty($mod->thumbnail))
<img src="https://placehold.co/144x144/EEE/31343C?font=source-sans-pro&text={{ $mod->name }}" alt="{{ $mod->name }}" class="block dark:hidden w-36 rounded-lg">
<img src="https://placehold.co/144x144/31343C/EEE?font=source-sans-pro&text={{ $mod->name }}" alt="{{ $mod->name }}" class="hidden dark:block w-36 rounded-lg">
@else
<img src="{{ $mod->thumbnail }}" alt="{{ $mod->name }}" class="w-36 rounded-lg">
@endif
<div>
<div class="sm:hidden">
<label for="tabs" class="sr-only">Select a tab</label>
<!-- Use an "onChange" listener to redirect the user to the selected tab URL. -->
<select id="tabs" name="tabs" class="block w-full rounded-md border-gray-300 focus:border-grey-500 focus:ring-grey-500">
<option selected>Description</option>
<option>Versions</option>
<option>Comments</option>
</select>
</div>
<div class="grow flex flex-col justify-center items-center sm:items-start text-gray-800 dark:text-gray-200">
<h2 class="pb-1 sm:p-0 text-3xl font-bold text-gray-900 dark:text-white">
{{ $mod->name }}
<span class="font-light text-nowrap text-gray-700 dark:text-gray-400">
{{ $mod->latestSptVersion->version }}
</span>
</h2>
<p>{{ __('Created by') }} {{ $mod->user->name }}</p>
<p>{{ $mod->latestSptVersion->sptVersion->version }} {{ __('Compatible') }}</p>
<p>{{ $mod->total_downloads }} {{ __('Downloads') }}</p>
<div class="hidden sm:block">
<nav class="isolate flex divide-x divide-gray-200 dark:divide-gray-800 rounded-xl shadow-md dark:shadow-gray-950 drop-shadow-2xl" aria-label="Tabs">
<a href="#description" class="tab rounded-l-xl group relative min-w-0 flex-1 overflow-hidden py-4 px-4 text-center text-sm font-medium text-gray-900 dark:text-white bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-black dark:hover:text-white focus:z-10" aria-current="page">
<span>Description</span>
<span aria-hidden="true" class="bg-gray-500 absolute inset-x-0 bottom-0 h-0.5"></span>
</a>
<a href="#versions" class="tab group relative min-w-0 flex-1 overflow-hidden py-4 px-4 text-center text-sm font-medium text-gray-900 dark:text-white bg-white dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-black dark:hover:text-white focus:z-10">
<span>Versions</span>
<span aria-hidden="true" class="bg-transparent absolute inset-x-0 bottom-0 h-0.5"></span>
</a>
<a href="#comments" class="tab rounded-r-xl group relative min-w-0 flex-1 overflow-hidden py-4 px-4 text-center text-sm font-medium text-gray-900 dark:text-white bg-white dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-black dark:hover:text-white focus:z-10">
<span>Comments</span>
<span aria-hidden="true" class="bg-transparent absolute inset-x-0 bottom-0 h-0.5"></span>
</a>
</nav>
</div>
</div>
<div id="description" class="user-markdown p-4 sm:p-6 bg-white dark:bg-gray-950 rounded-xl shadow-md dark:shadow-gray-950 drop-shadow-2xl">
{{-- The description below is safe to write directly because it has been run though HTMLPurifier during the import process. --}}
<p>{!! Str::markdown($mod->description) !!}</p>
</div>
</div>
<div class="col-span-1 flex flex-col gap-6">
<a href="{{ $mod->latestSptVersion->link }}" class="block">
<button type="button" class="w-full">{{ __('Download Latest Version') }}</button>
<button type="button" class="w-full">{{ __('Download Latest Version') }} ({{ $mod->latestSptVersion->version }})</button>
</a>
<div class="p-4 sm:p-6 bg-white dark:bg-gray-950 rounded-xl shadow-md dark:shadow-gray-950 drop-shadow-2xl">
@ -60,20 +94,26 @@
</p>
</li>
@endif
<li class="px-4 py-4 sm:px-0">
<h3>{{ __('Latest VirusTotal Result') }}</h3>
<p class="truncate">
<a href="{{ $mod->latestSptVersion->virus_total_link }}" title="{{ $mod->latestSptVersion->virus_total_link }}" target="_blank">
{{ $mod->latestSptVersion->virus_total_link }}
</a>
</p>
</li>
<li class="px-4 py-4 sm:px-0">
<h3>{{ __('Includes advertising?') }}</h3>
</li>
<li class="px-4 py-4 sm:px-0">
<h3>{{ __('Includes AI generated content?') }}</h3>
</li>
@if($mod->latestSptVersion->virus_total_link)
<li class="px-4 py-4 sm:px-0">
<h3>{{ __('Latest VirusTotal Result') }}</h3>
<p class="truncate">
<a href="{{ $mod->latestSptVersion->virus_total_link }}" title="{{ $mod->latestSptVersion->virus_total_link }}" target="_blank">
{{ $mod->latestSptVersion->virus_total_link }}
</a>
</p>
</li>
@endif
@if($mod->contains_ads)
<li class="px-4 py-4 sm:px-0">
<h3>{{ __('Includes Advertising') }}</h3>
</li>
@endif
@if($mod->contains_ai_content)
<li class="px-4 py-4 sm:px-0">
<h3>{{ __('Includes AI Generated Content') }}</h3>
</li>
@endif
</ul>
</div>
</div>