Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions app/Actions/CreateUserBadge.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Enums\ActivityGraphTheme;
use App\Models\User;

/**
* Generates a compact SVG follow badge for a user's GitHub profile README.
*
* @phpstan-type Palette array{card: string, border: string, ink: string, muted: string, empty: string, lanes: list<string>}
*/
final readonly class CreateUserBadge
{
private const int HEIGHT = 20;

/**
* The colors of `resources/css/app.css`.
*
* @var Palette
*/
private const array LIGHT = [
'card' => '#ffffff',
'border' => '#e1e4e8',
'ink' => '#121417',
'muted' => '#5b616b',
'empty' => '#eef0f2',
'lanes' => ['#d9480f', '#2f9e44', '#9c36b5', '#0c8599', '#c2255c', '#b7791f'],
];

/**
* @var Palette
*/
private const array DARK = [
'card' => '#18181a',
'border' => '#2a2a2d',
'ink' => '#ececec',
'muted' => '#a1a1a6',
'empty' => '#232326',
'lanes' => ['#ff8a4c', '#51cf66', '#da77f2', '#3bc9db', '#f06595', '#fcc419'],
];

/**
* Renders the compact SVG follow badge for the given user and theme.
*/
public function handle(
User $user,
ActivityGraphTheme $theme = ActivityGraphTheme::Light,
): string {
$message = $this->message($user);
$messageWidth = $this->textWidth($message);
$dotX = 77;
$messageX = 86;
$width = $messageX + $messageWidth + 10;

return view('svg.user-badge', [
'width' => $width,
'height' => self::HEIGHT,
'dotX' => $dotX,
'messageX' => $messageX,
'message' => $message,
'styles' => $this->styles($theme, $user->lane()),
])->render();
}

/**
* Formats the follow message with the user's handle.
*/
public function message(User $user): string
{
return "follow @{$user->handle}";
}

/**
* Calculates the estimated pixel width for the monospace message text.
*/
public function textWidth(string $text): int
{
return (int) ceil(mb_strlen($text) * 6.6);
}

/**
* Generates CSS stylesheet rules for the given theme and user lane color.
*/
private function styles(ActivityGraphTheme $theme, int|string $lane): string
{
return match ($theme) {
ActivityGraphTheme::Light => $this->palette(self::LIGHT, $lane),
ActivityGraphTheme::Dark => $this->palette(self::DARK, $lane),
ActivityGraphTheme::Auto => $this->palette(self::LIGHT, $lane).'@media (prefers-color-scheme: dark){'.$this->palette(self::DARK, $lane).'}',
};
}

/**
* Compiles CSS rules mapping palette values to card, ink, muted, lane, and empty classes.
*
* @param Palette $palette
*/
private function palette(array $palette, int|string $lane): string
{
$color = is_string($lane) ? $lane : $palette['lanes'][$lane];

$rules = [
'.card' => "fill:{$palette['card']};stroke:{$palette['border']}",
'.ink' => "fill:{$palette['ink']}",
'.muted' => "fill:{$palette['muted']}",
'.lane' => "fill:{$color}",
'.l0' => "fill:{$palette['empty']}",
];

return implode('', array_map(
fn (string $selector, string $declarations): string => "{$selector}{{$declarations}}",
array_keys($rules),
$rules,
));
}
}
23 changes: 23 additions & 0 deletions app/Http/Controllers/UserBadgeController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Actions\CreateUserBadge;
use App\Http\Requests\ShowUserBadgeRequest;
use App\Models\User;
use Illuminate\Http\Response;

final readonly class UserBadgeController
{
/**
* Serves the SVG follow badge for the user.
*/
public function show(ShowUserBadgeRequest $request, User $user, CreateUserBadge $action): Response
{
return response($action->handle($user, $request->theme()), headers: [
'Content-Type' => 'image/svg+xml',
]);
}
}
1 change: 1 addition & 0 deletions app/Http/Controllers/UserProfileController.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public function edit(#[CurrentUser] User $user, CommitTranslations $translations
'automaticLane' => $user->automaticLane(),
'githubSyncedAt' => $user->github_synced_at?->toIso8601String(),
'activityGraphUrl' => route('user-activity-graph.show', $user),
'badgeUrl' => route('user-badge.svg', $user),
'profileUrl' => route('user.show', $user),
]);
}
Expand Down
36 changes: 36 additions & 0 deletions app/Http/Requests/ShowUserBadgeRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use App\Enums\ActivityGraphTheme;
use Illuminate\Foundation\Http\FormRequest;

final class ShowUserBadgeRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}

/**
* The badge is embedded where nobody reads validation errors, so nothing is rejected and defaults are used.
*
* @return array<string, array<int, string>>
*/
public function rules(): array
{
return [];
}

/**
* Resolves the requested theme from query parameters, defaulting to light mode.
*/
public function theme(): ActivityGraphTheme
{
$theme = $this->query('theme');

return (is_string($theme) ? ActivityGraphTheme::tryFrom($theme) : null) ?? ActivityGraphTheme::Light;
}
}
Loading