diff --git a/app/Console/Commands/ClearIconCache.php b/app/Console/Commands/ClearIconCache.php new file mode 100644 index 00000000..11a6203d --- /dev/null +++ b/app/Console/Commands/ClearIconCache.php @@ -0,0 +1,21 @@ +info('SVG icon cache cleared.'); + } +} diff --git a/app/Providers/SvgIconServiceProvider.php b/app/Providers/SvgIconServiceProvider.php new file mode 100644 index 00000000..2b7da9a2 --- /dev/null +++ b/app/Providers/SvgIconServiceProvider.php @@ -0,0 +1,22 @@ +app->singleton(SvgIconRegistry::class); + } +} diff --git a/app/View/Components/Icons/IconComponent.php b/app/View/Components/Icons/IconComponent.php index c8a8ab22..2e89180f 100644 --- a/app/View/Components/Icons/IconComponent.php +++ b/app/View/Components/Icons/IconComponent.php @@ -4,18 +4,14 @@ namespace App\View\Components\Icons; -use App\Traits\IconTrait; use Illuminate\Contracts\View\View; use Illuminate\View\Component; final class IconComponent extends Component { - use IconTrait; - public string $altText = ''; public string $class = ''; public string $filename = ''; - public string $iconPath = ''; public string $titleText = ''; public function __construct( @@ -32,13 +28,11 @@ public function __construct( public function render(): View { - $this->iconPath = $this->getIconPath($this->filename) ?: 'icon-path'; - return view('components.icons.icon-component', [ - 'iconPath' => $this->iconPath, + 'filename' => $this->filename, + 'class' => $this->class, 'altText' => $this->altText, 'titleText' => $this->titleText, - 'class' => $this->class, ]); } } diff --git a/app/View/Components/Icons/SvgIconComponent.php b/app/View/Components/Icons/SvgIconComponent.php new file mode 100644 index 00000000..e7814aee --- /dev/null +++ b/app/View/Components/Icons/SvgIconComponent.php @@ -0,0 +1,44 @@ +registry = $registry; + $this->filename = $filename; + $this->class = $class; + $this->label = $label; + $this->title = $title; + } + + public function render(): View + { + $firstRender = $this->registry->isFirstRender($this->filename); + $viewName = $this->registry->getViewName($this->filename); + + return view($viewName, [ + 'class' => $this->class, + 'firstRender' => $firstRender, + 'label' => $this->label, + 'title' => $this->title, + ]); + } +} diff --git a/app/View/Components/Icons/SvgIconRegistry.php b/app/View/Components/Icons/SvgIconRegistry.php new file mode 100644 index 00000000..bcd56ed3 --- /dev/null +++ b/app/View/Components/Icons/SvgIconRegistry.php @@ -0,0 +1,170 @@ +used[$filename]); + $this->used[$filename] = true; + return $firstRender; + } + + /** + * Returns the name of the view that renders the icon with this filename. + * + * If the view has not been compiled yet, or is out-of-date, it will be + * compiled and saved to the icon-cache directory. + * + * @param string $filename The filename of the icon to get the view name for. + * @return string The name of the view that renders the icon. + */ + public function getViewName(string $filename): string + { + if (isset($this->compiled[$filename])) { + return $this->compiled[$filename]; + } + + $sourcePath = base_path(implode(DIRECTORY_SEPARATOR, [self::ICON_DIRECTORY, "{$filename}.svg"])); + + $viewId = Str::slug($filename); + $compiledPath = storage_path(implode(DIRECTORY_SEPARATOR, [self::BLADE_DIRECTORY, self::VIEW_PARENT_PATH, "{$viewId}.blade.php"])); + + if (!file_exists($sourcePath)) { + throw new \RuntimeException("SVG icon not found at {$sourcePath} for {$filename}"); + } + + if (!file_exists($compiledPath) || (filemtime($sourcePath) > filemtime($compiledPath))) { + $svg = file_get_contents($sourcePath); + $compiled = $this->transformSvgToBlade($svg, $viewId); + + @mkdir(dirname($compiledPath), 0777, true); + file_put_contents($compiledPath, $compiled); + } + + $this->compiled[$filename] = implode('.', [self::VIEW_PARENT_PATH, $viewId]); + + return $this->compiled[$filename]; + } + + protected function buildSvgAttributeString(array $attributes): string + { + $attributes = array_filter($attributes); + if (empty($attributes)) { + return ''; + } + + return ' ' . implode(' ', array_map( + fn($key, $value) => $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"', + array_keys($attributes), + $attributes, + )); + } + + /** + * Transforms the SVG markup into a Blade view that renders the icon. + * + * @param string $svg The SVG markup to transform. + * @param string $viewId The ID of the view that will render the icon. + * @return string The Blade view that renders the icon. + */ + protected function transformSvgToBlade(string $svg, string $viewId): string + { + $dom = new \DOMDocument(); + $dom->loadXML($svg); + + $svgElement = $dom->documentElement; + + // Attributes to use on uses of the SVG element. + $width = $svgElement->getAttribute('width'); + $height = $svgElement->getAttribute('height'); + + // Attributes to copy to the SVG symbol + $viewBox = $svgElement->getAttribute('viewBox'); + $preserveAspectRatio = $svgElement->getAttribute('preserveAspectRatio'); + + // Extract title if present to use as a default title for the icon. + $titleElement = $svgElement->getElementsByTagName('title')->item(0); + $title = $titleElement ? $titleElement->textContent : ''; + + // Extract the aria-label if present to use as a default label for the icon. + $label = $svgElement->getAttribute('aria-label'); + + $output = "@php \$title ??= '" . addslashes($title) . "'; @endphp\n"; + $output .= "@php \$titleId = '$viewId-' . uniqid(); @endphp\n"; + $output .= "@php \$label ??= '" . addslashes($label) . "'; @endphp\n"; + + $output .= 'buildSvgAttributeString([ + 'width' => $width, + 'height' => $height, + ]) . "\n"; + + $output .= " @if (!empty(\$label)) aria-label=\"{{ \$label }}\" @endif\n"; + $output .= " @if (!empty(\$title)) aria-describedby=\"{{ \$titleId }}\" @endif\n"; + $output .= ">\n"; + + $output .= " @if (!empty(\$title))\n"; + $output .= " {{ \$title }}\n"; + $output .= " @endif\n"; + + $output .= " @if (\$firstRender)\n"; + $output .= " \n"; + $output .= " buildSvgAttributeString([ + 'viewBox' => $viewBox, + 'preserveAspectRatio' => $preserveAspectRatio, + ]); + $output .= ">\n"; + + // Add all child nodes except title + foreach ($svgElement->childNodes as $child) { + if ($child instanceof \DOMElement && $child->tagName === 'title') { + continue; + } + + $content = mb_trim($child->ownerDocument->saveXML($child)); + if (!empty($content)) { + $output .= " $content\n"; + } + } + + $output .= " \n"; + $output .= " \n"; + $output .= " @endif\n"; + $output .= " \n"; + $output .= "\n"; + + return $output; + } +} diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 6bc212a7..ea04cb73 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -8,6 +8,7 @@ use App\Providers\FortifyServiceProvider; use App\Providers\HorizonServiceProvider; use App\Providers\RepositoryServiceProvider; +use App\Providers\SvgIconServiceProvider; use App\Providers\TelescopeServiceProvider; use App\Providers\ViewComposerServiceProvider; @@ -18,6 +19,7 @@ FortifyServiceProvider::class, HorizonServiceProvider::class, RepositoryServiceProvider::class, + SvgIconServiceProvider::class, TelescopeServiceProvider::class, ViewComposerServiceProvider::class, ]; diff --git a/public_html/images/icons/person.svg b/public_html/images/icons/person.svg index 98ea060f..e9c67a3b 100644 --- a/public_html/images/icons/person.svg +++ b/public_html/images/icons/person.svg @@ -1,3 +1,3 @@ - + \ No newline at end of file diff --git a/resources/sass/modules/_images.scss b/resources/sass/modules/_images.scss index 39d2ba37..51d40e96 100644 --- a/resources/sass/modules/_images.scss +++ b/resources/sass/modules/_images.scss @@ -22,7 +22,8 @@ figure a:hover { color: inherit; } -.icon img { +.icon img, +.icon svg { display: inline-flex; width: 1rem; height: 1rem; @@ -32,27 +33,31 @@ figure a:hover { color: currentColor; } -.icon.icon-big img { +.icon.icon-big img, +.icon.icon-big svg { width: 1.25rem; height: 1.25rem; } -.icon.icon-bigger img { +.icon.icon-bigger img, +.icon.icon-bigger svg { width: 1.5rem; height: 1.5rem; } -.icon.icon-small img { +.icon.icon-small img, +.icon.icon-small svg { width: 0.75rem; height: 0.75rem; bottom: 0; } -.icon.icon-smaller img { +.icon.icon-smaller img, +.icon.icon-smaller svg { width: 0.5rem; height: 0.5rem; } svg { fill: currentColor; -} +} \ No newline at end of file diff --git a/resources/sass/themes/modules/_themeable.scss b/resources/sass/themes/modules/_themeable.scss index b0d58c15..b684e677 100644 --- a/resources/sass/themes/modules/_themeable.scss +++ b/resources/sass/themes/modules/_themeable.scss @@ -1,4 +1,3 @@ - //noinspection CssInvalidFunction // PHPStorm is a harsh mistress body { color: light-dark(var(--black), var(--white)); @@ -25,7 +24,7 @@ body { background-color: light-dark(var(--very-light-gray), var(--dark-base-color)); } -.main-contents > .post { +.main-contents>.post { background-color: inherit; } @@ -54,46 +53,17 @@ h3 a:visited { h3 a:active, h3 a:hover { color: light-dark(var(--base-color), var(--yellow-green)); - background-color: light-dark(var(--base-color), var(--darker-base-color)); + background-color: light-dark(var(--base-color), var(--darker-base-color)); } //noinspection CssInvalidFunction -a.top-bottom-button:link .icon, -a.top-bottom-button:visited .icon { - color: light-dark(var(--dark-base-color), var(--light-base-color)); - background-color: transparent; -} +.link-button {} //noinspection CssInvalidFunction -a.top-bottom-button:active .icon, -a.top-bottom-button:hover .icon { - color: light-dark(var(--dark-base-color), var(--yellow-green)); - background-color: transparent; -} +.primary-button {} //noinspection CssInvalidFunction -.comment-footer a:link .icon, -.comment-footer a:visited .icon, -.post-footer a:link .icon, -.post-footer a:visited .icon { - color: light-dark(var(--darker-base-color), var(--lightest-base-color)); - background-color: inherit; -} - -//noinspection CssInvalidFunction -.link-button { - -} - -//noinspection CssInvalidFunction -.primary-button { - -} - -//noinspection CssInvalidFunction -.secondary-button { - -} +.secondary-button {} //noinspection CssInvalidFunction form small { @@ -264,13 +234,6 @@ a.previous:hover .title { background-color: transparent; } -//noinspection CssInvalidFunction -.notification.is-info .icon img { - color: light-dark(var(--base-color), var(--base-color)); - background-color: light-dark(var(--lightest-base-color), var(--lightest-base-color)); - border-color: var(--darker-base-color); -} - .notification.is-info a:link, .notification.is-info a:visited { color: var(--white); @@ -472,4 +435,4 @@ tbody tr:nth-child(even) { .years-ago a:hover { color: light-dark(var(--white), var(--base-color)); background-color: light-dark(var(--base-color), var(--white)); -} +} \ No newline at end of file diff --git a/resources/views/components/icons/icon-component.blade.php b/resources/views/components/icons/icon-component.blade.php index f9a5c9b9..baaa768a 100644 --- a/resources/views/components/icons/icon-component.blade.php +++ b/resources/views/components/icons/icon-component.blade.php @@ -1,16 +1,7 @@ - @if (isset($iconPath)) - @if (!empty($titleText)) - {{ $altText }} - @else - {{ $altText }} - @endif - @else - iconPath is not set - @endif +