diff --git a/.dev/.env b/.dev/.env index 22f162bd0a..4f0d54cd8a 100644 --- a/.dev/.env +++ b/.dev/.env @@ -25,11 +25,11 @@ LEAN_ENABLE_MENU_TYPE = false # Enable to specifiy menu on LEAN_SESSION_PASSWORD = '3evBlq9zdUEuzKvVJHWWx3QzsQhturBApxwcws2m' #Salting sessions. Replace with a strong password LEAN_SESSION_EXPIRATION = 28800 # How many seconds after inactivity should we logout? 28800seconds = 8hours LEAN_LOG_PATH = '' # Default Log Path (including filename), if not set /logs/error.log will be used -LEAN_PLUGINS = 'motivationalquotes' # Comma separated list of plugins to load +LEAN_PLUGINS = '' # Comma separated list of plugins to load ## Look & Feel, these settings are available in the UI and can be overwritten there. -LEAN_LOGO_PATH = '/dist/images/logo.svg' # Default logo path, can be changed later -LEAN_PRINT_LOGO_URL = '/dist/images/logo.jpg' # Default logo URL use for printing (must be jpg or png format) +LEAN_LOGO_PATH = '/dist/images/logo.svg' # Default logo path, can be changed later +LEAN_PRINT_LOGO_URL = '/dist/images/logo.jpg' # Default logo URL use for printing (must be jpg or png format) LEAN_DEFAULT_THEME = 'default' # Default theme LEAN_PRIMARY_COLOR = '#1b75bb' # Primary Theme color LEAN_SECONDARY_COLOR = '#81B1A8' # Secondary Theme Color diff --git a/.dev/dockerfile b/.dev/dockerfile index 7caa78d593..b284962a57 100644 --- a/.dev/dockerfile +++ b/.dev/dockerfile @@ -7,8 +7,9 @@ RUN apt update && apt install -f -y libonig-dev libcurl4-openssl-dev libxml2-de libfreetype6-dev libjpeg62-turbo-dev libpng-dev apt-utils vim curl sqlite3\ openssl RUN pecl install xdebug -RUN docker-php-ext-install mysqli pdo_mysql mbstring exif pcntl pdo bcmath opcache ldap +RUN docker-php-ext-install mysqli pdo_mysql mbstring exif pcntl pdo bcmath opcache ldap zip +RUN docker-php-ext-enable zip RUN docker-php-ext-configure gd --enable-gd --with-jpeg=/usr/include/ --with-freetype --with-jpeg RUN docker-php-ext-install gd RUN docker-php-ext-enable xdebug diff --git a/.phpactor.json b/.phpactor.json index 3181c8df51..06a9670c82 100644 --- a/.phpactor.json +++ b/.phpactor.json @@ -1,4 +1,5 @@ { "$schema": "/Users/josephroberts/.local/share/nvim/mason/packages/phpactor/phpactor.schema.json", - "php_code_sniffer.enabled": false + "php_code_sniffer.enabled": false, + "language_server_phpstan.enabled": true } \ No newline at end of file diff --git a/app/Command/MigrateCommand.php b/app/Command/MigrateCommand.php index fc0a66c827..baf10bc205 100644 --- a/app/Command/MigrateCommand.php +++ b/app/Command/MigrateCommand.php @@ -46,8 +46,8 @@ protected function configure(): void */ protected function execute(InputInterface $input, OutputInterface $output): int { - define('BASE_URL', ""); - define('CURRENT_URL', ""); + ! defined('BASE_URL') && define('BASE_URL', ""); + ! defined('CURRENT_URL') && define('CURRENT_URL', ""); $install = app()->make(Install::class); $io = new SymfonyStyle($input, $output); @@ -99,8 +99,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io->text("Successfully Installed DB"); } $success = $install->updateDB(); - if (!$success) { - throw new Exception("Migration Failed; Please Check Logs"); + if ($success !== true) { + throw new Exception("Migration Failed; See below" . PHP_EOL . implode(PHP_EOL, $success)); } } catch (Exception $ex) { $io->error($ex); diff --git a/app/Core/HtmxController.php b/app/Core/HtmxController.php index ae7cc556e7..cc38faa964 100644 --- a/app/Core/HtmxController.php +++ b/app/Core/HtmxController.php @@ -13,6 +13,7 @@ * * @package leantime * @subpackage core + * @method string|null run() The fallback method to be initialized. */ abstract class HtmxController { @@ -21,6 +22,7 @@ abstract class HtmxController protected IncomingRequest $incomingRequest; protected Template $tpl; protected Response $response; + protected static string $view; /** * constructor - initialize private variables @@ -28,12 +30,12 @@ abstract class HtmxController * @access public * * @param IncomingRequest $incomingRequest The request to be initialized. - * @param template $tpl The template to be initialized. + * @param Template $tpl The template to be initialized. * @throws BindingResolutionException */ public function __construct( IncomingRequest $incomingRequest, - template $tpl + Template $tpl ) { self::dispatch_event('begin'); @@ -69,11 +71,11 @@ private function executeActions(): void $action = Str::camel($this->incomingRequest->query->get('id', 'run')); - if (! method_exists($this, $action)) { - throw new Error("Method $action doesn't exist."); + if (! method_exists($this, $action) && ! method_exists($this, 'run')) { + throw new Error("Method $action doesn't exist and no fallback method."); } - $fragment = $this->$action(); + $fragment = method_exists($this, $action) ? $this->$action() : $this->run(); $this->response = $this->tpl->displayFragment($this::$view, $fragment ?? ''); } diff --git a/app/Core/HttpKernel.php b/app/Core/HttpKernel.php index 5cf2f8b383..0b5348d82d 100644 --- a/app/Core/HttpKernel.php +++ b/app/Core/HttpKernel.php @@ -6,6 +6,8 @@ use Illuminate\Http\Exceptions\HttpResponseException; use Illuminate\Pipeline\Pipeline; use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\ErrorHandler\ErrorRenderer\HtmlErrorRenderer; class HttpKernel implements HttpKernelContract { @@ -46,7 +48,16 @@ public function handle($request) return $e->getResponse(); } catch (\Throwable $e) { if (! app()->make(Environment::class)->debug) { - return new RedirectResponse(BASE_URL . "/errors/error500", 500); + return new RedirectResponse(BASE_URL . "/errors/error500", 201); + } + + if ($request instanceof HtmxRequest) { + /** @todo Replace with a proper error template for htmx requests */ + return new Response(sprintf( + '%s', + 'width: 90vw; height: 90vh; z-index: 9999999; position: fixed; top: 5vh; left: 5vh; overflow: scroll', + (new HtmlErrorRenderer(true))->render($e)->getAsString(), + )); } throw $e; diff --git a/app/Core/Language.php b/app/Core/Language.php index b6551f426e..791c822d8a 100644 --- a/app/Core/Language.php +++ b/app/Core/Language.php @@ -359,51 +359,55 @@ public function getBrowserLanguage(): string /** * __ - returns a language specific string * - * @access public - * @param string $index - * @param bool $convertValue If true then if a value has a conversion (i.e.: PHP -> JavaScript) then do it, otherwise return PHP value - * @return string + * @param string $index + * @param bool $convertValue If true then if a value has a conversion (i.e.: PHP -> JavaScript) then do it, otherwise return PHP value + * @param string $default If the index is not found, return this value + * @return string */ - public function __(string $index, bool $convertValue = false): string + public function __(string $index, bool $convertValue = false, $default = ''): string { - if (isset($this->ini_array[$index]) === true) { - $index = trim($index); - - // @TODO: move the date/time format logic into here and have Api/Controllers/I18n call this for each type? - $dateTimeIniSettings = [ - 'language.dateformat', - 'language.jsdateformat', - 'language.timeformat', - 'language.jstimeformat', - 'language.momentJSDate', - ]; - - $dateTimeFormat = $this->getCustomDateTimeFormat(); - - if (in_array($index, $dateTimeIniSettings) && $convertValue) { - $isMoment = stristr($index, 'momentjs') !== false; - $isJs = stristr($index, '.js') !== false; - $isDate = stristr($index, 'date') !== false; - - if ($isJs || $isMoment) { - return $this->convertDateFormatToJS($isDate ? $dateTimeFormat['date'] : $dateTimeFormat['time'], $isMoment); - } else if ($isDate) { - return $this->convertDateFormatToJS($dateTimeFormat['date'], false); - } - } else if ($index === 'language.dateformat') { - return $dateTimeFormat['date']; - } else if ($index === 'language.timeformat') { - return $dateTimeFormat['time']; + if (! isset($this->ini_array[$index])) { + if (! empty($default)) { + return $default; } - return (string) $this->ini_array[$index]; - } else { - if ($this->alert === true) { - return '' . $index . ''; - } else { - return $index; + if ($this->alert) { + return sprintf('%s', $index); } + + return $index; } + + $index = trim($index); + + // @TODO: move the date/time format logic into here and have Api/Controllers/I18n call this for each type? + $dateTimeIniSettings = [ + 'language.dateformat', + 'language.jsdateformat', + 'language.timeformat', + 'language.jstimeformat', + 'language.momentJSDate', + ]; + + $dateTimeFormat = $this->getCustomDateTimeFormat(); + + if (in_array($index, $dateTimeIniSettings) && $convertValue) { + $isMoment = stristr($index, 'momentjs') !== false; + $isJs = stristr($index, '.js') !== false; + $isDate = stristr($index, 'date') !== false; + + if ($isJs || $isMoment) { + return $this->convertDateFormatToJS($isDate ? $dateTimeFormat['date'] : $dateTimeFormat['time'], $isMoment); + } else if ($isDate) { + return $this->convertDateFormatToJS($dateTimeFormat['date'], false); + } + } else if ($index === 'language.dateformat') { + return $dateTimeFormat['date']; + } else if ($index === 'language.timeformat') { + return $dateTimeFormat['time']; + } + + return (string) $this->ini_array[$index]; } /** diff --git a/app/Core/Middleware/Updated.php b/app/Core/Middleware/Updated.php index f62b313281..a3988a03a6 100644 --- a/app/Core/Middleware/Updated.php +++ b/app/Core/Middleware/Updated.php @@ -25,7 +25,7 @@ public function handle(IncomingRequest $request, Closure $next): Response $dbVersion = app()->make(SettingRepository::class)->getSetting('db-version'); $settingsDbVersion = app()->make(AppSettings::class)->dbVersion; - $_SESSION['isUpdated'] = $dbVersion == $settingsDbVersion; + $_SESSION['isUpdated'] ??= $dbVersion == $settingsDbVersion; self::dispatch_event('system_update', ['dbVersion' => $dbVersion, 'settingsDbVersion' => $settingsDbVersion]); diff --git a/app/Domain/Plugins/Controllers/Details.php b/app/Domain/Plugins/Controllers/Details.php index 90958fdc9a..80ac7a7f2b 100644 --- a/app/Domain/Plugins/Controllers/Details.php +++ b/app/Domain/Plugins/Controllers/Details.php @@ -32,13 +32,17 @@ public function get(): Response } /** - * @var \Leantime\Domain\Plugins\Models\MarketplacePlugin[] $versions + * @var \Leantime\Domain\Plugins\Models\MarketplacePlugin|false $plugin */ - $versions = $this->pluginService->getMarketplacePlugin( + $plugin = $this->pluginService->getMarketplacePlugin( $this->incomingRequest->query->get('id'), ); - $this->tpl->assign('versions', $versions); + if (! $plugin) { + return $this->tpl->display('error.error404', 'blank'); + } + + $this->tpl->assign('plugin', $plugin); return $this->tpl->display('plugins.plugindetails', 'blank'); } diff --git a/app/Domain/Plugins/Hxcontrollers/Details.php b/app/Domain/Plugins/Hxcontrollers/Details.php index ffcde8b5be..54c6921b57 100644 --- a/app/Domain/Plugins/Hxcontrollers/Details.php +++ b/app/Domain/Plugins/Hxcontrollers/Details.php @@ -3,6 +3,7 @@ namespace Leantime\Domain\Plugins\Hxcontrollers; use Illuminate\Contracts\Container\BindingResolutionException; +use Illuminate\Http\Exceptions\HttpResponseException; use Leantime\Core\HtmxController; use Leantime\Domain\Plugins\Models\MarketplacePlugin; use Leantime\Domain\Plugins\Services\Plugins as PluginService; @@ -39,22 +40,30 @@ public function init( */ public function install(): string { - $pluginModel = app(MarketplacePlugin::class); $pluginProps = $this->incomingRequest->request->all()['plugin']; - collect($pluginProps)->each(fn($value, $key) => $pluginModel->{$key} = $value ?? ''); + $version = $pluginProps['version']; + unset($pluginProps['version']); + $builder = build(new MarketplacePlugin); - if (! empty($pluginModel->identifier)) { - $pluginModel->identifier = Str::studly($pluginModel->identifier); + foreach ($pluginProps as $key => $value) { + $newValue = json_decode(json: $value, flags: JSON_OBJECT_AS_ARRAY); + + if (json_last_error() === JSON_ERROR_NONE) { + $value = $newValue; + } + + $builder->set($key, $value); } - $this->tpl->assign('versions', [$pluginModel->version => $pluginModel]); + $pluginModel = $builder->get(); + + $this->tpl->assign('plugin', $pluginModel); try { - $this->pluginService->installMarketplacePlugin($pluginModel); + $this->pluginService->installMarketplacePlugin($pluginModel, $version); } catch (\Throwable $e) { - Frontcontroller::setResponseCode(200); - $this->tpl->assign('formError', $e->getMessage()); - return 'plugin-installation'; + $this->tpl->assign('formError', $e->getMessage()); + return 'plugin-installation'; } if ($this->pluginService->isPluginEnabled($pluginModel->identifier)) { diff --git a/app/Domain/Plugins/Hxcontrollers/Marketplaceplugins.php b/app/Domain/Plugins/Hxcontrollers/Marketplaceplugins.php index 9c7172841f..7e20e6080c 100644 --- a/app/Domain/Plugins/Hxcontrollers/Marketplaceplugins.php +++ b/app/Domain/Plugins/Hxcontrollers/Marketplaceplugins.php @@ -37,6 +37,7 @@ public function init( */ public function getlist(): void { + /** @var MarketplacePlugin[] $plugins */ $plugins = $this->pluginService->getMarketplacePlugins( $this->incomingRequest->query->get('page', 1), $this->incomingRequest->query->get('search', ''), diff --git a/app/Domain/Plugins/Models/MarketplacePlugin.php b/app/Domain/Plugins/Models/MarketplacePlugin.php index 2d68576fce..7989a78465 100644 --- a/app/Domain/Plugins/Models/MarketplacePlugin.php +++ b/app/Domain/Plugins/Models/MarketplacePlugin.php @@ -14,13 +14,18 @@ class MarketplacePlugin implements PluginDisplayStrategy public string $excerpt; public string $description; public string $imageUrl; - public array|string $authors; - public string $version; + public string $vendorDisplayName; + public int $vendorId; + public string $vendorEmail; public string $marketplaceUrl; - public ?string $price; + public ?string $startingPrice; + public ?array $pricingTiers; public ?string $license; public ?string $rating; + public ?int $reviewCount; + public array $reviews; public string $marketplaceId; + public array $compatibility; public function getCardDesc(): string { @@ -31,12 +36,28 @@ public function getMetadataLinks(): array { $links = []; - if (! empty($plugin->authors)) { - $author = is_array($plugin->authors) ? $plugin->authors[0] : $plugin->authors; - $links[] = [ + if (! empty($this->vendorDisplayName) && (! empty($this->vendorId) || ! empty($this->vendorEmail))) { + $vendor = [ 'prefix' => __('text.by'), - 'link' => "mailto:{$author->email}", - 'text' => $author->name, + 'display' => $this->vendorDisplayName, + ]; + + $vendor['link'] = ! empty($this->vendorId) ? "/plugins/marketplace?" . http_build_query(['vendor_id' => $this->vendorId]) : "mailto:{$this->vendorEmail}"; + + $links[] = $vendor; + } + + if (! empty($this->startingPrice)) { + $links[] = [ + 'prefix' => __('text.starting_at', 'Starting At'), + 'display' => $this->startingPrice, + ]; + } + + if (! empty($this->rating)) { + $links[] = [ + 'prefix' => __('text.rating', 'Rating: '), + 'display' => $this->rating, ]; } diff --git a/app/Domain/Plugins/Services/Plugins.php b/app/Domain/Plugins/Services/Plugins.php index 2c9ba54e0b..0add6e3ae3 100644 --- a/app/Domain/Plugins/Services/Plugins.php +++ b/app/Domain/Plugins/Services/Plugins.php @@ -390,12 +390,15 @@ public function getMarketplacePlugins(int $page, string $query = ''): array if (isset($pluginArray["data"])) { foreach ($pluginArray["data"] as $plugin) { $plugins[] = build(new MarketplacePlugin()) - ->setIdentifier($plugin['identifier'] ?? '') - ->setName($plugin['post_title'] ?? '') - ->setExcerpt($plugin['excerpt'] ?? '') - ->set('imageUrl', $plugin['featured_image'] ?? '') - /** @todo Send from marketplace **/ - ->setAuthors('') + ->set('identifier', $plugin['identifier'] ?? '') + ->set('name', $plugin['post_title'] ?? '') + ->set('excerpt', $plugin['excerpt'] ?? '') + ->set('imageUrl', $plugin['icon'] ?? '') + ->set('vendorDisplayName', $plugin['vendor'] ?? '') + ->set('vendorId', $plugin['vendor_id'] ?? '') + ->set('vendorEmail', $plugin['vendor_email'] ?? '') + ->set('startingPrice', '$' . ($plugin['price'] ?? '') . (! empty($plugin['sub_interval']) ? '/' . $plugin['sub_interval'] : '')) + ->set('rating', $plugin['rating'] ?? '') ->get(); } } @@ -407,46 +410,49 @@ public function getMarketplacePlugins(int $page, string $query = ''): array * @param string $identifier * @return MarketplacePlugin[] */ - public function getMarketplacePlugin(string $identifier): array + public function getMarketplacePlugin(string $identifier): MarketplacePlugin|false { - return Http::withoutVerifying()->get("$this->marketplaceUrl/ltmp-api/versions/$identifier") - ->collect() - ->mapWithKeys(function ($data, $version) use ($identifier) { - static $count; - $count ??= 0; - - $pluginModel = app()->make(MarketplacePlugin::class); - $pluginModel->identifier = $identifier; - $pluginModel->name = $data['name']; - $pluginModel->excerpt = ''; - $pluginModel->description = $data['description']; - $pluginModel->marketplaceUrl = $data['marketplace_url']; - $pluginModel->thumbnailUrl = $data['thumbnail_url'] ?: ''; - $pluginModel->authors = $data['author']; - $pluginModel->version = $version; - $pluginModel->price = $data['price']; - $pluginModel->license = $data['license']; - $pluginModel->rating = $data['rating']; - $pluginModel->marketplaceId = $data['product_id']; - - return [$count++ => $pluginModel]; - }) - ->all(); + $response = Http::withoutVerifying()->get("$this->marketplaceUrl/ltmp-api/details/$identifier"); + + if (! $response->ok()) { + return false; + } + + $data = $response->json(); + + return build(new MarketplacePlugin) + ->set('identifier', $identifier ?? '') + ->set('name', $data['name'] ?? '') + ->set('icon', $data['icon'] ?? '') + ->set('description', nl2br($data['description'] ?? '')) + ->set('marketplaceUrl', $data['marketplaceUrl'] ?? '') + ->set('vendorId', (int) $data['vendor']['id'] ?? null) + ->set('vendorDisplayName', $data['vendor']['name'] ?? '') + ->set('rating', $data['reviews']['average'] ?? 'N/A') + ->set('reviewCount', $data['reviews']['count'] ?? 0) + ->set('reviews', $data['reviews']['list']) + ->set('marketplaceId', $data['productId']) + ->set('pricingTiers', $data['tiers']) + ->set('categories', $data['categories'] ?? []) + ->set('tags', $data['tags'] ?? []) + ->set('compatibility', $data['compatibility'] ?? []) + ->get(); } /** * @param MarketplacePlugin $plugin + * @param string $version * @return void * @throws Illuminate\Http\Client\RequestException|Exception */ - public function installMarketplacePlugin(MarketplacePlugin $plugin): void + public function installMarketplacePlugin(MarketplacePlugin $plugin, string $version): void { $response = Http::withoutVerifying()->withHeaders([ 'X-License-Key' => $plugin->license, 'X-Instance-Id' => $this->settingsService->getCompanyId(), 'X-User-Count' => $this->usersService->getNumberOfUsers(activeOnly: true, includeApi: false), ]) - ->get("{$this->marketplaceUrl}/ltmp-api/download/{$plugin->marketplaceId}"); + ->get("{$this->marketplaceUrl}/ltmp-api/download/{$plugin->identifier}/{$version}"); $response->throwIf(in_array(true, [ ! $response->ok(), @@ -455,22 +461,18 @@ public function installMarketplacePlugin(MarketplacePlugin $plugin): void $filename = $response->header('Content-Disposition'); $filename = substr($filename, strpos($filename, 'filename=') + 9); + $foldername = Str::studly(basename($filename, '.zip')); + $filename = Str::finish($foldername, '.zip'); - if (! str_starts_with($filename, $plugin->identifier)) { - throw new \Exception('Wrong file downloaded'); - } - - if ( - ! file_put_contents( - $temporaryFile = Str::finish(sys_get_temp_dir(), '/') . $filename, - $response->body() - ) - ) { + if (! file_put_contents( + $temporaryFile = Str::finish(sys_get_temp_dir(), '/') . $filename, + $response->body() + )) { throw new \Exception('Could not download plugin'); } if ( - is_dir($pluginDir = "{$this->pluginDirectory}{$plugin->identifier}") + is_dir($pluginDir = "{$this->pluginDirectory}{$foldername}") && ! File::deleteDirectory($pluginDir) ) { throw new \Exception('Could not remove existing plugin'); @@ -503,7 +505,7 @@ public function installMarketplacePlugin(MarketplacePlugin $plugin): void unlink($temporaryFile); # read the composer.json content from the plugin phar file - $pluginModel = $this->createPluginFromComposer($plugin->identifier, $plugin->license); + $pluginModel = $this->createPluginFromComposer($foldername, $plugin->license); if (! $this->pluginRepository->addPlugin($pluginModel)) { throw new \Exception('Could not add plugin to database'); diff --git a/app/Domain/Plugins/Templates/partials/marketplace/plugincontrols.blade.php b/app/Domain/Plugins/Templates/partials/marketplace/plugincontrols.blade.php index 584b377aaa..29f14598e3 100644 --- a/app/Domain/Plugins/Templates/partials/marketplace/plugincontrols.blade.php +++ b/app/Domain/Plugins/Templates/partials/marketplace/plugincontrols.blade.php @@ -1,4 +1,4 @@ -
+
Learn More diff --git a/app/Domain/Plugins/Templates/partials/plugin.blade.php b/app/Domain/Plugins/Templates/partials/plugin.blade.php index eafacfcfb5..4e0aa2ba51 100644 --- a/app/Domain/Plugins/Templates/partials/plugin.blade.php +++ b/app/Domain/Plugins/Templates/partials/plugin.blade.php @@ -5,13 +5,14 @@
-
- +
+ @if($plugin instanceof \Leantime\Domain\Plugins\Models\MarketplacePlugin) -
+
Certified
@@ -25,12 +26,12 @@
@endif -
-
+
+
+ @if (! empty($desc = $plugin->getCardDesc()))

