diff --git a/app/Console/Commands/InitChangelog.php b/app/Console/Commands/InitChangelog.php new file mode 100644 index 000000000..f9eb12f04 --- /dev/null +++ b/app/Console/Commands/InitChangelog.php @@ -0,0 +1,98 @@ +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; + } +} diff --git a/app/Livewire/SettingsDropdown.php b/app/Livewire/SettingsDropdown.php new file mode 100644 index 000000000..5bb3e37a4 --- /dev/null +++ b/app/Livewire/SettingsDropdown.php @@ -0,0 +1,52 @@ +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, + ]); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 6cd1b66db..3c5a220f8 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -203,6 +203,16 @@ class User extends Authenticatable implements SendsEmail 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 { return [$this->email]; diff --git a/app/Models/UserChangelogRead.php b/app/Models/UserChangelogRead.php new file mode 100644 index 000000000..28c384cb5 --- /dev/null +++ b/app/Models/UserChangelogRead.php @@ -0,0 +1,48 @@ + '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(); + } +} diff --git a/app/Services/ChangelogService.php b/app/Services/ChangelogService.php new file mode 100644 index 000000000..c6f4c8752 --- /dev/null +++ b/app/Services/ChangelogService.php @@ -0,0 +1,294 @@ +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', '

$1

', $html); + $html = preg_replace('/^## (.*?)$/m', '

$1

', $html); + $html = preg_replace('/^# (.*?)$/m', '

$1

', $html); + + // Bold text + $html = preg_replace('/\*\*(.*?)\*\*/', '$1', $html); + $html = preg_replace('/__(.*?)__/', '$1', $html); + + // Italic text + $html = preg_replace('/\*(.*?)\*/', '$1', $html); + $html = preg_replace('/_(.*?)_/', '$1', $html); + + // Code blocks + $html = preg_replace('/```(.*?)```/s', '
$1
', $html); + + // Inline code + $html = preg_replace('/`([^`]+)`/', '$1', $html); + + // Links + $html = preg_replace('/\[([^\]]+)\]\(([^)]+)\)/', '$1', $html); + + // Line breaks (convert double newlines to paragraphs) + $paragraphs = preg_split('/\n\s*\n/', trim($html)); + $html = '

'.implode('

', $paragraphs).'

'; + + // Single line breaks + $html = preg_replace('/\n/', '
', $html); + + // Unordered lists + $html = preg_replace('/^\- (.*)$/m', '
  • • $1
  • ', $html); + $html = preg_replace('/(
  • .*<\/li>)/s', '', $html); + + return $html; + } +} diff --git a/config/services.php b/config/services.php index 7add50a5c..6a21cda18 100644 --- a/config/services.php +++ b/config/services.php @@ -65,6 +65,6 @@ return [ 'client_secret' => env('ZITADEL_CLIENT_SECRET'), 'redirect' => env('ZITADEL_REDIRECT_URI'), 'base_url' => env('ZITADEL_BASE_URL'), - ] + ], ]; diff --git a/database/migrations/2025_08_07_142403_create_user_changelog_reads_table.php b/database/migrations/2025_08_07_142403_create_user_changelog_reads_table.php new file mode 100644 index 000000000..4c340d106 --- /dev/null +++ b/database/migrations/2025_08_07_142403_create_user_changelog_reads_table.php @@ -0,0 +1,34 @@ +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'); + } +}; diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile index a2a4b5fa3..6c9628a81 100644 --- a/docker/production/Dockerfile +++ b/docker/production/Dockerfile @@ -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 artisan artisan COPY --chown=www-data:www-data openapi.yaml ./openapi.yaml +COPY --chown=www-data:www-data changelogs/ ./changelogs/ RUN composer dump-autoload diff --git a/resources/views/components/navbar.blade.php b/resources/views/components/navbar.blade.php index be26d55ca..1a145aa4b 100644 --- a/resources/views/components/navbar.blade.php +++ b/resources/views/components/navbar.blade.php @@ -1,119 +1,88 @@ -