diff --git a/app/Auth/XenForoUser.php b/app/Auth/XenForoUser.php index 8ce1d6f..28810ea 100644 --- a/app/Auth/XenForoUser.php +++ b/app/Auth/XenForoUser.php @@ -4,9 +4,12 @@ namespace App\Auth; use App\Services\XenforoService; use App\XenForoDataTypes\XenForoData; +use Illuminate\Contracts\Auth\Access\Authorizable; use Illuminate\Contracts\Auth\Authenticatable; -class XenForoUser extends XenForoData implements Authenticatable { +class XenForoUser extends XenForoData implements Authenticatable, Authorizable { + + use \Illuminate\Foundation\Auth\Access\Authorizable; public ?array $permissions = null; public function getAuthIdentifierName(): string @@ -64,7 +67,7 @@ class XenForoUser extends XenForoData implements Authenticatable { return null; } - public function can(string $permissionGroup, string $permissionName): bool + public function _can(string $permissionGroup, string $permissionName): bool { if( !$this->permissions ){ $this->permissions = $this->services->getPermissions($this->data->user_id, $this->data->permission_combination_id); diff --git a/app/Helpers/EntryHelpers.php b/app/Helpers/EntryHelpers.php index efdf4c9..a12bd62 100644 --- a/app/Helpers/EntryHelpers.php +++ b/app/Helpers/EntryHelpers.php @@ -2,6 +2,9 @@ namespace App\Helpers; +use App\Models\Entry; +use App\Services\XenforoApiService; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; class EntryHelpers { @@ -56,4 +59,33 @@ class EntryHelpers { default => $fields['entry_title'], }; } + + public static function getLatestComments(Entry $entry, int $limit = 20): array { + + if( !$entry->comments_thread_id ){ + return []; + } + + $cacheKey = "entry_comments_{$entry->id}"; + return Cache::remember($cacheKey, now()->addDays(1), function () use ($entry, $limit) { + + $service = app(XenforoApiService::class); + + // Get thread infos and pagination. + $paginationInfos = $service->getThreadPosts($entry->comments_thread_id, 1); + $lastPage = $paginationInfos['pagination']['last_page'] ?? 1; + + // Get last threads + $lastPageData = $lastPage > 1 ? $service->getThreadPosts($entry->comments_thread_id, $lastPage) : $paginationInfos; + $posts = $lastPageData['posts'] ?? []; + + if( count( $posts ) < $limit && $lastPage > 1 ){ + $previousPageData = $service->getThreadPosts($entry->comments_thread_id, $lastPage - 1 ); + $posts = array_merge( $posts, $previousPageData['posts'] ?? [] ); + } + + return collect( $posts )->slice(-$limit)->reverse()->values()->toArray(); + + }); + } } diff --git a/app/Http/Controllers/EntryController.php b/app/Http/Controllers/EntryController.php index 944dbb7..5f995ae 100644 --- a/app/Http/Controllers/EntryController.php +++ b/app/Http/Controllers/EntryController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Helpers\EntryHelpers; use App\Models\Entry; use Illuminate\Http\Request; use Illuminate\View\View; @@ -25,7 +26,9 @@ class EntryController extends Controller if( $entry->type !== $section ) abort(404); - return view('entries.show', compact('entry', 'section')); + $comments = EntryHelpers::getLatestComments( $entry ); + + return view('entries.show', compact('entry', 'section', 'comments' ) ); } diff --git a/app/Http/Controllers/RedirectController.php b/app/Http/Controllers/RedirectController.php new file mode 100644 index 0000000..10d9753 --- /dev/null +++ b/app/Http/Controllers/RedirectController.php @@ -0,0 +1,17 @@ +input('id'); + $entry = Entry::findOrFail($id); + + return redirect()->route('entries.show', ['section' => $entry->type, 'entry' => $entry])->with('success', "Your report has been sent."); + } +} diff --git a/app/Http/Controllers/SubmissionController.php b/app/Http/Controllers/SubmissionController.php index 6afe022..10c0787 100644 --- a/app/Http/Controllers/SubmissionController.php +++ b/app/Http/Controllers/SubmissionController.php @@ -102,164 +102,19 @@ class SubmissionController extends Controller public function update(StoreEntryRequest $request, string $section, Entry $entry) { - if( $entry->type !== $section ) { - abort(404); + try { + $entry = $this->services->editEntry($request, $section, $entry); + + return match ($entry->state) { + 'published' => redirect()->route('entries.show', ['section' => $section, 'entry' => $entry->slug])->with('success', "Your entry has been published."), + 'pending' => redirect()->route('home')->with('success', "Your entry has been submitted and is pending review."), + default => redirect()->route('home')->with('success', "Your entry has been saved as a draft.") + }; + } catch ( SubmissionException $e ) { + return back()->withInput()->withErrors(['error' => $e->getMessage()]); + } catch ( \Exception $e ) { + return back()->withInput()->withErrors(['error' => 'Unknown error: '.$e->getMessage()]); } - $gameId = null; - if( !$request->input('game_id') ){ - if( $request->input('new-game-title') && $request->input('new-game-platform') && $request->input('new-game-genre') ){ - $platform = Platform::find($request->input('new-game-platform')); - $genre = Genre::find($request->input('new-game-genre')); - $game = Game::create([ - 'name' => $request->input('new-game-title'), - 'slug' => Str::slug($request->input('new-game-title')), - 'platform_id' => $platform->id, - 'genre_id' => $genre->id, - ]); - $gameId = $game->id; - } - } else { - $gameId = $request->input('game_id'); - } - - $mainImage = $entry->main_image; - if ( $request->hasFile('main-image') ) { - if ( $mainImage ) { - Storage::disk('public')->delete($mainImage); - } - $mainImage = $request->file('main-image')->store('entries/main_images', 'public'); - } elseif ( $request->input('remove_main_image') === '1' ) { - if ( $mainImage ) { - Storage::disk('public')->delete($mainImage); - } - $mainImage = null; - } - - $staffCredits = collect($request->input('credits', [])) - ->filter(fn($item) => isset($item['name']) || isset($item['description'])) - ->map(function ($item) { - $name = trim($item['name'] ?? ''); - $description = trim($item['description'] ?? ''); - if ($name === '' && $description === '') { - return null; - } - return trim($name . ($name !== '' && $description !== '' ? ' — ' : '') . $description); - }) - ->filter() - ->implode("\n"); - - $fields = [ - 'type' => $section, - 'title' => $request->input('entry_title'), - 'slug' => $request->input('slug') ?? Str::slug($request->input('entry_title', '')), - 'description' => $request->input('description'), - 'main_image' => $mainImage, - 'state' => $request->input('submit-state', 'draft'), - 'game_id' => $gameId, - 'status_id' => $request->input('status'), - 'version' => $request->input('version'), - 'release_date' => $request->input('release-date'), - 'staff_credits' => $staffCredits ?: null, - 'relevant_link' => $request->input('release_site'), - 'youtube_link' => $request->input('youtube_video'), - ]; - - $entry->update($fields); - - $entry->hashes()->delete(); - foreach ( $request->input('hashes', []) as $hash ) { - if( !isset($hash['filename'], $hash['crc32'], $hash['sha1'], $hash['verified']) ) { - continue; - } - - EntryHash::create([ - 'entry_id' => $entry->id, - 'filename' => $hash['filename'], - 'hash_crc32' => $hash['crc32'], - 'hash_sha1' => $hash['sha1'], - 'verified' => $hash['verified'], - ]); - } - - $authorIds = []; - foreach ( $request->input('authors', []) as $authorId ) { - $author = Author::find($authorId); - if( $author ) { - $authorIds[] = $author->id; - } - } - foreach( $request->input('new-authors', []) as $authorName ) { - $authorName = trim($authorName); - if ($authorName === '') continue; - - $author = Author::firstOrCreate( - ['slug' => Str::slug($authorName)], - ['name' => $authorName], - ); - $authorIds[] = $author->id; - } - $entry->authors()->sync(array_values(array_unique($authorIds))); - - if( section_must_be( 'romhacks', $section ) ){ - $entry->modifications()->sync($request->input('modifications', [])); - } else { - $entry->modifications()->sync([]); - } - - $entry->languages()->sync($request->input('languages', [])); - - $existingFileUuids = $request->input('existing_file_ids', []); - if (!is_array($existingFileUuids)) { - $existingFileUuids = []; - } - $entry->files()->whereNotIn('file_uuid', $existingFileUuids)->delete(); - - foreach ( $request->input('file_ids', []) as $file_uuid ) { - $fileData = Cache::pull("uploaded_file_{$file_uuid}"); - if( ! $fileData ) { - continue; - } - - EntryFile::create([ - 'entry_id' => $entry->id, - 'file_uuid' => $fileData['uuid'], - 'filename' => $fileData['filename'], - 'filepath' => $fileData['filepath'], - 'favorite_server' => $fileData['favorite_server'], - 'favorite_at' => \DateTimeImmutable::createFromTimestamp( $fileData['favorite_at'] ), - 'filesize' => $fileData['filesize'], - 'state' => 'public' - ]); - } - - $existingGalleryIds = $request->input('existing_gallery_ids', []); - if (!is_array($existingGalleryIds)) { - $existingGalleryIds = []; - } - $entry->gallery()->whereNotIn('id', $existingGalleryIds)->get()->each(function ($gallery) { - if ($gallery->image) { - Storage::disk('public')->delete($gallery->image); - } - $gallery->delete(); - }); - - foreach ( $request->file('gallery', [] ) as $galleryFile ){ - if( !$galleryFile->isValid() ){ - continue; - } - - $path = $galleryFile->store('entries/gallery/' . $entry->id, 'public'); - EntryGallery::create([ - 'entry_id' => $entry->id, - 'image' => $path - ]); - } - - return match( $entry->state ){ - 'published' => redirect()->route('entries.show', [ 'section' => $section, 'entry' => $entry->slug ])->with('success', "Your entry has been published."), - 'pending' => redirect()->route('home')->with('success', "Your entry has been submitted and is pending review."), - default => redirect()->route('home')->with('success', "Your entry has been saved as a draft.") - }; } } diff --git a/app/Http/Controllers/WebhookController.php b/app/Http/Controllers/WebhookController.php new file mode 100644 index 0000000..48eda82 --- /dev/null +++ b/app/Http/Controllers/WebhookController.php @@ -0,0 +1,29 @@ +header('XF-Webhook-Secret') !== env('WEBHOOK_SECRET') ) + abort(403); + + $threadId = $request->input('data.thread_id'); + if( $threadId ){ + $entry = Entry::where('comments_thread_id', $threadId)->first(); + if( $entry ){ + Cache::forget("entry_comments_{$entry->id}"); + } + } + + return response()->json(['success' => true]); + } +} diff --git a/app/Http/Middleware/CheckXenForoPermissions.php b/app/Http/Middleware/CheckXenForoPermissions.php index 28cdea9..2738921 100644 --- a/app/Http/Middleware/CheckXenForoPermissions.php +++ b/app/Http/Middleware/CheckXenForoPermissions.php @@ -24,7 +24,7 @@ class CheckXenForoPermissions foreach ($permissions as $permissionStr) { [$group, $permission] = explode('.', $permissionStr); - if( !\Auth::user()->can($group, $permission) ) + if( !\Auth::user()->_can($group, $permission) ) return $this->deny($request, $permission); } diff --git a/app/Http/Requests/StoreEntryRequest.php b/app/Http/Requests/StoreEntryRequest.php index d8a1bd4..1b1229b 100644 --- a/app/Http/Requests/StoreEntryRequest.php +++ b/app/Http/Requests/StoreEntryRequest.php @@ -14,8 +14,11 @@ class StoreEntryRequest extends FormRequest */ public function authorize(): bool { - // TODO: Change it by role. - return true; + $entry = $this->route('entry'); + if( $entry ) + return $this->user()->can('update', $entry); + + return $this->user()->can('create', '\App\Models\Entry'); } /** diff --git a/app/Http/Requests/TemporaryFileUploadRequest.php b/app/Http/Requests/TemporaryFileUploadRequest.php index bbbad96..fea22ac 100644 --- a/app/Http/Requests/TemporaryFileUploadRequest.php +++ b/app/Http/Requests/TemporaryFileUploadRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests; +use App\Services\TemporaryFileService; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; @@ -12,7 +13,7 @@ class TemporaryFileUploadRequest extends FormRequest */ public function authorize(): bool { - return true; + return $this->user()->can('create', TemporaryFileService::class ); } /** diff --git a/app/Models/Entry.php b/app/Models/Entry.php index 4940786..1a84843 100644 --- a/app/Models/Entry.php +++ b/app/Models/Entry.php @@ -32,6 +32,7 @@ class Entry extends Model 'youtube_link', 'user_id', 'complete_title', + 'comments_thread_id', ]; /** diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index ecf876b..4879d47 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,6 +3,8 @@ namespace App\Providers; use App\Auth\XenForoGuard; +use App\Policies\TempFilePolicy; +use App\Services\TemporaryFileService; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -24,5 +26,7 @@ class AppServiceProvider extends ServiceProvider \Auth::extend('xenforo', function ($app, $name, array $config) { return new XenForoGuard($app['request']); }); + + \Gate::policy(TemporaryFileService::class, TempFilePolicy::class ); } } diff --git a/app/Services/SubmissionsService.php b/app/Services/SubmissionsService.php index 0f8db9a..343c5a7 100644 --- a/app/Services/SubmissionsService.php +++ b/app/Services/SubmissionsService.php @@ -5,6 +5,7 @@ namespace App\Services; use App\Exceptions\SubmissionException; use App\Helpers\EntryHelpers; use App\Http\Requests\StoreEntryRequest; +use App\Jobs\CreateXenForoCommentsThread; use App\Models\Author; use App\Models\Entry; use App\Models\EntryFile; @@ -36,6 +37,12 @@ class SubmissionsService { */ private ?string $section = null; + /** + * Entry for edit. + * @var Entry|null + */ + private ?Entry $entry = null; + /** * @return list */ @@ -174,6 +181,9 @@ class SubmissionsService { $this->Step12b_MoveMainImage( $entry ); $this->Step12c_SaveGalleryImages( $entry ); + // Step 13: Try to create the comments section. + $this->Step13_CreateCommentsThread( $entry ); + return $entry; } @@ -190,6 +200,13 @@ class SubmissionsService { return $this->request->input('game_id'); // Need to create a game. + $game = $this->createGameFromFormFields(); + + return $game->id; + } + + private function createGameFromFormFields(): Game + { if( !$this->request->input('new-game-title') || !$this->request->input('new-game-platform') || !$this->request->input('new-game-genre') ) throw new SubmissionException( "New game informations is missing" ); @@ -201,14 +218,12 @@ class SubmissionsService { $gameSlug = EntryHelpers::uniqueSlug( $this->request->input('new-game-title'), Game::class ); - $game = Game::create([ + return Game::create([ 'name' => trim( $this->request->input('new-game-title') ), 'slug' => $gameSlug, 'platform_id' => $platform->id, 'genre_id' => $genre->id, ]); - - return $game->id; } /** @@ -228,7 +243,7 @@ class SubmissionsService { if( section_must_be( 'translations', $this->section ) ) { $fields['languages_string'] = Language::whereIn('id', $this->request->input('languages', []))->pluck('name')->implode(', '); } - if( section_must_be(['romhacks', 'homebrew', 'lua-scripts', 'tutorials'], $this->section ) ) { + if( section_must_be(['romhacks', 'translations', 'homebrew', 'lua-scripts', 'tutorials'], $this->section ) ) { // TODO: Add single platform ID compatibility. $fields['platform_name'] = Game::find( $gameId )->platform->name; } @@ -242,12 +257,15 @@ class SubmissionsService { * @return void * @throws SubmissionException */ - private function Step7_SaveEntryFiles( int $entryId ): void + private function Step7_SaveEntryFiles( int $entryId, ?array $uuidData = null ): void { - foreach ( $this->request->input('files_uuid', [] ) as $uuid ) { + if( !$uuidData ) + $uuidData = $this->request->input('files_uuid', [] ); + + foreach ( $uuidData as $uuid ) { $fileData = Cache::pull("uploaded_file_{$uuid}"); if( !$fileData ) - throw new SubmissionException( "File {$uuid} has expired. Please delete all your files and retry." ); + throw new SubmissionException( "File {$uuid} has expired. Please delete all your files and retry. If it's an edition, delete all your new files and retry." ); EntryFile::create([ 'entry_id' => $entryId, @@ -293,6 +311,8 @@ class SubmissionsService { */ private function Step9_SaveAuthors( Entry $entry ): void { + // TODO: Code fragment to be replaced by edit version. + // Existing authors. foreach ( $this->request->input('authors', [] ) as $authorId ) { $author = Author::find( $authorId ); @@ -323,6 +343,9 @@ class SubmissionsService { */ private function Step10_SaveRomhacksModifications( Entry $entry ): void { + + // TODO: Replace by edit version + foreach ( $this->request->input('modifications', [] ) as $modificationId ) { $modification = Modification::find( $modificationId ); if( !$modification ) @@ -339,6 +362,8 @@ class SubmissionsService { */ private function Step11_SaveLanguages( Entry $entry ): void { + // TODO: Replace by edit version. + foreach ( $this->request->input('languages', [] ) as $languageId ) { $language = Language::find( $languageId ); if( !$language ) @@ -385,4 +410,325 @@ class SubmissionsService { } } + public function editEntry( StoreEntryRequest $request, string $section, Entry $entry ): Entry + { + + // STEP 1: Prepare basic fields and keep in save some others fields. + $this->request = $request; + $this->section = $section; + $this->entry = $entry; + $user_id = 0; // TODO: Replace that. + + $oldMainImage = $entry->main_image; + $galleryPaths = []; + + $entry = DB::transaction( function() use ( $user_id, &$galleryPaths ){ + + // STEP 2: Create game if different. + $gameId = null; + if( section_must_be( ['romhacks', 'translations' ], $this->section ) ){ + $gameId = $this->eStep2_VerifyCreateAndEditGameId(); + } + + // STEP 3: Recreate complete title and refresh slug if needed. + $completeTitle = $this->Step3_BuildCompleteTitle( $gameId ); + if( $completeTitle !== $this->entry->complete_title ) { + $this->entry->complete_title = $completeTitle; + $this->entry->slug = EntryHelpers::uniqueSlug( $completeTitle, Entry::class, $this->entry->id ); + } + + // STEP 4: Regenerate entry title. + + if( section_must_be( 'translations', $this->section ) && + !$this->request->input('entry_title') ){ + $this->entry->title = Game::find($gameId)->name; + } else { + $this->entry->title = $this->request->input('entry_title'); + } + + // STEP 5: Update entry fields. + + $fields = [ + 'type' => $this->section, + 'title' => $this->entry->title, // Useless, I know. + 'slug' => $this->entry->slug, + 'description' => $this->request->input('description'), + 'main_image' => $this->request->input('main-image'), + 'state' => $this->request->input('submit-state'), + 'game_id' => $gameId, + 'status_id' => $this->request->input('status'), + 'version' => $this->request->input('version'), + 'release_date' => $this->request->input('release-date'), + 'staff_credits' => $this->request->input('staff_credits'), + 'relevant_link' => $this->request->input('release_site'), + 'youtube_link' => $this->request->input('youtube_video'), + 'user_id' => $user_id, + 'complete_title' => $completeTitle, + ]; + $this->entry->update( $fields ); + + // STEP 6: Update entry files. + $this->eStep6_UpdateEntryFiles( $this->entry->id ); + + // STEP 7: Update hashes. + $this->eStep7_UpdateHashes( $this->entry->id ); + + // STEP 8: Update Authors. + $this->eStep8_UpdateAuthors(); + + // STEP 9: Update romhacks modifications. + if( section_must_be( 'romhacks', $this->section ) ) { + $this->eStep9_UpdateRomhacksModifications(); + } + + // STEP 10: Update Languages. + $this->eStep10_UpdateLanguages(); + + // STEP 11: Prepare new gallery images and prepare deletion of others ones. + $galleryPaths = $this->eStep11a_UpdateGalleryImages(); + + // STEP 13: Try to create comments area if it doesn't exist. + $this->Step13_CreateCommentsThread( $this->entry ); + + return $this->entry; + + }); + + // STEP 11 : Update main image if needed. + $this->eStep11b_UpdateMainImage( $oldMainImage ); + + // STEP 11 : Update gallery storage. + $this->eStep11c_UpdateGalleryImages( $galleryPaths ); + + return $entry; + } + + /** + * @throws SubmissionException + */ + private function eStep2_VerifyCreateAndEditGameId(): int + { + // Already existing game. + if( $this->request->input('game_id') ){ + + if( $this->entry->game_id == $this->request->input('game_id') ){ + + return $this->entry->game_id; // No changes. + + } else { // Change in game but already exist. + + $game = Game::find( $this->request->input('game_id') ); + if( !$game ) + throw new SubmissionException( "Game {$this->request->input('game_id')} does not exist." ); + $this->entry->game_id = $game->id; + return $this->entry->game_id; + + } + + } + + // Need to create a game. + $game = $this->createGameFromFormFields(); + + $this->entry->game_id = $game->id; + return $this->entry->game_id; + } + + /** + * @throws SubmissionException + */ + private function eStep6_UpdateEntryFiles(int $entryId ): void + { + $requestUuids = $this->request->input('files_uuid', []); + $existingUuids = EntryFile::where( 'entry_id', $entryId )->pluck('file_uuid')->toArray(); + + $needDeletion = array_diff( $existingUuids, $requestUuids ); + if( !empty( $needDeletion ) ){ + EntryFile::where('entry_id', $entryId)->whereIn('file_uuid', $needDeletion)->delete(); + } + + $needAddition = array_diff( $requestUuids, $existingUuids ); + + if( !empty( $needAddition ) ){ + $this->Step7_SaveEntryFiles( $this->entry->id, $needAddition ); // Same code. + } + } + + private function eStep7_UpdateHashes(int $entryId): void + { + $requestHashes = collect( $this->request->input('hashes', [] ) ) + ->filter( fn($h) => isset( $h['filename'], $h['hash_crc32'], $h['hash_sha1'], $h['verified'] ) ) + ->keyBy( 'hash_sha1' ) + ->toArray(); + ; + + $existingHashes = EntryHash::where( 'entry_id', $entryId )->get()->keyBy( 'hash_sha1' ); + + $hashsToDelete = array_diff( $existingHashes->keys()->toArray(), array_keys( $requestHashes ) ); + + if( !empty( $hashsToDelete ) ){ + EntryHash::where( 'entry_id', $entryId )->whereIn('hash_sha1', $hashsToDelete)->delete(); + } + + foreach( $requestHashes as $sha1 => $hash ){ + if( $existingHashes->has( $sha1 ) ){ + $existingHashes->get( $sha1 )->update([ + 'filename' => $hash['filename'], + 'hash_crc32' => $hash['hash_crc32'], + 'hash_sha1' => $hash['hash_sha1'], + 'verified' => $hash['verified'], + ]); + } else { + EntryHash::create([ + 'entry_id' => $entryId, + 'filename' => $hash['filename'], + 'hash_crc32' => $hash['hash_crc32'], + 'hash_sha1' => $hash['hash_sha1'], + 'verified' => $hash['verified'], + ]); + } + } + } + + /** + * @return void + * @throws SubmissionException + */ + private function eStep8_UpdateAuthors(): void + { + $syncAuthorsId = []; + $requestAuthorsId = $this->request->input('authors', [] ); + + if( !empty( $requestAuthorsId ) ){ + $valid = Author::whereIn( 'id', $requestAuthorsId )->pluck('id')->toArray(); + + if( count( $valid ) !== count( $requestAuthorsId ) ){ + throw new SubmissionException( "One of the authors doesn't exist." ); + } + + $syncAuthorsId = array_merge( $syncAuthorsId, $requestAuthorsId ); + } + + foreach ( $this->request->input('new-authors', [] ) as $authorName ) { + $authorName = trim($authorName); + if ($authorName === '') + continue; + + $author = Author::firstOrCreate( + ['slug' => EntryHelpers::uniqueSlug($authorName, Author::class)], + ['name' => $authorName] + ); + + $syncAuthorsId[] = $author->id; + } + + $this->entry->authors()->sync( $syncAuthorsId ); + } + + /** + * @return void + * @throws SubmissionException + */ + private function eStep9_UpdateRomhacksModifications(): void + { + $requestModifications = $this->request->input('modifications', [] ); + if( !empty( $requestModifications ) ){ + $valid = Modification::whereIn( 'id', $requestModifications )->pluck('id')->toArray(); + + if( count( $valid ) !== count( $requestModifications ) ){ + throw new SubmissionException( "One of the modifications doesn't exist." ); + } + + + } + + $this->entry->modifications()->sync( $requestModifications ); + + } + + /** + * @return void + * @throws SubmissionException + */ + private function eStep10_UpdateLanguages(): void + { + $requestLanguages = $this->request->input('languages', [] ); + if( !empty( $requestLanguages ) ){ + $valid = Language::whereIn( 'id', $requestLanguages )->pluck('id')->toArray(); + if( count( $valid ) !== count( $requestLanguages ) ){ + throw new SubmissionException( "One of the languages doesn't exist." ); + } + + } + + $this->entry->languages()->sync( $requestLanguages ); + } + + private function eStep11a_UpdateGalleryImages(): array + { + $requestGallery = $this->request->input('gallery', [] ); + $existingGalleryPaths = $this->entry->gallery->pluck('image')->toArray(); + + $needDeletion = array_diff( $existingGalleryPaths, $requestGallery ); + + if( !empty( $needDeletion ) ){ + EntryGallery::where('entry_id', $this->entry->id)->whereIn('image', $needDeletion )->delete(); + } + + $needAddition = array_diff( $requestGallery, $existingGalleryPaths ); + $images = []; + foreach( $needAddition as $imagePath ){ + $images[] = EntryGallery::create([ + 'entry_id' => $this->entry->id, + 'image' => $imagePath, + ]); + } + + return [ 'addition' => $images, 'deletion' => $needDeletion ]; + } + + private function eStep11b_UpdateMainImage( ?string $oldMainImagePath ): void + { + $currentMainImagePath = $this->entry->main_image; + + if( $currentMainImagePath === $oldMainImagePath ) + return; + + $newPath = 'entries/main-images/' . basename( $currentMainImagePath ); + + if( !Storage::disk('public')->move( $currentMainImagePath, $newPath ) ){ + $this->entry->update(['main_image' => $oldMainImagePath]); + return; + } + + $this->entry->update(['main_image' => $newPath]); + if( $oldMainImagePath && Storage::disk('public')->exists($oldMainImagePath) ) + Storage::disk('public')->delete($oldMainImagePath); + } + + private function eStep11c_UpdateGalleryImages( array $pathsChanges ): void + { + foreach ( $pathsChanges['deletion'] as $deletePath ){ + if( Storage::disk('public')->exists($deletePath) ) + Storage::disk('public')->delete($deletePath); + } + + foreach ( $pathsChanges['addition'] as $galleryItem ){ + $newPath = 'entries/gallery-images/' . $this->entry->id . '/' . basename( $galleryItem->image ); + + if( !Storage::disk('public')->move( $galleryItem->image, $newPath ) ){ + continue; + } + + $galleryItem->update(['image' => $newPath]); + } + } + + private function Step13_CreateCommentsThread( Entry $entry ): void + { + if( !$entry->comments_thread_id ) + CreateXenForoCommentsThread::dispatch( $entry ); + // app(XenforoApiService::class)->createCommentsThread( $entry ); + } + } diff --git a/app/Services/XenforoApiService.php b/app/Services/XenforoApiService.php index aa51595..275fa54 100644 --- a/app/Services/XenforoApiService.php +++ b/app/Services/XenforoApiService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Models\Entry; use Illuminate\Http\Client\ConnectionException; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; @@ -71,4 +72,40 @@ class XenforoApiService { }); } + public function createCommentsThread( Entry $entry ): bool + { + if( !$entry->comments_thread_id || $entry->comments_thread_id <= 0 ){ + $data = [ + 'node_id' => config('xenforo.comments_node_id'), + 'title' => $entry->complete_title, + 'message' => $entry->description, + 'prefix_id' => config('xenforo.comments_prefixes')[$entry->type] ?? 1, + 'custom_fields' => [ 'entry_id' => $entry->id ], + 'discussion_open' => true, + ]; + + // TODO: Flag must be removed. + $response = $this->post("threads?api_bypass_permissions=true", config('xenforo.bot_user_id'), $data ); + if( $response['success'] === true ){ + $commentsThreadId = $response['thread']['thread_id']; + $entry->update(['comments_thread_id' => $commentsThreadId]); + return true; + } + } + + return false; + } + + /** + * @throws ConnectionException + */ + public function getThreadPosts(int $threadId, int $page = 1 ): array + { + $response = $this->get("threads/{$threadId}/posts?page=$page"); + if( !isset( $response['posts'] ) || $response['posts'] === [] ) + return [ 'posts' => [], 'pagination' => null ]; + + return $response; + } + } diff --git a/app/View/Components/XenForoAvatar.php b/app/View/Components/XenForoAvatar.php index 10db62c..b661fc1 100644 --- a/app/View/Components/XenForoAvatar.php +++ b/app/View/Components/XenForoAvatar.php @@ -3,6 +3,7 @@ namespace App\View\Components; use App\Auth\XenForoUser; +use App\Services\XenforoService; use Closure; use Illuminate\Contracts\View\View; use Illuminate\View\Component; @@ -13,11 +14,13 @@ class XenForoAvatar extends Component * Create a new component instance. */ public function __construct( - public ?XenForoUser $user = null, + public null|int|XenForoUser $user = null, ) { if( $this->user === null ) $this->user = \Auth::user(); + else if( is_int( $this->user ) ) + $this->user = app(XenforoService::class)->getXfUser( $this->user ); } /** diff --git a/app/xenforo.php b/app/xenforo.php index a8be282..bb1f249 100644 --- a/app/xenforo.php +++ b/app/xenforo.php @@ -11,3 +11,9 @@ if( !function_exists( 'xfCsrfToken') ){ return app(\App\Services\XenforoService::class)->getCSRFToken(); } } + +if( !function_exists( 'xfStyleVariationUrl' ) ){ + function xfStyleVariationUrl( string $variation ): string { + return config('app.forum_url') . '/misc/style-variation?variation=' . $variation . "&t=" . xfCsrfToken(); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 1063f27..50ee1ec 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -8,10 +8,11 @@ return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', commands: __DIR__.'/../routes/console.php', + api: __DIR__.'/../routes/api.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - $middleware->encryptCookies(except: ['xf_session','xf_user','xf_csrf']); + $middleware->encryptCookies(except: ['xf_session','xf_user','xf_csrf','theme','entries_per_page']); $middleware->alias([ 'xf.auth' => \App\Http\Middleware\CheckXenForoPermissions::class, ]); diff --git a/config/auth.php b/config/auth.php index ac014ce..9df6e4f 100644 --- a/config/auth.php +++ b/config/auth.php @@ -45,6 +45,7 @@ return [ ], 'xenforo' => [ 'driver' => 'xenforo', + 'provider' => 'users', ] ], diff --git a/config/menu.php b/config/menu.php index a4bee6e..f3c24a4 100644 --- a/config/menu.php +++ b/config/menu.php @@ -82,7 +82,7 @@ return [ [ 'name' => 'Contact Us', 'icon' => 'at-sign', - 'route' => 'home' + 'xf_route' => 'misc/contact' ], [ 'name' => 'Legal pages', diff --git a/config/xenforo.php b/config/xenforo.php new file mode 100644 index 0000000..d644ace --- /dev/null +++ b/config/xenforo.php @@ -0,0 +1,18 @@ + 1, + + 'comments_node_id' => 4, + + 'comments_prefixes' => [ + 'translations' => 1, + 'romhacks' => 2, + 'homebrew' => 3, + 'utilities' => 4, + 'documents' => 5, + 'lua-scripts' => 6, + 'tutorials' => 7, + 'news' => 8 + ], +]; diff --git a/extra.less b/extra.less index b3ee9b8..ceef0c1 100644 --- a/extra.less +++ b/extra.less @@ -71,6 +71,32 @@ ul { --menu-user-avatar-bg: #555; } +.\$light-mode { + + /* RHPZ color */ + --rhpz-orange: #ff7300; + --rhpz-orange-hover: #e56700; + + /* Background colors */ + --bg: #f0f0f0; + --bg2: #ffffff; + --bg3: #e8e8e8; + --bg4: #dcdcdc; + + /* Text */ + --text: #454545; + --text2: #737373; + --text3: #111111; + + /* Elements */ + --border: #d0d0d0; + --error: #e57373; + --info: #1976d2; + --success: #81c784; + --success2: #388e3c; + +} + /* File: resources/css/components/cards.css */ /* STAT CARDS */ @@ -1695,6 +1721,165 @@ ul { } +/* File: resources/css/components/settings.css */ +.\$settings-dropdown { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: 240px; + background-color: var(--bg2); + border: 1px solid var(--border); + box-shadow: 0 8px 24px rgba(0,0,0,0.5); + z-index: 2000; +} + +.\$settings-header { + padding: 12px 16px; + border-bottom: 1px solid var(--border); + background-color: var(--bg3); + font-weight: 600; + font-size: 0.9rem; + color: var(--text); +} + +.\$settings-section { + padding: 12px 16px; +} + +.\$settings-section-title { + display: flex; + align-items: center; + gap: 7px; + font-size: 0.78rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.7px; + color: var(--text2); + margin-bottom: 10px; +} + +.\$settings-separator { + border-top: 1px solid var(--border); +} + +.\$settings-themes { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.\$settings-theme-btn { + width: 28px; + height: 28px; + border-radius: 50%; + border: 2px solid transparent; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: var(--text); + transition: transform 0.15s, border-color 0.15s; + padding: 0; + + &:hover { + transform: scale(1.15); + } + + .\$active { + border-color: var(--text); + transform: scale(1.1); + } +} + +.\$settings-theme-toggle { + width: 100%; + background-color: var(--bg3); + border: 1px solid var(--border); + color: var(--text); + padding: 8px 12px; + cursor: pointer; + font-family: var(--typography); + font-size: 0.88rem; + transition: background-color 0.1s; + text-align: left; + + &:hover { + background-color: var(--bg4); + } +} + +.\$settings-theme-toggle-inner { + display: flex; + align-items: center; + gap: 8px; +} + +.\$settings-theme-toggle-badge { + margin-left: auto; + background-color: var(--rhpz-orange); + color: #111; + font-size: 0.65rem; + font-weight: 700; + padding: 2px 6px; +} + +.\$settings-perpage { + display: flex; + gap: 6px; +} + +.\$settings-perpage-btn { + flex: 1; + padding: 6px 4px; + background-color: var(--bg3); + border: 1px solid var(--border); + color: var(--text2); + font-size: 0.85rem; + cursor: pointer; + font-family: var(--typography); + transition: all 0.1s; + + &:hover { + background-color: var(--bg4); + color: var(--text); + } + + .\$active { + background-color: var(--rhpz-orange); + border-color: var(--rhpz-orange); + color: var(--text3); + font-weight: 600; + } +} + +.\$settings-link { + display: flex; + align-items: center; + gap: 9px; + padding: 8px 10px; + color: var(--text); + text-decoration: none; + font-size: 0.88rem; + transition: background-color 0.1s; + border: 1px solid transparent; + + &:hover { + background-color: var(--bg3); + border-color: var(--border); + text-decoration: none; + } +} + +.\$settings-link--danger { + color: var(--error); + + &:hover { + background-color: rgba(229, 115, 115, 0.08); + border-color: rgba(229, 115, 115, 0.3); + } +} + + /* File: resources/css/layout/content.css */ #main-wrapper { flex-grow: 1; diff --git a/package-lock.json b/package-lock.json index c327f1c..4625da6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "dependencies": { "easymde": "^2.21.0", + "js-cookie": "^3.0.7", "lucide": "^1.14.0" }, "devDependencies": { @@ -971,6 +972,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-cookie": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.7.tgz", + "integrity": "sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/laravel-vite-plugin": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.1.0.tgz", diff --git a/package.json b/package.json index 41aaf9b..b5f4387 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "easymde": "^2.21.0", + "js-cookie": "^3.0.7", "lucide": "^1.14.0" } } diff --git a/resources/css/app.css b/resources/css/app.css index b83671a..51c1e62 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -15,6 +15,7 @@ @import './components/database.css'; @import './components/hovercard.css'; @import './components/notifications.css'; +@import './components/settings.css'; @import './components/easymde.css'; diff --git a/resources/css/base/variables.css b/resources/css/base/variables.css index ae56c03..5dd4727 100644 --- a/resources/css/base/variables.css +++ b/resources/css/base/variables.css @@ -29,3 +29,29 @@ --menu-size: 260px; --menu-user-avatar-bg: #555; } + +.light-mode { + + /* RHPZ color */ + --rhpz-orange: #ff7300; + --rhpz-orange-hover: #e56700; + + /* Background colors */ + --bg: #f0f0f0; + --bg2: #ffffff; + --bg3: #e8e8e8; + --bg4: #dcdcdc; + + /* Text */ + --text: #454545; + --text2: #737373; + --text3: #111111; + + /* Elements */ + --border: #d0d0d0; + --error: #e57373; + --info: #1976d2; + --success: #81c784; + --success2: #388e3c; + +} diff --git a/resources/css/components/common.css b/resources/css/components/common.css index 88cc8ed..fc98a24 100644 --- a/resources/css/components/common.css +++ b/resources/css/components/common.css @@ -64,6 +64,13 @@ border-bottom: 1px solid var(--border); padding-bottom: 10px; } +.block-success { + background-color: var(--success); + border: 1px solid var(--success); + color: var(--text); + padding: 20px; + margin-bottom: 20px; +} .block-error { background-color: var(--error); border: 1px solid var(--error); diff --git a/resources/css/components/settings.css b/resources/css/components/settings.css new file mode 100644 index 0000000..19cf473 --- /dev/null +++ b/resources/css/components/settings.css @@ -0,0 +1,156 @@ +.settings-dropdown { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: 240px; + background-color: var(--bg2); + border: 1px solid var(--border); + box-shadow: 0 8px 24px rgba(0,0,0,0.5); + z-index: 2000; +} + +.settings-header { + padding: 12px 16px; + border-bottom: 1px solid var(--border); + background-color: var(--bg3); + font-weight: 600; + font-size: 0.9rem; + color: var(--text); +} + +.settings-section { + padding: 12px 16px; +} + +.settings-section-title { + display: flex; + align-items: center; + gap: 7px; + font-size: 0.78rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.7px; + color: var(--text2); + margin-bottom: 10px; +} + +.settings-separator { + border-top: 1px solid var(--border); +} + +.settings-themes { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.settings-theme-btn { + width: 28px; + height: 28px; + border-radius: 50%; + border: 2px solid transparent; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: var(--text); + transition: transform 0.15s, border-color 0.15s; + padding: 0; + + &:hover { + transform: scale(1.15); + } + + .active { + border-color: var(--text); + transform: scale(1.1); + } +} + +.settings-theme-toggle { + width: 100%; + background-color: var(--bg3); + border: 1px solid var(--border); + color: var(--text); + padding: 8px 12px; + cursor: pointer; + font-family: var(--typography); + font-size: 0.88rem; + transition: background-color 0.1s; + text-align: left; + + &:hover { + background-color: var(--bg4); + } +} + +.settings-theme-toggle-inner { + display: flex; + align-items: center; + gap: 8px; +} + +.settings-theme-toggle-badge { + margin-left: auto; + background-color: var(--rhpz-orange); + color: #111; + font-size: 0.65rem; + font-weight: 700; + padding: 2px 6px; +} + +.settings-perpage { + display: flex; + gap: 6px; +} + +.settings-perpage-btn { + flex: 1; + padding: 6px 4px; + background-color: var(--bg3); + border: 1px solid var(--border); + color: var(--text2); + font-size: 0.85rem; + cursor: pointer; + font-family: var(--typography); + transition: all 0.1s; + + &:hover { + background-color: var(--bg4); + color: var(--text); + } + + .active { + background-color: var(--rhpz-orange); + border-color: var(--rhpz-orange); + color: var(--text3); + font-weight: 600; + } +} + +.settings-link { + display: flex; + align-items: center; + gap: 9px; + padding: 8px 10px; + color: var(--text); + text-decoration: none; + font-size: 0.88rem; + transition: background-color 0.1s; + border: 1px solid transparent; + + &:hover { + background-color: var(--bg3); + border-color: var(--border); + text-decoration: none; + } +} + +.settings-link--danger { + color: var(--error); + + &:hover { + background-color: rgba(229, 115, 115, 0.08); + border-color: rgba(229, 115, 115, 0.3); + } +} diff --git a/resources/css/layout/entry.css b/resources/css/layout/entry.css index 864002a..b6f7d86 100644 --- a/resources/css/layout/entry.css +++ b/resources/css/layout/entry.css @@ -1,4 +1,4 @@ -#entry-container { +#entry-container, #comments-section, #reviews-section { background-color: var(--bg2); border: 1px solid var(--border); display: flex; @@ -128,3 +128,112 @@ text-align: center; color: var( --text2 ); } + +.comment-block { + display: flex; + gap: 16px; + padding: 20px 0; + border-bottom: 1px solid var(--border); + + &:last-child { + border-bottom: none; + } + + .comment-avatar { + flex-shrink: 0; + width: 48px; + height: 48px; + border-radius: 50%; + overflow: hidden; + background-color: var(--bg4); + border: 1px solid var(--border); + + img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } + } + + .comment-content { + flex: 1; + min-width: 0; + + .comment-meta { + font-size: 0.88rem; + color: var(--text2); + margin-bottom: 6px; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + + .comment-author { + font-weight: 600; + color: var(--text); + text-decoration: none; + transition: color 0.2s; + + &:hover { + color: var(--rhpz-orange); + } + } + + .comment-date { + color: var(--text2); + } + + .comment-separator { + color: var(--border); + user-select: none; + } + } + + .comment-body { + font-size: 0.95rem; + color: var(--text); + line-height: 1.5; + word-wrap: break-word; + + p { + margin-bottom: 10px; + &:last-child { margin-bottom: 0; } + } + + a { + color: var(--rhpz-orange); + &:hover { + color: var(--rhpz-orange-hover); + text-decoration: underline; + } + } + + blockquote, .bbCodeBlock-blockquote { + background-color: var(--bg); + border-left: 3px solid var(--info); + padding: 12px 16px; + margin: 12px 0; + font-style: italic; + color: var(--text2); + } + + code { + font-family: monospace; + background-color: var(--bg3); + border: 1px solid var(--border); + padding: 2px 5px; + font-size: 0.9rem; + } + } + } +} + +.comments-empty { + text-align: center; + padding: 40px 20px; + color: var(--text2); + font-style: italic; + background-color: var(--bg); + border: 1px dashed var(--border); +} diff --git a/resources/js/SubmissionsClass/GalleryManager.js b/resources/js/SubmissionsClass/GalleryManager.js index 604215d..1f680c2 100644 --- a/resources/js/SubmissionsClass/GalleryManager.js +++ b/resources/js/SubmissionsClass/GalleryManager.js @@ -72,8 +72,9 @@ export function GalleryManager() { break; const IMG = GalleryImage(); - IMG.getOldImage( PATH ); + IMG.serverFilePath = PATH; this.images.push(IMG); + this.images[this.images.length - 1].getOldImage( PATH ); } }, diff --git a/resources/js/SubmissionsClass/MainImageManager.js b/resources/js/SubmissionsClass/MainImageManager.js index 408e93e..43684c4 100644 --- a/resources/js/SubmissionsClass/MainImageManager.js +++ b/resources/js/SubmissionsClass/MainImageManager.js @@ -2,6 +2,12 @@ export function MainImageManager() { return { + /** + * Used for gallery managament and indexation. + * @type {string} + */ + key: crypto.randomUUID(), + /** * If an image has been uploaded or not. * @type {boolean} diff --git a/resources/js/app.js b/resources/js/app.js index cd97947..860af44 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -6,7 +6,16 @@ import { calculate as calculateHashes } from "./hashes.js"; import hovercard from "./hovercard.js"; import notifications from "./notifications.js"; import conversations from "./conversations.js"; +import settings from "./settings.js"; +/** + * Get config defined in meta.blade.php + * @param {string} key + * @return {string|null} + */ +window.getConfig = function( key ){ + return document.querySelector('meta[name="config-' + key + '"]').getAttribute('content') ?? null; +} // Lucide icons. createIcons({ icons }); @@ -31,3 +40,6 @@ Alpine.store('notifications', notifications() ); // Conversations Alpine.store('conversations', conversations() ); + +// Settings +Alpine.store('settings', settings() ); diff --git a/resources/js/settings.js b/resources/js/settings.js new file mode 100644 index 0000000..51194fb --- /dev/null +++ b/resources/js/settings.js @@ -0,0 +1,83 @@ +import Cookies from 'js-cookie'; + +export default function settings() { + return { + + /** + * @type {boolean} + */ + start: false, + + /** + * Two keys, default and alternate. + * @type {Object} + */ + xfUrls: {}, + + /** + * @type {number[]} + */ + entriesPerPage: [ 12, 30, 48 ], + + /** + * @type {string} + */ + currentTheme: Cookies.get("theme") ?? 'default', + + /** + * @type {number} + */ + currentEntriesPerPage: Cookies.get("entries_per_page") ?? 30, + + /** + * + * @param {string} newTheme default|alternate + */ + themeChanged( newTheme ){ + if( newTheme !== "default" && newTheme !== "alternate" ) + return; + + if( newTheme === this.currentTheme ) + return; + + this.currentTheme = newTheme; + document.documentElement.classList.toggle('light-mode', this.currentTheme === 'alternate'); + + Cookies.set('theme', this.currentTheme, { expires: 365, path: '/', domain: window.getConfig('session-domain') } ); + this.syncXF(); + }, + + /** + * + * @return {Promise} + */ + async syncXF(){ + await fetch(this.xfUrls[this.currentTheme ?? 'default'], { method: "GET", credentials: "include", mode: "no-cors" }); + }, + + /** + * + */ + toggleTheme(){ + this.themeChanged(this.currentTheme === 'default' ? 'alternate' : 'default'); + }, + + /** + * + * @param n + */ + entriesPerPageChanged( n ){ + if( !this.entriesPerPage.includes(n) ) + return; + + this.entriesPerPage = n; + Cookies.set('entries_per_page', this.entriesPerPage, { expires: 365, path: '/', domain: window.getConfig('session-domain') } ); + if( window.Livewire ){ + Livewire.dispatch('entriesPerPageChanged', {n}); + } + }, + + open(){ this.start = !this.start; }, + close(){ this.start = false; }, + } +} diff --git a/resources/views/components/entry-card.blade.php b/resources/views/components/entry-card.blade.php index 18cc709..f1d991d 100644 --- a/resources/views/components/entry-card.blade.php +++ b/resources/views/components/entry-card.blade.php @@ -8,7 +8,7 @@ @endif
-
{{ $entry->title }}
+ {{ $entry->title }}
@forelse( $entry->authors as $author) @if($loop->first)By @endif @@ -24,13 +24,13 @@ @foreach( $entry->modifications as $modif ) {{ $modif->name }} @endforeach - @if( $entry->status_id ) - {{ $entry->status->name }} - @endif - @foreach( $entry->languages as $lang ) - {{ $lang->name }} - @endforeach @endif + @if( $entry->status_id ) + {{ $entry->status->name }} + @endif + @foreach( $entry->languages as $lang ) + {{ $lang->name }} + @endforeach
x diff --git a/resources/views/components/gallery-field.blade.php b/resources/views/components/gallery-field.blade.php index 9dac806..bc8e932 100644 --- a/resources/views/components/gallery-field.blade.php +++ b/resources/views/components/gallery-field.blade.php @@ -14,7 +14,7 @@