{{ $desc }}

@endif -
diff --git a/app/Domain/Plugins/Templates/plugindetails.blade.php b/app/Domain/Plugins/Templates/plugindetails.blade.php index 1c3a63fbd2..0ee667e5cc 100644 --- a/app/Domain/Plugins/Templates/plugindetails.blade.php +++ b/app/Domain/Plugins/Templates/plugindetails.blade.php @@ -1,76 +1,148 @@ @extends($layout) @section('content') - - - @foreach ($versions as $plugin) - {{ $plugin->version }} - @endforeach - +
+
+ +
+

+ {{ $plugin->name }} + @if (!empty($plugin->vendorDisplayName) && !empty($plugin->vendorId)) + {{ __('text.by') }} {{ $plugin->vendorDisplayName }} + @endif +

+

+ @if ((int) $plugin->reviewCount > 0) + Reviews: {{ $plugin->reviewCount }}
+ @endif - - @foreach ($versions as $plugin) - - @if (! empty($plugin->thumbnailUrl)) - {{ $plugin->thumbnailUrl }} + @if ((int) $plugin->rating > 0) + Rating: {{ $plugin->rating }}
@endif - @if (! empty($plugin->name)) -

{{ $plugin->name }}

+ @if (! empty($plugin->categories)) + Categories: @foreach ($plugin->categories as $category) + {{ $category['name'] }} + @endforeach
@endif - @if (! empty($plugin->description)) -

