feat(ui): star servers and resources for quick access on the dashboard

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-11-06 18:16:25 +01:00
co-authored by Claude Opus 5.5
parent cab5ae9903
commit c648f4ec6a
28 changed files with 383 additions and 12 deletions
+68
View File
@@ -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<int> $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<int, array{star_id: int, resource: Model, name: string, context: string, href: string}>
*/
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
+81
View File
@@ -0,0 +1,81 @@
<?php
namespace App\Livewire;
use App\Models\Star;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Livewire\Attributes\Locked;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* Stars or unstars a resource for the logged-in user. Starred resources show on the dashboard.
*/
class StarToggle extends Component
{
#[Locked]
public string $starrableType;
#[Locked]
public int $starrableId;
public bool $starred = false;
public function mount(Model $resource): void
{
$this->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');
}
}
+2 -1
View File
@@ -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<ApplicationFactory> */
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;
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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';
+2 -1
View File
@@ -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'];
+2 -1
View File
@@ -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',
+2 -1
View File
@@ -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',
+2 -1
View File
@@ -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',
+2 -1
View File
@@ -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',
+2 -1
View File
@@ -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',
+2 -1
View File
@@ -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',
+2 -1
View File
@@ -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'];
+2 -1
View File
@@ -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';
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* A resource that one user starred for quick access on the dashboard.
*/
class Star extends Model
{
/** Models that a user can star. Each one uses the HasStars trait. */
public const STARRABLE_TYPES = [
Server::class,
Application::class,
Service::class,
StandalonePostgresql::class,
StandaloneMysql::class,
StandaloneMariadb::class,
StandaloneMongodb::class,
StandaloneRedis::class,
StandaloneKeydb::class,
StandaloneDragonfly::class,
StandaloneClickhouse::class,
StandaloneSqlite::class,
];
protected $fillable = [
'user_id',
'starrable_type',
'starrable_id',
'position',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function starrable(): MorphTo
{
return $this->morphTo();
}
}
+5
View File
@@ -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);
@@ -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.',
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Traits;
use App\Models\Star;
use Illuminate\Database\Eloquent\Relations\MorphMany;
trait HasStars
{
public static function bootHasStars(): void
{
static::deleted(fn ($model) => $model->stars()->delete());
}
public function stars(): MorphMany
{
return $this->morphMany(Star::class, 'starrable');
}
}
@@ -0,0 +1,26 @@
<?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::create('stars', function (Blueprint $table) {
$table->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');
}
};
@@ -87,6 +87,9 @@
'broom' => '<g transform="scale(0.09375)"><path d="M235.29,216.7C212.86,205.69,200,182.12,200,152V134.69a15.94,15.94,0,0,0-10.09-14.87l-28.65-11.46A8,8,0,0,1,156.79,98l22.32-56.67C184,28.79,178,14.21,165.34,9.51a24,24,0,0,0-30.7,13.71L112.25,80.08a8,8,0,0,1-10.41,4.5L73.11,73.08a15.91,15.91,0,0,0-17.38,3.66C34.68,98.4,24,123.71,24,152a111.53,111.53,0,0,0,31.15,77.53A8.06,8.06,0,0,0,61,232H232a8,8,0,0,0,8-7.51A8.21,8.21,0,0,0,235.29,216.7ZM115.11,216a87.52,87.52,0,0,1-24.26-41.71,8.21,8.21,0,0,0-9.25-6.18A8,8,0,0,0,75.28,178a105.33,105.33,0,0,0,18.36,38H64.44A95.62,95.62,0,0,1,40,152a85.92,85.92,0,0,1,7.73-36.3l137.8,55.13c3,18.06,10.55,33.5,21.89,45.19Z" fill="currentColor"/></g>',
'shield-star' => '<g transform="scale(0.09375)"><path d="M80.57,117A8,8,0,0,1,91,112.57l29,11.61V96a8,8,0,0,1,16,0v28.18l29-11.61A8,8,0,1,1,171,127.43l-30.31,12.12L158.4,163.2a8,8,0,1,1-12.8,9.6L128,149.33,110.4,172.8a8,8,0,1,1-12.8-9.6l17.74-23.65L85,127.43A8,8,0,0,1,80.57,117ZM224,56v56c0,52.72-25.52,84.67-46.93,102.19-23.06,18.86-46,25.27-47,25.53a8,8,0,0,1-4.2,0c-1-.26-23.91-6.67-47-25.53C57.52,196.67,32,164.72,32,112V56A16,16,0,0,1,48,40H208A16,16,0,0,1,224,56Zm-16,0L48,56l0,56c0,37.3,13.82,67.51,41.07,89.81A128.25,128.25,0,0,0,128,223.62a129.3,129.3,0,0,0,39.41-22.2C194.34,179.16,208,149.07,208,112Z" fill="currentColor"/></g>',
'cloudflare' => '<g transform="translate(0 8.24) scale(0.498) translate(-51 -6.7)" fill="currentColor"><path d="M84.2,20.4a2.85546,2.85546,0,0,0-.3-2.6,3.09428,3.09428,0,0,0-2.1-1.1l-17.4-.2c-.1,0-.2-.1-.3-.1a.1875.1875,0,0,1,0-.3c.1-.2.2-.3.4-.3L82,15.6a6.29223,6.29223,0,0,0,5.1-3.8l1-2.6c0-.1.1-.2,0-.3A11.39646,11.39646,0,0,0,66.2,7.7a5.45941,5.45941,0,0,0-3.6-1A5.20936,5.20936,0,0,0,58,11.3a5.46262,5.46262,0,0,0,.1,1.8A7.30177,7.30177,0,0,0,51,20.4a4.102,4.102,0,0,0,.1,1.1.3193.3193,0,0,0,.3.3H83.5c.2,0,.4-.1.4-.3Z"/><path d="M89.7,9.2h-.5c-.1,0-.2.1-.3.2l-.7,2.4a2.85546,2.85546,0,0,0,.3,2.6,3.09428,3.09428,0,0,0,2.1,1.1l3.7.2c.1,0,.2.1.3.1a.1875.1875,0,0,1,0,.3c-.1.2-.2.3-.4.3l-3.8.2a6.29223,6.29223,0,0,0-5.1,3.8l-.2.9c-.1.1,0,.3.2.3H98.5a.26517.26517,0,0,0,.3-.3,10.87184,10.87184,0,0,0,.4-2.6,9.56045,9.56045,0,0,0-9.5-9.5"/></g>',
'star' => '<path d="M12 3.5l2.6 5.3 5.9.9-4.3 4.1 1 5.8L12 16.9l-5.2 2.7 1-5.8-4.3-4.1 5.9-.9L12 3.5z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>',
'star-filled' => '<path d="M12 3.5l2.6 5.3 5.9.9-4.3 4.1 1 5.8L12 16.9l-5.2 2.7 1-5.8-4.3-4.1 5.9-.9L12 3.5z" fill="currentColor" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>',
'grip-vertical' => '<g fill="currentColor"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></g>',
'network' => '<g transform="scale(0.09375)"><path d="M240,120a8,8,0,0,1-8,8H200v32h8a16,16,0,0,1,16,16v32a16,16,0,0,1-16,16H176a16,16,0,0,1-16-16V176a16,16,0,0,1,16-16h8V128H72v32h8a16,16,0,0,1,16,16v32a16,16,0,0,1-16,16H48a16,16,0,0,1-16-16V176a16,16,0,0,1,16-16h8V128H24a8,8,0,0,1,0-16h96V88h-8A16,16,0,0,1,96,72V40a16,16,0,0,1,16-16h32a16,16,0,0,1,16,16V72a16,16,0,0,1-16,16h-8v24h96A8,8,0,0,1,240,120Z" fill="currentColor"/></g>',
];
@@ -269,6 +269,8 @@
@if ($currentResource)
<x-breadcrumb-switcher title="Resources" :label="$currentResource->name" :uuid="$currentResource->uuid" :items="$resourceItems">
<x-slot:meta>
<livewire:star-toggle :resource="$currentResource"
:wire:key="'star-'.$currentResource->getMorphClass().'-'.$currentResource->uuid" />
{{-- Below xl, the page heading shows the status. --}}
<div class="hidden xl:flex">
@if ($currentApplication)
+2
View File
@@ -151,6 +151,8 @@
<div id="server-timing-hud-slot-mobile" data-server-timing-hud-slot
class="hidden shrink-0 items-center"></div>
<div id="configuration-warning-hud-slot-mobile" class="relative shrink-0"></div>
{{-- Resource and server pages put their star button here. --}}
<div id="resource-star-slot-mobile" class="flex shrink-0 items-center empty:hidden"></div>
<livewire:deployments-indicator variant="mobile" />
@if (isInstanceAdmin() && !isCloud())
<livewire:upgrade key="mobile-upgrade" />
@@ -64,6 +64,88 @@
</div>
@endif
{{-- First on every screen size, so starred resources are one tap away on phones too. --}}
@if ($starredResources->isNotEmpty())
<section class="@container mb-0! min-w-0">
<x-section-heading title="Starred" />
{{-- 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. --}}
<div class="grid min-w-0 grid-cols-1 gap-3 @lg:grid-cols-2 @5xl:grid-cols-3 @7xl:grid-cols-4"
x-data="{
dragged: null,
lifted: ['z-20', 'scale-[1.02]', 'shadow-lg', 'ring-2', 'ring-coollabs/40', 'dark:ring-brand-light/30', 'cursor-grabbing'],
start(event) {
this.dragged = event.currentTarget.closest('[data-star-id]');
this.dragged.classList.add(...this.lifted);
},
move(event) {
if (!this.dragged) return;
const target = document.elementsFromPoint(event.clientX, event.clientY)
.map((element) => element.closest('[data-star-id]'))
.find((card) => card && card !== this.dragged && $el.contains(card));
{{-- A card that still slides would move away under the pointer and swap back. --}}
if (!target || target.getAnimations().length) return;
const rect = target.getBoundingClientRect();
const after = (event.clientX - rect.left) / rect.width + (event.clientY - rect.top) / rect.height > 1;
const cards = [...$el.querySelectorAll('[data-star-id]')];
const oldRects = new Map(cards.map((card) => [card, card.getBoundingClientRect()]));
target.parentNode.insertBefore(this.dragged, after ? target.nextSibling : target);
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
{{-- FLIP: each card that moved slides from its old place to the new one. --}}
cards.forEach((card) => {
const oldRect = oldRects.get(card);
const newRect = card.getBoundingClientRect();
const dx = oldRect.left - newRect.left;
const dy = oldRect.top - newRect.top;
if (dx || dy) {
card.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }], { duration: 200, easing: 'cubic-bezier(0.2, 0, 0, 1)' });
}
});
},
end() {
if (!this.dragged) return;
this.dragged.classList.remove(...this.lifted);
this.dragged = null;
$wire.reorderStars([...$el.querySelectorAll('[data-star-id]')].map((card) => Number(card.dataset.starId)));
},
}" x-on:pointermove.window="move($event)" x-on:pointerup.window="end()"
x-on:pointercancel.window="end()">
@foreach ($starredResources as $starred)
<article wire:key="dashboard-star-{{ $starred['star_id'] }}" data-star-id="{{ $starred['star_id'] }}"
class="group relative flex min-h-16 min-w-0 select-none items-center gap-3 rounded-xl [-webkit-touch-callout:none] border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.05] dark:hover:border-white/[0.14]">
<a href="{{ $starred['href'] }}" {{ wireNavigate() }} class="absolute inset-0 rounded-xl"
aria-label="Open {{ $starred['name'] }}"></a>
<button type="button" title="Drag to change the order" aria-label="Move {{ $starred['name'] }}"
x-on:pointerdown.prevent="start($event)"
class="relative z-10 -mx-1.5 flex h-8 w-5 shrink-0 cursor-grab touch-none items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black active:cursor-grabbing dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg">
<x-reicon name="grip-vertical" class="size-4" />
</button>
<div
class="resource-glyph flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500">
@if ($starred['resource'] instanceof \App\Models\Server)
<x-reicon name="servers" class="size-4" />
@else
<x-resource-logo :resource="$starred['resource']" />
@endif
</div>
<div class="min-w-0 flex-1">
<h3 class="truncate text-[13px]! leading-4! font-semibold! text-black dark:text-fg">
{{ $starred['name'] }}
</h3>
<p class="mt-0.5 truncate text-[11px] text-neutral-500 dark:text-fg-faint">
{{ $starred['context'] }}
</p>
</div>
<button type="button" wire:click="unstar({{ $starred['star_id'] }})"
title="Remove from the dashboard" aria-label="Unstar {{ $starred['name'] }}"
class="relative z-10 flex size-6.5 shrink-0 items-center justify-center rounded-md text-warning transition-colors hover:bg-neutral-100 dark:hover:bg-white/[0.06]">
<x-reicon name="star-filled" class="size-4" />
</button>
</article>
@endforeach
</div>
</section>
@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). --}}
<div class="grid min-w-0 gap-8 xl:items-start xl:has-[.dashboard-activity_section]:grid-cols-[minmax(0,1fr)_22rem] 2xl:has-[.dashboard-activity_section]:grid-cols-[minmax(0,1fr)_26rem]">
@@ -1,5 +1,8 @@
<nav wire:poll.10000ms="checkStatus" class="w-full max-w-none" x-data
x-init="@js($loadComposeOnOpen) && $wire.loadComposeFile(true)">
@teleport('#resource-star-slot-mobile')
<livewire:star-toggle :resource="$application" :wire:key="'star-mobile-'.$application->getMorphClass().'-'.$application->uuid" />
@endteleport
<div>
@if ($application->build_pack === 'nixpacks')
<x-callout type="warning" title="Nixpacks is deprecated" class="mb-4">
@@ -1,4 +1,7 @@
<nav wire:poll.10000ms="checkStatus" class="w-full max-w-none">
@teleport('#resource-star-slot-mobile')
<livewire:star-toggle :resource="$database" :wire:key="'star-mobile-'.$database->getMorphClass().'-'.$database->uuid" />
@endteleport
@php
$databasePageItems = [
[
@@ -1,4 +1,7 @@
<nav wire:poll.10000ms="checkStatus" class="w-full max-w-none">
@teleport('#resource-star-slot-mobile')
<livewire:star-toggle :resource="$service" :wire:key="'star-mobile-'.$service->getMorphClass().'-'.$service->uuid" />
@endteleport
@php
$serviceStatus = str($service->status ?? 'exited');
$selectedResourceUuid = data_get($parameters, 'stack_service_uuid');
@@ -1,4 +1,7 @@
<nav class="w-full max-w-none pb-3 lg:pb-0" wire:poll.30s="refreshAgentStatus">
@teleport('#resource-star-slot-mobile')
<livewire:star-toggle :resource="$server" :wire:key="'star-mobile-'.$server->getMorphClass().'-'.$server->uuid" />
@endteleport
<x-process-dialog @startproxy.window="processDialogOpen = true" closeWithX>
<x-slot:title>Proxy Startup Logs</x-slot:title>
<x-slot:content>
@@ -151,6 +154,7 @@
</template>
</div>
</span>
<livewire:star-toggle :resource="$server" :wire:key="'star-server-'.$server->uuid" />
<x-server.status-summary :server="$server" :proxy-status="$proxyStatus"
:show-sentinel-status="$showSentinelStatus" />
</div>
@@ -0,0 +1,10 @@
<button type="button" wire:click="toggle" wire:loading.attr="disabled"
title="{{ $starred ? 'Remove from the dashboard' : 'Add to the dashboard' }}"
aria-label="{{ $starred ? 'Unstar' : 'Star' }}" aria-pressed="{{ $starred ? 'true' : 'false' }}"
@class([
'flex size-7 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-neutral-100 dark:hover:bg-white/[0.05]',
'text-warning' => $starred,
'text-neutral-400 hover:text-black dark:text-fg-faint dark:hover:text-fg' => ! $starred,
])>
<x-reicon :name="$starred ? 'star-filled' : 'star'" class="size-4" />
</button>