From c648f4ec6ab12ed672a1375edace7c4f6ddd2e8f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 6 Nov 2026 18:16:25 +0100 Subject: [PATCH] feat(ui): star servers and resources for quick access on the dashboard Co-Authored-By: Claude Opus 5.5 --- app/Livewire/Dashboard.php | 68 +++++++++++++++ app/Livewire/StarToggle.php | 81 ++++++++++++++++++ app/Models/Application.php | 3 +- app/Models/Server.php | 3 +- app/Models/Service.php | 3 +- app/Models/StandaloneClickhouse.php | 3 +- app/Models/StandaloneDragonfly.php | 3 +- app/Models/StandaloneKeydb.php | 3 +- app/Models/StandaloneMariadb.php | 3 +- app/Models/StandaloneMongodb.php | 3 +- app/Models/StandaloneMysql.php | 3 +- app/Models/StandalonePostgresql.php | 3 +- app/Models/StandaloneRedis.php | 3 +- app/Models/StandaloneSqlite.php | 3 +- app/Models/Star.php | 46 +++++++++++ app/Models/User.php | 5 ++ .../ServerTransfer/ServerTransferCoverage.php | 2 + app/Traits/HasStars.php | 19 +++++ .../2026_11_06_165709_create_stars_table.php | 26 ++++++ resources/views/components/reicon.blade.php | 3 + .../views/components/top-breadcrumb.blade.php | 2 + resources/views/layouts/app.blade.php | 2 + resources/views/livewire/dashboard.blade.php | 82 +++++++++++++++++++ .../project/application/heading.blade.php | 3 + .../project/database/heading.blade.php | 3 + .../project/service/heading.blade.php | 3 + .../views/livewire/server/navbar.blade.php | 4 + .../views/livewire/star-toggle.blade.php | 10 +++ 28 files changed, 383 insertions(+), 12 deletions(-) create mode 100644 app/Livewire/StarToggle.php create mode 100644 app/Models/Star.php create mode 100644 app/Traits/HasStars.php create mode 100644 database/migrations/2026_11_06_165709_create_stars_table.php create mode 100644 resources/views/livewire/star-toggle.blade.php diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index 1c86a3a79a..a2e8eac50b 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -6,9 +6,12 @@ use App\Livewire\Concerns\RemembersCanvasView; use App\Models\PrivateKey; use App\Models\Project; use App\Models\Server; +use App\Models\Star; use App\Models\TeamInvitation; use App\Services\Access\AccessResolver; use App\Services\ImageScan\TeamVulnerabilities; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Support\Collection; use Livewire\Attributes\Locked; use Livewire\Component; @@ -88,6 +91,7 @@ class Dashboard extends Component return view('livewire.dashboard', [ 'pendingInvitations' => $this->pendingInvitations(), + 'starredResources' => $this->starredResources(), 'dashboardProjects' => $dashboardProjects, 'dashboardServers' => $dashboardServers, 'projectResourceCounts' => $dashboardProjects->mapWithKeys(fn (Project $project) => [$project->id => $this->projectResourceCount($project)]), @@ -109,6 +113,70 @@ class Dashboard extends Component } } + public function unstar(int $starId): void + { + auth()->user()->stars()->whereKey($starId)->delete(); + } + + /** + * Saves the order of the starred cards after a drag and drop. Stars of other teams keep their positions. + * + * @param list $starIds + */ + public function reorderStars(array $starIds): void + { + $stars = auth()->user()->stars()->whereKey($starIds)->get()->keyBy('id'); + + foreach (array_values($starIds) as $position => $starId) { + $stars->get((int) $starId)?->update(['position' => $position]); + } + } + + /** + * Resources the user starred, in the current team, that the user can still see, in the order the user chose. + * + * @return Collection + */ + private function starredResources(): Collection + { + $user = auth()->user(); + $teamId = currentTeam()->id; + + return $user->stars() + ->whereIn('starrable_type', Star::STARRABLE_TYPES) + ->with(['starrable' => fn (MorphTo $morphTo) => $morphTo->morphWith( + collect(Star::STARRABLE_TYPES)->reject(Server::class)->mapWithKeys(fn (string $type) => [$type => ['environment.project']])->all() + )]) + ->orderBy('position') + ->orderBy('id') + ->get() + ->filter(function (Star $star) use ($user, $teamId): bool { + $resource = $star->starrable; + + if (! $resource) { + return false; + } + + if ($resource instanceof Server) { + return $this->showsTeamSections && $resource->team_id === $teamId && $user->can('view', $resource); + } + + return $resource->environment?->project?->team_id === $teamId && $user->can('view', $resource); + }) + ->map(fn (Star $star) => [ + 'star_id' => $star->id, + 'resource' => $star->starrable, + 'name' => $star->starrable->name, + 'context' => $star->starrable instanceof Server + ? 'Server' + : $star->starrable->environment->project->name.' / '.$star->starrable->environment->name, + 'href' => $star->starrable instanceof Server + ? route('server.show', ['server_uuid' => $star->starrable->uuid]) + : $star->starrable->link(), + ]) + ->values(); + } + private function projectResourceCount(Project $project): int { return $project->applications_count + $project->services_count + $project->postgresqls_count diff --git a/app/Livewire/StarToggle.php b/app/Livewire/StarToggle.php new file mode 100644 index 0000000000..47fc8b78bf --- /dev/null +++ b/app/Livewire/StarToggle.php @@ -0,0 +1,81 @@ +authorize('view', $resource); + + $this->starrableType = $resource->getMorphClass(); + $this->starrableId = $resource->getKey(); + $this->starred = $this->starQuery()->exists(); + } + + public function toggle(): void + { + abort_unless(in_array($this->starrableType, Star::STARRABLE_TYPES, true), 404); + $this->authorize('view', $this->starrableType::findOrFail($this->starrableId)); + + $this->starred = ! $this->starQuery()->exists(); + + if (! $this->starred) { + $this->starQuery()->delete(); + } else { + auth()->user()->stars()->firstOrCreate([ + 'starrable_type' => $this->starrableType, + 'starrable_id' => $this->starrableId, + ], [ + 'position' => (auth()->user()->stars()->max('position') ?? -1) + 1, + ]); + } + + $this->dispatch('star-toggled', type: $this->starrableType, id: $this->starrableId, starred: $this->starred); + } + + /** + * Keeps the other star button of the same resource on the page (desktop and mobile top bars) in sync. + */ + #[On('star-toggled')] + public function syncStar(string $type, int $id, bool $starred): void + { + if ($type !== $this->starrableType || $id !== $this->starrableId || $starred === $this->starred) { + $this->skipRender(); + + return; + } + + $this->starred = $starred; + } + + private function starQuery(): HasMany + { + return auth()->user()->stars() + ->where('starrable_type', $this->starrableType) + ->where('starrable_id', $this->starrableId); + } + + public function render() + { + return view('livewire.star-toggle'); + } +} diff --git a/app/Models/Application.php b/app/Models/Application.php index d74fe35d0d..1aaacf3e7d 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -23,6 +23,7 @@ use App\Traits\HasMetrics; use App\Traits\HasNoindexDomains; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\HasStars; use App\Traits\ReleasesManagedDnsRecords; use App\Traits\ScopedByAccessGrants; use Database\Factories\ApplicationFactory; @@ -142,7 +143,7 @@ use Symfony\Component\Yaml\Yaml; class Application extends BaseModel { /** @use HasFactory */ - use Auditable, ClearsGlobalSearchCache, HasComposeVolumeWarnings, HasConfiguration, HasFactory, HasImageScans, HasMaintenancePage, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, HasSecretManager, ReleasesManagedDnsRecords, ScopedByAccessGrants, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasComposeVolumeWarnings, HasConfiguration, HasFactory, HasImageScans, HasMaintenancePage, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, HasSecretManager, HasStars, ReleasesManagedDnsRecords, ScopedByAccessGrants, SoftDeletes; public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024; diff --git a/app/Models/Server.php b/app/Models/Server.php index 3192b71881..736d63aed5 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -31,6 +31,7 @@ use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasStars; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -126,7 +127,7 @@ use Symfony\Component\Yaml\Yaml; class Server extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasFactory, HasMetrics, SchemalessAttributesTrait, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasFactory, HasMetrics, HasStars, SchemalessAttributesTrait, SoftDeletes; /** * Sentinel IP for servers that do not have a real address yet diff --git a/app/Models/Service.php b/app/Models/Service.php index 26f5b81d46..b4af4124ca 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -12,6 +12,7 @@ use App\Traits\HasComposeVolumeWarnings; use App\Traits\HasMaintenancePage; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\HasStars; use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -47,7 +48,7 @@ use OpenApi\Attributes as OA; )] class Service extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasComposeVolumeWarnings, HasFactory, HasMaintenancePage, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasComposeVolumeWarnings, HasFactory, HasMaintenancePage, HasSafeStringAttribute, HasSecretManager, HasStars, ScopedByAccessGrants, SoftDeletes; private static $parserVersion = '5'; diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 68fc3e9ecd..246ba56fc4 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -10,6 +10,7 @@ use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\HasStars; use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -18,7 +19,7 @@ use Throwable; class StandaloneClickhouse extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, HasStars, ScopedByAccessGrants, SoftDeletes; protected array $auditExclude = ['last_online_at']; diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index f2711ab60f..4a80f1d207 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -10,6 +10,7 @@ use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\HasStars; use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -17,7 +18,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneDragonfly extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, HasStars, ScopedByAccessGrants, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index c6fe307de4..dc8c4d93b4 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -10,6 +10,7 @@ use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\HasStars; use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -17,7 +18,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneKeydb extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, HasStars, ScopedByAccessGrants, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index a94cee3388..bc49167ec5 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -10,6 +10,7 @@ use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\HasStars; use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -18,7 +19,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMariadb extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, HasStars, ScopedByAccessGrants, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index 7c43802188..75c5fc5190 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -10,6 +10,7 @@ use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\HasStars; use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -17,7 +18,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMongodb extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, HasStars, ScopedByAccessGrants, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index c714cfbe0a..590c148dcf 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -10,6 +10,7 @@ use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\HasStars; use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -17,7 +18,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMysql extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, HasStars, ScopedByAccessGrants, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index 8112e01ae5..de30f5627d 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -10,6 +10,7 @@ use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\HasStars; use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -17,7 +18,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandalonePostgresql extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, HasStars, ScopedByAccessGrants, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index 08d9768e22..19f7f9579e 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -10,6 +10,7 @@ use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\HasStars; use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -17,7 +18,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneRedis extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, HasStars, ScopedByAccessGrants, SoftDeletes; protected array $auditExclude = ['last_online_at']; diff --git a/app/Models/StandaloneSqlite.php b/app/Models/StandaloneSqlite.php index f8989b4d35..4c66aae413 100644 --- a/app/Models/StandaloneSqlite.php +++ b/app/Models/StandaloneSqlite.php @@ -10,6 +10,7 @@ use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\HasStars; use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -19,7 +20,7 @@ use InvalidArgumentException; class StandaloneSqlite extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasImageScans, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, HasStars, ScopedByAccessGrants, SoftDeletes; public const DATA_DIRECTORY = '/var/lib/sqlite'; diff --git a/app/Models/Star.php b/app/Models/Star.php new file mode 100644 index 0000000000..09ba4471a0 --- /dev/null +++ b/app/Models/Star.php @@ -0,0 +1,46 @@ +belongsTo(User::class); + } + + public function starrable(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 64a5c6ff9c..0c3aadc1c1 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -311,6 +311,11 @@ class User extends Authenticatable implements PasskeyUser, SendsEmail return $this->hasMany(UserChangelogRead::class); } + public function stars(): HasMany + { + return $this->hasMany(Star::class); + } + public function getUnreadChangelogCount(): int { return app(ChangelogService::class)->getUnreadCountForUser($this); diff --git a/app/Services/ServerTransfer/ServerTransferCoverage.php b/app/Services/ServerTransfer/ServerTransferCoverage.php index 0068c82c4c..fa4bd45841 100644 --- a/app/Services/ServerTransfer/ServerTransferCoverage.php +++ b/app/Services/ServerTransfer/ServerTransferCoverage.php @@ -69,6 +69,7 @@ use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; use App\Models\StandaloneSqlite; +use App\Models\Star; use App\Models\Subscription; use App\Models\SwarmDocker; use App\Models\Tag; @@ -355,6 +356,7 @@ final class ServerTransferCoverage OauthIdentity::class => 'User login data of the source instance.', PersonalAccessToken::class => 'API tokens of the source instance.', UserChangelogRead::class => 'User data of the source instance.', + Star::class => 'User data of the source instance: users are not moved with a server.', AuditEvent::class => 'Audit log of the source instance.', NotificationThrottle::class => 'Runtime state of the source instance.', ScheduledJobState::class => 'Scheduler state of the source instance.', diff --git a/app/Traits/HasStars.php b/app/Traits/HasStars.php new file mode 100644 index 0000000000..07ff1adb1d --- /dev/null +++ b/app/Traits/HasStars.php @@ -0,0 +1,19 @@ + $model->stars()->delete()); + } + + public function stars(): MorphMany + { + return $this->morphMany(Star::class, 'starrable'); + } +} diff --git a/database/migrations/2026_11_06_165709_create_stars_table.php b/database/migrations/2026_11_06_165709_create_stars_table.php new file mode 100644 index 0000000000..9d45e09676 --- /dev/null +++ b/database/migrations/2026_11_06_165709_create_stars_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->morphs('starrable'); + $table->unsignedInteger('position')->default(0); + $table->timestamps(); + + $table->unique(['user_id', 'starrable_type', 'starrable_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('stars'); + } +}; diff --git a/resources/views/components/reicon.blade.php b/resources/views/components/reicon.blade.php index 8b5ffca818..0dddad5e99 100644 --- a/resources/views/components/reicon.blade.php +++ b/resources/views/components/reicon.blade.php @@ -87,6 +87,9 @@ 'broom' => '', 'shield-star' => '', 'cloudflare' => '', + 'star' => '', + 'star-filled' => '', + 'grip-vertical' => '', 'network' => '', ]; diff --git a/resources/views/components/top-breadcrumb.blade.php b/resources/views/components/top-breadcrumb.blade.php index 4ca440c8eb..15e3d139fa 100644 --- a/resources/views/components/top-breadcrumb.blade.php +++ b/resources/views/components/top-breadcrumb.blade.php @@ -269,6 +269,8 @@ @if ($currentResource) + {{-- Below xl, the page heading shows the status. --}} @endif + {{-- First on every screen size, so starred resources are one tap away on phones too. --}} + @if ($starredResources->isNotEmpty()) +
+ + {{-- Pointer events, so the same code works for mouse, touch, and pen. The handle has touch-none, so a drag on a phone does not scroll the page. --}} +
+ @foreach ($starredResources as $starred) +
+ + +
+ @if ($starred['resource'] instanceof \App\Models\Server) + + @else + + @endif +
+
+

+ {{ $starred['name'] }} +

+

+ {{ $starred['context'] }} +

+
+ +
+ @endforeach +
+
+ @endif + {{-- Wide screens: projects and servers fill the left, activity is a fixed-width column on the right. It stays one column while the activity side has no section (no deployments, no traffic). --}}
diff --git a/resources/views/livewire/project/application/heading.blade.php b/resources/views/livewire/project/application/heading.blade.php index 00eb07346d..fffebfbbf9 100644 --- a/resources/views/livewire/project/application/heading.blade.php +++ b/resources/views/livewire/project/application/heading.blade.php @@ -1,5 +1,8 @@
diff --git a/resources/views/livewire/star-toggle.blade.php b/resources/views/livewire/star-toggle.blade.php new file mode 100644 index 0000000000..d88cad853c --- /dev/null +++ b/resources/views/livewire/star-toggle.blade.php @@ -0,0 +1,10 @@ +