{{ $plugin->description }}

+ @if (! empty($plugin->tags)) + Tags: @foreach ($plugin->tags as $tag) + {{ $tag['name'] }} + @endforeach
@endif +

+
+
+ + + + @if (! empty($plugin->description)) + Overview + @endif + + @if ($plugin->reviewCount > 0) + Reviews + @endif + + @if (! empty($plugin->compatibility)) + Compatibility + @endif + -
- @if (! empty($plugin->marketplaceUrl)) - Get a license - @else - This plugin currently isn't available for purchase. - @endif + + @if (! empty($plugin->description)) +
{!! $plugin->description !!}
+ @endif - @fragment('plugin-installation') - @if (! empty($plugin->marketplaceId)) - @if (isset($formNotification) && ! empty($formNotification)) -
{!! $formNotification !!}
- @else -
- @if (! empty($formError)) -
{!! $formError !!}
- @endif -
- @foreach ((array) $plugin as $prop => $value) - - @endforeach - - Install -
- -
-
-
- @endif - @else - This plugin currently isn't available for installation. + @if ($plugin->reviewCount > 0) + +
+ @foreach($plugin['reviews'] as $review) +

{{ $review }}

+ @endforeach +
+
+ @endif + + @if (! empty($plugin->compatibility)) + + + + + + + + + + @foreach ($plugin->compatibility as $compatibility) + + + + + @endforeach + +
Plugin Version:Compatible With Leantime Versions:
{{ $compatibility['version_number'] }}{{ $compatibility['supported_version_from'] }} - {{ $compatibility['supported_version_to'] }}
+
+ @endif +
+ + +
+ @if (! empty($plugin->marketplaceUrl)) + Get a license + @else + This plugin currently isn't available for purchase. + @endif + + @fragment('plugin-installation') + @if (! empty($plugin->marketplaceId)) + @if (isset($formNotification) && ! empty($formNotification)) +
{!! $formNotification !!}
+ @else +
+ @if (! empty($formError)) +
{!! $formError !!}
@endif - @endfragment -
- - @endforeach - - +
+ @php + if (isset($plugin->version)) { + unset($plugin->version); + } + @endphp + @foreach ((array) $plugin as $prop => $value) + + @endforeach + + + Install +
+ +
+
+
+ @endif + @else + This plugin currently isn't available for installation. + @endif + @endfragment +
+
@endsection diff --git a/app/Domain/Users/Repositories/Users.php b/app/Domain/Users/Repositories/Users.php index 49e9e9b310..a3235d225d 100644 --- a/app/Domain/Users/Repositories/Users.php +++ b/app/Domain/Users/Repositories/Users.php @@ -200,32 +200,26 @@ public function getUserByEmail($email, $status = "a"): array | false /** * @return int */ - public function getNumberOfUsers($filters = []): int + public function getNumberOfUsers($activeOnly = false, $includeApi = true): int { - $sql = "SELECT COUNT(id) AS userCount FROM `zp_user`"; + $conditions = []; - foreach ($filters as $key => $filter) { - if ( - ! isset($filter[0], $filter[1], $filter[2]) - || ! in_array($filter[1], ['=', '!=', '>', '<', '>=', '<=', 'LIKE', 'NOT LIKE']) - || ! in_array($filter[0], ['status', 'source']) - ) { - unset($filters[$key]); - continue; - } + if ($activeOnly) { + $conditions[] = "status = 'a'"; + } - $appender = str_contains('WHERE', $sql) ? ' AND ' : ' WHERE '; + if ($includeApi) { + $conditions[] = "(source != 'api' OR source IS NULL)"; + } - $sql .= "{$appender} {$filter[0]} {$filter[1]} ':{$filter[0]}'"; + foreach ($conditions as $condition) { + $sql .= str_contains($sql, 'WHERE') ? ' AND' : ' WHERE'; + $sql .= " $condition"; } $stmn = $this->db->database->prepare($sql); - foreach ($filters as $key => $filter) { - $stmn->bindValue(":{$filter[0]}", $filter[2], PDO::PARAM_STR); - } - $stmn->execute(); $values = $stmn->fetch(); $stmn->closeCursor(); diff --git a/app/Domain/Users/Services/Users.php b/app/Domain/Users/Services/Users.php index 6a69d53456..8b019af981 100644 --- a/app/Domain/Users/Services/Users.php +++ b/app/Domain/Users/Services/Users.php @@ -75,9 +75,11 @@ public function editUser($values, $id): bool } /** + * @param bool $activeOnly + * @param bool $includeApi * @return int */ - public function getNumberOfUsers($activeOnly = false, $includeApi = true): int + public function getNumberOfUsers(bool $activeOnly = false, bool $includeApi = true): int { $filters = []; diff --git a/app/Views/Templates/components/badge.blade.php b/app/Views/Templates/components/badge.blade.php new file mode 100644 index 0000000000..cfef7d1266 --- /dev/null +++ b/app/Views/Templates/components/badge.blade.php @@ -0,0 +1,26 @@ +@props([ + 'asLink' => false, + 'color' => match ($color ?? null) { + 'yellow' => ['tw-yellow-500', 'tw-bg-yellow-500'], + 'red' => ['tw-red-500', 'tw-bg-red-500'], + 'blue' => ['tw-blue-500', 'tw-bg-blue-500'], + 'green' => ['tw-green', 'tw-bg-green'], + 'primary' => ['tw-primary', 'tw-bg-primary'], + 'gray', default => ['tw-gray-900', 'tw-bg-gray-900'], + }, +]) + +@if ($asLink) +merge([ + 'class' => 'tw-mix-blend-difference tw-px-2.5 tw-py-0.5 tw-rounded' . ($asLink ? 'text-white' . $color[1] : $color[0] . 'tw-bg-gray-300'), +] + ($asLink ? ['href' => $url ?? '#'] : [])) }}> + {{ $slot }} +@if ($asLink) + +@else + +@endif diff --git a/app/Views/Templates/components/inlineLinks.blade.php b/app/Views/Templates/components/inlineLinks.blade.php index 27d5b20ad3..67403e7020 100644 --- a/app/Views/Templates/components/inlineLinks.blade.php +++ b/app/Views/Templates/components/inlineLinks.blade.php @@ -1,6 +1,11 @@ -@foreach($links as $link) - @if (! empty($link['text'])) - {{ ! $loop->first ? '|' : '' }} - {{ $link['prefix'] ?? '' }} @if (! empty($link['link'])) {{ $link['text'] }} @else {{ $link['text'] }} @endif - @endif -@endforeach +
+ @foreach ($links as $link) + @if (empty($link['display'])) + @continue + @endif + + + {{ $link['prefix'] ?? '' }} @if (! empty($link['link'])) {{ $link['display'] }} @else {{ $link['display'] }} @endif + + @endforeach +
diff --git a/app/helpers.php b/app/helpers.php index 4712643233..7634edd648 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -56,12 +56,13 @@ function bootstrap_minimal_app(): Application * Translate a string. * * @param string $index + * @param string $default * @return string * @throws BindingResolutionException */ - function __(string $index): string + function __(string $index, $default = ''): string { - return app()->make(Language::class)->__($index); + return app()->make(Language::class)->__(index: $index, default: $default); } } diff --git a/public/dist/mix-manifest.json b/public/dist/mix-manifest.json index 452b0805bf..1e656a91dd 100644 --- a/public/dist/mix-manifest.json +++ b/public/dist/mix-manifest.json @@ -19,45 +19,36 @@ "/images/Screenshots/Blueprints.png": "/images/Screenshots/Blueprints.png", "/images/Screenshots/Calendar.png": "/images/Screenshots/Calendar.png", "/images/Screenshots/Confetti.png": "/images/Screenshots/Confetti.png", + "/images/Screenshots/Dashboard.png": "/images/Screenshots/Dashboard.png", "/images/Screenshots/Docs.png": "/images/Screenshots/Docs.png", + "/images/Screenshots/DocsEmbed.png": "/images/Screenshots/DocsEmbed.png", + "/images/Screenshots/DocsStandard.png": "/images/Screenshots/DocsStandard.png", "/images/Screenshots/Files.png": "/images/Screenshots/Files.png", "/images/Screenshots/Goals.png": "/images/Screenshots/Goals.png", "/images/Screenshots/Home.png": "/images/Screenshots/Home.png", + "/images/Screenshots/Ideas.png": "/images/Screenshots/Ideas.png", "/images/Screenshots/Kanban2.png": "/images/Screenshots/Kanban2.png", "/images/Screenshots/Leancanvas.png": "/images/Screenshots/Leancanvas.png", + "/images/Screenshots/Milestones.png": "/images/Screenshots/Milestones.png", "/images/Screenshots/MyProjects.png": "/images/Screenshots/MyProjects.png", + "/images/Screenshots/Portfolio.png": "/images/Screenshots/Portfolio.png", "/images/Screenshots/ProjectDashboard.png": "/images/Screenshots/ProjectDashboard.png", "/images/Screenshots/Reports.png": "/images/Screenshots/Reports.png", + "/images/Screenshots/Strategy.png": "/images/Screenshots/Strategy.png", "/images/Screenshots/Task.png": "/images/Screenshots/Task.png", "/images/Screenshots/Tasks-calendar.png": "/images/Screenshots/Tasks-calendar.png", "/images/Screenshots/Tasks-kanban.png": "/images/Screenshots/Tasks-kanban.png", "/images/Screenshots/Tasks-list.png": "/images/Screenshots/Tasks-list.png", "/images/Screenshots/Tasks-table.png": "/images/Screenshots/Tasks-table.png", "/images/Screenshots/Tasks-timeline.png": "/images/Screenshots/Tasks-timeline.png", + "/images/Screenshots/TimesheetAdmin.png": "/images/Screenshots/TimesheetAdmin.png", "/images/Screenshots/Timesheets.png": "/images/Screenshots/Timesheets.png", + "/images/Screenshots/ToDoKanban.png": "/images/Screenshots/ToDoKanban.png", + "/images/Screenshots/ToDoTable.png": "/images/Screenshots/ToDoTable.png", + "/images/Screenshots/ToDoView.png": "/images/Screenshots/ToDoView.png", + "/images/Screenshots/UserDashboard.png": "/images/Screenshots/UserDashboard.png", "/images/ajaxLoader.gif": "/images/ajaxLoader.gif", "/images/apple-touch-icon.png": "/images/apple-touch-icon.png", - "/images/backgrounds/12067561_4909526.svg": "/images/backgrounds/12067561_4909526.svg", - "/images/backgrounds/4912.jpg": "/images/backgrounds/4912.jpg", - "/images/backgrounds/5714488.jpg": "/images/backgrounds/5714488.jpg", - "/images/backgrounds/6191247.jpg": "/images/backgrounds/6191247.jpg", - "/images/backgrounds/bg1.png": "/images/backgrounds/bg1.png", - "/images/backgrounds/bg2.png": "/images/backgrounds/bg2.png", - "/images/backgrounds/bg3.png": "/images/backgrounds/bg3.png", - "/images/backgrounds/bg4.png": "/images/backgrounds/bg4.png", - "/images/backgrounds/bg5.png": "/images/backgrounds/bg5.png", - "/images/backgrounds/bg6.png": "/images/backgrounds/bg6.png", - "/images/backgrounds/bg7.png": "/images/backgrounds/bg7.png", - "/images/backgrounds/bgLayer.png": "/images/backgrounds/bgLayer.png", - "/images/backgrounds/project_bg.png": "/images/backgrounds/project_bg.png", - "/images/backgrounds/squares.png": "/images/backgrounds/squares.png", - "/images/bg1.png": "/images/bg1.png", - "/images/bg2.png": "/images/bg2.png", - "/images/bg3.png": "/images/bg3.png", - "/images/bg4.png": "/images/bg4.png", - "/images/bg5.png": "/images/bg5.png", - "/images/bg6.png": "/images/bg6.png", - "/images/bg7.png": "/images/bg7.png", "/images/calarrow.png": "/images/calarrow.png", "/images/canvas.PNG": "/images/canvas.PNG", "/images/chosen-sprite-light.png": "/images/chosen-sprite-light.png", @@ -133,15 +124,6 @@ "/images/throbber.gif": "/images/throbber.gif", "/images/undraw_progressive_app_m9ms.png": "/images/undraw_progressive_app_m9ms.png", "/images/zulip-org-logo.png": "/images/zulip-org-logo.png", - "/fonts/Atkinson-Hyperlegib/Atkinson-Hyperlegible-Bold-102.ttf": "/fonts/Atkinson-Hyperlegib/Atkinson-Hyperlegible-Bold-102.ttf", - "/fonts/Atkinson-Hyperlegib/Atkinson-Hyperlegible-Bold-102a.woff2": "/fonts/Atkinson-Hyperlegib/Atkinson-Hyperlegible-Bold-102a.woff2", - "/fonts/Atkinson-Hyperlegib/Atkinson-Hyperlegible-BoldItalic-102.ttf": "/fonts/Atkinson-Hyperlegib/Atkinson-Hyperlegible-BoldItalic-102.ttf", - "/fonts/Atkinson-Hyperlegib/Atkinson-Hyperlegible-BoldItalic-102a.woff2": "/fonts/Atkinson-Hyperlegib/Atkinson-Hyperlegible-BoldItalic-102a.woff2", - "/fonts/Atkinson-Hyperlegib/Atkinson-Hyperlegible-Font-License-2020-1104.pdf": "/fonts/Atkinson-Hyperlegib/Atkinson-Hyperlegible-Font-License-2020-1104.pdf", - "/fonts/Atkinson-Hyperlegib/Atkinson-Hyperlegible-Italic-102.ttf": "/fonts/Atkinson-Hyperlegib/Atkinson-Hyperlegible-Italic-102.ttf", - "/fonts/Atkinson-Hyperlegib/Atkinson-Hyperlegible-Italic-102a.woff2": "/fonts/Atkinson-Hyperlegib/Atkinson-Hyperlegible-Italic-102a.woff2", - "/fonts/Atkinson-Hyperlegib/Atkinson-Hyperlegible-Regular-102.ttf": "/fonts/Atkinson-Hyperlegib/Atkinson-Hyperlegible-Regular-102.ttf", - "/fonts/Atkinson-Hyperlegib/Atkinson-Hyperlegible-Regular-102a.woff2": "/fonts/Atkinson-Hyperlegib/Atkinson-Hyperlegible-Regular-102a.woff2", "/fonts/Atkinson-Hyperlegible-Bold-102.ttf": "/fonts/Atkinson-Hyperlegible-Bold-102.ttf", "/fonts/Atkinson-Hyperlegible-Bold-102a.woff2": "/fonts/Atkinson-Hyperlegible-Bold-102a.woff2", "/fonts/Atkinson-Hyperlegible-BoldItalic-102.ttf": "/fonts/Atkinson-Hyperlegible-BoldItalic-102.ttf", @@ -162,6 +144,8 @@ "/fonts/Roboto-Medium.woff2": "/fonts/Roboto-Medium.woff2", "/fonts/Roboto-MediumItalic.ttf": "/fonts/Roboto-MediumItalic.ttf", "/fonts/Roboto-MediumItalic.woff2": "/fonts/Roboto-MediumItalic.woff2", + "/fonts/Roboto-Regular.ttf": "/fonts/Roboto-Regular.ttf", + "/fonts/Roboto-Regular.woff2": "/fonts/Roboto-Regular.woff2", "/fonts/Shantell_Sans-Informal_Bold.woff2": "/fonts/Shantell_Sans-Informal_Bold.woff2", "/fonts/Shantell_Sans-Informal_Bold_Italic.woff2": "/fonts/Shantell_Sans-Informal_Bold_Italic.woff2", "/fonts/Shantell_Sans-Informal_Regular.woff2": "/fonts/Shantell_Sans-Informal_Regular.woff2", diff --git a/tailwind.config.js b/tailwind.config.js index 4c0647ba78..7102a154db 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -10,10 +10,10 @@ module.exports = { extend: { colors: { 'primary': { - default: 'var(--primary-color)', + DEFAULT: 'var(--primary-color)', }, 'secondary': { - default: 'var(--secondary-color)', + DEFAULT: 'var(--secondary-color)', }, }, fontSize: { @@ -47,7 +47,16 @@ module.exports = { 'm': '15px', 'l': '20px', 'xl': '30px', - } + }, + gap: { + 'none': '0', + 'xs': '5px', + 'sm': '10px', + 'base': '15px', + 'm': '15px', + 'l': '20px', + 'xl': '30px', + }, }, }, plugins: [],