113 - Add per-feed colour dots to article routing

This commit is contained in:
myrmidex 2026-08-07 02:14:58 +02:00
parent 4274b69018
commit e0056864af
6 changed files with 114 additions and 4 deletions

View file

@ -0,0 +1,36 @@
<?php
namespace App\Enums;
enum FeedColorEnum: string
{
case SLATE = 'slate';
case AMBER = 'amber';
case SKY = 'sky';
case VIOLET = 'violet';
case TEAL = 'teal';
case ROSE = 'rose';
case INDIGO = 'indigo';
case ORANGE = 'orange';
public function dotClass(): string
{
return match ($this) {
self::SLATE => 'bg-slate-500',
self::AMBER => 'bg-amber-500',
self::SKY => 'bg-sky-500',
self::VIOLET => 'bg-violet-500',
self::TEAL => 'bg-teal-500',
self::ROSE => 'bg-rose-500',
self::INDIGO => 'bg-indigo-500',
self::ORANGE => 'bg-orange-500',
};
}
public static function forId(int $id): self
{
$cases = self::cases();
return $cases[$id % count($cases)];
}
}

View file

@ -2,6 +2,7 @@
namespace App\Models;
use App\Enums\FeedColorEnum;
use Database\Factories\FeedFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -20,6 +21,7 @@
* @property int|null $language_id
* @property Language|null $language
* @property string $description
* @property FeedColorEnum|null $color
* @property array<string, mixed> $settings
* @property bool $is_active
* @property Carbon|null $last_fetched_at
@ -46,6 +48,7 @@ class Feed extends Model
'provider',
'language_id',
'description',
'color',
'settings',
'is_active',
'last_fetched_at',
@ -55,8 +58,14 @@ class Feed extends Model
'settings' => 'array',
'is_active' => 'boolean',
'last_fetched_at' => 'datetime',
'color' => FeedColorEnum::class,
];
public function displayColor(): FeedColorEnum
{
return $this->color ?? FeedColorEnum::forId($this->id);
}
public function getTypeDisplayAttribute(): string
{
return match ($this->type) {

View file

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('feeds', function (Blueprint $table) {
$table->string('color')->nullable()->after('description');
});
}
public function down(): void
{
Schema::table('feeds', function (Blueprint $table) {
$table->dropColumn('color');
});
}
};

View file

@ -75,9 +75,9 @@ class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-
class="flex items-center flex-wrap gap-x-1.5 text-xs font-medium mb-1.5"
aria-label="Routed from {{ $routeArticle->feed?->name ?? 'Unknown feed' }} to {{ $routeArticle->platformChannel?->name ?? 'Unknown channel' }}"
>
<svg class="h-3 w-3 text-gray-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
@if ($routeArticle->feed)
<span class="h-2 w-2 rounded-full shrink-0 {{ $routeArticle->feed->displayColor()->dotClass() }}" aria-hidden="true"></span>
@endif
<span class="text-gray-900">{{ $routeArticle->feed?->name ?? 'Unknown feed' }}</span>
<span class="text-gray-400" aria-hidden="true">&rarr;</span>
<span class="text-gray-600">{{ $routeArticle->platformChannel?->name ?? 'Unknown channel' }}</span>

View file

@ -7,6 +7,17 @@ export default {
'./vendor/livewire/livewire/dist/livewire.esm.js',
'./storage/framework/views/*.php',
],
// Feed dot colours are built in PHP; nothing here is reachable by the scanner.
safelist: [
'bg-slate-500',
'bg-amber-500',
'bg-sky-500',
'bg-violet-500',
'bg-teal-500',
'bg-rose-500',
'bg-indigo-500',
'bg-orange-500',
],
theme: {
extend: {},
},

View file

@ -2,6 +2,7 @@
namespace Tests\Unit\Models;
use App\Enums\FeedColorEnum;
use App\Models\Article;
use App\Models\Feed;
use App\Models\Language;
@ -17,12 +18,43 @@ class FeedTest extends TestCase
public function test_fillable_fields(): void
{
$fillableFields = ['name', 'url', 'type', 'provider', 'language_id', 'description', 'settings', 'is_active', 'last_fetched_at'];
$fillableFields = ['name', 'url', 'type', 'provider', 'language_id', 'description', 'color', 'settings', 'is_active', 'last_fetched_at'];
$feed = new Feed;
$this->assertEquals($fillableFields, $feed->getFillable());
}
public function test_display_color_falls_back_to_a_deterministic_color(): void
{
$feed = Feed::factory()->create(['color' => null]);
$this->assertSame(FeedColorEnum::forId($feed->id), $feed->displayColor());
$this->assertSame(FeedColorEnum::forId($feed->id), $feed->fresh()->displayColor());
}
public function test_display_color_prefers_the_stored_color(): void
{
$feed = Feed::factory()->create(['color' => FeedColorEnum::ROSE]);
$this->assertSame(FeedColorEnum::ROSE, $feed->fresh()->displayColor());
}
public function test_color_fallback_wraps_around_the_palette(): void
{
$paletteSize = count(FeedColorEnum::cases());
$this->assertSame(FeedColorEnum::forId(0), FeedColorEnum::forId($paletteSize));
}
public function test_every_palette_dot_class_is_safelisted_for_tailwind(): void
{
$config = (string) file_get_contents(base_path('tailwind.config.js'));
foreach (FeedColorEnum::cases() as $color) {
$this->assertStringContainsString("'{$color->dotClass()}'", $config);
}
}
public function test_casts_settings_to_array(): void
{
$settings = ['key1' => 'value1', 'key2' => ['nested' => 'value']];