feat(user): add changelog read tracking and unread count method
This commit is contained in:
98
app/Console/Commands/InitChangelog.php
Normal file
98
app/Console/Commands/InitChangelog.php
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class InitChangelog extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'changelog:init {month? : Month in YYYY-MM format (defaults to current month)}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Initialize a new monthly changelog file with example structure';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
$month = $this->argument('month') ?: Carbon::now()->format('Y-m');
|
||||||
|
|
||||||
|
// Validate month format
|
||||||
|
if (! preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month)) {
|
||||||
|
$this->error('Invalid month format. Use YYYY-MM format with valid months 01-12 (e.g., 2025-08)');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$changelogsDir = base_path('changelogs');
|
||||||
|
$filePath = $changelogsDir."/{$month}.json";
|
||||||
|
|
||||||
|
// Create changelogs directory if it doesn't exist
|
||||||
|
if (! is_dir($changelogsDir)) {
|
||||||
|
mkdir($changelogsDir, 0755, true);
|
||||||
|
$this->info("Created changelogs directory: {$changelogsDir}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file already exists
|
||||||
|
if (file_exists($filePath)) {
|
||||||
|
if (! $this->confirm("File {$month}.json already exists. Overwrite?")) {
|
||||||
|
$this->info('Operation cancelled');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the month for example data
|
||||||
|
$carbonMonth = Carbon::createFromFormat('Y-m', $month);
|
||||||
|
$monthName = $carbonMonth->format('F Y');
|
||||||
|
$sampleDate = $carbonMonth->addDays(14)->toISOString(); // Mid-month
|
||||||
|
|
||||||
|
// Get version from config
|
||||||
|
$version = 'v'.config('constants.coolify.version');
|
||||||
|
|
||||||
|
// Create example changelog structure
|
||||||
|
$exampleData = [
|
||||||
|
'entries' => [
|
||||||
|
[
|
||||||
|
'version' => $version,
|
||||||
|
'title' => 'Example Feature Release',
|
||||||
|
'content' => "This is an example changelog entry for {$monthName}. Replace this with your actual release notes. Include details about new features, improvements, bug fixes, and any breaking changes.",
|
||||||
|
'published_at' => $sampleDate,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Write the file
|
||||||
|
$jsonContent = json_encode($exampleData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
|
if (file_put_contents($filePath, $jsonContent) === false) {
|
||||||
|
$this->error("Failed to create changelog file: {$filePath}");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("✅ Created changelog file: changelogs/{$month}.json");
|
||||||
|
$this->line(" Example entry created for {$monthName}");
|
||||||
|
$this->line(' Edit the file to add your actual changelog entries');
|
||||||
|
|
||||||
|
// Show the file contents
|
||||||
|
if ($this->option('verbose')) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->line('File contents:');
|
||||||
|
$this->line($jsonContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
52
app/Livewire/SettingsDropdown.php
Normal file
52
app/Livewire/SettingsDropdown.php
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Services\ChangelogService;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class SettingsDropdown extends Component
|
||||||
|
{
|
||||||
|
public $showWhatsNewModal = false;
|
||||||
|
|
||||||
|
public function getUnreadCountProperty()
|
||||||
|
{
|
||||||
|
return Auth::user()->getUnreadChangelogCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEntriesProperty()
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
|
||||||
|
return app(ChangelogService::class)->getEntriesForUser($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function openWhatsNewModal()
|
||||||
|
{
|
||||||
|
$this->showWhatsNewModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeWhatsNewModal()
|
||||||
|
{
|
||||||
|
$this->showWhatsNewModal = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markAsRead($identifier)
|
||||||
|
{
|
||||||
|
app(ChangelogService::class)->markAsReadForUser($identifier, Auth::user());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markAllAsRead()
|
||||||
|
{
|
||||||
|
app(ChangelogService::class)->markAllAsReadForUser(Auth::user());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.settings-dropdown', [
|
||||||
|
'entries' => $this->entries,
|
||||||
|
'unreadCount' => $this->unreadCount,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
@@ -203,6 +203,16 @@ class User extends Authenticatable implements SendsEmail
|
|||||||
return $this->belongsToMany(Team::class)->withPivot('role');
|
return $this->belongsToMany(Team::class)->withPivot('role');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function changelogReads()
|
||||||
|
{
|
||||||
|
return $this->hasMany(UserChangelogRead::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUnreadChangelogCount(): int
|
||||||
|
{
|
||||||
|
return app(\App\Services\ChangelogService::class)->getUnreadCountForUser($this);
|
||||||
|
}
|
||||||
|
|
||||||
public function getRecipients(): array
|
public function getRecipients(): array
|
||||||
{
|
{
|
||||||
return [$this->email];
|
return [$this->email];
|
||||||
|
48
app/Models/UserChangelogRead.php
Normal file
48
app/Models/UserChangelogRead.php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class UserChangelogRead extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'changelog_identifier',
|
||||||
|
'read_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'read_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function markAsRead(int $userId, string $identifier): void
|
||||||
|
{
|
||||||
|
self::firstOrCreate([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'changelog_identifier' => $identifier,
|
||||||
|
], [
|
||||||
|
'read_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isReadByUser(int $userId, string $identifier): bool
|
||||||
|
{
|
||||||
|
return self::where('user_id', $userId)
|
||||||
|
->where('changelog_identifier', $identifier)
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getReadIdentifiersForUser(int $userId): array
|
||||||
|
{
|
||||||
|
return self::where('user_id', $userId)
|
||||||
|
->pluck('changelog_identifier')
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
}
|
294
app/Services/ChangelogService.php
Normal file
294
app/Services/ChangelogService.php
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserChangelogRead;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class ChangelogService
|
||||||
|
{
|
||||||
|
public function getEntries(int $recentMonths = 3): Collection
|
||||||
|
{
|
||||||
|
// For backward compatibility, check if old changelog.json exists
|
||||||
|
if (file_exists(base_path('changelog.json'))) {
|
||||||
|
$data = $this->fetchChangelogData();
|
||||||
|
|
||||||
|
if (! $data || ! isset($data['entries'])) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return collect($data['entries'])
|
||||||
|
->filter(fn ($entry) => $this->validateEntryData($entry))
|
||||||
|
->map(function ($entry) {
|
||||||
|
$entry['published_at'] = Carbon::parse($entry['published_at']);
|
||||||
|
$entry['content_html'] = $this->parseMarkdown($entry['content']);
|
||||||
|
|
||||||
|
return (object) $entry;
|
||||||
|
})
|
||||||
|
->filter(fn ($entry) => $entry->published_at <= now())
|
||||||
|
->sortBy('published_at')
|
||||||
|
->reverse()
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load entries from recent months for performance
|
||||||
|
$availableMonths = $this->getAvailableMonths();
|
||||||
|
$monthsToLoad = $availableMonths->take($recentMonths);
|
||||||
|
|
||||||
|
return $monthsToLoad
|
||||||
|
->flatMap(fn ($month) => $this->getEntriesForMonth($month))
|
||||||
|
->sortBy('published_at')
|
||||||
|
->reverse()
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAllEntries(): Collection
|
||||||
|
{
|
||||||
|
$availableMonths = $this->getAvailableMonths();
|
||||||
|
|
||||||
|
return $availableMonths
|
||||||
|
->flatMap(fn ($month) => $this->getEntriesForMonth($month))
|
||||||
|
->sortBy('published_at')
|
||||||
|
->reverse()
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEntriesForUser(User $user): Collection
|
||||||
|
{
|
||||||
|
$entries = $this->getEntries();
|
||||||
|
$readIdentifiers = UserChangelogRead::getReadIdentifiersForUser($user->id);
|
||||||
|
|
||||||
|
return $entries->map(function ($entry) use ($readIdentifiers) {
|
||||||
|
$entry->is_read = in_array($entry->version, $readIdentifiers);
|
||||||
|
|
||||||
|
return $entry;
|
||||||
|
})->sortBy([
|
||||||
|
['is_read', 'asc'], // unread first
|
||||||
|
['published_at', 'desc'], // then by date
|
||||||
|
])->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUnreadCountForUser(User $user): int
|
||||||
|
{
|
||||||
|
if (isDev()) {
|
||||||
|
$entries = $this->getEntries();
|
||||||
|
$readIdentifiers = UserChangelogRead::getReadIdentifiersForUser($user->id);
|
||||||
|
|
||||||
|
return $entries->reject(fn ($entry) => in_array($entry->version, $readIdentifiers))->count();
|
||||||
|
} else {
|
||||||
|
return Cache::remember(
|
||||||
|
'user_unread_changelog_count_'.$user->id,
|
||||||
|
now()->addHour(),
|
||||||
|
function () use ($user) {
|
||||||
|
$entries = $this->getEntries();
|
||||||
|
$readIdentifiers = UserChangelogRead::getReadIdentifiersForUser($user->id);
|
||||||
|
|
||||||
|
return $entries->reject(fn ($entry) => in_array($entry->version, $readIdentifiers))->count();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAvailableMonths(): Collection
|
||||||
|
{
|
||||||
|
$pattern = base_path('changelogs/*.json');
|
||||||
|
$files = glob($pattern);
|
||||||
|
|
||||||
|
if ($files === false) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return collect($files)
|
||||||
|
->map(fn ($file) => basename($file, '.json'))
|
||||||
|
->filter(fn ($name) => preg_match('/^\d{4}-\d{2}$/', $name))
|
||||||
|
->sort()
|
||||||
|
->reverse()
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEntriesForMonth(string $month): Collection
|
||||||
|
{
|
||||||
|
$path = base_path("changelogs/{$month}.json");
|
||||||
|
|
||||||
|
if (! file_exists($path)) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = file_get_contents($path);
|
||||||
|
|
||||||
|
if ($content === false) {
|
||||||
|
Log::error("Failed to read changelog file: {$month}.json");
|
||||||
|
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($content, true);
|
||||||
|
|
||||||
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||||
|
Log::error("Invalid JSON in {$month}.json: ".json_last_error_msg());
|
||||||
|
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! isset($data['entries']) || ! is_array($data['entries'])) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return collect($data['entries'])
|
||||||
|
->filter(fn ($entry) => $this->validateEntryData($entry))
|
||||||
|
->map(function ($entry) {
|
||||||
|
$entry['published_at'] = Carbon::parse($entry['published_at']);
|
||||||
|
$entry['content_html'] = $this->parseMarkdown($entry['content']);
|
||||||
|
|
||||||
|
return (object) $entry;
|
||||||
|
})
|
||||||
|
->filter(fn ($entry) => $entry->published_at <= now())
|
||||||
|
->sortBy('published_at')
|
||||||
|
->reverse()
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fetchChangelogData(): ?array
|
||||||
|
{
|
||||||
|
// Legacy support for old changelog.json
|
||||||
|
$path = base_path('changelog.json');
|
||||||
|
|
||||||
|
if (file_exists($path)) {
|
||||||
|
$content = file_get_contents($path);
|
||||||
|
|
||||||
|
if ($content === false) {
|
||||||
|
Log::error('Failed to read changelog.json file');
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($content, true);
|
||||||
|
|
||||||
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||||
|
Log::error('Invalid JSON in changelog.json: '.json_last_error_msg());
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// New monthly structure - combine all months
|
||||||
|
$allEntries = [];
|
||||||
|
foreach ($this->getAvailableMonths() as $month) {
|
||||||
|
$monthEntries = $this->getEntriesForMonth($month);
|
||||||
|
foreach ($monthEntries as $entry) {
|
||||||
|
$allEntries[] = (array) $entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['entries' => $allEntries];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markAsReadForUser(string $version, User $user): void
|
||||||
|
{
|
||||||
|
UserChangelogRead::markAsRead($user->id, $version);
|
||||||
|
Cache::forget('user_unread_changelog_count_'.$user->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markAllAsReadForUser(User $user): void
|
||||||
|
{
|
||||||
|
$entries = $this->getEntries();
|
||||||
|
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
UserChangelogRead::markAsRead($user->id, $entry->version);
|
||||||
|
}
|
||||||
|
|
||||||
|
Cache::forget('user_unread_changelog_count_'.$user->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateEntryData(array $data): bool
|
||||||
|
{
|
||||||
|
$required = ['version', 'title', 'content', 'published_at'];
|
||||||
|
|
||||||
|
foreach ($required as $field) {
|
||||||
|
if (! isset($data[$field]) || empty($data[$field])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearAllReadStatus(): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$count = UserChangelogRead::count();
|
||||||
|
UserChangelogRead::truncate();
|
||||||
|
|
||||||
|
// Clear all user caches
|
||||||
|
$this->clearAllUserCaches();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'message' => "Successfully cleared {$count} read status records",
|
||||||
|
];
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Failed to clear read status: '.$e->getMessage());
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Failed to clear read status: '.$e->getMessage(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function clearAllUserCaches(): void
|
||||||
|
{
|
||||||
|
$users = User::select('id')->get();
|
||||||
|
|
||||||
|
foreach ($users as $user) {
|
||||||
|
Cache::forget('user_unread_changelog_count_'.$user->id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseMarkdown(string $content): string
|
||||||
|
{
|
||||||
|
// Convert markdown to HTML using simple regex patterns
|
||||||
|
$html = $content;
|
||||||
|
|
||||||
|
// Headers
|
||||||
|
$html = preg_replace('/^### (.*?)$/m', '<h3 class="text-md font-semibold dark:text-white mb-1">$1</h3>', $html);
|
||||||
|
$html = preg_replace('/^## (.*?)$/m', '<h2 class="text-lg font-semibold dark:text-white mb-2">$1</h2>', $html);
|
||||||
|
$html = preg_replace('/^# (.*?)$/m', '<h1 class="text-xl font-bold dark:text-white mb-2">$1</h1>', $html);
|
||||||
|
|
||||||
|
// Bold text
|
||||||
|
$html = preg_replace('/\*\*(.*?)\*\*/', '<strong class="font-semibold">$1</strong>', $html);
|
||||||
|
$html = preg_replace('/__(.*?)__/', '<strong class="font-semibold">$1</strong>', $html);
|
||||||
|
|
||||||
|
// Italic text
|
||||||
|
$html = preg_replace('/\*(.*?)\*/', '<em class="italic">$1</em>', $html);
|
||||||
|
$html = preg_replace('/_(.*?)_/', '<em class="italic">$1</em>', $html);
|
||||||
|
|
||||||
|
// Code blocks
|
||||||
|
$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);
|
||||||
|
|
||||||
|
// Inline code
|
||||||
|
$html = preg_replace('/`([^`]+)`/', '<code class="bg-gray-100 dark:bg-coolgray-300 px-1 py-0.5 rounded text-sm">$1</code>', $html);
|
||||||
|
|
||||||
|
// Links
|
||||||
|
$html = preg_replace('/\[([^\]]+)\]\(([^)]+)\)/', '<a href="$2" class="text-blue-500 hover:text-blue-600 underline" target="_blank" rel="noopener">$1</a>', $html);
|
||||||
|
|
||||||
|
// Line breaks (convert double newlines to paragraphs)
|
||||||
|
$paragraphs = preg_split('/\n\s*\n/', trim($html));
|
||||||
|
$html = '<p class="mb-2">'.implode('</p><p class="mb-2">', $paragraphs).'</p>';
|
||||||
|
|
||||||
|
// Single line breaks
|
||||||
|
$html = preg_replace('/\n/', '<br>', $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;
|
||||||
|
}
|
||||||
|
}
|
@@ -65,6 +65,6 @@ return [
|
|||||||
'client_secret' => env('ZITADEL_CLIENT_SECRET'),
|
'client_secret' => env('ZITADEL_CLIENT_SECRET'),
|
||||||
'redirect' => env('ZITADEL_REDIRECT_URI'),
|
'redirect' => env('ZITADEL_REDIRECT_URI'),
|
||||||
'base_url' => env('ZITADEL_BASE_URL'),
|
'base_url' => env('ZITADEL_BASE_URL'),
|
||||||
]
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('user_changelog_reads', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained()->onDelete('cascade');
|
||||||
|
$table->string('changelog_identifier');
|
||||||
|
$table->timestamp('read_at');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['user_id', 'changelog_identifier']);
|
||||||
|
$table->index('user_id');
|
||||||
|
$table->index('changelog_identifier');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('user_changelog_reads');
|
||||||
|
}
|
||||||
|
};
|
@@ -120,6 +120,7 @@ COPY --chown=www-data:www-data templates ./templates
|
|||||||
COPY --chown=www-data:www-data resources/views ./resources/views
|
COPY --chown=www-data:www-data resources/views ./resources/views
|
||||||
COPY --chown=www-data:www-data artisan artisan
|
COPY --chown=www-data:www-data artisan artisan
|
||||||
COPY --chown=www-data:www-data openapi.yaml ./openapi.yaml
|
COPY --chown=www-data:www-data openapi.yaml ./openapi.yaml
|
||||||
|
COPY --chown=www-data:www-data changelogs/ ./changelogs/
|
||||||
|
|
||||||
RUN composer dump-autoload
|
RUN composer dump-autoload
|
||||||
|
|
||||||
|
@@ -1,119 +1,88 @@
|
|||||||
<nav class="flex flex-col flex-1 px-2 bg-white border-r dark:border-coolgray-200 dark:bg-base" x-data="{
|
<nav class="flex flex-col flex-1 px-2 bg-white border-r dark:border-coolgray-200 border-neutral-300 dark:bg-base"
|
||||||
switchWidth() {
|
x-data="{
|
||||||
if (this.full === 'full') {
|
switchWidth() {
|
||||||
localStorage.setItem('pageWidth', 'center');
|
if (this.full === 'full') {
|
||||||
} else {
|
localStorage.setItem('pageWidth', 'center');
|
||||||
localStorage.setItem('pageWidth', 'full');
|
|
||||||
}
|
|
||||||
window.location.reload();
|
|
||||||
},
|
|
||||||
setZoom(zoom) {
|
|
||||||
localStorage.setItem('zoom', zoom);
|
|
||||||
window.location.reload();
|
|
||||||
},
|
|
||||||
init() {
|
|
||||||
this.full = localStorage.getItem('pageWidth');
|
|
||||||
this.zoom = localStorage.getItem('zoom');
|
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
|
|
||||||
const userSettings = localStorage.getItem('theme');
|
|
||||||
if (userSettings !== 'system') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.matches) {
|
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
} else {
|
} else {
|
||||||
|
localStorage.setItem('pageWidth', 'full');
|
||||||
|
}
|
||||||
|
window.location.reload();
|
||||||
|
},
|
||||||
|
setZoom(zoom) {
|
||||||
|
localStorage.setItem('zoom', zoom);
|
||||||
|
window.location.reload();
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.full = localStorage.getItem('pageWidth');
|
||||||
|
this.zoom = localStorage.getItem('zoom');
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
|
||||||
|
const userSettings = localStorage.getItem('theme');
|
||||||
|
if (userSettings !== 'system') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.matches) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.queryTheme();
|
||||||
|
this.checkZoom();
|
||||||
|
},
|
||||||
|
setTheme(type) {
|
||||||
|
this.theme = type;
|
||||||
|
localStorage.setItem('theme', type);
|
||||||
|
this.queryTheme();
|
||||||
|
},
|
||||||
|
queryTheme() {
|
||||||
|
const darkModePreference = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
const userSettings = localStorage.getItem('theme') || 'dark';
|
||||||
|
localStorage.setItem('theme', userSettings);
|
||||||
|
if (userSettings === 'dark') {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
this.theme = 'dark';
|
||||||
|
} else if (userSettings === 'light') {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
this.theme = 'light';
|
||||||
|
} else if (darkModePreference) {
|
||||||
|
this.theme = 'system';
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
} else if (!darkModePreference) {
|
||||||
|
this.theme = 'system';
|
||||||
document.documentElement.classList.remove('dark');
|
document.documentElement.classList.remove('dark');
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
this.queryTheme();
|
checkZoom() {
|
||||||
this.checkZoom();
|
if (this.zoom === null) {
|
||||||
},
|
this.setZoom(100);
|
||||||
setTheme(type) {
|
}
|
||||||
this.theme = type;
|
if (this.zoom === '90') {
|
||||||
localStorage.setItem('theme', type);
|
const style = document.createElement('style');
|
||||||
this.queryTheme();
|
style.textContent = `
|
||||||
},
|
|
||||||
queryTheme() {
|
|
||||||
const darkModePreference = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
const userSettings = localStorage.getItem('theme') || 'dark';
|
|
||||||
localStorage.setItem('theme', userSettings);
|
|
||||||
if (userSettings === 'dark') {
|
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
this.theme = 'dark';
|
|
||||||
} else if (userSettings === 'light') {
|
|
||||||
document.documentElement.classList.remove('dark');
|
|
||||||
this.theme = 'light';
|
|
||||||
} else if (darkModePreference) {
|
|
||||||
this.theme = 'system';
|
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
} else if (!darkModePreference) {
|
|
||||||
this.theme = 'system';
|
|
||||||
document.documentElement.classList.remove('dark');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
checkZoom() {
|
|
||||||
if (this.zoom === null) {
|
|
||||||
this.setZoom(100);
|
|
||||||
}
|
|
||||||
if (this.zoom === '90') {
|
|
||||||
const style = document.createElement('style');
|
|
||||||
style.textContent = `
|
|
||||||
html {
|
|
||||||
font-size: 93.75%;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--vh: 1vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
html {
|
html {
|
||||||
font-size: 87.5%;
|
font-size: 93.75%;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
`;
|
:root {
|
||||||
document.head.appendChild(style);
|
--vh: 1vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
html {
|
||||||
|
font-size: 87.5%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}">
|
||||||
}">
|
|
||||||
<div class="flex pt-6 pb-4 pl-2">
|
<div class="flex pt-6 pb-4 pl-2">
|
||||||
<div class="flex flex-col w-full">
|
<div class="flex flex-col w-full">
|
||||||
<div class="text-2xl font-bold tracking-wide dark:text-white">Coolify</div>
|
<div class="text-2xl font-bold tracking-wide dark:text-white">Coolify</div>
|
||||||
<x-version />
|
<x-version />
|
||||||
</div>
|
</div>
|
||||||
<div class="pt-1">
|
<livewire:settings-dropdown />
|
||||||
<x-dropdown>
|
|
||||||
<x-slot:title>
|
|
||||||
<div class="flex justify-end w-8" x-show="theme === 'dark' || theme === 'system'">
|
|
||||||
<svg class="w-5 h-5 rounded-sm dark:fill-white" xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-end w-8" x-show="theme === 'light'">
|
|
||||||
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</x-slot:title>
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<div class="font-bold border-b dark:border-coolgray-500 dark:text-white text-md">Color</div>
|
|
||||||
<button @click="setTheme('dark')" class="px-1 dropdown-item-no-padding">Dark</button>
|
|
||||||
<button @click="setTheme('light')" class="px-1 dropdown-item-no-padding">Light</button>
|
|
||||||
<button @click="setTheme('system')" class="px-1 dropdown-item-no-padding">System</button>
|
|
||||||
<div class="my-1 font-bold border-b dark:border-coolgray-500 dark:text-white text-md">Width</div>
|
|
||||||
<button @click="switchWidth()" class="px-1 dropdown-item-no-padding"
|
|
||||||
x-show="full === 'full'">Center</button>
|
|
||||||
<button @click="switchWidth()" class="px-1 dropdown-item-no-padding"
|
|
||||||
x-show="full === 'center'">Full</button>
|
|
||||||
<div class="my-1 font-bold border-b dark:border-coolgray-500 dark:text-white text-md">Zoom</div>
|
|
||||||
<button @click="setZoom(100)" class="px-1 dropdown-item-no-padding">100%</button>
|
|
||||||
<button @click="setZoom(90)" class="px-1 dropdown-item-no-padding">90%</button>
|
|
||||||
</div>
|
|
||||||
</x-dropdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="px-2 pt-2 pb-7">
|
<div class="px-2 pt-2 pb-7">
|
||||||
<livewire:switch-team />
|
<livewire:switch-team />
|
||||||
@@ -196,8 +165,8 @@
|
|||||||
class="{{ request()->is('storages*') ? 'menu-item-active menu-item' : 'menu-item' }}"
|
class="{{ request()->is('storages*') ? 'menu-item-active menu-item' : 'menu-item' }}"
|
||||||
href="{{ route('storage.index') }}">
|
href="{{ route('storage.index') }}">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24">
|
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24">
|
||||||
<g fill="none" stroke="currentColor" stroke-linecap="round"
|
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||||
stroke-linejoin="round" stroke-width="2">
|
stroke-width="2">
|
||||||
<path d="M4 6a8 3 0 1 0 16 0A8 3 0 1 0 4 6" />
|
<path d="M4 6a8 3 0 1 0 16 0A8 3 0 1 0 4 6" />
|
||||||
<path d="M4 6v6a8 3 0 0 0 16 0V6" />
|
<path d="M4 6v6a8 3 0 0 0 16 0V6" />
|
||||||
<path d="M4 12v6a8 3 0 0 0 16 0v-6" />
|
<path d="M4 12v6a8 3 0 0 0 16 0v-6" />
|
||||||
@@ -211,8 +180,8 @@
|
|||||||
class="{{ request()->is('shared-variables*') ? 'menu-item-active menu-item' : 'menu-item' }}"
|
class="{{ request()->is('shared-variables*') ? 'menu-item-active menu-item' : 'menu-item' }}"
|
||||||
href="{{ route('shared-variables.index') }}">
|
href="{{ route('shared-variables.index') }}">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24">
|
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24">
|
||||||
<g fill="none" stroke="currentColor" stroke-linecap="round"
|
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||||
stroke-linejoin="round" stroke-width="2">
|
stroke-width="2">
|
||||||
<path
|
<path
|
||||||
d="M5 4C2.5 9 2.5 14 5 20M19 4c2.5 5 2.5 10 0 16M9 9h1c1 0 1 1 2.016 3.527C13 15 13 16 14 16h1" />
|
d="M5 4C2.5 9 2.5 14 5 20M19 4c2.5 5 2.5 10 0 16M9 9h1c1 0 1 1 2.016 3.527C13 15 13 16 14 16h1" />
|
||||||
<path d="M8 16c1.5 0 3-2 4-3.5S14.5 9 16 9" />
|
<path d="M8 16c1.5 0 3-2 4-3.5S14.5 9 16 9" />
|
||||||
|
271
resources/views/livewire/settings-dropdown.blade.php
Normal file
271
resources/views/livewire/settings-dropdown.blade.php
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
<div x-data="{
|
||||||
|
dropdownOpen: false,
|
||||||
|
search: '',
|
||||||
|
allEntries: [],
|
||||||
|
init() {
|
||||||
|
this.mounted();
|
||||||
|
// Load all entries when component initializes
|
||||||
|
this.allEntries = @js($entries->toArray());
|
||||||
|
},
|
||||||
|
markEntryAsRead(version) {
|
||||||
|
// Update the entry in our local Alpine data
|
||||||
|
const entry = this.allEntries.find(e => e.version === version);
|
||||||
|
if (entry) {
|
||||||
|
entry.is_read = true;
|
||||||
|
}
|
||||||
|
// Call Livewire to update server-side
|
||||||
|
$wire.markAsRead(version);
|
||||||
|
},
|
||||||
|
markAllEntriesAsRead() {
|
||||||
|
// Update all entries in our local Alpine data
|
||||||
|
this.allEntries.forEach(entry => {
|
||||||
|
entry.is_read = true;
|
||||||
|
});
|
||||||
|
// Call Livewire to update server-side
|
||||||
|
$wire.markAllAsRead();
|
||||||
|
},
|
||||||
|
switchWidth() {
|
||||||
|
if (this.full === 'full') {
|
||||||
|
localStorage.setItem('pageWidth', 'center');
|
||||||
|
} else {
|
||||||
|
localStorage.setItem('pageWidth', 'full');
|
||||||
|
}
|
||||||
|
window.location.reload();
|
||||||
|
},
|
||||||
|
setZoom(zoom) {
|
||||||
|
localStorage.setItem('zoom', zoom);
|
||||||
|
window.location.reload();
|
||||||
|
},
|
||||||
|
setTheme(type) {
|
||||||
|
this.theme = type;
|
||||||
|
localStorage.setItem('theme', type);
|
||||||
|
this.queryTheme();
|
||||||
|
},
|
||||||
|
queryTheme() {
|
||||||
|
const darkModePreference = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
const userSettings = localStorage.getItem('theme') || 'dark';
|
||||||
|
localStorage.setItem('theme', userSettings);
|
||||||
|
if (userSettings === 'dark') {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
this.theme = 'dark';
|
||||||
|
} else if (userSettings === 'light') {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
this.theme = 'light';
|
||||||
|
} else if (darkModePreference) {
|
||||||
|
this.theme = 'system';
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
} else if (!darkModePreference) {
|
||||||
|
this.theme = 'system';
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.full = localStorage.getItem('pageWidth');
|
||||||
|
this.zoom = localStorage.getItem('zoom');
|
||||||
|
this.queryTheme();
|
||||||
|
},
|
||||||
|
get filteredEntries() {
|
||||||
|
let entries = this.allEntries;
|
||||||
|
|
||||||
|
// Apply search filter if search term exists
|
||||||
|
if (this.search && this.search.trim() !== '') {
|
||||||
|
const searchLower = this.search.trim().toLowerCase();
|
||||||
|
entries = entries.filter(entry => {
|
||||||
|
return (entry.title?.toLowerCase().includes(searchLower) ||
|
||||||
|
entry.content?.toLowerCase().includes(searchLower) ||
|
||||||
|
entry.version?.toLowerCase().includes(searchLower));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always sort: unread first, then by published date (newest first)
|
||||||
|
return entries.sort((a, b) => {
|
||||||
|
// First sort by read status (unread first)
|
||||||
|
if (a.is_read !== b.is_read) {
|
||||||
|
return a.is_read ? 1 : -1; // unread (false) comes before read (true)
|
||||||
|
}
|
||||||
|
// Then sort by published date (newest first)
|
||||||
|
return new Date(b.published_at) - new Date(a.published_at);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}" @click.outside="dropdownOpen = false">
|
||||||
|
<!-- Custom Dropdown without arrow -->
|
||||||
|
<div class="relative">
|
||||||
|
<button @click="dropdownOpen = !dropdownOpen"
|
||||||
|
class="relative p-2 dark:text-neutral-400 hover:dark:text-white transition-colors cursor-pointer"
|
||||||
|
title="Settings">
|
||||||
|
<!-- Gear Icon -->
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" title="Settings">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- Unread Count Badge -->
|
||||||
|
@if ($unreadCount > 0)
|
||||||
|
<span
|
||||||
|
class="absolute -top-1 -right-1 bg-error text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">
|
||||||
|
{{ $unreadCount > 9 ? '9+' : $unreadCount }}
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Dropdown Menu -->
|
||||||
|
<div x-show="dropdownOpen" x-transition:enter="ease-out duration-200"
|
||||||
|
x-transition:enter-start="opacity-0 -translate-y-2" x-transition:enter-end="opacity-100 translate-y-0"
|
||||||
|
x-transition:leave="ease-in duration-150" x-transition:leave-start="opacity-100 translate-y-0"
|
||||||
|
x-transition:leave-end="opacity-0 -translate-y-2" class="absolute right-0 top-full mt-1 z-50 w-48" x-cloak>
|
||||||
|
<div
|
||||||
|
class="p-1 bg-white border rounded-sm shadow-lg dark:bg-coolgray-200 dark:border-black border-neutral-300">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<!-- What's New Section -->
|
||||||
|
@if ($unreadCount > 0)
|
||||||
|
<button wire:click="openWhatsNewModal" @click="dropdownOpen = false"
|
||||||
|
class="px-1 dropdown-item-no-padding flex items-center justify-between">
|
||||||
|
<span>What's New</span>
|
||||||
|
<span
|
||||||
|
class="bg-error text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
|
||||||
|
{{ $unreadCount > 9 ? '9+' : $unreadCount }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
@else
|
||||||
|
<button wire:click="openWhatsNewModal" @click="dropdownOpen = false"
|
||||||
|
class="px-1 dropdown-item-no-padding">
|
||||||
|
Change Log
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
<div class="border-b dark:border-coolgray-500 border-neutral-300"></div>
|
||||||
|
|
||||||
|
<!-- Theme Section -->
|
||||||
|
<div class="font-bold border-b dark:border-coolgray-500 border-neutral-300 dark:text-white pb-1">
|
||||||
|
Appearance</div>
|
||||||
|
<button @click="setTheme('dark'); dropdownOpen = false"
|
||||||
|
class="px-1 dropdown-item-no-padding">Dark</button>
|
||||||
|
<button @click="setTheme('light'); dropdownOpen = false"
|
||||||
|
class="px-1 dropdown-item-no-padding">Light</button>
|
||||||
|
<button @click="setTheme('system'); dropdownOpen = false"
|
||||||
|
class="px-1 dropdown-item-no-padding">System</button>
|
||||||
|
|
||||||
|
<!-- Width Section -->
|
||||||
|
<div
|
||||||
|
class="my-1 font-bold border-b dark:border-coolgray-500 border-neutral-300 dark:text-white text-md">
|
||||||
|
Width</div>
|
||||||
|
<button @click="switchWidth(); dropdownOpen = false" class="px-1 dropdown-item-no-padding"
|
||||||
|
x-show="full === 'full'">Center</button>
|
||||||
|
<button @click="switchWidth(); dropdownOpen = false" class="px-1 dropdown-item-no-padding"
|
||||||
|
x-show="full === 'center'">Full</button>
|
||||||
|
|
||||||
|
<!-- Zoom Section -->
|
||||||
|
<div
|
||||||
|
class="my-1 font-bold border-b dark:border-coolgray-500 border-neutral-300 dark:text-white text-md">
|
||||||
|
Zoom</div>
|
||||||
|
<button @click="setZoom(100); dropdownOpen = false"
|
||||||
|
class="px-1 dropdown-item-no-padding">100%</button>
|
||||||
|
<button @click="setZoom(90); dropdownOpen = false"
|
||||||
|
class="px-1 dropdown-item-no-padding">90%</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- What's New Modal -->
|
||||||
|
@if ($showWhatsNewModal)
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-6 sm:p-8">
|
||||||
|
<!-- Background overlay -->
|
||||||
|
<div class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs" wire:click="closeWhatsNewModal">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal panel -->
|
||||||
|
<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">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between pb-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-2xl font-bold dark:text-white">
|
||||||
|
Change Log
|
||||||
|
</h3>
|
||||||
|
<p class="mt-1 text-sm dark:text-neutral-400">
|
||||||
|
Stay up to date with the latest features and improvements.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
@if ($unreadCount > 0)
|
||||||
|
<x-forms.button @click="markAllEntriesAsRead">
|
||||||
|
Mark all as read
|
||||||
|
</x-forms.button>
|
||||||
|
@endif
|
||||||
|
<button wire:click="closeWhatsNewModal"
|
||||||
|
class="flex items-center justify-center w-8 h-8 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 cursor-pointer">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="pb-4 border-b dark:border-coolgray-200">
|
||||||
|
<div class="relative">
|
||||||
|
<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"
|
||||||
|
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" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="py-4 max-h-96 overflow-y-auto scrollbar">
|
||||||
|
<div x-show="filteredEntries.length > 0">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<template x-for="entry in filteredEntries" :key="entry.version">
|
||||||
|
<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'">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<span x-show="entry.version"
|
||||||
|
class="px-2 py-1 text-xs font-semibold dark:bg-coolgray-300 dark:text-neutral-200 rounded-sm"
|
||||||
|
x-text="entry.version"></span>
|
||||||
|
<span class="text-xs dark:text-neutral-400 font-bold"
|
||||||
|
x-text="entry.title"></span>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div class="dark:text-neutral-300 leading-relaxed max-w-none"
|
||||||
|
x-html="entry.content_html">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button x-show="!entry.is_read" @click="markEntryAsRead(entry.version)"
|
||||||
|
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">
|
||||||
|
mark as read
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="filteredEntries.length === 0" class="text-center py-8">
|
||||||
|
<svg class="mx-auto h-12 w-12 dark:text-neutral-400" 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" />
|
||||||
|
</svg>
|
||||||
|
<h3 class="mt-2 text-sm font-medium dark:text-white">No updates found</h3>
|
||||||
|
<p class="mt-1 text-sm dark:text-neutral-400">
|
||||||
|
<span x-show="search.trim() !== ''">No updates match your search criteria.</span>
|
||||||
|
<span x-show="search.trim() === ''">There are no updates available at the moment.</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
Reference in New Issue
Block a user