feat(changelog): implement automated changelog fetching from GitHub and enhance changelog read tracking

This commit is contained in:
Andras Bacsai
2025-08-10 20:14:38 +02:00
parent 193995de79
commit a2ef545b6b
14 changed files with 519 additions and 69 deletions

View File

@@ -5,6 +5,7 @@ namespace App\Console\Commands;
use App\Enums\ActivityTypes; use App\Enums\ActivityTypes;
use App\Enums\ApplicationDeploymentStatus; use App\Enums\ApplicationDeploymentStatus;
use App\Jobs\CheckHelperImageJob; use App\Jobs\CheckHelperImageJob;
use App\Jobs\PullChangelogFromGitHub;
use App\Models\ApplicationDeploymentQueue; use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment; use App\Models\Environment;
use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledDatabaseBackup;
@@ -64,6 +65,7 @@ class Init extends Command
try { try {
$this->cleanupUnnecessaryDynamicProxyConfiguration(); $this->cleanupUnnecessaryDynamicProxyConfiguration();
$this->pullTemplatesFromCDN(); $this->pullTemplatesFromCDN();
$this->pullChangelogFromGitHub();
} catch (\Throwable $e) { } catch (\Throwable $e) {
echo "Could not pull templates from CDN: {$e->getMessage()}\n"; echo "Could not pull templates from CDN: {$e->getMessage()}\n";
} }
@@ -74,6 +76,7 @@ class Init extends Command
try { try {
$this->cleanupInProgressApplicationDeployments(); $this->cleanupInProgressApplicationDeployments();
$this->pullTemplatesFromCDN(); $this->pullTemplatesFromCDN();
$this->pullChangelogFromGitHub();
} catch (\Throwable $e) { } catch (\Throwable $e) {
echo "Could not pull templates from CDN: {$e->getMessage()}\n"; echo "Could not pull templates from CDN: {$e->getMessage()}\n";
} }
@@ -109,6 +112,16 @@ class Init extends Command
} }
} }
private function pullChangelogFromGitHub()
{
try {
PullChangelogFromGitHub::dispatch();
echo "Changelog fetch initiated\n";
} catch (\Throwable $e) {
echo "Could not fetch changelog from GitHub: {$e->getMessage()}\n";
}
}
private function optimize() private function optimize()
{ {
Artisan::call('optimize:clear'); Artisan::call('optimize:clear');

View File

@@ -6,6 +6,7 @@ use App\Jobs\CheckAndStartSentinelJob;
use App\Jobs\CheckForUpdatesJob; use App\Jobs\CheckForUpdatesJob;
use App\Jobs\CheckHelperImageJob; use App\Jobs\CheckHelperImageJob;
use App\Jobs\CleanupInstanceStuffsJob; use App\Jobs\CleanupInstanceStuffsJob;
use App\Jobs\PullChangelogFromGitHub;
use App\Jobs\PullTemplatesFromCDN; use App\Jobs\PullTemplatesFromCDN;
use App\Jobs\RegenerateSslCertJob; use App\Jobs\RegenerateSslCertJob;
use App\Jobs\ScheduledJobManager; use App\Jobs\ScheduledJobManager;
@@ -67,6 +68,7 @@ class Kernel extends ConsoleKernel
$this->scheduleInstance->command('cleanup:unreachable-servers')->daily()->onOneServer(); $this->scheduleInstance->command('cleanup:unreachable-servers')->daily()->onOneServer();
$this->scheduleInstance->job(new PullTemplatesFromCDN)->cron($this->updateCheckFrequency)->timezone($this->instanceTimezone)->onOneServer(); $this->scheduleInstance->job(new PullTemplatesFromCDN)->cron($this->updateCheckFrequency)->timezone($this->instanceTimezone)->onOneServer();
$this->scheduleInstance->job(new PullChangelogFromGitHub)->cron($this->updateCheckFrequency)->timezone($this->instanceTimezone)->onOneServer();
$this->scheduleInstance->job(new CleanupInstanceStuffsJob)->everyTwoMinutes()->onOneServer(); $this->scheduleInstance->job(new CleanupInstanceStuffsJob)->everyTwoMinutes()->onOneServer();
$this->scheduleUpdates(); $this->scheduleUpdates();

View File

@@ -0,0 +1,110 @@
<?php
namespace App\Jobs;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
class PullChangelogFromGitHub implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $timeout = 30;
public function __construct()
{
$this->onQueue('high');
}
public function handle(): void
{
try {
$response = Http::retry(3, 1000)
->timeout(30)
->get('https://api.github.com/repos/coollabsio/coolify/releases?per_page=10');
if ($response->successful()) {
$releases = $response->json();
$changelog = $this->transformReleasesToChangelog($releases);
// Group entries by month and save them
$this->saveChangelogEntries($changelog);
} else {
send_internal_notification('PullChangelogFromGitHub failed with: '.$response->status().' '.$response->body());
}
} catch (\Throwable $e) {
send_internal_notification('PullChangelogFromGitHub failed with: '.$e->getMessage());
}
}
private function transformReleasesToChangelog(array $releases): array
{
$entries = [];
foreach ($releases as $release) {
// Skip drafts and pre-releases if desired
if ($release['draft']) {
continue;
}
$publishedAt = Carbon::parse($release['published_at']);
$entry = [
'tag_name' => $release['tag_name'],
'title' => $release['name'] ?: $release['tag_name'],
'content' => $release['body'] ?: 'No release notes available.',
'published_at' => $publishedAt->toISOString(),
];
$entries[] = $entry;
}
return $entries;
}
private function saveChangelogEntries(array $entries): void
{
// Create changelogs directory if it doesn't exist
$changelogsDir = base_path('changelogs');
if (! File::exists($changelogsDir)) {
File::makeDirectory($changelogsDir, 0755, true);
}
// Group entries by year-month
$groupedEntries = [];
foreach ($entries as $entry) {
$date = Carbon::parse($entry['published_at']);
$monthKey = $date->format('Y-m');
if (! isset($groupedEntries[$monthKey])) {
$groupedEntries[$monthKey] = [];
}
$groupedEntries[$monthKey][] = $entry;
}
// Save each month's entries to separate files
foreach ($groupedEntries as $month => $monthEntries) {
// Sort entries by published date (newest first)
usort($monthEntries, function ($a, $b) {
return Carbon::parse($b['published_at'])->timestamp - Carbon::parse($a['published_at'])->timestamp;
});
$monthData = [
'entries' => $monthEntries,
'last_updated' => now()->toISOString(),
];
$filePath = base_path("changelogs/{$month}.json");
File::put($filePath, json_encode($monthData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
}
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Livewire; namespace App\Livewire;
use App\Jobs\PullChangelogFromGitHub;
use App\Services\ChangelogService; use App\Services\ChangelogService;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Livewire\Component; use Livewire\Component;
@@ -42,6 +43,20 @@ class SettingsDropdown extends Component
app(ChangelogService::class)->markAllAsReadForUser(Auth::user()); app(ChangelogService::class)->markAllAsReadForUser(Auth::user());
} }
public function manualFetchChangelog()
{
if (! isDev()) {
return;
}
try {
PullChangelogFromGitHub::dispatch();
$this->dispatch('success', 'Changelog fetch initiated! Check back in a few moments.');
} catch (\Throwable $e) {
$this->dispatch('error', 'Failed to fetch changelog: '.$e->getMessage());
}
}
public function render() public function render()
{ {
return view('livewire.settings-dropdown', [ return view('livewire.settings-dropdown', [

View File

@@ -9,7 +9,7 @@ class UserChangelogRead extends Model
{ {
protected $fillable = [ protected $fillable = [
'user_id', 'user_id',
'changelog_identifier', 'release_tag',
'read_at', 'read_at',
]; ];
@@ -26,7 +26,7 @@ class UserChangelogRead extends Model
{ {
self::firstOrCreate([ self::firstOrCreate([
'user_id' => $userId, 'user_id' => $userId,
'changelog_identifier' => $identifier, 'release_tag' => $identifier,
], [ ], [
'read_at' => now(), 'read_at' => now(),
]); ]);
@@ -35,14 +35,14 @@ class UserChangelogRead extends Model
public static function isReadByUser(int $userId, string $identifier): bool public static function isReadByUser(int $userId, string $identifier): bool
{ {
return self::where('user_id', $userId) return self::where('user_id', $userId)
->where('changelog_identifier', $identifier) ->where('release_tag', $identifier)
->exists(); ->exists();
} }
public static function getReadIdentifiersForUser(int $userId): array public static function getReadIdentifiersForUser(int $userId): array
{ {
return self::where('user_id', $userId) return self::where('user_id', $userId)
->pluck('changelog_identifier') ->pluck('release_tag')
->toArray(); ->toArray();
} }
} }

View File

@@ -8,6 +8,7 @@ use Carbon\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Spatie\LaravelMarkdown\MarkdownRenderer;
class ChangelogService class ChangelogService
{ {
@@ -63,7 +64,7 @@ class ChangelogService
$readIdentifiers = UserChangelogRead::getReadIdentifiersForUser($user->id); $readIdentifiers = UserChangelogRead::getReadIdentifiersForUser($user->id);
return $entries->map(function ($entry) use ($readIdentifiers) { return $entries->map(function ($entry) use ($readIdentifiers) {
$entry->is_read = in_array($entry->version, $readIdentifiers); $entry->is_read = in_array($entry->tag_name, $readIdentifiers);
return $entry; return $entry;
})->sortBy([ })->sortBy([
@@ -78,7 +79,7 @@ class ChangelogService
$entries = $this->getEntries(); $entries = $this->getEntries();
$readIdentifiers = UserChangelogRead::getReadIdentifiersForUser($user->id); $readIdentifiers = UserChangelogRead::getReadIdentifiersForUser($user->id);
return $entries->reject(fn ($entry) => in_array($entry->version, $readIdentifiers))->count(); return $entries->reject(fn ($entry) => in_array($entry->tag_name, $readIdentifiers))->count();
} else { } else {
return Cache::remember( return Cache::remember(
'user_unread_changelog_count_'.$user->id, 'user_unread_changelog_count_'.$user->id,
@@ -87,7 +88,7 @@ class ChangelogService
$entries = $this->getEntries(); $entries = $this->getEntries();
$readIdentifiers = UserChangelogRead::getReadIdentifiersForUser($user->id); $readIdentifiers = UserChangelogRead::getReadIdentifiersForUser($user->id);
return $entries->reject(fn ($entry) => in_array($entry->version, $readIdentifiers))->count(); return $entries->reject(fn ($entry) => in_array($entry->tag_name, $readIdentifiers))->count();
} }
); );
} }
@@ -200,7 +201,7 @@ class ChangelogService
$entries = $this->getEntries(); $entries = $this->getEntries();
foreach ($entries as $entry) { foreach ($entries as $entry) {
UserChangelogRead::markAsRead($user->id, $entry->version); UserChangelogRead::markAsRead($user->id, $entry->tag_name);
} }
Cache::forget('user_unread_changelog_count_'.$user->id); Cache::forget('user_unread_changelog_count_'.$user->id);
@@ -208,7 +209,7 @@ class ChangelogService
private function validateEntryData(array $data): bool private function validateEntryData(array $data): bool
{ {
$required = ['version', 'title', 'content', 'published_at']; $required = ['tag_name', 'title', 'content', 'published_at'];
foreach ($required as $field) { foreach ($required as $field) {
if (! isset($data[$field]) || empty($data[$field])) { if (! isset($data[$field]) || empty($data[$field])) {
@@ -253,41 +254,46 @@ class ChangelogService
private function parseMarkdown(string $content): string private function parseMarkdown(string $content): string
{ {
// Convert markdown to HTML using simple regex patterns $renderer = app(MarkdownRenderer::class);
$html = $content;
$html = $renderer->toHtml($content);
// Apply custom Tailwind CSS classes for dark mode compatibility
$html = $this->applyCustomStyling($html);
return $html;
}
private function applyCustomStyling(string $html): string
{
// Headers // Headers
$html = preg_replace('/^### (.*?)$/m', '<h3 class="text-md font-semibold dark:text-white mb-1">$1</h3>', $html); $html = preg_replace('/<h1[^>]*>/', '<h1 class="text-xl font-bold dark:text-white mb-2">', $html);
$html = preg_replace('/^## (.*?)$/m', '<h2 class="text-lg font-semibold dark:text-white mb-2">$1</h2>', $html); $html = preg_replace('/<h2[^>]*>/', '<h2 class="text-lg font-semibold dark:text-white mb-2">', $html);
$html = preg_replace('/^# (.*?)$/m', '<h1 class="text-xl font-bold dark:text-white mb-2">$1</h1>', $html); $html = preg_replace('/<h3[^>]*>/', '<h3 class="text-md font-semibold dark:text-white mb-1">', $html);
// Bold text // Paragraphs
$html = preg_replace('/\*\*(.*?)\*\*/', '<strong class="font-semibold">$1</strong>', $html); $html = preg_replace('/<p[^>]*>/', '<p class="mb-2 dark:text-neutral-300">', $html);
$html = preg_replace('/__(.*?)__/', '<strong class="font-semibold">$1</strong>', $html);
// Italic text // Lists
$html = preg_replace('/\*(.*?)\*/', '<em class="italic">$1</em>', $html); $html = preg_replace('/<ul[^>]*>/', '<ul class="mb-2 ml-4 list-disc">', $html);
$html = preg_replace('/_(.*?)_/', '<em class="italic">$1</em>', $html); $html = preg_replace('/<ol[^>]*>/', '<ol class="mb-2 ml-4 list-decimal">', $html);
$html = preg_replace('/<li[^>]*>/', '<li class="dark:text-neutral-300">', $html);
// Code blocks // Code blocks and inline code
$html = preg_replace('/```(.*?)```/s', '<pre class="bg-gray-100 dark:bg-coolgray-300 p-2 rounded text-sm overflow-x-auto my-2"><code>$1</code></pre>', $html); $html = preg_replace('/<pre[^>]*>/', '<pre class="bg-gray-100 dark:bg-coolgray-300 p-2 rounded text-sm overflow-x-auto my-2">', $html);
$html = preg_replace('/<code[^>]*>/', '<code class="bg-gray-100 dark:bg-coolgray-300 px-1 py-0.5 rounded text-sm">', $html);
// Inline code // Links - Apply styling to existing markdown links
$html = preg_replace('/`([^`]+)`/', '<code class="bg-gray-100 dark:bg-coolgray-300 px-1 py-0.5 rounded text-sm">$1</code>', $html); $html = preg_replace('/<a([^>]*)>/', '<a$1 class="text-blue-500 hover:text-blue-600 underline" target="_blank" rel="noopener">', $html);
// Links // Convert plain URLs to clickable links (that aren't already in <a> tags)
$html = preg_replace('/\[([^\]]+)\]\(([^)]+)\)/', '<a href="$2" class="text-blue-500 hover:text-blue-600 underline" target="_blank" rel="noopener">$1</a>', $html); $html = preg_replace('/(?<!href="|href=\')(?<!>)(?<!\/)(https?:\/\/[^\s<>"]+)(?![^<]*<\/a>)/', '<a href="$1" class="text-blue-500 hover:text-blue-600 underline" target="_blank" rel="noopener">$1</a>', $html);
// Line breaks (convert double newlines to paragraphs) // Strong/bold text
$paragraphs = preg_split('/\n\s*\n/', trim($html)); $html = preg_replace('/<strong[^>]*>/', '<strong class="font-semibold dark:text-white">', $html);
$html = '<p class="mb-2">'.implode('</p><p class="mb-2">', $paragraphs).'</p>';
// Single line breaks // Emphasis/italic text
$html = preg_replace('/\n/', '<br>', $html); $html = preg_replace('/<em[^>]*>/', '<em class="italic dark:text-neutral-300">', $html);
// Unordered lists
$html = preg_replace('/^\- (.*)$/m', '<li class="ml-4">• $1</li>', $html);
$html = preg_replace('/(<li class="ml-4">.*<\/li>)/s', '<ul class="mb-2">$1</ul>', $html);
return $html; return $html;
} }

17
changelogs/2025-05.json Normal file
View File

@@ -0,0 +1,17 @@
{
"entries": [
{
"tag_name": "v4.0.0-beta.418",
"title": "v4.0.0-beta.418",
"content": "- fix(core): Clean up Horizon Redis data every hour.\r\n- fix(ui): Infinite loop on requesting a png file on new resource page.\r\n\r\n## What's Changed\r\n* v4.0.0-beta.418 by @andrasbacsai in https://github.com/coollabsio/coolify/pull/5793\r\n\r\n\r\n**Full Changelog**: https://github.com/coollabsio/coolify/compare/v4.0.0-beta.417...v4.0.0-beta.418",
"published_at": "2025-05-09T06:30:49.000000Z"
},
{
"tag_name": "v4.0.0-beta.417",
"title": "v4.0.0-beta.417",
"content": "- fix(core): Cleanup redis data every 10 minutes.\r\n- fix(core): Revert jobs to be `dontRelease` as they were before the redis/job problems started.\r\n\r\n## What's Changed\r\n* v4.0.0-beta.417 by @andrasbacsai in https://github.com/coollabsio/coolify/pull/5784\r\n\r\n\r\n**Full Changelog**: https://github.com/coollabsio/coolify/compare/v4.0.0-beta.416...v4.0.0-beta.417",
"published_at": "2025-05-07T21:14:23.000000Z"
}
],
"last_updated": "2025-08-10T10:20:51.376736Z"
}

23
changelogs/2025-06.json Normal file

File diff suppressed because one or more lines are too long

35
changelogs/2025-07.json Normal file
View File

@@ -0,0 +1,35 @@
{
"entries": [
{
"tag_name": "v4.0.0-beta.420.6",
"title": "v4.0.0-beta.420.6",
"content": "## Changes\r\n- chore(deps): update composer dependencies to fix https://github.com/advisories/GHSA-29cq-5w36-x7w3\r\n\r\n## What's Changed\r\n* 4.0.0-beta.420.6 by @peaklabs-dev in https://github.com/coollabsio/coolify/pull/6221\r\n\r\n\r\n**Full Changelog**: https://github.com/coollabsio/coolify/compare/v4.0.0-beta.420.5...v4.0.0-beta.420.6",
"published_at": "2025-07-18T18:45:44.000000Z"
},
{
"tag_name": "v4.0.0-beta.420.5",
"title": "v4.0.0-beta.420.5",
"content": "- refactor(postgresql): improve layout and spacing in SSL and Proxy configuration sections for better UI consistency\r\n- fix(database): ensure internal port defaults correctly for unsupported database types in StartDatabaseProxy\r\n\r\n## What's Changed\r\n* v4.0.0-beta.420.5 by @andrasbacsai in https://github.com/coollabsio/coolify/pull/6156\r\n\r\n\r\n**Full Changelog**: https://github.com/coollabsio/coolify/compare/v4.0.0-beta.420.4...v4.0.0-beta.420.5",
"published_at": "2025-07-08T19:14:04.000000Z"
},
{
"tag_name": "v4.0.0-beta.420.4",
"title": "v4.0.0-beta.420.4",
"content": "# POSSIBLE BREAKING CHANGE\r\n- fix(envs): enhance COOLIFY_URL and COOLIFY_FQDN variable generation for better compatibility for applications and services.\r\n - **Switched URL with FQDN in case of applications, so they are correct now.**\r\n\r\n--- \r\n- feat(envs): New environment variables for `docker compose` based application. You can use `SERVICE_FQDN_<serviceName>` and `SERVICE_URL_<serviceName>` the same way as in services. For details check this: https://github.com/coollabsio/coolify/issues/6124#issuecomment-3045186382\r\n- fix(redis): Cleanup old jobs on Coolify start.\r\n- fix(database): Unsupported database with SSL.\r\n- fix(jobs): Sentinel update job fails if compose is invalid for a service/app.\r\n- fix(installscript): Add Cachyos support + reuse env variable for public ip.\r\n- fix(envs): Generate literal env variables better.\r\n- fix(scheduling): change redis cleanup command frequency from hourly to weekly for better resource management\r\n\r\n# Issues\r\n- fix https://github.com/coollabsio/coolify/issues/6142\r\n- fix https://github.com/coollabsio/coolify/issues/6134\r\n- fix https://github.com/coollabsio/coolify/issues/6141\r\n- fix https://github.com/coollabsio/coolify/issues/2470\r\n\r\n## What's Changed\r\n* fix(service): Postiz shows no available server on latest version by @ShadowArcanist in https://github.com/coollabsio/coolify/pull/6144\r\n* Typo Correction on modal by @Nathanjms in https://github.com/coollabsio/coolify/pull/6130\r\n* fix(install.sh): use IPV4_PUBLIC_IP variable in output instead of repeated curl by @dewstouh in https://github.com/coollabsio/coolify/pull/6129\r\n* interpret cachyos as arch in the install script by @flickowoa in https://github.com/coollabsio/coolify/pull/6127\r\n* v4.0.0-beta.420.4 by @andrasbacsai in https://github.com/coollabsio/coolify/pull/6146\r\n\r\n## New Contributors\r\n* @dewstouh made their first contribution in https://github.com/coollabsio/coolify/pull/6129\r\n* @flickowoa made their first contribution in https://github.com/coollabsio/coolify/pull/6127\r\n\r\n**Full Changelog**: https://github.com/coollabsio/coolify/compare/v4.0.0-beta.420.3...v4.0.0-beta.420.4",
"published_at": "2025-07-08T08:58:14.000000Z"
},
{
"tag_name": "v4.0.0-beta.420.3",
"title": "v4.0.0-beta.420.3",
"content": "- fix(shared): enhance FQDN generation logic for services in newParser function\r\n- fix(ui): light mode, configuration changed popup fixed\r\n\r\n## What's Changed\r\n* v4.0.0-beta.420.3 by @andrasbacsai in https://github.com/coollabsio/coolify/pull/6120\r\n\r\n\r\n**Full Changelog**: https://github.com/coollabsio/coolify/compare/v4.0.0-beta.420.2...v4.0.0-beta.420.3",
"published_at": "2025-07-03T19:31:13.000000Z"
},
{
"tag_name": "v4.0.0-beta.420.2",
"title": "v4.0.0-beta.420.2",
"content": "- fix(supabase): Fix supabase template\r\n- fix(database): proxy ssl port if ssl is enabled\r\n- fix(ui): enhance terminal access messaging to clarify server functionality and terminal status\r\n- fix(server): prepend 'mux_' to UUID in muxFilename method for consistent naming\r\n- fix(jobs): update middleware to use expireAfter for WithoutOverlapping in multiple job classes\r\n- fix(ui): improve destination selection description for clarity in resource segregation\r\n- fix(terminal): ensure shell execution only uses valid shell if available in terminal command\r\n- refactor(ui): remove unnecessary step3ButtonText attributes from modal confirmation components for cleaner code\r\n- refactor(ui): separate views for instance settings to separate paths to make it cleaner\r\n- refactor(ui): enhance project cloning interface with improved table layout for server and resource selection\r\n\r\n# Issues\r\n- fix #6074\r\n- fix #6022\r\n\r\n## What's Changed\r\n* fix: 500 status code when trying to clone an environment by @HicaroD in https://github.com/coollabsio/coolify/pull/6071\r\n* Update Authentik to version 2025.6.3 by @Datenschmutz in https://github.com/coollabsio/coolify/pull/6100\r\n* Update Appwrite services and image version (1.7.4) by @pujan-modha in https://github.com/coollabsio/coolify/pull/6099\r\n* feat(template): added excalidraw by @nktnet1 in https://github.com/coollabsio/coolify/pull/6095\r\n* v4.0.0-beta.420.2 by @andrasbacsai in https://github.com/coollabsio/coolify/pull/6096\r\n\r\n## New Contributors\r\n* @smad-bro made their first contribution in https://github.com/coollabsio/coolify/pull/6031\r\n* @HicaroD made their first contribution in https://github.com/coollabsio/coolify/pull/6071\r\n\r\n**Full Changelog**: https://github.com/coollabsio/coolify/compare/v4.0.0-beta.420.1...v4.0.0-beta.420.2",
"published_at": "2025-07-03T13:55:03.000000Z"
}
],
"last_updated": "2025-08-10T10:20:51.373867Z"
}

View File

@@ -47,6 +47,7 @@
"socialiteproviders/zitadel": "^4.2", "socialiteproviders/zitadel": "^4.2",
"spatie/laravel-activitylog": "^4.10.2", "spatie/laravel-activitylog": "^4.10.2",
"spatie/laravel-data": "^4.17.0", "spatie/laravel-data": "^4.17.0",
"spatie/laravel-markdown": "^2.7",
"spatie/laravel-ray": "^1.40.2", "spatie/laravel-ray": "^1.40.2",
"spatie/laravel-schemaless-attributes": "^2.5.1", "spatie/laravel-schemaless-attributes": "^2.5.1",
"spatie/url": "^2.4", "spatie/url": "^2.4",

203
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "52a680a0eb446dcaa74bc35e158aca57", "content-hash": "a78cf8fdfec25eac43de77c05640dc91",
"packages": [ "packages": [
{ {
"name": "amphp/amp", "name": "amphp/amp",
@@ -7902,6 +7902,66 @@
], ],
"time": "2025-05-08T15:41:09+00:00" "time": "2025-05-08T15:41:09+00:00"
}, },
{
"name": "spatie/commonmark-shiki-highlighter",
"version": "2.5.1",
"source": {
"type": "git",
"url": "https://github.com/spatie/commonmark-shiki-highlighter.git",
"reference": "595c7e0b45d4a63b17dfc1ccbd13532d431ec351"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/commonmark-shiki-highlighter/zipball/595c7e0b45d4a63b17dfc1ccbd13532d431ec351",
"reference": "595c7e0b45d4a63b17dfc1ccbd13532d431ec351",
"shasum": ""
},
"require": {
"league/commonmark": "^2.4.2",
"php": "^8.0",
"spatie/shiki-php": "^2.2.2",
"symfony/process": "^5.4|^6.4|^7.1"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^2.19|^v3.49.0",
"phpunit/phpunit": "^9.5",
"spatie/phpunit-snapshot-assertions": "^4.2.7",
"spatie/ray": "^1.28"
},
"type": "commonmark-extension",
"autoload": {
"psr-4": {
"Spatie\\CommonMarkShikiHighlighter\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"role": "Developer"
}
],
"description": "Highlight code blocks with league/commonmark and Shiki",
"homepage": "https://github.com/spatie/commonmark-shiki-highlighter",
"keywords": [
"commonmark-shiki-highlighter",
"spatie"
],
"support": {
"source": "https://github.com/spatie/commonmark-shiki-highlighter/tree/2.5.1"
},
"funding": [
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2025-01-13T11:25:47+00:00"
},
{ {
"name": "spatie/laravel-activitylog", "name": "spatie/laravel-activitylog",
"version": "4.10.2", "version": "4.10.2",
@@ -8076,6 +8136,82 @@
], ],
"time": "2025-06-25T11:36:37+00:00" "time": "2025-06-25T11:36:37+00:00"
}, },
{
"name": "spatie/laravel-markdown",
"version": "2.7.1",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-markdown.git",
"reference": "353e7f9fae62826e26cbadef58a12ecf39685280"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/laravel-markdown/zipball/353e7f9fae62826e26cbadef58a12ecf39685280",
"reference": "353e7f9fae62826e26cbadef58a12ecf39685280",
"shasum": ""
},
"require": {
"illuminate/cache": "^9.0|^10.0|^11.0|^12.0",
"illuminate/contracts": "^9.0|^10.0|^11.0|^12.0",
"illuminate/support": "^9.0|^10.0|^11.0|^12.0",
"illuminate/view": "^9.0|^10.0|^11.0|^12.0",
"league/commonmark": "^2.6.0",
"php": "^8.1",
"spatie/commonmark-shiki-highlighter": "^2.5",
"spatie/laravel-package-tools": "^1.4.3"
},
"require-dev": {
"brianium/paratest": "^6.2|^7.8",
"nunomaduro/collision": "^5.3|^6.0|^7.0|^8.0",
"orchestra/testbench": "^6.15|^7.0|^8.0|^10.0",
"pestphp/pest": "^1.22|^2.0|^3.7",
"phpunit/phpunit": "^9.3|^11.5.3",
"spatie/laravel-ray": "^1.23",
"spatie/pest-plugin-snapshots": "^1.1|^2.2|^3.0",
"vimeo/psalm": "^4.8|^6.7"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Spatie\\LaravelMarkdown\\MarkdownServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Spatie\\LaravelMarkdown\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"role": "Developer"
}
],
"description": "A highly configurable markdown renderer and Blade component for Laravel",
"homepage": "https://github.com/spatie/laravel-markdown",
"keywords": [
"Laravel-Markdown",
"laravel",
"spatie"
],
"support": {
"source": "https://github.com/spatie/laravel-markdown/tree/2.7.1"
},
"funding": [
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2025-02-21T13:43:18+00:00"
},
{ {
"name": "spatie/laravel-package-tools", "name": "spatie/laravel-package-tools",
"version": "1.92.7", "version": "1.92.7",
@@ -8515,6 +8651,71 @@
], ],
"time": "2025-04-18T08:17:40+00:00" "time": "2025-04-18T08:17:40+00:00"
}, },
{
"name": "spatie/shiki-php",
"version": "2.3.2",
"source": {
"type": "git",
"url": "https://github.com/spatie/shiki-php.git",
"reference": "a2e78a9ff8a1290b25d550be8fbf8285c13175c5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/shiki-php/zipball/a2e78a9ff8a1290b25d550be8fbf8285c13175c5",
"reference": "a2e78a9ff8a1290b25d550be8fbf8285c13175c5",
"shasum": ""
},
"require": {
"ext-json": "*",
"php": "^8.0",
"symfony/process": "^5.4|^6.4|^7.1"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^v3.0",
"pestphp/pest": "^1.8",
"phpunit/phpunit": "^9.5",
"spatie/pest-plugin-snapshots": "^1.1",
"spatie/ray": "^1.10"
},
"type": "library",
"autoload": {
"psr-4": {
"Spatie\\ShikiPhp\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Rias Van der Veken",
"email": "rias@spatie.be",
"role": "Developer"
},
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"role": "Developer"
}
],
"description": "Highlight code using Shiki in PHP",
"homepage": "https://github.com/spatie/shiki-php",
"keywords": [
"shiki",
"spatie"
],
"support": {
"source": "https://github.com/spatie/shiki-php/tree/2.3.2"
},
"funding": [
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2025-02-21T14:16:57+00:00"
},
{ {
"name": "spatie/url", "name": "spatie/url",
"version": "2.4.0", "version": "2.4.0",

View File

@@ -14,13 +14,13 @@ return new class extends Migration
Schema::create('user_changelog_reads', function (Blueprint $table) { Schema::create('user_changelog_reads', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade'); $table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('changelog_identifier'); $table->string('release_tag'); // GitHub tag_name (e.g., "v4.0.0-beta.420.6")
$table->timestamp('read_at'); $table->timestamp('read_at');
$table->timestamps(); $table->timestamps();
$table->unique(['user_id', 'changelog_identifier']); $table->unique(['user_id', 'release_tag']);
$table->index('user_id'); $table->index('user_id');
$table->index('changelog_identifier'); $table->index('release_tag');
}); });
} }

View File

@@ -7,14 +7,14 @@
// Load all entries when component initializes // Load all entries when component initializes
this.allEntries = @js($entries->toArray()); this.allEntries = @js($entries->toArray());
}, },
markEntryAsRead(version) { markEntryAsRead(tagName) {
// Update the entry in our local Alpine data // Update the entry in our local Alpine data
const entry = this.allEntries.find(e => e.version === version); const entry = this.allEntries.find(e => e.tag_name === tagName);
if (entry) { if (entry) {
entry.is_read = true; entry.is_read = true;
} }
// Call Livewire to update server-side // Call Livewire to update server-side
$wire.markAsRead(version); $wire.markAsRead(tagName);
}, },
markAllEntriesAsRead() { markAllEntriesAsRead() {
// Update all entries in our local Alpine data // Update all entries in our local Alpine data
@@ -73,7 +73,7 @@
entries = entries.filter(entry => { entries = entries.filter(entry => {
return (entry.title?.toLowerCase().includes(searchLower) || return (entry.title?.toLowerCase().includes(searchLower) ||
entry.content?.toLowerCase().includes(searchLower) || entry.content?.toLowerCase().includes(searchLower) ||
entry.version?.toLowerCase().includes(searchLower)); entry.tag_name?.toLowerCase().includes(searchLower));
}); });
} }
@@ -124,7 +124,8 @@
class="px-1 dropdown-item-no-padding flex items-center justify-between"> class="px-1 dropdown-item-no-padding flex items-center justify-between">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
<span>What's New</span> <span>What's New</span>
</div> </div>
@@ -137,7 +138,8 @@
<button wire:click="openWhatsNewModal" @click="dropdownOpen = false" <button wire:click="openWhatsNewModal" @click="dropdownOpen = false"
class="px-1 dropdown-item-no-padding flex items-center gap-2"> class="px-1 dropdown-item-no-padding flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg> </svg>
<span>Changelog</span> <span>Changelog</span>
</button> </button>
@@ -152,21 +154,24 @@
<button @click="setTheme('dark'); dropdownOpen = false" <button @click="setTheme('dark'); dropdownOpen = false"
class="px-1 dropdown-item-no-padding flex items-center gap-2"> class="px-1 dropdown-item-no-padding flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg> </svg>
<span>Dark</span> <span>Dark</span>
</button> </button>
<button @click="setTheme('light'); dropdownOpen = false" <button @click="setTheme('light'); dropdownOpen = false"
class="px-1 dropdown-item-no-padding flex items-center gap-2"> class="px-1 dropdown-item-no-padding flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg> </svg>
<span>Light</span> <span>Light</span>
</button> </button>
<button @click="setTheme('system'); dropdownOpen = false" <button @click="setTheme('system'); dropdownOpen = false"
class="px-1 dropdown-item-no-padding flex items-center gap-2"> class="px-1 dropdown-item-no-padding flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg> </svg>
<span>System</span> <span>System</span>
</button> </button>
@@ -175,17 +180,19 @@
<div <div
class="my-1 font-bold border-b dark:border-coolgray-500 border-neutral-300 dark:text-white text-md"> class="my-1 font-bold border-b dark:border-coolgray-500 border-neutral-300 dark:text-white text-md">
Width</div> Width</div>
<button @click="switchWidth(); dropdownOpen = false" class="px-1 dropdown-item-no-padding flex items-center gap-2" <button @click="switchWidth(); dropdownOpen = false"
x-show="full === 'full'"> class="px-1 dropdown-item-no-padding flex items-center gap-2" x-show="full === 'full'">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6h16M4 12h16M4 18h7" />
</svg> </svg>
<span>Center</span> <span>Center</span>
</button> </button>
<button @click="switchWidth(); dropdownOpen = false" class="px-1 dropdown-item-no-padding flex items-center gap-2" <button @click="switchWidth(); dropdownOpen = false"
x-show="full === 'center'"> class="px-1 dropdown-item-no-padding flex items-center gap-2" x-show="full === 'center'">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6h16M4 12h16M4 18h16" />
</svg> </svg>
<span>Full</span> <span>Full</span>
</button> </button>
@@ -197,14 +204,16 @@
<button @click="setZoom(100); dropdownOpen = false" <button @click="setZoom(100); dropdownOpen = false"
class="px-1 dropdown-item-no-padding flex items-center gap-2"> class="px-1 dropdown-item-no-padding flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg> </svg>
<span>100%</span> <span>100%</span>
</button> </button>
<button @click="setZoom(90); dropdownOpen = false" <button @click="setZoom(90); dropdownOpen = false"
class="px-1 dropdown-item-no-padding flex items-center gap-2"> class="px-1 dropdown-item-no-padding flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 10h4v4h-4v-4z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 10h4v4h-4v-4z" />
</svg> </svg>
<span>90%</span> <span>90%</span>
</button> </button>
@@ -215,14 +224,14 @@
<!-- What's New Modal --> <!-- What's New Modal -->
@if ($showWhatsNewModal) @if ($showWhatsNewModal)
<div class="fixed inset-0 z-50 flex items-center justify-center p-6 sm:p-8"> <div class="fixed inset-0 z-50 flex items-center justify-center py-6 px-4" @keydown.escape.window="$wire.closeWhatsNewModal()">
<!-- Background overlay --> <!-- Background overlay -->
<div class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs" wire:click="closeWhatsNewModal"> <div class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs" wire:click="closeWhatsNewModal">
</div> </div>
<!-- Modal panel --> <!-- Modal panel -->
<div <div
class="relative w-full max-w-4xl py-6 border rounded-sm drop-shadow-sm bg-white border-neutral-200 dark:bg-base px-6 dark:border-coolgray-300"> class="relative w-full h-full max-w-7xl py-6 border rounded-sm drop-shadow-sm bg-white border-neutral-200 dark:bg-base px-6 dark:border-coolgray-300 flex flex-col">
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between pb-3"> <div class="flex items-center justify-between pb-3">
<div> <div>
@@ -234,6 +243,16 @@
</p> </p>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@if (isDev())
<x-forms.button wire:click="manualFetchChangelog"
class="bg-coolgray-200 hover:bg-coolgray-300">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Fetch Latest
</x-forms.button>
@endif
@if ($unreadCount > 0) @if ($unreadCount > 0)
<x-forms.button @click="markAllEntriesAsRead"> <x-forms.button @click="markAllEntriesAsRead">
Mark all as read Mark all as read
@@ -250,7 +269,7 @@
</div> </div>
<!-- Search --> <!-- Search -->
<div class="pb-4 border-b dark:border-coolgray-200"> <div class="pb-4 border-b dark:border-coolgray-200 flex-shrink-0">
<div class="relative"> <div class="relative">
<input x-model="search" placeholder="Search updates..." class="input pl-10" /> <input x-model="search" placeholder="Search updates..." class="input pl-10" />
<svg class="absolute left-3 top-2 w-4 h-4 dark:text-neutral-400" fill="none" <svg class="absolute left-3 top-2 w-4 h-4 dark:text-neutral-400" fill="none"
@@ -262,20 +281,28 @@
</div> </div>
<!-- Content --> <!-- Content -->
<div class="py-4 max-h-96 overflow-y-auto scrollbar"> <div class="py-4 flex-1 overflow-y-auto scrollbar">
<div x-show="filteredEntries.length > 0"> <div x-show="filteredEntries.length > 0">
<div class="space-y-4"> <div class="space-y-4">
<template x-for="entry in filteredEntries" :key="entry.version"> <template x-for="entry in filteredEntries" :key="entry.tag_name">
<div class="relative p-4 border dark:border-coolgray-300 rounded-sm" <div class="relative p-4 border dark:border-coolgray-300 rounded-sm"
:class="!entry.is_read ? 'dark:bg-coolgray-200 border-warning' : 'dark:bg-coolgray-100'"> :class="!entry.is_read ? 'dark:bg-coolgray-200 border-warning' : 'dark:bg-coolgray-100'">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="flex-1"> <div class="flex-1">
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
<span x-show="entry.version" <span x-show="entry.title"
class="px-2 py-1 text-xs font-semibold dark:bg-coolgray-300 dark:text-neutral-200 rounded-sm" class="px-2 py-1 text-xs font-semibold dark:bg-coolgray-300 dark:text-neutral-200 rounded-sm"><a
x-text="entry.version"></span> :href="`https://github.com/coollabsio/coolify/releases/tag/${entry.tag_name}`"
<span class="text-xs dark:text-neutral-400 font-bold" target="_blank"
x-text="entry.title"></span> class="inline-flex items-center gap-1 hover:text-coolgray-500">
<span x-text="entry.title"></span>
<svg class="w-3 h-3 text-neutral-400" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a></span>
<span class="text-xs dark:text-neutral-400" <span class="text-xs dark:text-neutral-400"
x-text="new Date(entry.published_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })"></span> x-text="new Date(entry.published_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })"></span>
</div> </div>
@@ -284,7 +311,7 @@
</div> </div>
</div> </div>
<button x-show="!entry.is_read" @click="markEntryAsRead(entry.version)" <button x-show="!entry.is_read" @click="markEntryAsRead(entry.tag_name)"
class="ml-4 px-3 py-1 text-xs dark:text-neutral-400 hover:dark:text-white border dark:border-neutral-600 rounded hover:dark:bg-neutral-700 transition-colors cursor-pointer" class="ml-4 px-3 py-1 text-xs dark:text-neutral-400 hover:dark:text-white border dark:border-neutral-600 rounded hover:dark:bg-neutral-700 transition-colors cursor-pointer"
title="Mark as read"> title="Mark as read">
mark as read mark as read